@operatingtag/dsh-unclesam 0.0.0-stage → 1.0.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 +893 -0
- package/LICENSE +142 -0
- package/README.md +354 -2
- package/client.js +1099 -0
- package/cordis.patch.yml +48 -0
- package/icon.svg +17 -0
- package/index.js +1411 -0
- package/install.cmd +43 -0
- package/install.ps1 +311 -0
- package/lib/cdp.mjs +247 -0
- package/lib/engine.mjs +1412 -0
- package/lib/i18n.mjs +141 -0
- package/lib/prefs.mjs +101 -0
- package/lib/prompt.mjs +518 -0
- package/lib/savings.mjs +131 -0
- package/locale/ar.json +98 -0
- package/locale/az.json +98 -0
- package/locale/bg.json +98 -0
- package/locale/bn.json +98 -0
- package/locale/cs.json +98 -0
- package/locale/da.json +98 -0
- package/locale/de.json +98 -0
- package/locale/el.json +98 -0
- package/locale/en.json +98 -0
- package/locale/es.json +98 -0
- package/locale/fa.json +98 -0
- package/locale/fi.json +98 -0
- package/locale/fr.json +98 -0
- package/locale/he.json +98 -0
- package/locale/hi.json +98 -0
- package/locale/hr.json +98 -0
- package/locale/hu.json +98 -0
- package/locale/id.json +98 -0
- package/locale/it.json +98 -0
- package/locale/ja.json +98 -0
- package/locale/ka.json +98 -0
- package/locale/kk.json +98 -0
- package/locale/km.json +98 -0
- package/locale/ko.json +98 -0
- package/locale/lt.json +98 -0
- package/locale/mr.json +98 -0
- package/locale/ms.json +98 -0
- package/locale/my.json +98 -0
- package/locale/ne.json +98 -0
- package/locale/nl.json +98 -0
- package/locale/no.json +98 -0
- package/locale/pl.json +98 -0
- package/locale/pt.json +98 -0
- package/locale/ro.json +98 -0
- package/locale/ru.json +98 -0
- package/locale/si.json +98 -0
- package/locale/sk.json +98 -0
- package/locale/sl.json +98 -0
- package/locale/sr.json +98 -0
- package/locale/sv.json +98 -0
- package/locale/sw.json +98 -0
- package/locale/ta.json +98 -0
- package/locale/te.json +98 -0
- package/locale/th.json +98 -0
- package/locale/tl.json +98 -0
- package/locale/tr.json +98 -0
- package/locale/uk.json +98 -0
- package/locale/ur.json +98 -0
- package/locale/uz.json +98 -0
- package/locale/vi.json +98 -0
- package/locale/zh.json +98 -0
- package/package.json +73 -4
package/lib/engine.mjs
ADDED
|
@@ -0,0 +1,1412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The engine: turn "a prompt" into "a streamed reply" using chat.deepseek.com in a real browser.
|
|
3
|
+
*
|
|
4
|
+
* @file engine.mjs
|
|
5
|
+
* @version 3.9.11
|
|
6
|
+
* @author Operating Tag (@operatingtag)
|
|
7
|
+
* @license CC-BY-NC-ND-4.0
|
|
8
|
+
*
|
|
9
|
+
* THE ONE DECISION THIS FILE ENCODES
|
|
10
|
+
* The site requires a proof-of-work challenge per message and is fronted by Cloudflare Turnstile.
|
|
11
|
+
* Re-implementing those outside the page is a losing game: they change, they are obfuscated, and
|
|
12
|
+
* getting them wrong looks exactly like a network failure. So this engine never touches the API.
|
|
13
|
+
* It drives the page, and the page solves its own challenges the same way it does for a person.
|
|
14
|
+
* The cost is a DOM read every poll; the gain is that auth, PoW and Turnstile are simply not our
|
|
15
|
+
* problem and cannot break independently of the site itself.
|
|
16
|
+
*
|
|
17
|
+
* WHY EVERY CALL STARTS A NEW CHAT
|
|
18
|
+
* DSH sends the full conversation in `messages` on every call, because a provider is expected to be
|
|
19
|
+
* stateless. If this engine reused the open chat, the page would also keep its own history and the
|
|
20
|
+
* model would see every turn twice, growing on each call until the request was absurd. A fresh chat
|
|
21
|
+
* per call keeps the two models of state agreeing.
|
|
22
|
+
*
|
|
23
|
+
* WHY COMPLETION IS DETECTED BY STABILITY, NOT BY A BUTTON
|
|
24
|
+
* The generating state is indicated by a control whose class names are generated and change on
|
|
25
|
+
* deploy. Text that has stopped growing for a couple of seconds is a much more durable signal, and
|
|
26
|
+
* it degrades gracefully: a slow stream just takes longer to settle rather than hanging forever.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
30
|
+
import { existsSync, appendFileSync } from 'node:fs';
|
|
31
|
+
import { Cdp, waitFor } from './cdp.mjs';
|
|
32
|
+
|
|
33
|
+
const BRAVE_PATHS = [
|
|
34
|
+
'C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe',
|
|
35
|
+
'C:\\Program Files (x86)\\BraveSoftware\\Brave-Browser\\Application\\brave.exe',
|
|
36
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
37
|
+
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const CHAT_URL = 'https://chat.deepseek.com/';
|
|
41
|
+
const ASSISTANT = '.ds-assistant-message-main-content';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* How long the reply text must stop changing before it counts as finished.
|
|
45
|
+
*
|
|
46
|
+
* This is pure added latency: every reply pays it in full, after the last character has already arrived.
|
|
47
|
+
* The page offers no trustworthy "still generating" marker to read instead - a probe of the live document
|
|
48
|
+
* found the candidate classes flipping at the wrong moment - so silence is the only signal available.
|
|
49
|
+
*
|
|
50
|
+
* The tension is real: shorter means faster, but a mid-reply pause longer than the window would cut an
|
|
51
|
+
* answer in half, which is far worse than waiting. 600 ms is short enough to feel immediate and long
|
|
52
|
+
* enough to absorb the stream's normal gaps, and the reply loop polls at 70 ms so the window is measured
|
|
53
|
+
* accurately rather than overshooting.
|
|
54
|
+
*/
|
|
55
|
+
const SETTLE_MS = 600;
|
|
56
|
+
/**
|
|
57
|
+
* A reply that produces nothing for this long is a failure, not a slow answer.
|
|
58
|
+
*
|
|
59
|
+
* Overridable so a test can fail fast instead of sitting through the production grace period. Ninety seconds
|
|
60
|
+
* is right for a person waiting on a real answer; it is a waste of an evening when the question is "does the
|
|
61
|
+
* send land at all". `DSH_FIRST_TOKEN_TIMEOUT_MS` is honoured only when it parses to a positive number, so a
|
|
62
|
+
* typo cannot silently disable the guard.
|
|
63
|
+
*/
|
|
64
|
+
const FIRST_TOKEN_TIMEOUT_MS = (() => {
|
|
65
|
+
const raw = Number(process.env.DSH_FIRST_TOKEN_TIMEOUT_MS);
|
|
66
|
+
return Number.isFinite(raw) && raw >= 1000 ? raw : 90000;
|
|
67
|
+
})();
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A PAUSE longer than this means the page has stopped, so the call is abandoned.
|
|
71
|
+
*
|
|
72
|
+
* Rule set by the person after three runs stalled: "any time you detect a long pause more than 5 seconds,
|
|
73
|
+
* kill tests, kill browsers, stop and report". A pause is time since the last sign of life - a character of
|
|
74
|
+
* the answer, or the page becoming ready - not time since the call began, because starting a browser is
|
|
75
|
+
* legitimately slow and must not be mistaken for a hang.
|
|
76
|
+
*
|
|
77
|
+
* default 5000 ms
|
|
78
|
+
* DSH_STALL_MS overrides it, floored at 1000 ms; a non-number is ignored rather than trusted
|
|
79
|
+
* production use leave it alone - 5 s of utter silence is already abnormal
|
|
80
|
+
*/
|
|
81
|
+
export const STALL_MS = (() => {
|
|
82
|
+
const raw = Number(process.env.DSH_STALL_MS);
|
|
83
|
+
return Number.isFinite(raw) && raw >= 1000 ? raw : 5000;
|
|
84
|
+
})();
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The same limit, for a call that searched the web.
|
|
88
|
+
*
|
|
89
|
+
* A search fetches before it writes anything, so silence is the page working rather than the page dead.
|
|
90
|
+
* Measured: the first live search test was killed at 5.1 s with nothing on screen, which would abort every
|
|
91
|
+
* search if the ordinary limit applied. Thirty seconds of nothing is still a fault.
|
|
92
|
+
*/
|
|
93
|
+
export const SEARCH_STALL_MS = (() => {
|
|
94
|
+
const raw = Number(process.env.DSH_SEARCH_STALL_MS);
|
|
95
|
+
return Number.isFinite(raw) && raw >= 5000 ? raw : 30_000;
|
|
96
|
+
})();
|
|
97
|
+
const TOTAL_TIMEOUT_MS = 600000;
|
|
98
|
+
/**
|
|
99
|
+
* How long one call may hold the serialisation slot before it is treated as leaked.
|
|
100
|
+
*
|
|
101
|
+
* Comfortably longer than TOTAL_TIMEOUT_MS, so a genuinely slow turn is never cut short, but finite so a
|
|
102
|
+
* dropped generator cannot freeze the plugin until the harness is restarted.
|
|
103
|
+
*/
|
|
104
|
+
const WATCHDOG_MS = 20 * 60_000;
|
|
105
|
+
/**
|
|
106
|
+
* How often the reply text is sampled.
|
|
107
|
+
*
|
|
108
|
+
* This is the resolution of the settle measurement: at 110 ms, a 600 ms window could be read as anywhere
|
|
109
|
+
* between 600 and 710 ms and the reply is handed over late by that much. At 70 ms the overshoot is small
|
|
110
|
+
* enough to be invisible, and one extra DOM read every 70 ms is nothing.
|
|
111
|
+
*/
|
|
112
|
+
const POLL_MS = 70;
|
|
113
|
+
|
|
114
|
+
export class WebChatError extends Error {
|
|
115
|
+
constructor(message, code) {
|
|
116
|
+
super(message);
|
|
117
|
+
this.name = 'WebChatError';
|
|
118
|
+
this.code = code;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Where each supported Chromium keeps its everyday profile. */
|
|
123
|
+
const PROFILE_ROOTS = {
|
|
124
|
+
brave: `${process.env.LOCALAPPDATA}\\BraveSoftware\\Brave-Browser\\User Data`,
|
|
125
|
+
edge: `${process.env.LOCALAPPDATA}\\Microsoft\\Edge\\User Data`,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Choose the browser, preferring Brave and never failing when it is absent.
|
|
130
|
+
*
|
|
131
|
+
* Brave is preferred because it blocks ad and tracking scripts by default, which protects the person and
|
|
132
|
+
* keeps the page this plugin drives predictable. But requiring it would lose every user who does not
|
|
133
|
+
* already have it, and installing it on their behalf would be indefensible - so Edge, which every Windows
|
|
134
|
+
* machine already has, is the guaranteed fallback. The plugin therefore always works and never blocks an
|
|
135
|
+
* install on a missing browser.
|
|
136
|
+
*
|
|
137
|
+
* Headless is not considered at all: chat.deepseek.com answers headless clients with a 403 from
|
|
138
|
+
* CloudFront, measured with a clean profile and both headless flags, so that path cannot succeed.
|
|
139
|
+
*/
|
|
140
|
+
function chooseBrowser() {
|
|
141
|
+
for (const p of BRAVE_PATHS) {
|
|
142
|
+
if (existsSync(p)) return { kind: 'brave', exe: p, profile: PROFILE_ROOTS.brave };
|
|
143
|
+
}
|
|
144
|
+
for (const p of EDGE_PATHS) {
|
|
145
|
+
if (existsSync(p)) return { kind: 'edge', exe: p, profile: PROFILE_ROOTS.edge };
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Build the launch arguments.
|
|
152
|
+
*
|
|
153
|
+
* `--start-minimized` is deliberately NOT used: measured on this machine it produced a normal, visible,
|
|
154
|
+
* non-minimised window, because Chromium treats it as a hint and drops it when it hands off to an already
|
|
155
|
+
* running browser. Minimising is performed as an action once the window exists, which is the only
|
|
156
|
+
* approach that can be verified rather than hoped for.
|
|
157
|
+
*/
|
|
158
|
+
function launchArgs(port, browser) {
|
|
159
|
+
return [
|
|
160
|
+
`--remote-debugging-port=${port}`,
|
|
161
|
+
'--no-first-run',
|
|
162
|
+
'--no-default-browser-check',
|
|
163
|
+
`--user-data-dir=${browser.profile}`,
|
|
164
|
+
'--profile-directory=Default',
|
|
165
|
+
|
|
166
|
+
/*
|
|
167
|
+
* NEVER THROTTLE. This is the fix for "the answer only arrives when I open the window".
|
|
168
|
+
*
|
|
169
|
+
* MEASURED, BY THE PERSON USING IT
|
|
170
|
+
* A message was sent, the elapsed timer climbed past a minute, and nothing came back. The reply
|
|
171
|
+
* appeared the instant the browser window was raised. That is not a network problem - it is
|
|
172
|
+
* Chromium backgrounding a window nobody is looking at.
|
|
173
|
+
*
|
|
174
|
+
* WHY IT BREAKS THIS PLUGIN SPECIFICALLY
|
|
175
|
+
* The page streams its answer into the DOM and this client reads it by polling every 70ms. Chromium
|
|
176
|
+
* throttles timers and rendering in a minimised or occluded window - timers can drop to once a
|
|
177
|
+
* minute - so the answer finishes inside the page while this reader is not allowed to see it.
|
|
178
|
+
* Raising the window lifts the throttle, which is exactly what was reported.
|
|
179
|
+
*
|
|
180
|
+
* The plugin minimises the window on purpose, so being throttled is the NORMAL state here, not an
|
|
181
|
+
* edge case. These flags are not an optimisation: without them the default configuration is broken.
|
|
182
|
+
*/
|
|
183
|
+
'--disable-background-timer-throttling',
|
|
184
|
+
'--disable-backgrounding-occluded-windows',
|
|
185
|
+
'--disable-renderer-backgrounding',
|
|
186
|
+
'--disable-background-mode',
|
|
187
|
+
// Rendering-related throttles. A page that stops painting stops writing the DOM we poll.
|
|
188
|
+
'--run-all-compositor-stages-before-draw',
|
|
189
|
+
'--disable-new-content-rendering-timeout',
|
|
190
|
+
// `CalculateNativeWinOcclusion` is what tells Chromium the window is hidden; `IntensiveWakeUpThrottling`
|
|
191
|
+
// is the one-minute timer clamp. Both are removed deliberately.
|
|
192
|
+
'--disable-features=Translate,MediaRouter,CalculateNativeWinOcclusion,IntensiveWakeUpThrottling',
|
|
193
|
+
|
|
194
|
+
CHAT_URL,
|
|
195
|
+
];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Tell the page it is foregrounded, whatever the window is doing.
|
|
200
|
+
*
|
|
201
|
+
* The launch flags above stop the timers being clamped, but Chromium also marks a page `hidden` when its
|
|
202
|
+
* window is occluded, and some frameworks stop rendering work entirely on that signal. `Page.
|
|
203
|
+
* setWebLifecycleState('active')` overrides it, which is the state a person watching the window would
|
|
204
|
+
* produce anyway. Best-effort: an older build without the method must not stop a call.
|
|
205
|
+
*/
|
|
206
|
+
async function keepPageActive(cdp) {
|
|
207
|
+
try {
|
|
208
|
+
await cdp.send('Page.enable', {}, 5000);
|
|
209
|
+
await cdp.send('Page.setWebLifecycleState', { state: 'active' }, 5000);
|
|
210
|
+
return true;
|
|
211
|
+
} catch {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The process listening on a port, found SYNCHRONOUSLY.
|
|
218
|
+
*
|
|
219
|
+
* Needed by the exit hook, which runs where no promise can resolve. `netstat -ano` is the one tool present on
|
|
220
|
+
* every Windows machine; the PID is the last column of a LISTENING line. Returns null when nothing is
|
|
221
|
+
* listening, which is the ordinary "already stopped" case rather than an error.
|
|
222
|
+
*/
|
|
223
|
+
function ownerOfPortSync(port) {
|
|
224
|
+
try {
|
|
225
|
+
const out = execFileSync('netstat', ['-ano'], { encoding: 'utf8', timeout: 3000 });
|
|
226
|
+
for (const line of out.split('\n')) {
|
|
227
|
+
if (!line.includes('LISTENING')) continue;
|
|
228
|
+
const cols = line.trim().split(/\s+/);
|
|
229
|
+
if (cols.length < 5) continue;
|
|
230
|
+
// The local address column is "<ip>:<port>"; match the port exactly, not a substring of it.
|
|
231
|
+
const [, localPort] = String(cols[1]).match(/:(\d+)$/) ?? [];
|
|
232
|
+
if (localPort !== String(port)) continue;
|
|
233
|
+
const pid = Number(cols[cols.length - 1]);
|
|
234
|
+
if (Number.isInteger(pid) && pid > 0) return pid;
|
|
235
|
+
}
|
|
236
|
+
} catch {
|
|
237
|
+
/* netstat missing or refused: nothing we can do from an exit handler */
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Minimise the browser window that owns a debugging port.
|
|
244
|
+
*
|
|
245
|
+
* The window is found by asking the browser itself which process serves the port, then locating that
|
|
246
|
+
* process's top-level window. Asking the browser is what keeps this safe: the plugin only ever touches
|
|
247
|
+
* the window it launched, never a Brave window the person is using.
|
|
248
|
+
*
|
|
249
|
+
* Returns the measured result, because "minimise requested" and "minimise happened" are different facts
|
|
250
|
+
* and only the second one matters to somebody who does not want a window in front of them.
|
|
251
|
+
*/
|
|
252
|
+
export async function minimiseBrowserWindow(port) {
|
|
253
|
+
try {
|
|
254
|
+
const ps = `
|
|
255
|
+
$ErrorActionPreference = 'Stop'
|
|
256
|
+
Add-Type -Namespace W -Name U -MemberDefinition @"
|
|
257
|
+
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int c);
|
|
258
|
+
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
|
|
259
|
+
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
|
|
260
|
+
"@
|
|
261
|
+
$pidOwner = (Get-NetTCPConnection -State Listen -LocalPort ${port} -ErrorAction SilentlyContinue |
|
|
262
|
+
Select-Object -First 1).OwningProcess
|
|
263
|
+
if (-not $pidOwner) { '{"ok":false,"why":"nothing listening"}' ; exit }
|
|
264
|
+
$proc = Get-Process -Id $pidOwner -ErrorAction SilentlyContinue
|
|
265
|
+
$h = [IntPtr]$proc.MainWindowHandle
|
|
266
|
+
if ($h -eq [IntPtr]::Zero) { '{"ok":false,"why":"no window on that process","pid":' + $pidOwner + '}' ; exit }
|
|
267
|
+
[void][W.U]::ShowWindow($h, 6) # SW_MINIMIZE
|
|
268
|
+
Start-Sleep -Milliseconds 250
|
|
269
|
+
$iconic = [W.U]::IsIconic($h)
|
|
270
|
+
'{"ok":' + $iconic.ToString().ToLower() + ',"minimised":' + $iconic.ToString().ToLower() +
|
|
271
|
+
',"visible":' + [W.U]::IsWindowVisible($h).ToString().ToLower() + ',"pid":' + $pidOwner + '}'
|
|
272
|
+
`;
|
|
273
|
+
|
|
274
|
+
const out = execFileSync('pwsh', ['-NoProfile', '-Command', ps], {
|
|
275
|
+
encoding: 'utf8',
|
|
276
|
+
timeout: 15000,
|
|
277
|
+
}).trim();
|
|
278
|
+
const parsed = JSON.parse(out.split(/\r?\n/).filter(Boolean).pop());
|
|
279
|
+
return parsed;
|
|
280
|
+
} catch (err) {
|
|
281
|
+
return { ok: false, why: err?.message ?? String(err) };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function portOpen(port) {
|
|
286
|
+
try {
|
|
287
|
+
const res = await fetch(`http://127.0.0.1:${port}/json/version`, {
|
|
288
|
+
signal: AbortSignal.timeout(1500),
|
|
289
|
+
});
|
|
290
|
+
return res.ok;
|
|
291
|
+
} catch {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Read the newest assistant reply, but only if it is new.
|
|
298
|
+
*
|
|
299
|
+
* WHY THE BASELINE IS NOT JUST A COUNT ANY MORE
|
|
300
|
+
* The old version decided "is this reply new?" by comparing the number of assistant blocks with the count
|
|
301
|
+
* taken before sending. That is wrong for a conversation that has been going for a few turns: the site
|
|
302
|
+
* recycles or unmounts earlier messages, so the block count can stop growing even though a brand new
|
|
303
|
+
* answer has appeared.
|
|
304
|
+
*
|
|
305
|
+
* MEASURED, by the person watching the browser: the fourth message in a chat was answered on screen -
|
|
306
|
+
* they read it - while this reader reported no text at all and the call was abandoned as a stall. Three
|
|
307
|
+
* separate runs stalled on the fourth message and only the fourth; different questions, same position.
|
|
308
|
+
* A page that answered cannot be a page that stalled, so the fault was here.
|
|
309
|
+
*
|
|
310
|
+
* The baseline therefore records the shape of the last reply as well as how many there are: a new reply is
|
|
311
|
+
* either an extra block, or the same number of blocks with different text. Length and tail are enough to
|
|
312
|
+
* tell them apart, and they keep the injected expression small - the whole previous answer would be
|
|
313
|
+
* several kilobytes posted to the page on every 70 ms poll.
|
|
314
|
+
*/
|
|
315
|
+
const readReply = (base) => `(() => {
|
|
316
|
+
const nodes = [...document.querySelectorAll('${ASSISTANT}')];
|
|
317
|
+
const last = nodes[nodes.length - 1];
|
|
318
|
+
const text = last ? (last.innerText || '') : '';
|
|
319
|
+
const tail = text.length > 48 ? text.slice(-48) : text;
|
|
320
|
+
const grew = nodes.length > ${base.count};
|
|
321
|
+
const replaced = text.length > 0 && (text.length !== ${base.len} || tail !== ${JSON.stringify(base.tail)});
|
|
322
|
+
/*
|
|
323
|
+
* A second, cruder signal: how much text the whole page holds.
|
|
324
|
+
*
|
|
325
|
+
* MEASURED, by the person reading Brave: a 1000-word request was answered, but the plugin abandoned it after
|
|
326
|
+
* five silent seconds. The page was not silent at all - it was writing its reasoning into a Thinking block,
|
|
327
|
+
* which is a different element from the answer this reader looks at. From here that looked exactly like a
|
|
328
|
+
* dead page.
|
|
329
|
+
*
|
|
330
|
+
* The answer element is the only thing worth EMITTING, but it is not the only thing worth WATCHING. Growth
|
|
331
|
+
* anywhere in the document means the page is working, so the pause limit is reset by it. This is deliberately
|
|
332
|
+
* blunt: it needs no knowledge of the reasoning element's class name, which the site can rename at will.
|
|
333
|
+
*/
|
|
334
|
+
const alive = document.body ? document.body.innerText.length : 0;
|
|
335
|
+
return {
|
|
336
|
+
text,
|
|
337
|
+
found: nodes.length,
|
|
338
|
+
isNew: grew || replaced,
|
|
339
|
+
grew,
|
|
340
|
+
replaced,
|
|
341
|
+
alive,
|
|
342
|
+
};
|
|
343
|
+
})()`;
|
|
344
|
+
|
|
345
|
+
/** How many assistant blocks are on screen, and the shape of the last one. The baseline for one call. */
|
|
346
|
+
const READ_BASELINE = `(() => {
|
|
347
|
+
const nodes = [...document.querySelectorAll('${ASSISTANT}')];
|
|
348
|
+
const last = nodes[nodes.length - 1];
|
|
349
|
+
const text = last ? (last.innerText || '') : '';
|
|
350
|
+
return { count: nodes.length, len: text.length, tail: text.length > 48 ? text.slice(-48) : text };
|
|
351
|
+
})()`;
|
|
352
|
+
|
|
353
|
+
const FIND_COMPOSER = `(() => {
|
|
354
|
+
const vis = (el) => {
|
|
355
|
+
const r = el.getBoundingClientRect();
|
|
356
|
+
const s = getComputedStyle(el);
|
|
357
|
+
return r.width > 4 && r.height > 4 && s.visibility !== 'hidden' && s.display !== 'none';
|
|
358
|
+
};
|
|
359
|
+
const el = [...document.querySelectorAll('textarea,[contenteditable="true"]')].filter(vis)[0];
|
|
360
|
+
if (!el) return null;
|
|
361
|
+
return { tag: el.tagName.toLowerCase(), placeholder: el.getAttribute('placeholder') || '' };
|
|
362
|
+
})()`;
|
|
363
|
+
|
|
364
|
+
const TYPE_TEXT = (text) => `(() => {
|
|
365
|
+
const vis = (el) => {
|
|
366
|
+
const r = el.getBoundingClientRect();
|
|
367
|
+
const s = getComputedStyle(el);
|
|
368
|
+
return r.width > 4 && r.height > 4 && s.visibility !== 'hidden' && s.display !== 'none';
|
|
369
|
+
};
|
|
370
|
+
const el = [...document.querySelectorAll('textarea,[contenteditable="true"]')].filter(vis)[0];
|
|
371
|
+
if (!el) return { ok: false, why: 'no composer' };
|
|
372
|
+
el.focus();
|
|
373
|
+
const t = ${JSON.stringify(text)};
|
|
374
|
+
if (el.tagName === 'TEXTAREA') {
|
|
375
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set;
|
|
376
|
+
setter.call(el, t);
|
|
377
|
+
} else {
|
|
378
|
+
el.textContent = t;
|
|
379
|
+
}
|
|
380
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
381
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
382
|
+
return { ok: true, len: (el.value || el.textContent || '').length };
|
|
383
|
+
})()`;
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Click the control that starts a blank conversation.
|
|
387
|
+
*
|
|
388
|
+
* The class names on this control are generated and change between deploys, but the element that is
|
|
389
|
+
* actually clickable is the one carrying `tabindex="0"` and the label - the outer wrappers are not.
|
|
390
|
+
* Clicking a wrapper is what made the first version silently fail and leave the old transcript up.
|
|
391
|
+
*/
|
|
392
|
+
const CLICK_NEW_CHAT = `(() => {
|
|
393
|
+
const vis = (el) => {
|
|
394
|
+
const r = el.getBoundingClientRect();
|
|
395
|
+
return r.width > 2 && r.height > 2;
|
|
396
|
+
};
|
|
397
|
+
const candidates = [...document.querySelectorAll('[tabindex="0"],button,a')].filter(vis);
|
|
398
|
+
const hit = candidates.find((el) => {
|
|
399
|
+
const t = (el.innerText || '').trim().toLowerCase();
|
|
400
|
+
if (t !== 'new chat' && t !== 'new conversation') return false;
|
|
401
|
+
return el.querySelectorAll('[tabindex="0"]').length === 0;
|
|
402
|
+
});
|
|
403
|
+
if (!hit) return false;
|
|
404
|
+
hit.click();
|
|
405
|
+
return true;
|
|
406
|
+
})()`;
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Read the composer's two switches.
|
|
410
|
+
*
|
|
411
|
+
* `DeepThink` turns on the reasoning pass; `Search` lets it read the live web. Both are per-conversation
|
|
412
|
+
* and both reset with a new chat, which is exactly why they must be set AFTER the chat is reset and
|
|
413
|
+
* before the prompt is typed - setting them first would have them cleared a moment later.
|
|
414
|
+
*/
|
|
415
|
+
const READ_TOGGLES = `(() => {
|
|
416
|
+
const out = {};
|
|
417
|
+
for (const el of document.querySelectorAll('.ds-toggle-button')) {
|
|
418
|
+
const label = (el.innerText || '').trim();
|
|
419
|
+
if (!label) continue;
|
|
420
|
+
out[label] = String(el.className || '').indexOf('ds-toggle-button--selected') >= 0;
|
|
421
|
+
}
|
|
422
|
+
return out;
|
|
423
|
+
})()`;
|
|
424
|
+
|
|
425
|
+
const clickToggle = (label) => `(() => {
|
|
426
|
+
const want = ${JSON.stringify(label)}.toLowerCase();
|
|
427
|
+
const el = [...document.querySelectorAll('.ds-toggle-button')].find(
|
|
428
|
+
(n) => (n.innerText || '').trim().toLowerCase() === want,
|
|
429
|
+
);
|
|
430
|
+
if (!el) return false;
|
|
431
|
+
el.click();
|
|
432
|
+
return true;
|
|
433
|
+
})()`;
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Read the sources a search answer was built from.
|
|
437
|
+
*
|
|
438
|
+
* The page never writes the query it used into the DOM, and it does not expose a source list as text.
|
|
439
|
+
* What it does leave behind is a row of favicons whose URLs are `cdn.deepseek.com/site-icons/<domain>`,
|
|
440
|
+
* plus citation anchors pointing at the articles. The domain in that URL IS the source, so the search
|
|
441
|
+
* output is recoverable even though the panel that produced it is gone by the time the answer settles.
|
|
442
|
+
*/
|
|
443
|
+
const READ_SOURCES = `(() => {
|
|
444
|
+
const domains = new Set();
|
|
445
|
+
for (const img of document.querySelectorAll('img[src*="site-icons/"]')) {
|
|
446
|
+
const m = /site-icons\\/([^/?#]+)/.exec(img.getAttribute('src') || '');
|
|
447
|
+
if (m && m[1]) domains.add(m[1]);
|
|
448
|
+
}
|
|
449
|
+
const links = new Set();
|
|
450
|
+
for (const a of document.querySelectorAll('a[href^="http"]')) {
|
|
451
|
+
const h = a.getAttribute('href') || '';
|
|
452
|
+
if (!h) continue;
|
|
453
|
+
if (h.indexOf('chat.deepseek.com') >= 0) continue;
|
|
454
|
+
if (h.indexOf('cdn.deepseek.com') >= 0) continue;
|
|
455
|
+
links.add(h);
|
|
456
|
+
}
|
|
457
|
+
return { domains: [...domains], links: [...links].slice(0, 12) };
|
|
458
|
+
})()`;
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* One conversation with the web UI.
|
|
462
|
+
*
|
|
463
|
+
* A `ChatSession` owns the browser page. Calls are serialised deliberately: the site is a single
|
|
464
|
+
* document with one composer, so two concurrent prompts would type over each other and one caller
|
|
465
|
+
* would receive the other's answer.
|
|
466
|
+
*/
|
|
467
|
+
export class ChatSession {
|
|
468
|
+
#port;
|
|
469
|
+
#cdp = null;
|
|
470
|
+
#queue = Promise.resolve();
|
|
471
|
+
/** Which harness conversation the browser chat currently holds. See #askInner for why this exists. */
|
|
472
|
+
#ownerKey = null;
|
|
473
|
+
/**
|
|
474
|
+
* True from the moment THIS object launches a browser until that browser is gone.
|
|
475
|
+
*
|
|
476
|
+
* The distinction matters on exit: a browser we started is ours to kill, and one we merely attached to
|
|
477
|
+
* belongs to somebody else. Guessing after the fact is impossible, so it is recorded at the launch.
|
|
478
|
+
*/
|
|
479
|
+
#launchedByUs = false;
|
|
480
|
+
#exitHookInstalled = false;
|
|
481
|
+
/**
|
|
482
|
+
* Switches THIS plugin turned on for a call, so they can be put back afterwards.
|
|
483
|
+
*
|
|
484
|
+
* A search call used to leave the site's Search toggle on, so the next ordinary message searched the web
|
|
485
|
+
* without anybody asking - slower, and a silent change of behaviour. Recorded per call, restored at the end
|
|
486
|
+
* of it, and restored again at the start of the next call in case the previous one threw.
|
|
487
|
+
*/
|
|
488
|
+
#turnedOn = [];
|
|
489
|
+
/** Whether to minimise the window after it launches. The only display choice left. */
|
|
490
|
+
autoMinimise = true;
|
|
491
|
+
/** Result of the last minimise attempt: `{ ok, minimised, visible, pid }`. Reported by the setup panel. */
|
|
492
|
+
lastMinimise = null;
|
|
493
|
+
|
|
494
|
+
constructor({ port = 9222, autoMinimise = true } = {}) {
|
|
495
|
+
this.#port = port;
|
|
496
|
+
this.autoMinimise = autoMinimise;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
get port() {
|
|
500
|
+
return this.#port;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Which browser this machine will use: 'brave' when present, otherwise 'edge'. */
|
|
504
|
+
get browserKind() {
|
|
505
|
+
return chooseBrowser()?.kind ?? null;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Close the browser this session is driving.
|
|
510
|
+
*
|
|
511
|
+
* Only the browser on this session's own port is closed, so a window the person is using is never
|
|
512
|
+
* touched. Used when a launch has to be replaced, for example to bring the site forward for signing in.
|
|
513
|
+
*/
|
|
514
|
+
async stop() {
|
|
515
|
+
this.#cdp?.close();
|
|
516
|
+
this.#cdp = null;
|
|
517
|
+
try {
|
|
518
|
+
const res = await fetch(`http://127.0.0.1:${this.#port}/json/version`, {
|
|
519
|
+
signal: AbortSignal.timeout(1200),
|
|
520
|
+
});
|
|
521
|
+
if (res.ok) {
|
|
522
|
+
// Ask the browser to exit through its own automation endpoint where possible; fall back to the
|
|
523
|
+
// page-level close below when that endpoint is unavailable.
|
|
524
|
+
await fetch(`http://127.0.0.1:${this.#port}/json/list`).catch(() => {});
|
|
525
|
+
}
|
|
526
|
+
} catch {
|
|
527
|
+
/* nothing listening: already stopped */
|
|
528
|
+
}
|
|
529
|
+
// Kill by port owner so a stale headless process cannot hold the profile lock.
|
|
530
|
+
try {
|
|
531
|
+
const { execSync } = await import('node:child_process');
|
|
532
|
+
const out = execSync(`netstat -ano | findstr :${this.#port}`, { encoding: 'utf8' });
|
|
533
|
+
const pids = new Set([...out.matchAll(/\s(\d+)\s*$/gm)].map((m) => m[1]));
|
|
534
|
+
for (const pid of pids) {
|
|
535
|
+
try {
|
|
536
|
+
execSync(`taskkill /PID ${pid} /F`, { stdio: 'ignore' });
|
|
537
|
+
} catch {
|
|
538
|
+
/* already gone */
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
} catch {
|
|
542
|
+
/* no process on that port */
|
|
543
|
+
}
|
|
544
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async #connect() {
|
|
548
|
+
if (this.#cdp && !this.#cdp.closed) return this.#cdp;
|
|
549
|
+
|
|
550
|
+
if (!(await portOpen(this.#port))) {
|
|
551
|
+
/*
|
|
552
|
+
* RECORD WHO ASKED FOR THIS, BEFORE LAUNCHING.
|
|
553
|
+
*
|
|
554
|
+
* A browser has appeared on the person's desktop four times tonight with the route set to API, and each
|
|
555
|
+
* time the only answer available was a theory: the route handler is guarded, inspect() is read-only, the
|
|
556
|
+
* parent process had already exited. Theories cost the person three rounds of their evening.
|
|
557
|
+
*
|
|
558
|
+
* So the caller is captured here, where the launch actually happens, and written to a file as well as
|
|
559
|
+
* stderr. A file survives the process, which matters because the interesting case is a launcher that
|
|
560
|
+
* exits. `DSH_WEB_LAUNCH_LOG` overrides the path; the default sits beside the plugin so it is easy to
|
|
561
|
+
* find. Best-effort throughout: a diagnostic must never be able to stop a call.
|
|
562
|
+
*/
|
|
563
|
+
try {
|
|
564
|
+
const stack = new Error('launch trace').stack ?? '';
|
|
565
|
+
/*
|
|
566
|
+
* Keep the first frames that belong to neither this engine nor Node itself - that is what names the
|
|
567
|
+
* caller.
|
|
568
|
+
*
|
|
569
|
+
* A flat `slice(1, 12)` was not enough: one recorded launch produced ONLY
|
|
570
|
+
* `processTicksAndRejections`, because ten Node-internal frames pushed the real caller past the cut.
|
|
571
|
+
* The chain here is webserver -> host -> llm -> adapter -> ask -> connect, easily twelve frames.
|
|
572
|
+
*/
|
|
573
|
+
const lines = stack
|
|
574
|
+
.split('\n')
|
|
575
|
+
.slice(1)
|
|
576
|
+
.map((l) => l.trim())
|
|
577
|
+
.filter(Boolean);
|
|
578
|
+
const isEngine = (l) => l.includes('lib/engine.mjs');
|
|
579
|
+
const isNodeInternal = (l) => l.includes('node:internal');
|
|
580
|
+
const appFrames = lines.filter((l) => !isEngine(l) && !isNodeInternal(l)).slice(0, 5);
|
|
581
|
+
const note = lines.length > appFrames.length ? `\n (+${lines.length} frames seen)` : '';
|
|
582
|
+
const line = `\n=== browser launch ${new Date().toISOString()} port ${this.#port}\n ${appFrames.join('\n ')}${note}\n`;
|
|
583
|
+
if (process.env.DEBUG_WEB_CHAT) console.error(`[launch]${line}`);
|
|
584
|
+
const logPath =
|
|
585
|
+
process.env.DSH_WEB_LAUNCH_LOG ||
|
|
586
|
+
// One level up from lib/ is the package root. Two levels put the file outside the package.
|
|
587
|
+
new URL('../browser-launches.log', import.meta.url).pathname.replace(/^\//, '');
|
|
588
|
+
appendFileSync(logPath, line, 'utf8');
|
|
589
|
+
} catch {
|
|
590
|
+
/* a diagnostic that fails must not block the launch it is describing */
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const browser = chooseBrowser();
|
|
594
|
+
if (!browser) {
|
|
595
|
+
throw new WebChatError(
|
|
596
|
+
'no Chromium browser found. Brave is recommended; Edge ships with Windows and is used ' +
|
|
597
|
+
'automatically when Brave is absent.',
|
|
598
|
+
'NO_BROWSER',
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
spawn(browser.exe, launchArgs(this.#port, browser), {
|
|
602
|
+
detached: true,
|
|
603
|
+
stdio: 'ignore',
|
|
604
|
+
}).unref();
|
|
605
|
+
// From here on this browser is ours: it is killed if this process dies without stopping it first.
|
|
606
|
+
this.#launchedByUs = true;
|
|
607
|
+
this.#claimOnExit();
|
|
608
|
+
await waitFor(() => portOpen(this.#port), {
|
|
609
|
+
timeout: 90000,
|
|
610
|
+
interval: 600,
|
|
611
|
+
label: 'the browser debug port',
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
// Wait for the page before deciding whether to hide the window. A page that still needs a sign-in
|
|
615
|
+
// has to stay visible - hiding it would leave the person with no way to log in and nothing on
|
|
616
|
+
// screen to explain it. The decision is therefore made from what the page actually shows.
|
|
617
|
+
await waitFor(() => Cdp.findTarget(this.#port, 'chat.deepseek.com'), {
|
|
618
|
+
timeout: 60000,
|
|
619
|
+
interval: 500,
|
|
620
|
+
label: 'a chat.deepseek.com tab',
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
if (this.autoMinimise) {
|
|
624
|
+
const signedOut = await this.#needsSignIn().catch(() => false);
|
|
625
|
+
if (signedOut) {
|
|
626
|
+
// Leave it visible and on top: the person has something to do.
|
|
627
|
+
this.lastMinimise = { ok: false, skipped: 'waiting for sign-in' };
|
|
628
|
+
await this.#raiseWindow().catch(() => {});
|
|
629
|
+
} else {
|
|
630
|
+
this.lastMinimise = await minimiseBrowserWindow(this.#port);
|
|
631
|
+
// The window just became minimised, which is the signal that starts throttling. Re-assert the
|
|
632
|
+
// page is active so the reply that follows is not the one that has to wait for a human.
|
|
633
|
+
if (this.#cdp && !this.#cdp.closed) await keepPageActive(this.#cdp);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const target = await waitFor(() => Cdp.findTarget(this.#port, 'chat.deepseek.com'), {
|
|
639
|
+
timeout: 60000,
|
|
640
|
+
interval: 500,
|
|
641
|
+
label: 'a chat.deepseek.com tab',
|
|
642
|
+
});
|
|
643
|
+
this.#cdp = await Cdp.attach(this.#port, target.id);
|
|
644
|
+
await this.#cdp.send('Runtime.enable', {}, 10000);
|
|
645
|
+
/*
|
|
646
|
+
* Claim the page is foregrounded, every time a connection is made.
|
|
647
|
+
*
|
|
648
|
+
* The launch flags stop the timer clamp, but a page whose window is minimised is still marked hidden,
|
|
649
|
+
* and a page that believes it is hidden may defer rendering work. This says "active" - the state a
|
|
650
|
+
* person watching the window would have produced - so the answer streams whether the window is up or
|
|
651
|
+
* not. Best-effort: a build without the method must not stop a call.
|
|
652
|
+
*/
|
|
653
|
+
await keepPageActive(this.#cdp);
|
|
654
|
+
return this.#cdp;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Is this page waiting for a sign-in?
|
|
659
|
+
*
|
|
660
|
+
* Read from the document itself rather than from a stored token: the token can be present and stale,
|
|
661
|
+
* which is exactly how a sign-in screen was mistaken for a ready page earlier. The sign-in route and the
|
|
662
|
+
* absence of a composer are the two things that mean "a human has to act here".
|
|
663
|
+
*/
|
|
664
|
+
async #needsSignIn() {
|
|
665
|
+
const cdp = this.#cdp;
|
|
666
|
+
if (!cdp || cdp.closed) {
|
|
667
|
+
const target = await Cdp.findTarget(this.#port, 'chat.deepseek.com');
|
|
668
|
+
if (!target) return true;
|
|
669
|
+
this.#cdp = await Cdp.attach(this.#port, target.id);
|
|
670
|
+
await this.#cdp.send('Runtime.enable', {}, 10000);
|
|
671
|
+
await keepPageActive(this.#cdp);
|
|
672
|
+
}
|
|
673
|
+
const info = await this.#cdp.evaluate(
|
|
674
|
+
`(() => ({
|
|
675
|
+
url: location.href,
|
|
676
|
+
onSignIn: location.pathname.indexOf('sign_in') >= 0,
|
|
677
|
+
hasComposer: Boolean(document.querySelector('textarea')),
|
|
678
|
+
}))()`,
|
|
679
|
+
{ timeout: 8000 },
|
|
680
|
+
);
|
|
681
|
+
return Boolean(info?.onSignIn || !info?.hasComposer);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Bring the browser window to the front so a sign-in form is actually visible. */
|
|
685
|
+
async #raiseWindow() {
|
|
686
|
+
|
|
687
|
+
const ps = `
|
|
688
|
+
$ErrorActionPreference = 'Stop'
|
|
689
|
+
Add-Type -Namespace W -Name U -MemberDefinition @"
|
|
690
|
+
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int c);
|
|
691
|
+
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
|
692
|
+
"@
|
|
693
|
+
$pidOwner = (Get-NetTCPConnection -State Listen -LocalPort ${this.#port} -ErrorAction SilentlyContinue |
|
|
694
|
+
Select-Object -First 1).OwningProcess
|
|
695
|
+
if (-not $pidOwner) { exit }
|
|
696
|
+
$proc = Get-Process -Id $pidOwner -ErrorAction SilentlyContinue
|
|
697
|
+
$h = [IntPtr]$proc.MainWindowHandle
|
|
698
|
+
if ($h -eq [IntPtr]::Zero) { exit }
|
|
699
|
+
[void][W.U]::ShowWindow($h, 9) # SW_RESTORE
|
|
700
|
+
[void][W.U]::SetForegroundWindow($h)
|
|
701
|
+
`;
|
|
702
|
+
execFileSync('pwsh', ['-NoProfile', '-Command', ps], { encoding: 'utf8', timeout: 15000 });
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Report what the browser is doing, without changing anything.
|
|
707
|
+
*
|
|
708
|
+
* Separated from `ensureReady` because setup needs to ASK before acting: a person opening the plugin
|
|
709
|
+
* panel should see the current state, not trigger a browser launch just by looking. `ensureReady`
|
|
710
|
+
* navigates and waits; this only reads.
|
|
711
|
+
*/
|
|
712
|
+
async inspect() {
|
|
713
|
+
/*
|
|
714
|
+
* READ, NEVER LAUNCH.
|
|
715
|
+
*
|
|
716
|
+
* This used to go through #connect(), which starts the browser when the debug port is closed. That
|
|
717
|
+
* meant opening the Settings panel silently spawned a window - the exact opposite of the promise this
|
|
718
|
+
* method's contract makes: "looking must not act". When nothing is listening, the honest answer is
|
|
719
|
+
* "nothing to inspect", and the panel's launch button is the only thing that starts a browser.
|
|
720
|
+
*/
|
|
721
|
+
if (!(await portOpen(this.#port))) {
|
|
722
|
+
return { ok: false, error: `no browser is listening on port ${this.#port}` };
|
|
723
|
+
}
|
|
724
|
+
try {
|
|
725
|
+
const cdp = await this.#connect();
|
|
726
|
+
const info = await cdp.evaluate(
|
|
727
|
+
`(() => ({
|
|
728
|
+
url: location.href,
|
|
729
|
+
signedIn: Boolean(localStorage.getItem('userToken')),
|
|
730
|
+
hasComposer: Boolean(document.querySelector('textarea')),
|
|
731
|
+
onSignInPage: location.pathname.indexOf('sign_in') >= 0,
|
|
732
|
+
}))()`,
|
|
733
|
+
{ timeout: 8000 },
|
|
734
|
+
);
|
|
735
|
+
return { ok: true, ...info };
|
|
736
|
+
} catch (err) {
|
|
737
|
+
return { ok: false, error: err?.message ?? String(err) };
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Bring the site up in front so a person can sign in once.
|
|
743
|
+
*
|
|
744
|
+
* The plugin deliberately does not hold a password. A Chromium profile keeps the session for weeks
|
|
745
|
+
* after a single sign-in, and storing a credential would put it in plaintext on disk for a benefit
|
|
746
|
+
* measured in one avoided login. This method exists to make that one login effortless: it launches
|
|
747
|
+
* the browser if needed, navigates to the site and raises the window.
|
|
748
|
+
*/
|
|
749
|
+
async openSignIn() {
|
|
750
|
+
const cdp = await this.#connect();
|
|
751
|
+
await cdp.navigate(CHAT_URL);
|
|
752
|
+
await cdp.bringToFront();
|
|
753
|
+
return { ok: true };
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Reset the browser to a blank conversation.
|
|
758
|
+
*
|
|
759
|
+
* This is the one thing a person cannot reach from inside the harness. When a chat drifts, fills up, or
|
|
760
|
+
* starts answering the wrong subject, the only way back to a clean slate is the site's own "New chat" -
|
|
761
|
+
* which is exactly what this clicks, so it looks to the site like ordinary use.
|
|
762
|
+
*
|
|
763
|
+
* The reset is verified rather than assumed: newChat() re-reads the transcript and fails if a previous
|
|
764
|
+
* conversation survived. A click that merely looked successful would leave the next prompt inheriting the
|
|
765
|
+
* old subject, which is the bug this exists to avoid.
|
|
766
|
+
*/
|
|
767
|
+
async startFresh() {
|
|
768
|
+
const cdp = await this.ensureReady();
|
|
769
|
+
await this.newChat(cdp);
|
|
770
|
+
// Any conversation the session thought it owned is gone now.
|
|
771
|
+
this.#ownerKey = null;
|
|
772
|
+
return { ok: true };
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** Raise the browser window so a person can look at the page. */
|
|
776
|
+
async showWindow() {
|
|
777
|
+
await this.#connect();
|
|
778
|
+
await this.#raiseWindow();
|
|
779
|
+
return { ok: true };
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** Load the site and confirm the composer exists and a session is present. */
|
|
783
|
+
async ensureReady() {
|
|
784
|
+
const cdp = await this.#connect();
|
|
785
|
+
const composer = await waitFor(() => cdp.evaluate(FIND_COMPOSER, { timeout: 8000 }), {
|
|
786
|
+
timeout: 45000,
|
|
787
|
+
interval: 500,
|
|
788
|
+
label: 'the message composer',
|
|
789
|
+
}).catch(() => null);
|
|
790
|
+
|
|
791
|
+
if (!composer) {
|
|
792
|
+
// Either still loading or signed out. A reload settles both cases.
|
|
793
|
+
await cdp.navigate(CHAT_URL);
|
|
794
|
+
const again = await waitFor(() => cdp.evaluate(FIND_COMPOSER, { timeout: 8000 }), {
|
|
795
|
+
timeout: 60000,
|
|
796
|
+
interval: 600,
|
|
797
|
+
label: 'the message composer after reload',
|
|
798
|
+
}).catch(() => null);
|
|
799
|
+
if (!again) {
|
|
800
|
+
const signedIn = await cdp
|
|
801
|
+
.evaluate(`Boolean(localStorage.getItem('userToken'))`, { timeout: 5000 })
|
|
802
|
+
.catch(() => false);
|
|
803
|
+
throw new WebChatError(
|
|
804
|
+
signedIn
|
|
805
|
+
? 'chat.deepseek.com loaded but the composer never appeared'
|
|
806
|
+
: 'not signed in to chat.deepseek.com - open the browser and sign in once',
|
|
807
|
+
signedIn ? 'COMPOSER_MISSING' : 'NOT_SIGNED_IN',
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
await cdp.bringToFront();
|
|
812
|
+
return cdp;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Start a blank conversation and PROVE it is blank.
|
|
817
|
+
*
|
|
818
|
+
* Clicking is not evidence. The click is followed by a check that no assistant blocks remain, because
|
|
819
|
+
* a silent failure here does not raise - it just sends the next prompt into the previous conversation
|
|
820
|
+
* and returns an answer with the old one glued to the front.
|
|
821
|
+
*/
|
|
822
|
+
async newChat(cdp) {
|
|
823
|
+
// A blank chat has no assistant blocks at all, so a bare count is the right test here.
|
|
824
|
+
const empty = async () => (await cdp.evaluate(`document.querySelectorAll('${ASSISTANT}').length`, { timeout: 8000 })) === 0;
|
|
825
|
+
|
|
826
|
+
if (await cdp.evaluate(CLICK_NEW_CHAT, { timeout: 8000 }).catch(() => false)) {
|
|
827
|
+
const cleared = await waitFor(empty, {
|
|
828
|
+
timeout: 8000,
|
|
829
|
+
interval: 200,
|
|
830
|
+
label: 'the transcript to clear',
|
|
831
|
+
}).catch(() => false);
|
|
832
|
+
if (cleared) return;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
await cdp.navigate(CHAT_URL);
|
|
836
|
+
await waitFor(() => cdp.evaluate(FIND_COMPOSER, { timeout: 8000 }), {
|
|
837
|
+
timeout: 45000,
|
|
838
|
+
interval: 400,
|
|
839
|
+
label: 'the composer after navigating to a new chat',
|
|
840
|
+
});
|
|
841
|
+
const cleared = await waitFor(empty, {
|
|
842
|
+
timeout: 10000,
|
|
843
|
+
interval: 250,
|
|
844
|
+
label: 'the transcript to clear after navigation',
|
|
845
|
+
}).catch(() => false);
|
|
846
|
+
if (!cleared) {
|
|
847
|
+
throw new WebChatError(
|
|
848
|
+
'could not start a blank conversation - chat.deepseek.com restored a previous chat, so the ' +
|
|
849
|
+
'prompt would be sent with earlier turns still in context',
|
|
850
|
+
'NEW_CHAT_FAILED',
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Send one prompt and stream the reply.
|
|
857
|
+
*
|
|
858
|
+
* `options` carries `signal` plus the two switch states (`deepThink`, `search`).
|
|
859
|
+
* Yields `{ text }` deltas while the answer is being written, then `{ done: true, text }`.
|
|
860
|
+
*
|
|
861
|
+
* WHY THERE IS A WATCHDOG AND NOT A DELAY
|
|
862
|
+
* Runs are serialised through a promise queue so two callers cannot share one composer. That queue
|
|
863
|
+
* has one failure mode: if the caller stops consuming this generator - the harness abandons the
|
|
864
|
+
* stream on a cancel, or an exception escapes between `yield`s - the `finally` never runs, the
|
|
865
|
+
* release is never called, and every later request waits on a promise that will never settle. From
|
|
866
|
+
* outside it looks like the plugin has frozen and only a harness restart fixes it.
|
|
867
|
+
*
|
|
868
|
+
* So a held slot is released by force after WATCHDOG_MS. Nothing legitimate holds one that long:
|
|
869
|
+
* the slowest real turn is a few minutes, and TOTAL_TIMEOUT_MS caps a single call well below this.
|
|
870
|
+
*
|
|
871
|
+
* WHY THERE IS NO MINIMUM INTERVAL
|
|
872
|
+
* Other plugins in this space insert a random 2-4 second gap between requests, because the web API
|
|
873
|
+
* is stateless and they re-send the whole transcript every turn - a session can reach hundreds of
|
|
874
|
+
* thousands of tokens and accounts get muted. Uncle Sam continues the conversation inside the page
|
|
875
|
+
* and sends only the newest message, so its per-turn load is a small fraction of that. The gap is
|
|
876
|
+
* the single largest source of latency in those plugins, and it buys them nothing here.
|
|
877
|
+
*/
|
|
878
|
+
async *ask(prompt, options = {}) {
|
|
879
|
+
const previous = this.#queue;
|
|
880
|
+
let release;
|
|
881
|
+
let released = false;
|
|
882
|
+
this.#queue = new Promise((r) => {
|
|
883
|
+
release = r;
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
const releaseOnce = () => {
|
|
887
|
+
if (released) return;
|
|
888
|
+
released = true;
|
|
889
|
+
clearTimeout(watchdog);
|
|
890
|
+
release();
|
|
891
|
+
};
|
|
892
|
+
|
|
893
|
+
// A slot held past this is leaked, not busy.
|
|
894
|
+
const watchdog = setTimeout(() => {
|
|
895
|
+
if (released) return;
|
|
896
|
+
released = true;
|
|
897
|
+
release();
|
|
898
|
+
}, WATCHDOG_MS);
|
|
899
|
+
// The timer must not keep the host process alive by itself.
|
|
900
|
+
watchdog.unref?.();
|
|
901
|
+
|
|
902
|
+
await previous;
|
|
903
|
+
try {
|
|
904
|
+
yield* this.#askInner(prompt, options);
|
|
905
|
+
} finally {
|
|
906
|
+
releaseOnce();
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Force one switch to a wanted state, and prove it took.
|
|
912
|
+
*
|
|
913
|
+
* Clicking is a request, not a result: a stale element or a mis-hit leaves the switch as it was, and
|
|
914
|
+
* the call would then silently run in the wrong mode - answering from memory when Search was asked
|
|
915
|
+
* for, or spending a reasoning pass when it was not. So the state is re-read until it matches.
|
|
916
|
+
*/
|
|
917
|
+
async setToggle(cdp, label, want) {
|
|
918
|
+
const state = await cdp.evaluate(READ_TOGGLES, { timeout: 8000 });
|
|
919
|
+
if (state?.[label] === undefined) {
|
|
920
|
+
throw new WebChatError(
|
|
921
|
+
`the "${label}" switch is not present on the page - the composer may have changed shape`,
|
|
922
|
+
'TOGGLE_MISSING',
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
if (state[label] === want) return false;
|
|
926
|
+
|
|
927
|
+
const clicked = await cdp.evaluate(clickToggle(label), { timeout: 8000 });
|
|
928
|
+
if (!clicked) {
|
|
929
|
+
throw new WebChatError(`could not click the "${label}" switch`, 'TOGGLE_CLICK_FAILED');
|
|
930
|
+
}
|
|
931
|
+
await waitFor(
|
|
932
|
+
async () => {
|
|
933
|
+
const now = await cdp.evaluate(READ_TOGGLES, { timeout: 6000 });
|
|
934
|
+
return now?.[label] === want ? now : null;
|
|
935
|
+
},
|
|
936
|
+
{ timeout: 6000, interval: 150, label: `the "${label}" switch to become ${want ? 'on' : 'off'}` },
|
|
937
|
+
);
|
|
938
|
+
return true;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Apply switch states, but only where the plugin actually has an opinion.
|
|
943
|
+
*
|
|
944
|
+
* THIS USED TO FIGHT THE PERSON
|
|
945
|
+
* Every call passed `deepThink: false, search: false` for an ordinary model, and those were treated
|
|
946
|
+
* as instructions to turn both switches OFF. So somebody who turned DeepThink on in the browser - the
|
|
947
|
+
* visible, deliberate act of asking for a better answer - had it silently switched off by the next
|
|
948
|
+
* message. Recorded happening live: both toggles lit by hand, both dark again after the plugin sent.
|
|
949
|
+
*
|
|
950
|
+
* `undefined` now means "leave it as the person left it", and only an explicit `true` asks for a
|
|
951
|
+
* change. Turning something ON is the plugin's business; turning it OFF is the person's.
|
|
952
|
+
*/
|
|
953
|
+
async applyToggles(cdp, { deepThink, search } = {}) {
|
|
954
|
+
const changed = [];
|
|
955
|
+
if (deepThink === true) {
|
|
956
|
+
if (await this.setToggle(cdp, 'DeepThink', true)) changed.push('DeepThink');
|
|
957
|
+
}
|
|
958
|
+
if (search === true) {
|
|
959
|
+
if (await this.setToggle(cdp, 'Search', true)) changed.push('Search');
|
|
960
|
+
}
|
|
961
|
+
return changed;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Put back any switch this plugin turned on for one call.
|
|
966
|
+
*
|
|
967
|
+
* WHY THIS EXISTS
|
|
968
|
+
* A search call turned the site's Search toggle on and left it there. The next ordinary message then
|
|
969
|
+
* searched the web without anybody asking - slower, and a silent change of behaviour: the person picks
|
|
970
|
+
* "fast chat" and gets a web lookup. The toggle is per-conversation state on the site, so leaving it on
|
|
971
|
+
* changes what later turns do.
|
|
972
|
+
*
|
|
973
|
+
* Only switches THIS plugin turned on are restored. If the person had already enabled Search, it was
|
|
974
|
+
* never in the changed list, so it stays exactly as they left it - the plugin must not fight a
|
|
975
|
+
* deliberate choice.
|
|
976
|
+
*
|
|
977
|
+
* Best-effort: failing to restore must not fail a call that already succeeded.
|
|
978
|
+
*/
|
|
979
|
+
async restoreToggles(cdp, names) {
|
|
980
|
+
const restored = [];
|
|
981
|
+
for (const name of names ?? []) {
|
|
982
|
+
try {
|
|
983
|
+
await this.setToggle(cdp, name, false);
|
|
984
|
+
restored.push(name);
|
|
985
|
+
} catch {
|
|
986
|
+
/* the page may have moved on; the next call tries again */
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
return restored;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
async *#askInner(prompt, options = {}) {
|
|
993
|
+
const { signal, deepThink = false, search = false, continueChat = false, conversationKey } = options;
|
|
994
|
+
const cdp = await this.ensureReady();
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* One browser chat per harness conversation.
|
|
998
|
+
*
|
|
999
|
+
* There is exactly ONE browser tab, so every harness session on the web route would otherwise share
|
|
1000
|
+
* the same conversation in it. That is not cosmetic: session A asks about a plugin, session B asks
|
|
1001
|
+
* something unrelated, and B's message lands in A's chat - so the browser continues the wrong subject
|
|
1002
|
+
* and answers a question nobody in that conversation asked.
|
|
1003
|
+
*
|
|
1004
|
+
* The harness also makes internal calls to generate session titles and to compact history, and those
|
|
1005
|
+
* were being typed into the same chat too. That is the odd extra traffic appearing in the browser.
|
|
1006
|
+
*
|
|
1007
|
+
* The key decides which conversation the browser is holding. When it changes, the chat is reset
|
|
1008
|
+
* before anything is typed, so no other session's words are ever sent as context.
|
|
1009
|
+
*/
|
|
1010
|
+
const key = String(conversationKey ?? 'default');
|
|
1011
|
+
if (!continueChat || this.#ownerKey !== key) {
|
|
1012
|
+
await this.newChat(cdp);
|
|
1013
|
+
this.#ownerKey = key;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/*
|
|
1017
|
+
* Restore anything the PREVIOUS call turned on and could not put back.
|
|
1018
|
+
*
|
|
1019
|
+
* The normal path restores at the end of the call, but a call that throws or is abandoned leaves the
|
|
1020
|
+
* switch on. Catching it here means a failure costs one turn of leftover state rather than persisting
|
|
1021
|
+
* until somebody notices their messages are secretly searching the web.
|
|
1022
|
+
*/
|
|
1023
|
+
if (this.#turnedOn.length) {
|
|
1024
|
+
await this.restoreToggles(cdp, this.#turnedOn);
|
|
1025
|
+
this.#turnedOn = [];
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// Switches are per-conversation, so they are set after any reset and before sending.
|
|
1029
|
+
this.#turnedOn = await this.applyToggles(cdp, { deepThink, search });
|
|
1030
|
+
|
|
1031
|
+
/*
|
|
1032
|
+
* What is already on screen is history: how many assistant blocks there are, and the shape of the last
|
|
1033
|
+
* one. The count alone was not enough - the site recycles blocks, so the count can stay the same while a
|
|
1034
|
+
* new answer appears. See readReply for the measurement that proved it.
|
|
1035
|
+
*/
|
|
1036
|
+
const baseline = (await cdp.evaluate(READ_BASELINE, { timeout: 8000 })) ?? { count: 0, len: 0, tail: '' };
|
|
1037
|
+
|
|
1038
|
+
const typed = await cdp.evaluate(TYPE_TEXT(prompt), { timeout: 15000 });
|
|
1039
|
+
if (!typed?.ok) {
|
|
1040
|
+
throw new WebChatError(
|
|
1041
|
+
`could not type into the composer: ${typed?.why ?? 'unknown'}`,
|
|
1042
|
+
'TYPE_FAILED',
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* Submit the message, retrying until the composer actually clears.
|
|
1049
|
+
*
|
|
1050
|
+
* Enter alone is not reliable here. Measured: a 16-character message was typed in full and Enter did
|
|
1051
|
+
* nothing, while the very next attempt on the same page succeeded. The site is a React app that
|
|
1052
|
+
* re-renders the composer after each reply, and a synthesised Enter can land while focus is elsewhere
|
|
1053
|
+
* or before the handler is attached - so the keypress is delivered and ignored, with no error anywhere.
|
|
1054
|
+
*
|
|
1055
|
+
* Three attempts, each escalating:
|
|
1056
|
+
* 1. a real CDP Enter, which is what a person pressing the key produces
|
|
1057
|
+
* 2. a DOM Enter dispatched on the focused element, in case the page listens on the node rather than
|
|
1058
|
+
* on the document
|
|
1059
|
+
* 3. a click on the send button, which is the one path that cannot depend on keyboard focus
|
|
1060
|
+
*
|
|
1061
|
+
* Success is judged only by the composer emptying. Anything else is a failure to send, and saying so
|
|
1062
|
+
* immediately beats waiting 90 seconds for an answer that was never asked for.
|
|
1063
|
+
*/
|
|
1064
|
+
const composerEmpty = async () =>
|
|
1065
|
+
Number(
|
|
1066
|
+
await cdp
|
|
1067
|
+
.evaluate(
|
|
1068
|
+
`(() => { const t = [...document.querySelectorAll('textarea')]
|
|
1069
|
+
.filter((e) => e.getBoundingClientRect().width > 4)[0];
|
|
1070
|
+
return t ? t.value.length : 0; })()`,
|
|
1071
|
+
{ timeout: 5000 },
|
|
1072
|
+
)
|
|
1073
|
+
.catch(() => -1),
|
|
1074
|
+
) === 0;
|
|
1075
|
+
|
|
1076
|
+
const pressEnter = async () => {
|
|
1077
|
+
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, 8000);
|
|
1078
|
+
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, 8000);
|
|
1079
|
+
};
|
|
1080
|
+
|
|
1081
|
+
const pressEnterInPage = async () =>
|
|
1082
|
+
cdp
|
|
1083
|
+
.evaluate(
|
|
1084
|
+
`(() => {
|
|
1085
|
+
const vis = (el) => { const r = el.getBoundingClientRect(); return r.width > 4 && r.height > 4; };
|
|
1086
|
+
const el = [...document.querySelectorAll('textarea,[contenteditable="true"]')].filter(vis)[0];
|
|
1087
|
+
if (!el) return false;
|
|
1088
|
+
el.focus();
|
|
1089
|
+
for (const type of ['keydown', 'keypress', 'keyup']) {
|
|
1090
|
+
el.dispatchEvent(new KeyboardEvent(type, {
|
|
1091
|
+
key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true,
|
|
1092
|
+
}));
|
|
1093
|
+
}
|
|
1094
|
+
return true;
|
|
1095
|
+
})()`,
|
|
1096
|
+
{ timeout: 8000 },
|
|
1097
|
+
)
|
|
1098
|
+
.catch(() => false);
|
|
1099
|
+
|
|
1100
|
+
const clickSend = async () =>
|
|
1101
|
+
cdp
|
|
1102
|
+
.evaluate(
|
|
1103
|
+
`(() => {
|
|
1104
|
+
const vis = (el) => { const r = el.getBoundingClientRect(); return r.width > 4 && r.height > 4; };
|
|
1105
|
+
const send = [...document.querySelectorAll('button,[role="button"],div[class*="send"],[aria-label*="end"]')]
|
|
1106
|
+
.filter(vis)
|
|
1107
|
+
.find((el) => /send|submit/i.test((el.getAttribute('aria-label') || '') + ' ' + (el.className || '')));
|
|
1108
|
+
if (!send) return false;
|
|
1109
|
+
send.click();
|
|
1110
|
+
return true;
|
|
1111
|
+
})()`,
|
|
1112
|
+
{ timeout: 8000 },
|
|
1113
|
+
)
|
|
1114
|
+
.catch(() => false);
|
|
1115
|
+
|
|
1116
|
+
let sent = false;
|
|
1117
|
+
const attempts = [
|
|
1118
|
+
['Enter', pressEnter],
|
|
1119
|
+
['Enter (in page)', pressEnterInPage],
|
|
1120
|
+
['send button', clickSend],
|
|
1121
|
+
['Enter (retry)', pressEnter],
|
|
1122
|
+
];
|
|
1123
|
+
for (const [name, attempt] of attempts) {
|
|
1124
|
+
await attempt().catch(() => {});
|
|
1125
|
+
/**
|
|
1126
|
+
* Poll for the composer clearing instead of sleeping a fixed time.
|
|
1127
|
+
*
|
|
1128
|
+
* A flat 1,400 ms wait after every attempt added that delay to EVERY message, including the common
|
|
1129
|
+
* case where the first Enter worked. Polling exits as soon as the send lands - typically 120-250 ms -
|
|
1130
|
+
* and only spends the full budget when something is genuinely wrong.
|
|
1131
|
+
*/
|
|
1132
|
+
const cleared = await waitFor(
|
|
1133
|
+
async () => ((await composerEmpty()) ? true : undefined),
|
|
1134
|
+
{ timeout: 1500, interval: 120, label: 'the composer to clear' },
|
|
1135
|
+
).catch(() => false);
|
|
1136
|
+
if (cleared) {
|
|
1137
|
+
sent = true;
|
|
1138
|
+
break;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
if (!sent) {
|
|
1143
|
+
const stuck = Number(
|
|
1144
|
+
await cdp
|
|
1145
|
+
.evaluate(
|
|
1146
|
+
`(() => { const t = [...document.querySelectorAll('textarea')]
|
|
1147
|
+
.filter((e) => e.getBoundingClientRect().width > 4)[0];
|
|
1148
|
+
return t ? t.value.length : 0; })()`,
|
|
1149
|
+
{ timeout: 5000 },
|
|
1150
|
+
)
|
|
1151
|
+
.catch(() => -1),
|
|
1152
|
+
);
|
|
1153
|
+
// Clear it, so the next attempt starts from a clean box instead of appending to the wreckage.
|
|
1154
|
+
await cdp
|
|
1155
|
+
.evaluate(
|
|
1156
|
+
`(() => {
|
|
1157
|
+
const t = document.querySelector('textarea');
|
|
1158
|
+
if (!t) return false;
|
|
1159
|
+
const set = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
|
|
1160
|
+
set.call(t, '');
|
|
1161
|
+
t.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1162
|
+
return true;
|
|
1163
|
+
})()`,
|
|
1164
|
+
{ timeout: 5000 },
|
|
1165
|
+
)
|
|
1166
|
+
.catch(() => {});
|
|
1167
|
+
throw new WebChatError(
|
|
1168
|
+
`the message was typed but never sent after four attempts; the composer still holds ${stuck} ` +
|
|
1169
|
+
`characters. This is the site ignoring Enter rather than a size problem. The box has been ` +
|
|
1170
|
+
`cleared, so the next message can be tried.`,
|
|
1171
|
+
'SEND_FAILED',
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
const started = Date.now();
|
|
1176
|
+
let emitted = '';
|
|
1177
|
+
let lastChange = Date.now();
|
|
1178
|
+
let sawAnyText = false;
|
|
1179
|
+
/** The page's total text length at the last poll, so growth can be noticed even outside the answer. */
|
|
1180
|
+
let lastAlive = 0;
|
|
1181
|
+
/*
|
|
1182
|
+
* A throttle detector, so a stalled read is REPORTED instead of waited out.
|
|
1183
|
+
*
|
|
1184
|
+
* The launch flags should make this impossible, but if the page is still throttled the symptom is
|
|
1185
|
+
* indistinguishable from a slow answer: the poll returns the same snapshot forever and the person
|
|
1186
|
+
* watches a timer climb. Counting consecutive no-change reads turns that into a sentence they can act
|
|
1187
|
+
* on, and re-asserting the active state once mid-wait gives a throttled page a second chance before
|
|
1188
|
+
* the call is blamed.
|
|
1189
|
+
*/
|
|
1190
|
+
let staleReads = 0;
|
|
1191
|
+
let nudged = false;
|
|
1192
|
+
|
|
1193
|
+
while (Date.now() - started < TOTAL_TIMEOUT_MS) {
|
|
1194
|
+
if (signal?.aborted) {
|
|
1195
|
+
throw new WebChatError('cancelled', 'ABORTED');
|
|
1196
|
+
}
|
|
1197
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
1198
|
+
|
|
1199
|
+
let snap;
|
|
1200
|
+
try {
|
|
1201
|
+
snap = await cdp.evaluate(readReply(baseline), { timeout: 8000 });
|
|
1202
|
+
} catch {
|
|
1203
|
+
continue; // the page may be mid-navigation; a missed poll is not a failure
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/*
|
|
1207
|
+
* A BLOCK THAT EXISTS BUT STAYS EMPTY IS NOT A REPLY.
|
|
1208
|
+
*
|
|
1209
|
+
* MEASURED: a test call hung for over eight minutes. The page had created the assistant block - so
|
|
1210
|
+
* `isNew` was true - but never put a character in it. The no-reply guard used to live INSIDE the
|
|
1211
|
+
* `!isNew` branch, so that shape skipped it entirely and the loop ran to the ten-minute cap. A person
|
|
1212
|
+
* watching that sees a spinner and no explanation, which is the exact failure this guard exists to
|
|
1213
|
+
* prevent.
|
|
1214
|
+
*
|
|
1215
|
+
* So the guard is evaluated on "has any text arrived", independently of whether a block exists.
|
|
1216
|
+
*/
|
|
1217
|
+
const text = snap?.isNew ? String(snap.text ?? '') : '';
|
|
1218
|
+
const progressed = text.length > emitted.length && text.startsWith(emitted);
|
|
1219
|
+
|
|
1220
|
+
/*
|
|
1221
|
+
* A THINKING PAGE IS NOT A DEAD PAGE.
|
|
1222
|
+
*
|
|
1223
|
+
* MEASURED, by the person reading Brave: a 1000-word request was answered, but this call abandoned it
|
|
1224
|
+
* after five silent seconds with "no text arrived". The page had been writing its reasoning into a
|
|
1225
|
+
* Thinking block the whole time - a different element from the answer, so nothing this reader watches
|
|
1226
|
+
* had changed. Three earlier theories blamed the site; the person's screenshot showed the answer.
|
|
1227
|
+
*
|
|
1228
|
+
* So the page's total text length is sampled as a sign of life. It never decides what to EMIT - the
|
|
1229
|
+
* answer element does that - but any growth in it resets the pause clock, because it means work is
|
|
1230
|
+
* happening.
|
|
1231
|
+
*/
|
|
1232
|
+
const aliveNow = Number(snap?.alive) || 0;
|
|
1233
|
+
if (aliveNow !== lastAlive) {
|
|
1234
|
+
lastAlive = aliveNow;
|
|
1235
|
+
lastChange = Date.now();
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/*
|
|
1239
|
+
* THE PAUSE RULE.
|
|
1240
|
+
*
|
|
1241
|
+
* A pause is measured from the last sign of life, not from the start of the call: launching a browser or
|
|
1242
|
+
* opening a fresh chat takes seconds and is not a hang. Once something has been sent, five silent
|
|
1243
|
+
* seconds is already abnormal - the page either answered or it did not.
|
|
1244
|
+
*
|
|
1245
|
+
* Reported as its own error code so a test can tell "the page stalled" apart from "the page was slow",
|
|
1246
|
+
* and can stop and clean up rather than carrying on to the next case.
|
|
1247
|
+
*/
|
|
1248
|
+
const sinceProgress = Date.now() - lastChange;
|
|
1249
|
+
/*
|
|
1250
|
+
* A search call needs a longer budget, and that is not a loophole.
|
|
1251
|
+
*
|
|
1252
|
+
* MEASURED: the first live search test stalled at 5.1 seconds with nothing to show. A web search
|
|
1253
|
+
* legitimately spends time fetching before it writes any answer text, so a five-second silence is the
|
|
1254
|
+
* page working, not the page dead. Applying the plain-chat rule here would abort every search.
|
|
1255
|
+
*
|
|
1256
|
+
* The rule is kept for what it was made for - an ordinary message that produces nothing - and a search
|
|
1257
|
+
* that produces nothing for thirty seconds is still treated as dead.
|
|
1258
|
+
*/
|
|
1259
|
+
const stallBudget = search ? Math.max(STALL_MS, SEARCH_STALL_MS) : STALL_MS;
|
|
1260
|
+
if (sinceProgress > stallBudget) {
|
|
1261
|
+
if (process.env.DEBUG_WEB_CHAT) {
|
|
1262
|
+
console.error(`[stall] no progress for ${sinceProgress}ms (limit ${stallBudget}ms) - abandoning the call`);
|
|
1263
|
+
}
|
|
1264
|
+
throw new WebChatError(
|
|
1265
|
+
`the page went silent for ${(sinceProgress / 1000).toFixed(1)}s after the message was sent ` +
|
|
1266
|
+
`(limit ${(stallBudget / 1000).toFixed(1)}s). No text arrived, so this was abandoned rather than waited out.`,
|
|
1267
|
+
'STALLED',
|
|
1268
|
+
);
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
if (!progressed) {
|
|
1272
|
+
if (process.env.DEBUG_WEB_CHAT) {
|
|
1273
|
+
console.error(`[poll] no progress (found=${snap?.found} isNew=${Boolean(snap?.isNew)} len=${text.length})`);
|
|
1274
|
+
}
|
|
1275
|
+
staleReads += 1;
|
|
1276
|
+
/*
|
|
1277
|
+
* Nothing has changed for a while and the answer has not started. Either the page is throttled or the
|
|
1278
|
+
* site is not generating. Re-assert the active state once before blaming anything - that is the one
|
|
1279
|
+
* action available from this side.
|
|
1280
|
+
*/
|
|
1281
|
+
if (!nudged && !sawAnyText && staleReads * POLL_MS > 6000) {
|
|
1282
|
+
nudged = true;
|
|
1283
|
+
await keepPageActive(cdp);
|
|
1284
|
+
// The nudge is a sign of life: it must not itself count towards the pause budget.
|
|
1285
|
+
lastChange = Date.now();
|
|
1286
|
+
continue;
|
|
1287
|
+
}
|
|
1288
|
+
} else {
|
|
1289
|
+
staleReads = 0;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
if (!sawAnyText && Date.now() - started > FIRST_TOKEN_TIMEOUT_MS) {
|
|
1293
|
+
const secs = Math.round(FIRST_TOKEN_TIMEOUT_MS / 1000);
|
|
1294
|
+
throw new WebChatError(
|
|
1295
|
+
nudged
|
|
1296
|
+
? `chat.deepseek.com produced no text within ${secs}s even after being told the page is active. ` +
|
|
1297
|
+
'The account may be rate limited, or a proof-of-work challenge is being shown - open the ' +
|
|
1298
|
+
'browser window once and look at the page.'
|
|
1299
|
+
: `chat.deepseek.com produced no text within ${secs}s (the account may be rate limited, or a ` +
|
|
1300
|
+
'proof-of-work challenge is being shown)',
|
|
1301
|
+
'NO_FIRST_TOKEN',
|
|
1302
|
+
);
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
if (!snap?.isNew) continue;
|
|
1306
|
+
|
|
1307
|
+
if (progressed) {
|
|
1308
|
+
const delta = text.slice(emitted.length);
|
|
1309
|
+
emitted = text;
|
|
1310
|
+
lastChange = Date.now();
|
|
1311
|
+
sawAnyText = true;
|
|
1312
|
+
yield { text: delta };
|
|
1313
|
+
} else if (text !== emitted) {
|
|
1314
|
+
// The block changed in a way that is not a pure append: a re-render mid-stream, or a replaced
|
|
1315
|
+
// answer. Emit ONLY the characters not yet sent, found by the longest common prefix.
|
|
1316
|
+
//
|
|
1317
|
+
// An earlier version re-emitted the entire text on this path, and the consumer - which appends
|
|
1318
|
+
// every chunk - produced the answer twice over. A streaming adapter may never send text twice.
|
|
1319
|
+
let n = 0;
|
|
1320
|
+
const max = Math.min(emitted.length, text.length);
|
|
1321
|
+
while (n < max && emitted[n] === text[n]) n += 1;
|
|
1322
|
+
const tail = text.slice(n);
|
|
1323
|
+
emitted = text;
|
|
1324
|
+
lastChange = Date.now();
|
|
1325
|
+
sawAnyText = true;
|
|
1326
|
+
if (tail) yield { text: tail };
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
const quiet = Date.now() - lastChange;
|
|
1330
|
+
if (sawAnyText && quiet >= SETTLE_MS) break;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// With search on, the sources are only readable once the answer has settled, so they are collected
|
|
1334
|
+
// at the end and returned alongside the text rather than streamed.
|
|
1335
|
+
let sources = { domains: [], links: [] };
|
|
1336
|
+
if (search) {
|
|
1337
|
+
sources = await cdp.evaluate(READ_SOURCES, { timeout: 8000 }).catch(() => sources);
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/*
|
|
1341
|
+
* Put the switches back before handing over the answer.
|
|
1342
|
+
*
|
|
1343
|
+
* Done here rather than in a finally because a generator that is abandoned mid-yield never reaches one,
|
|
1344
|
+
* and the next call restores leftovers at its start. Between the two, a leftover switch survives at most
|
|
1345
|
+
* one turn.
|
|
1346
|
+
*/
|
|
1347
|
+
if (this.#turnedOn.length) {
|
|
1348
|
+
await this.restoreToggles(cdp, this.#turnedOn);
|
|
1349
|
+
this.#turnedOn = [];
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
yield { done: true, text: emitted, sources, switches: { deepThink, search } };
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/**
|
|
1356
|
+
* Detach from the page, leaving the browser running.
|
|
1357
|
+
*
|
|
1358
|
+
* This is what the HOST wants on dispose: the person's browser tab and session survive a plugin reload.
|
|
1359
|
+
* It is NOT what a one-shot script wants, which is the bug that produced this comment - see the exit hook
|
|
1360
|
+
* installed in #connect().
|
|
1361
|
+
*/
|
|
1362
|
+
close() {
|
|
1363
|
+
this.#cdp?.close();
|
|
1364
|
+
this.#cdp = null;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* Take ownership of the browser this session launches, and give it back on exit.
|
|
1369
|
+
*
|
|
1370
|
+
* THE BUG THIS FIXES
|
|
1371
|
+
* Scripts that drive the engine call close() when they finish, which detaches but leaves Brave running.
|
|
1372
|
+
* Measured: of the twenty-three dev scripts that construct this class, twenty-three leaked a browser.
|
|
1373
|
+
* A window would appear on the person's desktop minutes after a test had "finished", with nothing to
|
|
1374
|
+
* explain it - and it looks like the plugin is spying on them.
|
|
1375
|
+
*
|
|
1376
|
+
* So a browser this object LAUNCHED is killed when the process exits, and only that one: a browser it
|
|
1377
|
+
* merely attached to belongs to somebody else and is left alone. The distinction is recorded when the
|
|
1378
|
+
* launch happens, not guessed afterwards.
|
|
1379
|
+
*/
|
|
1380
|
+
#claimOnExit() {
|
|
1381
|
+
if (this.#exitHookInstalled) return;
|
|
1382
|
+
this.#exitHookInstalled = true;
|
|
1383
|
+
const kill = () => {
|
|
1384
|
+
if (!this.#launchedByUs) return;
|
|
1385
|
+
this.#launchedByUs = false;
|
|
1386
|
+
/*
|
|
1387
|
+
* Synchronous, because an exit handler gets no chance to await anything.
|
|
1388
|
+
*
|
|
1389
|
+
* `taskkill /F /T` takes the child processes with it - a Chromium launch is a tree, and killing only
|
|
1390
|
+
* the parent leaves the renderer holding the profile lock.
|
|
1391
|
+
*/
|
|
1392
|
+
try {
|
|
1393
|
+
const pid = ownerOfPortSync(this.#port);
|
|
1394
|
+
if (pid) execFileSync('taskkill', ['/PID', String(pid), '/F', '/T'], { stdio: 'ignore', timeout: 4000 });
|
|
1395
|
+
} catch {
|
|
1396
|
+
/* already gone, or not permitted: nothing else to try */
|
|
1397
|
+
}
|
|
1398
|
+
};
|
|
1399
|
+
process.once('exit', kill);
|
|
1400
|
+
// Ctrl-C in a terminal does not always reach 'exit'; these make it reliable.
|
|
1401
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
1402
|
+
try {
|
|
1403
|
+
process.once(signal, () => {
|
|
1404
|
+
kill();
|
|
1405
|
+
process.exit(130);
|
|
1406
|
+
});
|
|
1407
|
+
} catch {
|
|
1408
|
+
/* a signal this platform does not have */
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
}
|