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.
@@ -0,0 +1,161 @@
1
+ // PURE parsing of the DeepSeek history/message payloads (BACKLOG C3). These
2
+ // helpers are extracted from DeepSeekBrowser.fetchChatMessages: they used to be
3
+ // copy-pasted INLINE twice (the in-page evaluate AND the Playwright-request
4
+ // fallback), so the same role/fragment extraction lived in two places and could
5
+ // drift. They operate on plain JSON, touch no browser state, and are
6
+ // unit-tested without Playwright.
7
+ // Collapse 3+ blank lines to one blank line and trim — DeepSeek pads the
8
+ // fragments with newlines and the raw text looked double-spaced in the
9
+ // restored dialogue.
10
+ export function tidyFragmentText(text) {
11
+ const NL = String.fromCharCode(10);
12
+ return String(text || '')
13
+ .replace(new RegExp(NL + '{3,}', 'g'), NL + NL)
14
+ .trim();
15
+ }
16
+ /**
17
+ * Extract displayable messages from `data.biz_data.chat_messages[]`.
18
+ *
19
+ * Assistant turns keep only RESPONSE fragments, user turns only REQUEST
20
+ * fragments (THINK/FILE fragments are dropped, matching the live reader). The
21
+ * LATEST `accumulated_token_usage` seen in the payload is returned as
22
+ * `usage` (each message carries the running counter).
23
+ */
24
+ export function parseHistoryMessages(messages) {
25
+ if (!Array.isArray(messages))
26
+ return { list: [], usage: null };
27
+ const list = [];
28
+ let usage = null;
29
+ for (const raw of messages) {
30
+ if (raw && typeof raw.accumulated_token_usage === 'number') {
31
+ usage = raw.accumulated_token_usage;
32
+ }
33
+ const role = raw && raw.role === 'ASSISTANT' ? 'assistant' : 'user';
34
+ const want = role === 'assistant' ? 'RESPONSE' : 'REQUEST';
35
+ let text = '';
36
+ for (const fr of (raw && raw.fragments) || []) {
37
+ if (!fr || fr.type !== want)
38
+ continue;
39
+ if (typeof fr.content === 'string')
40
+ text += fr.content;
41
+ }
42
+ text = tidyFragmentText(text);
43
+ if (text)
44
+ list.push({ role, text });
45
+ }
46
+ return { list, usage };
47
+ }
48
+ /**
49
+ * Pull `chat_messages` out of a history_messages JSON response, tolerating the
50
+ * two shapes the endpoint has shipped (`data.biz_data.chat_messages`); returns
51
+ * null when the shape does not match, so the caller can report a clear error.
52
+ */
53
+ export function extractChatMessages(json) {
54
+ const j = json;
55
+ const messages = j?.data?.biz_data?.chat_messages;
56
+ return Array.isArray(messages) ? messages : null;
57
+ }
58
+ /**
59
+ * In-page scraper for the chat list (the sidebar anchors). Runs INSIDE the
60
+ * page via page.evaluate, so it must NOT close over module scope — it only
61
+ * uses its `limit` argument and DOM globals. Kept as a named export so the
62
+ * class body stays thin and this scraping policy has ONE home.
63
+ */
64
+ export function scrapeChatList(limit) {
65
+ const out = [];
66
+ const seen = new Set();
67
+ const anchors = document.querySelectorAll('a[href*="/chat/"]');
68
+ for (const a of Array.from(anchors)) {
69
+ const href = a.getAttribute('href') || '';
70
+ const m = href.match(/\/chat\/s\/([a-zA-Z0-9_-]+)/) ||
71
+ href.match(/\/a\/chat\/s\/([a-zA-Z0-9_-]+)/);
72
+ if (!m)
73
+ continue;
74
+ const id = m[1];
75
+ if (seen.has(id))
76
+ continue;
77
+ seen.add(id);
78
+ const titleEl = a.querySelector('[class*="title"], [class*="text"]');
79
+ let title = (titleEl ? titleEl.textContent : a.textContent) || '';
80
+ title = title.trim().replace(/\s+/g, ' ');
81
+ if (!title)
82
+ title = '(без названия)';
83
+ out.push({ id, title, href });
84
+ if (out.length >= limit)
85
+ break;
86
+ }
87
+ return out;
88
+ }
89
+ /**
90
+ * In-page scraper for the WHOLE visible dialogue of the open chat. Runs INSIDE
91
+ * the page via page.evaluate (no module-scope closure). Message-level
92
+ * containers are tried first, then assistant-only answer wrappers as a
93
+ * fallback; nested duplicate blocks are collapsed to the OUTERMOST one (some
94
+ * builds match both an outer and an inner container, which printed each turn
95
+ * twice). The model's reasoning (think class) is skipped.
96
+ */
97
+ export function scrapeChatMessages(opts) {
98
+ const think = new RegExp(opts.thinkRe, 'i');
99
+ const assistantRe = new RegExp(opts.assistantRe, 'i');
100
+ const inThink = (e) => {
101
+ let n = e;
102
+ while (n) {
103
+ const cls = (n.className || '').toString();
104
+ if (think.test(cls))
105
+ return true;
106
+ n = n.parentElement;
107
+ }
108
+ return false;
109
+ };
110
+ const textOf = (e) => {
111
+ const h = e;
112
+ const t = h.innerText || h.textContent || '';
113
+ const NL = String.fromCharCode(10);
114
+ return t.replace(new RegExp(NL + '{3,}', 'g'), NL + NL).trim();
115
+ };
116
+ const looksAssistant = (e) => {
117
+ const cls = (e.className || '').toString();
118
+ if (assistantRe.test(cls))
119
+ return true;
120
+ if (e.querySelector(opts.assistantSel))
121
+ return true;
122
+ if (e.querySelector(opts.markdownSel))
123
+ return true;
124
+ return false;
125
+ };
126
+ let blocks = [];
127
+ for (const s of opts.containerSels) {
128
+ const found = Array.from(document.querySelectorAll(s)).filter((e) => !inThink(e) && textOf(e).length > 0);
129
+ if (found.length) {
130
+ blocks = found;
131
+ break;
132
+ }
133
+ }
134
+ if (blocks.length > 1) {
135
+ const unique = Array.from(new Set(blocks));
136
+ blocks = unique.filter((b) => !unique.some((other) => other !== b && other.contains(b)));
137
+ }
138
+ const out = [];
139
+ if (blocks.length) {
140
+ for (const b of blocks) {
141
+ const t = textOf(b);
142
+ if (!t)
143
+ continue;
144
+ out.push({ role: looksAssistant(b) ? 'assistant' : 'user', text: t });
145
+ }
146
+ if (out.length)
147
+ return out;
148
+ }
149
+ for (const s of opts.answerSels) {
150
+ const list = Array.from(document.querySelectorAll(s)).filter((e) => !inThink(e));
151
+ if (!list.length)
152
+ continue;
153
+ for (const el of list) {
154
+ const t = textOf(el);
155
+ if (t)
156
+ out.push({ role: 'assistant', text: t });
157
+ }
158
+ break;
159
+ }
160
+ return out;
161
+ }
@@ -0,0 +1,416 @@
1
+ // Pure in-page DOM helpers extracted from DeepSeekBrowser (BACKLOG C3).
2
+ //
3
+ // Every function here runs INSIDE page.evaluate, so it MUST be fully
4
+ // self-contained: Playwright serializes the function SOURCE and evaluates it in
5
+ // the page, where module scope does not exist. Only DOM globals and the passed
6
+ // argument are available — a reference to another helper of this file would be
7
+ // lost. This is the same constraint browser-chats.ts works under; keeping the
8
+ // callbacks here instead of inline in the class keeps the class body thin
9
+ // without changing behavior.
10
+ /**
11
+ * Read ONLY visible toast/notification containers as one text blob.
12
+ *
13
+ * WHY this list is narrow: a broad `[class*="error" i]` matched leftover page
14
+ * text and produced a FALSE rate limit. Keeping to real toast containers means
15
+ * no false positives, so ask() does not go into a needless 5-minute wait.
16
+ */
17
+ export function readPageToastsInDom() {
18
+ const sels = [
19
+ '[role="alert"]',
20
+ '[class*="toast" i]',
21
+ '[class*="notification" i]',
22
+ ];
23
+ let out = '';
24
+ for (const s of sels) {
25
+ for (const el of Array.from(document.querySelectorAll(s))) {
26
+ const e = el;
27
+ const st = getComputedStyle(e);
28
+ if (st.display === 'none' || st.visibility === 'hidden')
29
+ continue;
30
+ out += ' ' + (e.innerText || '');
31
+ }
32
+ }
33
+ return out;
34
+ }
35
+ /**
36
+ * Read a visible login/credentials error message from the page, if any.
37
+ * Best-effort: the caller slices/trims; this returns the joined text.
38
+ */
39
+ export function readLoginErrorInDom() {
40
+ const sels = [
41
+ '[role="alert"]',
42
+ '[class*="error" i]',
43
+ '[class*="toast" i]',
44
+ '[class*="notification" i]',
45
+ ];
46
+ let out = '';
47
+ for (const s of sels) {
48
+ for (const el of Array.from(document.querySelectorAll(s))) {
49
+ const e = el;
50
+ const st = getComputedStyle(e);
51
+ if (st.display === 'none' || st.visibility === 'hidden')
52
+ continue;
53
+ const t = (e.innerText || '').trim();
54
+ if (t)
55
+ out += ' ' + t;
56
+ }
57
+ }
58
+ return out.replace(/\s+/g, ' ').trim();
59
+ }
60
+ /**
61
+ * The answer as RENDERED on the page, ALWAYS from the DOM (never the network
62
+ * capture). Reasoning blocks (think class) are skipped and must never be
63
+ * picked up as the answer.
64
+ */
65
+ export function readLastAnswerInDom(opts) {
66
+ const sels = opts.sels;
67
+ const think = new RegExp(opts.thinkRe, 'i');
68
+ const inThink = (e) => {
69
+ let n = e;
70
+ while (n) {
71
+ const cls = (n.className || '').toString();
72
+ if (think.test(cls))
73
+ return true;
74
+ n = n.parentElement;
75
+ }
76
+ return false;
77
+ };
78
+ let el = null;
79
+ for (const s of sels) {
80
+ const list = document.querySelectorAll(s);
81
+ if (!list.length)
82
+ continue;
83
+ for (let i = list.length - 1; i >= 0; i--) {
84
+ const cand = list[i];
85
+ if (inThink(cand))
86
+ continue;
87
+ el = cand;
88
+ break;
89
+ }
90
+ if (el)
91
+ break;
92
+ }
93
+ if (!el)
94
+ return '';
95
+ const out = el.innerText || el.textContent || '';
96
+ const NL = String.fromCharCode(10);
97
+ return out.replace(new RegExp(NL + '{3,}', 'g'), NL + NL).trim();
98
+ }
99
+ /**
100
+ * A CHEAP growth signal for the "did the answer start?" loop: the number of
101
+ * message/answer nodes plus the length of the LAST answer element. The old code
102
+ * read document.body.innerText.length every tick, which serializes the WHOLE
103
+ * page (sidebar, history, menus) and forces a layout — expensive on a long chat.
104
+ */
105
+ export function chatSignalInDom(opts) {
106
+ const nodes = document.querySelectorAll(opts.nodesSel).length;
107
+ let lastLen = 0;
108
+ const answers = document.querySelectorAll(opts.mdSel);
109
+ if (answers.length) {
110
+ const lastEl = answers[answers.length - 1];
111
+ lastLen = (lastEl.innerText || '').length;
112
+ }
113
+ return { nodes, lastLen };
114
+ }
115
+ /**
116
+ * Cheap first probe for the Continue button: ONE scan of buttons/role-buttons
117
+ * for a matching visible label. The common path (no button) is probed every
118
+ * tick, so a single evaluate replaces the getByRole count+isVisible scan.
119
+ */
120
+ export function continueVisibleCheapInDom(nameRe) {
121
+ const re = new RegExp(nameRe, 'i');
122
+ const cands = Array.from(document.querySelectorAll('div[role="button"], button'));
123
+ for (const e of cands) {
124
+ const label = ((e.textContent || '') +
125
+ ' ' +
126
+ (e.getAttribute('aria-label') || ''))
127
+ .replace(/\s+/g, ' ')
128
+ .trim();
129
+ if (!re.test(label))
130
+ continue;
131
+ const st = getComputedStyle(e);
132
+ if (st.display === 'none' || st.visibility === 'hidden')
133
+ continue;
134
+ const r = e.getBoundingClientRect();
135
+ if (r.width > 0 && r.height > 0)
136
+ return true;
137
+ }
138
+ return false;
139
+ }
140
+ /**
141
+ * Find the Stop button in the DeepSeek UI. We can't rely on the class alone:
142
+ * during generation the send button (the same circle button) changes its icon
143
+ * to a "square" (stop) while keeping the classes, so a square icon (svg rect)
144
+ * also means Stop.
145
+ */
146
+ export function stopButtonVisibleInDom(btnRe) {
147
+ const re = new RegExp(btnRe, 'i');
148
+ const btns = Array.from(document.querySelectorAll('div[role="button"], button'));
149
+ for (const b of btns) {
150
+ const cls = (b.className || '').toString();
151
+ if (!re.test(cls))
152
+ continue;
153
+ const label = ((b.getAttribute('aria-label') || '') +
154
+ ' ' +
155
+ (b.getAttribute('title') || '') +
156
+ ' ' +
157
+ (b.textContent || '')).toLowerCase();
158
+ if (/stop|останов/.test(label))
159
+ return true;
160
+ // A square icon = Stop button; an arrow (path without rect) = send.
161
+ const svg = b.querySelector('svg');
162
+ if (svg && svg.querySelector('rect'))
163
+ return true;
164
+ }
165
+ return false;
166
+ }
167
+ /**
168
+ * Raw-DOM Stop click with the full pointer/mouse sequence (older builds
169
+ * without a proper role, or a click intercepted by an overlay). Returns true
170
+ * when a square-icon button was found and clicked.
171
+ */
172
+ export function clickStopInDom(btnRe) {
173
+ const re = new RegExp(btnRe, 'i');
174
+ const btns = Array.from(document.querySelectorAll('div[role="button"], button'));
175
+ for (const b of btns) {
176
+ const cls = (b.className || '').toString();
177
+ if (!re.test(cls))
178
+ continue;
179
+ const svg = b.querySelector('svg');
180
+ if (svg && svg.querySelector('rect')) {
181
+ for (const type of [
182
+ 'pointerdown',
183
+ 'mousedown',
184
+ 'pointerup',
185
+ 'mouseup',
186
+ 'click',
187
+ ]) {
188
+ b.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
189
+ }
190
+ b.click();
191
+ return true;
192
+ }
193
+ }
194
+ return false;
195
+ }
196
+ /**
197
+ * Raw-DOM scan for a visible Continue button (fallback for builds without a
198
+ * proper role). The label match is an EXACT short form (optionally with a
199
+ * reasoning suffix) so a "Continue" inside rendered prose is never matched.
200
+ */
201
+ export function continueVisibleInDom() {
202
+ const labelOf = (b) => ((b.textContent || '') + ' ' + (b.getAttribute('aria-label') || ''))
203
+ .replace(/\s+/g, ' ')
204
+ .trim();
205
+ const isExact = (t) => /^(?:continue|продолжить|продолжение)(?:\s+(?:think(?:ing)?|reason(?:ing)?|размышлени[ея]|генераци[юя]|ответ))?\s*[.!…]?$/i.test(t);
206
+ const cands = Array.from(document.querySelectorAll('div[role="button"], button'));
207
+ for (const e of cands) {
208
+ if (!isExact(labelOf(e)))
209
+ continue;
210
+ const st = getComputedStyle(e);
211
+ if (st.display === 'none' || st.visibility === 'hidden')
212
+ continue;
213
+ const r = e.getBoundingClientRect();
214
+ if (r.width > 0 && r.height > 0)
215
+ return true;
216
+ }
217
+ return false;
218
+ }
219
+ /**
220
+ * Raw-DOM Continue click with the full pointer/mouse sequence (fallback for a
221
+ * build without a proper role, or a click intercepted by an overlay).
222
+ * Returns true when a matching visible button was clicked.
223
+ */
224
+ export function clickContinueInDom() {
225
+ const labelOf = (b) => ((b.textContent || '') + ' ' + (b.getAttribute('aria-label') || ''))
226
+ .replace(/\s+/g, ' ')
227
+ .trim();
228
+ const isExact = (t) => /^(?:continue|продолжить|продолжение)(?:\s+(?:think(?:ing)?|reason(?:ing)?|размышлени[ея]|генераци[юя]|ответ))?\s*[.!…]?$/i.test(t);
229
+ const cands = Array.from(document.querySelectorAll('div[role="button"], button'));
230
+ for (const e of cands) {
231
+ const t = labelOf(e);
232
+ if (!isExact(t))
233
+ continue;
234
+ const st = getComputedStyle(e);
235
+ if (st.display === 'none' || st.visibility === 'hidden')
236
+ continue;
237
+ const rect = e.getBoundingClientRect();
238
+ if (rect.width === 0 || rect.height === 0)
239
+ continue;
240
+ const cx = rect.left + rect.width / 2;
241
+ const cy = rect.top + rect.height / 2;
242
+ const opts = {
243
+ bubbles: true,
244
+ cancelable: true,
245
+ composed: true,
246
+ clientX: cx,
247
+ clientY: cy,
248
+ button: 0,
249
+ };
250
+ try {
251
+ e.dispatchEvent(new PointerEvent('pointerdown', opts));
252
+ e.dispatchEvent(new MouseEvent('mousedown', opts));
253
+ e.dispatchEvent(new PointerEvent('pointerup', opts));
254
+ e.dispatchEvent(new MouseEvent('mouseup', opts));
255
+ }
256
+ catch { }
257
+ e.click();
258
+ return true;
259
+ }
260
+ return false;
261
+ }
262
+ /**
263
+ * Insert text into a contenteditable/input element by dispatching a synthetic
264
+ * paste event (the way a real paste lands, which React handlers honor). Runs
265
+ * bound to the element, so it receives `el` + the text value.
266
+ */
267
+ export function pasteTextIntoElement(el, value) {
268
+ el.focus();
269
+ const dt = new DataTransfer();
270
+ dt.setData('text/plain', value);
271
+ const ev = new ClipboardEvent('paste', {
272
+ clipboardData: dt,
273
+ bubbles: true,
274
+ cancelable: true,
275
+ });
276
+ el.dispatchEvent(ev);
277
+ return true;
278
+ }
279
+ /**
280
+ * Read the current text of an input/textarea (its `value`) or a
281
+ * contenteditable element (its innerText/textContent). Runs bound to the
282
+ * element so it can distinguish the two by tagName.
283
+ */
284
+ export function readElementText(el) {
285
+ const tag = el.tagName.toLowerCase();
286
+ if (tag === 'textarea' || tag === 'input') {
287
+ return el.value;
288
+ }
289
+ return el.innerText || el.textContent || '';
290
+ }
291
+ /**
292
+ * Lowercased tagName of an element. Runs bound to the element (Playwright's
293
+ * Locator.evaluate), used to tell a native input/textarea from a
294
+ * contenteditable box.
295
+ */
296
+ export function elementTagNameInDom(el) {
297
+ return el.tagName.toLowerCase();
298
+ }
299
+ /**
300
+ * Is the send control actually enabled? DeepSeek's send control is a
301
+ * div[role=button]; Playwright's isEnabled() does NOT honor aria-disabled on a
302
+ * div, and a click on a disabled button is silently ignored (during an upload
303
+ * the message stays in the box). Read the attribute and a disabled class
304
+ * directly. Runs bound to the element.
305
+ */
306
+ export function isElementEnabledInDom(el) {
307
+ if (el.getAttribute('aria-disabled') === 'true')
308
+ return false;
309
+ const cls = (el.className || '').toString();
310
+ if (/disabled|is-disabled/.test(cls))
311
+ return false;
312
+ return true;
313
+ }
314
+ /**
315
+ * The current text of the input box, trimmed — used to verify the message was
316
+ * actually SENT (DeepSeek clears the box on accept; if the text is still there
317
+ * the click/Enter was ignored). Runs bound to the element.
318
+ */
319
+ export function readInputTrimmedTextInDom(el) {
320
+ return (el.value || el.innerText || el.textContent || '').trim();
321
+ }
322
+ /**
323
+ * The real User-Agent the engine reports. Read after launch so a headless run
324
+ * can strip the "Headless" marker (DeepSeek's CDN 403s that UA).
325
+ */
326
+ export function readUserAgentInDom() {
327
+ return navigator.userAgent;
328
+ }
329
+ /**
330
+ * Find the index of the text/email/tel input that immediately precedes the
331
+ * password field, so the login form can be filled when no explicit
332
+ * LOGIN_SELECTORS matched. Returns -1 when not found.
333
+ */
334
+ export function findLoginInputIndexInDom(pwdSel) {
335
+ const p = document.querySelector(pwdSel);
336
+ if (!p)
337
+ return -1;
338
+ const inputs = Array.from(document.querySelectorAll('input'));
339
+ const pi = inputs.indexOf(p);
340
+ for (let i = pi - 1; i >= 0; i--) {
341
+ const t = (inputs[i].type || 'text').toLowerCase();
342
+ if (t === 'text' || t === 'email' || t === 'tel')
343
+ return i;
344
+ }
345
+ return -1;
346
+ }
347
+ /**
348
+ * Submit the login form from inside the page: click the button whose label
349
+ * matches the login regex, else the primary-looking button in the password
350
+ * field's form. Returns true when a button was clicked. The in-page click is
351
+ * a fallback for a React form whose submit control the selectors missed.
352
+ */
353
+ export function submitLoginFormInDom(opts) {
354
+ const loginRe = new RegExp(opts.submitRe, 'i');
355
+ const p = document.querySelector(opts.pwdSel);
356
+ if (!p)
357
+ return false;
358
+ const form = p.closest('form');
359
+ const scope = form || document;
360
+ const btns = Array.from(scope.querySelectorAll('button[type="submit"], button, div[role="button"]'));
361
+ for (const b of btns) {
362
+ const txt = (b.textContent || '').trim().toLowerCase();
363
+ const aria = (b.getAttribute('aria-label') || '').toLowerCase();
364
+ if (loginRe.test(txt + ' ' + aria)) {
365
+ b.click();
366
+ return true;
367
+ }
368
+ }
369
+ // Fallback: the primary-looking button in the scope.
370
+ const primary = btns.find((b) => {
371
+ const cls = (b.className || '').toString();
372
+ return /primary|submit|ds-button--primary/i.test(cls);
373
+ });
374
+ if (primary) {
375
+ primary.click();
376
+ return true;
377
+ }
378
+ return false;
379
+ }
380
+ /**
381
+ * Prefix/restore the tab title so the operator, looking at the browser
382
+ * window, can tell that the agent is generating. The ORIGINAL title is saved
383
+ * ONCE on `window` and restored when generation ends, so DeepSeek's own chat
384
+ * title is never lost.
385
+ */
386
+ export function setBusyTitleInDom(on) {
387
+ const w = window;
388
+ if (on) {
389
+ if (typeof w.__zamesTitle !== 'string') {
390
+ w.__zamesTitle = document.title.replace(/^⏳\s*/, '');
391
+ }
392
+ document.title = '⏳ ' + w.__zamesTitle;
393
+ }
394
+ else if (typeof w.__zamesTitle === 'string') {
395
+ document.title = w.__zamesTitle;
396
+ w.__zamesTitle = undefined;
397
+ }
398
+ }
399
+ /**
400
+ * Count how many elements each candidate selector matches, for /debug-dom.
401
+ * Runs in-page (see the module header) so it takes the selector lists as an
402
+ * argument instead of referencing deepseek-ui.ts.
403
+ */
404
+ export function dumpDomCountsInDom(sels) {
405
+ const result = { answers: {}, stops: {}, inputs: {} };
406
+ for (const s of sels.answers) {
407
+ result.answers[s] = document.querySelectorAll(s).length;
408
+ }
409
+ for (const s of sels.stops) {
410
+ result.stops[s] = document.querySelectorAll(s).length;
411
+ }
412
+ for (const s of sels.inputs) {
413
+ result.inputs[s] = document.querySelectorAll(s).length;
414
+ }
415
+ return result;
416
+ }