zames_pro 2.72.8 → 2.73.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,1668 @@
1
+ // Qwen Chat (chat.qwen.ai) provider — the second chat backend zames can
2
+ // drive, so the operator can switch models without switching tools.
3
+ //
4
+ // WHY a separate class instead of a flag in browser.ts: DeepSeek and Qwen
5
+ // differ in EVERY layer we touch — selectors, the send flow, the SSE format
6
+ // and the chat-id scheme. DeepSeekBrowser is already large; folding a second
7
+ // site into it would make both harder to audit. The shared surface is the
8
+ // ChatProvider interface (types.ts); the agent loop only needs BrowserLike and
9
+ // never sees either concrete class.
10
+ //
11
+ // Verified against a live session (headless Chromium, guest + logged in):
12
+ // input -> textarea.message-input-textarea
13
+ // send -> button.send-button
14
+ // send -> POST /api/v2/chats/new, then POST /api/v2/chat/completions?chat_id=…
15
+ // answer -> SSE choices[].delta.content where phase != thinking_summary
16
+ // The site sits behind Alibaba's WAF, which is exactly why we drive a REAL
17
+ // browser: the page solves its own JS challenge and carries the tokens.
18
+ import fs from 'fs/promises';
19
+ import path from 'path';
20
+ import os from 'os';
21
+ import { chromium } from 'playwright';
22
+ import { translate, DEFAULT_LOCALE } from './i18n.js';
23
+ import { rewriteFailedAttachments } from './commands.js';
24
+ import { theme } from './theme.js';
25
+ import { scrapeQwenChatList, scrapeQwenChatMessages, parseQwenChatList, parseQwenHistory, } from './qwen-chats.js';
26
+ import { cleanAnswer, headlessEngineKey, readCachedHeadlessUA, writeCachedHeadlessUA, sanitizeHeadlessUA, cleanSingletonFiles, profileLooksLocked, nukeProfile, AUTH_MARKER_FILE, } from './browser-pure.js';
27
+ import { chatSignalInDom, dumpDomCountsInDom, isElementEnabledInDom, readInputTrimmedTextInDom, readLastAnswerInDom, readPageToastsInDom, readUserAgentInDom, setBusyTitleInDom, clickStopInDom, stopButtonVisibleInDom, } from './browser-dom.js';
28
+ import { probeUiHealth, } from './ui-health.js';
29
+ import { CHAT_URL, CHAT_ID_RE, chatUrl, INPUT_SELECTORS, SEND_SELECTORS, STOP_SELECTORS, STOP_NAME_RE, NEW_CHAT_SELECTOR, MODEL_TRIGGER_SELECTOR, MODELS_URL, CHAT_LIST_URL, chatHistoryUrl, AGE_GATE_MODAL, AGE_GATE_CONTINUE, COOKIE_ACCEPT_RE, ATTACH_SELECTORS, ATTACH_MENU_TRIGGER, ATTACH_MENU_ITEM_RE, THINKING_SELECTOR, THINKING_MODE_RE, FAST_MODE_RE, WEB_SEARCH_MENU_RE, ANSWER_SELECTORS, THINK_CLASS_RE, MESSAGE_CONTAINER_SELECTORS, ASSISTANT_CLASS_RE, CHAT_SIGNAL_NODES_SELECTOR, MARKDOWN_SELECTOR, TOGGLE_SELECTORS, } from './qwen-ui.js';
30
+ import { extractAnswer, extractTokenUsage, isFinishedWithoutAnswer, isRateLimitText, isGuestLimitText, } from './qwen-net-capture.js';
31
+ // A SEPARATE profile from DeepSeek's ~/.zames/profile. Two persistent Chromium
32
+ // contexts on ONE --user-data-dir cannot coexist (SingletonLock), and keeping
33
+ // Qwen's cookies apart also means switching providers never logs the other
34
+ // one out.
35
+ export const QWEN_USER_DATA_DIR = path.join(os.homedir(), '.zames', 'qwen-profile');
36
+ // Escape a string for use inside a RegExp (the model id may carry dots).
37
+ function escapeRegExp(s) {
38
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
39
+ }
40
+ const CHAT_RE = /chat\.qwen\.ai/i;
41
+ const ANSWER_URL_RE = /\/api\/v2\/chat\/completions/i;
42
+ const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
43
+ export class QwenBrowser {
44
+ providerName = 'qwen';
45
+ headless;
46
+ debug;
47
+ channel;
48
+ answerTimeoutMs;
49
+ askRetries;
50
+ stabilityChecks;
51
+ stabilityDelayMs;
52
+ minSendIntervalMs;
53
+ rateLimitWaitMs;
54
+ maxRateLimitRetries;
55
+ maxServerBusyRetries;
56
+ serverBusyWaitMs;
57
+ maxIncompleteRetries;
58
+ incompleteWaitMs;
59
+ // The Qwen model id sent with each completion request. Empty means "let the
60
+ // site pick" (its own default). Read from config, overridable with /model.
61
+ model;
62
+ deepThinking;
63
+ webSearch;
64
+ auth;
65
+ locale;
66
+ onAuthSave;
67
+ // Watchdog budget the agent loop reads (same contract as DeepSeekBrowser).
68
+ maxAfterToolRetries;
69
+ askDeadlineMs;
70
+ _lastSentAt;
71
+ _abort;
72
+ _askCancelled;
73
+ _askInFlight;
74
+ _stopped;
75
+ context;
76
+ page;
77
+ _netCapture;
78
+ _netCaptureAt;
79
+ _netNoAnswer;
80
+ _netRateLimited;
81
+ // True when the guest daily limit was hit ("You've reached the guest chat
82
+ // limit for today's usage"). Distinct from _netRateLimited: it cannot be
83
+ // waited out, so ask() fails fast with a "log in" message.
84
+ _netGuestLimit;
85
+ _netChatId;
86
+ _lastTokenUsage;
87
+ _toggles;
88
+ // Last thinking mode / web-search state we actually APPLIED to the page. The
89
+ // mode menu must NOT be opened on every send (it is slow and can steal focus
90
+ // from the send button), so we only drive the UI when the DESIRED state
91
+ // differs from what we last set. -1 = not applied yet this session.
92
+ _appliedThinking;
93
+ _appliedWebSearch;
94
+ _apiAuth;
95
+ _lastHistoryError;
96
+ _netBody;
97
+ _netHookInstalled;
98
+ _netHookPage;
99
+ onSendStart;
100
+ onSendPause;
101
+ onSendState;
102
+ onNotice;
103
+ userAgent;
104
+ constructor({ headless = false, debug = false, channel = null, answerTimeoutMs = 180000, askRetries = 3, stabilityChecks = 2, stabilityDelayMs = 400, minSendIntervalMs = 15000, rateLimitWaitMs = 300000, maxRateLimitRetries = 6, maxServerBusyRetries = 5, serverBusyWaitMs = 3000, maxIncompleteRetries = 4, incompleteWaitMs = 2000, model = '', deepThinking = false, webSearch = true, auth, locale, onAuthSave, userAgent, } = {}) {
105
+ this.headless = headless;
106
+ this.debug = debug;
107
+ this.channel = channel;
108
+ this.answerTimeoutMs = answerTimeoutMs;
109
+ this.askRetries = askRetries;
110
+ this.stabilityChecks = stabilityChecks;
111
+ this.stabilityDelayMs = stabilityDelayMs;
112
+ this.minSendIntervalMs = minSendIntervalMs;
113
+ this.rateLimitWaitMs = rateLimitWaitMs;
114
+ this.maxRateLimitRetries = maxRateLimitRetries;
115
+ this.maxServerBusyRetries = maxServerBusyRetries;
116
+ this.serverBusyWaitMs = serverBusyWaitMs;
117
+ this.maxIncompleteRetries = maxIncompleteRetries;
118
+ this.incompleteWaitMs = incompleteWaitMs;
119
+ this.model = model;
120
+ this.deepThinking = deepThinking;
121
+ this.webSearch = webSearch;
122
+ this.auth = {
123
+ username: (auth?.username || '').trim(),
124
+ password: auth?.password || '',
125
+ saveSession: auth?.saveSession !== false,
126
+ };
127
+ this.locale = locale || DEFAULT_LOCALE;
128
+ this.onAuthSave = onAuthSave || null;
129
+ this.maxAfterToolRetries = 6;
130
+ this.askDeadlineMs = answerTimeoutMs + 60000;
131
+ this._lastSentAt = 0;
132
+ this._abort = false;
133
+ this._askCancelled = false;
134
+ this._askInFlight = 0;
135
+ this._stopped = false;
136
+ this._netCapture = '';
137
+ this._netCaptureAt = 0;
138
+ this._netNoAnswer = false;
139
+ this._netRateLimited = false;
140
+ this._netGuestLimit = false;
141
+ this._netChatId = null;
142
+ this._lastTokenUsage = null;
143
+ this._toggles = { deepThinking, webSearch };
144
+ this._appliedThinking = -1;
145
+ this._appliedWebSearch = -1;
146
+ this._apiAuth = '';
147
+ this._lastHistoryError = '';
148
+ this._netBody = '';
149
+ this._netHookInstalled = false;
150
+ this._netHookPage = null;
151
+ this.onSendStart = null;
152
+ this.onSendPause = null;
153
+ this.onSendState = null;
154
+ this.onNotice = null;
155
+ this.userAgent = userAgent || '';
156
+ }
157
+ _t(key, params) {
158
+ return translate(this.locale)(key, { provider: 'Qwen', ...params });
159
+ }
160
+ setLocale(locale) {
161
+ if (locale)
162
+ this.locale = locale;
163
+ }
164
+ _notice(text) {
165
+ if (this.onNotice) {
166
+ try {
167
+ this.onNotice(text);
168
+ return;
169
+ }
170
+ catch { }
171
+ }
172
+ console.error(text);
173
+ }
174
+ _emitSendState(state) {
175
+ if (this.onSendState) {
176
+ try {
177
+ this.onSendState(state);
178
+ }
179
+ catch { }
180
+ }
181
+ }
182
+ async _setBusyTitle(busy) {
183
+ try {
184
+ await this.page.evaluate(setBusyTitleInDom, busy);
185
+ }
186
+ catch { }
187
+ }
188
+ sendIntervalMs() {
189
+ return this.minSendIntervalMs;
190
+ }
191
+ // ---------- lifecycle ----------
192
+ async launch() {
193
+ if (await profileLooksLocked(QWEN_USER_DATA_DIR)) {
194
+ if (this.debug)
195
+ console.error('qwen profile: removing Singleton files');
196
+ await cleanSingletonFiles(QWEN_USER_DATA_DIR);
197
+ }
198
+ if (this.headless && !this.userAgent) {
199
+ const cached = await readCachedHeadlessUA(headlessEngineKey() + ':qwen');
200
+ if (cached)
201
+ this.userAgent = cached;
202
+ }
203
+ await this._launchOnce();
204
+ await this._fixHeadlessUserAgent();
205
+ }
206
+ // A headless Chromium sends "HeadlessChrome/…", and Alibaba's WAF (like
207
+ // DeepSeek's CDN) is far more suspicious of it than of a normal Chrome UA.
208
+ // Strip only the marker; an explicit UA from the config always wins.
209
+ async _fixHeadlessUserAgent() {
210
+ if (!this.headless)
211
+ return;
212
+ if (this.userAgent)
213
+ return;
214
+ try {
215
+ const ua = await this.page.evaluate(readUserAgentInDom);
216
+ const fixed = sanitizeHeadlessUA(ua);
217
+ if (fixed === ua)
218
+ return;
219
+ this.userAgent = fixed;
220
+ void writeCachedHeadlessUA(headlessEngineKey() + ':qwen', fixed);
221
+ if (this.debug)
222
+ console.error('qwen profile: headless UA -> relaunching plain Chrome UA');
223
+ await this.context.close().catch(() => { });
224
+ await this._launchOnce();
225
+ }
226
+ catch (e) {
227
+ if (this.debug)
228
+ console.error('qwen profile: UA fix failed:', e.message);
229
+ }
230
+ }
231
+ async _launchOnce() {
232
+ const options = {
233
+ headless: this.headless,
234
+ slowMo: 10,
235
+ args: ['--disable-blink-features=AutomationControlled'],
236
+ };
237
+ if (this.channel)
238
+ options.channel = this.channel;
239
+ if (this.userAgent)
240
+ options.userAgent = this.userAgent;
241
+ try {
242
+ this.context = await chromium.launchPersistentContext(QWEN_USER_DATA_DIR, options);
243
+ }
244
+ catch (e) {
245
+ if (/profile.*in use|has been closed/i.test(e.message)) {
246
+ await cleanSingletonFiles(QWEN_USER_DATA_DIR);
247
+ try {
248
+ this.context = await chromium.launchPersistentContext(QWEN_USER_DATA_DIR, options);
249
+ }
250
+ catch {
251
+ await nukeProfile(QWEN_USER_DATA_DIR);
252
+ this.context = await chromium.launchPersistentContext(QWEN_USER_DATA_DIR, options);
253
+ }
254
+ }
255
+ else {
256
+ throw e;
257
+ }
258
+ }
259
+ this.page = this.context.pages()[0] || (await this.context.newPage());
260
+ this._installNetHook();
261
+ await this.page.goto(CHAT_URL, { waitUntil: 'domcontentloaded' });
262
+ await this._dismissOverlays();
263
+ }
264
+ async restart() {
265
+ try {
266
+ if (this.context)
267
+ await this.context.close();
268
+ }
269
+ catch { }
270
+ await this._launchOnce();
271
+ await this._fixHeadlessUserAgent();
272
+ }
273
+ // The cookie banner and the age-confirmation modal sit OVER the input on a
274
+ // first visit; a click on the input would hit the overlay instead. Both are
275
+ // dismissed best-effort before any interaction.
276
+ async _dismissOverlays() {
277
+ try {
278
+ await this.page
279
+ .getByRole('button', { name: COOKIE_ACCEPT_RE })
280
+ .first()
281
+ .click({ timeout: 1500 })
282
+ .catch(() => { });
283
+ const gate = this.page.locator(AGE_GATE_MODAL);
284
+ if (await gate.count().catch(() => 0)) {
285
+ await this.page
286
+ .locator(AGE_GATE_CONTINUE)
287
+ .first()
288
+ .click({ timeout: 2000 })
289
+ .catch(() => { });
290
+ await this.page.waitForTimeout(400);
291
+ }
292
+ }
293
+ catch { }
294
+ }
295
+ async close() {
296
+ try {
297
+ if (this.context)
298
+ await this.context.close();
299
+ }
300
+ catch { }
301
+ }
302
+ _installNetHook() {
303
+ const pg = this.page;
304
+ if (!pg)
305
+ return;
306
+ if (this._netHookInstalled && this._netHookPage === pg)
307
+ return;
308
+ this._netHookInstalled = true;
309
+ this._netHookPage = pg;
310
+ pg.on('response', (resp) => {
311
+ void this._onResponse(resp).catch(() => { });
312
+ });
313
+ // Sniff the bearer token off Qwen's own API calls. A bare fetch from our
314
+ // code does not carry it (the app reads it from its own storage).
315
+ pg.on('request', (req) => {
316
+ try {
317
+ const url = req.url();
318
+ if (!/qwen\.ai\/api\//i.test(url))
319
+ return;
320
+ const h = req.headers();
321
+ if (h['authorization'])
322
+ this._apiAuth = h['authorization'];
323
+ }
324
+ catch { }
325
+ });
326
+ }
327
+ async _onResponse(resp) {
328
+ try {
329
+ const url = resp.url();
330
+ if (!CHAT_RE.test(url))
331
+ return;
332
+ if (/\.(js|css|png|jpg|jpeg|svg|woff2?|ico|map|webp)(\?|$)/i.test(url))
333
+ return;
334
+ const ct = (resp.headers()['content-type'] || '').toLowerCase();
335
+ const interesting = ct.includes('event-stream') ||
336
+ ct.includes('json') ||
337
+ ct.includes('text/plain');
338
+ if (!interesting)
339
+ return;
340
+ const body = await resp.text().catch(() => '');
341
+ if (!body)
342
+ return;
343
+ const usage = extractTokenUsage(body);
344
+ if (usage !== null)
345
+ this._lastTokenUsage = usage;
346
+ // The GUEST daily limit can arrive from ANY qwen API endpoint
347
+ // (getstsToken during an upload, or completions). Flag it regardless of
348
+ // the URL so ask() can fail fast with a "log in" message instead of
349
+ // retrying for `rateLimitWaitMs` (which cannot help — it resets
350
+ // tomorrow).
351
+ if (isGuestLimitText(body)) {
352
+ this._netGuestLimit = true;
353
+ this._netBody = body;
354
+ this._netCaptureAt = Date.now();
355
+ }
356
+ const isAnswerUrl = ANSWER_URL_RE.test(url);
357
+ if (!isAnswerUrl)
358
+ return;
359
+ const extracted = extractAnswer(body);
360
+ if (extracted) {
361
+ this._netCapture = extracted;
362
+ this._netNoAnswer = false;
363
+ this._netCaptureAt = Date.now();
364
+ }
365
+ if (this._askInFlight === 0) {
366
+ // A completion arrived with no ask of ours in flight: the operator
367
+ // sent it by hand. Bump the throttle so our next send waits.
368
+ this._lastSentAt = Date.now();
369
+ }
370
+ this._netBody = body;
371
+ const cid = body.match(UUID_RE);
372
+ if (cid)
373
+ this._netChatId = cid[0];
374
+ if (!extracted) {
375
+ if (isFinishedWithoutAnswer(body)) {
376
+ this._netNoAnswer = true;
377
+ this._netCapture = '';
378
+ }
379
+ if (isRateLimitText(body)) {
380
+ this._netRateLimited = true;
381
+ this._netCapture = '';
382
+ }
383
+ this._netCaptureAt = Date.now();
384
+ }
385
+ }
386
+ catch { }
387
+ }
388
+ // ---------- login ----------
389
+ // Are we signed in? Detected from the PAGE, the same way a human sees it: a
390
+ // signed-out visitor has the header "Log in" / "Sign up" buttons, a signed-in
391
+ // one shows the account menu instead. The `auths/refresh` API was the first
392
+ // choice, but it answers `Bad_Request: Invalid request header` without the
393
+ // app's own header, so it cannot be used from a bare fetch — the DOM check is
394
+ // reliable and needs no secret header.
395
+ //
396
+ async isLoggedIn() {
397
+ if (!this.page)
398
+ return false;
399
+ // POLL until the chat UI has HYDRATED and the header settles: the React app
400
+ // renders the guest CTA for a moment on a cold page and only then swaps in
401
+ // the account menu, so a single early probe reported "guest" on a genuinely
402
+ // signed-in session. A real guest keeps the CTA forever, so it correctly
403
+ // ends as false at the deadline; a signed-in session matches within a few
404
+ // seconds.
405
+ const deadline = Date.now() + 20_000;
406
+ while (Date.now() < deadline) {
407
+ try {
408
+ const input = await this.page
409
+ .locator(INPUT_SELECTORS.join(', '))
410
+ .count()
411
+ .catch(() => 0);
412
+ if (input > 0) {
413
+ const login = await this.page
414
+ .getByRole('button', { name: /^\s*log ?in\s*$/i })
415
+ .count()
416
+ .catch(() => 0);
417
+ const signup = await this.page
418
+ .getByRole('button', { name: /^\s*sign ?up\s*$/i })
419
+ .count()
420
+ .catch(() => 0);
421
+ if (login === 0 && signup === 0)
422
+ return true;
423
+ }
424
+ }
425
+ catch { }
426
+ await this.page.waitForTimeout(500);
427
+ }
428
+ return false;
429
+ }
430
+ async waitForLogin() {
431
+ if (await this.isLoggedIn()) {
432
+ if (this.debug)
433
+ console.error('qwen auth: session already active');
434
+ await this._persistSessionIfNeeded();
435
+ return;
436
+ }
437
+ // No stored session: Qwen still answers a GUEST for a limited number of
438
+ // turns, but the agent is multi-turn by nature, so a real login is
439
+ // strongly recommended. Try the saved credentials first, then ask the
440
+ // operator in a TTY.
441
+ if (this.auth.username) {
442
+ if (await this._autoLogin()) {
443
+ await this._persistSessionIfNeeded();
444
+ return;
445
+ }
446
+ }
447
+ if (process.stdin.isTTY) {
448
+ await this._promptAndLogin();
449
+ }
450
+ else {
451
+ this._notice(this._t('qwen.guest_mode'));
452
+ }
453
+ await this._persistSessionIfNeeded();
454
+ }
455
+ async _persistSessionIfNeeded() {
456
+ if (!this.auth.saveSession)
457
+ return;
458
+ if (!(await this.isLoggedIn()))
459
+ return;
460
+ try {
461
+ await fs.mkdir(path.dirname(AUTH_MARKER_FILE), { recursive: true });
462
+ await fs.writeFile(AUTH_MARKER_FILE, JSON.stringify({ provider: 'qwen', at: new Date().toISOString() }), 'utf-8');
463
+ }
464
+ catch { }
465
+ }
466
+ // Best-effort PASSWORD login for the saved credentials. Qwen's default form
467
+ // is the email CODE flow, so this first switches to the password form (the
468
+ // "Log in with a password" link) and only then fills email + password. A
469
+ // password-less flow cannot be automated (it needs the emailed code, which
470
+ // only the operator can read), so that path is handled interactively in
471
+ // _promptAndLogin().
472
+ // Password login (the verified flow): open the auth page, fill the email,
473
+ // switch to the password form via "Log in with a password", fill the
474
+ // password and submit. This is preferred over the email-CODE flow when a
475
+ // password is saved, because the code needs the operator to read the mail.
476
+ async _autoLogin() {
477
+ if (!this.auth.password || !this.auth.username)
478
+ return false;
479
+ try {
480
+ await this._openLoginPage();
481
+ // 1) email first (the form starts in the CODE mode)
482
+ const email = this.page
483
+ .locator('input[name="email"], input[type="email"]')
484
+ .first();
485
+ await email.waitFor({ state: 'visible', timeout: 8000 });
486
+ await email.fill(this.auth.username);
487
+ // 2) switch to the password form, then fill the password
488
+ await this._switchToPasswordForm();
489
+ const pwd = this.page.locator('input[type="password"]').first();
490
+ if ((await pwd.count().catch(() => 0)) === 0) {
491
+ // The password form did not appear: fall back to the code flow.
492
+ return false;
493
+ }
494
+ await pwd.fill(this.auth.password);
495
+ await this._clickSubmit();
496
+ const ok = await this._waitLoggedIn(20000);
497
+ if (ok)
498
+ this._notice(this._t('qwen.login_ok'));
499
+ return ok;
500
+ }
501
+ catch (e) {
502
+ if (this.debug)
503
+ console.error('qwen auto-login failed:', e.message);
504
+ return false;
505
+ }
506
+ }
507
+ // Click the login/submit button (label varies: Log in / Continue).
508
+ async _clickSubmit() {
509
+ const btn = this.page
510
+ .getByRole('button', { name: /^\s*(log ?in|sign ?in|continue)\s*$/i })
511
+ .first();
512
+ if (await btn.count().catch(() => 0)) {
513
+ await btn.click({ timeout: 4000 }).catch(() => { });
514
+ }
515
+ else {
516
+ await this.page.keyboard.press('Enter').catch(() => { });
517
+ }
518
+ await this.page.waitForTimeout(1500);
519
+ }
520
+ // Interactive login: email -> "Continue" -> Qwen mails a one-time code ->
521
+ // ask the operator for it in the terminal -> submit -> verify via the API.
522
+ // This is the DEFAULT Qwen flow (no password needed).
523
+ async _promptAndLogin() {
524
+ await this._openLoginPage();
525
+ const readline = await import('readline');
526
+ const rl = readline.createInterface({
527
+ input: process.stdin,
528
+ output: process.stdout,
529
+ });
530
+ const email = await new Promise((resolve) => rl.question(this._t('qwen.login_email_prompt', {
531
+ def: this.auth.username ? ' [' + this.auth.username + ']' : '',
532
+ }), resolve));
533
+ rl.close();
534
+ const addr = email.trim() || this.auth.username;
535
+ if (!addr) {
536
+ this._notice(this._t('qwen.login_failed'));
537
+ return false;
538
+ }
539
+ let ok;
540
+ try {
541
+ await this._fillEmailAndContinue(addr);
542
+ await this.page.waitForTimeout(1500);
543
+ const rlCode = readline.createInterface({
544
+ input: process.stdin,
545
+ output: process.stdout,
546
+ });
547
+ const code = await new Promise((resolve) => rlCode.question(this._t('qwen.login_code_prompt'), resolve));
548
+ rlCode.close();
549
+ await this._submitCode(code.trim());
550
+ ok = await this._waitLoggedIn(30000);
551
+ }
552
+ catch (e) {
553
+ if (this.debug)
554
+ console.error('qwen login failed:', e.message);
555
+ ok = false;
556
+ }
557
+ if (ok) {
558
+ this._notice(this._t('qwen.login_ok'));
559
+ if (this.onAuthSave) {
560
+ try {
561
+ this.onAuthSave(addr, '');
562
+ }
563
+ catch { }
564
+ }
565
+ }
566
+ else {
567
+ this._notice(this._t('qwen.login_failed'));
568
+ }
569
+ return ok;
570
+ }
571
+ // Open the login form. Qwen's sign-in lives on the SAME origin
572
+ // (chat.qwen.ai/auth); a direct navigation is the reliable path (the header
573
+ // "Log in" button opens the same page).
574
+ async _openLoginPage() {
575
+ try {
576
+ if (!/\/auth(\b|$|\?)/i.test(this.page.url())) {
577
+ const btn = this.page
578
+ .getByRole('button', { name: /log ?in|sign ?in/i })
579
+ .first();
580
+ if (await btn.count().catch(() => 0)) {
581
+ await btn.click({ timeout: 3000 }).catch(() => { });
582
+ await this.page.waitForTimeout(2000);
583
+ }
584
+ }
585
+ if (!/chat\.qwen\.ai\/auth/i.test(this.page.url())) {
586
+ await this.page
587
+ .goto('https://chat.qwen.ai/auth', {
588
+ waitUntil: 'domcontentloaded',
589
+ })
590
+ .catch(() => { });
591
+ await this.page.waitForTimeout(2500);
592
+ }
593
+ }
594
+ catch { }
595
+ }
596
+ async _fillEmailAndContinue(email) {
597
+ const input = this.page
598
+ .locator('input[name="email"], input[type="email"]')
599
+ .first();
600
+ await input.waitFor({ state: 'visible', timeout: 8000 });
601
+ await input.fill(email);
602
+ await this._clickContinue();
603
+ }
604
+ async _clickContinue() {
605
+ const btn = this.page
606
+ .getByRole('button', { name: /^\s*continue\s*$/i })
607
+ .first();
608
+ if (await btn.count().catch(() => 0)) {
609
+ await btn.click({ timeout: 4000 }).catch(() => { });
610
+ }
611
+ else {
612
+ await this.page.keyboard.press('Enter');
613
+ }
614
+ await this.page.waitForTimeout(2000);
615
+ }
616
+ // Switch from the default code form to the password form (the
617
+ // "Log in with a password" link). Best-effort: a build without the link
618
+ // simply keeps the code form, and _autoLogin() bails out.
619
+ async _switchToPasswordForm() {
620
+ try {
621
+ const link = this.page.getByText(/log in with a password/i).first();
622
+ if (await link.count().catch(() => 0)) {
623
+ await link.click({ timeout: 3000 }).catch(() => { });
624
+ await this.page.waitForTimeout(1500);
625
+ }
626
+ }
627
+ catch { }
628
+ }
629
+ // Submit the one-time code. Qwen renders EITHER six single-character boxes
630
+ // OR one code input; handle both. Best-effort (the exact markup may change).
631
+ async _submitCode(code) {
632
+ try {
633
+ const digits = code.replace(/[^0-9a-zA-Z]/g, '');
634
+ const sel = 'input[autocomplete="one-time-code"], input[inputmode="numeric"], input[maxlength="1"], input[type="tel"], input[type="text"]';
635
+ const inputs = this.page.locator(sel);
636
+ const n = await inputs.count().catch(() => 0);
637
+ if (n >= digits.length && n > 1) {
638
+ for (let i = 0; i < digits.length; i++) {
639
+ await inputs
640
+ .nth(i)
641
+ .fill(digits[i])
642
+ .catch(() => { });
643
+ }
644
+ }
645
+ else {
646
+ await inputs
647
+ .first()
648
+ .fill(digits)
649
+ .catch(() => { });
650
+ }
651
+ await this.page.keyboard.press('Enter');
652
+ const submit = this.page
653
+ .getByRole('button', {
654
+ name: /^\s*(continue|verify|submit|log ?in|sign ?in)\s*$/i,
655
+ })
656
+ .first();
657
+ if (await submit.count().catch(() => 0)) {
658
+ await submit.click({ timeout: 3000 }).catch(() => { });
659
+ }
660
+ }
661
+ catch { }
662
+ }
663
+ async _waitLoggedIn(timeoutMs) {
664
+ const deadline = Date.now() + timeoutMs;
665
+ while (Date.now() < deadline) {
666
+ if (await this.isLoggedIn())
667
+ return true;
668
+ await this.page.waitForTimeout(800);
669
+ }
670
+ return false;
671
+ }
672
+ // ---------- chat management ----------
673
+ async newChat() {
674
+ const before = await this.getCurrentChatId();
675
+ const btn = this.page.locator(NEW_CHAT_SELECTOR).first();
676
+ let clicked = false;
677
+ try {
678
+ await btn.click({ timeout: 2500 });
679
+ clicked = true;
680
+ }
681
+ catch { }
682
+ let ok = false;
683
+ if (clicked) {
684
+ const deadline = Date.now() + 5000;
685
+ while (Date.now() < deadline) {
686
+ const now = await this.getCurrentChatId();
687
+ if (now !== before) {
688
+ ok = true;
689
+ break;
690
+ }
691
+ await this.page.waitForTimeout(100);
692
+ }
693
+ }
694
+ if (!ok) {
695
+ await this.page.goto(CHAT_URL, { waitUntil: 'domcontentloaded' });
696
+ await this._awaitInput(5000);
697
+ }
698
+ this._netChatId = null;
699
+ this._lastTokenUsage = null;
700
+ }
701
+ async _awaitInput(timeoutMs = 5000) {
702
+ const deadline = Date.now() + timeoutMs;
703
+ while (Date.now() < deadline) {
704
+ const input = await this._findVisible(INPUT_SELECTORS, 500);
705
+ if (input)
706
+ return true;
707
+ await this.page.waitForTimeout(150);
708
+ }
709
+ return false;
710
+ }
711
+ async _findVisible(selectors, timeout = 1000) {
712
+ for (const sel of selectors) {
713
+ try {
714
+ const loc = this.page.locator(sel).last();
715
+ if ((await loc.count()) === 0)
716
+ continue;
717
+ if (await loc.isVisible({ timeout }).catch(() => false))
718
+ return loc;
719
+ }
720
+ catch { }
721
+ }
722
+ return null;
723
+ }
724
+ async listChats(limit = 30) {
725
+ if (!this.page)
726
+ return [];
727
+ // Primary: the API the app itself uses (verified on a signed-in session) —
728
+ // it returns the whole list, while the sidebar only renders a window.
729
+ try {
730
+ const res = await this.page.evaluate(async (opts) => {
731
+ try {
732
+ const headers = {
733
+ accept: 'application/json',
734
+ };
735
+ if (opts.auth)
736
+ headers['authorization'] = opts.auth;
737
+ const r = await fetch(opts.url, {
738
+ credentials: 'include',
739
+ headers,
740
+ });
741
+ if (!r.ok)
742
+ return { error: 'HTTP ' + r.status, data: null };
743
+ return { error: '', data: await r.json() };
744
+ }
745
+ catch (e) {
746
+ return { error: e.message, data: null };
747
+ }
748
+ }, { url: CHAT_LIST_URL, auth: this._apiAuth });
749
+ if (!res?.error) {
750
+ const list = parseQwenChatList(res.data).slice(0, limit);
751
+ if (list.length)
752
+ return list;
753
+ }
754
+ else {
755
+ this._lastHistoryError = res.error;
756
+ }
757
+ }
758
+ catch { }
759
+ // Fallback: scrape the sidebar (works for a guest with a rendered list).
760
+ await this._ensureSidebarOpen();
761
+ try {
762
+ return await this.page.evaluate(scrapeQwenChatList, limit);
763
+ }
764
+ catch {
765
+ return [];
766
+ }
767
+ }
768
+ // Best-effort: click a sidebar/\"all chats\" toggle so the chat list is
769
+ // rendered before scraping. Matches a couple of likely accessible names.
770
+ async _ensureSidebarOpen() {
771
+ for (const name of [/all chats/i, /history/i, /sidebar/i, /чаты/i]) {
772
+ try {
773
+ const btn = this.page.getByRole('button', { name }).first();
774
+ if ((await btn.count().catch(() => 0)) === 0)
775
+ continue;
776
+ if (!(await btn.isVisible().catch(() => false)))
777
+ continue;
778
+ await btn.click({ timeout: 1500 }).catch(() => { });
779
+ await this.page.waitForTimeout(500);
780
+ return;
781
+ }
782
+ catch { }
783
+ }
784
+ }
785
+ async openChat(id) {
786
+ try {
787
+ await this.page.goto(chatUrl(id), {
788
+ waitUntil: 'domcontentloaded',
789
+ timeout: 20000,
790
+ });
791
+ const deadline = Date.now() + 8000;
792
+ while (Date.now() < deadline) {
793
+ if ((await this.getCurrentChatId()) === id) {
794
+ await this._awaitInput(2000);
795
+ return true;
796
+ }
797
+ await this.page.waitForTimeout(100);
798
+ }
799
+ return false;
800
+ }
801
+ catch {
802
+ return false;
803
+ }
804
+ }
805
+ // Qwen does not document a stable history endpoint we can rely on, so this
806
+ // is best-effort: try the chats API, then fall back to DOM scraping in
807
+ // readChatMessages(). A failure never breaks the restore.
808
+ // The model catalogue (GET /api/v2/models/) so /model can list and pick a
809
+ // model without leaving the terminal. Requested from INSIDE the page so the
810
+ // WAF cookies/bearer token are the browser's.
811
+ async listModels() {
812
+ if (!this.page)
813
+ return [];
814
+ try {
815
+ const res = await this.page.evaluate(async (opts) => {
816
+ try {
817
+ const headers = {
818
+ accept: 'application/json',
819
+ };
820
+ if (opts.auth)
821
+ headers['authorization'] = opts.auth;
822
+ const r = await fetch(opts.url, { credentials: 'include', headers });
823
+ if (!r.ok)
824
+ return { error: 'HTTP ' + r.status, data: null };
825
+ return { error: '', data: await r.json() };
826
+ }
827
+ catch (e) {
828
+ return { error: e.message, data: null };
829
+ }
830
+ }, { url: MODELS_URL, auth: this._apiAuth });
831
+ if (res?.error) {
832
+ this._lastHistoryError = res.error;
833
+ return [];
834
+ }
835
+ const data = res?.data || {};
836
+ // The catalogue is nested: { success, data: { data: [ {id, name, …} ] } }.
837
+ // Accept a flat { data: [...] } too, since the shape has changed before.
838
+ const lvl1 = data?.data;
839
+ const list = Array.isArray(lvl1)
840
+ ? lvl1
841
+ : lvl1 &&
842
+ typeof lvl1 === 'object' &&
843
+ Array.isArray(lvl1.data)
844
+ ? lvl1.data
845
+ : [];
846
+ const out = [];
847
+ for (const m of list) {
848
+ if (!m || typeof m !== 'object')
849
+ continue;
850
+ const mm = m;
851
+ const id = typeof mm.id === 'string' ? mm.id : '';
852
+ const info = mm.info;
853
+ const meta = info?.meta;
854
+ const name = (typeof meta?.name === 'string' && meta.name) ||
855
+ (typeof mm.name === 'string' && mm.name) ||
856
+ id;
857
+ if (id)
858
+ out.push({ id, name });
859
+ }
860
+ return out;
861
+ }
862
+ catch (e) {
863
+ this._lastHistoryError = e.message;
864
+ return [];
865
+ }
866
+ }
867
+ // Fetch one chat's history via the app's OWN endpoint
868
+ // `GET /api/v2/chats/<id>?direction=up&limit=N` (verified live on a
869
+ // signed-in session). Parsed by the PURE parseQwenHistory() so the shape
870
+ // handling is unit-tested and can drift without touching this method.
871
+ async fetchChatMessages(id) {
872
+ this._lastHistoryError = '';
873
+ if (!this.page || !id)
874
+ return [];
875
+ try {
876
+ const res = await this.page.evaluate(async (opts) => {
877
+ try {
878
+ const headers = {
879
+ accept: 'application/json',
880
+ };
881
+ if (opts.auth)
882
+ headers['authorization'] = opts.auth;
883
+ const r = await fetch(opts.url, {
884
+ credentials: 'include',
885
+ headers,
886
+ });
887
+ if (!r.ok)
888
+ return { error: 'HTTP ' + r.status, data: null };
889
+ return { error: '', data: await r.json() };
890
+ }
891
+ catch (e) {
892
+ return { error: e.message, data: null };
893
+ }
894
+ }, { url: chatHistoryUrl(id, 50), auth: this._apiAuth });
895
+ if (res?.error) {
896
+ this._lastHistoryError = res.error;
897
+ return [];
898
+ }
899
+ return parseQwenHistory(res.data);
900
+ }
901
+ catch (e) {
902
+ this._lastHistoryError = e.message;
903
+ return [];
904
+ }
905
+ }
906
+ async readChatMessages() {
907
+ if (!this.page)
908
+ return [];
909
+ try {
910
+ return await this.page.evaluate(scrapeQwenChatMessages, {
911
+ containerSels: MESSAGE_CONTAINER_SELECTORS,
912
+ thinkRe: THINK_CLASS_RE.source,
913
+ assistantRe: ASSISTANT_CLASS_RE.source,
914
+ });
915
+ }
916
+ catch {
917
+ return [];
918
+ }
919
+ }
920
+ async getCurrentChatId() {
921
+ try {
922
+ const m = this.page.url().match(CHAT_ID_RE);
923
+ if (m && m[1] !== 'guest')
924
+ return m[1];
925
+ return this._netChatId;
926
+ }
927
+ catch {
928
+ return this._netChatId;
929
+ }
930
+ }
931
+ getLastTokenUsage() {
932
+ return this._lastTokenUsage;
933
+ }
934
+ // ---------- answer reading ----------
935
+ async _readLastAnswerText() {
936
+ if (this._netCapture && this._netCaptureAt >= this._lastSentAt) {
937
+ return this._netCapture;
938
+ }
939
+ return await this._readLastAnswerTextDom();
940
+ }
941
+ async _readLastAnswerTextDom() {
942
+ return await this.page.evaluate(readLastAnswerInDom, {
943
+ sels: ANSWER_SELECTORS,
944
+ thinkRe: THINK_CLASS_RE.source,
945
+ });
946
+ }
947
+ async _readPageText() {
948
+ return await this.page.evaluate(readPageToastsInDom).catch(() => '');
949
+ }
950
+ async _readLastAnswerTextCleanDom() {
951
+ const raw = await this._readLastAnswerTextDom().catch(() => '');
952
+ return this._cleanAnswer(raw);
953
+ }
954
+ _cleanAnswer(raw) {
955
+ return cleanAnswer(raw);
956
+ }
957
+ async _stopButtonVisible() {
958
+ const explicit = await this._findVisible(STOP_SELECTORS, 250);
959
+ if (explicit)
960
+ return true;
961
+ return await this.page
962
+ .evaluate(stopButtonVisibleInDom, 'send-button|stop-button')
963
+ .catch(() => false);
964
+ }
965
+ async _chatSignal() {
966
+ return await this.page
967
+ .evaluate(chatSignalInDom, {
968
+ nodesSel: CHAT_SIGNAL_NODES_SELECTOR,
969
+ mdSel: MARKDOWN_SELECTOR,
970
+ })
971
+ .catch(() => ({ nodes: 0, lastLen: 0 }));
972
+ }
973
+ async stopGeneration() {
974
+ this._abort = true;
975
+ this._stopped = true;
976
+ try {
977
+ const loc = this.page.getByRole('button', { name: STOP_NAME_RE });
978
+ const n = await loc.count().catch(() => 0);
979
+ for (let i = 0; i < n; i++) {
980
+ const b = loc.nth(i);
981
+ if (await b.isVisible().catch(() => false)) {
982
+ await b.click({ timeout: 2000 });
983
+ return true;
984
+ }
985
+ }
986
+ }
987
+ catch { }
988
+ const btn = await this._findVisible(STOP_SELECTORS, 500);
989
+ if (btn) {
990
+ try {
991
+ await btn.click({ timeout: 1500 });
992
+ return true;
993
+ }
994
+ catch { }
995
+ }
996
+ const clicked = await this.page
997
+ .evaluate(clickStopInDom, 'send-button|stop-button')
998
+ .catch(() => false);
999
+ if (clicked)
1000
+ return true;
1001
+ try {
1002
+ await this.page.keyboard.press('Escape');
1003
+ return true;
1004
+ }
1005
+ catch { }
1006
+ return false;
1007
+ }
1008
+ // ---------- toggles (model thinking / search) ----------
1009
+ getToggleStatesSync() {
1010
+ return this._toggles;
1011
+ }
1012
+ setToggleState(s) {
1013
+ if (typeof s.deepThinking === 'boolean')
1014
+ this._toggles.deepThinking = s.deepThinking;
1015
+ if (typeof s.webSearch === 'boolean')
1016
+ this._toggles.webSearch = s.webSearch;
1017
+ }
1018
+ async getToggleStates() {
1019
+ return {
1020
+ deepThinking: this.deepThinking,
1021
+ webSearch: this.webSearch,
1022
+ };
1023
+ }
1024
+ // Qwen's thinking is a MODE selector (Auto / Thinking / Fast), and web search
1025
+ // is an item in the "+" (mode) menu — unlike DeepSeek's two boolean toggles.
1026
+ // Map the config onto Qwen's UI: deepThinking ON -> "Thinking", OFF -> "Auto"
1027
+ // (the smart default); webSearch ON -> enable the "Web search" menu item.
1028
+ // Everything is best-effort: a missing control never fails the send.
1029
+ async _applyToggles() {
1030
+ this._toggles = {
1031
+ deepThinking: this.deepThinking,
1032
+ webSearch: this.webSearch,
1033
+ };
1034
+ await this._applyModel();
1035
+ await this._applyThinkingMode();
1036
+ await this._applyWebSearch();
1037
+ }
1038
+ // Pick Auto/Thinking/Fast in the `.qwen-thinking-selector` dropdown. The
1039
+ // option labels are localized, so they are matched by a loose regex.
1040
+ async _applyThinkingMode() {
1041
+ try {
1042
+ const trigger = this.page.locator(THINKING_SELECTOR).first();
1043
+ if ((await trigger.count().catch(() => 0)) === 0)
1044
+ return;
1045
+ const wantRe = this.deepThinking ? THINKING_MODE_RE : FAST_MODE_RE;
1046
+ const cur = ((await trigger.innerText().catch(() => '')) || '').trim();
1047
+ // Already in the wanted mode? (Auto counts as "not thinking", so it
1048
+ // satisfies deepThinking=false; only a real switch needs a click.)
1049
+ const curIsThinking = THINKING_MODE_RE.test(cur);
1050
+ if (curIsThinking === this.deepThinking) {
1051
+ this._appliedThinking = this.deepThinking ? 1 : 0;
1052
+ return;
1053
+ }
1054
+ await trigger.click({ timeout: 2000 }).catch(() => { });
1055
+ await this.page.waitForTimeout(300);
1056
+ const ok = await this._clickMenuOption(wantRe);
1057
+ if (ok)
1058
+ this._appliedThinking = this.deepThinking ? 1 : 0;
1059
+ }
1060
+ catch { }
1061
+ }
1062
+ // Enable web search via the "+" (mode) menu item, reading its selected state
1063
+ // first so a manual choice is not flipped back. The item shows a check/active
1064
+ // class when on; we click only on a mismatch.
1065
+ async _applyWebSearch() {
1066
+ // Only open the mode menu when the desired state changed since the last
1067
+ // apply: opening it on every send is slow and can steal focus from the
1068
+ // send button. A manual flip by the operator is still honored because we
1069
+ // re-read the item's selected state below the first time.
1070
+ const wantWs = this.webSearch ? 1 : 0;
1071
+ if (this._appliedWebSearch === wantWs)
1072
+ return;
1073
+ try {
1074
+ const trigger = this.page.locator(ATTACH_MENU_TRIGGER).first();
1075
+ if ((await trigger.count().catch(() => 0)) === 0)
1076
+ return;
1077
+ await trigger.click({ timeout: 2000 }).catch(() => { });
1078
+ await this.page.waitForTimeout(400);
1079
+ const item = this.page.getByText(WEB_SEARCH_MENU_RE).first();
1080
+ if ((await item.count().catch(() => 0)) === 0) {
1081
+ await this.page.keyboard.press('Escape').catch(() => { });
1082
+ return;
1083
+ }
1084
+ // Read the selected state from the item's own / ancestor class.
1085
+ const on = await item
1086
+ .evaluate((el) => {
1087
+ let n = el;
1088
+ for (let i = 0; i < 4 && n; i++) {
1089
+ const cls = (n.className || '').toString();
1090
+ if (/selected|active|checked|chosen/i.test(cls))
1091
+ return true;
1092
+ n = n.parentElement;
1093
+ }
1094
+ return false;
1095
+ })
1096
+ .catch(() => null);
1097
+ if (on === null) {
1098
+ await this.page.keyboard.press('Escape').catch(() => { });
1099
+ return;
1100
+ }
1101
+ if (on !== this.webSearch) {
1102
+ await item.click({ timeout: 2000 }).catch(() => { });
1103
+ await this.page.waitForTimeout(300);
1104
+ }
1105
+ this._appliedWebSearch = wantWs;
1106
+ // Close the menu (the send button lives behind it).
1107
+ await this.page.keyboard.press('Escape').catch(() => { });
1108
+ }
1109
+ catch { }
1110
+ }
1111
+ // Click the first visible menu option whose label matches `labelRe`.
1112
+ async _clickMenuOption(labelRe) {
1113
+ const opt = this.page
1114
+ .locator('[class*="dropdown-menu-item"], [role="option"], [role="menuitem"], li')
1115
+ .filter({ hasText: labelRe })
1116
+ .first();
1117
+ if ((await opt.count().catch(() => 0)) > 0) {
1118
+ await opt.click({ timeout: 2000 }).catch(() => { });
1119
+ return true;
1120
+ }
1121
+ await this.page.keyboard.press('Escape').catch(() => { });
1122
+ return false;
1123
+ }
1124
+ // Select the configured model in Qwen's header dropdown. The site builds the
1125
+ // completion request from ITS OWN UI state, so switching the model means
1126
+ // driving the `.wms-trigger` menu, not sending a field. Match the option by
1127
+ // a NORMALIZED label (strip non-alphanumerics, lowercase) so the config id
1128
+ // `qwen3.7-plus` matches the displayed name `Qwen3.7-Plus`. Best-effort: a
1129
+ // missing dropdown/option never fails the send.
1130
+ async _applyModel() {
1131
+ if (!this.model)
1132
+ return;
1133
+ const norm = (s) => String(s || '')
1134
+ .toLowerCase()
1135
+ .replace(/[^a-z0-9]/g, '');
1136
+ const want = norm(this.model);
1137
+ if (!want)
1138
+ return;
1139
+ try {
1140
+ const trigger = this.page.locator(MODEL_TRIGGER_SELECTOR).first();
1141
+ if ((await trigger.count().catch(() => 0)) === 0)
1142
+ return;
1143
+ const current = norm((await trigger.innerText().catch(() => '')) || '');
1144
+ if (current === want)
1145
+ return;
1146
+ await trigger.click({ timeout: 2000 }).catch(() => { });
1147
+ await this.page.waitForTimeout(300);
1148
+ const option = this.page
1149
+ .locator('li, [role="option"], [class*="option"], [class*="item"]')
1150
+ .filter({ hasText: new RegExp(escapeRegExp(this.model), 'i') })
1151
+ .first();
1152
+ if ((await option.count().catch(() => 0)) > 0) {
1153
+ await option.click({ timeout: 2000 }).catch(() => { });
1154
+ }
1155
+ else {
1156
+ // Fall back to a normalized text scan (the option label may differ in
1157
+ // punctuation from the config id).
1158
+ const clicked = await this.page
1159
+ .evaluate((w) => {
1160
+ const norm = (s) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
1161
+ for (const el of Array.from(document.querySelectorAll('li, [role="option"], [class*="option"], [class*="item"], div'))) {
1162
+ const t = (el.textContent || '').trim();
1163
+ if (t && norm(t) === w) {
1164
+ ;
1165
+ el.click();
1166
+ return true;
1167
+ }
1168
+ }
1169
+ return false;
1170
+ }, want)
1171
+ .catch(() => false);
1172
+ if (!clicked)
1173
+ await this.page.keyboard.press('Escape').catch(() => { });
1174
+ }
1175
+ }
1176
+ catch { }
1177
+ }
1178
+ // ---------- diagnostics ----------
1179
+ async dumpDom(filePath) {
1180
+ if (!this.page)
1181
+ throw new Error('qwen: browser not launched');
1182
+ const html = await this.page.content();
1183
+ await fs.writeFile(filePath, html, 'utf-8');
1184
+ const report = await this.page.evaluate(dumpDomCountsInDom, {
1185
+ answers: ANSWER_SELECTORS,
1186
+ stops: STOP_SELECTORS,
1187
+ inputs: INPUT_SELECTORS,
1188
+ });
1189
+ return { file: filePath, selectors: report };
1190
+ }
1191
+ // Qwen's send button (`button.send-button`) appears ONLY after the input has
1192
+ // text — on an idle page it is replaced by the voice-mode control. Probing it
1193
+ // at startup would therefore always report a false negative, so it is NOT
1194
+ // probed (like DeepSeek's transient Stop/answer nodes). The INPUT is the
1195
+ // critical always-present control; new-chat and toggles are advisory.
1196
+ _uiCapabilities() {
1197
+ return [
1198
+ { capability: 'input', selectors: INPUT_SELECTORS, critical: true },
1199
+ { capability: 'toggles', selectors: TOGGLE_SELECTORS, critical: false },
1200
+ {
1201
+ capability: 'newChat',
1202
+ selectors: [NEW_CHAT_SELECTOR],
1203
+ critical: false,
1204
+ },
1205
+ ];
1206
+ }
1207
+ async probeUiHealth() {
1208
+ if (!this.page) {
1209
+ return { ok: false, present: [], missing: [], missingCritical: [] };
1210
+ }
1211
+ const page = {
1212
+ count: (sel) => this.page.locator(sel).count(),
1213
+ countByText: (sel, textRe) => this.page.locator(sel).filter({ hasText: textRe }).count(),
1214
+ };
1215
+ return await probeUiHealth(page, this._uiCapabilities());
1216
+ }
1217
+ // Exposed so /chat can build the right URL for the active provider.
1218
+ chatUrl(id) {
1219
+ return chatUrl(id);
1220
+ }
1221
+ // ---------- ask ----------
1222
+ async _waitForSendSlot(agent) {
1223
+ if (!agent)
1224
+ return;
1225
+ if (!this._lastSentAt)
1226
+ return;
1227
+ const gap = this.sendIntervalMs() - (Date.now() - this._lastSentAt);
1228
+ if (gap <= 0)
1229
+ return;
1230
+ const report = (leftMs) => {
1231
+ const secs = Math.max(0, Math.ceil(leftMs / 1000));
1232
+ if (this.onSendPause) {
1233
+ try {
1234
+ this.onSendPause(secs);
1235
+ }
1236
+ catch { }
1237
+ }
1238
+ };
1239
+ report(gap);
1240
+ this._emitSendState('paused');
1241
+ await this._sleepInterruptible(gap, report);
1242
+ }
1243
+ async _sleepInterruptible(ms, onTick) {
1244
+ const step = 100;
1245
+ let left = ms;
1246
+ let sinceTick = 0;
1247
+ while (left > 0) {
1248
+ if (this._abort)
1249
+ return true;
1250
+ if (this._askCancelled)
1251
+ return true;
1252
+ const chunk = Math.min(step, left);
1253
+ await this.page.waitForTimeout(chunk);
1254
+ left -= chunk;
1255
+ if (onTick) {
1256
+ sinceTick += chunk;
1257
+ if (sinceTick >= 1000) {
1258
+ sinceTick = 0;
1259
+ onTick(left);
1260
+ }
1261
+ }
1262
+ }
1263
+ return this._abort;
1264
+ }
1265
+ // Cancel an in-flight ask() whose caller already gave up on it (the agent
1266
+ // loop's watchdog). Reset at the start of every _askOnce.
1267
+ cancelPendingAsk() {
1268
+ this._askCancelled = true;
1269
+ }
1270
+ async ask(prompt, { timeout = this.answerTimeoutMs, agent = false, attachments = [], } = {}) {
1271
+ let lastErr = null;
1272
+ let attempt = 0;
1273
+ let rateLimitRetries = 0;
1274
+ let serverBusyRetries = 0;
1275
+ let incompleteRetries = 0;
1276
+ this._askInFlight++;
1277
+ try {
1278
+ while (attempt < this.askRetries) {
1279
+ try {
1280
+ const answer = await this._askOnce(prompt, {
1281
+ timeout,
1282
+ agent,
1283
+ attachments,
1284
+ });
1285
+ if (answer !== '(прервано пользователем)') {
1286
+ this._emitSendState('settled');
1287
+ }
1288
+ return answer;
1289
+ }
1290
+ catch (e) {
1291
+ lastErr = e;
1292
+ const msg = e.message || '';
1293
+ // The guest daily limit cannot be waited out — fail fast and tell the
1294
+ // operator to sign in instead of burning the rate-limit budget.
1295
+ if (e instanceof QwenGuestLimitError) {
1296
+ throw new Error(this._t('qwen.guest_limit'));
1297
+ }
1298
+ if (/rate limit|too frequent/i.test(msg)) {
1299
+ if (rateLimitRetries >= this.maxRateLimitRetries)
1300
+ break;
1301
+ rateLimitRetries++;
1302
+ this._notice(this._t('ds.rate_limit_wait', {
1303
+ min: Math.ceil(this.rateLimitWaitMs / 60000),
1304
+ attempt: rateLimitRetries,
1305
+ max: this.maxRateLimitRetries,
1306
+ }));
1307
+ await this._sleepInterruptible(this.rateLimitWaitMs);
1308
+ continue;
1309
+ }
1310
+ if (/server busy/i.test(msg)) {
1311
+ if (serverBusyRetries >= this.maxServerBusyRetries)
1312
+ break;
1313
+ serverBusyRetries++;
1314
+ await this._sleepInterruptible(this.serverBusyWaitMs);
1315
+ continue;
1316
+ }
1317
+ if (e instanceof QwenIncompleteError) {
1318
+ if (incompleteRetries >= this.maxIncompleteRetries)
1319
+ break;
1320
+ incompleteRetries++;
1321
+ this._notice(theme.warn(this._t('ds.incomplete_retry', {
1322
+ attempt: incompleteRetries,
1323
+ max: this.maxIncompleteRetries,
1324
+ })));
1325
+ await this._sleepInterruptible(this.incompleteWaitMs);
1326
+ continue;
1327
+ }
1328
+ // A LOST page/context ("Target page, context or browser has been
1329
+ // closed") is fatal for this attempt but recoverable: relaunch the
1330
+ // persistent context and re-run the turn. Qwen's page can crash under
1331
+ // memory pressure on a long run, and without this ask() just kept
1332
+ // retrying against a dead page and failed every time.
1333
+ if (/target page, context or browser has been closed|page.*has been closed/i.test(msg)) {
1334
+ this._notice(theme.warn(this._t('qwen.restart_browser')));
1335
+ try {
1336
+ await this.restart();
1337
+ await this.waitForLogin();
1338
+ }
1339
+ catch (re) {
1340
+ this._notice(theme.warn(this._t('qwen.restart_failed', {
1341
+ error: re.message,
1342
+ })));
1343
+ }
1344
+ }
1345
+ this._notice(theme.warn(this._t('ds.ask_retry', {
1346
+ attempt: attempt + 1,
1347
+ max: this.askRetries,
1348
+ error: msg,
1349
+ })));
1350
+ if (attempt < this.askRetries - 1) {
1351
+ await new Promise((r) => setTimeout(r, 2000 * (attempt + 1)));
1352
+ }
1353
+ attempt++;
1354
+ }
1355
+ }
1356
+ throw new Error(this._t('ds.ask_failed', {
1357
+ max: this.askRetries,
1358
+ error: lastErr?.message || '',
1359
+ }));
1360
+ }
1361
+ finally {
1362
+ this._askInFlight = Math.max(0, this._askInFlight - 1);
1363
+ void this._setBusyTitle(false);
1364
+ }
1365
+ }
1366
+ // Attach files/images before typing the text (the upload chip appears above
1367
+ // the input). Returns the files that FAILED so the prompt can rewrite their
1368
+ // markers. Mirrors DeepSeekBrowser._attachFiles.
1369
+ async _attachFiles(files) {
1370
+ const failed = [];
1371
+ const bufPayload = [];
1372
+ for (const f of files) {
1373
+ try {
1374
+ const buffer = await fs.readFile(f.path);
1375
+ bufPayload.push({ name: f.name, mimeType: f.mime, buffer });
1376
+ }
1377
+ catch (e) {
1378
+ failed.push({ path: f.path, name: f.name });
1379
+ this._notice(theme.warn(this._t('ds.attach_read_failed', {
1380
+ name: f.name,
1381
+ error: e.message,
1382
+ })));
1383
+ }
1384
+ }
1385
+ if (!bufPayload.length)
1386
+ return failed;
1387
+ // Primary flow (verified live): click the "+" (`.mode-select-open`) to
1388
+ // open the mode menu, then "Upload attachment" — that click fires a real
1389
+ // filechooser the upload handler listens to. A direct setInputFiles on the
1390
+ // hidden input uploads the bytes but does NOT register the chip in the UI
1391
+ // state, so it is only the fallback.
1392
+ try {
1393
+ const [chooser] = await Promise.all([
1394
+ this.page.waitForEvent('filechooser', { timeout: 5000 }),
1395
+ (async () => {
1396
+ await this.page
1397
+ .locator(ATTACH_MENU_TRIGGER)
1398
+ .first()
1399
+ .click({ timeout: 3000 });
1400
+ await this.page.waitForTimeout(600);
1401
+ await this.page
1402
+ .getByText(ATTACH_MENU_ITEM_RE)
1403
+ .first()
1404
+ .click({ timeout: 3000 });
1405
+ })(),
1406
+ ]);
1407
+ await chooser.setFiles(bufPayload);
1408
+ await this._awaitUploadPreview(files.map((f) => f.name), 5000);
1409
+ return failed;
1410
+ }
1411
+ catch { }
1412
+ // Fallback: set the files directly on the hidden <input type=file>.
1413
+ for (const sel of ATTACH_SELECTORS) {
1414
+ const el = this.page.locator(sel).last();
1415
+ try {
1416
+ if ((await el.count()) === 0)
1417
+ continue;
1418
+ await el.setInputFiles(bufPayload, { timeout: 15_000 });
1419
+ await this._awaitUploadPreview(files.map((f) => f.name), 3000);
1420
+ return failed;
1421
+ }
1422
+ catch { }
1423
+ }
1424
+ this._notice(this._t('ds.attach_no_input'));
1425
+ for (const f of files)
1426
+ failed.push({ path: f.path, name: f.name });
1427
+ return failed;
1428
+ }
1429
+ // Wait for the upload chip (the file name above the input) instead of a
1430
+ // fixed sleep: files are attached BEFORE the text is typed, so the name
1431
+ // cannot appear on the page for any other reason.
1432
+ async _awaitUploadPreview(names, timeoutMs) {
1433
+ const clean = names.map((n) => String(n || '').trim()).filter(Boolean);
1434
+ if (!clean.length) {
1435
+ await this.page.waitForTimeout(timeoutMs);
1436
+ return;
1437
+ }
1438
+ const deadline = Date.now() + timeoutMs;
1439
+ while (Date.now() < deadline) {
1440
+ for (const name of clean) {
1441
+ const n = await this.page
1442
+ .getByText(name, { exact: false })
1443
+ .count()
1444
+ .catch(() => 0);
1445
+ if (n > 0)
1446
+ return;
1447
+ }
1448
+ await this.page.waitForTimeout(150);
1449
+ }
1450
+ }
1451
+ async _setInputText(input, text) {
1452
+ const tag = await input.evaluate((el) => el.tagName.toLowerCase());
1453
+ if (tag === 'textarea' || tag === 'input') {
1454
+ try {
1455
+ await input.fill(text, { timeout: 5000 });
1456
+ return;
1457
+ }
1458
+ catch { }
1459
+ }
1460
+ // contenteditable fallback: paste so a big block is inserted in one go.
1461
+ await input.click({ timeout: 3000 }).catch(() => { });
1462
+ await input
1463
+ .evaluate((el, value) => {
1464
+ el.focus();
1465
+ const dt = new DataTransfer();
1466
+ dt.setData('text/plain', value);
1467
+ el.dispatchEvent(new ClipboardEvent('paste', {
1468
+ clipboardData: dt,
1469
+ bubbles: true,
1470
+ cancelable: true,
1471
+ }));
1472
+ }, text)
1473
+ .catch(async () => {
1474
+ await input.fill(text).catch(() => { });
1475
+ });
1476
+ }
1477
+ async _askOnce(prompt, { timeout, agent, attachments = [], }) {
1478
+ this._abort = false;
1479
+ this._askCancelled = false;
1480
+ if (this._stopped)
1481
+ return '(прервано пользователем)';
1482
+ await this._dismissOverlays();
1483
+ // The input may briefly be absent right after load ("Model loading…"), so
1484
+ // poll for it instead of a single lookup — the first run on a cold page
1485
+ // otherwise threw ds.input_missing while the textarea was still mounting.
1486
+ // If the page lost the input entirely (a crash during a cancelled send),
1487
+ // navigate back to the chat so the next attempt has a live input again
1488
+ // instead of failing every retry against a dead DOM.
1489
+ await this._awaitInput(10_000);
1490
+ let input = await this._findVisible(INPUT_SELECTORS, 3000);
1491
+ if (!input) {
1492
+ try {
1493
+ await this.page.goto(CHAT_URL, { waitUntil: 'domcontentloaded' });
1494
+ await this._dismissOverlays();
1495
+ await this._awaitInput(10_000);
1496
+ input = await this._findVisible(INPUT_SELECTORS, 3000);
1497
+ }
1498
+ catch { }
1499
+ }
1500
+ if (!input)
1501
+ throw new Error(this._t('ds.input_missing'));
1502
+ const beforeText = await this._readLastAnswerTextCleanDom().catch(() => '');
1503
+ await this._waitForSendSlot(agent);
1504
+ if (this._abort)
1505
+ return '(прервано пользователем)';
1506
+ if (this.onSendStart) {
1507
+ try {
1508
+ this.onSendStart();
1509
+ }
1510
+ catch { }
1511
+ }
1512
+ this._emitSendState('generating');
1513
+ void this._setBusyTitle(true);
1514
+ this._netCapture = '';
1515
+ this._netCaptureAt = 0;
1516
+ this._netNoAnswer = false;
1517
+ this._netRateLimited = false;
1518
+ this._netGuestLimit = false;
1519
+ this._netBody = '';
1520
+ await this._applyToggles();
1521
+ if (attachments.length) {
1522
+ const failed = await this._attachFiles(attachments);
1523
+ prompt = rewriteFailedAttachments(prompt, failed);
1524
+ }
1525
+ await this._setInputText(input, prompt);
1526
+ // Send: wait for an ENABLED send button (it is disabled while the input is
1527
+ // empty or an upload is in flight), then click; fall back to Enter. With
1528
+ // attachments the upload can keep the button disabled for a while, so the
1529
+ // window is longer in that case.
1530
+ const sendDeadline = Date.now() + (attachments.length ? 30_000 : 8000);
1531
+ let sent = false;
1532
+ while (!sent && Date.now() < sendDeadline) {
1533
+ for (const sel of SEND_SELECTORS) {
1534
+ const btn = this.page.locator(sel).last();
1535
+ try {
1536
+ if ((await btn.count()) === 0)
1537
+ continue;
1538
+ if (!(await btn.isVisible().catch(() => false)))
1539
+ continue;
1540
+ const enabled = await btn
1541
+ .evaluate(isElementEnabledInDom)
1542
+ .catch(() => true);
1543
+ if (!enabled)
1544
+ continue;
1545
+ await btn.click({ timeout: 1500 });
1546
+ sent = true;
1547
+ break;
1548
+ }
1549
+ catch { }
1550
+ }
1551
+ if (!sent)
1552
+ await this.page.waitForTimeout(150);
1553
+ }
1554
+ if (!sent)
1555
+ await this.page.keyboard.press('Enter');
1556
+ this._lastSentAt = Date.now();
1557
+ // Verify the send really went out: Qwen clears the textarea when the
1558
+ // message is accepted. If the text is still there, press Enter once more.
1559
+ try {
1560
+ await this.page.waitForTimeout(300);
1561
+ const stillThere = await input
1562
+ .evaluate(readInputTrimmedTextInDom)
1563
+ .catch(() => '');
1564
+ if (stillThere) {
1565
+ await this.page.keyboard.press('Enter');
1566
+ this._lastSentAt = Date.now();
1567
+ }
1568
+ }
1569
+ catch { }
1570
+ // Wait for the answer to start, then for it to settle.
1571
+ const startDeadline = Date.now() + 30_000;
1572
+ const startSignal = await this._chatSignal();
1573
+ let started = false;
1574
+ while (Date.now() < startDeadline) {
1575
+ if (this._abort || this._askCancelled)
1576
+ return '(прервано пользователем)';
1577
+ const pageText = await this._readPageText();
1578
+ if (isRateLimitText(pageText)) {
1579
+ throw new QwenRateLimitError(pageText.slice(0, 300));
1580
+ }
1581
+ if (this._netGuestLimit && this._netCaptureAt >= this._lastSentAt) {
1582
+ throw new QwenGuestLimitError(this._netBody.slice(0, 200));
1583
+ }
1584
+ if (this._netRateLimited && this._netCaptureAt >= this._lastSentAt) {
1585
+ throw new QwenRateLimitError(this._netBody.slice(0, 300));
1586
+ }
1587
+ const sig = await this._chatSignal();
1588
+ if (sig.nodes > startSignal.nodes || sig.lastLen > startSignal.lastLen) {
1589
+ started = true;
1590
+ break;
1591
+ }
1592
+ await this.page.waitForTimeout(200);
1593
+ }
1594
+ if (!started && !this._netCapture) {
1595
+ // The answer may have arrived over the network without changing the DOM
1596
+ // signal (e.g. a very short reply rendered late). Give the capture a
1597
+ // chance before declaring failure.
1598
+ await this.page.waitForTimeout(1500);
1599
+ }
1600
+ // Finish loop: wait until the answer text stops changing for
1601
+ // `stabilityChecks` ticks, or the deadline passes.
1602
+ const deadline = Date.now() + timeout;
1603
+ let last = '';
1604
+ let stable = 0;
1605
+ while (Date.now() < deadline) {
1606
+ if (this._abort || this._askCancelled)
1607
+ return '(прервано пользователем)';
1608
+ const pageText = await this._readPageText();
1609
+ if (isRateLimitText(pageText)) {
1610
+ throw new QwenRateLimitError(pageText.slice(0, 300));
1611
+ }
1612
+ if (this._netGuestLimit && this._netCaptureAt >= this._lastSentAt) {
1613
+ throw new QwenGuestLimitError(this._netBody.slice(0, 200));
1614
+ }
1615
+ if (this._netRateLimited && this._netCaptureAt >= this._lastSentAt) {
1616
+ throw new QwenRateLimitError(this._netBody.slice(0, 300));
1617
+ }
1618
+ if (this._netNoAnswer && this._netCaptureAt >= this._lastSentAt) {
1619
+ throw new QwenIncompleteError('finished without answer');
1620
+ }
1621
+ const cur = await this._readLastAnswerTextCleanDom().catch(() => '');
1622
+ const net = this._netCapture;
1623
+ if (net && net !== last) {
1624
+ last = net;
1625
+ stable = 0;
1626
+ }
1627
+ else if (cur && cur !== beforeText && cur !== last) {
1628
+ last = cur;
1629
+ stable = 0;
1630
+ }
1631
+ else {
1632
+ stable++;
1633
+ }
1634
+ if (stable >= this.stabilityChecks - 1) {
1635
+ const answer = this._netCapture || cur;
1636
+ if (answer && answer !== beforeText)
1637
+ return answer;
1638
+ // No new answer yet — keep waiting a bit longer.
1639
+ }
1640
+ await this.page.waitForTimeout(this.stabilityDelayMs);
1641
+ }
1642
+ const answer = this._netCapture || last;
1643
+ if (answer && answer !== beforeText)
1644
+ return answer;
1645
+ throw new Error(this._t('ds.send_no_new_answer'));
1646
+ }
1647
+ }
1648
+ // Qwen-specific error classes so ask() can branch on the retryable families.
1649
+ class QwenRateLimitError extends Error {
1650
+ constructor(detail) {
1651
+ super('rate limit: ' + detail);
1652
+ this.name = 'QwenRateLimitError';
1653
+ }
1654
+ }
1655
+ // The guest daily limit — NOT retryable (resets tomorrow), so ask() fails
1656
+ // immediately with a message telling the operator to sign in.
1657
+ class QwenGuestLimitError extends Error {
1658
+ constructor(detail) {
1659
+ super('guest limit: ' + detail);
1660
+ this.name = 'QwenGuestLimitError';
1661
+ }
1662
+ }
1663
+ class QwenIncompleteError extends Error {
1664
+ constructor(detail) {
1665
+ super('generation incomplete: ' + detail);
1666
+ this.name = 'QwenIncompleteError';
1667
+ }
1668
+ }