zames_pro 2.69.0 → 2.70.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/dist/browser.js +114 -212
- package/dist/commands.js +14 -0
- package/dist/deepseek-ui.js +225 -0
- package/dist/i18n/doctor.js +20 -0
- package/dist/index.js +23 -0
- package/dist/net-capture.js +3 -2
- package/dist/ui-health.js +71 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.70.0] - 2026-10-08
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **browser:** centralize DeepSeek UI constants and add a UI health probe
|
|
15
|
+
|
|
10
16
|
## [2.69.0] - 2026-10-08
|
|
11
17
|
|
|
12
18
|
### Changed
|
package/dist/browser.js
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
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';
|
|
5
|
+
import { probeUiHealth, } from './ui-health.js';
|
|
4
6
|
import path from 'path';
|
|
5
7
|
import os from 'os';
|
|
6
8
|
import fs from 'fs/promises';
|
|
7
9
|
import { theme } from './theme.js';
|
|
8
10
|
import { translate, DEFAULT_LOCALE } from './i18n.js';
|
|
11
|
+
// Re-export the DeepSeek UI regexes other modules/tests import from browser.ts.
|
|
12
|
+
// The definitions live in deepseek-ui.ts; re-exporting the imported bindings
|
|
13
|
+
// here preserves the existing public API (test/*.test.ts import them from
|
|
14
|
+
// browser.ts).
|
|
15
|
+
export { STOP_NAME_RE, CONTINUE_NAME_RE };
|
|
9
16
|
// Muted terminal input for passwords. We do NOT use readline here: readline
|
|
10
17
|
// echoes the typed text through its own internal _writeToOutput, which cannot
|
|
11
18
|
// be reliably overridden from outside (a real bug: the login prompt swallowed
|
|
@@ -86,14 +93,6 @@ export function askPassword(question) {
|
|
|
86
93
|
});
|
|
87
94
|
}
|
|
88
95
|
const USER_DATA_DIR = path.join(os.homedir(), '.zames', 'profile');
|
|
89
|
-
const CHAT_URL = 'https://chat.deepseek.com/';
|
|
90
|
-
// The "New chat" control is NOT a <button>/<a> on the current DeepSeek build
|
|
91
|
-
// (it is a <div>), so matching only button/a missed it, the click timed out
|
|
92
|
-
// and newChat() silently no-opped. Match any clickable-ish element whose text
|
|
93
|
-
// is EXACTLY the new-chat label (an anchored regex keeps a big outer container
|
|
94
|
-
// from matching its long descendant text).
|
|
95
|
-
const NEW_CHAT_SELECTOR = 'button, a, [role="button"], div, span';
|
|
96
|
-
const NEW_CHAT_TEXT_RE = /^\s*(new chat|новый чат|новый диалог)\s*$/i;
|
|
97
96
|
// Cache of the sanitized (non-Headless) User-Agent, keyed by the browser
|
|
98
97
|
// engine path. On the FIRST headless run we detect the real UA, strip the
|
|
99
98
|
// "Headless" marker and store it here; later runs pass it straight to
|
|
@@ -165,119 +164,6 @@ export function sanitizeHeadlessUA(ua) {
|
|
|
165
164
|
// Written after a successful sign-in so the next launch knows a session was
|
|
166
165
|
// stored in the persistent profile (used by /doctor and diagnostics).
|
|
167
166
|
const AUTH_MARKER_FILE = path.join(os.homedir(), '.zames', 'auth.json');
|
|
168
|
-
// DeepSeek sign-in form. The login page is served on the same origin and
|
|
169
|
-
// swaps in a password field; the exact classes change, so we match loosely on
|
|
170
|
-
// input types and the known placeholder/autocomplete attributes.
|
|
171
|
-
const PASSWORD_SELECTORS = [
|
|
172
|
-
'input[type="password"]',
|
|
173
|
-
'input[autocomplete="current-password"]',
|
|
174
|
-
'input[autocomplete="new-password"]',
|
|
175
|
-
];
|
|
176
|
-
const LOGIN_SELECTORS = [
|
|
177
|
-
'input[type="email"]',
|
|
178
|
-
'input[type="tel"]',
|
|
179
|
-
'input[name="email"]',
|
|
180
|
-
'input[name="phone"]',
|
|
181
|
-
'input[name="username"]',
|
|
182
|
-
'input[placeholder*="email" i]',
|
|
183
|
-
'input[placeholder*="phone" i]',
|
|
184
|
-
'input[placeholder*="телефон" i]',
|
|
185
|
-
'input[placeholder*="почт" i]',
|
|
186
|
-
'input[autocomplete="username"]',
|
|
187
|
-
'input[autocomplete="email"]',
|
|
188
|
-
];
|
|
189
|
-
const LOGIN_SUBMIT_SELECTORS = [
|
|
190
|
-
// DeepSeek's real button: a div[role=button] with these classes and a
|
|
191
|
-
// <span class="ds-button__content">Log in</span> inside.
|
|
192
|
-
'div[role="button"].ds-button--primary.ds-button--filled',
|
|
193
|
-
'div[role="button"].ds-button--primary',
|
|
194
|
-
'button[type="submit"]',
|
|
195
|
-
'button:has-text("Log in")',
|
|
196
|
-
'button:has-text("Sign in")',
|
|
197
|
-
'button:has-text("Войти")',
|
|
198
|
-
'div[role="button"]:has-text("Log in")',
|
|
199
|
-
'div[role="button"]:has-text("Sign in")',
|
|
200
|
-
'div[role="button"]:has-text("Войти")',
|
|
201
|
-
];
|
|
202
|
-
const INPUT_SELECTORS = [
|
|
203
|
-
'textarea',
|
|
204
|
-
'div[contenteditable="true"]',
|
|
205
|
-
'[role="textbox"]',
|
|
206
|
-
];
|
|
207
|
-
const ANSWER_SELECTORS = [
|
|
208
|
-
'div.ds-assistant-message-main-content',
|
|
209
|
-
'div[class*="ds-assistant-message-main-content"]',
|
|
210
|
-
'div[class*="ds-markdown"]',
|
|
211
|
-
'div[class*="markdown"]',
|
|
212
|
-
];
|
|
213
|
-
const SEND_SELECTORS = [
|
|
214
|
-
'div[role="button"].ds-button--primary.ds-button--circle',
|
|
215
|
-
'div[role="button"].ds-button--primary.ds-button--filled',
|
|
216
|
-
'button[type="submit"]',
|
|
217
|
-
'button[aria-label*="send" i]',
|
|
218
|
-
'button[aria-label*="отправ" i]',
|
|
219
|
-
];
|
|
220
|
-
// IMPORTANT: you MUST NOT add the generic 'div[role="button"][class*="ds-button--primary"]'
|
|
221
|
-
// here — it matches the send button, which is always visible, and then
|
|
222
|
-
// _isGenerating() always returns true, so the answer is never considered ready.
|
|
223
|
-
const STOP_SELECTORS = [
|
|
224
|
-
'div[role="button"][aria-label*="stop" i]',
|
|
225
|
-
'div[role="button"][aria-label*="останов" i]',
|
|
226
|
-
'button:has-text("Stop")',
|
|
227
|
-
'button:has-text("Остановить")',
|
|
228
|
-
'button[aria-label*="Stop" i]',
|
|
229
|
-
];
|
|
230
|
-
// Accessible name of the Stop button, for a TRUSTED Playwright click. It is a
|
|
231
|
-
// bare stop word (optionally with a short suffix), anchored and length-limited
|
|
232
|
-
// so a "Stop" inside rendered prose is never clicked.
|
|
233
|
-
export const STOP_NAME_RE = /^(?:stop|остановить)(?:\s+\S{0,20})?\s*[.!…]?$/i;
|
|
234
|
-
// DeepSeek's "Continue" button. With the reasoning ("Deep thinking") toggle
|
|
235
|
-
// on, the server caps the THINK phase: the reasoning stops mid-way and the UI
|
|
236
|
-
// offers a Continue button (this is NOT the same as a truncated turn with
|
|
237
|
-
// generation_err — there the answer failed; here the model simply paused).
|
|
238
|
-
// The agent must click it so the reasoning/answer keeps flowing, otherwise the
|
|
239
|
-
// turn sits idle until the operator presses Continue by hand.
|
|
240
|
-
//
|
|
241
|
-
// The button is found by Playwright's accessible name (getByRole) with an
|
|
242
|
-
// EXACT match, falling back to a raw DOM scan. The class names change between
|
|
243
|
-
// builds, so we do not rely on them.
|
|
244
|
-
// Reasoning (Deep thinking) mode uses a LONGER label: the server pauses the
|
|
245
|
-
// THINK phase and offers «Продолжить размышление» / «Continue thinking», NOT a
|
|
246
|
-
// bare «Continue». The old exact list missed those, so with thinking ON the
|
|
247
|
-
// button was never found — neither for the auto-click NOR for the "is the turn
|
|
248
|
-
// paused?" guard — and the operator had to press it by hand.
|
|
249
|
-
//
|
|
250
|
-
// We match the accessible name with a REGEX: a bare continue word OR the same
|
|
251
|
-
// word followed by a short reasoning/answer suffix. It is anchored and
|
|
252
|
-
// length-limited, so a "Continue" inside rendered prose is never clicked (the
|
|
253
|
-
// old non-exact `getByRole` matched any button CONTAINING the word).
|
|
254
|
-
export const CONTINUE_NAME_RE = /^(?:continue|продолжить|продолжение)(?:\s+(?:think(?:ing)?|reason(?:ing)?|размышлени[ея]|генераци[юя]|ответ))?\s*[.!…]?$/i;
|
|
255
|
-
// DeepSeek UI service statuses that are NOT the model's answer.
|
|
256
|
-
// Otherwise the agent takes a status (Reading...) for an answer and breaks parsing.
|
|
257
|
-
const STATUS_RE = /^(reading|thinking|searching|analyzing|generating|stop|остановить|читаю|думаю|поиск|анализ)[\s.…]*$/i;
|
|
258
|
-
// DeepSeek's answer when the rate limit is exceeded.
|
|
259
|
-
//
|
|
260
|
-
// IMPORTANT: the generic "try again later" / "повторите позже" tail is NOT
|
|
261
|
-
// part of this pattern. DeepSeek appends it to MANY toasts ("Server busy.
|
|
262
|
-
// Try again later.", "Service unavailable. Try again later."), so matching it
|
|
263
|
-
// here classified every server hiccup as a RATE LIMIT and sent the agent into
|
|
264
|
-
// a 5-minute wait (and, after the finish-loop stopped reading toasts every
|
|
265
|
-
// tick, looked like "the agent hung"). The rate limit is recognized by its
|
|
266
|
-
// specific wording only.
|
|
267
|
-
const RATE_LIMIT_RE = /(messages? too frequent|too many requests|rate limit|слишком часто|сообщени[яе] слишком част)/i;
|
|
268
|
-
// Transient server-side hiccups (DeepSeek overloaded / hiccup). Unlike the
|
|
269
|
-
// rate limit, these usually clear in seconds, so we retry quickly instead of
|
|
270
|
-
// waiting minutes. "Server busy", 503, "temporarily unavailable", etc.
|
|
271
|
-
const SERVER_BUSY_RE = /(server (is )?busy|server error|service (is )?unavailable|temporarily unavailable|internal server error|502|503|504|server overloaded|сервер занят|сервер перегружен|сервис недоступен|внутренняя ошибка|попробуйте позже)/i;
|
|
272
|
-
// DeepSeek turns a generation that FAILED mid-way into a truncated turn
|
|
273
|
-
// (`quasi_status: INCOMPLETE` + `finish_reason: generation_err`) and shows a
|
|
274
|
-
// "Continue" button in the UI. The failure is invisible to the answer text:
|
|
275
|
-
// the DOM keeps the partial answer (often the previous one), so the finish
|
|
276
|
-
// loop used to wait out the whole timeout and throw ds.send_no_new_answer.
|
|
277
|
-
// The SSE body IS available (net-capture keeps it as `_netCapture`), so we
|
|
278
|
-
// recognize the failed/truncated turn from it and retry instead of hanging.
|
|
279
|
-
const GENERATION_ERR_RE = /"finish_reason":\s*"generation_err"/i;
|
|
280
|
-
const INCOMPLETE_STATUS_RE = /"quasi_status","v":"INCOMPLETE"/i;
|
|
281
167
|
export function isGenerationIncompleteText(text) {
|
|
282
168
|
const t = String(text || '');
|
|
283
169
|
return GENERATION_ERR_RE.test(t) || INCOMPLETE_STATUS_RE.test(t);
|
|
@@ -763,7 +649,7 @@ export class DeepSeekBrowser {
|
|
|
763
649
|
// capture assignment to those URLs: a future endpoint returning a
|
|
764
650
|
// `content`/`text`/`response` string must not clobber the answer and
|
|
765
651
|
// look fresh (it would be returned as the final answer).
|
|
766
|
-
const isAnswerUrl =
|
|
652
|
+
const isAnswerUrl = ANSWER_URL_RE.test(url);
|
|
767
653
|
const extracted = extractAnswer(body);
|
|
768
654
|
if (extracted && isAnswerUrl) {
|
|
769
655
|
this._netCapture = extracted;
|
|
@@ -1065,8 +951,8 @@ export class DeepSeekBrowser {
|
|
|
1065
951
|
// Click exactly the one whose visible label is a login word, so we never
|
|
1066
952
|
// hit "Log in with Google" or another primary button by mistake.
|
|
1067
953
|
const precise = this.page
|
|
1068
|
-
.locator(
|
|
1069
|
-
.filter({ hasText:
|
|
954
|
+
.locator(LOGIN_BUTTON_SELECTOR)
|
|
955
|
+
.filter({ hasText: LOGIN_NAME_RE });
|
|
1070
956
|
const pc = await precise.count().catch(() => 0);
|
|
1071
957
|
if (pc > 0) {
|
|
1072
958
|
try {
|
|
@@ -1091,8 +977,9 @@ export class DeepSeekBrowser {
|
|
|
1091
977
|
}
|
|
1092
978
|
// Button inside the same form as the password field.
|
|
1093
979
|
const formClicked = await this.page
|
|
1094
|
-
.evaluate((
|
|
1095
|
-
const
|
|
980
|
+
.evaluate((opts) => {
|
|
981
|
+
const loginRe = new RegExp(opts.submitRe, 'i');
|
|
982
|
+
const p = document.querySelector(opts.pwdSel);
|
|
1096
983
|
if (!p)
|
|
1097
984
|
return false;
|
|
1098
985
|
const form = p.closest('form');
|
|
@@ -1101,7 +988,7 @@ export class DeepSeekBrowser {
|
|
|
1101
988
|
for (const b of btns) {
|
|
1102
989
|
const txt = (b.textContent || '').trim().toLowerCase();
|
|
1103
990
|
const aria = (b.getAttribute('aria-label') || '').toLowerCase();
|
|
1104
|
-
if (
|
|
991
|
+
if (loginRe.test(txt + ' ' + aria)) {
|
|
1105
992
|
b.click();
|
|
1106
993
|
return true;
|
|
1107
994
|
}
|
|
@@ -1116,7 +1003,10 @@ export class DeepSeekBrowser {
|
|
|
1116
1003
|
return true;
|
|
1117
1004
|
}
|
|
1118
1005
|
return false;
|
|
1119
|
-
},
|
|
1006
|
+
}, {
|
|
1007
|
+
pwdSel: PASSWORD_SELECTORS[0],
|
|
1008
|
+
submitRe: LOGIN_FORM_SUBMIT_RE.source,
|
|
1009
|
+
})
|
|
1120
1010
|
.catch(() => false);
|
|
1121
1011
|
if (formClicked && (await tryLogin()))
|
|
1122
1012
|
return true;
|
|
@@ -1267,16 +1157,18 @@ export class DeepSeekBrowser {
|
|
|
1267
1157
|
// call". The DOM reader has no such self-comparison problem: before the
|
|
1268
1158
|
// send the DOM shows the OLD answer, after it the NEW one.
|
|
1269
1159
|
async _readLastAnswerTextDom() {
|
|
1270
|
-
return await this.page.evaluate((
|
|
1271
|
-
|
|
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).
|
|
1272
1163
|
// They are NOT the answer and must never be picked up as the answer
|
|
1273
1164
|
// (otherwise the terminal would show the long reasoning). We also
|
|
1274
1165
|
// prefer the first (most specific) selector with a hit.
|
|
1166
|
+
const think = new RegExp(opts.thinkRe, 'i');
|
|
1275
1167
|
const inThink = (e) => {
|
|
1276
1168
|
let n = e;
|
|
1277
1169
|
while (n) {
|
|
1278
1170
|
const cls = (n.className || '').toString();
|
|
1279
|
-
if (
|
|
1171
|
+
if (think.test(cls))
|
|
1280
1172
|
return true;
|
|
1281
1173
|
n = n.parentElement;
|
|
1282
1174
|
}
|
|
@@ -1302,7 +1194,7 @@ export class DeepSeekBrowser {
|
|
|
1302
1194
|
const out = el.innerText || el.textContent || '';
|
|
1303
1195
|
const NL = String.fromCharCode(10);
|
|
1304
1196
|
return out.replace(new RegExp(NL + '{3,}', 'g'), NL + NL).trim();
|
|
1305
|
-
}, ANSWER_SELECTORS);
|
|
1197
|
+
}, { sels: ANSWER_SELECTORS, thinkRe: THINK_CLASS_RE.source });
|
|
1306
1198
|
}
|
|
1307
1199
|
// We read ONLY visible toasts/notifications, not the whole page text.
|
|
1308
1200
|
// Otherwise we catch "try again later" from service/hidden blocks and go
|
|
@@ -1358,11 +1250,12 @@ export class DeepSeekBrowser {
|
|
|
1358
1250
|
if (explicit)
|
|
1359
1251
|
return true;
|
|
1360
1252
|
return await this.page
|
|
1361
|
-
.evaluate(() => {
|
|
1253
|
+
.evaluate((btnRe) => {
|
|
1254
|
+
const re = new RegExp(btnRe, 'i');
|
|
1362
1255
|
const btns = Array.from(document.querySelectorAll('div[role="button"], button'));
|
|
1363
1256
|
for (const b of btns) {
|
|
1364
1257
|
const cls = (b.className || '').toString();
|
|
1365
|
-
if (
|
|
1258
|
+
if (!re.test(cls))
|
|
1366
1259
|
continue;
|
|
1367
1260
|
const label = ((b.getAttribute('aria-label') || '') +
|
|
1368
1261
|
' ' +
|
|
@@ -1377,7 +1270,7 @@ export class DeepSeekBrowser {
|
|
|
1377
1270
|
return true;
|
|
1378
1271
|
}
|
|
1379
1272
|
return false;
|
|
1380
|
-
})
|
|
1273
|
+
}, BUTTON_CLASS_RE.source)
|
|
1381
1274
|
.catch(() => false);
|
|
1382
1275
|
}
|
|
1383
1276
|
async _isGenerating() {
|
|
@@ -1391,16 +1284,16 @@ export class DeepSeekBrowser {
|
|
|
1391
1284
|
// all we need to tell "the page grew / a new answer is streaming".
|
|
1392
1285
|
async _chatSignal() {
|
|
1393
1286
|
return await this.page
|
|
1394
|
-
.evaluate(() => {
|
|
1395
|
-
const nodes = document.querySelectorAll(
|
|
1287
|
+
.evaluate((opts) => {
|
|
1288
|
+
const nodes = document.querySelectorAll(opts.nodesSel).length;
|
|
1396
1289
|
let lastLen = 0;
|
|
1397
|
-
const answers = document.querySelectorAll(
|
|
1290
|
+
const answers = document.querySelectorAll(opts.mdSel);
|
|
1398
1291
|
if (answers.length) {
|
|
1399
1292
|
const lastEl = answers[answers.length - 1];
|
|
1400
1293
|
lastLen = (lastEl.innerText || '').length;
|
|
1401
1294
|
}
|
|
1402
1295
|
return { nodes, lastLen };
|
|
1403
|
-
})
|
|
1296
|
+
}, { nodesSel: CHAT_SIGNAL_NODES_SELECTOR, mdSel: MARKDOWN_SELECTOR })
|
|
1404
1297
|
.catch(() => ({ nodes: 0, lastLen: 0 }));
|
|
1405
1298
|
}
|
|
1406
1299
|
// Cheap first probe for the Continue button. Runs ONE page.evaluate
|
|
@@ -1411,8 +1304,8 @@ export class DeepSeekBrowser {
|
|
|
1411
1304
|
// HITS do we run the full scan to get a clickable Locator.
|
|
1412
1305
|
async _continueVisibleCheap() {
|
|
1413
1306
|
return await this.page
|
|
1414
|
-
.evaluate(() => {
|
|
1415
|
-
const re =
|
|
1307
|
+
.evaluate((nameRe) => {
|
|
1308
|
+
const re = new RegExp(nameRe, 'i');
|
|
1416
1309
|
const cands = Array.from(document.querySelectorAll('div[role="button"], button'));
|
|
1417
1310
|
for (const e of cands) {
|
|
1418
1311
|
const label = ((e.textContent || '') +
|
|
@@ -1430,7 +1323,7 @@ export class DeepSeekBrowser {
|
|
|
1430
1323
|
return true;
|
|
1431
1324
|
}
|
|
1432
1325
|
return false;
|
|
1433
|
-
})
|
|
1326
|
+
}, CONTINUE_NAME_RE.source)
|
|
1434
1327
|
.catch(() => false);
|
|
1435
1328
|
}
|
|
1436
1329
|
// Is DeepSeek's "Continue" button currently visible? Used to BLOCK the
|
|
@@ -1608,28 +1501,43 @@ export class DeepSeekBrowser {
|
|
|
1608
1501
|
// so we match by a loose regex on the visible text and read the state from
|
|
1609
1502
|
// aria-pressed. We click ONLY when the state differs, so a send does not
|
|
1610
1503
|
// flip a toggle the operator set by hand.
|
|
1504
|
+
// Find the toggle button whose label matches `labelRe`. Tries the class
|
|
1505
|
+
// anchor first, then the semantic fallbacks (see TOGGLE_SELECTORS) so a
|
|
1506
|
+
// renamed `ds-toggle-button` class does not silently disable the toggles.
|
|
1507
|
+
async _findToggle(labelRe) {
|
|
1508
|
+
for (const sel of TOGGLE_SELECTORS) {
|
|
1509
|
+
try {
|
|
1510
|
+
const btns = this.page.locator(sel);
|
|
1511
|
+
const count = await btns.count().catch(() => 0);
|
|
1512
|
+
for (let i = 0; i < count; i++) {
|
|
1513
|
+
const b = btns.nth(i);
|
|
1514
|
+
const txt = ((await b.textContent().catch(() => '')) || '').trim();
|
|
1515
|
+
if (!labelRe.test(txt))
|
|
1516
|
+
continue;
|
|
1517
|
+
const pressed = (await b.getAttribute('aria-pressed').catch(() => null)) === 'true';
|
|
1518
|
+
return { loc: b, pressed };
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
catch { }
|
|
1522
|
+
}
|
|
1523
|
+
return null;
|
|
1524
|
+
}
|
|
1611
1525
|
async _setToggle(labelRe, want) {
|
|
1612
1526
|
try {
|
|
1613
|
-
const
|
|
1614
|
-
|
|
1615
|
-
for (let i = 0; i < count; i++) {
|
|
1616
|
-
const b = btns.nth(i);
|
|
1617
|
-
const txt = ((await b.textContent().catch(() => '')) || '').trim();
|
|
1618
|
-
if (!labelRe.test(txt))
|
|
1619
|
-
continue;
|
|
1620
|
-
const pressed = (await b.getAttribute('aria-pressed').catch(() => null)) === 'true';
|
|
1621
|
-
if (pressed !== want) {
|
|
1622
|
-
await b.click({ timeout: 2000 }).catch(() => { });
|
|
1623
|
-
await this.page.waitForTimeout(150);
|
|
1624
|
-
}
|
|
1527
|
+
const found = await this._findToggle(labelRe);
|
|
1528
|
+
if (!found)
|
|
1625
1529
|
return;
|
|
1530
|
+
if (found.pressed !== want) {
|
|
1531
|
+
await found.loc.click({ timeout: 2000 }).catch(() => { });
|
|
1532
|
+
await this.page.waitForTimeout(150);
|
|
1626
1533
|
}
|
|
1534
|
+
return;
|
|
1627
1535
|
}
|
|
1628
1536
|
catch { }
|
|
1629
1537
|
}
|
|
1630
1538
|
async _applyToggles() {
|
|
1631
|
-
await this._setToggle(
|
|
1632
|
-
await this._setToggle(
|
|
1539
|
+
await this._setToggle(DEEP_THINKING_RE, this.deepThinking);
|
|
1540
|
+
await this._setToggle(WEB_SEARCH_RE, this.webSearch);
|
|
1633
1541
|
this._toggles = await this.getToggleStates();
|
|
1634
1542
|
}
|
|
1635
1543
|
// Synchronous snapshot of the last known toggle states, for the editor's
|
|
@@ -1652,22 +1560,16 @@ export class DeepSeekBrowser {
|
|
|
1652
1560
|
async getToggleStates() {
|
|
1653
1561
|
const read = async (labelRe, fallback) => {
|
|
1654
1562
|
try {
|
|
1655
|
-
const
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
const b = btns.nth(i);
|
|
1659
|
-
const txt = ((await b.textContent().catch(() => '')) || '').trim();
|
|
1660
|
-
if (!labelRe.test(txt))
|
|
1661
|
-
continue;
|
|
1662
|
-
return ((await b.getAttribute('aria-pressed').catch(() => null)) === 'true');
|
|
1663
|
-
}
|
|
1563
|
+
const found = await this._findToggle(labelRe);
|
|
1564
|
+
if (found)
|
|
1565
|
+
return found.pressed;
|
|
1664
1566
|
}
|
|
1665
1567
|
catch { }
|
|
1666
1568
|
return fallback;
|
|
1667
1569
|
};
|
|
1668
1570
|
return {
|
|
1669
|
-
deepThinking: await read(
|
|
1670
|
-
webSearch: await read(
|
|
1571
|
+
deepThinking: await read(DEEP_THINKING_RE, this.deepThinking),
|
|
1572
|
+
webSearch: await read(WEB_SEARCH_RE, this.webSearch),
|
|
1671
1573
|
};
|
|
1672
1574
|
}
|
|
1673
1575
|
// Stop the current generation. Used by Esc/Ctrl+C. Prefers a TRUSTED
|
|
@@ -1704,11 +1606,12 @@ export class DeepSeekBrowser {
|
|
|
1704
1606
|
// 3) Raw DOM scan + the full pointer/mouse sequence (older builds without
|
|
1705
1607
|
// a proper role, or a click intercepted by an overlay).
|
|
1706
1608
|
const clicked = await this.page
|
|
1707
|
-
.evaluate(() => {
|
|
1609
|
+
.evaluate((btnRe) => {
|
|
1610
|
+
const re = new RegExp(btnRe, 'i');
|
|
1708
1611
|
const btns = Array.from(document.querySelectorAll('div[role="button"], button'));
|
|
1709
1612
|
for (const b of btns) {
|
|
1710
1613
|
const cls = (b.className || '').toString();
|
|
1711
|
-
if (
|
|
1614
|
+
if (!re.test(cls))
|
|
1712
1615
|
continue;
|
|
1713
1616
|
const svg = b.querySelector('svg');
|
|
1714
1617
|
if (svg && svg.querySelector('rect')) {
|
|
@@ -1726,7 +1629,7 @@ export class DeepSeekBrowser {
|
|
|
1726
1629
|
}
|
|
1727
1630
|
}
|
|
1728
1631
|
return false;
|
|
1729
|
-
})
|
|
1632
|
+
}, BUTTON_CLASS_RE.source)
|
|
1730
1633
|
.catch(() => false);
|
|
1731
1634
|
if (clicked)
|
|
1732
1635
|
return true;
|
|
@@ -1984,15 +1887,7 @@ export class DeepSeekBrowser {
|
|
|
1984
1887
|
return failed;
|
|
1985
1888
|
// Preferred path: click the attach button and let the file chooser event
|
|
1986
1889
|
// carry the files. This is how a real user attaches a file and it reliably
|
|
1987
|
-
// triggers DeepSeek's upload handler.
|
|
1988
|
-
const ATTACH_SELECTORS = [
|
|
1989
|
-
'div[role="button"][aria-label*="attach" i]',
|
|
1990
|
-
'div[role="button"][aria-label*="влож" i]',
|
|
1991
|
-
'button[aria-label*="attach" i]',
|
|
1992
|
-
'button[aria-label*="влож" i]',
|
|
1993
|
-
'[class*="upload"]',
|
|
1994
|
-
'[class*="attach"]',
|
|
1995
|
-
];
|
|
1890
|
+
// triggers DeepSeek's upload handler. See ATTACH_SELECTORS in deepseek-ui.ts.
|
|
1996
1891
|
for (const sel of ATTACH_SELECTORS) {
|
|
1997
1892
|
const btn = this.page.locator(sel).last();
|
|
1998
1893
|
try {
|
|
@@ -2678,15 +2573,23 @@ export class DeepSeekBrowser {
|
|
|
2678
2573
|
});
|
|
2679
2574
|
return { file: filePath, selectors: report };
|
|
2680
2575
|
}
|
|
2576
|
+
// A cheap "can the agent still drive this page?" probe for /doctor and
|
|
2577
|
+
// startup. See ui-health.ts: it probes the ALWAYS-present controls (input,
|
|
2578
|
+
// send, toggles, new-chat) and reports the missing ones, so a DeepSeek
|
|
2579
|
+
// redesign surfaces as a clear message BEFORE a task hangs on it. Never
|
|
2580
|
+
// throws — a dead page reports every capability missing.
|
|
2581
|
+
async probeUiHealth() {
|
|
2582
|
+
if (!this.page) {
|
|
2583
|
+
return { ok: false, present: [], missing: [], missingCritical: [] };
|
|
2584
|
+
}
|
|
2585
|
+
const page = {
|
|
2586
|
+
count: (sel) => this.page.locator(sel).count(),
|
|
2587
|
+
countByText: (sel, textRe) => this.page.locator(sel).filter({ hasText: textRe }).count(),
|
|
2588
|
+
};
|
|
2589
|
+
return await probeUiHealth(page);
|
|
2590
|
+
}
|
|
2681
2591
|
async _ensureSidebarOpen() {
|
|
2682
|
-
const
|
|
2683
|
-
'button[aria-label*="sidebar" i]',
|
|
2684
|
-
'button[aria-label*="история" i]',
|
|
2685
|
-
'button[aria-label*="history" i]',
|
|
2686
|
-
'button[class*="sidebar-toggle"]',
|
|
2687
|
-
'button[class*="sidebarToggle"]',
|
|
2688
|
-
];
|
|
2689
|
-
for (const sel of toggles) {
|
|
2592
|
+
for (const sel of SIDEBAR_TOGGLE_SELECTORS) {
|
|
2690
2593
|
try {
|
|
2691
2594
|
const btn = this.page.locator(sel).first();
|
|
2692
2595
|
if ((await btn.count()) === 0)
|
|
@@ -2750,7 +2653,7 @@ export class DeepSeekBrowser {
|
|
|
2750
2653
|
catch { }
|
|
2751
2654
|
}
|
|
2752
2655
|
try {
|
|
2753
|
-
await this.page.goto(
|
|
2656
|
+
await this.page.goto(chatUrl(id), {
|
|
2754
2657
|
waitUntil: 'domcontentloaded',
|
|
2755
2658
|
timeout: 20_000,
|
|
2756
2659
|
});
|
|
@@ -2796,7 +2699,8 @@ export class DeepSeekBrowser {
|
|
|
2796
2699
|
const res = await this.page
|
|
2797
2700
|
.evaluate(async (opts) => {
|
|
2798
2701
|
try {
|
|
2799
|
-
const url =
|
|
2702
|
+
const url = opts.historyUrl +
|
|
2703
|
+
'?chat_session_id=' +
|
|
2800
2704
|
encodeURIComponent(opts.chatId);
|
|
2801
2705
|
const headers = {
|
|
2802
2706
|
accept: 'application/json',
|
|
@@ -2845,7 +2749,7 @@ export class DeepSeekBrowser {
|
|
|
2845
2749
|
catch (e) {
|
|
2846
2750
|
return { error: 'fetch failed: ' + e.message, list: [] };
|
|
2847
2751
|
}
|
|
2848
|
-
}, { chatId: id, auth, pow })
|
|
2752
|
+
}, { chatId: id, auth, pow, historyUrl: HISTORY_MESSAGES_URL })
|
|
2849
2753
|
.catch((e) => ({
|
|
2850
2754
|
error: 'evaluate failed: ' + e.message,
|
|
2851
2755
|
list: [],
|
|
@@ -2863,8 +2767,7 @@ export class DeepSeekBrowser {
|
|
|
2863
2767
|
// when the in-page fetch was blocked (CSP, CORS, a page error).
|
|
2864
2768
|
this._lastHistoryError = res.error;
|
|
2865
2769
|
try {
|
|
2866
|
-
const resp = await this.page.request.get('
|
|
2867
|
-
encodeURIComponent(id), { headers: { accept: 'application/json' } });
|
|
2770
|
+
const resp = await this.page.request.get(HISTORY_MESSAGES_URL + '?chat_session_id=' + encodeURIComponent(id), { headers: { accept: 'application/json' } });
|
|
2868
2771
|
if (!resp.ok())
|
|
2869
2772
|
return list;
|
|
2870
2773
|
const json = (await resp.json());
|
|
@@ -2911,12 +2814,14 @@ export class DeepSeekBrowser {
|
|
|
2911
2814
|
if (!this.page)
|
|
2912
2815
|
return [];
|
|
2913
2816
|
const raw = await this.page
|
|
2914
|
-
.evaluate(() => {
|
|
2817
|
+
.evaluate((opts) => {
|
|
2818
|
+
const think = new RegExp(opts.thinkRe, 'i');
|
|
2819
|
+
const assistantRe = new RegExp(opts.assistantRe, 'i');
|
|
2915
2820
|
const inThink = (e) => {
|
|
2916
2821
|
let n = e;
|
|
2917
2822
|
while (n) {
|
|
2918
2823
|
const cls = (n.className || '').toString();
|
|
2919
|
-
if (
|
|
2824
|
+
if (think.test(cls))
|
|
2920
2825
|
return true;
|
|
2921
2826
|
n = n.parentElement;
|
|
2922
2827
|
}
|
|
@@ -2934,25 +2839,19 @@ export class DeepSeekBrowser {
|
|
|
2934
2839
|
// detection is content-based, not class-prefix-based.
|
|
2935
2840
|
const looksAssistant = (e) => {
|
|
2936
2841
|
const cls = (e.className || '').toString();
|
|
2937
|
-
if (
|
|
2842
|
+
if (assistantRe.test(cls))
|
|
2938
2843
|
return true;
|
|
2939
|
-
if (e.querySelector(
|
|
2844
|
+
if (e.querySelector(opts.assistantSel))
|
|
2940
2845
|
return true;
|
|
2941
2846
|
// A user bubble has no rendered markdown; an answer does.
|
|
2942
|
-
if (e.querySelector(
|
|
2847
|
+
if (e.querySelector(opts.markdownSel))
|
|
2943
2848
|
return true;
|
|
2944
2849
|
return false;
|
|
2945
2850
|
};
|
|
2946
2851
|
// Message-level containers first (broad), then the answer wrappers.
|
|
2947
|
-
|
|
2948
|
-
'[data-message-id]',
|
|
2949
|
-
'[class*="ds-message"]',
|
|
2950
|
-
'[class*="chat-message"]',
|
|
2951
|
-
'[class*="message-item"]',
|
|
2952
|
-
'[class*="_message"]',
|
|
2953
|
-
];
|
|
2852
|
+
// See MESSAGE_CONTAINER_SELECTORS in deepseek-ui.ts.
|
|
2954
2853
|
let blocks = [];
|
|
2955
|
-
for (const s of containerSels) {
|
|
2854
|
+
for (const s of opts.containerSels) {
|
|
2956
2855
|
const found = Array.from(document.querySelectorAll(s)).filter((e) => !inThink(e) && textOf(e).length > 0);
|
|
2957
2856
|
if (found.length) {
|
|
2958
2857
|
blocks = found;
|
|
@@ -2983,12 +2882,8 @@ export class DeepSeekBrowser {
|
|
|
2983
2882
|
}
|
|
2984
2883
|
// Last resort: assistant answers only (no user turns) — better than
|
|
2985
2884
|
// nothing when no message container matched.
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
'div[class*="ds-assistant-message-main-content"]',
|
|
2989
|
-
'div[class*="ds-markdown"]',
|
|
2990
|
-
];
|
|
2991
|
-
for (const s of sels) {
|
|
2885
|
+
// See RESTORE_ANSWER_SELECTORS in deepseek-ui.ts.
|
|
2886
|
+
for (const s of opts.answerSels) {
|
|
2992
2887
|
const list = Array.from(document.querySelectorAll(s)).filter((e) => !inThink(e));
|
|
2993
2888
|
if (!list.length)
|
|
2994
2889
|
continue;
|
|
@@ -3000,6 +2895,13 @@ export class DeepSeekBrowser {
|
|
|
3000
2895
|
break;
|
|
3001
2896
|
}
|
|
3002
2897
|
return out;
|
|
2898
|
+
}, {
|
|
2899
|
+
containerSels: MESSAGE_CONTAINER_SELECTORS,
|
|
2900
|
+
answerSels: RESTORE_ANSWER_SELECTORS,
|
|
2901
|
+
thinkRe: THINK_CLASS_RE.source,
|
|
2902
|
+
assistantRe: ASSISTANT_CLASS_RE.source,
|
|
2903
|
+
assistantSel: ASSISTANT_SELECTOR,
|
|
2904
|
+
markdownSel: ANSWER_MARKDOWN_SELECTOR,
|
|
3003
2905
|
})
|
|
3004
2906
|
.catch(() => []);
|
|
3005
2907
|
return raw;
|
|
@@ -3007,7 +2909,7 @@ export class DeepSeekBrowser {
|
|
|
3007
2909
|
async getCurrentChatId() {
|
|
3008
2910
|
try {
|
|
3009
2911
|
const url = this.page.url();
|
|
3010
|
-
const m = url.match(
|
|
2912
|
+
const m = url.match(CHAT_ID_RE);
|
|
3011
2913
|
if (m)
|
|
3012
2914
|
return m[1];
|
|
3013
2915
|
return this._netChatId;
|
package/dist/commands.js
CHANGED
|
@@ -309,6 +309,20 @@ export function renderDoctor(d, t = translate('en')) {
|
|
|
309
309
|
if (d.headless) {
|
|
310
310
|
row(!!d.headlessUaCached, 'headless', d.headlessUaCached ? t('doctor.ua_cached') : t('doctor.ua_not_cached'));
|
|
311
311
|
}
|
|
312
|
+
// The DeepSeek UI probe (see ui-health.ts). Rendered only when the caller
|
|
313
|
+
// ran it (the browser must be up); a missing capability is a WARN row, and a
|
|
314
|
+
// missing CRITICAL one is called out in words so the operator knows the next
|
|
315
|
+
// task will fail before it starts.
|
|
316
|
+
if (d.uiPresent || d.uiMissing) {
|
|
317
|
+
const missing = d.uiMissing || [];
|
|
318
|
+
const missingCritical = d.uiMissingCritical || [];
|
|
319
|
+
const summary = missing.length === 0
|
|
320
|
+
? t('doctor.ui_ok')
|
|
321
|
+
: missingCritical.length > 0
|
|
322
|
+
? t('doctor.ui_broken', { v: missingCritical.join(', ') })
|
|
323
|
+
: t('doctor.ui_degraded', { v: missing.join(', ') });
|
|
324
|
+
row(missing.length === 0, 'deepseek ui', summary);
|
|
325
|
+
}
|
|
312
326
|
return t('doctor.title') + NL + rows.join(NL);
|
|
313
327
|
}
|
|
314
328
|
// ---------- /add-dir ----------
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// DeepSeek UI constants — the SINGLE place where chat.deepseek.com specifics live.
|
|
2
|
+
//
|
|
3
|
+
// WHY this module exists: the agent drives the DeepSeek web UI through
|
|
4
|
+
// Playwright, so every selector, button label and protocol marker is a
|
|
5
|
+
// hardcoded assumption about a page we do not control. Keeping them scattered
|
|
6
|
+
// across browser.ts made a DeepSeek redesign a diffuse, easy-to-miss breakage
|
|
7
|
+
// (a renamed class here, a relabeled button there). Concentrating them here
|
|
8
|
+
// turns "DeepSeek changed the UI" into ONE file to audit, and lets the health
|
|
9
|
+
// check (probeUiHealth) report the exact broken capability to the operator
|
|
10
|
+
// BEFORE a task hangs on it.
|
|
11
|
+
//
|
|
12
|
+
// Priority order everywhere: prefer SEMANTIC anchors (role / aria-label /
|
|
13
|
+
// accessible name / input type) over DeepSeek's `ds-*` class names, which
|
|
14
|
+
// drift between builds. The `ds-*` selectors stay in the chains as a
|
|
15
|
+
// high-precision FIRST hit, with semantic fallbacks after them.
|
|
16
|
+
// ---------- endpoints ----------
|
|
17
|
+
export const CHAT_URL = 'https://chat.deepseek.com/';
|
|
18
|
+
export const CHAT_ID_RE = /\/chat\/s\/([a-zA-Z0-9_-]+)/;
|
|
19
|
+
// DeepSeek's chat URL shape. A direct navigation always yields the requested
|
|
20
|
+
// chat (the sidebar link click is tried first, this is the fallback).
|
|
21
|
+
export const CHAT_PATH_PREFIX = 'a/chat/s/';
|
|
22
|
+
export function chatUrl(id) {
|
|
23
|
+
return CHAT_URL + CHAT_PATH_PREFIX + id;
|
|
24
|
+
}
|
|
25
|
+
// The history endpoint returns EVERY message of a chat as JSON (the rendered
|
|
26
|
+
// page virtualizes long chats). Requested from INSIDE the page so the auth
|
|
27
|
+
// cookie and PoW handling are the browser's; see browser.fetchChatMessages.
|
|
28
|
+
export const HISTORY_MESSAGES_URL = 'https://chat.deepseek.com/api/v0/chat/history_messages';
|
|
29
|
+
// ---------- input ----------
|
|
30
|
+
export const INPUT_SELECTORS = [
|
|
31
|
+
'textarea',
|
|
32
|
+
'div[contenteditable="true"]',
|
|
33
|
+
'[role="textbox"]',
|
|
34
|
+
];
|
|
35
|
+
// ---------- send ----------
|
|
36
|
+
// IMPORTANT: you MUST NOT add the generic
|
|
37
|
+
// 'div[role="button"][class*="ds-button--primary"]' here — it matches the
|
|
38
|
+
// STOP button too, and then _isGenerating() always returns true, so an answer
|
|
39
|
+
// is never considered ready.
|
|
40
|
+
export const SEND_SELECTORS = [
|
|
41
|
+
'div[role="button"].ds-button--primary.ds-button--circle',
|
|
42
|
+
'div[role="button"].ds-button--primary.ds-button--filled',
|
|
43
|
+
'button[type="submit"]',
|
|
44
|
+
'button[aria-label*="send" i]',
|
|
45
|
+
'button[aria-label*="отправ" i]',
|
|
46
|
+
];
|
|
47
|
+
// DeepSeek's own button classes. Used as a coarse "is this a DeepSeek button?"
|
|
48
|
+
// filter in the raw-DOM fallbacks for Stop and for the send/stop icon
|
|
49
|
+
// detection. Kept as a source string because the fallback runs INSIDE
|
|
50
|
+
// page.evaluate, which cannot receive a RegExp object.
|
|
51
|
+
export const BUTTON_CLASS_RE = /ds-button--(circle|primary|filled)/i;
|
|
52
|
+
// ---------- stop ----------
|
|
53
|
+
export const STOP_SELECTORS = [
|
|
54
|
+
'div[role="button"][aria-label*="stop" i]',
|
|
55
|
+
'div[role="button"][aria-label*="останов" i]',
|
|
56
|
+
'button:has-text("Stop")',
|
|
57
|
+
'button:has-text("Остановить")',
|
|
58
|
+
'button[aria-label*="Stop" i]',
|
|
59
|
+
];
|
|
60
|
+
// Accessible name of the Stop button, for a TRUSTED Playwright click. A bare
|
|
61
|
+
// stop word (optionally with a short suffix), anchored and length-limited so a
|
|
62
|
+
// "Stop" inside rendered prose is never clicked.
|
|
63
|
+
export const STOP_NAME_RE = /^(?:stop|остановить)(?:\s+\S{0,20})?\s*[.!…]?$/i;
|
|
64
|
+
// ---------- continue ----------
|
|
65
|
+
// DeepSeek's "Continue" button. With the reasoning ("Deep thinking") toggle on
|
|
66
|
+
// the server caps the THINK phase: the reasoning stops mid-way and the UI
|
|
67
|
+
// offers a Continue button (NOT the same as a truncated turn with
|
|
68
|
+
// generation_err — there the answer FAILED; here the model paused). The agent
|
|
69
|
+
// must click it so the reasoning/answer keeps flowing.
|
|
70
|
+
//
|
|
71
|
+
// Reasoning mode uses a LONGER label («Продолжить размышление» / «Continue
|
|
72
|
+
// thinking»), not a bare «Continue», so the match is a regex: a bare continue
|
|
73
|
+
// word OR the word plus a short reasoning/answer suffix. It is anchored and
|
|
74
|
+
// length-limited, so a "Continue" inside rendered prose is never clicked.
|
|
75
|
+
export const CONTINUE_NAME_RE = /^(?:continue|продолжить|продолжение)(?:\s+(?:think(?:ing)?|reason(?:ing)?|размышлени[ея]|генераци[юя]|ответ))?\s*[.!…]?$/i;
|
|
76
|
+
// ---------- messages / answers ----------
|
|
77
|
+
// The rendered assistant answer. The FIRST hit is preferred (most specific),
|
|
78
|
+
// so `ds-markdown` stays ahead of the generic `markdown` catch-all.
|
|
79
|
+
export const ANSWER_SELECTORS = [
|
|
80
|
+
'div.ds-assistant-message-main-content',
|
|
81
|
+
'div[class*="ds-assistant-message-main-content"]',
|
|
82
|
+
'div[class*="ds-markdown"]',
|
|
83
|
+
'div[class*="markdown"]',
|
|
84
|
+
];
|
|
85
|
+
// Reasoning blocks. They are NOT the answer and must never be picked up as
|
|
86
|
+
// one, otherwise the terminal would show the model's long reasoning.
|
|
87
|
+
export const THINK_CLASS_RE = /ds-think-content|thinking-content/i;
|
|
88
|
+
// Last-resort answer-only selectors for the restored-dialogue scraper. The
|
|
89
|
+
// generic `markdown` catch-all is deliberately absent here: without a message
|
|
90
|
+
// container it would also match a user bubble that happens to render markdown.
|
|
91
|
+
export const RESTORE_ANSWER_SELECTORS = [
|
|
92
|
+
'div.ds-assistant-message-main-content',
|
|
93
|
+
'div[class*="ds-assistant-message-main-content"]',
|
|
94
|
+
'div[class*="ds-markdown"]',
|
|
95
|
+
];
|
|
96
|
+
// Message-level containers for the restored dialogue (broad first).
|
|
97
|
+
export const MESSAGE_CONTAINER_SELECTORS = [
|
|
98
|
+
'[data-message-id]',
|
|
99
|
+
'[class*="ds-message"]',
|
|
100
|
+
'[class*="chat-message"]',
|
|
101
|
+
'[class*="message-item"]',
|
|
102
|
+
'[class*="_message"]',
|
|
103
|
+
];
|
|
104
|
+
// A message block is "assistant" when it carries the assistant class OR a
|
|
105
|
+
// rendered markdown wrapper (a user bubble has no rendered markdown). Used by
|
|
106
|
+
// the restored-dialogue scraper; the class names drift between builds, so the
|
|
107
|
+
// detection is content-based rather than class-prefix-only.
|
|
108
|
+
export const ASSISTANT_CLASS_RE = /ds-assistant-message|assistant-message/i;
|
|
109
|
+
export const ASSISTANT_SELECTOR = '[class*="ds-assistant-message"]';
|
|
110
|
+
export const ANSWER_MARKDOWN_SELECTOR = '[class*="ds-markdown"]';
|
|
111
|
+
// A cheap "did the page grow?" signal (_chatSignal): count message nodes and
|
|
112
|
+
// measure the LAST answer node's text length. The old code read
|
|
113
|
+
// document.body.innerText.length every tick, which serializes the WHOLE page
|
|
114
|
+
// (sidebar, history, menus) and forces a layout — expensive on a long chat.
|
|
115
|
+
export const CHAT_SIGNAL_NODES_SELECTOR = '[class*="ds-message"], [class*="chat-message"], .ds-markdown';
|
|
116
|
+
export const MARKDOWN_SELECTOR = '.ds-markdown';
|
|
117
|
+
// ---------- new chat ----------
|
|
118
|
+
// The "New chat" control is NOT a <button>/<a> on the current DeepSeek build
|
|
119
|
+
// (it is a <div>), so matching only button/a missed it. Match any
|
|
120
|
+
// clickable-ish element whose text is EXACTLY the new-chat label (an anchored
|
|
121
|
+
// regex keeps a big outer container from matching its long descendant text).
|
|
122
|
+
export const NEW_CHAT_SELECTOR = 'button, a, [role="button"], div, span';
|
|
123
|
+
export const NEW_CHAT_TEXT_RE = /^\s*(new chat|новый чат|новый диалог)\s*$/i;
|
|
124
|
+
// ---------- login ----------
|
|
125
|
+
// The login page is served on the same origin and swaps in a password field;
|
|
126
|
+
// the exact classes change, so we match loosely on input types and the known
|
|
127
|
+
// placeholder/autocomplete attributes.
|
|
128
|
+
export const PASSWORD_SELECTORS = [
|
|
129
|
+
'input[type="password"]',
|
|
130
|
+
'input[autocomplete="current-password"]',
|
|
131
|
+
'input[autocomplete="new-password"]',
|
|
132
|
+
];
|
|
133
|
+
export const LOGIN_SELECTORS = [
|
|
134
|
+
'input[type="email"]',
|
|
135
|
+
'input[type="tel"]',
|
|
136
|
+
'input[name="email"]',
|
|
137
|
+
'input[name="phone"]',
|
|
138
|
+
'input[name="username"]',
|
|
139
|
+
'input[placeholder*="email" i]',
|
|
140
|
+
'input[placeholder*="phone" i]',
|
|
141
|
+
'input[placeholder*="телефон" i]',
|
|
142
|
+
'input[placeholder*="почт" i]',
|
|
143
|
+
'input[autocomplete="username"]',
|
|
144
|
+
'input[autocomplete="email"]',
|
|
145
|
+
];
|
|
146
|
+
// Any login-ish button: a primary DeepSeek button or a plain <button>. The
|
|
147
|
+
// visible label is filtered by LOGIN_NAME_RE so "Log in with Google" is not
|
|
148
|
+
// hit by mistake.
|
|
149
|
+
export const LOGIN_BUTTON_SELECTOR = 'div[role="button"].ds-button--primary, button';
|
|
150
|
+
export const LOGIN_NAME_RE = /^\s*(log ?in|sign ?in|войти)\s*$/i;
|
|
151
|
+
// A looser label test used inside the form fallback: a Continue button can also
|
|
152
|
+
// submit the login step on some builds.
|
|
153
|
+
export const LOGIN_FORM_SUBMIT_RE = /log ?in|sign ?in|войти|continue|продолж/i;
|
|
154
|
+
export const LOGIN_SUBMIT_SELECTORS = [
|
|
155
|
+
// DeepSeek's real button: a div[role=button] with these classes and a
|
|
156
|
+
// <span class="ds-button__content">Log in</span> inside.
|
|
157
|
+
'div[role="button"].ds-button--primary.ds-button--filled',
|
|
158
|
+
'div[role="button"].ds-button--primary',
|
|
159
|
+
'button[type="submit"]',
|
|
160
|
+
'button:has-text("Log in")',
|
|
161
|
+
'button:has-text("Sign in")',
|
|
162
|
+
'button:has-text("Войти")',
|
|
163
|
+
'div[role="button"]:has-text("Log in")',
|
|
164
|
+
'div[role="button"]:has-text("Sign in")',
|
|
165
|
+
'div[role="button"]:has-text("Войти")',
|
|
166
|
+
];
|
|
167
|
+
// ---------- sidebar / chats ----------
|
|
168
|
+
export const SIDEBAR_TOGGLE_SELECTORS = [
|
|
169
|
+
'button[aria-label*="sidebar" i]',
|
|
170
|
+
'button[aria-label*="история" i]',
|
|
171
|
+
'button[aria-label*="history" i]',
|
|
172
|
+
'button[class*="sidebar-toggle"]',
|
|
173
|
+
'button[class*="sidebarToggle"]',
|
|
174
|
+
];
|
|
175
|
+
// ---------- toggles (deep thinking / web search) ----------
|
|
176
|
+
// DeepSeek exposes two toggle buttons above the input. Their labels are
|
|
177
|
+
// localized, so we match by a loose regex on the visible text and read the
|
|
178
|
+
// state from aria-pressed. `.ds-toggle-button` is the class anchor; the
|
|
179
|
+
// semantic fallback ([aria-pressed] or [role=switch]) is tried when the class
|
|
180
|
+
// is renamed, so a redesign does not silently disable the toggles.
|
|
181
|
+
export const TOGGLE_SELECTORS = [
|
|
182
|
+
'.ds-toggle-button',
|
|
183
|
+
'[role="switch"]',
|
|
184
|
+
'div[role="button"][aria-pressed]',
|
|
185
|
+
'button[aria-pressed]',
|
|
186
|
+
];
|
|
187
|
+
export const DEEP_THINKING_RE = /глубок|deep\s*think/i;
|
|
188
|
+
export const WEB_SEARCH_RE = /поиск|search/i;
|
|
189
|
+
// ---------- attachments ----------
|
|
190
|
+
export const ATTACH_SELECTORS = [
|
|
191
|
+
'div[role="button"][aria-label*="attach" i]',
|
|
192
|
+
'div[role="button"][aria-label*="влож" i]',
|
|
193
|
+
'button[aria-label*="attach" i]',
|
|
194
|
+
'button[aria-label*="влож" i]',
|
|
195
|
+
'[class*="upload"]',
|
|
196
|
+
'[class*="attach"]',
|
|
197
|
+
];
|
|
198
|
+
// ---------- service statuses / errors ----------
|
|
199
|
+
// DeepSeek UI service statuses that are NOT the model's answer. Otherwise the
|
|
200
|
+
// agent takes a status (Reading...) for an answer and breaks parsing.
|
|
201
|
+
export const STATUS_RE = /^(reading|thinking|searching|analyzing|generating|stop|остановить|читаю|думаю|поиск|анализ)[\s.…]*$/i;
|
|
202
|
+
// DeepSeek's answer when the rate limit is exceeded.
|
|
203
|
+
//
|
|
204
|
+
// IMPORTANT: the generic "try again later" / "повторите позже" tail is NOT
|
|
205
|
+
// part of this pattern. DeepSeek appends it to MANY toasts ("Server busy.
|
|
206
|
+
// Try again later.", "Service unavailable. Try again later."), so matching it
|
|
207
|
+
// here classified every server hiccup as a RATE LIMIT and sent the agent into
|
|
208
|
+
// a 5-minute wait. The rate limit is recognized by its specific wording only.
|
|
209
|
+
export const RATE_LIMIT_RE = /(messages? too frequent|too many requests|rate limit|слишком часто|сообщени[яе] слишком част)/i;
|
|
210
|
+
// Transient server-side hiccups (DeepSeek overloaded / hiccup). Unlike the
|
|
211
|
+
// rate limit, these usually clear in seconds, so we retry quickly instead of
|
|
212
|
+
// waiting minutes.
|
|
213
|
+
export const SERVER_BUSY_RE = /(server (is )?busy|server error|service (is )?unavailable|temporarily unavailable|internal server error|502|503|504|server overloaded|сервер занят|сервер перегружен|сервис недоступен|внутренняя ошибка|попробуйте позже)/i;
|
|
214
|
+
// ---------- network protocol (SSE) ----------
|
|
215
|
+
export const GENERATION_ERR_RE = /"finish_reason":\s*"generation_err"/i;
|
|
216
|
+
export const INCOMPLETE_STATUS_RE = /"quasi_status","v":"INCOMPLETE"/i;
|
|
217
|
+
// A turn that ENDED (`quasi_status: FINISHED`) but produced NO answer text —
|
|
218
|
+
// the reasoning was generated and the model stopped WITHOUT a RESPONSE
|
|
219
|
+
// fragment. The UI shows Stopped + Continue.
|
|
220
|
+
export const FINISHED_STATUS_RE = /"quasi_status","v":"FINISHED"/i;
|
|
221
|
+
export const RESPONSE_CONTENT_RE = /"type":"RESPONSE","content":"[^"]/;
|
|
222
|
+
// Only a completion/continue turn carries a real answer. Gating the capture
|
|
223
|
+
// assignment to these URLs keeps a future endpoint (which happens to return a
|
|
224
|
+
// `content`/`text` field) from clobbering the answer and looking fresh.
|
|
225
|
+
export const ANSWER_URL_RE = /chat\/(completion|continue)/i;
|
package/dist/i18n/doctor.js
CHANGED
|
@@ -38,6 +38,26 @@ export const doctorMessages = {
|
|
|
38
38
|
ru: 'UA ещё не закэширован (первый запуск перезапустится один раз)',
|
|
39
39
|
en: 'no cached UA yet (first start relaunches once)',
|
|
40
40
|
},
|
|
41
|
+
'doctor.ui_ok': {
|
|
42
|
+
ru: 'все ключевые элементы найдены',
|
|
43
|
+
en: 'all key elements found',
|
|
44
|
+
},
|
|
45
|
+
'doctor.ui_degraded': {
|
|
46
|
+
ru: 'часть элементов не найдена: {v} (агент может работать некорректно)',
|
|
47
|
+
en: 'some elements not found: {v} (the agent may misbehave)',
|
|
48
|
+
},
|
|
49
|
+
'doctor.ui_broken': {
|
|
50
|
+
ru: 'НЕ найдено критичное: {v} — DeepSeek сменил вёрстку, задачи не будут отправляться. Запусти /debug-dom.',
|
|
51
|
+
en: 'MISSING critical: {v} — DeepSeek changed the markup, tasks will not send. Run /debug-dom.',
|
|
52
|
+
},
|
|
53
|
+
'ui.probe_failed': {
|
|
54
|
+
ru: '⚠ DeepSeek UI: не найдено критичное ({v}) — возможно, сменилась вёрстка chat.deepseek.com. Задачи могут не отправляться; проверь /doctor и /debug-dom.',
|
|
55
|
+
en: '⚠ DeepSeek UI: missing critical element(s) ({v}) — chat.deepseek.com markup may have changed. Tasks may not send; check /doctor and /debug-dom.',
|
|
56
|
+
},
|
|
57
|
+
'ui.probe_degraded': {
|
|
58
|
+
ru: '⚠ DeepSeek UI: не найдено ({v}) — часть возможностей (тумблеры/новый чат) может не работать.',
|
|
59
|
+
en: '⚠ DeepSeek UI: not found ({v}) — some features (toggles/new chat) may not work.',
|
|
60
|
+
},
|
|
41
61
|
'adddir.usage': {
|
|
42
62
|
ru: 'Использование: /add-dir <path>',
|
|
43
63
|
en: 'Usage: /add-dir <path>',
|
package/dist/index.js
CHANGED
|
@@ -997,6 +997,23 @@ async function main() {
|
|
|
997
997
|
await transcript.close();
|
|
998
998
|
process.exit(1);
|
|
999
999
|
}
|
|
1000
|
+
// DeepSeek UI health probe. The agent drives a page we do not control, so a
|
|
1001
|
+
// redesign silently breaks selectors and the FIRST symptom is a task hanging
|
|
1002
|
+
// on "input field not found". Probe the always-present controls right after
|
|
1003
|
+
// login and warn the operator up front. Best-effort: a probe failure must
|
|
1004
|
+
// never block startup.
|
|
1005
|
+
const warnUiHealth = (h) => {
|
|
1006
|
+
if (h.missingCritical.length) {
|
|
1007
|
+
console.error(theme.warn(t('ui.probe_failed', { v: h.missingCritical.join(', ') })));
|
|
1008
|
+
}
|
|
1009
|
+
else if (h.missing.length) {
|
|
1010
|
+
console.error(theme.warn(t('ui.probe_degraded', { v: h.missing.join(', ') })));
|
|
1011
|
+
}
|
|
1012
|
+
};
|
|
1013
|
+
try {
|
|
1014
|
+
warnUiHealth(await browser.probeUiHealth());
|
|
1015
|
+
}
|
|
1016
|
+
catch { }
|
|
1000
1017
|
// One-shot mode
|
|
1001
1018
|
if (task) {
|
|
1002
1019
|
const baseTools = mod.createTools(currentWorkdir, {
|
|
@@ -2940,6 +2957,9 @@ async function main() {
|
|
|
2940
2957
|
const mcpStatus = mcpPool
|
|
2941
2958
|
? mcpPool.status()
|
|
2942
2959
|
: { servers: [], toolCount: 0 };
|
|
2960
|
+
// Probe the DeepSeek UI so a redesign shows up as a /doctor row instead
|
|
2961
|
+
// of a task that hangs on a missing input. Best-effort.
|
|
2962
|
+
const uiHealth = await browser.probeUiHealth().catch(() => null);
|
|
2943
2963
|
console.log(theme.system(mod.commands.renderDoctor({
|
|
2944
2964
|
nodeVersion: process.version,
|
|
2945
2965
|
platform: process.platform,
|
|
@@ -2961,6 +2981,9 @@ async function main() {
|
|
|
2961
2981
|
sshRemote: !!(process.env.SSH_CONNECTION || process.env.SSH_TTY),
|
|
2962
2982
|
headless: !!headless,
|
|
2963
2983
|
headlessUaCached: await headlessUACacheReady().catch(() => false),
|
|
2984
|
+
uiPresent: uiHealth?.present,
|
|
2985
|
+
uiMissing: uiHealth?.missing,
|
|
2986
|
+
uiMissingCritical: uiHealth?.missingCritical,
|
|
2964
2987
|
}, t)));
|
|
2965
2988
|
continue;
|
|
2966
2989
|
}
|
package/dist/net-capture.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
2
|
import os from 'os';
|
|
3
3
|
import path from 'path';
|
|
4
|
+
import { FINISHED_STATUS_RE, RESPONSE_CONTENT_RE } from './deepseek-ui.js';
|
|
4
5
|
// Extraction of the RAW model answer text from DeepSeek's network data.
|
|
5
6
|
//
|
|
6
7
|
// Why: the answer is read from the RENDERED DOM, and DeepSeek's rendering
|
|
@@ -175,11 +176,11 @@ export function isFinishedWithoutAnswer(body) {
|
|
|
175
176
|
const b = String(body || '');
|
|
176
177
|
if (!b)
|
|
177
178
|
return false;
|
|
178
|
-
const finished =
|
|
179
|
+
const finished = FINISHED_STATUS_RE.test(b);
|
|
179
180
|
if (!finished)
|
|
180
181
|
return false;
|
|
181
182
|
// A RESPONSE fragment with actual content means the model DID answer.
|
|
182
|
-
if (
|
|
183
|
+
if (RESPONSE_CONTENT_RE.test(b))
|
|
183
184
|
return false;
|
|
184
185
|
// The turn carried reasoning (THINK) or was truncated mid-way; treat the
|
|
185
186
|
// absence of response content as "no answer produced".
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// UI health check — a cheap "can the agent still drive this page?" probe.
|
|
2
|
+
//
|
|
3
|
+
// WHY: every selector in deepseek-ui.ts is an assumption about a page we do
|
|
4
|
+
// not control. When DeepSeek ships a redesign, the FIRST symptom used to be a
|
|
5
|
+
// task hanging on "input field not found" (or a silently mis-clicked send),
|
|
6
|
+
// minutes into a run. This module turns that into an explicit answer BEFORE
|
|
7
|
+
// the task: it checks the capabilities the agent ALWAYS needs and reports the
|
|
8
|
+
// missing ones. The page object is injected (the browser owns Playwright), so
|
|
9
|
+
// the decision logic here stays pure and unit-testable.
|
|
10
|
+
//
|
|
11
|
+
// Only ALWAYS-PRESENT capabilities are probed. The Stop button and the answer
|
|
12
|
+
// nodes are TRANSIENT (Stop exists only during generation, answers only after
|
|
13
|
+
// a turn), so a presence probe on a healthy idle page would report false
|
|
14
|
+
// negatives; they are deliberately excluded.
|
|
15
|
+
import { INPUT_SELECTORS, SEND_SELECTORS, TOGGLE_SELECTORS, NEW_CHAT_SELECTOR, NEW_CHAT_TEXT_RE, } from './deepseek-ui.js';
|
|
16
|
+
// Critical: without the input the prompt cannot be typed, without send it
|
|
17
|
+
// cannot be submitted — a task would hang on `ds.input_missing`. Advisory:
|
|
18
|
+
// toggles/newChat degrade gracefully (the toggles fall back to the configured
|
|
19
|
+
// state, newChat falls back to navigating to the base URL).
|
|
20
|
+
export const UI_CAPABILITIES = [
|
|
21
|
+
{ capability: 'input', selectors: INPUT_SELECTORS, critical: true },
|
|
22
|
+
{ capability: 'send', selectors: SEND_SELECTORS, critical: true },
|
|
23
|
+
{ capability: 'toggles', selectors: TOGGLE_SELECTORS, critical: false },
|
|
24
|
+
{ capability: 'newChat', selectors: [NEW_CHAT_SELECTOR], critical: false },
|
|
25
|
+
];
|
|
26
|
+
async function hasAny(page, sel, textRe) {
|
|
27
|
+
try {
|
|
28
|
+
const n = textRe
|
|
29
|
+
? await page.countByText(sel, textRe)
|
|
30
|
+
: await page.count(sel);
|
|
31
|
+
return n > 0;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Probe the always-present DeepSeek controls. Never throws: a dead page is
|
|
39
|
+
* reported as every capability missing, which the caller surfaces to the
|
|
40
|
+
* operator instead of letting the next task hang.
|
|
41
|
+
*/
|
|
42
|
+
export async function probeUiHealth(page) {
|
|
43
|
+
const present = [];
|
|
44
|
+
const missing = [];
|
|
45
|
+
const missingCritical = [];
|
|
46
|
+
for (const spec of UI_CAPABILITIES) {
|
|
47
|
+
let found = false;
|
|
48
|
+
// The new-chat control is matched by an EXACT text label on a broad
|
|
49
|
+
// element set (it is a <div> on the current build, not a <button>).
|
|
50
|
+
const textRe = spec.capability === 'newChat' ? NEW_CHAT_TEXT_RE : undefined;
|
|
51
|
+
for (const sel of spec.selectors) {
|
|
52
|
+
if (await hasAny(page, sel, textRe)) {
|
|
53
|
+
found = true;
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (found)
|
|
58
|
+
present.push(spec.capability);
|
|
59
|
+
else {
|
|
60
|
+
missing.push(spec.capability);
|
|
61
|
+
if (spec.critical)
|
|
62
|
+
missingCritical.push(spec.capability);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
ok: missingCritical.length === 0,
|
|
67
|
+
present,
|
|
68
|
+
missing,
|
|
69
|
+
missingCritical,
|
|
70
|
+
};
|
|
71
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zames_pro",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.70.0",
|
|
4
4
|
"description": "Terminal coding agent that drives chat.deepseek.com through Playwright — no API key required. Reads and edits files, runs commands, commits to git, like Claude Code / Codex CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|