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