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/CHANGELOG.md +46 -0
- package/dist/agent-loop.js +253 -924
- package/dist/answer-parse.js +765 -0
- package/dist/browser-chats.js +161 -0
- package/dist/browser-dom.js +416 -0
- package/dist/browser-pure.js +249 -0
- package/dist/browser.js +75 -713
- package/dist/changelog.js +29 -0
- package/dist/cli-args.js +48 -0
- package/dist/compact.js +9 -0
- package/dist/config.js +6 -0
- package/dist/diagnostics.js +71 -0
- package/dist/hooks.js +30 -2
- package/dist/i18n/common.js +4 -0
- package/dist/index.js +49 -24
- package/dist/input.js +25 -1
- package/dist/run-task.js +31 -0
- package/dist/shell.js +102 -1
- package/dist/statusline.js +52 -0
- package/dist/subagent.js +40 -3
- package/dist/tools.js +81 -8
- package/package.json +10 -8
- package/scripts/changelog-draft.mts +38 -1
- package/scripts/{eslint-ts6.mjs → eslint-ts6.mts} +11 -4
- package/scripts/release.mts +36 -7
- package/scripts/{render-demo.mjs → render-demo.mts} +12 -11
- package/scripts/{self-smoke.mjs → self-smoke.mts} +41 -4
- /package/scripts/{clean-dist.mjs → clean-dist.mts} +0 -0
- /package/scripts/{coverage-gate.mjs → coverage-gate.mts} +0 -0
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 {
|
|
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
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
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
|
-
//
|
|
1235
|
-
//
|
|
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
|
-
|
|
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(
|
|
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(
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
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,
|
|
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',
|
|
2192
|
+
return { error: 'no chat_messages in response', messages: null };
|
|
2724
2193
|
}
|
|
2725
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
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 =
|
|
2774
|
-
const messages = json
|
|
2775
|
-
if (!
|
|
2224
|
+
const json = await resp.json();
|
|
2225
|
+
const messages = extractChatMessages(json);
|
|
2226
|
+
if (!messages)
|
|
2776
2227
|
return list;
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
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
|
|
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(
|
|
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,
|