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.
@@ -0,0 +1,1766 @@
1
+ // GLM (chat.z.ai) provider — the third chat backend zames can drive.
2
+ //
3
+ // WHY a separate class (the same reason QwenBrowser exists): the backends
4
+ // differ in EVERY layer we touch — selectors, the send flow, the SSE format and
5
+ // the chat-id scheme. The shared surface is the ChatProvider interface
6
+ // (types.ts); the agent loop only needs BrowserLike and never sees either
7
+ // concrete class.
8
+ //
9
+ // Verified against a LIVE signed-in session (headless + headed Chromium):
10
+ // input -> textarea#chat-input
11
+ // send -> button#send-message-button (POST /api/v1/chats/new, then
12
+ // POST /api/v2/chat/completions?... SSE)
13
+ // answer -> SSE chat:completion chunks with phase === 'answer'
14
+ // model -> button#model-selector-<id>-button
15
+ // A FIRST message from a fresh profile is gated by an Aliyun slider captcha,
16
+ // which a headless browser cannot pass — hence the browser-based login
17
+ // (src/browser-login.ts) and the explicit captcha error below.
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 { awaitUploadPreview, readAttachBuffers } from './attach-flow.js';
25
+ import { theme } from './theme.js';
26
+ import { scrapeGlmChatList, scrapeGlmChatMessages, parseGlmChatList, parseGlmHistory, } from './glm-chats.js';
27
+ import { cleanAnswer, headlessEngineKey, readCachedHeadlessUA, writeCachedHeadlessUA, sanitizeHeadlessUA, cleanSingletonFiles, profileLooksLocked, nukeProfile, writeAuthMarker, } from './browser-pure.js';
28
+ import { readLastAnswerInDom, readPageToastsInDom, readUserAgentInDom, readInputTrimmedTextInDom, isElementEnabledInDom, setBusyTitleInDom, chatSignalInDom, } from './browser-dom.js';
29
+ import { probeUiHealth, } from './ui-health.js';
30
+ import { CHAT_URL, LOGIN_URL, CHAT_ID_RE, chatUrl, INPUT_SELECTORS, UPLOAD_BUTTON_SELECTOR, SEND_SELECTORS, STOP_SELECTORS, STOP_NAME_RE, NEW_CHAT_SELECTOR, MODELS_URL, MODEL_TRIGGER_SELECTOR, modelIdFromTriggerId, CHAT_LIST_URL, chatHistoryUrl, AUTH_URL, ANSWER_URL_RE, ANSWER_SELECTORS, THINK_CLASS_RE, MESSAGE_CONTAINER_SELECTORS, USER_CLASS_RE, CHAT_SIGNAL_NODES_SELECTOR, MARKDOWN_SELECTOR, } from './glm-ui.js';
31
+ import { extractAnswer, extractTokenUsage, isFinishedWithoutAnswer, isRateLimitText, isQuotaExhaustedText, isServerBusyText, isGuestLimitText, isCaptchaText, extractChatId, } from './glm-net-capture.js';
32
+ import { SendRuntime } from './send-runtime.js';
33
+ import { readGlmBodyFeatures, unsafeGlmModes, glmFlagsMismatch, } from './glm-flags.js';
34
+ import { interactiveBrowserLogin, loginIsInteractive, canShowWindow, } from './browser-login.js';
35
+ export const GLM_USER_DATA_DIR = path.join(os.homedir(), '.zames', 'glm-profile');
36
+ const MAX_NET_BODY = 64 * 1024;
37
+ function capNetBody(body) {
38
+ if (body.length <= MAX_NET_BODY)
39
+ return body;
40
+ const head = MAX_NET_BODY - 1024;
41
+ return body.slice(0, head) + '\n...[truncated]...\n' + body.slice(-1024);
42
+ }
43
+ export class GlmBrowser {
44
+ providerName = 'glm';
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
+ model;
62
+ deepThinking;
63
+ webSearch;
64
+ auth;
65
+ locale;
66
+ onAuthSave;
67
+ onLoginPrompt;
68
+ loginTimeoutMs;
69
+ maxAfterToolRetries;
70
+ askDeadlineMs;
71
+ _lastSentAt;
72
+ _abort;
73
+ _askCancelled;
74
+ _askInFlight;
75
+ _stopped;
76
+ context;
77
+ page;
78
+ _netCapture;
79
+ _netCaptureAt;
80
+ _netNoAnswer;
81
+ _netRateLimited;
82
+ _netGuestLimit;
83
+ _netServerBusy;
84
+ _netCaptcha;
85
+ _netChatId;
86
+ _lastTokenUsage;
87
+ _toggles;
88
+ _apiAuth;
89
+ _lastHistoryError;
90
+ _netBody;
91
+ _netHookInstalled;
92
+ _netHookPage;
93
+ /** The features of the LAST completion request the page sent. */
94
+ _lastBodyFeatures;
95
+ _windowClosed;
96
+ /** Cookies captured at close time when a session cookie has no expiry. */
97
+ _carryCookies;
98
+ onSendStart;
99
+ onSendPause;
100
+ onSendState;
101
+ onNotice;
102
+ userAgent;
103
+ /** Telemetry + adaptive throttle state (see src/send-runtime.ts). */
104
+ sendRuntime;
105
+ /** Rewrite the request to disable z.ai's own agent runtime (see options). */
106
+ disableAgentRuntime;
107
+ /**
108
+ * `reasoning_effort` sent with each request ('low' | 'high' | 'max').
109
+ * z.ai defaults to 'max', which makes EVERY turn deliberate at length —
110
+ * a measured 8-14 s for the system-prompt alone and minutes for a task.
111
+ * 'low' keeps thinking on (it cannot be disabled for these models) but
112
+ * removes most of the wait. Empty = leave the site's default.
113
+ */
114
+ reasoningEffort;
115
+ constructor({ headless = false, debug = false, channel = null, answerTimeoutMs = 300000, askRetries = 3, stabilityChecks = 2, stabilityDelayMs = 400, minSendIntervalMs = 0, rateLimitWaitMs = 300000, maxRateLimitRetries = 6, maxServerBusyRetries = 5, serverBusyWaitMs = 3000, maxIncompleteRetries = 4, incompleteWaitMs = 2000, startWaitMs = 30000, sendButtonWaitMs = 8000, model = '', deepThinking = false, webSearch = false, auth, locale, onAuthSave, onLoginPrompt, disableAgentRuntime = true, reasoningEffort = 'low', sendTelemetry = true, logsDir, loginTimeoutMs = 600000, userAgent, } = {}) {
116
+ this.headless = headless;
117
+ this.debug = debug;
118
+ this.channel = channel;
119
+ this.answerTimeoutMs = answerTimeoutMs;
120
+ this.askRetries = askRetries;
121
+ this.stabilityChecks = stabilityChecks;
122
+ this.stabilityDelayMs = stabilityDelayMs;
123
+ this.minSendIntervalMs = minSendIntervalMs;
124
+ this.rateLimitWaitMs = rateLimitWaitMs;
125
+ this.maxRateLimitRetries = maxRateLimitRetries;
126
+ this.maxServerBusyRetries = maxServerBusyRetries;
127
+ this.serverBusyWaitMs = serverBusyWaitMs;
128
+ this.maxIncompleteRetries = maxIncompleteRetries;
129
+ this.incompleteWaitMs = incompleteWaitMs;
130
+ this.startWaitMs = startWaitMs;
131
+ this.sendButtonWaitMs = sendButtonWaitMs;
132
+ this.model = model;
133
+ // The flagship models cannot skip thinking (skip_think=false in the
134
+ // catalogue), so the provider always reports it ON: the 🧠 icon then
135
+ // tells the truth and /thinking cannot pretend to disable it.
136
+ this.deepThinking = true;
137
+ this.webSearch = webSearch;
138
+ this.auth = {
139
+ username: (auth?.username || '').trim(),
140
+ password: auth?.password || '',
141
+ saveSession: auth?.saveSession !== false,
142
+ };
143
+ this.locale = locale || DEFAULT_LOCALE;
144
+ this.onAuthSave = onAuthSave || null;
145
+ this.onLoginPrompt = onLoginPrompt || null;
146
+ this.loginTimeoutMs = loginTimeoutMs;
147
+ this.maxAfterToolRetries = 6;
148
+ this.askDeadlineMs = answerTimeoutMs + 60000;
149
+ this._lastSentAt = 0;
150
+ this._abort = false;
151
+ this._askCancelled = false;
152
+ this._askInFlight = 0;
153
+ this._stopped = false;
154
+ this._netCapture = '';
155
+ this._netCaptureAt = 0;
156
+ this._netNoAnswer = false;
157
+ this._netRateLimited = false;
158
+ this._netGuestLimit = false;
159
+ this._netServerBusy = false;
160
+ this._netCaptcha = false;
161
+ this._netChatId = null;
162
+ this._lastTokenUsage = null;
163
+ this._toggles = { deepThinking, webSearch };
164
+ this._apiAuth = '';
165
+ this._lastHistoryError = '';
166
+ this._netBody = '';
167
+ this._netHookInstalled = false;
168
+ this._netHookPage = null;
169
+ this._lastBodyFeatures = null;
170
+ this._windowClosed = false;
171
+ this._carryCookies = null;
172
+ this.onSendStart = null;
173
+ this.onSendPause = null;
174
+ this.onSendState = null;
175
+ this.onNotice = null;
176
+ this.userAgent = userAgent || '';
177
+ // The adaptive throttle is ON for GLM: its own rate limit was not measured
178
+ // (a guest is gated by a captcha first), so a fixed 0 pause plus a LEARNED
179
+ // back-off beats a guess. The telemetry is what the operator analyses
180
+ // later to pick the real values.
181
+ this.disableAgentRuntime = disableAgentRuntime;
182
+ this.reasoningEffort = reasoningEffort;
183
+ this.sendRuntime = new SendRuntime({
184
+ provider: 'glm',
185
+ baseMs: minSendIntervalMs,
186
+ rateLimitWaitMs,
187
+ adaptive: true,
188
+ telemetry: sendTelemetry,
189
+ logsDir: logsDir || path.join(os.homedir(), '.zames', 'logs'),
190
+ });
191
+ }
192
+ _t(key, params) {
193
+ return translate(this.locale)(key, { provider: 'GLM', ...params });
194
+ }
195
+ setLocale(locale) {
196
+ if (locale)
197
+ this.locale = locale;
198
+ }
199
+ _notice(text) {
200
+ if (this.onNotice) {
201
+ try {
202
+ this.onNotice(text);
203
+ return;
204
+ }
205
+ catch { }
206
+ }
207
+ console.error(text);
208
+ }
209
+ _emitSendState(state) {
210
+ if (this.onSendState) {
211
+ try {
212
+ this.onSendState(state);
213
+ }
214
+ catch { }
215
+ }
216
+ }
217
+ async _setBusyTitle(busy) {
218
+ try {
219
+ await this.page.evaluate(setBusyTitleInDom, busy);
220
+ }
221
+ catch { }
222
+ }
223
+ sendIntervalMs() {
224
+ return this.sendRuntime.intervalMs();
225
+ }
226
+ // ---------- lifecycle ----------
227
+ async launch() {
228
+ if (await profileLooksLocked(GLM_USER_DATA_DIR)) {
229
+ if (this.debug)
230
+ console.error('glm profile: removing Singleton files');
231
+ await cleanSingletonFiles(GLM_USER_DATA_DIR);
232
+ }
233
+ if (this.headless && !this.userAgent) {
234
+ const cached = await readCachedHeadlessUA(headlessEngineKey());
235
+ if (cached)
236
+ this.userAgent = cached;
237
+ }
238
+ await this._launchOnce();
239
+ await this._fixHeadlessUserAgent();
240
+ }
241
+ async _fixHeadlessUserAgent() {
242
+ if (!this.headless)
243
+ return;
244
+ if (this.userAgent)
245
+ return;
246
+ try {
247
+ const ua = await this.page.evaluate(readUserAgentInDom);
248
+ const fixed = sanitizeHeadlessUA(ua);
249
+ if (fixed === ua)
250
+ return;
251
+ this.userAgent = fixed;
252
+ void writeCachedHeadlessUA(headlessEngineKey(), fixed);
253
+ if (this.debug)
254
+ console.error('glm profile: headless UA -> relaunching plain Chrome UA');
255
+ await this.context.close().catch(() => { });
256
+ await this._launchOnce();
257
+ }
258
+ catch (e) {
259
+ if (this.debug)
260
+ console.error('glm profile: UA fix failed:', e.message);
261
+ }
262
+ }
263
+ async _launchOnce() {
264
+ const options = {
265
+ headless: this.headless,
266
+ slowMo: 10,
267
+ args: ['--disable-blink-features=AutomationControlled'],
268
+ };
269
+ if (this.channel)
270
+ options.channel = this.channel;
271
+ if (this.userAgent)
272
+ options.userAgent = this.userAgent;
273
+ try {
274
+ this.context = await chromium.launchPersistentContext(GLM_USER_DATA_DIR, options);
275
+ }
276
+ catch (e) {
277
+ if (/profile.*in use|has been closed/i.test(e.message)) {
278
+ await cleanSingletonFiles(GLM_USER_DATA_DIR);
279
+ try {
280
+ this.context = await chromium.launchPersistentContext(GLM_USER_DATA_DIR, options);
281
+ }
282
+ catch {
283
+ await nukeProfile(GLM_USER_DATA_DIR);
284
+ this.context = await chromium.launchPersistentContext(GLM_USER_DATA_DIR, options);
285
+ }
286
+ }
287
+ else {
288
+ throw e;
289
+ }
290
+ }
291
+ // Session cookies that have NO expiry are dropped when the context closes;
292
+ // re-apply them BEFORE the first goto, or the SPA answers 401 and may wipe
293
+ // its own token.
294
+ if (this._carryCookies && this._carryCookies.length) {
295
+ await this.context
296
+ .addCookies(this._carryCookies)
297
+ .catch(() => { });
298
+ }
299
+ this.page = this.context.pages()[0] || (await this.context.newPage());
300
+ this._windowClosed = false;
301
+ this.page.on('close', () => {
302
+ this._windowClosed = true;
303
+ });
304
+ this.context.on('close', () => {
305
+ this._windowClosed = true;
306
+ });
307
+ this._installNetHook();
308
+ await this.page
309
+ .goto(CHAT_URL, { waitUntil: 'domcontentloaded' })
310
+ .catch(() => { });
311
+ }
312
+ async restart() {
313
+ try {
314
+ if (this.context)
315
+ await this.context.close();
316
+ }
317
+ catch { }
318
+ await this._launchOnce();
319
+ await this._fixHeadlessUserAgent();
320
+ }
321
+ /**
322
+ * Relaunch the SAME persistent profile in another headless mode — the
323
+ * dedicated path the login flow needs (NOT _launchOnce: it must not clean
324
+ * Singleton files or nuke the profile, and it must wait for the previous
325
+ * context to release its lock).
326
+ */
327
+ async relaunchSafe(headless) {
328
+ // Session cookies (no `expires`) live only in memory: grab them before the
329
+ // context closes so the new one starts signed in.
330
+ try {
331
+ const cookies = await this.context.cookies();
332
+ const session = cookies.filter((c) => c.expires === -1 || c.expires < 0);
333
+ if (session.length) {
334
+ this._carryCookies = session.map((c) => ({
335
+ name: c.name,
336
+ value: c.value,
337
+ domain: c.domain,
338
+ path: c.path,
339
+ httpOnly: c.httpOnly,
340
+ secure: c.secure,
341
+ sameSite: c.sameSite,
342
+ }));
343
+ }
344
+ }
345
+ catch { }
346
+ try {
347
+ if (this.context)
348
+ await this.context.close();
349
+ }
350
+ catch { }
351
+ await this._waitProfileUnlocked(15_000);
352
+ this.headless = headless;
353
+ const options = {
354
+ headless,
355
+ slowMo: 10,
356
+ args: ['--disable-blink-features=AutomationControlled'],
357
+ };
358
+ if (this.channel)
359
+ options.channel = this.channel;
360
+ if (this.userAgent)
361
+ options.userAgent = this.userAgent;
362
+ try {
363
+ this.context = await chromium.launchPersistentContext(GLM_USER_DATA_DIR, options);
364
+ }
365
+ catch (e) {
366
+ throw new Error('glm: the browser profile is busy (' + e.message + ')');
367
+ }
368
+ if (this._carryCookies && this._carryCookies.length) {
369
+ await this.context
370
+ .addCookies(this._carryCookies)
371
+ .catch(() => { });
372
+ }
373
+ this.page = this.context.pages()[0] || (await this.context.newPage());
374
+ this._windowClosed = false;
375
+ this.page.on('close', () => {
376
+ this._windowClosed = true;
377
+ });
378
+ this.context.on('close', () => {
379
+ this._windowClosed = true;
380
+ });
381
+ this._netHookInstalled = false;
382
+ this._netHookPage = null;
383
+ this._installNetHook();
384
+ }
385
+ /** Poll until the profile's SingletonLock is gone (a timeout is an error). */
386
+ async _waitProfileUnlocked(timeoutMs) {
387
+ const deadline = Date.now() + timeoutMs;
388
+ while (Date.now() < deadline) {
389
+ if (!(await profileLooksLocked(GLM_USER_DATA_DIR)))
390
+ return;
391
+ await new Promise((r) => setTimeout(r, 200));
392
+ }
393
+ throw new Error('glm: the browser profile is still locked');
394
+ }
395
+ // ---------- login ----------
396
+ /**
397
+ * A FAST login probe with NO long waits (the old isLoggedIn polled up to
398
+ * 20 s, which is a false "logged out" on a slow network). The signal is the
399
+ * API's own answer — a bearer token in localStorage plus GET /api/v1/auths/
400
+ * — and the DOM is only a fallback, because a GUEST also sees the input.
401
+ */
402
+ async probeLogin() {
403
+ if (!this.page)
404
+ return 'unknown';
405
+ try {
406
+ const r = await this.page.evaluate(async (authUrl) => {
407
+ const hasToken = (() => {
408
+ try {
409
+ return !!localStorage.getItem('token');
410
+ }
411
+ catch {
412
+ return false;
413
+ }
414
+ })();
415
+ let role = null;
416
+ let api = false;
417
+ try {
418
+ const res = await fetch(authUrl, {
419
+ credentials: 'include',
420
+ headers: { accept: 'application/json' },
421
+ });
422
+ api = res.ok;
423
+ const j = await res.json();
424
+ role = typeof j?.role === 'string' ? j.role : null;
425
+ }
426
+ catch { }
427
+ const bodyText = document.body ? document.body.innerText : '';
428
+ const captcha = /security verification|drag the slider/i.test(bodyText);
429
+ const signInVisible = /\bsign in\b/i.test(bodyText) && !hasToken;
430
+ return { hasToken, role, api, captcha, signInVisible };
431
+ }, AUTH_URL);
432
+ if (r.captcha)
433
+ return 'challenge';
434
+ const role = String(r.role || '').toLowerCase();
435
+ if (role === 'user' || role === 'admin')
436
+ return 'in';
437
+ if (role === 'guest')
438
+ return r.hasToken ? 'out' : 'out';
439
+ if (r.hasToken && r.api)
440
+ return 'in';
441
+ if (r.signInVisible)
442
+ return 'out';
443
+ return r.api ? 'out' : 'unknown';
444
+ }
445
+ catch {
446
+ return 'unknown';
447
+ }
448
+ }
449
+ /** Compatibility wrapper: is the session active right now? */
450
+ async isLoggedIn() {
451
+ return (await this.probeLogin()) === 'in';
452
+ }
453
+ async _openLoginPage() {
454
+ try {
455
+ if (!/\/auth(\b|$|\?)/i.test(this.page.url())) {
456
+ await this.page
457
+ .goto(LOGIN_URL, { waitUntil: 'domcontentloaded', timeout: 30000 })
458
+ .catch(() => { });
459
+ }
460
+ await this.page.waitForTimeout(1500);
461
+ // The email form lives behind "Continue with Email".
462
+ await this.page
463
+ .getByRole('button', { name: /continue\s*with\s*email/i })
464
+ .first()
465
+ .click({ timeout: 3000 })
466
+ .catch(() => { });
467
+ await this.page.waitForTimeout(1500);
468
+ }
469
+ catch { }
470
+ }
471
+ /** Prefill the SAVED login (never the password — that is filled on submit). */
472
+ async _prefillLogin() {
473
+ try {
474
+ const saved = this.auth.username;
475
+ if (!saved)
476
+ return;
477
+ const email = this.page.locator('input[type="email"]').first();
478
+ if ((await email.count().catch(() => 0)) === 0)
479
+ return;
480
+ if (await email.isVisible().catch(() => false)) {
481
+ await email.fill(saved, { timeout: 2000 }).catch(() => { });
482
+ }
483
+ }
484
+ catch { }
485
+ }
486
+ /**
487
+ * Best-effort password login with the SAVED credentials. z.ai's form is
488
+ * email + password on /auth; a captcha may still block the submit, in which
489
+ * case the caller falls through to the browser window.
490
+ */
491
+ async _autoLogin() {
492
+ if (!this.auth.username || !this.auth.password)
493
+ return false;
494
+ try {
495
+ await this._openLoginPage();
496
+ const email = this.page.locator('input[type="email"]').first();
497
+ await email.waitFor({ state: 'visible', timeout: 8000 });
498
+ await email.fill(this.auth.username);
499
+ const pwd = this.page.locator('input[type="password"]').first();
500
+ if ((await pwd.count().catch(() => 0)) === 0)
501
+ return false;
502
+ await pwd.fill(this.auth.password);
503
+ await this._clickSignIn();
504
+ const ok = await this._waitLoggedIn(20000);
505
+ if (ok) {
506
+ this._notice(this._t('glm.login_ok'));
507
+ // Remember the credentials that WORKED, so the next launch signs in
508
+ // silently without a window. The auto-login is the one path that used
509
+ // to skip this, which is why a successful auto sign-in left the
510
+ // config without the password and the NEXT cold start needed a window.
511
+ try {
512
+ this.onAuthSave?.(this.auth.username, this.auth.password);
513
+ }
514
+ catch { }
515
+ }
516
+ return ok;
517
+ }
518
+ catch (e) {
519
+ if (this.debug)
520
+ console.error('glm auto-login failed:', e.message);
521
+ return false;
522
+ }
523
+ }
524
+ async _clickSignIn() {
525
+ const btn = this.page
526
+ .getByRole('button', { name: /^\s*sign in\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
+ async _waitLoggedIn(timeoutMs) {
537
+ const deadline = Date.now() + timeoutMs;
538
+ while (Date.now() < deadline) {
539
+ if ((await this.probeLogin()) === 'in')
540
+ return true;
541
+ await this.page.waitForTimeout(800);
542
+ }
543
+ return false;
544
+ }
545
+ /**
546
+ * Hook the login form so the credentials the OPERATOR types in the window
547
+ * reach Node (and then the config) — that is what makes the NEXT launch a
548
+ * silent auto-login. Registered through page.exposeFunction, so the page
549
+ * calls back into Node on submit.
550
+ */
551
+ async _captureLoginCredentials() {
552
+ try {
553
+ await this.page.exposeFunction('__zamesGlmCreds', (email, password) => {
554
+ const u = String(email || '').trim();
555
+ const p = String(password || '');
556
+ if (!u)
557
+ return;
558
+ this.auth.username = u;
559
+ if (p)
560
+ this.auth.password = p;
561
+ try {
562
+ this.onAuthSave?.(u, p);
563
+ }
564
+ catch { }
565
+ });
566
+ await this.page.evaluate(() => {
567
+ const w = window;
568
+ if (w.__zamesGlmHooked)
569
+ return;
570
+ w.__zamesGlmHooked = true;
571
+ document.addEventListener('submit', (ev) => {
572
+ try {
573
+ const form = ev.target;
574
+ if (!form)
575
+ return;
576
+ const email = form.querySelector('input[type="email"]');
577
+ const pwd = form.querySelector('input[type="password"]');
578
+ const fn = window.__zamesGlmCreds;
579
+ if (email && fn)
580
+ fn(email.value || '', pwd ? pwd.value || '' : '');
581
+ }
582
+ catch { }
583
+ }, true);
584
+ });
585
+ }
586
+ catch { }
587
+ }
588
+ async _persistSessionIfNeeded() {
589
+ if (!this.auth.saveSession)
590
+ return;
591
+ if ((await this.probeLogin()) !== 'in')
592
+ return;
593
+ await writeAuthMarker('glm');
594
+ }
595
+ /**
596
+ * Sign in, in this order: an existing session, the saved credentials, the
597
+ * browser window (a human solves the captcha there), then back to headless.
598
+ * The decision sequence is the SHARED interactiveBrowserLogin.
599
+ */
600
+ async waitForLogin() {
601
+ const interactive = loginIsInteractive({
602
+ isTTY: !!process.stdin.isTTY,
603
+ oneShot: false,
604
+ scheduled: false,
605
+ env: process.env,
606
+ });
607
+ const showWindow = canShowWindow(process.env, process.platform);
608
+ await interactiveBrowserLogin({
609
+ provider: 'GLM',
610
+ probeLogin: () => this.probeLogin(),
611
+ autoLogin: () => this._autoLogin(),
612
+ relaunch: (h) => this.relaunchSafe(h),
613
+ openLoginPage: () => this._openLoginPage(),
614
+ gotoChat: async () => {
615
+ await this.page
616
+ .goto(CHAT_URL, { waitUntil: 'domcontentloaded', timeout: 30000 })
617
+ .catch(() => { });
618
+ },
619
+ prefill: () => this._prefillLogin(),
620
+ captureCredentials: () => this._captureLoginCredentials(),
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: false,
630
+ canShowWindow: showWindow,
631
+ tuiLogin: this.onLoginPrompt
632
+ ? async () => {
633
+ const res = await this.onLoginPrompt?.({
634
+ savedUsername: this.auth.username,
635
+ hasSavedPassword: !!this.auth.password,
636
+ });
637
+ if (!res || typeof res !== 'object')
638
+ return false;
639
+ this.auth.username = res.username.trim();
640
+ this.auth.password = res.password;
641
+ const ok = await this._autoLogin();
642
+ if (ok)
643
+ this.onAuthSave?.(this.auth.username, this.auth.password);
644
+ return ok;
645
+ }
646
+ : undefined,
647
+ timeoutMs: this.loginTimeoutMs,
648
+ });
649
+ await this._persistSessionIfNeeded().catch(() => { });
650
+ }
651
+ async close() {
652
+ // A session cookie (expires = -1) is dropped when the context closes, so a
653
+ // sign-in would not survive the process. Re-add it as a PERSISTENT cookie
654
+ // before closing: the token then lives in the profile and the next launch
655
+ // is signed in without a window.
656
+ try {
657
+ if (this.context) {
658
+ const cookies = await this.context.cookies();
659
+ const session = cookies.filter((c) => c.expires === -1 || c.expires < 0);
660
+ if (session.length) {
661
+ const keepUntil = Math.floor((Date.now() + 15552000000) / 1000);
662
+ await this.context.addCookies(session.map((c) => ({
663
+ name: c.name,
664
+ value: c.value,
665
+ domain: c.domain,
666
+ path: c.path,
667
+ httpOnly: c.httpOnly,
668
+ secure: c.secure,
669
+ sameSite: c.sameSite,
670
+ expires: keepUntil,
671
+ })));
672
+ }
673
+ }
674
+ }
675
+ catch { }
676
+ try {
677
+ if (this.context)
678
+ await this.context.close();
679
+ }
680
+ catch { }
681
+ }
682
+ /**
683
+ * Rewrite the completion REQUEST so z.ai's own agent runtime stays off.
684
+ * The site builds the body with `features.preview_mode:true` and
685
+ * `flags:['general_agent']`; with them the model's tool calls are executed
686
+ * INSIDE z.ai's sandbox and never reach zames. Playwright can rewrite the
687
+ * post body before it is sent, which is the only lever we have (no UI
688
+ * control exists for those flags).
689
+ */
690
+ async _installRuntimeRewrite() {
691
+ if (!this.disableAgentRuntime)
692
+ return;
693
+ try {
694
+ // A PREDICATE (not an anchored regex): the live URL carries
695
+ // ?timestamp=…&requestId=…, which an end-anchored pattern misses.
696
+ await this.page.route((url) => url.pathname.includes('/api/v2/chat/completions'), async (route) => {
697
+ try {
698
+ const body = JSON.parse(route.request().postData() || '{}');
699
+ const f = body.features;
700
+ if (f && typeof f === 'object') {
701
+ f.preview_mode = false;
702
+ if (Array.isArray(f.flags)) {
703
+ f.flags = f.flags.filter((x) => x !== 'general_agent');
704
+ }
705
+ // z.ai starts its OWN web search even when the request says
706
+ // `web_search:false` (measured: 260+ search iterations on a plain
707
+ // file task). Each call burns account credits and minutes, and the
708
+ // busy UI then refuses the next send ("send button not found").
709
+ // The model does not need the web to read or edit local files, so
710
+ // the flags are forced off on the way out.
711
+ f.web_search = false;
712
+ f.auto_web_search = false;
713
+ f.vlm_web_search_enable = false;
714
+ // `reasoning_effort: 'max'` makes every turn THINK as long as it
715
+ // can: a 19 KB system-prompt took 8-14 s, and a real task 3+ min.
716
+ // The catalogue offers low/high/max; `low` keeps thinking on
717
+ // (it cannot be disabled) but cuts the deliberation time a lot.
718
+ // Configurable, because a hard debugging task may want `max`.
719
+ // The field lives at features.reasoning_effort, but the body
720
+ // ALSO carries a top-level `reasoning_effort` on some builds -
721
+ // set both so the site cannot fall back to its 'max' default.
722
+ if (this.reasoningEffort) {
723
+ f.reasoning_effort = this.reasoningEffort;
724
+ if (typeof body.reasoning_effort === 'string') {
725
+ body.reasoning_effort = this.reasoningEffort;
726
+ }
727
+ }
728
+ }
729
+ // The body that ACTUALLY goes out - the only honest source for
730
+ // _verifyModes (the `request` event sees the pre-rewrite body).
731
+ this._lastBodyFeatures = readGlmBodyFeatures(body);
732
+ if (this.debug) {
733
+ console.error('glm route: web_search=' +
734
+ String(f && f.web_search) +
735
+ ' auto=' +
736
+ String(f && f.auto_web_search) +
737
+ ' effort=' +
738
+ String(f && f.reasoning_effort) +
739
+ ' flags=' +
740
+ JSON.stringify((f && f.flags) || []));
741
+ }
742
+ await route.continue({ postData: JSON.stringify(body) });
743
+ }
744
+ catch {
745
+ await route.continue();
746
+ }
747
+ });
748
+ }
749
+ catch { }
750
+ }
751
+ // ---------- network capture ----------
752
+ _installNetHook() {
753
+ const pg = this.page;
754
+ if (!pg)
755
+ return;
756
+ if (this._netHookInstalled && this._netHookPage === pg)
757
+ return;
758
+ this._netHookInstalled = true;
759
+ this._netHookPage = pg;
760
+ pg.on('response', (resp) => {
761
+ void this._onResponse(resp).catch(() => { });
762
+ });
763
+ // Neutralise z.ai's own agent runtime BEFORE the first send.
764
+ void this._installRuntimeRewrite();
765
+ pg.on('request', (req) => {
766
+ try {
767
+ const url = req.url();
768
+ if (!url.includes('chat.z.ai/api/'))
769
+ return;
770
+ const h = req.headers();
771
+ if (h['authorization'])
772
+ this._apiAuth = h['authorization'];
773
+ // Remember what the site actually sent: the mode flags are decided
774
+ // by ITS UI state, so this is the ground truth for _verifyModes.
775
+ // (The features are recorded by _installRuntimeRewrite's route handler:
776
+ // the `request` event still carries the ORIGINAL body.)
777
+ }
778
+ catch { }
779
+ });
780
+ }
781
+ async _onResponse(resp) {
782
+ try {
783
+ const url = resp.url();
784
+ if (!/chat\.z\.ai/.test(url))
785
+ return;
786
+ if (/\.(js|css|png|jpg|jpeg|svg|woff2?|ico|map|webp)(\?|$)/i.test(url))
787
+ return;
788
+ const ct = (resp.headers()['content-type'] || '').toLowerCase();
789
+ const interesting = ct.includes('event-stream') ||
790
+ ct.includes('json') ||
791
+ ct.includes('text/plain') ||
792
+ ct.includes('text/html');
793
+ if (!interesting)
794
+ return;
795
+ const body = await resp.text().catch(() => '');
796
+ if (!body)
797
+ return;
798
+ const usage = extractTokenUsage(body);
799
+ if (usage !== null)
800
+ this._lastTokenUsage = usage;
801
+ if (isCaptchaText(body)) {
802
+ this._netCaptcha = true;
803
+ this._netBody = capNetBody(body);
804
+ this._netCaptureAt = Date.now();
805
+ }
806
+ if (isGuestLimitText(body)) {
807
+ this._netGuestLimit = true;
808
+ this._netBody = capNetBody(body);
809
+ this._netCaptureAt = Date.now();
810
+ }
811
+ // Track the current chat id as soon as the API reveals it (the URL may
812
+ // lag behind a fresh chat).
813
+ if (/\/api\/v1\/chats(\/new)?(\?|$)/i.test(url)) {
814
+ const cid = extractChatId(body);
815
+ if (cid)
816
+ this._netChatId = cid;
817
+ }
818
+ if (!ANSWER_URL_RE.test(url))
819
+ return;
820
+ const extracted = extractAnswer(body);
821
+ if (extracted) {
822
+ this._netCapture = extracted;
823
+ this._netNoAnswer = false;
824
+ this._netCaptureAt = Date.now();
825
+ }
826
+ if (this._askInFlight === 0) {
827
+ this._lastSentAt = Date.now();
828
+ }
829
+ this._netBody = capNetBody(body);
830
+ const cid = extractChatId(body);
831
+ if (cid)
832
+ this._netChatId = cid;
833
+ if (!extracted) {
834
+ if (isFinishedWithoutAnswer(body)) {
835
+ this._netNoAnswer = true;
836
+ this._netCapture = '';
837
+ }
838
+ if (isRateLimitText(body)) {
839
+ this._netRateLimited = true;
840
+ this._netCapture = '';
841
+ }
842
+ if (isServerBusyText(body)) {
843
+ this._netServerBusy = true;
844
+ }
845
+ this._netCaptureAt = Date.now();
846
+ }
847
+ }
848
+ catch { }
849
+ }
850
+ // ---------- chat management ----------
851
+ async newChat() {
852
+ const before = await this.getCurrentChatId();
853
+ const btn = this.page.locator(NEW_CHAT_SELECTOR).first();
854
+ let clicked = false;
855
+ try {
856
+ await btn.click({ timeout: 2500 });
857
+ clicked = true;
858
+ }
859
+ catch { }
860
+ let ok = false;
861
+ if (clicked) {
862
+ const deadline = Date.now() + 5000;
863
+ while (Date.now() < deadline) {
864
+ const now = await this.getCurrentChatId();
865
+ if (now !== before) {
866
+ ok = true;
867
+ break;
868
+ }
869
+ await this.page.waitForTimeout(100);
870
+ }
871
+ }
872
+ if (!ok) {
873
+ await this.page.goto(CHAT_URL, { waitUntil: 'domcontentloaded' });
874
+ await this._awaitInput(5000);
875
+ }
876
+ this._netChatId = null;
877
+ this._lastTokenUsage = null;
878
+ }
879
+ async _awaitInput(timeoutMs = 5000) {
880
+ const deadline = Date.now() + timeoutMs;
881
+ while (Date.now() < deadline) {
882
+ const input = await this._findVisible(INPUT_SELECTORS, 500);
883
+ if (input)
884
+ return true;
885
+ await this.page.waitForTimeout(150);
886
+ }
887
+ return false;
888
+ }
889
+ async _findVisible(selectors, timeout = 1000) {
890
+ for (const sel of selectors) {
891
+ try {
892
+ const loc = this.page.locator(sel).last();
893
+ if ((await loc.count()) === 0)
894
+ continue;
895
+ if (await loc.isVisible({ timeout }).catch(() => false))
896
+ return loc;
897
+ }
898
+ catch { }
899
+ }
900
+ return null;
901
+ }
902
+ async listChats(limit = 30) {
903
+ if (!this.page)
904
+ return [];
905
+ try {
906
+ const res = await this.page.evaluate(async (opts) => {
907
+ try {
908
+ const headers = {
909
+ accept: 'application/json',
910
+ };
911
+ if (opts.auth)
912
+ headers['authorization'] = opts.auth;
913
+ const r = await fetch(opts.url, {
914
+ credentials: 'include',
915
+ headers,
916
+ });
917
+ if (!r.ok)
918
+ return { error: 'HTTP ' + r.status, data: null };
919
+ return { error: '', data: await r.json() };
920
+ }
921
+ catch (e) {
922
+ return { error: e.message, data: null };
923
+ }
924
+ }, { url: CHAT_LIST_URL, auth: this._apiAuth });
925
+ if (!res?.error) {
926
+ const list = parseGlmChatList(res.data).slice(0, limit);
927
+ if (list.length)
928
+ return list;
929
+ }
930
+ else {
931
+ this._lastHistoryError = res.error;
932
+ }
933
+ }
934
+ catch { }
935
+ try {
936
+ return await this.page.evaluate(scrapeGlmChatList, limit);
937
+ }
938
+ catch {
939
+ return [];
940
+ }
941
+ }
942
+ async openChat(id) {
943
+ try {
944
+ await this.page.goto(chatUrl(id), {
945
+ waitUntil: 'domcontentloaded',
946
+ timeout: 20000,
947
+ });
948
+ const deadline = Date.now() + 8000;
949
+ while (Date.now() < deadline) {
950
+ if ((await this.getCurrentChatId()) === id) {
951
+ await this._awaitInput(2000);
952
+ return true;
953
+ }
954
+ await this.page.waitForTimeout(100);
955
+ }
956
+ return false;
957
+ }
958
+ catch {
959
+ return false;
960
+ }
961
+ }
962
+ /** The model catalogue (GET /api/models) so /model can list/pick a model. */
963
+ async listModels() {
964
+ if (!this.page)
965
+ return [];
966
+ try {
967
+ const res = await this.page.evaluate(async (opts) => {
968
+ try {
969
+ const headers = {
970
+ accept: 'application/json',
971
+ };
972
+ if (opts.auth)
973
+ headers['authorization'] = opts.auth;
974
+ const r = await fetch(opts.url, { credentials: 'include', headers });
975
+ if (!r.ok)
976
+ return { error: 'HTTP ' + r.status, data: null };
977
+ return { error: '', data: await r.json() };
978
+ }
979
+ catch (e) {
980
+ return { error: e.message, data: null };
981
+ }
982
+ }, { url: MODELS_URL, auth: this._apiAuth });
983
+ if (res?.error) {
984
+ this._lastHistoryError = res.error;
985
+ this._notice(this._t('glm.model_error', { v: res.error }));
986
+ return [];
987
+ }
988
+ const data = res?.data || {};
989
+ const list = Array.isArray(data.data) ? data.data : [];
990
+ const out = [];
991
+ for (const m of list) {
992
+ if (!m || typeof m !== 'object')
993
+ continue;
994
+ const mm = m;
995
+ const id = typeof mm.id === 'string' ? mm.id : '';
996
+ const info = mm.info;
997
+ const name = (typeof mm.name === 'string' && mm.name) ||
998
+ (info && typeof info.name === 'string' && info.name) ||
999
+ id;
1000
+ if (id)
1001
+ out.push({ id, name });
1002
+ }
1003
+ return out;
1004
+ }
1005
+ catch (e) {
1006
+ this._lastHistoryError = e.message;
1007
+ this._notice(this._t('glm.model_error', { v: e.message }));
1008
+ return [];
1009
+ }
1010
+ }
1011
+ async fetchChatMessages(id) {
1012
+ this._lastHistoryError = '';
1013
+ if (!this.page || !id)
1014
+ return [];
1015
+ try {
1016
+ const res = await this.page.evaluate(async (opts) => {
1017
+ try {
1018
+ const headers = {
1019
+ accept: 'application/json',
1020
+ };
1021
+ if (opts.auth)
1022
+ headers['authorization'] = opts.auth;
1023
+ const r = await fetch(opts.url, {
1024
+ credentials: 'include',
1025
+ headers,
1026
+ });
1027
+ if (!r.ok)
1028
+ return { error: 'HTTP ' + r.status, data: null };
1029
+ return { error: '', data: await r.json() };
1030
+ }
1031
+ catch (e) {
1032
+ return { error: e.message, data: null };
1033
+ }
1034
+ }, { url: chatHistoryUrl(id), auth: this._apiAuth });
1035
+ if (res?.error) {
1036
+ this._lastHistoryError = res.error;
1037
+ return [];
1038
+ }
1039
+ return parseGlmHistory(res.data);
1040
+ }
1041
+ catch (e) {
1042
+ this._lastHistoryError = e.message;
1043
+ return [];
1044
+ }
1045
+ }
1046
+ async readChatMessages() {
1047
+ if (!this.page)
1048
+ return [];
1049
+ try {
1050
+ return await this.page.evaluate(scrapeGlmChatMessages, {
1051
+ containerSels: MESSAGE_CONTAINER_SELECTORS,
1052
+ thinkRe: THINK_CLASS_RE.source,
1053
+ userRe: USER_CLASS_RE.source,
1054
+ });
1055
+ }
1056
+ catch {
1057
+ return [];
1058
+ }
1059
+ }
1060
+ async getCurrentChatId() {
1061
+ try {
1062
+ const m = this.page.url().match(CHAT_ID_RE);
1063
+ if (m && m[1])
1064
+ return m[1];
1065
+ return this._netChatId;
1066
+ }
1067
+ catch {
1068
+ return this._netChatId;
1069
+ }
1070
+ }
1071
+ getLastTokenUsage() {
1072
+ return this._lastTokenUsage;
1073
+ }
1074
+ // ---------- answer reading ----------
1075
+ async _readLastAnswerText() {
1076
+ if (this._netCapture && this._netCaptureAt >= this._lastSentAt) {
1077
+ return this._netCapture;
1078
+ }
1079
+ return await this._readLastAnswerTextDom();
1080
+ }
1081
+ async _readLastAnswerTextDom() {
1082
+ return await this.page.evaluate(readLastAnswerInDom, {
1083
+ sels: ANSWER_SELECTORS,
1084
+ thinkRe: THINK_CLASS_RE.source,
1085
+ });
1086
+ }
1087
+ async _readLastAnswerTextCleanDom() {
1088
+ const raw = await this._readLastAnswerTextDom().catch(() => '');
1089
+ return cleanAnswer(raw);
1090
+ }
1091
+ async _readPageText() {
1092
+ return await this.page.evaluate(readPageToastsInDom).catch(() => '');
1093
+ }
1094
+ async _chatSignal() {
1095
+ return await this.page
1096
+ .evaluate(chatSignalInDom, {
1097
+ nodesSel: CHAT_SIGNAL_NODES_SELECTOR,
1098
+ mdSel: MARKDOWN_SELECTOR,
1099
+ })
1100
+ .catch(() => ({ nodes: 0, lastLen: 0 }));
1101
+ }
1102
+ /** Is the page generating right now (the send button swapped for a stop)? */
1103
+ async _stopButtonVisible() {
1104
+ const explicit = await this._findVisible(STOP_SELECTORS, 250);
1105
+ if (explicit)
1106
+ return true;
1107
+ // z.ai disables the input while generating; that is the reliable signal.
1108
+ const disabled = await this.page
1109
+ .evaluate((sel) => {
1110
+ const el = document.querySelector(sel);
1111
+ if (!el)
1112
+ return false;
1113
+ return el.disabled === true || el.getAttribute('aria-disabled') === 'true';
1114
+ }, INPUT_SELECTORS[0])
1115
+ .catch(() => false);
1116
+ return !!disabled;
1117
+ }
1118
+ /**
1119
+ * True while the page is still generating (the input is disabled or a stop
1120
+ * control is visible). Used to explain a missing send button instead of
1121
+ * blaming the selectors.
1122
+ */
1123
+ async _isGenerating() {
1124
+ return await this._stopButtonVisible().catch(() => false);
1125
+ }
1126
+ /**
1127
+ * Wait (bounded) until the page stops generating. Returns true when it was
1128
+ * STILL generating at the deadline - the caller then reports "busy" rather
1129
+ * than "send button missing".
1130
+ */
1131
+ async _waitWhileGenerating(maxMs) {
1132
+ const deadline = Date.now() + maxMs;
1133
+ while (Date.now() < deadline) {
1134
+ if (this._abort || this._askCancelled)
1135
+ return false;
1136
+ if (!(await this._isGenerating()))
1137
+ return false;
1138
+ await this.page.waitForTimeout(500);
1139
+ }
1140
+ return await this._isGenerating();
1141
+ }
1142
+ async _captchaVisible() {
1143
+ try {
1144
+ const txt = await this.page.evaluate(() => {
1145
+ return document.body ? document.body.innerText : '';
1146
+ });
1147
+ return /security verification|drag the slider/i.test(String(txt || ''));
1148
+ }
1149
+ catch {
1150
+ return false;
1151
+ }
1152
+ }
1153
+ async stopGeneration() {
1154
+ this._abort = true;
1155
+ this._stopped = true;
1156
+ try {
1157
+ const loc = this.page.getByRole('button', { name: STOP_NAME_RE });
1158
+ const n = await loc.count().catch(() => 0);
1159
+ for (let i = 0; i < n; i++) {
1160
+ const b = loc.nth(i);
1161
+ if (await b.isVisible().catch(() => false)) {
1162
+ await b.click({ timeout: 2000 });
1163
+ return true;
1164
+ }
1165
+ }
1166
+ }
1167
+ catch { }
1168
+ const btn = await this._findVisible(STOP_SELECTORS, 500);
1169
+ if (btn) {
1170
+ try {
1171
+ await btn.click({ timeout: 1500 });
1172
+ return true;
1173
+ }
1174
+ catch { }
1175
+ }
1176
+ // No dedicated stop control was observed; Escape aborts the generation.
1177
+ try {
1178
+ await this.page.keyboard.press('Escape');
1179
+ return true;
1180
+ }
1181
+ catch { }
1182
+ return false;
1183
+ }
1184
+ /**
1185
+ * Select the configured model in z.ai's header dropdown. The site builds the
1186
+ * completion request from ITS OWN UI state, so a switch means clicking
1187
+ * button#model-selector-<id>-button and then the matching menu item. The
1188
+ * option is matched by a NORMALIZED label so a config id
1189
+ * (`glm-5.3`) matches the displayed name (`GLM-5.3`). Best-effort: a
1190
+ * missing dropdown never fails the send.
1191
+ */
1192
+ async _applyModel() {
1193
+ if (!this.model)
1194
+ return;
1195
+ const norm = (v) => String(v || '')
1196
+ .toLowerCase()
1197
+ .replace(/[^a-z0-9]/g, '');
1198
+ const want = norm(this.model);
1199
+ if (!want)
1200
+ return;
1201
+ try {
1202
+ const trigger = this.page.locator(MODEL_TRIGGER_SELECTOR).first();
1203
+ if ((await trigger.count().catch(() => 0)) === 0)
1204
+ return;
1205
+ // The current id is IN the trigger's own id (verified live).
1206
+ const curId = await trigger
1207
+ .evaluate((el) => el.id || '')
1208
+ .catch(() => '');
1209
+ if (norm(modelIdFromTriggerId(curId)) === want)
1210
+ return;
1211
+ await trigger.click({ timeout: 2000 }).catch(() => { });
1212
+ await this.page.waitForTimeout(400);
1213
+ const clicked = await this.page
1214
+ .evaluate((w) => {
1215
+ const norm = (v) => (v || '').toLowerCase().replace(/[^a-z0-9]/g, '');
1216
+ for (const el of Array.from(document.querySelectorAll('li, [role="option"], [role="menuitem"], [class*="option"], [class*="item"]'))) {
1217
+ const t = (el.textContent || '').trim();
1218
+ if (t && norm(t) === w) {
1219
+ ;
1220
+ el.click();
1221
+ return true;
1222
+ }
1223
+ }
1224
+ return false;
1225
+ }, want)
1226
+ .catch(() => false);
1227
+ if (!clicked)
1228
+ await this.page.keyboard.press('Escape').catch(() => { });
1229
+ }
1230
+ catch { }
1231
+ }
1232
+ // ---------- toggles ----------
1233
+ getToggleStatesSync() {
1234
+ return this._toggles;
1235
+ }
1236
+ setToggleState(s) {
1237
+ if (typeof s.deepThinking === 'boolean')
1238
+ this._toggles.deepThinking = s.deepThinking;
1239
+ if (typeof s.webSearch === 'boolean')
1240
+ this._toggles.webSearch = s.webSearch;
1241
+ }
1242
+ async getToggleStates() {
1243
+ return { deepThinking: this.deepThinking, webSearch: this.webSearch };
1244
+ }
1245
+ /**
1246
+ * The mode flags are asserted through the REQUEST BODY, not a UI toggle:
1247
+ * z.ai's chat/agent runtime (preview_mode, the `general_agent` flag, its own
1248
+ * mcp servers) changes the answer format and would break the zames tool-call
1249
+ * protocol. Part C turns thinking/search on; this part keeps them off.
1250
+ * No stable UI control for them was observed in the recon.
1251
+ */
1252
+ async _applyToggles() {
1253
+ await this._applyModel();
1254
+ this._toggles = {
1255
+ deepThinking: this.deepThinking,
1256
+ webSearch: this.webSearch,
1257
+ };
1258
+ // GLM exposes no stable UI control for thinking / web search in the
1259
+ // observed build, so the desired state is carried by the request body the
1260
+ // SITE builds from its own UI. We therefore VERIFY the body after the send
1261
+ // (_verifyModes) rather than click a control that may not exist.
1262
+ }
1263
+ /**
1264
+ * Check the LAST completion request the page sent: unsafe modes must be off
1265
+ * (z.ai's own agent runtime breaks the zames tool-call protocol) and the
1266
+ * thinking/web-search flags should match the operator's setting. Best-effort
1267
+ * and NON-fatal: a mismatch is reported, the turn is not aborted.
1268
+ */
1269
+ _verifyModes() {
1270
+ const feat = this._lastBodyFeatures;
1271
+ if (!feat)
1272
+ return;
1273
+ const unsafe = unsafeGlmModes(feat);
1274
+ if (unsafe.length) {
1275
+ this._notice(this._t('glm.mode_flags', { v: unsafe.join(', ') }));
1276
+ }
1277
+ // Only the THINKING flag is actionable: GLM starts its own web search
1278
+ // regardless of the request flag, so a web_search mismatch was pure noise
1279
+ // (it fired on every send and told the operator nothing they could fix).
1280
+ const mismatch = glmFlagsMismatch(feat, {
1281
+ thinking: this.deepThinking,
1282
+ webSearch: feat.webSearch === true || feat.autoWebSearch === true,
1283
+ }).filter((m) => m !== 'web_search');
1284
+ if (mismatch.length) {
1285
+ this._notice(this._t('glm.mode_mismatch', { v: mismatch.join(', ') }));
1286
+ }
1287
+ }
1288
+ // ---------- diagnostics ----------
1289
+ async dumpDom(filePath) {
1290
+ if (!this.page)
1291
+ throw new Error('glm: browser not launched');
1292
+ const html = await this.page.content();
1293
+ await fs.writeFile(filePath, html, 'utf-8');
1294
+ return { file: filePath, selectors: {} };
1295
+ }
1296
+ _uiCapabilities() {
1297
+ return [
1298
+ { capability: 'input', selectors: INPUT_SELECTORS, critical: true },
1299
+ { capability: 'toggles', selectors: [], critical: false },
1300
+ { capability: 'newChat', selectors: [NEW_CHAT_SELECTOR], critical: false },
1301
+ ];
1302
+ }
1303
+ async probeUiHealth() {
1304
+ if (!this.page) {
1305
+ return { ok: false, present: [], missing: [], missingCritical: [] };
1306
+ }
1307
+ const page = {
1308
+ count: (sel) => this.page.locator(sel).count(),
1309
+ countByText: (sel, textRe) => this.page.locator(sel).filter({ hasText: textRe }).count(),
1310
+ };
1311
+ return await probeUiHealth(page, this._uiCapabilities());
1312
+ }
1313
+ chatUrl(id) {
1314
+ return chatUrl(id);
1315
+ }
1316
+ // ---------- ask ----------
1317
+ async _waitForSendSlot(agent) {
1318
+ if (!agent)
1319
+ return;
1320
+ const gap = this.sendRuntime.waitMs();
1321
+ if (gap <= 0)
1322
+ return;
1323
+ const report = (leftMs) => {
1324
+ const secs = Math.max(0, Math.ceil(leftMs / 1000));
1325
+ if (this.onSendPause) {
1326
+ try {
1327
+ this.onSendPause(secs);
1328
+ }
1329
+ catch { }
1330
+ }
1331
+ };
1332
+ report(gap);
1333
+ this._emitSendState('paused');
1334
+ await this._sleepInterruptible(gap, report);
1335
+ }
1336
+ async _sleepInterruptible(ms, onTick) {
1337
+ const step = 100;
1338
+ let left = ms;
1339
+ let sinceTick = 0;
1340
+ while (left > 0) {
1341
+ if (this._abort)
1342
+ return true;
1343
+ if (this._askCancelled)
1344
+ return true;
1345
+ const chunk = Math.min(step, left);
1346
+ await this.page.waitForTimeout(chunk);
1347
+ left -= chunk;
1348
+ if (onTick) {
1349
+ sinceTick += chunk;
1350
+ if (sinceTick >= 1000) {
1351
+ sinceTick = 0;
1352
+ onTick(left);
1353
+ }
1354
+ }
1355
+ }
1356
+ return this._abort;
1357
+ }
1358
+ cancelPendingAsk() {
1359
+ this._askCancelled = true;
1360
+ }
1361
+ async ask(prompt, { timeout = this.answerTimeoutMs, agent = false, attachments = [], } = {}) {
1362
+ let lastErr = null;
1363
+ let attempt = 0;
1364
+ let rateLimitRetries = 0;
1365
+ let serverBusyRetries = 0;
1366
+ let incompleteRetries = 0;
1367
+ this._askInFlight++;
1368
+ try {
1369
+ while (attempt < this.askRetries) {
1370
+ try {
1371
+ const answer = await this._askOnce(prompt, {
1372
+ timeout,
1373
+ agent,
1374
+ attachments,
1375
+ });
1376
+ if (answer !== '(прервано пользователем)') {
1377
+ this._emitSendState('settled');
1378
+ }
1379
+ return answer;
1380
+ }
1381
+ catch (e) {
1382
+ lastErr = e;
1383
+ const msg = e.message || '';
1384
+ if (e instanceof GlmGuestLimitError) {
1385
+ this.sendRuntime.onOutcome('guest_limit', { errorText: msg });
1386
+ throw new Error(this._t('glm.guest_mode'));
1387
+ }
1388
+ if (isQuotaExhaustedText(msg) || isQuotaExhaustedText(this._netBody)) {
1389
+ this.sendRuntime.onOutcome('rate_limit', { errorText: 'quota' });
1390
+ throw new Error(this._t('glm.quota_exhausted'));
1391
+ }
1392
+ if (e instanceof GlmCaptchaError) {
1393
+ this.sendRuntime.onOutcome('guest_limit', { errorText: 'captcha' });
1394
+ throw new Error(this._t('glm.captcha'));
1395
+ }
1396
+ if (/rate limit|too frequent/i.test(msg)) {
1397
+ this.sendRuntime.onOutcome('rate_limit', { errorText: msg });
1398
+ if (rateLimitRetries >= this.maxRateLimitRetries)
1399
+ break;
1400
+ rateLimitRetries++;
1401
+ this._notice(this._t('glm.rate_limit_wait', {
1402
+ min: Math.ceil(this.rateLimitWaitMs / 60000),
1403
+ attempt: rateLimitRetries,
1404
+ max: this.maxRateLimitRetries,
1405
+ }));
1406
+ if (await this._sleepInterruptible(this.rateLimitWaitMs))
1407
+ return '(прервано пользователем)';
1408
+ continue;
1409
+ }
1410
+ if (e instanceof GlmServerBusyError || /server busy/i.test(msg)) {
1411
+ this.sendRuntime.onOutcome('busy', { errorText: msg });
1412
+ if (serverBusyRetries >= this.maxServerBusyRetries)
1413
+ break;
1414
+ serverBusyRetries++;
1415
+ this._notice(this._t('glm.server_busy_wait', {
1416
+ sec: Math.ceil(this.serverBusyWaitMs / 1000),
1417
+ attempt: serverBusyRetries,
1418
+ max: this.maxServerBusyRetries,
1419
+ }));
1420
+ if (await this._sleepInterruptible(this.serverBusyWaitMs))
1421
+ return '(прервано пользователем)';
1422
+ continue;
1423
+ }
1424
+ if (e instanceof GlmIncompleteError) {
1425
+ this.sendRuntime.onOutcome('incomplete', { errorText: msg });
1426
+ if (incompleteRetries >= this.maxIncompleteRetries)
1427
+ break;
1428
+ incompleteRetries++;
1429
+ this._notice(this._t('glm.incomplete_retry', {
1430
+ attempt: incompleteRetries,
1431
+ max: this.maxIncompleteRetries,
1432
+ }));
1433
+ if (await this._sleepInterruptible(this.incompleteWaitMs))
1434
+ return '(прервано пользователем)';
1435
+ continue;
1436
+ }
1437
+ if (/target page, context or browser has been closed|page.*has been closed/i.test(msg)) {
1438
+ this._notice(this._t('glm.restart_browser'));
1439
+ try {
1440
+ await this.restart();
1441
+ await this.waitForLogin();
1442
+ }
1443
+ catch (re) {
1444
+ this._notice(this._t('glm.restart_failed', {
1445
+ error: re.message,
1446
+ }));
1447
+ }
1448
+ }
1449
+ this._notice(this._t('glm.ask_retry', {
1450
+ attempt: attempt + 1,
1451
+ max: this.askRetries,
1452
+ error: msg,
1453
+ }));
1454
+ if (attempt < this.askRetries - 1) {
1455
+ await new Promise((r) => setTimeout(r, 2000 * (attempt + 1)));
1456
+ }
1457
+ attempt++;
1458
+ }
1459
+ }
1460
+ throw new Error(this._t('glm.ask_failed', {
1461
+ max: this.askRetries,
1462
+ error: lastErr?.message || '',
1463
+ }));
1464
+ }
1465
+ finally {
1466
+ this._askInFlight = Math.max(0, this._askInFlight - 1);
1467
+ void this._setBusyTitle(false);
1468
+ }
1469
+ }
1470
+ async _setInputText(input, text) {
1471
+ const tag = await input.evaluate((el) => el.tagName.toLowerCase());
1472
+ if (tag === 'textarea' || tag === 'input') {
1473
+ try {
1474
+ await input.fill(text, { timeout: 5000 });
1475
+ return;
1476
+ }
1477
+ catch { }
1478
+ }
1479
+ await input.click({ timeout: 3000 }).catch(() => { });
1480
+ await input
1481
+ .evaluate((el, value) => {
1482
+ el.focus();
1483
+ const dt = new DataTransfer();
1484
+ dt.setData('text/plain', value);
1485
+ el.dispatchEvent(new ClipboardEvent('paste', {
1486
+ clipboardData: dt,
1487
+ bubbles: true,
1488
+ cancelable: true,
1489
+ }));
1490
+ }, text)
1491
+ .catch(async () => {
1492
+ await input.fill(text).catch(() => { });
1493
+ });
1494
+ }
1495
+ async _attachFiles(files) {
1496
+ const { payload: bufPayload, failed } = await readAttachBuffers(files, (name, error) => this._notice(theme.warn(this._t('glm.attach_read_failed', { name, error }))));
1497
+ if (!bufPayload.length)
1498
+ return failed;
1499
+ // Primary flow: the observed upload control is button#upload-file-button,
1500
+ // which fires a real filechooser (the same shape as Qwen's menu item).
1501
+ try {
1502
+ const [chooser] = await Promise.all([
1503
+ this.page.waitForEvent('filechooser', { timeout: 5000 }),
1504
+ this.page.locator(UPLOAD_BUTTON_SELECTOR).first().click({ timeout: 3000 }),
1505
+ ]);
1506
+ await chooser.setFiles(bufPayload);
1507
+ await this._awaitUploadPreview(files.map((f) => f.name), 5000);
1508
+ return failed;
1509
+ }
1510
+ catch { }
1511
+ // Fallback: set the files directly on the hidden <input type=file>.
1512
+ for (const sel of ['input[type="file"]']) {
1513
+ const el = this.page.locator(sel).last();
1514
+ try {
1515
+ if ((await el.count()) === 0)
1516
+ continue;
1517
+ await el.setInputFiles(bufPayload, { timeout: 15_000 });
1518
+ await this._awaitUploadPreview(files.map((f) => f.name), 3000);
1519
+ return failed;
1520
+ }
1521
+ catch { }
1522
+ }
1523
+ this._notice(this._t('glm.attach_no_input'));
1524
+ for (const f of files)
1525
+ failed.push({ path: f.path, name: f.name });
1526
+ return failed;
1527
+ }
1528
+ async _awaitUploadPreview(names, timeoutMs) {
1529
+ await awaitUploadPreview(this.page, names, timeoutMs);
1530
+ }
1531
+ async _askOnce(prompt, { timeout, agent, attachments = [], }) {
1532
+ this._abort = false;
1533
+ this._askCancelled = false;
1534
+ if (this._stopped)
1535
+ return '(прервано пользователем)';
1536
+ await this._awaitInput(10_000);
1537
+ let input = await this._findVisible(INPUT_SELECTORS, 3000);
1538
+ if (!input) {
1539
+ try {
1540
+ await this.page.goto(CHAT_URL, { waitUntil: 'domcontentloaded' });
1541
+ await this._awaitInput(10_000);
1542
+ input = await this._findVisible(INPUT_SELECTORS, 3000);
1543
+ }
1544
+ catch { }
1545
+ }
1546
+ if (!input)
1547
+ throw new Error(this._t('glm.input_missing'));
1548
+ const beforeText = await this._readLastAnswerTextCleanDom().catch(() => '');
1549
+ await this._waitForSendSlot(agent);
1550
+ if (this._abort)
1551
+ return '(прервано пользователем)';
1552
+ if (this.onSendStart) {
1553
+ try {
1554
+ this.onSendStart();
1555
+ }
1556
+ catch { }
1557
+ }
1558
+ this._emitSendState('generating');
1559
+ void this._setBusyTitle(true);
1560
+ // The throttle records this send; its outcome is filled in below.
1561
+ this.sendRuntime.onSend({
1562
+ model: this.model,
1563
+ thinking: this.deepThinking,
1564
+ webSearch: this.webSearch,
1565
+ });
1566
+ this._netCapture = '';
1567
+ this._netCaptureAt = 0;
1568
+ this._netNoAnswer = false;
1569
+ this._netRateLimited = false;
1570
+ this._netGuestLimit = false;
1571
+ this._netServerBusy = false;
1572
+ this._netCaptcha = false;
1573
+ this._netBody = '';
1574
+ await this._applyToggles();
1575
+ if (attachments.length) {
1576
+ const failed = await this._attachFiles(attachments);
1577
+ prompt = rewriteFailedAttachments(prompt, failed);
1578
+ }
1579
+ await this._setInputText(input, prompt);
1580
+ const sendDeadline = Date.now() + (attachments.length ? 30_000 : this.sendButtonWaitMs);
1581
+ const preSendSignal = await this._chatSignal();
1582
+ let sent = false;
1583
+ while (!sent && Date.now() < sendDeadline) {
1584
+ let clicked = false;
1585
+ for (const sel of SEND_SELECTORS) {
1586
+ const btn = this.page.locator(sel).last();
1587
+ try {
1588
+ if ((await btn.count()) === 0)
1589
+ continue;
1590
+ if (!(await btn.isVisible().catch(() => false)))
1591
+ continue;
1592
+ const enabled = await btn
1593
+ .evaluate(isElementEnabledInDom)
1594
+ .catch(() => true);
1595
+ if (!enabled)
1596
+ continue;
1597
+ await btn.click({ timeout: 1500 });
1598
+ clicked = true;
1599
+ break;
1600
+ }
1601
+ catch { }
1602
+ }
1603
+ if (!clicked) {
1604
+ await this.page.keyboard.press('Enter').catch(() => { });
1605
+ }
1606
+ await this.page.waitForTimeout(300);
1607
+ const stillThere = await input
1608
+ .evaluate(readInputTrimmedTextInDom)
1609
+ .catch(() => '');
1610
+ if (!stillThere) {
1611
+ sent = true;
1612
+ break;
1613
+ }
1614
+ const sig = await this._chatSignal().catch(() => preSendSignal);
1615
+ const stopVisible = await this._stopButtonVisible().catch(() => false);
1616
+ if (stopVisible ||
1617
+ sig.nodes > preSendSignal.nodes ||
1618
+ sig.lastLen > preSendSignal.lastLen) {
1619
+ sent = true;
1620
+ break;
1621
+ }
1622
+ await this.page.waitForTimeout(150);
1623
+ }
1624
+ this._lastSentAt = Date.now();
1625
+ if (!sent) {
1626
+ if (await this._captchaVisible().catch(() => false)) {
1627
+ throw new GlmCaptchaError('captcha blocks the send');
1628
+ }
1629
+ // A PAGE THAT IS STILL GENERATING hides/disabled the send control. That
1630
+ // is not a broken button: the previous turn (often a long z.ai web
1631
+ // search) is still running. Wait for it to settle before failing, so a
1632
+ // slow turn does not turn into "send button not found" + a retry storm.
1633
+ const settled = await this._waitWhileGenerating(120_000);
1634
+ if (settled) {
1635
+ throw new Error(this._t('glm.page_busy'));
1636
+ }
1637
+ throw new Error(this._t('glm.send_button_missing'));
1638
+ }
1639
+ // The site has just built the request: check the mode flags it used.
1640
+ this._verifyModes();
1641
+ // Wait for the answer to start, then for it to settle.
1642
+ const startDeadline = Date.now() + this.startWaitMs;
1643
+ const startSignal = await this._chatSignal();
1644
+ let started = false;
1645
+ while (Date.now() < startDeadline) {
1646
+ if (this._abort || this._askCancelled)
1647
+ return '(прервано пользователем)';
1648
+ const pageText = await this._readPageText();
1649
+ if (await this._captchaVisible().catch(() => false)) {
1650
+ throw new GlmCaptchaError('captcha blocks the answer');
1651
+ }
1652
+ if (isRateLimitText(pageText)) {
1653
+ throw new GlmRateLimitError(pageText.slice(0, 300));
1654
+ }
1655
+ if (this._netCaptcha && this._netCaptureAt >= this._lastSentAt) {
1656
+ throw new GlmCaptchaError(this._netBody.slice(0, 200));
1657
+ }
1658
+ if (this._netGuestLimit && this._netCaptureAt >= this._lastSentAt) {
1659
+ throw new GlmGuestLimitError(this._netBody.slice(0, 200));
1660
+ }
1661
+ if (this._netRateLimited && this._netCaptureAt >= this._lastSentAt) {
1662
+ throw new GlmRateLimitError(this._netBody.slice(0, 300));
1663
+ }
1664
+ if (this._netServerBusy && this._netCaptureAt >= this._lastSentAt) {
1665
+ throw new GlmServerBusyError(this._netBody.slice(0, 300));
1666
+ }
1667
+ const sig = await this._chatSignal();
1668
+ if (sig.nodes > startSignal.nodes || sig.lastLen > startSignal.lastLen) {
1669
+ started = true;
1670
+ break;
1671
+ }
1672
+ await this.page.waitForTimeout(200);
1673
+ }
1674
+ if (!started && !this._netCapture) {
1675
+ await this.page.waitForTimeout(1500);
1676
+ }
1677
+ const deadline = Date.now() + timeout;
1678
+ let last = '';
1679
+ let stable = 0;
1680
+ while (Date.now() < deadline) {
1681
+ if (this._abort || this._askCancelled)
1682
+ return '(прервано пользователем)';
1683
+ const pageText = await this._readPageText();
1684
+ if (await this._captchaVisible().catch(() => false)) {
1685
+ throw new GlmCaptchaError('captcha blocks the answer');
1686
+ }
1687
+ if (isRateLimitText(pageText)) {
1688
+ throw new GlmRateLimitError(pageText.slice(0, 300));
1689
+ }
1690
+ if (this._netCaptcha && this._netCaptureAt >= this._lastSentAt) {
1691
+ throw new GlmCaptchaError(this._netBody.slice(0, 200));
1692
+ }
1693
+ if (this._netGuestLimit && this._netCaptureAt >= this._lastSentAt) {
1694
+ throw new GlmGuestLimitError(this._netBody.slice(0, 200));
1695
+ }
1696
+ if (this._netRateLimited && this._netCaptureAt >= this._lastSentAt) {
1697
+ throw new GlmRateLimitError(this._netBody.slice(0, 300));
1698
+ }
1699
+ if (this._netServerBusy && this._netCaptureAt >= this._lastSentAt) {
1700
+ throw new GlmServerBusyError(this._netBody.slice(0, 300));
1701
+ }
1702
+ if (this._netNoAnswer && this._netCaptureAt >= this._lastSentAt) {
1703
+ throw new GlmIncompleteError('finished without answer');
1704
+ }
1705
+ const cur = await this._readLastAnswerTextCleanDom().catch(() => '');
1706
+ const net = this._netCapture;
1707
+ if (net && net !== last) {
1708
+ last = net;
1709
+ stable = 0;
1710
+ }
1711
+ else if (cur && cur !== beforeText && cur !== last) {
1712
+ last = cur;
1713
+ stable = 0;
1714
+ }
1715
+ else {
1716
+ stable++;
1717
+ }
1718
+ if (stable >= this.stabilityChecks - 1) {
1719
+ const answer = this._netCapture || cur;
1720
+ if (answer && answer !== beforeText) {
1721
+ this.sendRuntime.onOutcome('ok');
1722
+ return answer;
1723
+ }
1724
+ }
1725
+ await this.page.waitForTimeout(this.stabilityDelayMs);
1726
+ }
1727
+ const answer = this._netCapture || last;
1728
+ if (answer && answer !== beforeText) {
1729
+ this.sendRuntime.onOutcome('ok');
1730
+ return answer;
1731
+ }
1732
+ this.sendRuntime.onOutcome('incomplete', { errorText: 'no new answer' });
1733
+ throw new Error(this._t('glm.send_no_new_answer'));
1734
+ }
1735
+ }
1736
+ // GLM-specific error classes so ask() can branch on the retryable families.
1737
+ class GlmRateLimitError extends Error {
1738
+ constructor(detail) {
1739
+ super('rate limit: ' + detail);
1740
+ this.name = 'GlmRateLimitError';
1741
+ }
1742
+ }
1743
+ class GlmGuestLimitError extends Error {
1744
+ constructor(detail) {
1745
+ super('guest limit: ' + detail);
1746
+ this.name = 'GlmGuestLimitError';
1747
+ }
1748
+ }
1749
+ class GlmCaptchaError extends Error {
1750
+ constructor(detail) {
1751
+ super('captcha: ' + detail);
1752
+ this.name = 'GlmCaptchaError';
1753
+ }
1754
+ }
1755
+ class GlmIncompleteError extends Error {
1756
+ constructor(detail) {
1757
+ super('generation incomplete: ' + detail);
1758
+ this.name = 'GlmIncompleteError';
1759
+ }
1760
+ }
1761
+ class GlmServerBusyError extends Error {
1762
+ constructor(detail) {
1763
+ super('server busy: ' + detail);
1764
+ this.name = 'GlmServerBusyError';
1765
+ }
1766
+ }