zames_pro 2.72.9 → 2.73.1

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