zames_pro 2.80.8 → 2.81.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/README.md +71 -21
- package/dist/browser-login.js +177 -0
- package/dist/browser-pure.js +112 -8
- package/dist/browser.js +274 -16
- package/dist/commands.js +24 -8
- package/dist/config.js +106 -2
- package/dist/glm-browser.js +1766 -0
- package/dist/glm-chats.js +172 -0
- package/dist/glm-flags.js +75 -0
- package/dist/glm-net-capture.js +338 -0
- package/dist/glm-ui.js +171 -0
- package/dist/i18n/common.js +18 -2
- package/dist/i18n/config.js +41 -1
- package/dist/i18n/doctor.js +11 -6
- package/dist/i18n/glm.js +86 -0
- package/dist/i18n/help.js +14 -10
- package/dist/i18n/qwen.js +47 -15
- package/dist/i18n/self-review.js +2 -2
- package/dist/i18n.js +2 -0
- package/dist/index.js +325 -114
- package/dist/provider-handoff.js +11 -0
- package/dist/providers.js +65 -0
- package/dist/qwen-browser.js +274 -30
- package/dist/send-runtime.js +112 -0
- package/dist/send-telemetry.js +99 -0
- package/dist/send-throttle.js +108 -0
- package/dist/system-prompt.js +23 -2
- package/package.json +3 -2
package/dist/provider-handoff.js
CHANGED
|
@@ -11,9 +11,20 @@ import fs from 'fs';
|
|
|
11
11
|
import path from 'path';
|
|
12
12
|
import os from 'os';
|
|
13
13
|
import { writeJsonAtomic } from './fsutil.js';
|
|
14
|
+
import { providerLabel } from './providers.js';
|
|
14
15
|
const ZAMES_HOME = path.join(os.homedir(), '.zames');
|
|
15
16
|
const HANDOFF_FILE = path.join(ZAMES_HOME, 'provider-handoff.json');
|
|
16
17
|
const NL = String.fromCharCode(10);
|
|
18
|
+
/**
|
|
19
|
+
* The DISPLAY label of the provider a handoff came from. Prefers the explicit
|
|
20
|
+
* `from` field; a legacy file (written when exactly two backends existed) has
|
|
21
|
+
* none, and there "the other one" is the only correct answer. PURE.
|
|
22
|
+
*/
|
|
23
|
+
export function handoffFromLabel(h) {
|
|
24
|
+
if (h.from)
|
|
25
|
+
return providerLabel(h.from);
|
|
26
|
+
return h.provider === 'qwen' ? 'DeepSeek' : 'Qwen';
|
|
27
|
+
}
|
|
17
28
|
/**
|
|
18
29
|
* Wrap a cross-provider summary into the first message of the new provider's
|
|
19
30
|
* chat. PURE (unit-tested): the wording must make clear this is carried-over
|
package/dist/providers.js
CHANGED
|
@@ -8,20 +8,55 @@
|
|
|
8
8
|
// /doctor row, the {provider} label in operator messages, /chat URL) reads
|
|
9
9
|
// from HERE, so nothing else has to be touched.
|
|
10
10
|
//
|
|
11
|
+
// CAPABILITY FLAGS: the code used to ask `provider === 'qwen'` in a dozen
|
|
12
|
+
// places, which made "there are exactly two backends" a silent assumption and
|
|
13
|
+
// broke the moment a third one arrived. The flags below are that knowledge,
|
|
14
|
+
// stated once per provider, so every call site asks a CAPABILITY instead.
|
|
15
|
+
//
|
|
11
16
|
// This module is PURE (no imports, no side effects) so it can be imported from
|
|
12
17
|
// anywhere without pulling in Playwright.
|
|
18
|
+
import path from 'path';
|
|
19
|
+
import os from 'os';
|
|
20
|
+
/** `~/.zames/<dir>` — the per-provider profile location. */
|
|
21
|
+
function zamesProfile(dir) {
|
|
22
|
+
return path.join(os.homedir(), '.zames', dir);
|
|
23
|
+
}
|
|
13
24
|
export const PROVIDERS = {
|
|
14
25
|
deepseek: {
|
|
15
26
|
id: 'deepseek',
|
|
16
27
|
label: 'DeepSeek',
|
|
17
28
|
chatUrlPrefix: 'https://chat.deepseek.com/a/chat/s',
|
|
29
|
+
chatUrl: 'https://chat.deepseek.com/',
|
|
30
|
+
chatIdPattern: '/a/chat/s/([A-Za-z0-9_-]+)',
|
|
18
31
|
isDefault: true,
|
|
32
|
+
hasModelPicker: false,
|
|
33
|
+
guestAllowed: false,
|
|
34
|
+
profileDir: zamesProfile('profile'),
|
|
35
|
+
needsVisibleBrowser: false,
|
|
19
36
|
},
|
|
20
37
|
qwen: {
|
|
21
38
|
id: 'qwen',
|
|
22
39
|
label: 'Qwen',
|
|
23
40
|
chatUrlPrefix: 'https://chat.qwen.ai/c',
|
|
41
|
+
chatUrl: 'https://chat.qwen.ai/',
|
|
42
|
+
chatIdPattern: '/c/([A-Za-z0-9_-]+)',
|
|
43
|
+
isDefault: false,
|
|
44
|
+
hasModelPicker: true,
|
|
45
|
+
guestAllowed: true,
|
|
46
|
+
profileDir: zamesProfile('qwen-profile'),
|
|
47
|
+
needsVisibleBrowser: false,
|
|
48
|
+
},
|
|
49
|
+
glm: {
|
|
50
|
+
id: 'glm',
|
|
51
|
+
label: 'GLM',
|
|
52
|
+
chatUrlPrefix: 'https://chat.z.ai/c',
|
|
53
|
+
chatUrl: 'https://chat.z.ai/',
|
|
54
|
+
chatIdPattern: '/c/([A-Za-z0-9_-]+)',
|
|
24
55
|
isDefault: false,
|
|
56
|
+
hasModelPicker: true,
|
|
57
|
+
guestAllowed: true,
|
|
58
|
+
profileDir: zamesProfile('glm-profile'),
|
|
59
|
+
needsVisibleBrowser: true,
|
|
25
60
|
},
|
|
26
61
|
};
|
|
27
62
|
/** All provider ids, in a stable order (default first). */
|
|
@@ -48,3 +83,33 @@ export function providerLabel(id) {
|
|
|
48
83
|
export function providerChatUrlPrefix(id) {
|
|
49
84
|
return isProviderId(id) ? PROVIDERS[id].chatUrlPrefix : '';
|
|
50
85
|
}
|
|
86
|
+
/** The provider's config section name (`qwen`, `glm`, …) or null for DeepSeek. */
|
|
87
|
+
export function providerConfigSection(id) {
|
|
88
|
+
// DeepSeek's shared settings live in `browser`; the other backends own a
|
|
89
|
+
// per-provider section (qwen.*, glm.*).
|
|
90
|
+
return id === 'deepseek' ? null : id;
|
|
91
|
+
}
|
|
92
|
+
/** True when the provider exposes a model picker (`/model`). */
|
|
93
|
+
export function providerHasModelPicker(id) {
|
|
94
|
+
return isProviderId(id) ? PROVIDERS[id].hasModelPicker : false;
|
|
95
|
+
}
|
|
96
|
+
/** True when an unsigned (guest) visitor is usable on the site. */
|
|
97
|
+
export function providerGuestAllowed(id) {
|
|
98
|
+
return isProviderId(id) ? PROVIDERS[id].guestAllowed : false;
|
|
99
|
+
}
|
|
100
|
+
/** The persistent profile directory of a provider. */
|
|
101
|
+
/** True when the provider needs a VISIBLE browser (a headless captcha). */
|
|
102
|
+
export function providerNeedsVisibleBrowser(id) {
|
|
103
|
+
return isProviderId(id) ? PROVIDERS[id].needsVisibleBrowser : false;
|
|
104
|
+
}
|
|
105
|
+
export function providerProfileDir(id) {
|
|
106
|
+
return isProviderId(id) ? PROVIDERS[id].profileDir : '';
|
|
107
|
+
}
|
|
108
|
+
/** The chat home URL of a provider. */
|
|
109
|
+
export function providerChatUrl(id) {
|
|
110
|
+
return isProviderId(id) ? PROVIDERS[id].chatUrl : '';
|
|
111
|
+
}
|
|
112
|
+
/** A provider's chat-id matcher (compiled from the registry pattern). */
|
|
113
|
+
export function providerChatIdRe(id) {
|
|
114
|
+
return new RegExp(PROVIDERS[id].chatIdPattern);
|
|
115
|
+
}
|
package/dist/qwen-browser.js
CHANGED
|
@@ -21,10 +21,11 @@ import os from 'os';
|
|
|
21
21
|
import { chromium } from 'playwright';
|
|
22
22
|
import { translate, DEFAULT_LOCALE } from './i18n.js';
|
|
23
23
|
import { rewriteFailedAttachments } from './commands.js';
|
|
24
|
+
import { interactiveBrowserLogin, loginIsInteractive, canShowWindow, } from './browser-login.js';
|
|
24
25
|
import { awaitUploadPreview, readAttachBuffers } from './attach-flow.js';
|
|
25
26
|
import { theme } from './theme.js';
|
|
26
27
|
import { scrapeQwenChatList, scrapeQwenChatMessages, parseQwenChatList, parseQwenHistory, } from './qwen-chats.js';
|
|
27
|
-
import { cleanAnswer, headlessEngineKey, readCachedHeadlessUA, writeCachedHeadlessUA, sanitizeHeadlessUA, cleanSingletonFiles, profileLooksLocked, nukeProfile,
|
|
28
|
+
import { cleanAnswer, headlessEngineKey, readCachedHeadlessUA, writeCachedHeadlessUA, sanitizeHeadlessUA, cleanSingletonFiles, profileLooksLocked, nukeProfile, writeAuthMarker, } from './browser-pure.js';
|
|
28
29
|
import { chatSignalInDom, dumpDomCountsInDom, isElementEnabledInDom, readInputTrimmedTextInDom, readLastAnswerInDom, readPageToastsInDom, readUserAgentInDom, setBusyTitleInDom, clickStopInDom, stopButtonVisibleInDom, } from './browser-dom.js';
|
|
29
30
|
import { probeUiHealth, } from './ui-health.js';
|
|
30
31
|
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, AGE_GATE_YEAR_SELECT, AGE_GATE_YEAR_OPTION, 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';
|
|
@@ -122,7 +123,10 @@ export class QwenBrowser {
|
|
|
122
123
|
onSendState;
|
|
123
124
|
onNotice;
|
|
124
125
|
userAgent;
|
|
125
|
-
|
|
126
|
+
loginTimeoutMs;
|
|
127
|
+
_windowClosed;
|
|
128
|
+
_carryCookies;
|
|
129
|
+
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, onLoginPrompt, loginTimeoutMs = 600000, userAgent, } = {}) {
|
|
126
130
|
this.headless = headless;
|
|
127
131
|
this.debug = debug;
|
|
128
132
|
this.channel = channel;
|
|
@@ -178,6 +182,9 @@ export class QwenBrowser {
|
|
|
178
182
|
this.onSendState = null;
|
|
179
183
|
this.onNotice = null;
|
|
180
184
|
this.userAgent = userAgent || '';
|
|
185
|
+
this.loginTimeoutMs = loginTimeoutMs;
|
|
186
|
+
this._windowClosed = false;
|
|
187
|
+
this._carryCookies = null;
|
|
181
188
|
}
|
|
182
189
|
_t(key, params) {
|
|
183
190
|
return translate(this.locale)(key, { provider: 'Qwen', ...params });
|
|
@@ -221,7 +228,7 @@ export class QwenBrowser {
|
|
|
221
228
|
await cleanSingletonFiles(QWEN_USER_DATA_DIR);
|
|
222
229
|
}
|
|
223
230
|
if (this.headless && !this.userAgent) {
|
|
224
|
-
const cached = await readCachedHeadlessUA(headlessEngineKey()
|
|
231
|
+
const cached = await readCachedHeadlessUA(headlessEngineKey());
|
|
225
232
|
if (cached)
|
|
226
233
|
this.userAgent = cached;
|
|
227
234
|
}
|
|
@@ -242,7 +249,7 @@ export class QwenBrowser {
|
|
|
242
249
|
if (fixed === ua)
|
|
243
250
|
return;
|
|
244
251
|
this.userAgent = fixed;
|
|
245
|
-
void writeCachedHeadlessUA(headlessEngineKey()
|
|
252
|
+
void writeCachedHeadlessUA(headlessEngineKey(), fixed);
|
|
246
253
|
if (this.debug)
|
|
247
254
|
console.error('qwen profile: headless UA -> relaunching plain Chrome UA');
|
|
248
255
|
await this.context.close().catch(() => { });
|
|
@@ -341,6 +348,30 @@ export class QwenBrowser {
|
|
|
341
348
|
catch { }
|
|
342
349
|
}
|
|
343
350
|
async close() {
|
|
351
|
+
// A session cookie (expires = -1) is dropped when the context closes, so a
|
|
352
|
+
// sign-in would not survive the process. Re-add it as a PERSISTENT cookie
|
|
353
|
+
// before closing: the token then lives in the profile and the next launch
|
|
354
|
+
// is signed in without a window.
|
|
355
|
+
try {
|
|
356
|
+
if (this.context) {
|
|
357
|
+
const cookies = await this.context.cookies();
|
|
358
|
+
const session = cookies.filter((c) => c.expires === -1 || c.expires < 0);
|
|
359
|
+
if (session.length) {
|
|
360
|
+
const keepUntil = Math.floor((Date.now() + 15552000000) / 1000);
|
|
361
|
+
await this.context.addCookies(session.map((c) => ({
|
|
362
|
+
name: c.name,
|
|
363
|
+
value: c.value,
|
|
364
|
+
domain: c.domain,
|
|
365
|
+
path: c.path,
|
|
366
|
+
httpOnly: c.httpOnly,
|
|
367
|
+
secure: c.secure,
|
|
368
|
+
sameSite: c.sameSite,
|
|
369
|
+
expires: keepUntil,
|
|
370
|
+
})));
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
catch { }
|
|
344
375
|
try {
|
|
345
376
|
if (this.context)
|
|
346
377
|
await this.context.close();
|
|
@@ -481,41 +512,245 @@ export class QwenBrowser {
|
|
|
481
512
|
}
|
|
482
513
|
return false;
|
|
483
514
|
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
515
|
+
/**
|
|
516
|
+
* A FAST login probe: Qwen's own `auths/refresh` API needs the app's header,
|
|
517
|
+
* so it cannot be replayed from a bare fetch. Instead the page reports
|
|
518
|
+
* whether a token is in storage and whether a sign-in control is visible. A
|
|
519
|
+
* guest ALSO sees the input, so the input alone must never mean "signed in".
|
|
520
|
+
*/
|
|
521
|
+
async probeLogin() {
|
|
522
|
+
if (!this.page)
|
|
523
|
+
return 'unknown';
|
|
524
|
+
try {
|
|
525
|
+
const r = await this.page.evaluate(() => {
|
|
526
|
+
const hasToken = (() => {
|
|
527
|
+
try {
|
|
528
|
+
return !!localStorage.getItem('token');
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
})();
|
|
534
|
+
const body = document.body ? document.body.innerText : '';
|
|
535
|
+
const signInVisible = /\b(log ?in|sign ?in)\b/i.test(body);
|
|
536
|
+
return { hasToken, signInVisible };
|
|
537
|
+
});
|
|
538
|
+
if (r.hasToken)
|
|
539
|
+
return 'in';
|
|
540
|
+
if (r.signInVisible)
|
|
541
|
+
return 'out';
|
|
542
|
+
return 'unknown';
|
|
490
543
|
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
544
|
+
catch {
|
|
545
|
+
return 'unknown';
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* A human signs in inside a VISIBLE window (a one-time code cannot be typed
|
|
550
|
+
* into a terminal), then zames returns to headless. The decision sequence is
|
|
551
|
+
* the shared interactiveBrowserLogin.
|
|
552
|
+
*/
|
|
553
|
+
async _browserLogin() {
|
|
554
|
+
const interactive = loginIsInteractive({
|
|
555
|
+
isTTY: !!(process.stdin.isTTY && process.stdout.isTTY),
|
|
556
|
+
oneShot: false,
|
|
557
|
+
scheduled: false,
|
|
558
|
+
env: process.env,
|
|
559
|
+
});
|
|
560
|
+
await interactiveBrowserLogin({
|
|
561
|
+
provider: 'Qwen',
|
|
562
|
+
probeLogin: () => this.probeLogin(),
|
|
563
|
+
autoLogin: () => this._autoLogin(),
|
|
564
|
+
relaunch: (h) => this.relaunchSafe(h),
|
|
565
|
+
openLoginPage: () => this._openLoginPage(),
|
|
566
|
+
gotoChat: async () => {
|
|
567
|
+
await this.page
|
|
568
|
+
.goto(CHAT_URL, { waitUntil: 'domcontentloaded', timeout: 30000 })
|
|
569
|
+
.catch(() => { });
|
|
570
|
+
},
|
|
571
|
+
prefill: async () => {
|
|
572
|
+
try {
|
|
573
|
+
if (!this.auth.username)
|
|
574
|
+
return;
|
|
575
|
+
const email = this.page
|
|
576
|
+
.locator('input[name="email"], input[type="email"]')
|
|
577
|
+
.first();
|
|
578
|
+
if ((await email.count().catch(() => 0)) === 0)
|
|
579
|
+
return;
|
|
580
|
+
if (await email.isVisible().catch(() => false)) {
|
|
581
|
+
await email
|
|
582
|
+
.fill(this.auth.username, { timeout: 2000 })
|
|
583
|
+
.catch(() => { });
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
catch { }
|
|
587
|
+
},
|
|
588
|
+
captureCredentials: async () => {
|
|
589
|
+
try {
|
|
590
|
+
await this.page.exposeFunction('__zamesQwenCreds', (u) => {
|
|
591
|
+
const user = String(u || '').trim();
|
|
592
|
+
if (!user)
|
|
593
|
+
return;
|
|
594
|
+
this.auth.username = user;
|
|
595
|
+
try {
|
|
596
|
+
this.onAuthSave?.(user, '');
|
|
597
|
+
}
|
|
598
|
+
catch { }
|
|
599
|
+
});
|
|
600
|
+
await this.page.evaluate(() => {
|
|
601
|
+
const w = window;
|
|
602
|
+
if (w.__zamesQwenHooked)
|
|
603
|
+
return;
|
|
604
|
+
w.__zamesQwenHooked = true;
|
|
605
|
+
document.addEventListener('submit', (ev) => {
|
|
606
|
+
try {
|
|
607
|
+
const form = ev.target;
|
|
608
|
+
if (!form)
|
|
609
|
+
return;
|
|
610
|
+
const e = form.querySelector('input[type="email"], input[name="email"]');
|
|
611
|
+
const fn = window.__zamesQwenCreds;
|
|
612
|
+
if (fn && e)
|
|
613
|
+
fn(e.value || '');
|
|
614
|
+
}
|
|
615
|
+
catch { }
|
|
616
|
+
}, true);
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
catch { }
|
|
620
|
+
},
|
|
621
|
+
persistSession: () => this._persistSessionIfNeeded(),
|
|
622
|
+
notice: (key) => this._notice(this._t(key)),
|
|
623
|
+
isAborted: () => this._abort,
|
|
624
|
+
windowClosed: () => this._windowClosed,
|
|
625
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
626
|
+
now: () => Date.now(),
|
|
627
|
+
interactive,
|
|
628
|
+
stayHeaded: !this.headless,
|
|
629
|
+
guestAllowed: true,
|
|
630
|
+
canShowWindow: canShowWindow(process.env, process.platform),
|
|
631
|
+
tuiLogin: this.onLoginPrompt
|
|
632
|
+
? async () => {
|
|
633
|
+
return await this._promptAndLogin();
|
|
634
|
+
}
|
|
635
|
+
: undefined,
|
|
636
|
+
timeoutMs: this.loginTimeoutMs,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
/** Relaunch in another headless mode (login flow), keeping session cookies. */
|
|
640
|
+
async relaunchSafe(headless) {
|
|
641
|
+
try {
|
|
642
|
+
const cookies = await this.context.cookies();
|
|
643
|
+
const session = cookies.filter((c) => c.expires === -1 || c.expires < 0);
|
|
644
|
+
if (session.length)
|
|
645
|
+
this._carryCookies = session;
|
|
500
646
|
}
|
|
501
|
-
|
|
502
|
-
|
|
647
|
+
catch { }
|
|
648
|
+
try {
|
|
649
|
+
if (this.context)
|
|
650
|
+
await this.context.close();
|
|
503
651
|
}
|
|
504
|
-
|
|
505
|
-
|
|
652
|
+
catch { }
|
|
653
|
+
const deadline = Date.now() + 15000;
|
|
654
|
+
while (Date.now() < deadline) {
|
|
655
|
+
if (!(await profileLooksLocked(QWEN_USER_DATA_DIR)))
|
|
656
|
+
break;
|
|
657
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
658
|
+
}
|
|
659
|
+
this.headless = headless;
|
|
660
|
+
const options = {
|
|
661
|
+
headless,
|
|
662
|
+
slowMo: 10,
|
|
663
|
+
args: ['--disable-blink-features=AutomationControlled'],
|
|
664
|
+
};
|
|
665
|
+
if (this.channel)
|
|
666
|
+
options.channel = this.channel;
|
|
667
|
+
if (this.userAgent)
|
|
668
|
+
options.userAgent = this.userAgent;
|
|
669
|
+
try {
|
|
670
|
+
this.context = await chromium.launchPersistentContext(QWEN_USER_DATA_DIR, options);
|
|
671
|
+
}
|
|
672
|
+
catch (e) {
|
|
673
|
+
throw new Error('qwen: the browser profile is busy (' + e.message + ')');
|
|
674
|
+
}
|
|
675
|
+
if (this._carryCookies && this._carryCookies.length) {
|
|
676
|
+
await this.context.addCookies(this._carryCookies).catch(() => { });
|
|
506
677
|
}
|
|
507
|
-
await this.
|
|
678
|
+
this.page = this.context.pages()[0] || (await this.context.newPage());
|
|
679
|
+
this._windowClosed = false;
|
|
680
|
+
this.page.on('close', () => {
|
|
681
|
+
this._windowClosed = true;
|
|
682
|
+
});
|
|
683
|
+
this.context.on('close', () => {
|
|
684
|
+
this._windowClosed = true;
|
|
685
|
+
});
|
|
686
|
+
this._netHookInstalled = false;
|
|
687
|
+
this._netHookPage = null;
|
|
688
|
+
this._installNetHook();
|
|
689
|
+
}
|
|
690
|
+
async waitForLogin() {
|
|
691
|
+
// An existing session, then the saved credentials, then (Qwen allows a
|
|
692
|
+
// guest, so no error) the guest notice. The visible window and the return
|
|
693
|
+
// to headless are the SHARED interactiveBrowserLogin path.
|
|
694
|
+
const interactive = loginIsInteractive({
|
|
695
|
+
isTTY: !!(process.stdin.isTTY && process.stdout.isTTY),
|
|
696
|
+
oneShot: false,
|
|
697
|
+
scheduled: false,
|
|
698
|
+
env: process.env,
|
|
699
|
+
});
|
|
700
|
+
const outcome = await interactiveBrowserLogin({
|
|
701
|
+
provider: 'Qwen',
|
|
702
|
+
probeLogin: () => this.probeLogin(),
|
|
703
|
+
autoLogin: () => this._autoLogin(),
|
|
704
|
+
relaunch: (h) => this.relaunchSafe(h),
|
|
705
|
+
openLoginPage: () => this._openLoginPage(),
|
|
706
|
+
gotoChat: async () => {
|
|
707
|
+
await this.page
|
|
708
|
+
.goto(CHAT_URL, { waitUntil: 'domcontentloaded', timeout: 30000 })
|
|
709
|
+
.catch(() => { });
|
|
710
|
+
},
|
|
711
|
+
prefill: async () => {
|
|
712
|
+
try {
|
|
713
|
+
if (!this.auth.username)
|
|
714
|
+
return;
|
|
715
|
+
const email = this.page
|
|
716
|
+
.locator('input[name="email"], input[type="email"]')
|
|
717
|
+
.first();
|
|
718
|
+
if ((await email.count().catch(() => 0)) === 0)
|
|
719
|
+
return;
|
|
720
|
+
if (await email.isVisible().catch(() => false)) {
|
|
721
|
+
await email.fill(this.auth.username).catch(() => { });
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
catch { }
|
|
725
|
+
},
|
|
726
|
+
captureCredentials: async () => { },
|
|
727
|
+
persistSession: () => this._persistSessionIfNeeded(),
|
|
728
|
+
notice: (key) => this._notice(this._t(key)),
|
|
729
|
+
isAborted: () => this._abort,
|
|
730
|
+
windowClosed: () => this._windowClosed,
|
|
731
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
732
|
+
now: () => Date.now(),
|
|
733
|
+
interactive,
|
|
734
|
+
stayHeaded: !this.headless,
|
|
735
|
+
// A guest is usable, but a real session is strongly recommended; the
|
|
736
|
+
// notice tells the operator why.
|
|
737
|
+
guestAllowed: true,
|
|
738
|
+
canShowWindow: canShowWindow(process.env, process.platform),
|
|
739
|
+
tuiLogin: this.onLoginPrompt
|
|
740
|
+
? async () => await this._promptAndLogin()
|
|
741
|
+
: undefined,
|
|
742
|
+
timeoutMs: this.loginTimeoutMs,
|
|
743
|
+
});
|
|
744
|
+
if (outcome === 'guest')
|
|
745
|
+
this._notice(this._t('qwen.guest_mode'));
|
|
746
|
+
await this._persistSessionIfNeeded().catch(() => { });
|
|
508
747
|
}
|
|
509
748
|
async _persistSessionIfNeeded() {
|
|
510
749
|
if (!this.auth.saveSession)
|
|
511
750
|
return;
|
|
512
751
|
if (!(await this.isLoggedIn()))
|
|
513
752
|
return;
|
|
514
|
-
|
|
515
|
-
await fs.mkdir(path.dirname(AUTH_MARKER_FILE), { recursive: true });
|
|
516
|
-
await fs.writeFile(AUTH_MARKER_FILE, JSON.stringify({ provider: 'qwen', at: new Date().toISOString() }), 'utf-8');
|
|
517
|
-
}
|
|
518
|
-
catch { }
|
|
753
|
+
await writeAuthMarker('qwen');
|
|
519
754
|
}
|
|
520
755
|
// Best-effort PASSWORD login for the saved credentials. Qwen's default form
|
|
521
756
|
// is the email CODE flow, so this first switches to the password form (the
|
|
@@ -548,8 +783,17 @@ export class QwenBrowser {
|
|
|
548
783
|
await pwd.fill(this.auth.password);
|
|
549
784
|
await this._clickSubmit();
|
|
550
785
|
const ok = await this._waitLoggedIn(20000);
|
|
551
|
-
if (ok)
|
|
786
|
+
if (ok) {
|
|
552
787
|
this._notice(this._t('qwen.login_ok'));
|
|
788
|
+
// Remember the credentials that WORKED, so the next launch signs in
|
|
789
|
+
// silently without a window. The auto-login is the one path that used
|
|
790
|
+
// to skip this, which is why a successful auto sign-in left the
|
|
791
|
+
// config without the password and the NEXT cold start needed a window.
|
|
792
|
+
try {
|
|
793
|
+
this.onAuthSave?.(this.auth.username, this.auth.password);
|
|
794
|
+
}
|
|
795
|
+
catch { }
|
|
796
|
+
}
|
|
553
797
|
return ok;
|
|
554
798
|
}
|
|
555
799
|
catch (e) {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Per-browser SEND RUNTIME: telemetry + the adaptive throttle, wired in ONE
|
|
2
|
+
// place so each provider class only forwards its send events here.
|
|
3
|
+
//
|
|
4
|
+
// WHY a helper instead of per-class code: the three backends (DeepSeek, Qwen,
|
|
5
|
+
// GLM) share the policy but differ in where a send starts and how an error is
|
|
6
|
+
// classified. Keeping the state machine here means one implementation to test
|
|
7
|
+
// and one place to look at when "the agent waits too long" is reported.
|
|
8
|
+
//
|
|
9
|
+
// The state lives ON the browser object (a SendRuntime instance), so it
|
|
10
|
+
// survives across tasks and a provider switch within a session (a new browser
|
|
11
|
+
// object is created, but the operator's decision about the interval does not
|
|
12
|
+
// silently reset mid-session — it is re-derived from this session's events).
|
|
13
|
+
import { DEFAULT_THROTTLE, initialThrottleState, applySendEvent, nextWaitMs, withBlockDuration, } from './send-throttle.js';
|
|
14
|
+
import { appendSendEvent, } from './send-telemetry.js';
|
|
15
|
+
/**
|
|
16
|
+
* The runtime a provider keeps: the learned interval, the last send and the
|
|
17
|
+
* bookkeeping needed to record the outcome of the NEXT send.
|
|
18
|
+
*/
|
|
19
|
+
export class SendRuntime {
|
|
20
|
+
cfg;
|
|
21
|
+
state;
|
|
22
|
+
opts;
|
|
23
|
+
now;
|
|
24
|
+
lastSentAt = 0;
|
|
25
|
+
pending = null;
|
|
26
|
+
constructor(opts) {
|
|
27
|
+
this.opts = opts;
|
|
28
|
+
this.now = opts.now || Date.now;
|
|
29
|
+
this.cfg = {
|
|
30
|
+
baseMs: opts.baseMs,
|
|
31
|
+
rateLimitWaitMs: opts.rateLimitWaitMs,
|
|
32
|
+
...DEFAULT_THROTTLE,
|
|
33
|
+
windowMs: opts.windowMs ?? DEFAULT_THROTTLE.windowMs,
|
|
34
|
+
budget: opts.budget ?? DEFAULT_THROTTLE.budget,
|
|
35
|
+
};
|
|
36
|
+
this.state = initialThrottleState(this.cfg);
|
|
37
|
+
}
|
|
38
|
+
/** The interval currently in force (the adaptive one when enabled). */
|
|
39
|
+
intervalMs() {
|
|
40
|
+
return this.opts.adaptive ? this.state.intervalMs : this.opts.baseMs;
|
|
41
|
+
}
|
|
42
|
+
/** Milliseconds to wait before the next send. */
|
|
43
|
+
waitMs() {
|
|
44
|
+
if (!this.opts.adaptive) {
|
|
45
|
+
const gap = this.opts.baseMs - (this.now() - this.lastSentAt);
|
|
46
|
+
return this.lastSentAt && gap > 0 ? gap : 0;
|
|
47
|
+
}
|
|
48
|
+
return nextWaitMs(this.state, this.lastSentAt, this.now());
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Mark a send as STARTED. `model`/`thinking`/`webSearch` are recorded with
|
|
52
|
+
* the outcome, so a later analysis can correlate a limit with a mode.
|
|
53
|
+
*/
|
|
54
|
+
onSend(info = {}) {
|
|
55
|
+
const waited = this.lastSentAt ? this.now() - this.lastSentAt : 0;
|
|
56
|
+
this.lastSentAt = this.now();
|
|
57
|
+
this.pending = {
|
|
58
|
+
at: new Date(this.lastSentAt).toISOString(),
|
|
59
|
+
provider: this.opts.provider,
|
|
60
|
+
model: info.model,
|
|
61
|
+
thinking: info.thinking,
|
|
62
|
+
webSearch: info.webSearch,
|
|
63
|
+
intervalMs: this.intervalMs(),
|
|
64
|
+
waitedMs: waited,
|
|
65
|
+
outcome: 'ok',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Record the outcome of the send started by onSend(). `errorText` and
|
|
70
|
+
* `retryAfterMs` come from the server and never include the prompt.
|
|
71
|
+
*/
|
|
72
|
+
onOutcome(outcome, extra = {}) {
|
|
73
|
+
if (!this.pending)
|
|
74
|
+
return;
|
|
75
|
+
let e = { ...this.pending, outcome };
|
|
76
|
+
if (extra.errorText)
|
|
77
|
+
e.errorText = String(extra.errorText).slice(0, 300);
|
|
78
|
+
if (typeof extra.retryAfterMs === 'number') {
|
|
79
|
+
e.retryAfterMs = extra.retryAfterMs;
|
|
80
|
+
}
|
|
81
|
+
if (this.opts.adaptive) {
|
|
82
|
+
this.state = applySendEvent(this.state, e, this.cfg, this.now());
|
|
83
|
+
}
|
|
84
|
+
if (outcome === 'rate_limit') {
|
|
85
|
+
e = withBlockDuration(e, this.state.blockedAt, this.now());
|
|
86
|
+
}
|
|
87
|
+
if (this.opts.telemetry) {
|
|
88
|
+
appendSendEvent(this.opts.logsDir, e);
|
|
89
|
+
}
|
|
90
|
+
this.pending = null;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The block lifted (the next send succeeded after a rate limit): the
|
|
94
|
+
* telemetry gets the measured duration.
|
|
95
|
+
*/
|
|
96
|
+
onBlockLifted() {
|
|
97
|
+
if (!this.state.blockedAt)
|
|
98
|
+
return;
|
|
99
|
+
const liftedAt = this.now();
|
|
100
|
+
if (this.opts.telemetry) {
|
|
101
|
+
appendSendEvent(this.opts.logsDir, {
|
|
102
|
+
at: new Date(liftedAt).toISOString(),
|
|
103
|
+
provider: this.opts.provider,
|
|
104
|
+
intervalMs: this.intervalMs(),
|
|
105
|
+
waitedMs: 0,
|
|
106
|
+
outcome: 'ok',
|
|
107
|
+
blockMs: liftedAt - this.state.blockedAt,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
this.state = { ...this.state, blocked: false, blockedAt: 0 };
|
|
111
|
+
}
|
|
112
|
+
}
|