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