zames_pro 2.71.0 → 2.72.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/dist/browser.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { chromium, } from 'playwright';
2
2
  import { extractAnswer, extractTokenUsage, dumpNetBody, isFinishedWithoutAnswer, } from './net-capture.js';
3
3
  import { rewriteFailedAttachments } from './commands.js';
4
- import { CHAT_URL, CHAT_ID_RE, chatUrl, HISTORY_MESSAGES_URL, BUTTON_CLASS_RE, INPUT_SELECTORS, SEND_SELECTORS, STOP_SELECTORS, STOP_NAME_RE, CONTINUE_NAME_RE, ANSWER_SELECTORS, THINK_CLASS_RE, MESSAGE_CONTAINER_SELECTORS, RESTORE_ANSWER_SELECTORS, ASSISTANT_CLASS_RE, ASSISTANT_SELECTOR, ANSWER_MARKDOWN_SELECTOR, MARKDOWN_SELECTOR, CHAT_SIGNAL_NODES_SELECTOR, NEW_CHAT_SELECTOR, NEW_CHAT_TEXT_RE, PASSWORD_SELECTORS, LOGIN_SELECTORS, LOGIN_SUBMIT_SELECTORS, LOGIN_BUTTON_SELECTOR, LOGIN_NAME_RE, LOGIN_FORM_SUBMIT_RE, SIDEBAR_TOGGLE_SELECTORS, TOGGLE_SELECTORS, DEEP_THINKING_RE, WEB_SEARCH_RE, ATTACH_SELECTORS, STATUS_RE, RATE_LIMIT_RE, SERVER_BUSY_RE, GENERATION_ERR_RE, INCOMPLETE_STATUS_RE, ANSWER_URL_RE, } from './deepseek-ui.js';
4
+ import { parseHistoryMessages, extractChatMessages, scrapeChatList, scrapeChatMessages, } from './browser-chats.js';
5
+ import { CHAT_URL, CHAT_ID_RE, chatUrl, HISTORY_MESSAGES_URL, BUTTON_CLASS_RE, INPUT_SELECTORS, SEND_SELECTORS, STOP_SELECTORS, STOP_NAME_RE, CONTINUE_NAME_RE, ANSWER_SELECTORS, THINK_CLASS_RE, MESSAGE_CONTAINER_SELECTORS, RESTORE_ANSWER_SELECTORS, ASSISTANT_CLASS_RE, ASSISTANT_SELECTOR, ANSWER_MARKDOWN_SELECTOR, MARKDOWN_SELECTOR, CHAT_SIGNAL_NODES_SELECTOR, NEW_CHAT_SELECTOR, NEW_CHAT_TEXT_RE, PASSWORD_SELECTORS, LOGIN_SELECTORS, LOGIN_SUBMIT_SELECTORS, LOGIN_BUTTON_SELECTOR, LOGIN_NAME_RE, LOGIN_FORM_SUBMIT_RE, SIDEBAR_TOGGLE_SELECTORS, TOGGLE_SELECTORS, DEEP_THINKING_RE, WEB_SEARCH_RE, ATTACH_SELECTORS, ANSWER_URL_RE, } from './deepseek-ui.js';
5
6
  import { probeUiHealth, } from './ui-health.js';
7
+ import { readPageToastsInDom, readLoginErrorInDom, readLastAnswerInDom, chatSignalInDom, continueVisibleCheapInDom, stopButtonVisibleInDom, clickStopInDom, continueVisibleInDom, clickContinueInDom, readUserAgentInDom, findLoginInputIndexInDom, submitLoginFormInDom, setBusyTitleInDom, pasteTextIntoElement, readElementText, elementTagNameInDom, isElementEnabledInDom, readInputTrimmedTextInDom, dumpDomCountsInDom, } from './browser-dom.js';
6
8
  import path from 'path';
7
9
  import os from 'os';
8
10
  import fs from 'fs/promises';
@@ -13,234 +15,12 @@ import { translate, DEFAULT_LOCALE } from './i18n.js';
13
15
  // here preserves the existing public API (test/*.test.ts import them from
14
16
  // browser.ts).
15
17
  export { STOP_NAME_RE, CONTINUE_NAME_RE };
16
- // Muted terminal input for passwords. We do NOT use readline here: readline
17
- // echoes the typed text through its own internal _writeToOutput, which cannot
18
- // be reliably overridden from outside (a real bug: the login prompt swallowed
19
- // the password step). Instead we read raw keystrokes from stdin and echo one
20
- // `*` per typed character. In a non-TTY (pipe/redirect) we fall back to a
21
- // plain line read.
22
- export function askPassword(question) {
23
- return new Promise((resolve) => {
24
- process.stdout.write(question);
25
- // Non-interactive: read a single line from stdin as-is.
26
- if (!process.stdin.isTTY) {
27
- void import('readline').then(({ createInterface }) => {
28
- const rl = createInterface({
29
- input: process.stdin,
30
- output: process.stdout,
31
- });
32
- rl.question('', (answer) => {
33
- rl.close();
34
- resolve(answer);
35
- });
36
- });
37
- return;
38
- }
39
- const stdin = process.stdin;
40
- const wasRaw = stdin.isRaw;
41
- stdin.setRawMode(true);
42
- stdin.resume();
43
- let value = '';
44
- const onData = (buf) => {
45
- const s = buf.toString('utf8');
46
- for (const ch of s) {
47
- const code = ch.charCodeAt(0);
48
- // Enter / Ctrl+J -> done.
49
- if (ch === '\r' || ch === '\n') {
50
- done();
51
- return;
52
- }
53
- // Ctrl+C -> abort the whole process (same as elsewhere in the CLI).
54
- if (code === 3) {
55
- cleanup();
56
- process.stdout.write('\n');
57
- process.exit(130);
58
- }
59
- // Ctrl+D -> finish with whatever we have.
60
- if (code === 4) {
61
- done();
62
- return;
63
- }
64
- // Backspace (0x7f or 0x08): remove the last char and one star.
65
- if (code === 127 || code === 8) {
66
- if (value.length > 0) {
67
- value = value.slice(0, -1);
68
- process.stdout.write('\b \b');
69
- }
70
- continue;
71
- }
72
- // Ignore other control characters (arrows, etc.).
73
- if (code < 32)
74
- continue;
75
- value += ch;
76
- process.stdout.write('*');
77
- }
78
- };
79
- const cleanup = () => {
80
- stdin.removeListener('data', onData);
81
- try {
82
- stdin.setRawMode(wasRaw || false);
83
- }
84
- catch { }
85
- stdin.pause();
86
- };
87
- const done = () => {
88
- cleanup();
89
- process.stdout.write('\n');
90
- resolve(value);
91
- };
92
- stdin.on('data', onData);
93
- });
94
- }
95
- const USER_DATA_DIR = path.join(os.homedir(), '.zames', 'profile');
96
- // Cache of the sanitized (non-Headless) User-Agent, keyed by the browser
97
- // engine path. On the FIRST headless run we detect the real UA, strip the
98
- // "Headless" marker and store it here; later runs pass it straight to
99
- // _launchOnce, so the browser starts with a good UA and the "launch → read UA →
100
- // relaunch" dance happens only once (it otherwise doubled every headless
101
- // start). Keying by the executable path means a Playwright update (new engine
102
- // path) invalidates the cache and we re-derive the UA once for the new build.
103
- const HEADLESS_UA_CACHE = path.join(os.homedir(), '.zames', 'headless-ua.json');
104
- // Pure: pick a reusable UA from the cache file's text for the given engine, or
105
- // null. Exported so the policy is unit-tested without touching ~/.zames.
106
- export function parseHeadlessUACache(raw, engine) {
107
- try {
108
- const j = JSON.parse(raw);
109
- if (j &&
110
- j.engine === engine &&
111
- typeof j.ua === 'string' &&
112
- j.ua &&
113
- !/Headless/i.test(j.ua)) {
114
- return j.ua;
115
- }
116
- }
117
- catch { }
118
- return null;
119
- }
120
- function headlessEngineKey() {
121
- try {
122
- return chromium.executablePath();
123
- }
124
- catch {
125
- return '';
126
- }
127
- }
128
- async function readCachedHeadlessUA(engine) {
129
- if (!engine)
130
- return null;
131
- try {
132
- const raw = await fs.readFile(HEADLESS_UA_CACHE, 'utf-8');
133
- return parseHeadlessUACache(raw, engine);
134
- }
135
- catch {
136
- return null;
137
- }
138
- }
139
- async function writeCachedHeadlessUA(engine, ua) {
140
- if (!engine || !ua)
141
- return;
142
- try {
143
- await fs.mkdir(path.dirname(HEADLESS_UA_CACHE), { recursive: true });
144
- await fs.writeFile(HEADLESS_UA_CACHE, JSON.stringify({ engine, ua }, null, 2), 'utf-8');
145
- }
146
- catch { }
147
- }
148
- // For /doctor: is a sanitized UA cached for the CURRENT engine build? A missing
149
- // cache only means the next headless start relaunches once (not an error), so
150
- // this is informational. Best-effort.
151
- export async function headlessUACacheReady() {
152
- const ua = await readCachedHeadlessUA(headlessEngineKey());
153
- return !!ua;
154
- }
155
- // A headless Chrome advertises "HeadlessChrome/..." in its User-Agent, and
156
- // DeepSeek's CDN (CloudFront/WAF) rejects that UA with a plain "403 ERROR"
157
- // page before the app is even served — so a headless login looked broken while
158
- // a headed one worked (headed Chrome sends "Chrome/..."). We keep the real
159
- // engine version and only drop the "Headless" marker. When the operator
160
- // explicitly set a UA in the config, that one wins (see _launchOnce).
161
- export function sanitizeHeadlessUA(ua) {
162
- return String(ua || '').replace(/HeadlessChrome/g, 'Chrome');
163
- }
164
- // Written after a successful sign-in so the next launch knows a session was
165
- // stored in the persistent profile (used by /doctor and diagnostics).
166
- const AUTH_MARKER_FILE = path.join(os.homedir(), '.zames', 'auth.json');
167
- export function isGenerationIncompleteText(text) {
168
- const t = String(text || '');
169
- return GENERATION_ERR_RE.test(t) || INCOMPLETE_STATUS_RE.test(t);
170
- }
171
- export function isRateLimitText(text) {
172
- return RATE_LIMIT_RE.test(String(text || ''));
173
- }
174
- export function isServerBusyText(text) {
175
- const t = String(text || '');
176
- // Rate limit takes priority (it needs the long wait).
177
- if (RATE_LIMIT_RE.test(t))
178
- return false;
179
- return SERVER_BUSY_RE.test(t);
180
- }
181
- // Normalize text for comparison: collapse whitespace so that tiny DOM
182
- // differences (nbsp, trailing spaces) don't count as "a new answer".
183
- export function normText(s) {
184
- return String(s || '')
185
- .replace(/\u00a0/g, ' ')
186
- .replace(/\s+/g, ' ')
187
- .trim();
188
- }
189
- // The "too frequent" error: distinct from others so ask() waits a long time
190
- // (DeepSeek limits reset over minutes) and retries the send itself.
191
- export class RateLimitError extends Error {
192
- constructor(detail) {
193
- super('Messages too frequent. Try again later. ' + detail);
194
- this.name = 'RateLimitError';
195
- }
196
- }
197
- // DeepSeek transient server error (Server busy).
198
- export class ServerBusyError extends Error {
199
- constructor(detail) {
200
- super('Server busy. Try again later. ' + detail);
201
- this.name = 'ServerBusyError';
202
- }
203
- }
204
- // The generation was truncated by the server (`generation_err` / INCOMPLETE).
205
- // Distinct from ServerBusyError: the DeepSeek backend answered, but the turn
206
- // did not finish, and the UI offers a "Continue" button. The agent retries the
207
- // same send (a new message into the chat) instead of waiting for an answer
208
- // that will never arrive. This is what the operator hit with the reasoning
209
- // (deep thinking) mode on: the long THINK phase makes the truncation far more
210
- // likely, so with thinking off everything worked and with it on the turn kept
211
- // stopping and showing "Continue".
212
- export class GenerationIncompleteError extends Error {
213
- constructor(detail) {
214
- super('Generation incomplete (server truncated the answer). ' + detail);
215
- this.name = 'GenerationIncompleteError';
216
- }
217
- }
218
- // ---------- profile cleanup ----------
219
- async function cleanSingletonFiles() {
220
- const names = ['SingletonLock', 'SingletonCookie', 'SingletonSocket'];
221
- for (const name of names) {
222
- const p = path.join(USER_DATA_DIR, name);
223
- try {
224
- await fs.unlink(p);
225
- }
226
- catch { }
227
- }
228
- }
229
- async function profileLooksLocked() {
230
- try {
231
- await fs.access(path.join(USER_DATA_DIR, 'SingletonLock'));
232
- return true;
233
- }
234
- catch {
235
- return false;
236
- }
237
- }
238
- async function nukeProfile() {
239
- try {
240
- await fs.rm(USER_DATA_DIR, { recursive: true, force: true });
241
- }
242
- catch { }
243
- }
18
+ // The pure, state-free helpers live in browser-pure.ts (BACKLOG C3): they are
19
+ // imported for the class below and RE-EXPORTED here, so the public API of this
20
+ // module is unchanged (tests and other modules import these names from
21
+ // './browser.js').
22
+ import { askPassword, USER_DATA_DIR, parseHeadlessUACache, headlessEngineKey, readCachedHeadlessUA, writeCachedHeadlessUA, headlessUACacheReady, sanitizeHeadlessUA, AUTH_MARKER_FILE, isGenerationIncompleteText, isRateLimitText, isServerBusyText, normText, cleanAnswer, RateLimitError, ServerBusyError, GenerationIncompleteError, cleanSingletonFiles, profileLooksLocked, nukeProfile, } from './browser-pure.js';
23
+ export { askPassword, parseHeadlessUACache, headlessUACacheReady, sanitizeHeadlessUA, isGenerationIncompleteText, isRateLimitText, isServerBusyText, normText, RateLimitError, ServerBusyError, GenerationIncompleteError, };
244
24
  export class DeepSeekBrowser {
245
25
  headless;
246
26
  debug;
@@ -478,19 +258,7 @@ export class DeepSeekBrowser {
478
258
  // the send loop.
479
259
  async _setBusyTitle(busy) {
480
260
  try {
481
- await this.page.evaluate((on) => {
482
- const w = window;
483
- if (on) {
484
- if (typeof w.__zamesTitle !== 'string') {
485
- w.__zamesTitle = document.title.replace(/^⏳\s*/, '');
486
- }
487
- document.title = '⏳ ' + w.__zamesTitle;
488
- }
489
- else if (typeof w.__zamesTitle === 'string') {
490
- document.title = w.__zamesTitle;
491
- w.__zamesTitle = undefined;
492
- }
493
- }, busy);
261
+ await this.page.evaluate(setBusyTitleInDom, busy);
494
262
  }
495
263
  catch { }
496
264
  }
@@ -529,7 +297,7 @@ export class DeepSeekBrowser {
529
297
  if (!this.headless)
530
298
  return;
531
299
  try {
532
- const ua = await this.page.evaluate(() => navigator.userAgent);
300
+ const ua = await this.page.evaluate(readUserAgentInDom);
533
301
  const fixed = sanitizeHeadlessUA(ua);
534
302
  if (fixed === ua)
535
303
  return;
@@ -862,27 +630,7 @@ export class DeepSeekBrowser {
862
630
  */
863
631
  async _readLoginError() {
864
632
  try {
865
- const txt = await this.page.evaluate(() => {
866
- const sels = [
867
- '[role="alert"]',
868
- '[class*="error" i]',
869
- '[class*="toast" i]',
870
- '[class*="notification" i]',
871
- ];
872
- let out = '';
873
- for (const s of sels) {
874
- for (const el of Array.from(document.querySelectorAll(s))) {
875
- const e = el;
876
- const st = getComputedStyle(e);
877
- if (st.display === 'none' || st.visibility === 'hidden')
878
- continue;
879
- const t = (e.innerText || '').trim();
880
- if (t)
881
- out += ' ' + t;
882
- }
883
- }
884
- return out.replace(/\s+/g, ' ').trim();
885
- });
633
+ const txt = await this.page.evaluate(readLoginErrorInDom);
886
634
  return txt.slice(0, 200);
887
635
  }
888
636
  catch {
@@ -911,19 +659,7 @@ export class DeepSeekBrowser {
911
659
  if (!userInput) {
912
660
  // Find the index of the password input and use the closest preceding
913
661
  // text/email/tel input as the login field.
914
- const idx = await this.page.evaluate((pwdSel) => {
915
- const p = document.querySelector(pwdSel);
916
- if (!p)
917
- return -1;
918
- const inputs = Array.from(document.querySelectorAll('input'));
919
- const pi = inputs.indexOf(p);
920
- for (let i = pi - 1; i >= 0; i--) {
921
- const t = (inputs[i].type || 'text').toLowerCase();
922
- if (t === 'text' || t === 'email' || t === 'tel')
923
- return i;
924
- }
925
- return -1;
926
- }, PASSWORD_SELECTORS[0]);
662
+ const idx = await this.page.evaluate(findLoginInputIndexInDom, PASSWORD_SELECTORS[0]);
927
663
  if (idx >= 0)
928
664
  userInput = this.page.locator('input').nth(idx);
929
665
  }
@@ -977,33 +713,7 @@ export class DeepSeekBrowser {
977
713
  }
978
714
  // Button inside the same form as the password field.
979
715
  const formClicked = await this.page
980
- .evaluate((opts) => {
981
- const loginRe = new RegExp(opts.submitRe, 'i');
982
- const p = document.querySelector(opts.pwdSel);
983
- if (!p)
984
- return false;
985
- const form = p.closest('form');
986
- const scope = form || document;
987
- const btns = Array.from(scope.querySelectorAll('button[type="submit"], button, div[role="button"]'));
988
- for (const b of btns) {
989
- const txt = (b.textContent || '').trim().toLowerCase();
990
- const aria = (b.getAttribute('aria-label') || '').toLowerCase();
991
- if (loginRe.test(txt + ' ' + aria)) {
992
- b.click();
993
- return true;
994
- }
995
- }
996
- // Fallback: the primary-looking button in the scope.
997
- const primary = btns.find((b) => {
998
- const cls = (b.className || '').toString();
999
- return /primary|submit|ds-button--primary/i.test(cls);
1000
- });
1001
- if (primary) {
1002
- primary.click();
1003
- return true;
1004
- }
1005
- return false;
1006
- }, {
716
+ .evaluate(submitLoginFormInDom, {
1007
717
  pwdSel: PASSWORD_SELECTORS[0],
1008
718
  submitRe: LOGIN_FORM_SUBMIT_RE.source,
1009
719
  })
@@ -1157,44 +867,10 @@ export class DeepSeekBrowser {
1157
867
  // call". The DOM reader has no such self-comparison problem: before the
1158
868
  // send the DOM shows the OLD answer, after it the NEW one.
1159
869
  async _readLastAnswerTextDom() {
1160
- return await this.page.evaluate((opts) => {
1161
- const sels = opts.sels;
1162
- // DeepSeek stores the model's reasoning in think blocks (THINK_CLASS_RE).
1163
- // They are NOT the answer and must never be picked up as the answer
1164
- // (otherwise the terminal would show the long reasoning). We also
1165
- // prefer the first (most specific) selector with a hit.
1166
- const think = new RegExp(opts.thinkRe, 'i');
1167
- const inThink = (e) => {
1168
- let n = e;
1169
- while (n) {
1170
- const cls = (n.className || '').toString();
1171
- if (think.test(cls))
1172
- return true;
1173
- n = n.parentElement;
1174
- }
1175
- return false;
1176
- };
1177
- let el = null;
1178
- for (const s of sels) {
1179
- const list = document.querySelectorAll(s);
1180
- if (!list.length)
1181
- continue;
1182
- for (let i = list.length - 1; i >= 0; i--) {
1183
- const cand = list[i];
1184
- if (inThink(cand))
1185
- continue;
1186
- el = cand;
1187
- break;
1188
- }
1189
- if (el)
1190
- break;
1191
- }
1192
- if (!el)
1193
- return '';
1194
- const out = el.innerText || el.textContent || '';
1195
- const NL = String.fromCharCode(10);
1196
- return out.replace(new RegExp(NL + '{3,}', 'g'), NL + NL).trim();
1197
- }, { sels: ANSWER_SELECTORS, thinkRe: THINK_CLASS_RE.source });
870
+ return await this.page.evaluate(readLastAnswerInDom, {
871
+ sels: ANSWER_SELECTORS,
872
+ thinkRe: THINK_CLASS_RE.source,
873
+ });
1198
874
  }
1199
875
  // We read ONLY visible toasts/notifications, not the whole page text.
1200
876
  // Otherwise we catch "try again later" from service/hidden blocks and go
@@ -1203,26 +879,7 @@ export class DeepSeekBrowser {
1203
879
  // text from the page and produced a FALSE rate limit (the agent stopped
1204
880
  // while the chat had no error). Keep only real toast/alert containers.
1205
881
  async _readPageText() {
1206
- return await this.page
1207
- .evaluate(() => {
1208
- const sels = [
1209
- '[role="alert"]',
1210
- '[class*="toast" i]',
1211
- '[class*="notification" i]',
1212
- ];
1213
- let out = '';
1214
- for (const s of sels) {
1215
- for (const el of Array.from(document.querySelectorAll(s))) {
1216
- const e = el;
1217
- const st = getComputedStyle(e);
1218
- if (st.display === 'none' || st.visibility === 'hidden')
1219
- continue;
1220
- out += ' ' + (e.innerText || '');
1221
- }
1222
- }
1223
- return out;
1224
- })
1225
- .catch(() => '');
882
+ return await this.page.evaluate(readPageToastsInDom).catch(() => '');
1226
883
  }
1227
884
  // The "clean" filter, but read from the DOM only. Used by _askOnce for the
1228
885
  // before/after comparison: the network capture must not be compared against
@@ -1231,16 +888,10 @@ export class DeepSeekBrowser {
1231
888
  const raw = await this._readLastAnswerTextDom().catch(() => '');
1232
889
  return this._cleanAnswer(raw);
1233
890
  }
1234
- // Drop service placeholders ("Reading…", "Thinking…") so they are not
1235
- // mistaken for an answer; keep everything else as-is (including whitespace
1236
- // the tool-call relies on).
891
+ // The pure filter lives in browser-pure.ts (cleanAnswer); kept as a method
892
+ // for the existing `this._cleanAnswer(...)` call sites.
1237
893
  _cleanAnswer(raw) {
1238
- const t = (raw || '').trim();
1239
- if (!t)
1240
- return '';
1241
- if (STATUS_RE.test(t))
1242
- return '';
1243
- return raw;
894
+ return cleanAnswer(raw);
1244
895
  }
1245
896
  // Find the Stop button in the DeepSeek UI. We can't rely on the class
1246
897
  // alone: during generation the send button (the same circle button)
@@ -1250,27 +901,7 @@ export class DeepSeekBrowser {
1250
901
  if (explicit)
1251
902
  return true;
1252
903
  return await this.page
1253
- .evaluate((btnRe) => {
1254
- const re = new RegExp(btnRe, 'i');
1255
- const btns = Array.from(document.querySelectorAll('div[role="button"], button'));
1256
- for (const b of btns) {
1257
- const cls = (b.className || '').toString();
1258
- if (!re.test(cls))
1259
- continue;
1260
- const label = ((b.getAttribute('aria-label') || '') +
1261
- ' ' +
1262
- (b.getAttribute('title') || '') +
1263
- ' ' +
1264
- (b.textContent || '')).toLowerCase();
1265
- if (/stop|останов/.test(label))
1266
- return true;
1267
- // A square icon = Stop button; an arrow (path without rect) = send.
1268
- const svg = b.querySelector('svg');
1269
- if (svg && svg.querySelector('rect'))
1270
- return true;
1271
- }
1272
- return false;
1273
- }, BUTTON_CLASS_RE.source)
904
+ .evaluate(stopButtonVisibleInDom, BUTTON_CLASS_RE.source)
1274
905
  .catch(() => false);
1275
906
  }
1276
907
  async _isGenerating() {
@@ -1284,16 +915,10 @@ export class DeepSeekBrowser {
1284
915
  // all we need to tell "the page grew / a new answer is streaming".
1285
916
  async _chatSignal() {
1286
917
  return await this.page
1287
- .evaluate((opts) => {
1288
- const nodes = document.querySelectorAll(opts.nodesSel).length;
1289
- let lastLen = 0;
1290
- const answers = document.querySelectorAll(opts.mdSel);
1291
- if (answers.length) {
1292
- const lastEl = answers[answers.length - 1];
1293
- lastLen = (lastEl.innerText || '').length;
1294
- }
1295
- return { nodes, lastLen };
1296
- }, { nodesSel: CHAT_SIGNAL_NODES_SELECTOR, mdSel: MARKDOWN_SELECTOR })
918
+ .evaluate(chatSignalInDom, {
919
+ nodesSel: CHAT_SIGNAL_NODES_SELECTOR,
920
+ mdSel: MARKDOWN_SELECTOR,
921
+ })
1297
922
  .catch(() => ({ nodes: 0, lastLen: 0 }));
1298
923
  }
1299
924
  // Cheap first probe for the Continue button. Runs ONE page.evaluate
@@ -1304,26 +929,7 @@ export class DeepSeekBrowser {
1304
929
  // HITS do we run the full scan to get a clickable Locator.
1305
930
  async _continueVisibleCheap() {
1306
931
  return await this.page
1307
- .evaluate((nameRe) => {
1308
- const re = new RegExp(nameRe, 'i');
1309
- const cands = Array.from(document.querySelectorAll('div[role="button"], button'));
1310
- for (const e of cands) {
1311
- const label = ((e.textContent || '') +
1312
- ' ' +
1313
- (e.getAttribute('aria-label') || ''))
1314
- .replace(/\s+/g, ' ')
1315
- .trim();
1316
- if (!re.test(label))
1317
- continue;
1318
- const st = getComputedStyle(e);
1319
- if (st.display === 'none' || st.visibility === 'hidden')
1320
- continue;
1321
- const r = e.getBoundingClientRect();
1322
- if (r.width > 0 && r.height > 0)
1323
- return true;
1324
- }
1325
- return false;
1326
- }, CONTINUE_NAME_RE.source)
932
+ .evaluate(continueVisibleCheapInDom, CONTINUE_NAME_RE.source)
1327
933
  .catch(() => false);
1328
934
  }
1329
935
  // Is DeepSeek's "Continue" button currently visible? Used to BLOCK the
@@ -1349,26 +955,7 @@ export class DeepSeekBrowser {
1349
955
  catch { }
1350
956
  // Fallback: the raw DOM scan (older/newer DeepSeek builds where the role
1351
957
  // is absent).
1352
- return this.page
1353
- .evaluate(() => {
1354
- const labelOf = (b) => ((b.textContent || '') + ' ' + (b.getAttribute('aria-label') || ''))
1355
- .replace(/\s+/g, ' ')
1356
- .trim();
1357
- const isExact = (t) => /^(?:continue|продолжить|продолжение)(?:\s+(?:think(?:ing)?|reason(?:ing)?|размышлени[ея]|генераци[юя]|ответ))?\s*[.!…]?$/i.test(t);
1358
- const cands = Array.from(document.querySelectorAll('div[role="button"], button'));
1359
- for (const e of cands) {
1360
- if (!isExact(labelOf(e)))
1361
- continue;
1362
- const st = getComputedStyle(e);
1363
- if (st.display === 'none' || st.visibility === 'hidden')
1364
- continue;
1365
- const r = e.getBoundingClientRect();
1366
- if (r.width > 0 && r.height > 0)
1367
- return true;
1368
- }
1369
- return false;
1370
- })
1371
- .catch(() => false);
958
+ return this.page.evaluate(continueVisibleInDom).catch(() => false);
1372
959
  }
1373
960
  // Click the DeepSeek "Continue" button when it is visible (reasoning/answer
1374
961
  // paused by the server). Returns true when a click happened.
@@ -1448,44 +1035,7 @@ export class DeepSeekBrowser {
1448
1035
  // 2) Fallback: raw DOM scan + full pointer/mouse event sequence (an older
1449
1036
  // build without a proper role, or a click intercepted by an overlay).
1450
1037
  try {
1451
- const clicked = await this.page.evaluate(() => {
1452
- const labelOf = (b) => ((b.textContent || '') + ' ' + (b.getAttribute('aria-label') || ''))
1453
- .replace(/\s+/g, ' ')
1454
- .trim();
1455
- const isExact = (t) => /^(?:continue|продолжить|продолжение)(?:\s+(?:think(?:ing)?|reason(?:ing)?|размышлени[ея]|генераци[юя]|ответ))?\s*[.!…]?$/i.test(t);
1456
- const cands = Array.from(document.querySelectorAll('div[role="button"], button'));
1457
- for (const e of cands) {
1458
- const t = labelOf(e);
1459
- if (!isExact(t))
1460
- continue;
1461
- const st = getComputedStyle(e);
1462
- if (st.display === 'none' || st.visibility === 'hidden')
1463
- continue;
1464
- const rect = e.getBoundingClientRect();
1465
- if (rect.width === 0 || rect.height === 0)
1466
- continue;
1467
- const cx = rect.left + rect.width / 2;
1468
- const cy = rect.top + rect.height / 2;
1469
- const opts = {
1470
- bubbles: true,
1471
- cancelable: true,
1472
- composed: true,
1473
- clientX: cx,
1474
- clientY: cy,
1475
- button: 0,
1476
- };
1477
- try {
1478
- e.dispatchEvent(new PointerEvent('pointerdown', opts));
1479
- e.dispatchEvent(new MouseEvent('mousedown', opts));
1480
- e.dispatchEvent(new PointerEvent('pointerup', opts));
1481
- e.dispatchEvent(new MouseEvent('mouseup', opts));
1482
- }
1483
- catch { }
1484
- e.click();
1485
- return true;
1486
- }
1487
- return false;
1488
- });
1038
+ const clicked = await this.page.evaluate(clickContinueInDom);
1489
1039
  if (clicked) {
1490
1040
  this._lastContinueAt = Date.now();
1491
1041
  this._notice(this._t('ds.continue_clicked'));
@@ -1606,30 +1156,7 @@ export class DeepSeekBrowser {
1606
1156
  // 3) Raw DOM scan + the full pointer/mouse sequence (older builds without
1607
1157
  // a proper role, or a click intercepted by an overlay).
1608
1158
  const clicked = await this.page
1609
- .evaluate((btnRe) => {
1610
- const re = new RegExp(btnRe, 'i');
1611
- const btns = Array.from(document.querySelectorAll('div[role="button"], button'));
1612
- for (const b of btns) {
1613
- const cls = (b.className || '').toString();
1614
- if (!re.test(cls))
1615
- continue;
1616
- const svg = b.querySelector('svg');
1617
- if (svg && svg.querySelector('rect')) {
1618
- for (const type of [
1619
- 'pointerdown',
1620
- 'mousedown',
1621
- 'pointerup',
1622
- 'mouseup',
1623
- 'click',
1624
- ]) {
1625
- b.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
1626
- }
1627
- b.click();
1628
- return true;
1629
- }
1630
- }
1631
- return false;
1632
- }, BUTTON_CLASS_RE.source)
1159
+ .evaluate(clickStopInDom, BUTTON_CLASS_RE.source)
1633
1160
  .catch(() => false);
1634
1161
  if (clicked)
1635
1162
  return true;
@@ -1799,7 +1326,7 @@ export class DeepSeekBrowser {
1799
1326
  this._askCancelled = true;
1800
1327
  }
1801
1328
  async _setInputText(input, text) {
1802
- const tag = await input.evaluate((el) => el.tagName.toLowerCase());
1329
+ const tag = await input.evaluate(elementTagNameInDom);
1803
1330
  const isNative = tag === 'textarea' || tag === 'input';
1804
1331
  if (isNative) {
1805
1332
  try {
@@ -1811,28 +1338,11 @@ export class DeepSeekBrowser {
1811
1338
  await input.click();
1812
1339
  await this.page.keyboard.press('Control+A');
1813
1340
  await this.page.keyboard.press('Delete');
1814
- const ok = await input.evaluate((el, value) => {
1815
- el.focus();
1816
- const dt = new DataTransfer();
1817
- dt.setData('text/plain', value);
1818
- const ev = new ClipboardEvent('paste', {
1819
- clipboardData: dt,
1820
- bubbles: true,
1821
- cancelable: true,
1822
- });
1823
- el.dispatchEvent(ev);
1824
- return true;
1825
- }, text);
1341
+ const ok = await input.evaluate(pasteTextIntoElement, text);
1826
1342
  if (!ok) {
1827
1343
  await this.page.keyboard.insertText(text);
1828
1344
  }
1829
- const got = await input.evaluate((el) => {
1830
- if (el.tagName.toLowerCase() === 'textarea' ||
1831
- el.tagName.toLowerCase() === 'input') {
1832
- return el.value;
1833
- }
1834
- return el.innerText || el.textContent || '';
1835
- });
1345
+ const got = await input.evaluate(readElementText);
1836
1346
  const norm = (s) => (s || '')
1837
1347
  .replace(/\r\n/g, '\n')
1838
1348
  .replace(/\u00a0/g, ' ')
@@ -1846,12 +1356,7 @@ export class DeepSeekBrowser {
1846
1356
  await this.page.keyboard.press('Control+A');
1847
1357
  await this.page.keyboard.press('Delete');
1848
1358
  await this.page.keyboard.insertText(text);
1849
- const got2 = await input.evaluate((el) => {
1850
- if (el.tagName.toLowerCase() === 'textarea' ||
1851
- el.tagName.toLowerCase() === 'input')
1852
- return el.value;
1853
- return el.innerText || el.textContent || '';
1854
- });
1359
+ const got2 = await input.evaluate(readElementText);
1855
1360
  if (norm(got2) !== norm(text)) {
1856
1361
  throw new Error(this._t('ds.input_partial', {
1857
1362
  got: norm(got2).length,
@@ -2141,14 +1646,7 @@ export class DeepSeekBrowser {
2141
1646
  // attribute (and a disabled class) directly — during an upload the
2142
1647
  // button is aria-disabled=true and a click is silently ignored.
2143
1648
  const enabled = await btn
2144
- .evaluate((el) => {
2145
- if (el.getAttribute('aria-disabled') === 'true')
2146
- return false;
2147
- const cls = (el.className || '').toString();
2148
- if (/disabled|is-disabled/.test(cls))
2149
- return false;
2150
- return true;
2151
- })
1649
+ .evaluate(isElementEnabledInDom)
2152
1650
  .catch(() => true);
2153
1651
  if (!enabled)
2154
1652
  continue;
@@ -2174,7 +1672,7 @@ export class DeepSeekBrowser {
2174
1672
  try {
2175
1673
  await this.page.waitForTimeout(300);
2176
1674
  const stillThere = await input
2177
- .evaluate((el) => (el.value || el.innerText || el.textContent || '').trim())
1675
+ .evaluate(readInputTrimmedTextInDom)
2178
1676
  .catch(() => '');
2179
1677
  if (stillThere) {
2180
1678
  this._askDebug('SEND not cleared, pressing Enter again');
@@ -2554,19 +2052,7 @@ export class DeepSeekBrowser {
2554
2052
  throw new Error(this._t('ds.not_launched'));
2555
2053
  const html = await this.page.content();
2556
2054
  await fs.writeFile(filePath, html, 'utf-8');
2557
- const report = await this.page.evaluate((sels) => {
2558
- const result = { answers: {}, stops: {}, inputs: {} };
2559
- for (const s of sels.answers) {
2560
- result.answers[s] = document.querySelectorAll(s).length;
2561
- }
2562
- for (const s of sels.stops) {
2563
- result.stops[s] = document.querySelectorAll(s).length;
2564
- }
2565
- for (const s of sels.inputs) {
2566
- result.inputs[s] = document.querySelectorAll(s).length;
2567
- }
2568
- return result;
2569
- }, {
2055
+ const report = await this.page.evaluate(dumpDomCountsInDom, {
2570
2056
  answers: ANSWER_SELECTORS,
2571
2057
  stops: STOP_SELECTORS,
2572
2058
  inputs: INPUT_SELECTORS,
@@ -2605,31 +2091,9 @@ export class DeepSeekBrowser {
2605
2091
  }
2606
2092
  async listChats(limit = 30) {
2607
2093
  await this._ensureSidebarOpen();
2608
- return await this.page.evaluate((lim) => {
2609
- const out = [];
2610
- const seen = new Set();
2611
- const anchors = document.querySelectorAll('a[href*="/chat/"]');
2612
- for (const a of anchors) {
2613
- const href = a.getAttribute('href') || '';
2614
- const m = href.match(/\/chat\/s\/([a-zA-Z0-9_-]+)/) ||
2615
- href.match(/\/a\/chat\/s\/([a-zA-Z0-9_-]+)/);
2616
- if (!m)
2617
- continue;
2618
- const id = m[1];
2619
- if (seen.has(id))
2620
- continue;
2621
- seen.add(id);
2622
- const titleEl = a.querySelector('[class*="title"], [class*="text"]');
2623
- let title = (titleEl ? titleEl.textContent : a.textContent) || '';
2624
- title = title.trim().replace(/\s+/g, ' ');
2625
- if (!title)
2626
- title = '(без названия)';
2627
- out.push({ id, title, href });
2628
- if (out.length >= lim)
2629
- break;
2630
- }
2631
- return out;
2632
- }, limit);
2094
+ // The scraper is a named export (browser-chats.ts) so the class stays thin
2095
+ // and the scraping policy has one home; it runs inside the page.
2096
+ return await this.page.evaluate(scrapeChatList, limit);
2633
2097
  }
2634
2098
  async openChat(id) {
2635
2099
  // Click the chat link and VERIFY the URL actually points at the
@@ -2696,6 +2160,11 @@ export class DeepSeekBrowser {
2696
2160
  }
2697
2161
  const auth = this._apiAuth;
2698
2162
  const pow = this._apiPow;
2163
+ // The in-page fetch only RETURNS the raw chat_messages; the parsing happens
2164
+ // OUTSIDE, in the pure parseHistoryMessages() (browser-chats.ts), so the
2165
+ // in-page and the Playwright-request fallback paths share ONE parser and
2166
+ // cannot drift. The in-page callback cannot call our helpers (it runs in
2167
+ // the page context), so it must stay a plain data fetch.
2699
2168
  const res = await this.page
2700
2169
  .evaluate(async (opts) => {
2701
2170
  try {
@@ -2714,53 +2183,35 @@ export class DeepSeekBrowser {
2714
2183
  headers,
2715
2184
  });
2716
2185
  if (!resp.ok)
2717
- return { error: 'HTTP ' + resp.status, list: [] };
2186
+ return { error: 'HTTP ' + resp.status, messages: null };
2718
2187
  const json = await resp.json();
2719
2188
  const messages = json && json.data && json.data.biz_data
2720
2189
  ? json.data.biz_data.chat_messages
2721
2190
  : null;
2722
2191
  if (!Array.isArray(messages)) {
2723
- return { error: 'no chat_messages in response', list: [] };
2192
+ return { error: 'no chat_messages in response', messages: null };
2724
2193
  }
2725
- const NL = String.fromCharCode(10);
2726
- const out = [];
2727
- // The context size: the LATEST accumulated_token_usage in the chat
2728
- // (each message carries the running counter).
2729
- let usage = null;
2730
- for (const m of messages) {
2731
- if (m && typeof m.accumulated_token_usage === 'number') {
2732
- usage = m.accumulated_token_usage;
2733
- }
2734
- const role = m && m.role === 'ASSISTANT' ? 'assistant' : 'user';
2735
- const want = role === 'assistant' ? 'RESPONSE' : 'REQUEST';
2736
- let text = '';
2737
- for (const fr of (m && m.fragments) || []) {
2738
- if (!fr || fr.type !== want)
2739
- continue;
2740
- if (typeof fr.content === 'string')
2741
- text += fr.content;
2742
- }
2743
- text = text.replace(new RegExp(NL + '{3,}', 'g'), NL + NL).trim();
2744
- if (text)
2745
- out.push({ role, text });
2746
- }
2747
- return { error: '', list: out, usage };
2194
+ return { error: '', messages };
2748
2195
  }
2749
2196
  catch (e) {
2750
- return { error: 'fetch failed: ' + e.message, list: [] };
2197
+ return {
2198
+ error: 'fetch failed: ' + e.message,
2199
+ messages: null,
2200
+ };
2751
2201
  }
2752
2202
  }, { chatId: id, auth, pow, historyUrl: HISTORY_MESSAGES_URL })
2753
2203
  .catch((e) => ({
2754
2204
  error: 'evaluate failed: ' + e.message,
2755
- list: [],
2205
+ messages: null,
2756
2206
  }));
2757
2207
  this._lastHistoryError = res?.error || '';
2208
+ // Parse the raw messages with the SAME pure parser the fallback uses.
2209
+ const parsed = parseHistoryMessages(res?.messages);
2758
2210
  // Pick up the context size the history carries, so /resume (and /cost
2759
2211
  // right after it) shows a real number even before the first answer.
2760
- const usage = res?.usage;
2761
- if (typeof usage === 'number')
2762
- this._lastTokenUsage = usage;
2763
- const list = (res?.list || []);
2212
+ if (typeof parsed.usage === 'number')
2213
+ this._lastTokenUsage = parsed.usage;
2214
+ const list = parsed.list;
2764
2215
  if (list.length || !res?.error)
2765
2216
  return list;
2766
2217
  // Fallback: Playwright's own request context (shares the browser cookies)
@@ -2770,31 +2221,17 @@ export class DeepSeekBrowser {
2770
2221
  const resp = await this.page.request.get(HISTORY_MESSAGES_URL + '?chat_session_id=' + encodeURIComponent(id), { headers: { accept: 'application/json' } });
2771
2222
  if (!resp.ok())
2772
2223
  return list;
2773
- const json = (await resp.json());
2774
- const messages = json?.data?.biz_data?.chat_messages;
2775
- if (!Array.isArray(messages))
2224
+ const json = await resp.json();
2225
+ const messages = extractChatMessages(json);
2226
+ if (!messages)
2776
2227
  return list;
2777
- const NL = String.fromCharCode(10);
2778
- const out = [];
2779
- for (const m of messages) {
2780
- if (m && typeof m.accumulated_token_usage === 'number') {
2781
- this._lastTokenUsage = m.accumulated_token_usage;
2782
- }
2783
- const role = m && m.role === 'ASSISTANT' ? 'assistant' : 'user';
2784
- const want = role === 'assistant' ? 'RESPONSE' : 'REQUEST';
2785
- let text = '';
2786
- for (const fr of (m && m.fragments) || []) {
2787
- if (!fr || fr.type !== want)
2788
- continue;
2789
- if (typeof fr.content === 'string')
2790
- text += fr.content;
2791
- }
2792
- text = text.replace(new RegExp(NL + '{3,}', 'g'), NL + NL).trim();
2793
- if (text)
2794
- out.push({ role, text });
2795
- }
2228
+ // Same pure parser as the in-page path (browser-chats.ts), so the two
2229
+ // cannot drift.
2230
+ const parsed = parseHistoryMessages(messages);
2231
+ if (typeof parsed.usage === 'number')
2232
+ this._lastTokenUsage = parsed.usage;
2796
2233
  this._lastHistoryError = '';
2797
- return out;
2234
+ return parsed.list;
2798
2235
  }
2799
2236
  catch (e) {
2800
2237
  this._lastHistoryError = 'request failed: ' + e.message;
@@ -2810,92 +2247,17 @@ export class DeepSeekBrowser {
2810
2247
  // ds-assistant-message-* class; anything that is not an assistant block is
2811
2248
  // treated as a user message. The model's reasoning (.ds-think-content) is
2812
2249
  // skipped, exactly like in _readLastAnswerText.
2250
+ // Read the WHOLE visible dialogue of the currently open chat, top to
2251
+ // bottom. Used by /resume and /resume-id so the operator sees the restored
2252
+ // context in the terminal instead of just "Chat opened.".
2253
+ //
2254
+ // The in-page scraper (scrapeChatMessages, browser-chats.ts) is a named
2255
+ // export so the class body stays thin; it runs inside the page.
2813
2256
  async readChatMessages() {
2814
2257
  if (!this.page)
2815
2258
  return [];
2816
2259
  const raw = await this.page
2817
- .evaluate((opts) => {
2818
- const think = new RegExp(opts.thinkRe, 'i');
2819
- const assistantRe = new RegExp(opts.assistantRe, 'i');
2820
- const inThink = (e) => {
2821
- let n = e;
2822
- while (n) {
2823
- const cls = (n.className || '').toString();
2824
- if (think.test(cls))
2825
- return true;
2826
- n = n.parentElement;
2827
- }
2828
- return false;
2829
- };
2830
- const textOf = (e) => {
2831
- const h = e;
2832
- const t = h.innerText || h.textContent || '';
2833
- const NL = String.fromCharCode(10);
2834
- return t.replace(new RegExp(NL + '{3,}', 'g'), NL + NL).trim();
2835
- };
2836
- // A message block is "assistant" when it contains an answer markdown
2837
- // wrapper (ds-markdown / ds-assistant-message), otherwise it is a
2838
- // user bubble. DeepSeek's class names drift between builds, so the
2839
- // detection is content-based, not class-prefix-based.
2840
- const looksAssistant = (e) => {
2841
- const cls = (e.className || '').toString();
2842
- if (assistantRe.test(cls))
2843
- return true;
2844
- if (e.querySelector(opts.assistantSel))
2845
- return true;
2846
- // A user bubble has no rendered markdown; an answer does.
2847
- if (e.querySelector(opts.markdownSel))
2848
- return true;
2849
- return false;
2850
- };
2851
- // Message-level containers first (broad), then the answer wrappers.
2852
- // See MESSAGE_CONTAINER_SELECTORS in deepseek-ui.ts.
2853
- let blocks = [];
2854
- for (const s of opts.containerSels) {
2855
- const found = Array.from(document.querySelectorAll(s)).filter((e) => !inThink(e) && textOf(e).length > 0);
2856
- if (found.length) {
2857
- blocks = found;
2858
- break;
2859
- }
2860
- }
2861
- // De-duplicate by node identity and drop a block that is NESTED
2862
- // inside another block. Some builds match both an outer message
2863
- // container and an inner one; without this the restored dialogue
2864
- // printed each turn twice. We keep only the OUTERMOST blocks.
2865
- if (blocks.length > 1) {
2866
- const unique = Array.from(new Set(blocks));
2867
- blocks = unique.filter((b) => !unique.some((other) => other !== b && other.contains(b)));
2868
- }
2869
- const out = [];
2870
- if (blocks.length) {
2871
- for (const b of blocks) {
2872
- const t = textOf(b);
2873
- if (!t)
2874
- continue;
2875
- out.push({
2876
- role: looksAssistant(b) ? 'assistant' : 'user',
2877
- text: t,
2878
- });
2879
- }
2880
- if (out.length)
2881
- return out;
2882
- }
2883
- // Last resort: assistant answers only (no user turns) — better than
2884
- // nothing when no message container matched.
2885
- // See RESTORE_ANSWER_SELECTORS in deepseek-ui.ts.
2886
- for (const s of opts.answerSels) {
2887
- const list = Array.from(document.querySelectorAll(s)).filter((e) => !inThink(e));
2888
- if (!list.length)
2889
- continue;
2890
- for (const el of list) {
2891
- const t = textOf(el);
2892
- if (t)
2893
- out.push({ role: 'assistant', text: t });
2894
- }
2895
- break;
2896
- }
2897
- return out;
2898
- }, {
2260
+ .evaluate(scrapeChatMessages, {
2899
2261
  containerSels: MESSAGE_CONTAINER_SELECTORS,
2900
2262
  answerSels: RESTORE_ANSWER_SELECTORS,
2901
2263
  thinkRe: THINK_CLASS_RE.source,