@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/index.js
ADDED
|
@@ -0,0 +1,1411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeepSeek web-chat provider for the harness.
|
|
3
|
+
*
|
|
4
|
+
* Registers the provider route `deepseek-web`, backed by a real browser driving chat.deepseek.com.
|
|
5
|
+
*
|
|
6
|
+
* @file index.js
|
|
7
|
+
* @version 3.9.11
|
|
8
|
+
* @author Operating Tag (@operatingtag)
|
|
9
|
+
* @license CC-BY-NC-ND-4.0
|
|
10
|
+
*
|
|
11
|
+
* WHY THIS PROVIDER EXISTS
|
|
12
|
+
* A harness can only talk to a provider it has an adapter for, and an adapter is allowed to be slow
|
|
13
|
+
* or clever underneath as long as it speaks `StreamChunk`. This one speaks it by driving the public
|
|
14
|
+
* web product, which means the account in the browser is the account being used: no API key, no
|
|
15
|
+
* per-token billing, and the same model the person already has open in a tab.
|
|
16
|
+
*
|
|
17
|
+
* WHAT IT DELIBERATELY DOES NOT DO
|
|
18
|
+
* It does not call the site's private HTTP API. That API requires a proof-of-work challenge per
|
|
19
|
+
* message and sits behind a bot gate; re-implementing either would break whenever the site changed
|
|
20
|
+
* and would look like a network fault when it did. The page solves its own challenges, so the
|
|
21
|
+
* adapter never has to know they exist.
|
|
22
|
+
*
|
|
23
|
+
* THE HONEST LIMITATION
|
|
24
|
+
* A web chat cannot return a structured tool call. Tool use is therefore emulated through the
|
|
25
|
+
* prompt and recovered by parsing JSON out of the reply, which is described in `lib/prompt.mjs`.
|
|
26
|
+
* Emulation is weaker than a native tool channel: a model that ignores the instruction produces
|
|
27
|
+
* prose instead of a call, and the harness sees no tool use that turn.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { ChatSession, WebChatError } from './lib/engine.mjs';
|
|
31
|
+
import { buildPrompt, messageText, parseToolCall, stripToolCall, streamSafeCut } from './lib/prompt.mjs';
|
|
32
|
+
import { SavingsMeter, estimateTokens as estimateTokensForSavings } from './lib/savings.mjs';
|
|
33
|
+
import { Locales } from './lib/i18n.mjs';
|
|
34
|
+
import { Prefs, prefsPath, profileDir } from './lib/prefs.mjs';
|
|
35
|
+
|
|
36
|
+
// `llm` is required: without it there is no provider to register.
|
|
37
|
+
//
|
|
38
|
+
// The other three are REQUIRED for the switch and were the reason it silently did nothing. In Cordis a
|
|
39
|
+
// service is only resolvable through `ctx.get` once it is declared here; an undeclared one resolves to
|
|
40
|
+
// undefined, no error is raised, and every call that guarded on it quietly took the fallback path. That
|
|
41
|
+
// is how the switch came to save a default while the open conversation kept answering from the API.
|
|
42
|
+
export const inject = ['llm', 'agentDefaultModel', 'sessionController', 'webServer'];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Route switching.
|
|
46
|
+
*
|
|
47
|
+
* The client cannot change the model: client services are layout, locale, sessions, slots, theme,
|
|
48
|
+
* timer and uiWorkspace, and none of them selects a model. The Host can, through
|
|
49
|
+
* `agentDefaultModel.saveSelection`, which writes the profile. So the switch is a Host route and the
|
|
50
|
+
* client component is a view over it - one source of truth, and both places that show the switch read
|
|
51
|
+
* the same state.
|
|
52
|
+
*
|
|
53
|
+
* WHY THE PREVIOUS SELECTION IS REMEMBERED
|
|
54
|
+
* The left side of the switch must return the person to the model they were actually using, not to
|
|
55
|
+
* a hard-coded guess. The first time the switch is moved to WEB the current API selection is stored;
|
|
56
|
+
* moving back restores exactly that. Nothing here ever changes the route on its own - the only
|
|
57
|
+
* writer is a click.
|
|
58
|
+
*/
|
|
59
|
+
const ROUTE_PATH = '/api/deepseek-web/route';
|
|
60
|
+
const WEB_SELECTION = { provider: 'deepseek-web', model: 'deepseek-chat' };
|
|
61
|
+
|
|
62
|
+
/** Per-process memory of the API-side selection, so switching back is exact. */
|
|
63
|
+
let lastApiSelection = null;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The previous answer and when it arrived, per conversation.
|
|
67
|
+
*
|
|
68
|
+
* Used only to notice that the page served the same answer twice in a row, which is a stuck generation
|
|
69
|
+
* rather than a new reply. Two facts are stored: the text, and the time. An identical answer minutes
|
|
70
|
+
* apart is a person asking the same question twice - legitimate - so only a near-immediate repeat counts
|
|
71
|
+
* as a fault. One small object per live conversation.
|
|
72
|
+
*/
|
|
73
|
+
const lastAnswer = new Map();
|
|
74
|
+
|
|
75
|
+
function selectionIsWeb(selection) {
|
|
76
|
+
return Boolean(selection) && selection.provider === 'deepseek-web';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function json(res, status, body) {
|
|
80
|
+
const payload = JSON.stringify(body);
|
|
81
|
+
res.writeHead(status, {
|
|
82
|
+
'content-type': 'application/json; charset=utf-8',
|
|
83
|
+
'cache-control': 'no-store',
|
|
84
|
+
});
|
|
85
|
+
res.end(payload);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readBody(req) {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
const chunks = [];
|
|
91
|
+
let size = 0;
|
|
92
|
+
req.on('data', (chunk) => {
|
|
93
|
+
size += chunk.length;
|
|
94
|
+
if (size > 64 * 1024) {
|
|
95
|
+
reject(new Error('request body too large'));
|
|
96
|
+
req.destroy();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
chunks.push(chunk);
|
|
100
|
+
});
|
|
101
|
+
req.on('end', () => {
|
|
102
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
103
|
+
if (!text) return resolve({});
|
|
104
|
+
try {
|
|
105
|
+
resolve(JSON.parse(text));
|
|
106
|
+
} catch {
|
|
107
|
+
reject(new Error('body is not JSON'));
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
req.on('error', reject);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const PROVIDER = 'deepseek-web';
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Model identities advertised for this route.
|
|
118
|
+
*
|
|
119
|
+
* The web product has TWO switches - DeepThink (a reasoning pass) and Search (read the live web) - and
|
|
120
|
+
* the harness has no per-call provider-option channel. Rather than hide them in config, they are encoded
|
|
121
|
+
* in the model name, because the model picker is exactly the place a person already chooses "what kind
|
|
122
|
+
* of answer do I want". Four combinations, four entries, no hidden state:
|
|
123
|
+
*
|
|
124
|
+
* deepseek-chat fast, from memory
|
|
125
|
+
* deepseek-chat-search fast, but reads the web first
|
|
126
|
+
* deepseek-reasoner thinks before answering
|
|
127
|
+
* deepseek-reasoner-search thinks, and reads the web
|
|
128
|
+
*/
|
|
129
|
+
/**
|
|
130
|
+
* The model list, translated.
|
|
131
|
+
*
|
|
132
|
+
* Ids never change - they are embedded in saved selections - so only the words a person reads are replaced.
|
|
133
|
+
* The provider is a browser tab rather than an API, so the descriptions say what the page will actually do.
|
|
134
|
+
*/
|
|
135
|
+
/**
|
|
136
|
+
* Every key the settings panel draws.
|
|
137
|
+
*
|
|
138
|
+
* One list, used by the route and by the locale tests, so a string added to en.json and forgotten here is
|
|
139
|
+
* caught rather than silently missing. Anything not listed cannot reach the panel.
|
|
140
|
+
*/
|
|
141
|
+
const PANEL_KEYS = [
|
|
142
|
+
'title', 'buyMeACoffee', 'languageLabel', 'starting', 'switching', 'languageAuto',
|
|
143
|
+
'routeTitle', 'routeNote', 'pageIntro',
|
|
144
|
+
'braveTitle', 'braveWhy', 'braveSecurity', 'braveSecurityBody',
|
|
145
|
+
'bravePrivacy', 'bravePrivacyBody', 'braveQuiet', 'braveQuietBody',
|
|
146
|
+
'braveInvisible', 'usingBrave', 'usingEdge', 'edgeSwitch', 'getBrave',
|
|
147
|
+
'statusRunning', 'statusSession', 'statusProvider', 'statusReady', 'statusUsing',
|
|
148
|
+
'browserListening', 'browserNone', 'sessionSignedIn', 'sessionNotSignedIn',
|
|
149
|
+
'sessionSignedInButSignInScreen', 'browserNoneFound',
|
|
150
|
+
'providerRegistered', 'providerMissing', 'readyWeb', 'readyApiFallback', 'readyApiOnly',
|
|
151
|
+
'savingsTitle', 'savingsEmpty', 'savingsEstimatedNote', 'savingsDetail',
|
|
152
|
+
'trustTitle', 'trustUsesSession', 'trustLocal', 'trustCounts',
|
|
153
|
+
'trustNoPassword', 'trustNoProxy', 'trustNoTelemetry',
|
|
154
|
+
'lastSentTitle', 'toolsOffered',
|
|
155
|
+
'buttonSignIn', 'buttonOpenChat', 'buttonCheck', 'buttonFresh', 'buttonShow', 'buttonNeedsBrowser',
|
|
156
|
+
'noteFirstRun',
|
|
157
|
+
'authorIntro', 'authorAsk', 'authorCloser',
|
|
158
|
+
'specTitle', 'specCpu', 'specNpu', 'specGpu', 'specRam',
|
|
159
|
+
'specStorage', 'specDisplay', 'specPorts', 'specPower',
|
|
160
|
+
'apiModelHint',
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
function localizedModels(t) {
|
|
164
|
+
const names = {
|
|
165
|
+
'deepseek-chat': ['modelChatName', 'modelChatDesc'],
|
|
166
|
+
'deepseek-chat-search': ['modelChatSearchName', 'modelChatSearchDesc'],
|
|
167
|
+
'deepseek-reasoner': ['modelReasonerName', 'modelReasonerDesc'],
|
|
168
|
+
'deepseek-reasoner-search': ['modelReasonerSearchName', 'modelReasonerSearchDesc'],
|
|
169
|
+
};
|
|
170
|
+
return MODELS.map((m) => {
|
|
171
|
+
const keys = names[m.id];
|
|
172
|
+
if (!keys) return m;
|
|
173
|
+
return { ...m, name: t(keys[0]), description: t(keys[1]) };
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const MODELS = [
|
|
178
|
+
{
|
|
179
|
+
provider: PROVIDER,
|
|
180
|
+
id: 'deepseek-chat',
|
|
181
|
+
name: 'DeepSeek Chat (web)',
|
|
182
|
+
description: 'Fast web chat. DeepThink off, Search off.',
|
|
183
|
+
inputModalities: ['text'],
|
|
184
|
+
switches: { deepThink: false, search: false },
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
provider: PROVIDER,
|
|
188
|
+
id: 'deepseek-chat-search',
|
|
189
|
+
name: 'DeepSeek Chat + Search (web)',
|
|
190
|
+
description: 'Fast web chat with live web search. DeepThink off, Search on.',
|
|
191
|
+
inputModalities: ['text'],
|
|
192
|
+
switches: { deepThink: false, search: true },
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
provider: PROVIDER,
|
|
196
|
+
id: 'deepseek-reasoner',
|
|
197
|
+
name: 'DeepSeek Reasoner (web)',
|
|
198
|
+
description: 'Thinks before answering. DeepThink on, Search off. Only the answer is forwarded.',
|
|
199
|
+
inputModalities: ['text'],
|
|
200
|
+
switches: { deepThink: true, search: false },
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
provider: PROVIDER,
|
|
204
|
+
id: 'deepseek-reasoner-search',
|
|
205
|
+
name: 'DeepSeek Reasoner + Search (web)',
|
|
206
|
+
description: 'Thinks, and reads the live web. DeepThink on, Search on.',
|
|
207
|
+
inputModalities: ['text'],
|
|
208
|
+
switches: { deepThink: true, search: true },
|
|
209
|
+
},
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
/** Resolve a model id to its switch states, falling back to the configured default model. */
|
|
213
|
+
function switchesFor(modelId, config) {
|
|
214
|
+
const found = MODELS.find((m) => m.id === modelId);
|
|
215
|
+
if (found) return found.switches;
|
|
216
|
+
return {
|
|
217
|
+
deepThink: config.deepThink === true,
|
|
218
|
+
search: config.search === true,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const CONTEXT_TOKENS = 64000;
|
|
223
|
+
|
|
224
|
+
/** A rough token count. Used only for usage reporting, never for routing or truncation. */
|
|
225
|
+
function estimateTokens(text) {
|
|
226
|
+
return Math.max(1, Math.round(String(text ?? '').length / 4));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function textOf(options) {
|
|
230
|
+
const parts = [];
|
|
231
|
+
if (options.system) parts.push(options.system);
|
|
232
|
+
for (const m of options.messages ?? []) {
|
|
233
|
+
const c = m?.content;
|
|
234
|
+
if (typeof c === 'string') parts.push(c);
|
|
235
|
+
else if (Array.isArray(c)) {
|
|
236
|
+
for (const b of c) if (b?.type === 'text' && typeof b.text === 'string') parts.push(b.text);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return parts.join('\n');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function apply(ctx, config = {}) {
|
|
243
|
+
const settings = {
|
|
244
|
+
port: Number(config.port) || 9222,
|
|
245
|
+
defaultModel: String(config.defaultModel || 'deepseek-chat'),
|
|
246
|
+
showBrowser: config.showBrowser !== false,
|
|
247
|
+
// Fallbacks used only for an unrecognised model id; the model list is the real control surface.
|
|
248
|
+
deepThink: config.deepThink === true,
|
|
249
|
+
search: config.search === true,
|
|
250
|
+
/**
|
|
251
|
+
* How the browser should present itself.
|
|
252
|
+
*
|
|
253
|
+
* 'headless' invisible. Chromium REFUSES headless on a profile a visible browser has used
|
|
254
|
+
* ("Multiple targets are not supported in headless mode"), so this needs a profile of
|
|
255
|
+
* its own - which means its own one-time sign-in. That trade is why the default is
|
|
256
|
+
* headless only for people who have completed that sign-in; the preflight decides.
|
|
257
|
+
* 'minimized' a real window, started minimised. Uses the existing profile and session, so no
|
|
258
|
+
* second login, and it still stays out of the way.
|
|
259
|
+
* 'visible' a normal window. Only useful for watching what the plugin is doing.
|
|
260
|
+
*/
|
|
261
|
+
// Headless is deliberately absent: CloudFront answers it with 403, measured with a clean profile and
|
|
262
|
+
// both the modern and legacy flags, so it can never work. Only the two windowed modes remain.
|
|
263
|
+
// The window is minimised after launch unless the person explicitly asks to watch it. There is no
|
|
264
|
+
// mode to choose: Brave is used when present and Edge otherwise, and headless is not possible.
|
|
265
|
+
autoMinimise: config.autoMinimise !== false,
|
|
266
|
+
// Whether the browser conversation continues turn to turn. The default is on; `false` makes every
|
|
267
|
+
// call start a fresh chat.
|
|
268
|
+
continueChat: config.continueChat !== false,
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// Captured here, while `llm` is definitely mounted on this context. The web-server route handler runs
|
|
272
|
+
// in a different context that cannot resolve `llm`, so it must not try to look the service up itself -
|
|
273
|
+
// that threw "cannot get required service llm in inactive context" and made every readiness check lie.
|
|
274
|
+
const llmService = ctx.get('llm') ?? ctx.llm;
|
|
275
|
+
|
|
276
|
+
// Browser launch state, shared with the route so the badge can say "starting" instead of "unavailable".
|
|
277
|
+
// `launching` distinguishes "not started yet" from "starting right now", which is the difference
|
|
278
|
+
// between a badge that looks broken and one that explains a ten-second pause.
|
|
279
|
+
let launching = false;
|
|
280
|
+
let browserLaunchError;
|
|
281
|
+
|
|
282
|
+
const session = new ChatSession({ port: settings.port, autoMinimise: settings.autoMinimise });
|
|
283
|
+
|
|
284
|
+
// Counts what the web route saved this session. In memory only: the figure is an estimate and should
|
|
285
|
+
// not survive a restart looking like a bill.
|
|
286
|
+
/**
|
|
287
|
+
* Durable plugin state. The language choice lives here so a restart cannot silently lose it - the bug
|
|
288
|
+
* that made the panel go back to English and answers come back in English.
|
|
289
|
+
*/
|
|
290
|
+
const prefsDir = profileDir();
|
|
291
|
+
const prefs = prefsDir ? new Prefs(prefsPath(prefsDir)) : null;
|
|
292
|
+
|
|
293
|
+
/** A language the reader picked in the panel; empty means follow the harness. */
|
|
294
|
+
let chosenLanguage = String((prefs && prefs.get('language')) || '');
|
|
295
|
+
if (chosenLanguage) {
|
|
296
|
+
try {
|
|
297
|
+
console.log(`[deepseek-web] language restored from the last session: ${chosenLanguage}`);
|
|
298
|
+
} catch {
|
|
299
|
+
/* a logger that throws must not stop activation */
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const locales = new Locales(new URL('./locale', import.meta.url).pathname.replace(/^\//, ''), config.language);
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* The translator for the language the harness is currently in.
|
|
307
|
+
*
|
|
308
|
+
* The harness owns the language choice - it has its own settings row for it - so this asks rather than
|
|
309
|
+
* decides. When the service is absent, or the language has no translation, English answers, because a
|
|
310
|
+
* panel in the wrong language is still usable and a panel of key names is not.
|
|
311
|
+
*/
|
|
312
|
+
function translator() {
|
|
313
|
+
if (chosenLanguage) {
|
|
314
|
+
// An override naming a language this package cannot speak falls through to the harness value rather
|
|
315
|
+
// than silently showing English under the wrong name.
|
|
316
|
+
if (locales.available().some((l) => l.code === chosenLanguage)) return locales.pick(chosenLanguage);
|
|
317
|
+
}
|
|
318
|
+
let active = '';
|
|
319
|
+
try {
|
|
320
|
+
const service = ctx.get('locale');
|
|
321
|
+
active = service?.getSnapshot?.()?.active ?? '';
|
|
322
|
+
} catch {
|
|
323
|
+
// No locale service on this profile; English it is.
|
|
324
|
+
}
|
|
325
|
+
return locales.pick(active);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Every string the panel draws, keyed as locale/en.json is keyed.
|
|
330
|
+
*
|
|
331
|
+
* Placeholders stay as %s on purpose: the values they stand for are runtime facts the panel already
|
|
332
|
+
* holds (the port, the character count), so it fills them and the host never has to format prose.
|
|
333
|
+
*/
|
|
334
|
+
function stringsForPanel() {
|
|
335
|
+
const t = translator();
|
|
336
|
+
const out = { metaTitle: t.meta('title'), metaDescription: t.meta('description') };
|
|
337
|
+
for (const key of PANEL_KEYS) out[key] = t(key);
|
|
338
|
+
return out;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const savings = new SavingsMeter({
|
|
342
|
+
inputPerMTok: Number(config.inputPricePerMTok) || undefined,
|
|
343
|
+
outputPerMTok: Number(config.outputPricePerMTok) || undefined,
|
|
344
|
+
currency: config.currency ? String(config.currency) : undefined,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Count one completed web call toward the session saving.
|
|
349
|
+
*
|
|
350
|
+
* Only called where a reply actually came back. A failed call, or one the API served, contributes
|
|
351
|
+
* nothing - counting those would inflate the number the product is sold on.
|
|
352
|
+
*/
|
|
353
|
+
let lastRecordedAt = 0;
|
|
354
|
+
/** The most recent text typed into the browser, for the "what am I sending" panel. */
|
|
355
|
+
let lastPromptPreview = null;
|
|
356
|
+
function recordWebCall(inputText, outputText) {
|
|
357
|
+
// The three usage sites pass their own numbers in; this guards against a double count when a
|
|
358
|
+
// single turn emits usage more than once.
|
|
359
|
+
const now = Date.now();
|
|
360
|
+
if (now - lastRecordedAt < 50) return;
|
|
361
|
+
lastRecordedAt = now;
|
|
362
|
+
savings.record({
|
|
363
|
+
inputTokens: estimateTokensForSavings(inputText),
|
|
364
|
+
outputTokens: estimateTokensForSavings(outputText),
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const adapter = {
|
|
369
|
+
providerInfo(provider) {
|
|
370
|
+
return { id: provider || PROVIDER, name: 'DeepSeek (web chat)' };
|
|
371
|
+
},
|
|
372
|
+
|
|
373
|
+
providerRetryPolicy() {
|
|
374
|
+
// The browser path fails in ways an HTTP retry cannot fix (a closed tab, a signed-out session),
|
|
375
|
+
// so retries are shallow and slow rather than aggressive.
|
|
376
|
+
return {
|
|
377
|
+
mode: 'normal',
|
|
378
|
+
maxRetries: 1,
|
|
379
|
+
retryableCodes: ['NO_FIRST_TOKEN'],
|
|
380
|
+
initialDelayMs: 1200,
|
|
381
|
+
maxDelayMs: 4000,
|
|
382
|
+
jitterRatio: 0.25,
|
|
383
|
+
};
|
|
384
|
+
},
|
|
385
|
+
|
|
386
|
+
imageRequestPricing() {
|
|
387
|
+
return undefined;
|
|
388
|
+
},
|
|
389
|
+
|
|
390
|
+
imageRequestTarget() {
|
|
391
|
+
const err = new Error('deepseek-web accepts text only; it cannot carry images');
|
|
392
|
+
err.code = 'UNSUPPORTED_IMAGE';
|
|
393
|
+
throw err;
|
|
394
|
+
},
|
|
395
|
+
|
|
396
|
+
async listModels() {
|
|
397
|
+
// Rebuilt per call so the picker speaks the language the harness is in now, not the one that was
|
|
398
|
+
// active when the plugin loaded.
|
|
399
|
+
return localizedModels(translator());
|
|
400
|
+
},
|
|
401
|
+
|
|
402
|
+
async resolveModel(provider, model) {
|
|
403
|
+
const known = MODELS.find((m) => m.id === model) ?? MODELS[0];
|
|
404
|
+
return {
|
|
405
|
+
...known,
|
|
406
|
+
provider: provider || PROVIDER,
|
|
407
|
+
model: known.id,
|
|
408
|
+
id: known.id,
|
|
409
|
+
context: { contextWindow: CONTEXT_TOKENS },
|
|
410
|
+
defaultMaxTokens: 8192,
|
|
411
|
+
};
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
async prepareCall(provider, model) {
|
|
415
|
+
const resolved = await adapter.resolveModel(provider, model);
|
|
416
|
+
return {
|
|
417
|
+
model: resolved,
|
|
418
|
+
stream: (options) => adapter.stream(options),
|
|
419
|
+
};
|
|
420
|
+
},
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Stream one call.
|
|
424
|
+
*
|
|
425
|
+
* The shape of this generator is fixed by the harness contract: a block must be opened before any
|
|
426
|
+
* delta refers to it, and exactly one terminal `finish` chunk must end the stream. Everything
|
|
427
|
+
* between those two points is the browser's problem, translated into text deltas.
|
|
428
|
+
*/
|
|
429
|
+
async *stream(options) {
|
|
430
|
+
/*
|
|
431
|
+
* Continue the browser chat rather than restarting it.
|
|
432
|
+
*
|
|
433
|
+
* This is what makes the WEB route useful: the conversation the person watches in the harness is
|
|
434
|
+
* the conversation in the browser tab, and it keeps its own memory turn to turn. Resetting on every
|
|
435
|
+
* call meant the whole transcript was re-typed into a fresh chat each time, which looked like the
|
|
436
|
+
* harness "sending everything twice" and cost the browser a full re-read per message.
|
|
437
|
+
*
|
|
438
|
+
* A call with no prior user turn cannot continue anything, so it starts clean either way.
|
|
439
|
+
*/
|
|
440
|
+
const continueChat = settings.continueChat !== false;
|
|
441
|
+
/*
|
|
442
|
+
* The language the panel is in, so the page knows what language to answer in.
|
|
443
|
+
*
|
|
444
|
+
* The NAME is the English one: an instruction reading "answer in Spanish" is unambiguous where
|
|
445
|
+
* "answer in Español" asks the model to translate the instruction before following it. English
|
|
446
|
+
* itself is dropped inside buildPrompt, because an English reader needs no telling.
|
|
447
|
+
*/
|
|
448
|
+
const prompt = buildPrompt({ ...options, continueChat, answerIn: translator().languageNameInEnglish });
|
|
449
|
+
const model = options.model || settings.defaultModel;
|
|
450
|
+
|
|
451
|
+
if (!prompt.text) {
|
|
452
|
+
yield {
|
|
453
|
+
type: 'finish',
|
|
454
|
+
reason: { kind: 'error', failure: { message: 'empty prompt', code: 'EMPTY_PROMPT' } },
|
|
455
|
+
};
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const wantTools = Array.isArray(options.tools) && options.tools.length > 0;
|
|
460
|
+
const switches = switchesFor(model, settings);
|
|
461
|
+
// A search answer is delivered as two blocks: what was searched and where it looked, then the
|
|
462
|
+
// answer. That means the text cannot stream while search is on - the sources are only readable
|
|
463
|
+
// once the answer settles - so search calls buffer and plain calls still stream token by token.
|
|
464
|
+
const wantSearch = switches.search === true;
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Internal calls get their own slot, never the person's chat.
|
|
468
|
+
*
|
|
469
|
+
* The harness asks the model to summarise a session for its title and to compact long history.
|
|
470
|
+
* Those requests carry `purpose`, and routing them into the same browser chat is what produced odd
|
|
471
|
+
* extra messages there: a chat titled "hello" would suddenly contain a request to summarise the
|
|
472
|
+
* conversation. They are short, self-contained, and must not continue anything, so each purpose
|
|
473
|
+
* gets a key of its own and therefore a fresh chat.
|
|
474
|
+
*/
|
|
475
|
+
const purpose = options.purpose;
|
|
476
|
+
const conversationKey = purpose
|
|
477
|
+
? `purpose:${purpose}`
|
|
478
|
+
: String(options.sessionId ?? 'unattributed');
|
|
479
|
+
|
|
480
|
+
// Tool calls need the whole transcript in view, because the reply must be checked for JSON that
|
|
481
|
+
// names a tool. A continuing chat would only see the newest turn, so those calls start clean.
|
|
482
|
+
// A purpose call never continues: it asks its own question and must not inherit a conversation.
|
|
483
|
+
const effectiveContinue = !purpose && prompt.continuing && !wantTools;
|
|
484
|
+
let sources = { domains: [], links: [] };
|
|
485
|
+
|
|
486
|
+
let assembled = '';
|
|
487
|
+
// `emitted` must match the concatenation of every delta in the block, including the route label, or
|
|
488
|
+
// the committed block-end text disagrees with what streamed and the harness rightly rejects it.
|
|
489
|
+
let emitted = '';
|
|
490
|
+
/*
|
|
491
|
+
* How far into `assembled` has already been shown.
|
|
492
|
+
*
|
|
493
|
+
* The old code decided once, from the reply's FIRST character, whether to stream or to buffer. That is
|
|
494
|
+
* how the tool envelope reached the screen: this reply opened with prose, so it was streamed whole, and
|
|
495
|
+
* the JSON arrived on the last chunk where scrubbing was too late. Position is tracked instead -
|
|
496
|
+
* everything up to the first brace that could still become a call is shown, the rest waits.
|
|
497
|
+
*/
|
|
498
|
+
let shown = 0;
|
|
499
|
+
|
|
500
|
+
/*
|
|
501
|
+
* Name the web route in the answer itself.
|
|
502
|
+
*
|
|
503
|
+
* A badge beside the composer has to be remembered and looked for; a name above the reply is read
|
|
504
|
+
* whether the person meant to check or not. Since nobody is ever quite sure which engine answered,
|
|
505
|
+
* the answer says so: a red dot and "Uncle Sam" precede every WEB reply, and API replies carry
|
|
506
|
+
* nothing, so the label's presence IS the signal.
|
|
507
|
+
*
|
|
508
|
+
* The red dot is the joke. "Uncle Sam" alone would read as a claim of authority; a red light beside
|
|
509
|
+
* it reads as "this one came from the other side, and we both know it". It goes in as plain text at
|
|
510
|
+
* the head of the first block rather than as UI decoration, because a transcript that is copied,
|
|
511
|
+
* exported or read back later must still show which route produced it.
|
|
512
|
+
*/
|
|
513
|
+
// Internal calls (session titles, compaction) carry no label: nothing shows their text to a person,
|
|
514
|
+
// and a title that began "Uncle Sam" would end up in the sidebar.
|
|
515
|
+
const WEB_LABEL = purpose ? '' : '\u{1F534} Uncle Sam\n\n';
|
|
516
|
+
|
|
517
|
+
try {
|
|
518
|
+
if (!wantSearch) {
|
|
519
|
+
yield { type: 'block-start', index: 0, blockType: 'text' };
|
|
520
|
+
/*
|
|
521
|
+
* The label is NOT emitted here.
|
|
522
|
+
*
|
|
523
|
+
* It used to be, before anything was known about the answer. When the page returned nothing at
|
|
524
|
+
* all, the person was left with "🔴 Uncle Sam" and no words after it - recorded on screen, and it
|
|
525
|
+
* reads as the plugin failing rather than the page failing. It now goes out with the first real
|
|
526
|
+
* text, so a reply can never be a bare signature.
|
|
527
|
+
*/
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Keep a bounded copy for the panel: enough to recognise what was sent, not the whole document.
|
|
531
|
+
lastPromptPreview = {
|
|
532
|
+
chars: prompt.text.length,
|
|
533
|
+
text: prompt.text.length > 2000 ? prompt.text.slice(0, 2000) + '\u2026' : prompt.text,
|
|
534
|
+
at: Date.now(),
|
|
535
|
+
tools: Array.isArray(options.tools) ? options.tools.length : 0,
|
|
536
|
+
// The tool preamble on its own, so a person can see exactly what the page was told about tools
|
|
537
|
+
// without hunting for it inside the message. A prompt without a blank line has no separate
|
|
538
|
+
// preamble, so it is reported in full rather than with its last character cut off.
|
|
539
|
+
preamble:
|
|
540
|
+
prompt.text.includes('Tools: ')
|
|
541
|
+
? prompt.text.slice(0, Math.max(0, prompt.text.indexOf('\n\n')))
|
|
542
|
+
: prompt.text.slice(0, 400),
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
for await (const chunk of session.ask(prompt.text, {
|
|
546
|
+
signal: options.signal,
|
|
547
|
+
deepThink: switches.deepThink,
|
|
548
|
+
search: switches.search,
|
|
549
|
+
// Pass the decision down so the engine does not reset the page.
|
|
550
|
+
continueChat: effectiveContinue,
|
|
551
|
+
// Which harness conversation the browser chat belongs to; a change resets it.
|
|
552
|
+
conversationKey,
|
|
553
|
+
})) {
|
|
554
|
+
if (chunk.done) {
|
|
555
|
+
assembled = chunk.text;
|
|
556
|
+
if (chunk.sources) sources = chunk.sources;
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
if (!chunk.text) continue;
|
|
560
|
+
assembled += chunk.text;
|
|
561
|
+
|
|
562
|
+
if (wantSearch) {
|
|
563
|
+
// held for ordering; nothing is emitted until the sources are known
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/*
|
|
568
|
+
* Show only what cannot become a tool call.
|
|
569
|
+
*
|
|
570
|
+
* `streamSafeCut` answers "how far is safe right now" - up to the first brace that might still
|
|
571
|
+
* grow into {"tool":...}. Ordinary prose passes straight through, braces included, so the stream
|
|
572
|
+
* stays live; it simply stops one brace early when a call might be starting.
|
|
573
|
+
*/
|
|
574
|
+
const safe = streamSafeCut(assembled);
|
|
575
|
+
if (safe > shown) {
|
|
576
|
+
const out = assembled.slice(shown, safe);
|
|
577
|
+
shown = safe;
|
|
578
|
+
if (!emitted) {
|
|
579
|
+
// First real text: the label goes out with it, never on its own.
|
|
580
|
+
emitted += WEB_LABEL;
|
|
581
|
+
yield { type: 'text-delta', index: 0, text: WEB_LABEL };
|
|
582
|
+
}
|
|
583
|
+
emitted += out;
|
|
584
|
+
yield { type: 'text-delta', index: 0, text: out };
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Search answers are emitted as two visible blocks so the transcript shows BOTH what was asked
|
|
589
|
+
// of the web and what came back, before the answer that used it.
|
|
590
|
+
if (wantSearch) {
|
|
591
|
+
/*
|
|
592
|
+
* The query is the person's newest message - and ONLY the person's.
|
|
593
|
+
*
|
|
594
|
+
* The harness files its own notices under `role: 'user'`: time context, runtime context, the
|
|
595
|
+
* "[model changed: …]" line written when the model is switched mid-conversation, and more.
|
|
596
|
+
* Taking the newest `role: 'user'` entry therefore took whatever machinery was written last.
|
|
597
|
+
* Recorded: the person switched from Web Chat to Web Reasoner+Search and the search query became
|
|
598
|
+
* "[model changed: assistant turns above this point were generated by …]", so the search found
|
|
599
|
+
* nothing and the answer was written from memory instead of from the web.
|
|
600
|
+
*/
|
|
601
|
+
const isFromPerson = (m) => {
|
|
602
|
+
if (m?.role !== 'user') return false;
|
|
603
|
+
const kind = m?.source?.kind;
|
|
604
|
+
return typeof kind !== 'string' || kind === 'user';
|
|
605
|
+
};
|
|
606
|
+
const asked = [...(options.messages ?? [])].reverse().find(isFromPerson);
|
|
607
|
+
const query = messageText(asked).replace(/\s+/g, ' ').trim().slice(0, 240);
|
|
608
|
+
const domains = (sources?.domains ?? []).slice(0, 12);
|
|
609
|
+
const links = (sources?.links ?? []).slice(0, 5);
|
|
610
|
+
|
|
611
|
+
/*
|
|
612
|
+
* A search reply must not lie about what backing it had.
|
|
613
|
+
*
|
|
614
|
+
* Search was ON, so the person paid for a web lookup and expects a web-backed answer. When the
|
|
615
|
+
* page reports no sources the answer is written from the model's own memory, and saying so is
|
|
616
|
+
* the difference between an honest answer and one that looks researched. Recorded: an answer
|
|
617
|
+
* presented under a "Web search" heading that had used no search results at all.
|
|
618
|
+
*/
|
|
619
|
+
const lines = [`Web search — "${query}"`];
|
|
620
|
+
lines.push(
|
|
621
|
+
domains.length
|
|
622
|
+
? `Sources (${domains.length}): ${domains.join(' · ')}`
|
|
623
|
+
: 'Sources: none — this answer comes from the model\'s own memory, not from a web search',
|
|
624
|
+
);
|
|
625
|
+
if (links.length) lines.push(`Links: ${links.join(' ')}`);
|
|
626
|
+
const summary = lines.join('\n');
|
|
627
|
+
|
|
628
|
+
// The search summary opens the reply, so the route label belongs at its head. The answer block
|
|
629
|
+
// below is then plain: one name per reply, not one per block.
|
|
630
|
+
const summaryWithLabel = WEB_LABEL + summary;
|
|
631
|
+
yield { type: 'block-start', index: 0, blockType: 'text' };
|
|
632
|
+
yield { type: 'text-delta', index: 0, text: summaryWithLabel };
|
|
633
|
+
yield { type: 'block-end', index: 0, block: { type: 'text', text: summaryWithLabel } };
|
|
634
|
+
|
|
635
|
+
const answerText = stripToolCall(assembled);
|
|
636
|
+
yield { type: 'block-start', index: 1, blockType: 'text' };
|
|
637
|
+
if (answerText) yield { type: 'text-delta', index: 1, text: answerText };
|
|
638
|
+
yield { type: 'block-end', index: 1, block: { type: 'text', text: answerText } };
|
|
639
|
+
|
|
640
|
+
/*
|
|
641
|
+
* Search and tools are not mutually exclusive. Before this, a reply that carried both ended
|
|
642
|
+
* here without ever being parsed, so a raw tool envelope landed in the answer block. The call
|
|
643
|
+
* block is emitted after the answer, exactly as the plain path does.
|
|
644
|
+
*/
|
|
645
|
+
if (wantTools) {
|
|
646
|
+
const call = parseToolCall(assembled);
|
|
647
|
+
if (call) {
|
|
648
|
+
const callId = `webcall_${Date.now().toString(36)}`;
|
|
649
|
+
yield { type: 'block-start', index: 2, blockType: 'tool-call' };
|
|
650
|
+
yield {
|
|
651
|
+
type: 'tool-call-delta',
|
|
652
|
+
index: 2,
|
|
653
|
+
id: callId,
|
|
654
|
+
name: call.name,
|
|
655
|
+
argumentsDelta: call.arguments,
|
|
656
|
+
};
|
|
657
|
+
yield {
|
|
658
|
+
type: 'block-end',
|
|
659
|
+
index: 2,
|
|
660
|
+
block: { type: 'tool-call', id: callId, name: call.name, arguments: call.arguments },
|
|
661
|
+
};
|
|
662
|
+
recordWebCall(prompt.text, answerText);
|
|
663
|
+
yield {
|
|
664
|
+
type: 'usage',
|
|
665
|
+
usage: {
|
|
666
|
+
inputTokens: estimateTokens(prompt.text),
|
|
667
|
+
outputTokens: estimateTokens(assembled),
|
|
668
|
+
totalTokens: estimateTokens(prompt.text) + estimateTokens(assembled),
|
|
669
|
+
},
|
|
670
|
+
};
|
|
671
|
+
yield { type: 'finish', reason: { kind: 'tool-calls' } };
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
recordWebCall(prompt.text, answerText);
|
|
677
|
+
yield {
|
|
678
|
+
type: 'usage',
|
|
679
|
+
usage: {
|
|
680
|
+
inputTokens: estimateTokens(prompt.text),
|
|
681
|
+
outputTokens: estimateTokens(assembled),
|
|
682
|
+
totalTokens: estimateTokens(prompt.text) + estimateTokens(assembled),
|
|
683
|
+
},
|
|
684
|
+
};
|
|
685
|
+
yield { type: 'finish', reason: { kind: 'stop' } };
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if (wantTools) {
|
|
690
|
+
const call = parseToolCall(assembled);
|
|
691
|
+
if (call) {
|
|
692
|
+
// A tool call was made. Any prose the model wrapped around it belongs in the text block;
|
|
693
|
+
// the JSON itself must not reach the transcript.
|
|
694
|
+
/*
|
|
695
|
+
* Emit only the part of the prose that has not streamed yet.
|
|
696
|
+
*
|
|
697
|
+
* `emitted` includes the route label, so comparing the prose against it used to fail for every
|
|
698
|
+
* turn and the WHOLE prose was appended a second time - the streamed prefix appeared twice in
|
|
699
|
+
* the transcript. The streamed part is exactly the safe prefix `assembled.slice(0, shown)`,
|
|
700
|
+
* which stripping never touches, so the unseen remainder is simply everything after `shown`.
|
|
701
|
+
*/
|
|
702
|
+
/*
|
|
703
|
+
* Scrub ONLY the held portion, and never index into the whole reply.
|
|
704
|
+
*
|
|
705
|
+
* The previous expression was `prose.slice(shown)`, where `prose` is the SCRUBBED WHOLE reply and
|
|
706
|
+
* `shown` is an offset into the UNSCRUBBED one. `stripToolCall` ends with `.trim()`, so a reply
|
|
707
|
+
* that began with whitespace is shorter than its offset - and the tail then started mid-word.
|
|
708
|
+
* Measured on four shapes:
|
|
709
|
+
*
|
|
710
|
+
* "\n\nHi. {…} more text here" became "Hi. ore text here"
|
|
711
|
+
* " Hello there. {…} and then" became "Hello there. nd then"
|
|
712
|
+
* "\n\n{…} trailing words" became "ailing words"
|
|
713
|
+
*
|
|
714
|
+
* Characters were silently deleted from the person's answer whenever the model prefixed prose
|
|
715
|
+
* with a newline - which it does constantly. Scrubbing the held slice keeps every offset inside
|
|
716
|
+
* one string, so the two numbers can never disagree.
|
|
717
|
+
*/
|
|
718
|
+
/*
|
|
719
|
+
* The label goes out even when there is no prose, because a call-only turn has to say which
|
|
720
|
+
* route made the call.
|
|
721
|
+
*
|
|
722
|
+
* MEASURED: three live cases in a row produced a bare tool call and no text, so `emitted` stayed
|
|
723
|
+
* empty, the label was never written, and the turn carried no signature at all. That breaks the
|
|
724
|
+
* one rule this label exists for - the person must always be able to tell the web route from the
|
|
725
|
+
* API route. A tool call is not an empty reply: something happened, so naming the route above it
|
|
726
|
+
* is information rather than noise. (An empty reply with no call is a different case and still
|
|
727
|
+
* fails with an error, unlabelled, because there is nothing to sign.)
|
|
728
|
+
*/
|
|
729
|
+
if (!emitted) {
|
|
730
|
+
emitted += WEB_LABEL;
|
|
731
|
+
yield { type: 'text-delta', index: 0, text: WEB_LABEL };
|
|
732
|
+
}
|
|
733
|
+
const tail = stripToolCall(assembled.slice(shown));
|
|
734
|
+
if (tail) {
|
|
735
|
+
emitted += tail;
|
|
736
|
+
yield { type: 'text-delta', index: 0, text: tail };
|
|
737
|
+
}
|
|
738
|
+
yield { type: 'block-end', index: 0, block: { type: 'text', text: emitted } };
|
|
739
|
+
|
|
740
|
+
const callId = `webcall_${Date.now().toString(36)}`;
|
|
741
|
+
yield { type: 'block-start', index: 1, blockType: 'tool-call' };
|
|
742
|
+
yield {
|
|
743
|
+
type: 'tool-call-delta',
|
|
744
|
+
index: 1,
|
|
745
|
+
id: callId,
|
|
746
|
+
name: call.name,
|
|
747
|
+
argumentsDelta: call.arguments,
|
|
748
|
+
};
|
|
749
|
+
yield {
|
|
750
|
+
type: 'block-end',
|
|
751
|
+
index: 1,
|
|
752
|
+
block: { type: 'tool-call', id: callId, name: call.name, arguments: call.arguments },
|
|
753
|
+
};
|
|
754
|
+
recordWebCall(prompt.text, emitted);
|
|
755
|
+
yield {
|
|
756
|
+
type: 'usage',
|
|
757
|
+
usage: {
|
|
758
|
+
inputTokens: estimateTokens(prompt.text),
|
|
759
|
+
outputTokens: estimateTokens(assembled),
|
|
760
|
+
totalTokens: estimateTokens(prompt.text) + estimateTokens(assembled),
|
|
761
|
+
},
|
|
762
|
+
};
|
|
763
|
+
yield { type: 'finish', reason: { kind: 'tool-calls' } };
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/*
|
|
769
|
+
* Release whatever was held, scrubbed exactly once.
|
|
770
|
+
*
|
|
771
|
+
* Only the held portion needs scrubbing: what was already shown contains no tool object, because the
|
|
772
|
+
* scanner stopped one brace short of any. Scrubbing the tail and appending it keeps the committed
|
|
773
|
+
* block equal to the deltas that were yielded, which the harness checks.
|
|
774
|
+
*/
|
|
775
|
+
const held = stripToolCall(assembled.slice(shown));
|
|
776
|
+
if (held) {
|
|
777
|
+
if (!emitted) {
|
|
778
|
+
emitted += WEB_LABEL;
|
|
779
|
+
yield { type: 'text-delta', index: 0, text: WEB_LABEL };
|
|
780
|
+
}
|
|
781
|
+
emitted += held;
|
|
782
|
+
yield { type: 'text-delta', index: 0, text: held };
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/*
|
|
786
|
+
* An empty answer is a fault, not a reply.
|
|
787
|
+
*
|
|
788
|
+
* The page does occasionally return nothing. Because the label used to be emitted up front, the
|
|
789
|
+
* person saw "🔴 Uncle Sam" with no words and no explanation. Nothing is emitted now, and the turn
|
|
790
|
+
* ends as an error, which is what actually happened.
|
|
791
|
+
*/
|
|
792
|
+
if (!assembled.trim()) {
|
|
793
|
+
yield { type: 'block-end', index: 0, block: { type: 'text', text: emitted } };
|
|
794
|
+
yield {
|
|
795
|
+
type: 'finish',
|
|
796
|
+
reason: {
|
|
797
|
+
kind: 'error',
|
|
798
|
+
failure: {
|
|
799
|
+
message: 'the browser page returned an empty answer — send it again, or start a fresh chat',
|
|
800
|
+
code: 'WEB_CHAT_EMPTY',
|
|
801
|
+
},
|
|
802
|
+
},
|
|
803
|
+
};
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/*
|
|
808
|
+
* An identical repeat is a fault too.
|
|
809
|
+
*
|
|
810
|
+
* The page sometimes serves the previous answer again - a stuck generation rather than a new one.
|
|
811
|
+
* Showing it twice teaches the person that this plugin repeats itself, so it is reported instead.
|
|
812
|
+
* Two guards keep this from firing on honest traffic: the answer must be long (an "ok" twice is
|
|
813
|
+
* plausible, a paragraph twice is not), and it must arrive within a minute of the previous one
|
|
814
|
+
* (the same question asked an hour later, answered the same way, is a correct answer).
|
|
815
|
+
*/
|
|
816
|
+
const answerSignature = assembled.replace(/\s+/g, ' ').trim();
|
|
817
|
+
const previous = lastAnswer.get(conversationKey);
|
|
818
|
+
lastAnswer.set(conversationKey, { text: answerSignature, at: Date.now() });
|
|
819
|
+
if (
|
|
820
|
+
previous &&
|
|
821
|
+
previous.text === answerSignature &&
|
|
822
|
+
answerSignature.length > 40 &&
|
|
823
|
+
Date.now() - previous.at < 60_000
|
|
824
|
+
) {
|
|
825
|
+
yield { type: 'block-end', index: 0, block: { type: 'text', text: emitted } };
|
|
826
|
+
yield {
|
|
827
|
+
type: 'finish',
|
|
828
|
+
reason: {
|
|
829
|
+
kind: 'error',
|
|
830
|
+
failure: {
|
|
831
|
+
message:
|
|
832
|
+
'the browser page repeated its previous answer word for word — start a fresh chat and ask again',
|
|
833
|
+
code: 'WEB_CHAT_REPEATED',
|
|
834
|
+
},
|
|
835
|
+
},
|
|
836
|
+
};
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
yield { type: 'block-end', index: 0, block: { type: 'text', text: emitted } };
|
|
841
|
+
recordWebCall(prompt.text, emitted);
|
|
842
|
+
yield {
|
|
843
|
+
type: 'usage',
|
|
844
|
+
usage: {
|
|
845
|
+
inputTokens: estimateTokens(prompt.text),
|
|
846
|
+
outputTokens: estimateTokens(assembled),
|
|
847
|
+
totalTokens: estimateTokens(prompt.text) + estimateTokens(assembled),
|
|
848
|
+
},
|
|
849
|
+
};
|
|
850
|
+
yield { type: 'finish', reason: { kind: 'stop' } };
|
|
851
|
+
} catch (err) {
|
|
852
|
+
const aborted = err?.code === 'ABORTED' || err?.name === 'AbortError';
|
|
853
|
+
if (!aborted && emitted) {
|
|
854
|
+
yield { type: 'block-end', index: 0, block: { type: 'text', text: emitted } };
|
|
855
|
+
}
|
|
856
|
+
yield {
|
|
857
|
+
type: 'finish',
|
|
858
|
+
reason: aborted
|
|
859
|
+
? { kind: 'aborted', failure: { message: 'cancelled', code: 'ABORTED' } }
|
|
860
|
+
: {
|
|
861
|
+
kind: 'error',
|
|
862
|
+
failure: {
|
|
863
|
+
message: err?.message ?? String(err),
|
|
864
|
+
code: err?.code ?? (err instanceof WebChatError ? 'WEB_CHAT' : 'WEB_CHAT_UNKNOWN'),
|
|
865
|
+
},
|
|
866
|
+
},
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
},
|
|
870
|
+
};
|
|
871
|
+
|
|
872
|
+
const registration = ctx.llm.registerAdapter([PROVIDER], adapter);
|
|
873
|
+
|
|
874
|
+
// ---------------------------------------------------------------- route switch
|
|
875
|
+
//
|
|
876
|
+
// The route is optional: a profile without the web server still gets a working provider, and the
|
|
877
|
+
// switch simply does not appear. That keeps a deployment without the Web app from failing to mount.
|
|
878
|
+
let routeDisposer = null;
|
|
879
|
+
// Declared alongside routeDisposer, not inside the block that registers it: the cleanup closure below
|
|
880
|
+
// lives outside that block, and a `let` inside it would be invisible here - a ReferenceError on
|
|
881
|
+
// dispose, which is exactly when a cleanup bug is least welcome.
|
|
882
|
+
let setupDisposer = null;
|
|
883
|
+
const defaultModel = ctx.get('agentDefaultModel');
|
|
884
|
+
const sessionController = ctx.get('sessionController');
|
|
885
|
+
const server = ctx.get('webServer');
|
|
886
|
+
|
|
887
|
+
// A missing service used to be invisible; now it is reported in every reply so the cause of a switch
|
|
888
|
+
// that does nothing is on screen instead of inferred. `missing` is what the route echoes back.
|
|
889
|
+
const missing = [];
|
|
890
|
+
if (!defaultModel) missing.push('agentDefaultModel');
|
|
891
|
+
if (!sessionController) missing.push('sessionController');
|
|
892
|
+
if (!server) missing.push('webServer');
|
|
893
|
+
if (missing.length) {
|
|
894
|
+
try {
|
|
895
|
+
console.error(`[deepseek-web] Host services unavailable: ${missing.join(', ')}`);
|
|
896
|
+
} catch {
|
|
897
|
+
/* a logger that throws must not stop activation */
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* Apply a selection to the session the person is looking at, not just to future ones.
|
|
903
|
+
*
|
|
904
|
+
* `agentDefaultModel` changes what a NEW session starts with; the open conversation keeps the model
|
|
905
|
+
* it began on. That is why flipping the switch used to leave the current chat answering from the API
|
|
906
|
+
* while the badge said WEB - the switch was correct and the conversation was simply not listening.
|
|
907
|
+
* `sessionController.selectModel` is the call that changes the live session.
|
|
908
|
+
*
|
|
909
|
+
* A missing sessionId is not a failure: it means no conversation is open, and saving the default is
|
|
910
|
+
* all that can be done.
|
|
911
|
+
*/
|
|
912
|
+
async function applyToSession(sessionId, selection) {
|
|
913
|
+
if (!sessionId || !sessionController?.selectModel) return { applied: false, reason: 'no session' };
|
|
914
|
+
try {
|
|
915
|
+
const result = await sessionController.selectModel({
|
|
916
|
+
sessionId,
|
|
917
|
+
provider: selection.provider,
|
|
918
|
+
model: selection.model,
|
|
919
|
+
...(selection.reasoningEffort ? { reasoningEffort: selection.reasoningEffort } : {}),
|
|
920
|
+
});
|
|
921
|
+
return { applied: true, selected: result?.selected ?? selection };
|
|
922
|
+
} catch (err) {
|
|
923
|
+
return { applied: false, reason: err?.message ?? String(err) };
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
if (defaultModel && server) {
|
|
928
|
+
const handler = async (req, res) => {
|
|
929
|
+
try {
|
|
930
|
+
const current = defaultModel.currentSelection();
|
|
931
|
+
const onWeb = selectionIsWeb(current);
|
|
932
|
+
if (!onWeb && current?.provider && current?.model) {
|
|
933
|
+
// remember where "API" should return to; only meaningful while we are NOT on web
|
|
934
|
+
if (current.provider !== PROVIDER) lastApiSelection = { ...current };
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Two independent facts decide whether "WEB" is honest:
|
|
939
|
+
*
|
|
940
|
+
* 1. the adapter is registered, and
|
|
941
|
+
* 2. a browser is actually reachable to drive.
|
|
942
|
+
*
|
|
943
|
+
* Only checking (1) was not enough. The provider registers whenever the plugin mounts, so the
|
|
944
|
+
* badge read WEB while Brave was closed and no page could possibly answer - and the reply that
|
|
945
|
+
* arrived came from the API instead. A registered adapter with nothing behind it accepts a call
|
|
946
|
+
* and fails, and a fallback then bills the wrong route quietly.
|
|
947
|
+
*
|
|
948
|
+
* So the browser is probed directly here. `webReady` means "this can work right now", not
|
|
949
|
+
* "this was configured once".
|
|
950
|
+
*/
|
|
951
|
+
let adapterRegistered = false;
|
|
952
|
+
let providerProbeError;
|
|
953
|
+
try {
|
|
954
|
+
// `ctx` here is the web-server's context, which cannot see `llm` - reading it inside the
|
|
955
|
+
// handler throws "cannot get required service \"llm\" in inactive context". The service is
|
|
956
|
+
// therefore captured once at apply() time, where it is definitely in hand.
|
|
957
|
+
const llm = llmService ?? ctx.llm;
|
|
958
|
+
const providers = llm?.listProviders?.() ?? [];
|
|
959
|
+
adapterRegistered = providers.some((p) => (p?.id ?? p?.provider) === PROVIDER);
|
|
960
|
+
if (!providers.length) providerProbeError = 'the llm service reported no providers';
|
|
961
|
+
} catch (err) {
|
|
962
|
+
adapterRegistered = false;
|
|
963
|
+
providerProbeError = err?.message ?? String(err);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
let browserReady = false;
|
|
967
|
+
let browserReason;
|
|
968
|
+
try {
|
|
969
|
+
const res2 = await fetch(`http://127.0.0.1:${settings.port}/json/version`, {
|
|
970
|
+
signal: AbortSignal.timeout(1200),
|
|
971
|
+
});
|
|
972
|
+
browserReady = res2.ok;
|
|
973
|
+
if (!browserReady) browserReason = `the browser answered ${res2.status}`;
|
|
974
|
+
} catch {
|
|
975
|
+
browserReady = false;
|
|
976
|
+
browserReason = `no browser is listening on port ${settings.port}`;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
const webReady = adapterRegistered && browserReady;
|
|
980
|
+
const webReadyReason = !adapterRegistered
|
|
981
|
+
? 'the deepseek-web provider is not registered'
|
|
982
|
+
: browserReady
|
|
983
|
+
? undefined
|
|
984
|
+
: launching
|
|
985
|
+
? 'starting the browser…'
|
|
986
|
+
: browserLaunchError
|
|
987
|
+
? `the browser could not start: ${browserLaunchError}`
|
|
988
|
+
: `${browserReason} — click WEB to start it`;
|
|
989
|
+
|
|
990
|
+
// A conversation configured for web while it cannot actually work is NOT on web, whatever the
|
|
991
|
+
// setting says. Reporting it as `web` is what made the badge lie.
|
|
992
|
+
const effectiveRoute = onWeb && webReady ? 'web' : 'api';
|
|
993
|
+
|
|
994
|
+
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
995
|
+
json(res, 200, {
|
|
996
|
+
route: effectiveRoute,
|
|
997
|
+
configuredRoute: onWeb ? 'web' : 'api',
|
|
998
|
+
webReady,
|
|
999
|
+
adapterRegistered,
|
|
1000
|
+
browserReady,
|
|
1001
|
+
launching,
|
|
1002
|
+
browserLaunchError,
|
|
1003
|
+
providerProbeError,
|
|
1004
|
+
webReadyReason,
|
|
1005
|
+
selection: current,
|
|
1006
|
+
apiSelection: lastApiSelection,
|
|
1007
|
+
webSelection: WEB_SELECTION,
|
|
1008
|
+
missing,
|
|
1009
|
+
});
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
if (req.method !== 'POST') {
|
|
1014
|
+
json(res, 405, { error: 'method not allowed', allow: 'GET, POST' });
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
const body = await readBody(req);
|
|
1019
|
+
const want = String(body.route || '').toLowerCase();
|
|
1020
|
+
if (want !== 'api' && want !== 'web') {
|
|
1021
|
+
json(res, 400, { error: 'route must be "api" or "web"' });
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// Which conversation to change.
|
|
1026
|
+
//
|
|
1027
|
+
// The client is the only thing that knows which chat the person is looking at, so it must send
|
|
1028
|
+
// the id. The Host never infers one: "the newest session" is not "the open session", and a wrong
|
|
1029
|
+
// guess here changes a DIFFERENT conversation mid-answer while the person watches the one they
|
|
1030
|
+
// are in. When the id is missing or unknown the switch still saves the default and reports
|
|
1031
|
+
// `applied: false` with the reason - a visible non-action beats a silent wrong action.
|
|
1032
|
+
const requestedId = typeof body.sessionId === 'string' && body.sessionId ? body.sessionId : null;
|
|
1033
|
+
let sessionResult = {
|
|
1034
|
+
applied: false,
|
|
1035
|
+
reason: requestedId ? 'session controller unavailable' : 'the client did not identify a conversation',
|
|
1036
|
+
};
|
|
1037
|
+
|
|
1038
|
+
if (want === 'web') {
|
|
1039
|
+
if (!onWeb) {
|
|
1040
|
+
if (current?.provider && current?.model && current.provider !== PROVIDER) {
|
|
1041
|
+
lastApiSelection = { ...current };
|
|
1042
|
+
}
|
|
1043
|
+
await defaultModel.saveSelection({ ...WEB_SELECTION });
|
|
1044
|
+
}
|
|
1045
|
+
if (requestedId) sessionResult = await applyToSession(requestedId, WEB_SELECTION);
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* Choosing WEB starts the browser.
|
|
1049
|
+
*
|
|
1050
|
+
* Without this, nothing ever began the launch: the adapter starts Brave lazily on the first
|
|
1051
|
+
* call, so the switch reported "unavailable" until a message was sent, and the person had no
|
|
1052
|
+
* way to tell whether WEB would ever work. Asking for the route is the moment the intent is
|
|
1053
|
+
* expressed, so it is the moment to satisfy it.
|
|
1054
|
+
*
|
|
1055
|
+
* The launch is not awaited. It takes about ten seconds and the click must feel immediate; the
|
|
1056
|
+
* `launching` flag lets the badge explain the pause instead of looking stuck.
|
|
1057
|
+
*/
|
|
1058
|
+
if (!browserReady && !launching) {
|
|
1059
|
+
launching = true;
|
|
1060
|
+
browserLaunchError = undefined;
|
|
1061
|
+
/**
|
|
1062
|
+
* A launch that never settles must not become a badge that says "starting" forever.
|
|
1063
|
+
*
|
|
1064
|
+
* `ensureReady` polls for the browser and the page, and on a wrong port or a browser that
|
|
1065
|
+
* refuses to start it polls until its own long timeout. If that promise is slow the person
|
|
1066
|
+
* sees an eternal "starting the browser…" with no way out. A watchdog therefore clears the
|
|
1067
|
+
* flag on a hard deadline and records why, so the state always resolves to something a
|
|
1068
|
+
* person can act on.
|
|
1069
|
+
*/
|
|
1070
|
+
const watchdog = setTimeout(() => {
|
|
1071
|
+
if (launching) {
|
|
1072
|
+
launching = false;
|
|
1073
|
+
browserLaunchError =
|
|
1074
|
+
browserLaunchError ||
|
|
1075
|
+
'the browser did not finish starting within 45 s — click Check now, or switch mode';
|
|
1076
|
+
}
|
|
1077
|
+
}, 45000);
|
|
1078
|
+
session
|
|
1079
|
+
.ensureReady()
|
|
1080
|
+
.then(() => {
|
|
1081
|
+
clearTimeout(watchdog);
|
|
1082
|
+
launching = false;
|
|
1083
|
+
})
|
|
1084
|
+
.catch((err) => {
|
|
1085
|
+
clearTimeout(watchdog);
|
|
1086
|
+
launching = false;
|
|
1087
|
+
browserLaunchError = err?.message ?? String(err);
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
} else if (onWeb) {
|
|
1091
|
+
/**
|
|
1092
|
+
* Returning to the API must always be possible.
|
|
1093
|
+
*
|
|
1094
|
+
* A switch that goes one way and refuses to come back is a trap, and refusing is what this did:
|
|
1095
|
+
* when no API model had been remembered - which happens whenever the plugin starts already on
|
|
1096
|
+
* the web route, so no switch was ever observed - it answered with an error instead of moving.
|
|
1097
|
+
*
|
|
1098
|
+
* The order of attempts is: what was remembered, then the deployment default, then ANY routable
|
|
1099
|
+
* non-web model the harness reports. Only when the harness offers no API model at all does this
|
|
1100
|
+
* refuse, and then it says exactly that rather than blaming the person for not having picked one.
|
|
1101
|
+
*/
|
|
1102
|
+
let back = lastApiSelection;
|
|
1103
|
+
let returnReason;
|
|
1104
|
+
|
|
1105
|
+
if (!back?.provider || !back?.model) {
|
|
1106
|
+
try {
|
|
1107
|
+
const catalog = await sessionController.modelCatalog?.();
|
|
1108
|
+
const isApi = (sel) => sel && sel.provider && sel.model && sel.provider !== PROVIDER;
|
|
1109
|
+
|
|
1110
|
+
if (isApi(catalog?.default)) {
|
|
1111
|
+
back = { provider: catalog.default.provider, model: catalog.default.model };
|
|
1112
|
+
} else {
|
|
1113
|
+
// The default is the web route, so look through the routable providers for anything else.
|
|
1114
|
+
const groups = Array.isArray(catalog?.groups) ? catalog.groups : [];
|
|
1115
|
+
for (const group of groups) {
|
|
1116
|
+
if (!group || group.id === PROVIDER) continue;
|
|
1117
|
+
const first = Array.isArray(group.models) ? group.models[0] : undefined;
|
|
1118
|
+
if (first?.id) {
|
|
1119
|
+
back = { provider: group.id, model: first.id };
|
|
1120
|
+
break;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
if (!back) {
|
|
1124
|
+
const routable = Array.isArray(catalog?.routableProviders)
|
|
1125
|
+
? catalog.routableProviders.filter((p) => p !== PROVIDER)
|
|
1126
|
+
: [];
|
|
1127
|
+
returnReason = routable.length
|
|
1128
|
+
? `the harness lists ${routable.join(', ')} but reported no model for it`
|
|
1129
|
+
: 'the harness reports no API model at all — only the web route is configured. ' +
|
|
1130
|
+
'Add an API model in Settings → Models and this switch will return to it.';
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
} catch (err) {
|
|
1134
|
+
returnReason = `could not read the model catalog: ${err?.message ?? err}`;
|
|
1135
|
+
}
|
|
1136
|
+
if (back && (!back.provider || !back.model)) back = null;
|
|
1137
|
+
if (back) lastApiSelection = { ...back };
|
|
1138
|
+
}
|
|
1139
|
+
if (!back?.provider || !back?.model) {
|
|
1140
|
+
json(res, 409, {
|
|
1141
|
+
error:
|
|
1142
|
+
returnReason ||
|
|
1143
|
+
'no API model to return to. Pick an API model once in the model list and the switch ' +
|
|
1144
|
+
'will remember it from then on.',
|
|
1145
|
+
});
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
await defaultModel.saveSelection({ ...back });
|
|
1149
|
+
if (requestedId) sessionResult = await applyToSession(requestedId, back);
|
|
1150
|
+
} else if (requestedId) {
|
|
1151
|
+
// already on the API: still make sure THIS conversation is on it
|
|
1152
|
+
sessionResult = await applyToSession(requestedId, current);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
const after = sessionResult?.selected ?? defaultModel.currentSelection();
|
|
1156
|
+
const afterOnWeb = selectionIsWeb(after);
|
|
1157
|
+
json(res, 200, {
|
|
1158
|
+
route: afterOnWeb && webReady ? 'web' : 'api',
|
|
1159
|
+
configuredRoute: afterOnWeb ? 'web' : 'api',
|
|
1160
|
+
webReady,
|
|
1161
|
+
adapterRegistered,
|
|
1162
|
+
browserReady,
|
|
1163
|
+
launching,
|
|
1164
|
+
browserLaunchError,
|
|
1165
|
+
providerProbeError,
|
|
1166
|
+
webReadyReason,
|
|
1167
|
+
selection: after,
|
|
1168
|
+
apiSelection: lastApiSelection,
|
|
1169
|
+
webSelection: WEB_SELECTION,
|
|
1170
|
+
session: sessionResult,
|
|
1171
|
+
missing,
|
|
1172
|
+
/*
|
|
1173
|
+
* The switch's own words.
|
|
1174
|
+
*
|
|
1175
|
+
* The composer control renders "Uncle Sam" and its tooltip, so it needs the same translated words
|
|
1176
|
+
* the panel has. Sending them here keeps one dictionary for the whole plugin.
|
|
1177
|
+
*/
|
|
1178
|
+
strings: {
|
|
1179
|
+
title: translator()('title'),
|
|
1180
|
+
dockApi: translator()('dockApi'),
|
|
1181
|
+
dockWebTip: translator()('dockWebTip'),
|
|
1182
|
+
dockApiTip: translator()('dockApiTip'),
|
|
1183
|
+
starting: translator()('starting'),
|
|
1184
|
+
switching: translator()('switching'),
|
|
1185
|
+
},
|
|
1186
|
+
});
|
|
1187
|
+
} catch (err) {
|
|
1188
|
+
json(res, 500, { error: err?.message ?? String(err) });
|
|
1189
|
+
}
|
|
1190
|
+
};
|
|
1191
|
+
|
|
1192
|
+
try {
|
|
1193
|
+
routeDisposer = server.register({ kind: 'exact', path: ROUTE_PATH, handler });
|
|
1194
|
+
} catch {
|
|
1195
|
+
routeDisposer = null;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// ------------------------------------------------------------------ setup panel
|
|
1199
|
+
//
|
|
1200
|
+
// A plugin that needs one manual sign-in has to say so somewhere the person will find it. This route
|
|
1201
|
+
// backs the Settings page: it reports the three facts that decide whether the provider can work, and
|
|
1202
|
+
// it can perform the single action that fixes the common case - open the browser at the sign-in page.
|
|
1203
|
+
//
|
|
1204
|
+
// It never accepts, stores, or returns a credential. The session lives in the browser profile, which
|
|
1205
|
+
// is where a session belongs.
|
|
1206
|
+
const SETUP_PATH = '/api/deepseek-web/setup';
|
|
1207
|
+
|
|
1208
|
+
const setupHandler = async (req, res) => {
|
|
1209
|
+
try {
|
|
1210
|
+
const probe = async () => {
|
|
1211
|
+
|
|
1212
|
+
let browserReady = false;
|
|
1213
|
+
let browserError;
|
|
1214
|
+
try {
|
|
1215
|
+
const r = await fetch(`http://127.0.0.1:${settings.port}/json/version`, {
|
|
1216
|
+
signal: AbortSignal.timeout(1200),
|
|
1217
|
+
});
|
|
1218
|
+
browserReady = r.ok;
|
|
1219
|
+
} catch {
|
|
1220
|
+
browserReady = false;
|
|
1221
|
+
browserError = `no browser is listening on port ${settings.port}`;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
let page = { ok: false };
|
|
1225
|
+
if (browserReady) {
|
|
1226
|
+
/**
|
|
1227
|
+
* Wait for the single-page app to hydrate before judging it.
|
|
1228
|
+
*
|
|
1229
|
+
* The first read right after a navigation sees an empty shell: `signedIn` can be true from a
|
|
1230
|
+
* stored token while the composer has not been created yet, and the panel then reported "not
|
|
1231
|
+
* signed in, but the page is showing the sign-in screen" for a page that was simply still
|
|
1232
|
+
* loading. Worst case it showed a red failure that fixed itself a second later.
|
|
1233
|
+
*
|
|
1234
|
+
* A signed-in page gets a short grace period to produce a composer. A genuinely signed-out
|
|
1235
|
+
* page has nothing to wait for, so it is answered immediately and the panel stays responsive.
|
|
1236
|
+
*/
|
|
1237
|
+
page = await session.inspect();
|
|
1238
|
+
if (page.ok && page.signedIn && !page.hasComposer) {
|
|
1239
|
+
const started = Date.now();
|
|
1240
|
+
while (Date.now() - started < 6000) {
|
|
1241
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
1242
|
+
const again = await session.inspect();
|
|
1243
|
+
if (again.ok) {
|
|
1244
|
+
page = again;
|
|
1245
|
+
if (again.hasComposer) break;
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
let adapterRegistered = false;
|
|
1252
|
+
try {
|
|
1253
|
+
const providers = llmService?.listProviders?.() ?? [];
|
|
1254
|
+
adapterRegistered = providers.some((p) => (p?.id ?? p?.provider) === PROVIDER);
|
|
1255
|
+
} catch {
|
|
1256
|
+
adapterRegistered = false;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
const signedIn = Boolean(page.ok && page.signedIn);
|
|
1260
|
+
const composer = Boolean(page.ok && page.hasComposer);
|
|
1261
|
+
return {
|
|
1262
|
+
browserReady,
|
|
1263
|
+
browserError,
|
|
1264
|
+
browserUrl: page.ok ? page.url : undefined,
|
|
1265
|
+
signedIn,
|
|
1266
|
+
composer,
|
|
1267
|
+
onSignInPage: Boolean(page.ok && page.onSignInPage),
|
|
1268
|
+
pageError: page.ok ? undefined : page.error,
|
|
1269
|
+
adapterRegistered,
|
|
1270
|
+
launching,
|
|
1271
|
+
launchError: browserLaunchError,
|
|
1272
|
+
// Ready means a call could succeed right now: browser up, signed in, and a composer to type in.
|
|
1273
|
+
ready: browserReady && signedIn && composer && adapterRegistered,
|
|
1274
|
+
// The port actually in use, so the panel names the address it just probed rather than a
|
|
1275
|
+
// fixed one that may not be the live browser.
|
|
1276
|
+
port: settings.port,
|
|
1277
|
+
// Which engine will be used, and whether it is the recommended one.
|
|
1278
|
+
browser: session.browserKind,
|
|
1279
|
+
// What the window actually did on the last launch, not what was requested. `null` means no
|
|
1280
|
+
// launch has happened since the plugin loaded.
|
|
1281
|
+
lastMinimise: session.lastMinimise ?? null,
|
|
1282
|
+
// Which engine each mode uses, so the trade is visible before choosing rather than after.
|
|
1283
|
+
recommended: session.browserKind === 'brave',
|
|
1284
|
+
// What the web route has saved so far, priced at the API rate. Estimated from character
|
|
1285
|
+
// counts - the page reports no usage - and labelled as an estimate wherever it is shown.
|
|
1286
|
+
savings: savings.snapshot(),
|
|
1287
|
+
// The exact text most recently typed into the browser, so a person can see what was sent
|
|
1288
|
+
// without opening the tab.
|
|
1289
|
+
lastPrompt: lastPromptPreview,
|
|
1290
|
+
/*
|
|
1291
|
+
* The panel's words, in the language the harness is set to.
|
|
1292
|
+
*
|
|
1293
|
+
* The panel runs in the browser and cannot read files, so the dictionary travels with the
|
|
1294
|
+
* status. Sending the whole table means the panel never needs to know which keys exist: it asks
|
|
1295
|
+
* for one and renders whatever comes back, with the English default already built in.
|
|
1296
|
+
*/
|
|
1297
|
+
strings: stringsForPanel(),
|
|
1298
|
+
// Which languages this package can speak, and which one is in use right now.
|
|
1299
|
+
languages: locales.available(),
|
|
1300
|
+
language: translator().languageName,
|
|
1301
|
+
languageCode: chosenLanguage || '',
|
|
1302
|
+
};
|
|
1303
|
+
};
|
|
1304
|
+
|
|
1305
|
+
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
1306
|
+
json(res, 200, await probe());
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
if (req.method !== 'POST') {
|
|
1310
|
+
json(res, 405, { error: 'method not allowed', allow: 'GET, POST' });
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
const body = await readBody(req);
|
|
1315
|
+
const action = String(body.action || 'check').toLowerCase();
|
|
1316
|
+
|
|
1317
|
+
if (action === 'signin') {
|
|
1318
|
+
/**
|
|
1319
|
+
* Bring the site forward so the person can sign in.
|
|
1320
|
+
*
|
|
1321
|
+
* Signing in needs a window somebody can type into, which is why this is the one moment the
|
|
1322
|
+
* plugin shows the browser deliberately. The session then lives in that browser's profile, so
|
|
1323
|
+
* it is asked for once and not again.
|
|
1324
|
+
*/
|
|
1325
|
+
launching = true;
|
|
1326
|
+
browserLaunchError = undefined;
|
|
1327
|
+
try {
|
|
1328
|
+
await session.openSignIn();
|
|
1329
|
+
} catch (err) {
|
|
1330
|
+
browserLaunchError = err?.message ?? String(err);
|
|
1331
|
+
} finally {
|
|
1332
|
+
launching = false;
|
|
1333
|
+
}
|
|
1334
|
+
} else if (action === 'launch') {
|
|
1335
|
+
launching = true;
|
|
1336
|
+
browserLaunchError = undefined;
|
|
1337
|
+
try {
|
|
1338
|
+
await session.ensureReady();
|
|
1339
|
+
} catch (err) {
|
|
1340
|
+
browserLaunchError = err?.message ?? String(err);
|
|
1341
|
+
} finally {
|
|
1342
|
+
launching = false;
|
|
1343
|
+
}
|
|
1344
|
+
} else if (action === 'fresh') {
|
|
1345
|
+
/**
|
|
1346
|
+
* Start a clean conversation in the browser.
|
|
1347
|
+
*
|
|
1348
|
+
* The one thing a person cannot do from inside the harness: when a chat drifts or fills up, the
|
|
1349
|
+
* only way back to a clean starting point is to reset the page. The site sees the same "New chat"
|
|
1350
|
+
* a human would click, so this is ordinary use, not a workaround.
|
|
1351
|
+
*/
|
|
1352
|
+
try {
|
|
1353
|
+
await session.startFresh();
|
|
1354
|
+
} catch (err) {
|
|
1355
|
+
browserLaunchError = err?.message ?? String(err);
|
|
1356
|
+
}
|
|
1357
|
+
} else if (action === 'language') {
|
|
1358
|
+
/*
|
|
1359
|
+
* The reader's own choice of language, which always beats what the harness is set to.
|
|
1360
|
+
*
|
|
1361
|
+
* Detection is a good default and a bad decision: someone who reads English fine on a Chinese
|
|
1362
|
+
* system should not be forced into Chinese. An empty value clears the override and follows the
|
|
1363
|
+
* harness again.
|
|
1364
|
+
*/
|
|
1365
|
+
chosenLanguage = String(body?.language ?? '').trim().toLowerCase().replace(/[^a-z-]/g, '');
|
|
1366
|
+
// Written through immediately: the whole point is that it survives the next restart.
|
|
1367
|
+
if (prefs) prefs.set('language', chosenLanguage);
|
|
1368
|
+
} else if (action === 'show') {
|
|
1369
|
+
// Raise the browser window so a person can look at the page without hunting the taskbar.
|
|
1370
|
+
try {
|
|
1371
|
+
await session.showWindow();
|
|
1372
|
+
} catch (err) {
|
|
1373
|
+
browserLaunchError = err?.message ?? String(err);
|
|
1374
|
+
}
|
|
1375
|
+
} else if (action !== 'check') {
|
|
1376
|
+
json(res, 400, {
|
|
1377
|
+
error: `unknown action "${action}"`,
|
|
1378
|
+
allow: ['signin', 'launch', 'fresh', 'show', 'language', 'check'],
|
|
1379
|
+
});
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
json(res, 200, await probe());
|
|
1384
|
+
} catch (err) {
|
|
1385
|
+
json(res, 500, { error: err?.message ?? String(err) });
|
|
1386
|
+
}
|
|
1387
|
+
};
|
|
1388
|
+
|
|
1389
|
+
try {
|
|
1390
|
+
setupDisposer = server.register({ kind: 'exact', path: SETUP_PATH, handler: setupHandler });
|
|
1391
|
+
} catch {
|
|
1392
|
+
setupDisposer = null;
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
return () => {
|
|
1397
|
+
try {
|
|
1398
|
+
registration?.();
|
|
1399
|
+
} catch {
|
|
1400
|
+
/* already disposed */
|
|
1401
|
+
}
|
|
1402
|
+
for (const dispose of [routeDisposer, setupDisposer]) {
|
|
1403
|
+
try {
|
|
1404
|
+
dispose?.();
|
|
1405
|
+
} catch {
|
|
1406
|
+
/* already disposed */
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
session.close();
|
|
1410
|
+
};
|
|
1411
|
+
}
|