zames_pro 2.16.0 → 2.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -1
- package/dist/agent-loop.js +22 -6
- package/dist/browser.js +828 -29
- package/dist/commands.js +73 -0
- package/dist/config-menu.js +4 -0
- package/dist/config.js +11 -1
- package/dist/i18n.js +119 -1
- package/dist/index.js +111 -5
- package/dist/input.js +51 -9
- package/dist/spinner.js +39 -6
- package/package.json +2 -2
package/dist/browser.js
CHANGED
|
@@ -5,8 +5,134 @@ import os from 'os';
|
|
|
5
5
|
import fs from 'fs/promises';
|
|
6
6
|
import { execSync } from 'child_process';
|
|
7
7
|
import { theme } from './theme.js';
|
|
8
|
+
import { translate, DEFAULT_LOCALE } from './i18n.js';
|
|
9
|
+
// Muted terminal input for passwords. We do NOT use readline here: readline
|
|
10
|
+
// echoes the typed text through its own internal _writeToOutput, which cannot
|
|
11
|
+
// be reliably overridden from outside (a real bug: the login prompt swallowed
|
|
12
|
+
// the password step). Instead we read raw keystrokes from stdin and echo one
|
|
13
|
+
// `*` per typed character. In a non-TTY (pipe/redirect) we fall back to a
|
|
14
|
+
// plain line read.
|
|
15
|
+
export function askPassword(question) {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
process.stdout.write(question);
|
|
18
|
+
// Non-interactive: read a single line from stdin as-is.
|
|
19
|
+
if (!process.stdin.isTTY) {
|
|
20
|
+
void import('readline').then(({ createInterface }) => {
|
|
21
|
+
const rl = createInterface({
|
|
22
|
+
input: process.stdin,
|
|
23
|
+
output: process.stdout,
|
|
24
|
+
});
|
|
25
|
+
rl.question('', (answer) => {
|
|
26
|
+
rl.close();
|
|
27
|
+
resolve(answer);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const stdin = process.stdin;
|
|
33
|
+
const wasRaw = stdin.isRaw;
|
|
34
|
+
stdin.setRawMode(true);
|
|
35
|
+
stdin.resume();
|
|
36
|
+
let value = '';
|
|
37
|
+
const onData = (buf) => {
|
|
38
|
+
const s = buf.toString('utf8');
|
|
39
|
+
for (const ch of s) {
|
|
40
|
+
const code = ch.charCodeAt(0);
|
|
41
|
+
// Enter / Ctrl+J -> done.
|
|
42
|
+
if (ch === '\r' || ch === '\n') {
|
|
43
|
+
done();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
// Ctrl+C -> abort the whole process (same as elsewhere in the CLI).
|
|
47
|
+
if (code === 3) {
|
|
48
|
+
cleanup();
|
|
49
|
+
process.stdout.write('\n');
|
|
50
|
+
process.exit(130);
|
|
51
|
+
}
|
|
52
|
+
// Ctrl+D -> finish with whatever we have.
|
|
53
|
+
if (code === 4) {
|
|
54
|
+
done();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// Backspace (0x7f or 0x08): remove the last char and one star.
|
|
58
|
+
if (code === 127 || code === 8) {
|
|
59
|
+
if (value.length > 0) {
|
|
60
|
+
value = value.slice(0, -1);
|
|
61
|
+
process.stdout.write('\b \b');
|
|
62
|
+
}
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
// Ignore other control characters (arrows, etc.).
|
|
66
|
+
if (code < 32)
|
|
67
|
+
continue;
|
|
68
|
+
value += ch;
|
|
69
|
+
process.stdout.write('*');
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const cleanup = () => {
|
|
73
|
+
stdin.removeListener('data', onData);
|
|
74
|
+
try {
|
|
75
|
+
stdin.setRawMode(wasRaw || false);
|
|
76
|
+
}
|
|
77
|
+
catch { }
|
|
78
|
+
stdin.pause();
|
|
79
|
+
};
|
|
80
|
+
const done = () => {
|
|
81
|
+
cleanup();
|
|
82
|
+
process.stdout.write('\n');
|
|
83
|
+
resolve(value);
|
|
84
|
+
};
|
|
85
|
+
stdin.on('data', onData);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
8
88
|
const USER_DATA_DIR = path.join(os.homedir(), '.zames', 'profile');
|
|
9
89
|
const CHAT_URL = 'https://chat.deepseek.com/';
|
|
90
|
+
// A headless Chrome advertises "HeadlessChrome/..." in its User-Agent, and
|
|
91
|
+
// DeepSeek's CDN (CloudFront/WAF) rejects that UA with a plain "403 ERROR"
|
|
92
|
+
// page before the app is even served — so a headless login looked broken while
|
|
93
|
+
// a headed one worked (headed Chrome sends "Chrome/..."). We keep the real
|
|
94
|
+
// engine version and only drop the "Headless" marker. When the operator
|
|
95
|
+
// explicitly set a UA in the config, that one wins (see _launchOnce).
|
|
96
|
+
export function sanitizeHeadlessUA(ua) {
|
|
97
|
+
return String(ua || '').replace(/HeadlessChrome/g, 'Chrome');
|
|
98
|
+
}
|
|
99
|
+
// Written after a successful sign-in so the next launch knows a session was
|
|
100
|
+
// stored in the persistent profile (used by /doctor and diagnostics).
|
|
101
|
+
const AUTH_MARKER_FILE = path.join(os.homedir(), '.zames', 'auth.json');
|
|
102
|
+
// DeepSeek sign-in form. The login page is served on the same origin and
|
|
103
|
+
// swaps in a password field; the exact classes change, so we match loosely on
|
|
104
|
+
// input types and the known placeholder/autocomplete attributes.
|
|
105
|
+
const PASSWORD_SELECTORS = [
|
|
106
|
+
'input[type="password"]',
|
|
107
|
+
'input[autocomplete="current-password"]',
|
|
108
|
+
'input[autocomplete="new-password"]',
|
|
109
|
+
];
|
|
110
|
+
const LOGIN_SELECTORS = [
|
|
111
|
+
'input[type="email"]',
|
|
112
|
+
'input[type="tel"]',
|
|
113
|
+
'input[name="email"]',
|
|
114
|
+
'input[name="phone"]',
|
|
115
|
+
'input[name="username"]',
|
|
116
|
+
'input[placeholder*="email" i]',
|
|
117
|
+
'input[placeholder*="phone" i]',
|
|
118
|
+
'input[placeholder*="телефон" i]',
|
|
119
|
+
'input[placeholder*="почт" i]',
|
|
120
|
+
'input[autocomplete="username"]',
|
|
121
|
+
'input[autocomplete="email"]',
|
|
122
|
+
];
|
|
123
|
+
const LOGIN_SUBMIT_SELECTORS = [
|
|
124
|
+
// DeepSeek's real button: a div[role=button] with these classes and a
|
|
125
|
+
// <span class="ds-button__content">Log in</span> inside.
|
|
126
|
+
'div[role="button"].ds-button--primary.ds-button--filled',
|
|
127
|
+
'div[role="button"].ds-button--primary',
|
|
128
|
+
'button[type="submit"]',
|
|
129
|
+
'button:has-text("Log in")',
|
|
130
|
+
'button:has-text("Sign in")',
|
|
131
|
+
'button:has-text("Войти")',
|
|
132
|
+
'div[role="button"]:has-text("Log in")',
|
|
133
|
+
'div[role="button"]:has-text("Sign in")',
|
|
134
|
+
'div[role="button"]:has-text("Войти")',
|
|
135
|
+
];
|
|
10
136
|
const INPUT_SELECTORS = [
|
|
11
137
|
'textarea',
|
|
12
138
|
'div[contenteditable="true"]',
|
|
@@ -130,6 +256,16 @@ export class DeepSeekBrowser {
|
|
|
130
256
|
// is never read/shown). webSearch: the "Smart search" toggle.
|
|
131
257
|
deepThinking;
|
|
132
258
|
webSearch;
|
|
259
|
+
// Credentials for automatic sign-in. The password is stored in the config
|
|
260
|
+
// file (~/.zames/config.json) and is used only when DeepSeek has logged the
|
|
261
|
+
// session out. `saveSession` keeps the profile (cookies) so a fresh launch
|
|
262
|
+
// reuses the session instead of asking the operator every time.
|
|
263
|
+
auth;
|
|
264
|
+
// Interface language for the login prompts printed by the browser itself.
|
|
265
|
+
locale;
|
|
266
|
+
// Called after a successful interactive login so index.ts can persist the
|
|
267
|
+
// credentials into the user config (the browser does not write config).
|
|
268
|
+
onAuthSave;
|
|
133
269
|
_lastSentAt;
|
|
134
270
|
_abort;
|
|
135
271
|
// The user pressed Esc/Ctrl+C — a "stop" for the WHOLE current batch of
|
|
@@ -141,6 +277,13 @@ export class DeepSeekBrowser {
|
|
|
141
277
|
_netCapture;
|
|
142
278
|
_netCaptureAt;
|
|
143
279
|
_netChatId;
|
|
280
|
+
// Authorization / PoW headers sniffed from DeepSeek's own API requests, so
|
|
281
|
+
// fetchChatMessages can replay them (a bare fetch does not get them).
|
|
282
|
+
_apiAuth;
|
|
283
|
+
_apiPow;
|
|
284
|
+
// Last error from fetchChatMessages ('' on success). Surfaced under --debug
|
|
285
|
+
// so a missing restored history can be diagnosed.
|
|
286
|
+
_lastHistoryError;
|
|
144
287
|
_netSniff;
|
|
145
288
|
_netSniffLimit;
|
|
146
289
|
_netHookInstalled;
|
|
@@ -149,7 +292,15 @@ export class DeepSeekBrowser {
|
|
|
149
292
|
// a generation really begins, not during the pre-send phase (chat open,
|
|
150
293
|
// throttle wait), which used to show a spinner with no work in flight.
|
|
151
294
|
onSendStart;
|
|
152
|
-
|
|
295
|
+
// Fired when the agent starts waiting out the send-interval pause, with the
|
|
296
|
+
// remaining seconds. Lets the UI animate the pause status instead of
|
|
297
|
+
// printing a static line (the dots used to be frozen during the pause).
|
|
298
|
+
onSendPause;
|
|
299
|
+
// User-Agent sent by the browser. In headless mode the "Headless" marker
|
|
300
|
+
// must be stripped (DeepSeek's CDN answers 403 to it); computed in the
|
|
301
|
+
// constructor, overridable by an explicit UA in the options/config.
|
|
302
|
+
userAgent;
|
|
303
|
+
constructor({ headless = false, debug = false, channel = 'chrome', answerTimeoutMs = 180000, askRetries = 3, stabilityChecks = 2, stabilityDelayMs = 400, minSendIntervalMs = 15000, rateLimitWaitMs = 300000, maxRateLimitRetries = 6, maxServerBusyRetries = 5, serverBusyWaitMs = 3000, deepThinking = false, webSearch = true, auth, locale, onAuthSave, userAgent, } = {}) {
|
|
153
304
|
this.headless = headless;
|
|
154
305
|
this.debug = debug;
|
|
155
306
|
this.channel = channel;
|
|
@@ -164,16 +315,36 @@ export class DeepSeekBrowser {
|
|
|
164
315
|
this.serverBusyWaitMs = serverBusyWaitMs;
|
|
165
316
|
this.deepThinking = deepThinking;
|
|
166
317
|
this.webSearch = webSearch;
|
|
318
|
+
this.auth = {
|
|
319
|
+
username: (auth?.username || '').trim(),
|
|
320
|
+
password: auth?.password || '',
|
|
321
|
+
saveSession: auth?.saveSession !== false,
|
|
322
|
+
};
|
|
323
|
+
this.locale = locale || DEFAULT_LOCALE;
|
|
324
|
+
this.onAuthSave = onAuthSave || null;
|
|
167
325
|
this._lastSentAt = 0;
|
|
168
326
|
this._abort = false;
|
|
169
327
|
this._stopped = false;
|
|
170
328
|
this._netCapture = '';
|
|
171
329
|
this._netCaptureAt = 0;
|
|
172
330
|
this._netChatId = null;
|
|
331
|
+
this._apiAuth = '';
|
|
332
|
+
this._apiPow = '';
|
|
333
|
+
this._lastHistoryError = '';
|
|
173
334
|
this._netSniff = [];
|
|
174
335
|
this._netSniffLimit = 5;
|
|
175
336
|
this._netHookInstalled = false;
|
|
176
337
|
this.onSendStart = null;
|
|
338
|
+
this.onSendPause = null;
|
|
339
|
+
// An explicit UA (config/options) is respected as-is. Otherwise it stays
|
|
340
|
+
// empty here and is derived from the real engine UA after launch — see
|
|
341
|
+
// _fixHeadlessUserAgent (a headless "HeadlessChrome/..." UA gets 403 from
|
|
342
|
+
// DeepSeek's CDN).
|
|
343
|
+
this.userAgent = userAgent ? sanitizeHeadlessUA(userAgent) : '';
|
|
344
|
+
}
|
|
345
|
+
/** Translation bound to this browser's locale. */
|
|
346
|
+
_t(key, params) {
|
|
347
|
+
return translate(this.locale)(key, params);
|
|
177
348
|
}
|
|
178
349
|
async launch() {
|
|
179
350
|
await killStaleChrome();
|
|
@@ -183,6 +354,35 @@ export class DeepSeekBrowser {
|
|
|
183
354
|
await cleanSingletonFiles();
|
|
184
355
|
}
|
|
185
356
|
await this._launchOnce();
|
|
357
|
+
await this._fixHeadlessUserAgent();
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* A headless Chromium advertises "HeadlessChrome/<v>" in its User-Agent, and
|
|
361
|
+
* DeepSeek's CDN (CloudFront/WAF) answers that UA with a plain "403 ERROR"
|
|
362
|
+
* page before the app is served — so sign-in worked with a visible window and
|
|
363
|
+
* silently failed headless. Read the UA the engine really reports and, when
|
|
364
|
+
* it carries the "Headless" marker, relaunch once with a sanitized
|
|
365
|
+
* `--user-agent` (the marker removed, the real version kept). A UA passed
|
|
366
|
+
* explicitly in the options/config is never touched.
|
|
367
|
+
*/
|
|
368
|
+
async _fixHeadlessUserAgent() {
|
|
369
|
+
if (!this.headless)
|
|
370
|
+
return;
|
|
371
|
+
try {
|
|
372
|
+
const ua = await this.page.evaluate(() => navigator.userAgent);
|
|
373
|
+
const fixed = sanitizeHeadlessUA(ua);
|
|
374
|
+
if (fixed === ua)
|
|
375
|
+
return;
|
|
376
|
+
this.userAgent = fixed;
|
|
377
|
+
if (this.debug)
|
|
378
|
+
console.error('profile: headless UA → перезапуск с обычным Chrome UA');
|
|
379
|
+
await this.context.close().catch(() => { });
|
|
380
|
+
await this._launchOnce();
|
|
381
|
+
}
|
|
382
|
+
catch (e) {
|
|
383
|
+
if (this.debug)
|
|
384
|
+
console.error('profile: UA-фикс не удался:', e.message);
|
|
385
|
+
}
|
|
186
386
|
}
|
|
187
387
|
async _launchOnce() {
|
|
188
388
|
const options = {
|
|
@@ -195,6 +395,8 @@ export class DeepSeekBrowser {
|
|
|
195
395
|
};
|
|
196
396
|
if (this.channel)
|
|
197
397
|
options.channel = this.channel;
|
|
398
|
+
if (this.userAgent)
|
|
399
|
+
options.userAgent = this.userAgent;
|
|
198
400
|
try {
|
|
199
401
|
this.context = await chromium.launchPersistentContext(USER_DATA_DIR, options);
|
|
200
402
|
}
|
|
@@ -232,6 +434,23 @@ export class DeepSeekBrowser {
|
|
|
232
434
|
pg.on('response', (resp) => {
|
|
233
435
|
void this._onResponse(resp).catch(() => { });
|
|
234
436
|
});
|
|
437
|
+
// DeepSeek's own API calls carry an Authorization header (the app reads a
|
|
438
|
+
// token from its storage and sets it explicitly). A bare fetch from our
|
|
439
|
+
// code does NOT get it, so history_messages would answer 401/empty.
|
|
440
|
+
// Sniff the header off the real requests and replay it later.
|
|
441
|
+
pg.on('request', (req) => {
|
|
442
|
+
try {
|
|
443
|
+
const url = req.url();
|
|
444
|
+
if (!/deepseek\.com\/api\//i.test(url))
|
|
445
|
+
return;
|
|
446
|
+
const h = req.headers();
|
|
447
|
+
if (h['authorization'])
|
|
448
|
+
this._apiAuth = h['authorization'];
|
|
449
|
+
if (h['x-ds-pow-response'])
|
|
450
|
+
this._apiPow = h['x-ds-pow-response'];
|
|
451
|
+
}
|
|
452
|
+
catch { }
|
|
453
|
+
});
|
|
235
454
|
}
|
|
236
455
|
async _onResponse(resp) {
|
|
237
456
|
try {
|
|
@@ -273,11 +492,47 @@ export class DeepSeekBrowser {
|
|
|
273
492
|
await this.launch();
|
|
274
493
|
}
|
|
275
494
|
async waitForLogin() {
|
|
276
|
-
|
|
277
|
-
if (
|
|
495
|
+
// 1) Already signed in (session cookie in the persistent profile)?
|
|
496
|
+
if (await this.isLoggedIn()) {
|
|
497
|
+
if (this.debug)
|
|
498
|
+
console.error('auth: сессия уже активна');
|
|
278
499
|
return;
|
|
279
|
-
|
|
280
|
-
|
|
500
|
+
}
|
|
501
|
+
// 2) Try to sign in automatically with the saved credentials. The
|
|
502
|
+
// password lives in the config (~/.zames/config.json) and is only used
|
|
503
|
+
// when the session is logged out — i.e. exactly the "re-login without
|
|
504
|
+
// asking" case. If the login form is absent (e.g. DeepSeek uses a
|
|
505
|
+
// captcha / another provider), this fails and we fall through.
|
|
506
|
+
if (this.auth.username && this.auth.password) {
|
|
507
|
+
console.log(theme.system(this._t('auth.auto_login')));
|
|
508
|
+
const ok = await this._autoLogin().catch((e) => {
|
|
509
|
+
console.log(theme.warn(this._t('auth.auto_login_failed', { v: e.message })));
|
|
510
|
+
return false;
|
|
511
|
+
});
|
|
512
|
+
if (ok) {
|
|
513
|
+
console.log(theme.assistant(this._t('auth.auto_login_ok')));
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
// 3) Ask the operator for credentials in the terminal, try them, and
|
|
518
|
+
// only then fall back to the manual hint. Interactive only: in a
|
|
519
|
+
// pipe/redirect (or headless without credentials) we print a hint.
|
|
520
|
+
const asked = await this._promptAndLogin().catch((e) => {
|
|
521
|
+
if (this.debug)
|
|
522
|
+
console.error('auth: prompt error:', e.message);
|
|
523
|
+
return false;
|
|
524
|
+
});
|
|
525
|
+
if (asked)
|
|
526
|
+
return;
|
|
527
|
+
// 4) Last resort: manual sign-in in the browser window.
|
|
528
|
+
console.log('\n' + theme.warn(this._t('auth.need_login')));
|
|
529
|
+
if (this.headless) {
|
|
530
|
+
console.log(' ' + this._t('auth.manual_hint_headless'));
|
|
531
|
+
}
|
|
532
|
+
else {
|
|
533
|
+
console.log(' ' + this._t('auth.manual_hint'));
|
|
534
|
+
}
|
|
535
|
+
console.log('\n');
|
|
281
536
|
const readline = await import('readline');
|
|
282
537
|
const rl = readline.createInterface({
|
|
283
538
|
input: process.stdin,
|
|
@@ -289,20 +544,286 @@ export class DeepSeekBrowser {
|
|
|
289
544
|
resolve();
|
|
290
545
|
});
|
|
291
546
|
});
|
|
547
|
+
if (await this.isLoggedIn()) {
|
|
548
|
+
await this._persistSessionIfNeeded();
|
|
549
|
+
console.log(theme.assistant(this._t('auth.auto_login_ok')));
|
|
550
|
+
}
|
|
292
551
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
552
|
+
/**
|
|
553
|
+
* Try to sign in with this.auth. Returns true when the login form was
|
|
554
|
+
* filled and the session became active. Never throws for a missing form —
|
|
555
|
+
* the caller decides whether to fall back.
|
|
556
|
+
*/
|
|
557
|
+
async _autoLogin() {
|
|
558
|
+
const filled = await this._fillLoginForm(this.auth.username, this.auth.password);
|
|
559
|
+
if (!filled)
|
|
560
|
+
return false;
|
|
561
|
+
const ok = await this._waitLoggedIn(20_000);
|
|
562
|
+
if (ok)
|
|
563
|
+
await this._persistSessionIfNeeded();
|
|
564
|
+
return ok;
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Ask the operator for login/password in the terminal (only in a TTY) and
|
|
568
|
+
* try them. An empty password falls back to the saved one. Returns true
|
|
569
|
+
* when the sign-in succeeded.
|
|
570
|
+
*/
|
|
571
|
+
async _promptAndLogin() {
|
|
572
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
573
|
+
return false;
|
|
574
|
+
const hasSaved = !!(this.auth.username && this.auth.password);
|
|
575
|
+
// Prompt only when we have a login field to fill.
|
|
576
|
+
if (!(await this._loginFormVisible())) {
|
|
577
|
+
if (this.debug)
|
|
578
|
+
console.error('auth: форма входа не найдена');
|
|
579
|
+
return false;
|
|
580
|
+
}
|
|
581
|
+
console.log('\n' + theme.warn(this._t('auth.need_login')));
|
|
582
|
+
// Username via a normal readline question (echoed).
|
|
583
|
+
let user = this.auth.username;
|
|
584
|
+
if (!user) {
|
|
585
|
+
const readline = await import('readline');
|
|
586
|
+
const rl = readline.createInterface({
|
|
587
|
+
input: process.stdin,
|
|
588
|
+
output: process.stdout,
|
|
589
|
+
terminal: true,
|
|
590
|
+
});
|
|
591
|
+
user = (await new Promise((resolve) => rl.question(this._t('auth.prompt_login'), (a) => resolve(a)))).trim();
|
|
592
|
+
rl.close();
|
|
593
|
+
}
|
|
594
|
+
// Password via raw keystroke reading, echoed as `*` (readline would echo
|
|
595
|
+
// it in clear or swallow the prompt — see askPassword).
|
|
596
|
+
const passPromptKey = hasSaved
|
|
597
|
+
? 'auth.prompt_password_saved'
|
|
598
|
+
: 'auth.prompt_password';
|
|
599
|
+
const pass = await askPassword(this._t(passPromptKey));
|
|
600
|
+
const password = pass || this.auth.password;
|
|
601
|
+
if (!user || !password)
|
|
602
|
+
return false;
|
|
603
|
+
console.log(theme.system(this._t('auth.auto_login')));
|
|
604
|
+
const filled = await this._fillLoginForm(user, password);
|
|
605
|
+
if (!filled) {
|
|
606
|
+
console.log(theme.warn(this._t('auth.form_not_found')));
|
|
607
|
+
return false;
|
|
608
|
+
}
|
|
609
|
+
const ok = await this._waitLoggedIn(20_000);
|
|
610
|
+
if (ok) {
|
|
611
|
+
// Remember the credentials so the next launch can re-login silently.
|
|
612
|
+
this.auth.username = user;
|
|
613
|
+
this.auth.password = password;
|
|
614
|
+
await this._persistSessionIfNeeded();
|
|
615
|
+
this.onAuthSave?.(user, password);
|
|
616
|
+
console.log(theme.assistant(this._t('auth.auto_login_ok')));
|
|
617
|
+
}
|
|
618
|
+
else {
|
|
619
|
+
// Detect a credentials error shown by DeepSeek, otherwise report a
|
|
620
|
+
// generic failure with the page hint so the operator can react.
|
|
621
|
+
const reason = await this._readLoginError();
|
|
622
|
+
if (reason) {
|
|
623
|
+
console.log(theme.error(this._t('auth.login_rejected', { v: reason })));
|
|
624
|
+
}
|
|
625
|
+
else {
|
|
626
|
+
console.log(theme.warn(this._t('auth.auto_login_failed', { v: this._t('auth.no_reason') })));
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return ok;
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Read a visible login/credentials error message from the page, if any.
|
|
633
|
+
* Best-effort: returns '' when nothing recognizable is found.
|
|
634
|
+
*/
|
|
635
|
+
async _readLoginError() {
|
|
636
|
+
try {
|
|
637
|
+
const txt = await this.page.evaluate(() => {
|
|
638
|
+
const sels = [
|
|
639
|
+
'[role="alert"]',
|
|
640
|
+
'[class*="error" i]',
|
|
641
|
+
'[class*="toast" i]',
|
|
642
|
+
'[class*="notification" i]',
|
|
643
|
+
];
|
|
644
|
+
let out = '';
|
|
645
|
+
for (const s of sels) {
|
|
646
|
+
for (const el of Array.from(document.querySelectorAll(s))) {
|
|
647
|
+
const e = el;
|
|
648
|
+
const st = getComputedStyle(e);
|
|
649
|
+
if (st.display === 'none' || st.visibility === 'hidden')
|
|
650
|
+
continue;
|
|
651
|
+
const t = (e.innerText || '').trim();
|
|
652
|
+
if (t)
|
|
653
|
+
out += ' ' + t;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return out.replace(/\s+/g, ' ').trim();
|
|
657
|
+
});
|
|
658
|
+
return txt.slice(0, 200);
|
|
659
|
+
}
|
|
660
|
+
catch {
|
|
661
|
+
return '';
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
/** Is a login form (password field) present on the page? */
|
|
665
|
+
async _loginFormVisible() {
|
|
666
|
+
return await this.page
|
|
667
|
+
.locator(PASSWORD_SELECTORS.join(', '))
|
|
668
|
+
.first()
|
|
669
|
+
.isVisible({ timeout: 2000 })
|
|
670
|
+
.catch(() => false);
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Fill the DeepSeek sign-in form with the given credentials and submit it.
|
|
674
|
+
* Returns false if the form (or the submit button) is not found.
|
|
675
|
+
*/
|
|
676
|
+
async _fillLoginForm(username, password) {
|
|
677
|
+
const pwd = await this._findVisible(PASSWORD_SELECTORS, 4000);
|
|
678
|
+
if (!pwd)
|
|
679
|
+
return false;
|
|
680
|
+
// The email/phone field is the text input above the password field. Try
|
|
681
|
+
// the known selectors first, then fall back to the nearest text input.
|
|
682
|
+
let userInput = await this._findVisible(LOGIN_SELECTORS, 2000);
|
|
683
|
+
if (!userInput) {
|
|
684
|
+
// Find the index of the password input and use the closest preceding
|
|
685
|
+
// text/email/tel input as the login field.
|
|
686
|
+
const idx = await this.page.evaluate((pwdSel) => {
|
|
687
|
+
const p = document.querySelector(pwdSel);
|
|
688
|
+
if (!p)
|
|
689
|
+
return -1;
|
|
690
|
+
const inputs = Array.from(document.querySelectorAll('input'));
|
|
691
|
+
const pi = inputs.indexOf(p);
|
|
692
|
+
for (let i = pi - 1; i >= 0; i--) {
|
|
693
|
+
const t = (inputs[i].type || 'text').toLowerCase();
|
|
694
|
+
if (t === 'text' || t === 'email' || t === 'tel')
|
|
695
|
+
return i;
|
|
696
|
+
}
|
|
697
|
+
return -1;
|
|
698
|
+
}, PASSWORD_SELECTORS[0]);
|
|
699
|
+
if (idx >= 0)
|
|
700
|
+
userInput = this.page.locator('input').nth(idx);
|
|
701
|
+
}
|
|
702
|
+
if (!userInput)
|
|
703
|
+
return false;
|
|
704
|
+
await this._setInputText(userInput, username);
|
|
705
|
+
await this._setInputText(pwd, password);
|
|
706
|
+
await this.page.waitForTimeout(150);
|
|
707
|
+
// Submit. DeepSeek's login form is a React form; the button text is
|
|
708
|
+
// localized and classes change, so we try, in order:
|
|
709
|
+
// 1. known submit selectors;
|
|
710
|
+
// 2. a submit/primary button inside the same <form> as the password;
|
|
711
|
+
// 3. pressing Enter in the password field;
|
|
712
|
+
// 4. clicking the password field's closest button sibling.
|
|
713
|
+
// After each attempt we give the login a short window and stop as soon as
|
|
714
|
+
// it succeeds, so a stray click does not fire on an already-logged-in page.
|
|
715
|
+
const tryLogin = async () => {
|
|
716
|
+
if (await this._waitLoggedIn(3000))
|
|
300
717
|
return true;
|
|
718
|
+
return false;
|
|
719
|
+
};
|
|
720
|
+
// Precise first attempt: DeepSeek's button is
|
|
721
|
+
// <div role="button" class="ds-button ds-button--primary ...">
|
|
722
|
+
// <span class="ds-button__content">Log in</span></div>
|
|
723
|
+
// Click exactly the one whose visible label is a login word, so we never
|
|
724
|
+
// hit "Log in with Google" or another primary button by mistake.
|
|
725
|
+
const precise = this.page
|
|
726
|
+
.locator('div[role="button"].ds-button--primary, button')
|
|
727
|
+
.filter({ hasText: /^\s*(log ?in|sign ?in|войти)\s*$/i });
|
|
728
|
+
const pc = await precise.count().catch(() => 0);
|
|
729
|
+
if (pc > 0) {
|
|
730
|
+
try {
|
|
731
|
+
await precise.last().click({ timeout: 2000 });
|
|
732
|
+
if (await tryLogin())
|
|
733
|
+
return true;
|
|
301
734
|
}
|
|
302
735
|
catch { }
|
|
303
736
|
}
|
|
737
|
+
for (const sel of LOGIN_SUBMIT_SELECTORS) {
|
|
738
|
+
const btn = this.page.locator(sel).last();
|
|
739
|
+
try {
|
|
740
|
+
if ((await btn.count()) === 0)
|
|
741
|
+
continue;
|
|
742
|
+
if (!(await btn.isVisible().catch(() => false)))
|
|
743
|
+
continue;
|
|
744
|
+
await btn.click({ timeout: 2000 });
|
|
745
|
+
if (await tryLogin())
|
|
746
|
+
return true;
|
|
747
|
+
}
|
|
748
|
+
catch { }
|
|
749
|
+
}
|
|
750
|
+
// Button inside the same form as the password field.
|
|
751
|
+
const formClicked = await this.page
|
|
752
|
+
.evaluate((pwdSel) => {
|
|
753
|
+
const p = document.querySelector(pwdSel);
|
|
754
|
+
if (!p)
|
|
755
|
+
return false;
|
|
756
|
+
const form = p.closest('form');
|
|
757
|
+
const scope = form || document;
|
|
758
|
+
const btns = Array.from(scope.querySelectorAll('button[type="submit"], button, div[role="button"]'));
|
|
759
|
+
for (const b of btns) {
|
|
760
|
+
const txt = (b.textContent || '').trim().toLowerCase();
|
|
761
|
+
const aria = (b.getAttribute('aria-label') || '').toLowerCase();
|
|
762
|
+
if (/log ?in|sign ?in|войти|continue|продолж/.test(txt + ' ' + aria)) {
|
|
763
|
+
b.click();
|
|
764
|
+
return true;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
// Fallback: the primary-looking button in the scope.
|
|
768
|
+
const primary = btns.find((b) => {
|
|
769
|
+
const cls = (b.className || '').toString();
|
|
770
|
+
return /primary|submit|ds-button--primary/i.test(cls);
|
|
771
|
+
});
|
|
772
|
+
if (primary) {
|
|
773
|
+
primary.click();
|
|
774
|
+
return true;
|
|
775
|
+
}
|
|
776
|
+
return false;
|
|
777
|
+
}, PASSWORD_SELECTORS[0])
|
|
778
|
+
.catch(() => false);
|
|
779
|
+
if (formClicked && (await tryLogin()))
|
|
780
|
+
return true;
|
|
781
|
+
// Enter in the password field.
|
|
782
|
+
await pwd.press('Enter').catch(() => { });
|
|
783
|
+
return true;
|
|
784
|
+
}
|
|
785
|
+
/** Poll isLoggedIn until it succeeds or the deadline passes. */
|
|
786
|
+
async _waitLoggedIn(timeoutMs) {
|
|
787
|
+
const deadline = Date.now() + timeoutMs;
|
|
788
|
+
while (Date.now() < deadline) {
|
|
789
|
+
if (await this.isLoggedIn().catch(() => false))
|
|
790
|
+
return true;
|
|
791
|
+
await this.page.waitForTimeout(500);
|
|
792
|
+
}
|
|
304
793
|
return false;
|
|
305
794
|
}
|
|
795
|
+
/**
|
|
796
|
+
* Persist the authenticated session. The persistent profile already keeps
|
|
797
|
+
* cookies on disk; `saveSession` (config) is kept as an explicit toggle for
|
|
798
|
+
* the operator. We additionally save a small marker so /doctor and the next
|
|
799
|
+
* launch can tell that a session exists.
|
|
800
|
+
*/
|
|
801
|
+
async _persistSessionIfNeeded() {
|
|
802
|
+
if (!this.auth.saveSession)
|
|
803
|
+
return;
|
|
804
|
+
if (!(await this.isLoggedIn().catch(() => false)))
|
|
805
|
+
return;
|
|
806
|
+
try {
|
|
807
|
+
await fs.mkdir(path.join(os.homedir(), '.zames'), { recursive: true });
|
|
808
|
+
await fs.writeFile(AUTH_MARKER_FILE, JSON.stringify({ at: new Date().toISOString() }, null, 2), 'utf-8');
|
|
809
|
+
}
|
|
810
|
+
catch { }
|
|
811
|
+
}
|
|
812
|
+
async isLoggedIn() {
|
|
813
|
+
// Single combined query with a short timeout: the old loop waited up to
|
|
814
|
+
// 3s PER selector (9s total), and _waitLoggedIn calls this repeatedly,
|
|
815
|
+
// which made login feel like it "hangs".
|
|
816
|
+
try {
|
|
817
|
+
await this.page
|
|
818
|
+
.locator(INPUT_SELECTORS.join(', '))
|
|
819
|
+
.first()
|
|
820
|
+
.waitFor({ state: 'visible', timeout: 2000 });
|
|
821
|
+
return true;
|
|
822
|
+
}
|
|
823
|
+
catch {
|
|
824
|
+
return false;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
306
827
|
async newChat() {
|
|
307
828
|
const newChatBtn = this.page
|
|
308
829
|
.locator('button, a')
|
|
@@ -523,10 +1044,17 @@ export class DeepSeekBrowser {
|
|
|
523
1044
|
if (e instanceof RateLimitError) {
|
|
524
1045
|
rateLimitRetries++;
|
|
525
1046
|
if (rateLimitRetries > this.maxRateLimitRetries) {
|
|
526
|
-
console.error(theme.error(
|
|
1047
|
+
console.error(theme.error(this._t('ds.rate_limit_give_up', {
|
|
1048
|
+
attempt: rateLimitRetries,
|
|
1049
|
+
min: Math.ceil(this.rateLimitWaitMs / 60000),
|
|
1050
|
+
})));
|
|
527
1051
|
throw e;
|
|
528
1052
|
}
|
|
529
|
-
console.error(theme.warn(
|
|
1053
|
+
console.error(theme.warn(this._t('ds.rate_limit_wait', {
|
|
1054
|
+
min: Math.ceil(this.rateLimitWaitMs / 60000),
|
|
1055
|
+
attempt: rateLimitRetries,
|
|
1056
|
+
max: this.maxRateLimitRetries,
|
|
1057
|
+
})));
|
|
530
1058
|
// Interruptible: Esc/Ctrl+C must cancel this long wait too,
|
|
531
1059
|
// otherwise "stop" stays frozen for up to 5 minutes.
|
|
532
1060
|
const aborted = await this._sleepInterruptible(this.rateLimitWaitMs);
|
|
@@ -534,15 +1062,44 @@ export class DeepSeekBrowser {
|
|
|
534
1062
|
return '(прервано пользователем)';
|
|
535
1063
|
continue;
|
|
536
1064
|
}
|
|
537
|
-
|
|
1065
|
+
// Server busy / overloaded: clear in seconds, so we retry quickly
|
|
1066
|
+
// (unlike the rate limit). Without this branch the error fell into the
|
|
1067
|
+
// generic ask() retry and the operator saw a bare message with no
|
|
1068
|
+
// explanation of what DeepSeek is doing.
|
|
1069
|
+
if (e instanceof ServerBusyError) {
|
|
1070
|
+
serverBusyRetries++;
|
|
1071
|
+
if (serverBusyRetries > this.maxServerBusyRetries) {
|
|
1072
|
+
console.error(theme.error(this._t('ds.server_busy_give_up', {
|
|
1073
|
+
attempt: serverBusyRetries,
|
|
1074
|
+
})));
|
|
1075
|
+
throw e;
|
|
1076
|
+
}
|
|
1077
|
+
console.error(theme.warn(this._t('ds.server_busy_wait', {
|
|
1078
|
+
sec: Math.ceil(this.serverBusyWaitMs / 1000),
|
|
1079
|
+
attempt: serverBusyRetries,
|
|
1080
|
+
max: this.maxServerBusyRetries,
|
|
1081
|
+
})));
|
|
1082
|
+
const aborted = await this._sleepInterruptible(this.serverBusyWaitMs);
|
|
1083
|
+
if (aborted)
|
|
1084
|
+
return '(прервано пользователем)';
|
|
1085
|
+
continue;
|
|
1086
|
+
}
|
|
1087
|
+
console.error('\n' +
|
|
1088
|
+
theme.warn(this._t('ds.ask_retry', {
|
|
1089
|
+
attempt,
|
|
1090
|
+
max: this.askRetries,
|
|
1091
|
+
error: e.message,
|
|
1092
|
+
})));
|
|
538
1093
|
if (/closed|crash|Target page|browser/i.test(e.message)) {
|
|
539
|
-
console.error('
|
|
1094
|
+
console.error(theme.warn(this._t('ds.ask_restart_browser')));
|
|
540
1095
|
try {
|
|
541
1096
|
await this.restart();
|
|
542
1097
|
await this.waitForLogin();
|
|
543
1098
|
}
|
|
544
1099
|
catch (re) {
|
|
545
|
-
console.error(
|
|
1100
|
+
console.error(theme.warn(this._t('ds.ask_restart_failed', {
|
|
1101
|
+
error: re.message,
|
|
1102
|
+
})));
|
|
546
1103
|
}
|
|
547
1104
|
}
|
|
548
1105
|
if (attempt < this.askRetries) {
|
|
@@ -550,7 +1107,10 @@ export class DeepSeekBrowser {
|
|
|
550
1107
|
}
|
|
551
1108
|
}
|
|
552
1109
|
}
|
|
553
|
-
throw new Error(
|
|
1110
|
+
throw new Error(this._t('ds.ask_failed', {
|
|
1111
|
+
max: this.askRetries,
|
|
1112
|
+
error: lastErr?.message || '',
|
|
1113
|
+
}));
|
|
554
1114
|
}
|
|
555
1115
|
async _setInputText(input, text) {
|
|
556
1116
|
const tag = await input.evaluate((el) => el.tagName.toLowerCase());
|
|
@@ -606,7 +1166,10 @@ export class DeepSeekBrowser {
|
|
|
606
1166
|
return el.innerText || el.textContent || "";
|
|
607
1167
|
});
|
|
608
1168
|
if (norm(got2) !== norm(text)) {
|
|
609
|
-
throw new Error(
|
|
1169
|
+
throw new Error(this._t('ds.input_partial', {
|
|
1170
|
+
got: norm(got2).length,
|
|
1171
|
+
want: norm(text).length,
|
|
1172
|
+
}));
|
|
610
1173
|
}
|
|
611
1174
|
}
|
|
612
1175
|
}
|
|
@@ -690,20 +1253,47 @@ export class DeepSeekBrowser {
|
|
|
690
1253
|
const gap = this.minSendIntervalMs - (Date.now() - this._lastSentAt);
|
|
691
1254
|
if (gap <= 0)
|
|
692
1255
|
return;
|
|
693
|
-
|
|
694
|
-
|
|
1256
|
+
// Report the pause to the UI as an ANIMATED status (with the remaining
|
|
1257
|
+
// seconds) instead of the old static console line — the dots used to be
|
|
1258
|
+
// frozen here, which looked like the spinner had hung. The seconds are
|
|
1259
|
+
// refreshed once per second, so the status visibly counts down.
|
|
1260
|
+
const report = (leftMs) => {
|
|
1261
|
+
const secs = Math.max(0, Math.ceil(leftMs / 1000));
|
|
1262
|
+
if (this.onSendPause) {
|
|
1263
|
+
try {
|
|
1264
|
+
this.onSendPause(secs);
|
|
1265
|
+
}
|
|
1266
|
+
catch { }
|
|
1267
|
+
}
|
|
1268
|
+
else {
|
|
1269
|
+
console.error(theme.warn(`⏳ send pause ${secs}s`));
|
|
1270
|
+
}
|
|
1271
|
+
};
|
|
1272
|
+
report(gap);
|
|
1273
|
+
await this._sleepInterruptible(gap, report);
|
|
695
1274
|
}
|
|
696
1275
|
// Sleep in small slices so Esc/Ctrl+C can cancel the wait promptly.
|
|
697
1276
|
// Returns true if the sleep was cut short by _abort.
|
|
698
|
-
|
|
1277
|
+
//
|
|
1278
|
+
// onTick (optional) is called about once per second with the remaining time,
|
|
1279
|
+
// so the UI can show a live countdown during the pause.
|
|
1280
|
+
async _sleepInterruptible(ms, onTick) {
|
|
699
1281
|
const step = 100;
|
|
700
1282
|
let left = ms;
|
|
1283
|
+
let sinceTick = 0;
|
|
701
1284
|
while (left > 0) {
|
|
702
1285
|
if (this._abort)
|
|
703
1286
|
return true;
|
|
704
1287
|
const chunk = Math.min(step, left);
|
|
705
1288
|
await this.page.waitForTimeout(chunk);
|
|
706
1289
|
left -= chunk;
|
|
1290
|
+
if (onTick) {
|
|
1291
|
+
sinceTick += chunk;
|
|
1292
|
+
if (sinceTick >= 1000) {
|
|
1293
|
+
sinceTick = 0;
|
|
1294
|
+
onTick(left);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
707
1297
|
}
|
|
708
1298
|
return this._abort;
|
|
709
1299
|
}
|
|
@@ -729,7 +1319,7 @@ export class DeepSeekBrowser {
|
|
|
729
1319
|
return '(прервано пользователем)';
|
|
730
1320
|
const input = await this._findVisible(INPUT_SELECTORS, 10_000);
|
|
731
1321
|
if (!input) {
|
|
732
|
-
throw new Error('
|
|
1322
|
+
throw new Error(this._t('ds.input_missing'));
|
|
733
1323
|
}
|
|
734
1324
|
const beforeText = await this._readLastAnswerTextClean().catch(() => '');
|
|
735
1325
|
this._askDebug('SEND agent=' + agent + ' len=' + prompt.length + ' beforeLen=' + beforeText.length + ' beforeHead=' + JSON.stringify(beforeText.slice(0, 60)));
|
|
@@ -890,8 +1480,7 @@ export class DeepSeekBrowser {
|
|
|
890
1480
|
}
|
|
891
1481
|
}
|
|
892
1482
|
if (!started) {
|
|
893
|
-
throw new Error('
|
|
894
|
-
'Проверьте чат DeepSeek вручную.');
|
|
1483
|
+
throw new Error(this._t('ds.send_no_start'));
|
|
895
1484
|
}
|
|
896
1485
|
// Wait until the answer stops changing. We check the "not generating"
|
|
897
1486
|
// condition via text growth, NOT via _isGenerating().
|
|
@@ -961,12 +1550,11 @@ export class DeepSeekBrowser {
|
|
|
961
1550
|
return this._netCapture;
|
|
962
1551
|
}
|
|
963
1552
|
this._askDebug('THROW no-new-answer lastLen=' + last.length + ' beforeLen=' + beforeText.length);
|
|
964
|
-
throw new Error('
|
|
965
|
-
'Возможно, сообщение не отправилось.');
|
|
1553
|
+
throw new Error(this._t('ds.send_no_new_answer'));
|
|
966
1554
|
}
|
|
967
1555
|
async dumpDom(filePath) {
|
|
968
1556
|
if (!this.page)
|
|
969
|
-
throw new Error('
|
|
1557
|
+
throw new Error(this._t('ds.not_launched'));
|
|
970
1558
|
const html = await this.page.content();
|
|
971
1559
|
await fs.writeFile(filePath, html, 'utf-8');
|
|
972
1560
|
const report = await this.page.evaluate((sels) => {
|
|
@@ -1060,9 +1648,220 @@ export class DeepSeekBrowser {
|
|
|
1060
1648
|
return true;
|
|
1061
1649
|
}
|
|
1062
1650
|
catch (e) {
|
|
1063
|
-
throw new Error(
|
|
1651
|
+
throw new Error(this._t('ds.open_chat_failed', { id, error: e.message }));
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
// Fetch the WHOLE dialogue from DeepSeek's own history endpoint instead of
|
|
1655
|
+
// scraping the DOM. The rendered page only shows a part of the history (the
|
|
1656
|
+
// list is virtualized), while /api/v0/chat/history_messages returns every
|
|
1657
|
+
// message of the chat as JSON. The request is made INSIDE the page, so the
|
|
1658
|
+
// auth cookie and the CSRF/PoW handling are the browser's.
|
|
1659
|
+
//
|
|
1660
|
+
// Shape of data.biz_data.chat_messages[]: { role: 'USER'|'ASSISTANT',
|
|
1661
|
+
// fragments: [{ type: 'REQUEST'|'RESPONSE'|'THINK'|'FILE', content }] }.
|
|
1662
|
+
// We keep REQUEST (user) and RESPONSE (assistant); THINK (reasoning) is
|
|
1663
|
+
// skipped and FILE fragments carry no text.
|
|
1664
|
+
async fetchChatMessages(id) {
|
|
1665
|
+
this._lastHistoryError = '';
|
|
1666
|
+
if (!this.page || !id)
|
|
1667
|
+
return [];
|
|
1668
|
+
// The Authorization header is sniffed off DeepSeek's own API requests. On
|
|
1669
|
+
// a freshly opened chat that request may not have fired yet — wait a bit
|
|
1670
|
+
// (up to 3s) so the replay is authorized instead of answering 401/empty.
|
|
1671
|
+
for (let i = 0; i < 15 && !this._apiAuth; i++) {
|
|
1672
|
+
await this.page.waitForTimeout(200);
|
|
1673
|
+
}
|
|
1674
|
+
const auth = this._apiAuth;
|
|
1675
|
+
const pow = this._apiPow;
|
|
1676
|
+
const res = await this.page
|
|
1677
|
+
.evaluate(async (opts) => {
|
|
1678
|
+
try {
|
|
1679
|
+
const url = 'https://chat.deepseek.com/api/v0/chat/history_messages?chat_session_id=' +
|
|
1680
|
+
encodeURIComponent(opts.chatId);
|
|
1681
|
+
const headers = {
|
|
1682
|
+
accept: 'application/json',
|
|
1683
|
+
};
|
|
1684
|
+
if (opts.auth)
|
|
1685
|
+
headers['authorization'] = opts.auth;
|
|
1686
|
+
if (opts.pow)
|
|
1687
|
+
headers['x-ds-pow-response'] = opts.pow;
|
|
1688
|
+
const resp = await fetch(url, {
|
|
1689
|
+
credentials: 'include',
|
|
1690
|
+
headers,
|
|
1691
|
+
});
|
|
1692
|
+
if (!resp.ok)
|
|
1693
|
+
return { error: 'HTTP ' + resp.status, list: [] };
|
|
1694
|
+
const json = await resp.json();
|
|
1695
|
+
const messages = json && json.data && json.data.biz_data
|
|
1696
|
+
? json.data.biz_data.chat_messages
|
|
1697
|
+
: null;
|
|
1698
|
+
if (!Array.isArray(messages)) {
|
|
1699
|
+
return { error: 'no chat_messages in response', list: [] };
|
|
1700
|
+
}
|
|
1701
|
+
const NL = String.fromCharCode(10);
|
|
1702
|
+
const out = [];
|
|
1703
|
+
for (const m of messages) {
|
|
1704
|
+
const role = m && m.role === 'ASSISTANT' ? 'assistant' : 'user';
|
|
1705
|
+
const want = role === 'assistant' ? 'RESPONSE' : 'REQUEST';
|
|
1706
|
+
let text = '';
|
|
1707
|
+
for (const fr of (m && m.fragments) || []) {
|
|
1708
|
+
if (!fr || fr.type !== want)
|
|
1709
|
+
continue;
|
|
1710
|
+
if (typeof fr.content === 'string')
|
|
1711
|
+
text += fr.content;
|
|
1712
|
+
}
|
|
1713
|
+
text = text.replace(new RegExp(NL + '{3,}', 'g'), NL + NL).trim();
|
|
1714
|
+
if (text)
|
|
1715
|
+
out.push({ role, text });
|
|
1716
|
+
}
|
|
1717
|
+
return { error: '', list: out };
|
|
1718
|
+
}
|
|
1719
|
+
catch (e) {
|
|
1720
|
+
return { error: 'fetch failed: ' + e.message, list: [] };
|
|
1721
|
+
}
|
|
1722
|
+
}, { chatId: id, auth, pow })
|
|
1723
|
+
.catch((e) => ({
|
|
1724
|
+
error: 'evaluate failed: ' + e.message,
|
|
1725
|
+
list: [],
|
|
1726
|
+
}));
|
|
1727
|
+
this._lastHistoryError = res?.error || '';
|
|
1728
|
+
const list = (res?.list || []);
|
|
1729
|
+
if (list.length || !res?.error)
|
|
1730
|
+
return list;
|
|
1731
|
+
// Fallback: Playwright's own request context (shares the browser cookies)
|
|
1732
|
+
// when the in-page fetch was blocked (CSP, CORS, a page error).
|
|
1733
|
+
this._lastHistoryError = res.error;
|
|
1734
|
+
try {
|
|
1735
|
+
const resp = await this.page.request.get('https://chat.deepseek.com/api/v0/chat/history_messages?chat_session_id=' +
|
|
1736
|
+
encodeURIComponent(id), { headers: { accept: 'application/json' } });
|
|
1737
|
+
if (!resp.ok())
|
|
1738
|
+
return list;
|
|
1739
|
+
const json = (await resp.json());
|
|
1740
|
+
const messages = json?.data?.biz_data?.chat_messages;
|
|
1741
|
+
if (!Array.isArray(messages))
|
|
1742
|
+
return list;
|
|
1743
|
+
const NL = String.fromCharCode(10);
|
|
1744
|
+
const out = [];
|
|
1745
|
+
for (const m of messages) {
|
|
1746
|
+
const role = m && m.role === 'ASSISTANT' ? 'assistant' : 'user';
|
|
1747
|
+
const want = role === 'assistant' ? 'RESPONSE' : 'REQUEST';
|
|
1748
|
+
let text = '';
|
|
1749
|
+
for (const fr of (m && m.fragments) || []) {
|
|
1750
|
+
if (!fr || fr.type !== want)
|
|
1751
|
+
continue;
|
|
1752
|
+
if (typeof fr.content === 'string')
|
|
1753
|
+
text += fr.content;
|
|
1754
|
+
}
|
|
1755
|
+
text = text.replace(new RegExp(NL + '{3,}', 'g'), NL + NL).trim();
|
|
1756
|
+
if (text)
|
|
1757
|
+
out.push({ role, text });
|
|
1758
|
+
}
|
|
1759
|
+
this._lastHistoryError = '';
|
|
1760
|
+
return out;
|
|
1761
|
+
}
|
|
1762
|
+
catch (e) {
|
|
1763
|
+
this._lastHistoryError = 'request failed: ' + e.message;
|
|
1764
|
+
return list;
|
|
1064
1765
|
}
|
|
1065
1766
|
}
|
|
1767
|
+
// Read the WHOLE visible dialogue of the currently open chat, top to
|
|
1768
|
+
// bottom. Used by /resume and /resume-id so the operator sees the restored
|
|
1769
|
+
// context in the terminal instead of just "Chat opened.".
|
|
1770
|
+
//
|
|
1771
|
+
// Best-effort DOM scraping: DeepSeek renders every message as a markdown
|
|
1772
|
+
// block (assistant) or a plain bubble (user). Role detection relies on the
|
|
1773
|
+
// ds-assistant-message-* class; anything that is not an assistant block is
|
|
1774
|
+
// treated as a user message. The model's reasoning (.ds-think-content) is
|
|
1775
|
+
// skipped, exactly like in _readLastAnswerText.
|
|
1776
|
+
async readChatMessages() {
|
|
1777
|
+
if (!this.page)
|
|
1778
|
+
return [];
|
|
1779
|
+
const raw = await this.page
|
|
1780
|
+
.evaluate(() => {
|
|
1781
|
+
const inThink = (e) => {
|
|
1782
|
+
let n = e;
|
|
1783
|
+
while (n) {
|
|
1784
|
+
const cls = (n.className || '').toString();
|
|
1785
|
+
if (/ds-think-content|thinking-content/i.test(cls))
|
|
1786
|
+
return true;
|
|
1787
|
+
n = n.parentElement;
|
|
1788
|
+
}
|
|
1789
|
+
return false;
|
|
1790
|
+
};
|
|
1791
|
+
const textOf = (e) => {
|
|
1792
|
+
const h = e;
|
|
1793
|
+
const t = h.innerText || h.textContent || '';
|
|
1794
|
+
const NL = String.fromCharCode(10);
|
|
1795
|
+
return t.replace(new RegExp(NL + '{3,}', 'g'), NL + NL).trim();
|
|
1796
|
+
};
|
|
1797
|
+
// A message block is "assistant" when it contains an answer markdown
|
|
1798
|
+
// wrapper (ds-markdown / ds-assistant-message), otherwise it is a
|
|
1799
|
+
// user bubble. DeepSeek's class names drift between builds, so the
|
|
1800
|
+
// detection is content-based, not class-prefix-based.
|
|
1801
|
+
const looksAssistant = (e) => {
|
|
1802
|
+
const cls = (e.className || '').toString();
|
|
1803
|
+
if (/ds-assistant-message|assistant-message/i.test(cls))
|
|
1804
|
+
return true;
|
|
1805
|
+
if (e.querySelector('[class*="ds-assistant-message"]'))
|
|
1806
|
+
return true;
|
|
1807
|
+
// A user bubble has no rendered markdown; an answer does.
|
|
1808
|
+
if (e.querySelector('[class*="ds-markdown"]'))
|
|
1809
|
+
return true;
|
|
1810
|
+
return false;
|
|
1811
|
+
};
|
|
1812
|
+
// Message-level containers first (broad), then the answer wrappers.
|
|
1813
|
+
const containerSels = [
|
|
1814
|
+
'[data-message-id]',
|
|
1815
|
+
'[class*="ds-message"]',
|
|
1816
|
+
'[class*="chat-message"]',
|
|
1817
|
+
'[class*="message-item"]',
|
|
1818
|
+
'[class*="_message"]',
|
|
1819
|
+
];
|
|
1820
|
+
let blocks = [];
|
|
1821
|
+
for (const s of containerSels) {
|
|
1822
|
+
const found = Array.from(document.querySelectorAll(s)).filter((e) => !inThink(e) && textOf(e).length > 0);
|
|
1823
|
+
if (found.length) {
|
|
1824
|
+
blocks = found;
|
|
1825
|
+
break;
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
const out = [];
|
|
1829
|
+
if (blocks.length) {
|
|
1830
|
+
for (const b of blocks) {
|
|
1831
|
+
const t = textOf(b);
|
|
1832
|
+
if (!t)
|
|
1833
|
+
continue;
|
|
1834
|
+
out.push({
|
|
1835
|
+
role: looksAssistant(b) ? 'assistant' : 'user',
|
|
1836
|
+
text: t,
|
|
1837
|
+
});
|
|
1838
|
+
}
|
|
1839
|
+
if (out.length)
|
|
1840
|
+
return out;
|
|
1841
|
+
}
|
|
1842
|
+
// Last resort: assistant answers only (no user turns) — better than
|
|
1843
|
+
// nothing when no message container matched.
|
|
1844
|
+
const sels = [
|
|
1845
|
+
'div.ds-assistant-message-main-content',
|
|
1846
|
+
'div[class*="ds-assistant-message-main-content"]',
|
|
1847
|
+
'div[class*="ds-markdown"]',
|
|
1848
|
+
];
|
|
1849
|
+
for (const s of sels) {
|
|
1850
|
+
const list = Array.from(document.querySelectorAll(s)).filter((e) => !inThink(e));
|
|
1851
|
+
if (!list.length)
|
|
1852
|
+
continue;
|
|
1853
|
+
for (const el of list) {
|
|
1854
|
+
const t = textOf(el);
|
|
1855
|
+
if (t)
|
|
1856
|
+
out.push({ role: 'assistant', text: t });
|
|
1857
|
+
}
|
|
1858
|
+
break;
|
|
1859
|
+
}
|
|
1860
|
+
return out;
|
|
1861
|
+
})
|
|
1862
|
+
.catch(() => []);
|
|
1863
|
+
return raw;
|
|
1864
|
+
}
|
|
1066
1865
|
async getCurrentChatId() {
|
|
1067
1866
|
try {
|
|
1068
1867
|
const url = this.page.url();
|