@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/client.js
ADDED
|
@@ -0,0 +1,1099 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two-way API / WEB switch, rendered in the composer dock and in Settings.
|
|
3
|
+
*
|
|
4
|
+
* @file client.js
|
|
5
|
+
* @version 3.9.11
|
|
6
|
+
* @author Operating Tag (@operatingtag)
|
|
7
|
+
* @license CC-BY-NC-ND-4.0
|
|
8
|
+
*
|
|
9
|
+
* WHAT THE SWITCH IS FOR
|
|
10
|
+
* Two ways to reach a model: the metered API, or the free browser session on chat.deepseek.com.
|
|
11
|
+
* Choosing between them is a persistent decision, not a per-message one, so the switch writes the
|
|
12
|
+
* Host's default model selection and nothing ever flips it back. The only writer is a click.
|
|
13
|
+
*
|
|
14
|
+
* WHY THE COMPONENT DOES NOT HOLD THE TRUTH
|
|
15
|
+
* The Host route is the single source of truth. This component reads it on mount and after every
|
|
16
|
+
* click, so the dock copy and the Settings copy can never disagree, and a reload shows the real
|
|
17
|
+
* persisted state rather than an optimistic guess.
|
|
18
|
+
*
|
|
19
|
+
* WHY A SEGMENTED CONTROL RATHER THAN A CHECKBOX
|
|
20
|
+
* A checkbox answers "is X on?". This decision has two named sides and no neutral: the person is
|
|
21
|
+
* either on the API or on the web chat, always. A two-position control says that directly, and the
|
|
22
|
+
* moving thumb makes the current side readable at a glance from across the room.
|
|
23
|
+
*
|
|
24
|
+
* WHY IT RENDERS A DISABLED STATE
|
|
25
|
+
* If the Host route is missing (a profile without the web server) the control still appears, greyed,
|
|
26
|
+
* with the reason in its title. A control that silently vanishes is indistinguishable from a plugin
|
|
27
|
+
* that failed to load, which is exactly the confusion this plugin has already caused once.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
window.__ModuleLoader__.load({
|
|
31
|
+
id: '@operatingtag/dsh-unclesam',
|
|
32
|
+
factory(require) {
|
|
33
|
+
const React = require('react');
|
|
34
|
+
const h = React.createElement;
|
|
35
|
+
|
|
36
|
+
const ENDPOINT = '/api/deepseek-web/route';
|
|
37
|
+
const BLUE = '#3b82f6';
|
|
38
|
+
const RED = '#dc2626';
|
|
39
|
+
/*
|
|
40
|
+
* The panel's words.
|
|
41
|
+
*
|
|
42
|
+
* The Host sends the whole dictionary with each status reply, already in the language the harness is set
|
|
43
|
+
* to, so the panel never reads files and never decides what language to speak. Until the first reply lands
|
|
44
|
+
* this answers with nothing, and the caller falls back to the key - which is why the panel renders its
|
|
45
|
+
* skeleton rather than blank boxes.
|
|
46
|
+
*/
|
|
47
|
+
function makeT(strings) {
|
|
48
|
+
const table = strings && typeof strings === 'object' ? strings : null;
|
|
49
|
+
return (key, ...args) => {
|
|
50
|
+
const raw = table?.[key];
|
|
51
|
+
if (typeof raw !== 'string') return '';
|
|
52
|
+
let i = 0;
|
|
53
|
+
return raw.replace(/%s/g, () => (i < args.length ? String(args[i++]) : '%s'));
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const GREY = '#6b7280';
|
|
58
|
+
|
|
59
|
+
/** Read the current route from the Host. Returns null when the route is unavailable. */
|
|
60
|
+
async function readRoute() {
|
|
61
|
+
try {
|
|
62
|
+
const res = await fetch(ENDPOINT, { cache: 'no-store' });
|
|
63
|
+
if (!res.ok) return null;
|
|
64
|
+
return await res.json();
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Ask the Host to move the route, for this conversation as well as for future ones.
|
|
72
|
+
*
|
|
73
|
+
* `sessionId` is the whole point: without it the Host only saves a default and the open chat keeps
|
|
74
|
+
* answering from the old model, which is precisely the behaviour that made the switch look broken.
|
|
75
|
+
*/
|
|
76
|
+
async function writeRoute(route, sessionId) {
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetch(ENDPOINT, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: { 'content-type': 'application/json' },
|
|
81
|
+
body: JSON.stringify(sessionId ? { route, sessionId } : { route }),
|
|
82
|
+
});
|
|
83
|
+
const body = await res.json().catch(() => ({}));
|
|
84
|
+
if (!res.ok) return { error: body?.error || `HTTP ${res.status}` };
|
|
85
|
+
return body;
|
|
86
|
+
} catch (err) {
|
|
87
|
+
return { error: String(err?.message ?? err) };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Find the id of the conversation on screen.
|
|
93
|
+
*
|
|
94
|
+
* Three sources are tried in order of authority, because the wrong guess here changes a different
|
|
95
|
+
* conversation than the one the person is reading:
|
|
96
|
+
*
|
|
97
|
+
* 1. `ctx.sessions` — the client's own session service. If the plugin can reach it this is the
|
|
98
|
+
* only source that is definitely right, because it is the same object the conversation uses.
|
|
99
|
+
* 2. The address bar, which the app keeps in step with the open conversation on some builds.
|
|
100
|
+
* 3. Nothing. `null` is returned rather than a guess: the Host then saves the default and says
|
|
101
|
+
* plainly that no conversation was identified, instead of switching a chat at random.
|
|
102
|
+
*
|
|
103
|
+
* Session ids are `session-<uuid>`; a bare uuid was measured as "session not found", so both forms
|
|
104
|
+
* are accepted and normalised.
|
|
105
|
+
*/
|
|
106
|
+
/** The Client context this plugin was mounted with; the switch uses it to reach `sessions`. */
|
|
107
|
+
let pluginCtx = null;
|
|
108
|
+
|
|
109
|
+
const SESSION_RE = /(?:^|[^0-9a-z-])(session-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i;
|
|
110
|
+
const BARE_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
111
|
+
|
|
112
|
+
function normalise(id) {
|
|
113
|
+
const s = String(id || '').trim();
|
|
114
|
+
if (!s) return null;
|
|
115
|
+
if (SESSION_RE.test(s)) return SESSION_RE.exec(s)[1];
|
|
116
|
+
if (BARE_RE.test(s)) return `session-${s}`;
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function sessionFromUrl() {
|
|
121
|
+
try {
|
|
122
|
+
const hit = SESSION_RE.exec(String(location.href));
|
|
123
|
+
if (hit) return hit[1];
|
|
124
|
+
const params = new URLSearchParams(location.search || '');
|
|
125
|
+
for (const key of ['session', 'sessionId', 's']) {
|
|
126
|
+
const v = normalise(params.get(key));
|
|
127
|
+
if (v) return v;
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Ask the client service for the selected session.
|
|
137
|
+
*
|
|
138
|
+
* The service exposes `binding(id)` and `scope(id)` for ids it already retains, but not the selected
|
|
139
|
+
* id itself, so the retained generations are enumerated through the retention counts the service
|
|
140
|
+
* already tracks. A single retained session is unambiguous; several means there is no way to tell
|
|
141
|
+
* which is on screen from here, and the caller falls back rather than guessing.
|
|
142
|
+
*/
|
|
143
|
+
async function sessionFromService(ctx) {
|
|
144
|
+
const sessions = ctx?.sessions;
|
|
145
|
+
if (!sessions) return null;
|
|
146
|
+
try {
|
|
147
|
+
// `retainInfo` is the only enumeration the face offers; it needs an id, so ids are discovered
|
|
148
|
+
// from the DOM's session links as a candidate set and each is checked for a live generation.
|
|
149
|
+
const ids = new Set();
|
|
150
|
+
for (const a of document.querySelectorAll('a[href*="session-"]')) {
|
|
151
|
+
const v = normalise(a.getAttribute('href'));
|
|
152
|
+
if (v) ids.add(v);
|
|
153
|
+
}
|
|
154
|
+
const live = [];
|
|
155
|
+
for (const id of ids) {
|
|
156
|
+
const info = sessions.retainInfo?.(id);
|
|
157
|
+
const snap = info?.getSnapshot?.();
|
|
158
|
+
if (snap && (snap.referenceCount || 0) > 0) live.push(id);
|
|
159
|
+
}
|
|
160
|
+
if (live.length === 1) return live[0];
|
|
161
|
+
return null;
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function useRoute(ctx, sessionIdRef) {
|
|
168
|
+
const [state, setState] = React.useState({ loading: true, data: null, error: null, busy: false });
|
|
169
|
+
|
|
170
|
+
const refresh = React.useCallback(async () => {
|
|
171
|
+
const data = await readRoute();
|
|
172
|
+
setState((s) => ({
|
|
173
|
+
...s,
|
|
174
|
+
loading: false,
|
|
175
|
+
data,
|
|
176
|
+
error: data ? null : 'switch unavailable (Host route not registered)',
|
|
177
|
+
}));
|
|
178
|
+
}, []);
|
|
179
|
+
|
|
180
|
+
React.useEffect(() => {
|
|
181
|
+
let alive = true;
|
|
182
|
+
refresh().catch(() => {});
|
|
183
|
+
// A slow poll keeps the two mounted copies (dock and settings) in step without a socket.
|
|
184
|
+
const timer = setInterval(() => {
|
|
185
|
+
if (alive) refresh().catch(() => {});
|
|
186
|
+
}, 4000);
|
|
187
|
+
return () => {
|
|
188
|
+
alive = false;
|
|
189
|
+
clearInterval(timer);
|
|
190
|
+
};
|
|
191
|
+
}, [refresh]);
|
|
192
|
+
|
|
193
|
+
const choose = React.useCallback(
|
|
194
|
+
async (route) => {
|
|
195
|
+
setState((s) => ({ ...s, busy: true }));
|
|
196
|
+
const sessionId = sessionIdRef.current;
|
|
197
|
+
const result = await writeRoute(route, sessionId);
|
|
198
|
+
if (result && result.error) {
|
|
199
|
+
setState((s) => ({ ...s, busy: false, error: result.error }));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
setState((s) => ({ ...s, busy: false, data: result, error: null }));
|
|
203
|
+
},
|
|
204
|
+
[],
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
return { ...state, choose, refresh };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The switch under the composer.
|
|
212
|
+
*
|
|
213
|
+
* Its slot is declared with `scope: 'session'`, and that scope passes `sessionId` to every contribution
|
|
214
|
+
* as a prop. That is the whole reason this works: the id of the conversation on screen is handed to us.
|
|
215
|
+
*
|
|
216
|
+
* The previous version went looking for it - through the sessions client service, then through sidebar
|
|
217
|
+
* links, then the address bar - and when all of those failed it sent no id at all. The Host then saved
|
|
218
|
+
* the choice as the default for NEW conversations and left the one on screen untouched, so the badge
|
|
219
|
+
* turned red while every reply still came from the API. The control said WEB and the routing said no,
|
|
220
|
+
* which is the one failure this whole plugin exists to prevent.
|
|
221
|
+
*/
|
|
222
|
+
function DockSwitch(props) {
|
|
223
|
+
const sessionIdRef = React.useRef(null);
|
|
224
|
+
sessionIdRef.current = props?.sessionId ?? null;
|
|
225
|
+
/*
|
|
226
|
+
* Right-aligned to the composer edge, not centred under it.
|
|
227
|
+
*
|
|
228
|
+
* Centred, the switch floated in the middle of the row and read as a separate widget. Pushed to the
|
|
229
|
+
* end, it lines up with the right edge of the chat box above it, which is where a control that
|
|
230
|
+
* belongs to that box should sit.
|
|
231
|
+
*/
|
|
232
|
+
return h(
|
|
233
|
+
'div',
|
|
234
|
+
{
|
|
235
|
+
style: {
|
|
236
|
+
display: 'flex',
|
|
237
|
+
justifyContent: 'flex-end',
|
|
238
|
+
width: '100%',
|
|
239
|
+
marginBottom: '2px',
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
h(Switch, { compact: true, sessionIdRef }),
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function Switch({ compact, sessionIdRef }) {
|
|
247
|
+
const { loading, data, error, busy, choose } = useRoute(pluginCtx, sessionIdRef);
|
|
248
|
+
const route = data?.route === 'web' ? 'web' : 'api';
|
|
249
|
+
|
|
250
|
+
// `webReady` is whether the provider is genuinely mounted. The switch must show the truth, not the
|
|
251
|
+
// saved intent: a conversation can be configured for web while every reply is served by the API,
|
|
252
|
+
// and saying WEB then is worse than saying nothing because it looks like a confirmation.
|
|
253
|
+
const webReady = data?.webReady !== false;
|
|
254
|
+
const configuredWeb = data?.configuredRoute === 'web';
|
|
255
|
+
/**
|
|
256
|
+
* Grey means "the route you are ON cannot work" - nothing else.
|
|
257
|
+
*
|
|
258
|
+
* This used to look only at the saved default, so after a restart with WEB saved but no browser
|
|
259
|
+
* running, the dot went grey even while the conversation was on the API and answering perfectly.
|
|
260
|
+
* A grey light on a working route is worse than no light: it invites people to go and fix something
|
|
261
|
+
* that is not broken. Availability only matters when the route in force is the web one.
|
|
262
|
+
*/
|
|
263
|
+
const webUnavailable = route === 'web' && configuredWeb && !webReady;
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Only a request in flight makes the control untouchable.
|
|
267
|
+
*
|
|
268
|
+
* The first version ALSO disabled it whenever `error` was set, so a 409 complaining about the API
|
|
269
|
+
* side greyed out the whole switch - the one control needed to fix the problem was locked by the
|
|
270
|
+
* problem. `loading` had the same hazard: it starts true, and a failed first read left it stuck.
|
|
271
|
+
* A status light that stops responding while you need it is worse than no light.
|
|
272
|
+
*/
|
|
273
|
+
const disabled = busy;
|
|
274
|
+
|
|
275
|
+
/*
|
|
276
|
+
* One quiet chip, not a pill with two buttons.
|
|
277
|
+
*
|
|
278
|
+
* The row it lives in is full of quiet, borderless text - the attachment button, "Full access", the
|
|
279
|
+
* model name. A bordered pill with a filled block and letter-spaced capitals was the loudest thing
|
|
280
|
+
* in it, which is why it looked bolted on rather than belonging.
|
|
281
|
+
*
|
|
282
|
+
* So it is now the same weight as its neighbours: a small dot and a lowercase word. The WORD is what
|
|
283
|
+
* makes it a button - a dot alone reads as a status light, and nothing about it invites a click. On
|
|
284
|
+
* hover it takes the same soft surface the other buttons use, so it is unmistakably interactive the
|
|
285
|
+
* moment somebody points at it, and invisible until then.
|
|
286
|
+
*
|
|
287
|
+
* Clicking toggles. Clicking the side already active re-applies it, which repairs a conversation
|
|
288
|
+
* whose badge and model have drifted apart.
|
|
289
|
+
*/
|
|
290
|
+
const t = makeT(data?.strings);
|
|
291
|
+
const label = busy
|
|
292
|
+
? t('switching') || 'switching…'
|
|
293
|
+
: data?.launching
|
|
294
|
+
? t('starting')
|
|
295
|
+
: route === 'web'
|
|
296
|
+
? // The same name the replies carry. The label in the composer and the signature on the answer
|
|
297
|
+
// are one idea, so seeing "El Tío Sam" here and "🔴 El Tío Sam" above the reply reads as one
|
|
298
|
+
// thing rather than two unrelated pieces of jargon.
|
|
299
|
+
t('title') || 'Uncle Sam'
|
|
300
|
+
: t('dockApi') || 'DeepSeek Harness';
|
|
301
|
+
|
|
302
|
+
const hint =
|
|
303
|
+
route === 'web'
|
|
304
|
+
? t('dockWebTip') || 'Uncle Sam — replies come from chat.deepseek.com. Click for DeepSeek Harness.'
|
|
305
|
+
: t('dockApiTip') || 'DeepSeek Harness — replies come from your API key. Click for Uncle Sam.';
|
|
306
|
+
|
|
307
|
+
const dotColour = webUnavailable ? (data?.launching ? '#b45309' : GREY) : route === 'web' ? RED : BLUE;
|
|
308
|
+
|
|
309
|
+
return h(
|
|
310
|
+
'button',
|
|
311
|
+
{
|
|
312
|
+
type: 'button',
|
|
313
|
+
'data-deepseek-web-switch': route,
|
|
314
|
+
disabled,
|
|
315
|
+
title: error ? error : hint,
|
|
316
|
+
onClick: () => choose(route === 'web' ? 'api' : 'web'),
|
|
317
|
+
style: {
|
|
318
|
+
display: 'inline-flex',
|
|
319
|
+
alignItems: 'center',
|
|
320
|
+
gap: '5px',
|
|
321
|
+
// Matched to the neighbouring controls: same font, same size, no border, no fill.
|
|
322
|
+
font: 'inherit',
|
|
323
|
+
fontSize: '12px',
|
|
324
|
+
fontWeight: 500,
|
|
325
|
+
color: 'inherit',
|
|
326
|
+
opacity: disabled && !busy ? 0.55 : 0.85,
|
|
327
|
+
background: 'transparent',
|
|
328
|
+
border: 'none',
|
|
329
|
+
borderRadius: '6px',
|
|
330
|
+
padding: '2px 6px',
|
|
331
|
+
margin: '0',
|
|
332
|
+
cursor: disabled ? 'default' : 'pointer',
|
|
333
|
+
whiteSpace: 'nowrap',
|
|
334
|
+
flex: '0 0 auto',
|
|
335
|
+
userSelect: 'none',
|
|
336
|
+
transition: 'background 120ms ease, opacity 120ms ease',
|
|
337
|
+
},
|
|
338
|
+
onMouseEnter: (e) => {
|
|
339
|
+
if (!disabled) e.currentTarget.style.background = 'rgba(128,128,128,0.16)';
|
|
340
|
+
},
|
|
341
|
+
onMouseLeave: (e) => {
|
|
342
|
+
e.currentTarget.style.background = 'transparent';
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
[
|
|
346
|
+
h('span', {
|
|
347
|
+
key: 'dot',
|
|
348
|
+
style: {
|
|
349
|
+
width: '6px',
|
|
350
|
+
height: '6px',
|
|
351
|
+
borderRadius: '50%',
|
|
352
|
+
background: dotColour,
|
|
353
|
+
flex: '0 0 auto',
|
|
354
|
+
transition: 'background 180ms ease',
|
|
355
|
+
},
|
|
356
|
+
}),
|
|
357
|
+
h('span', { key: 'word', style: { lineHeight: '16px' } }, loading && !data ? 'reading…' : label),
|
|
358
|
+
],
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The settings switch: no conversation is on screen here, so it carries no session id.
|
|
364
|
+
*
|
|
365
|
+
* Changing the route from Settings sets it for NEW conversations. Saying so is better than letting
|
|
366
|
+
* somebody believe it also moved the conversation they left open.
|
|
367
|
+
*/
|
|
368
|
+
function SettingsSwitch() {
|
|
369
|
+
const sessionIdRef = React.useRef(null);
|
|
370
|
+
return h(
|
|
371
|
+
'div',
|
|
372
|
+
{
|
|
373
|
+
style: {
|
|
374
|
+
display: 'flex',
|
|
375
|
+
alignItems: 'center',
|
|
376
|
+
justifyContent: 'space-between',
|
|
377
|
+
gap: '16px',
|
|
378
|
+
padding: '14px 4px',
|
|
379
|
+
borderTop: '1px solid rgba(128,128,128,0.22)',
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
[
|
|
383
|
+
h(
|
|
384
|
+
'div',
|
|
385
|
+
{ key: 'text', style: { display: 'flex', flexDirection: 'column', gap: '3px' } },
|
|
386
|
+
[
|
|
387
|
+
h('div', { key: 't', style: { fontSize: '14px', fontWeight: 600 } }, t('routeTitle')),
|
|
388
|
+
h(
|
|
389
|
+
'div',
|
|
390
|
+
{ key: 'd', style: { fontSize: '12px', opacity: 0.65, maxWidth: '46ch' } },
|
|
391
|
+
t('routeNote'),
|
|
392
|
+
),
|
|
393
|
+
],
|
|
394
|
+
),
|
|
395
|
+
h('div', { key: 'ctl' }, h(Switch, { compact: false })),
|
|
396
|
+
],
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Render the saved amount so a small one is still legible.
|
|
402
|
+
*
|
|
403
|
+
* WHY THIS EXISTS
|
|
404
|
+
* The figure was printed with two decimals, so anything under a cent read "$0.00" - which is exactly
|
|
405
|
+
* what a first test produces. One turn of ordinary chat costs about a hundredth of a cent at the API's
|
|
406
|
+
* list rate, so a person could use this for an evening and watch a counter that never left zero. A
|
|
407
|
+
* number that cannot move is worse than no number: it reads as broken rather than as small.
|
|
408
|
+
*
|
|
409
|
+
* So: whole cents and above keep the familiar "$0.42"; anything smaller is stated in cents with two
|
|
410
|
+
* decimals ("0.01c"), and anything smaller still says "<0.01c" rather than pretending to be zero.
|
|
411
|
+
* Currencies other than USD have no cents convention here, so they keep four decimals instead.
|
|
412
|
+
*/
|
|
413
|
+
function moneySaved(savings) {
|
|
414
|
+
const value = Number(savings?.saved) || 0;
|
|
415
|
+
const currency = String(savings?.currency || 'USD').toUpperCase();
|
|
416
|
+
// Zero is zero. Reporting it as "<0.01c" would claim a small positive that is not there - which can
|
|
417
|
+
// happen legitimately, for instance when the configured rates are zero.
|
|
418
|
+
if (value <= 0) return currency === 'USD' ? '0.00c' : `${currency} 0.0000`;
|
|
419
|
+
if (currency !== 'USD') {
|
|
420
|
+
return value >= 0.01 ? `${currency} ${value.toFixed(2)}` : `${currency} ${value.toFixed(4)}`;
|
|
421
|
+
}
|
|
422
|
+
if (value >= 0.01) return `$${value.toFixed(2)}`;
|
|
423
|
+
const cents = value * 100;
|
|
424
|
+
return cents >= 0.01 ? `${cents.toFixed(2)}c` : '<0.01c';
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* The setup page under Settings → Plugins.
|
|
429
|
+
*
|
|
430
|
+
* This is the first thing a new user needs, so it leads with the one fact that matters - can this
|
|
431
|
+
* work right now - and then offers the single action that fixes the usual blocker. No credential is
|
|
432
|
+
* requested or stored: the browser profile holds the session, which is where a session belongs.
|
|
433
|
+
*/
|
|
434
|
+
function SetupPage() {
|
|
435
|
+
const [state, setState] = React.useState({ loading: true, data: null, busy: false, error: null });
|
|
436
|
+
|
|
437
|
+
const refresh = React.useCallback(async () => {
|
|
438
|
+
try {
|
|
439
|
+
const res = await fetch('/api/deepseek-web/setup', { cache: 'no-store' });
|
|
440
|
+
if (!res.ok) {
|
|
441
|
+
setState({ loading: false, data: null, busy: false, error: `setup route returned ${res.status}` });
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
setState({ loading: false, data: await res.json(), busy: false, error: null });
|
|
445
|
+
} catch (err) {
|
|
446
|
+
setState({ loading: false, data: null, busy: false, error: String(err?.message ?? err) });
|
|
447
|
+
}
|
|
448
|
+
}, []);
|
|
449
|
+
|
|
450
|
+
React.useEffect(() => {
|
|
451
|
+
let alive = true;
|
|
452
|
+
refresh();
|
|
453
|
+
const timer = setInterval(() => {
|
|
454
|
+
if (alive) refresh();
|
|
455
|
+
}, 5000);
|
|
456
|
+
return () => {
|
|
457
|
+
alive = false;
|
|
458
|
+
clearInterval(timer);
|
|
459
|
+
};
|
|
460
|
+
}, [refresh]);
|
|
461
|
+
|
|
462
|
+
const act = async (action, extra) => {
|
|
463
|
+
setState((s) => ({ ...s, busy: true, error: null }));
|
|
464
|
+
try {
|
|
465
|
+
await fetch('/api/deepseek-web/setup', {
|
|
466
|
+
method: 'POST',
|
|
467
|
+
headers: { 'content-type': 'application/json' },
|
|
468
|
+
body: JSON.stringify({ action, ...(extra || {}) }),
|
|
469
|
+
});
|
|
470
|
+
} catch (err) {
|
|
471
|
+
setState((s) => ({ ...s, busy: false, error: String(err?.message ?? err) }));
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
await refresh();
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
;
|
|
478
|
+
|
|
479
|
+
const d = state.data ?? {};
|
|
480
|
+
// Every word this page shows comes from here, in the language the Host chose.
|
|
481
|
+
const t = makeT(d.strings);
|
|
482
|
+
const dot = (ok, warn) => ({
|
|
483
|
+
width: '8px',
|
|
484
|
+
height: '8px',
|
|
485
|
+
borderRadius: '50%',
|
|
486
|
+
flex: '0 0 auto',
|
|
487
|
+
background: ok ? '#16a34a' : warn ? '#d97706' : '#9ca3af',
|
|
488
|
+
});
|
|
489
|
+
const row = (key, label, ok, detail, warn) =>
|
|
490
|
+
h('div', { key, style: { display: 'flex', gap: '10px', alignItems: 'baseline', padding: '5px 0' } }, [
|
|
491
|
+
h('span', { key: 'd', style: dot(ok, warn) }),
|
|
492
|
+
h('span', { key: 'l', style: { minWidth: '132px', fontSize: '13px', fontWeight: 500 } }, label),
|
|
493
|
+
h('span', { key: 'v', style: { fontSize: '12px', opacity: 0.7 } }, detail),
|
|
494
|
+
]);
|
|
495
|
+
|
|
496
|
+
const button = (label, action, primary, unavailable) =>
|
|
497
|
+
h(
|
|
498
|
+
'button',
|
|
499
|
+
{
|
|
500
|
+
type: 'button',
|
|
501
|
+
disabled: state.busy || Boolean(unavailable),
|
|
502
|
+
title: unavailable ? t('buttonNeedsBrowser') : undefined,
|
|
503
|
+
onClick: () => act(action),
|
|
504
|
+
style: {
|
|
505
|
+
padding: '6px 14px',
|
|
506
|
+
borderRadius: '6px',
|
|
507
|
+
fontSize: '13px',
|
|
508
|
+
fontWeight: 500,
|
|
509
|
+
cursor: state.busy || unavailable ? 'default' : 'pointer',
|
|
510
|
+
color: primary ? '#fff' : 'inherit',
|
|
511
|
+
background: primary ? '#2563eb' : 'transparent',
|
|
512
|
+
border: primary ? '1px solid #2563eb' : '1px solid rgba(128,128,128,0.5)',
|
|
513
|
+
opacity: state.busy || unavailable ? 0.5 : 1,
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
label,
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
return h('div', { style: { padding: '8px 2px', fontFamily: 'inherit', maxWidth: '72ch' } }, [
|
|
520
|
+
h('div', { key: 'h', style: { fontSize: '16px', fontWeight: 600, marginBottom: '2px' } }, t('title')),
|
|
521
|
+
/*
|
|
522
|
+
* Language, right under the title.
|
|
523
|
+
*
|
|
524
|
+
* The default follows the harness, which already knows what language the person chose there. This is
|
|
525
|
+
* for the case detection gets wrong - someone living abroad, or reading English happily on a system
|
|
526
|
+
* set to another language - and it is deliberately small: it is a correction, not a feature.
|
|
527
|
+
*/
|
|
528
|
+
h(
|
|
529
|
+
'div',
|
|
530
|
+
{ key: 'lang', style: { fontSize: '11px', opacity: 0.6, marginBottom: '10px', display: 'flex', gap: '6px', alignItems: 'center' } },
|
|
531
|
+
[
|
|
532
|
+
h('span', { key: 'l' }, d.strings?.languageLabel || 'Language'),
|
|
533
|
+
h(
|
|
534
|
+
'select',
|
|
535
|
+
{
|
|
536
|
+
key: 's',
|
|
537
|
+
/*
|
|
538
|
+
* The dropdown shows what the HOST has, never what the reader once clicked.
|
|
539
|
+
*
|
|
540
|
+
* When a choice is lost the host reports an empty code and the page is told nothing, so
|
|
541
|
+
* answers come back in English. Showing "Español" anyway was how the panel and the answers
|
|
542
|
+
* came to disagree: the reader saw Spanish selected and got English back with no
|
|
543
|
+
* explanation. "auto" is the honest reading of "no choice is stored".
|
|
544
|
+
*/
|
|
545
|
+
value: d.languageCode || '',
|
|
546
|
+
onChange: (event) => act('language', { language: event.target.value }),
|
|
547
|
+
/*
|
|
548
|
+
* The harness paints its controls from CSS custom properties, so this does too. 'transparent'
|
|
549
|
+
* was the mistake: a native <select> draws its popup with the system colour, and on a light
|
|
550
|
+
* system under a dark panel that is a white sheet over the page. Using the same tokens as
|
|
551
|
+
* the shipped UI means the field and its list follow the theme, light or dark, with nothing
|
|
552
|
+
* hard-coded here.
|
|
553
|
+
*/
|
|
554
|
+
style: {
|
|
555
|
+
fontSize: '11px',
|
|
556
|
+
padding: '2px 6px',
|
|
557
|
+
borderRadius: 'var(--dsw-radius-sm)',
|
|
558
|
+
background: 'var(--dsw-alias-bg-layer-2)',
|
|
559
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
560
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
561
|
+
colorScheme: 'normal',
|
|
562
|
+
},
|
|
563
|
+
},
|
|
564
|
+
[
|
|
565
|
+
h('option', { key: 'auto', value: '' }, t('languageAuto', d.language || 'English')),
|
|
566
|
+
...(d.languages || []).map((lang) =>
|
|
567
|
+
h(
|
|
568
|
+
'option',
|
|
569
|
+
{
|
|
570
|
+
key: lang.code,
|
|
571
|
+
value: lang.code,
|
|
572
|
+
// The popup inherits nothing from the page, so each option is painted explicitly.
|
|
573
|
+
style: {
|
|
574
|
+
background: 'var(--dsw-alias-bg-layer-2)',
|
|
575
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
576
|
+
},
|
|
577
|
+
},
|
|
578
|
+
lang.name,
|
|
579
|
+
),
|
|
580
|
+
),
|
|
581
|
+
],
|
|
582
|
+
),
|
|
583
|
+
],
|
|
584
|
+
),
|
|
585
|
+
h(
|
|
586
|
+
'div',
|
|
587
|
+
{ key: 'sub', style: { fontSize: '13px', opacity: 0.65, marginBottom: '14px', lineHeight: 1.5 } },
|
|
588
|
+
t('pageIntro'),
|
|
589
|
+
),
|
|
590
|
+
|
|
591
|
+
state.error
|
|
592
|
+
? h('div', { key: 'err', style: { fontSize: '12px', color: '#c2410c', marginBottom: '10px' } }, state.error)
|
|
593
|
+
: null,
|
|
594
|
+
|
|
595
|
+
/*
|
|
596
|
+
* The ask, sitting immediately above the Brave recommendation.
|
|
597
|
+
*
|
|
598
|
+
* It used to be pinned at the top of the page, where it read as a banner and pushed the actual
|
|
599
|
+
* settings down. Here it reads as part of the same thought: this page is about which browser to use
|
|
600
|
+
* and why the work is worth supporting, so the link belongs with that rather than above everything.
|
|
601
|
+
*/
|
|
602
|
+
h(
|
|
603
|
+
'div',
|
|
604
|
+
{ key: 'coffee-rec', style: { marginBottom: '10px' } },
|
|
605
|
+
h(
|
|
606
|
+
'a',
|
|
607
|
+
{
|
|
608
|
+
key: 'l',
|
|
609
|
+
href: 'https://revolut.me/operatingtag',
|
|
610
|
+
target: '_blank',
|
|
611
|
+
rel: 'noreferrer noopener',
|
|
612
|
+
style: {
|
|
613
|
+
color: '#3b82f6',
|
|
614
|
+
fontWeight: 700,
|
|
615
|
+
textDecoration: 'none',
|
|
616
|
+
textTransform: 'uppercase',
|
|
617
|
+
letterSpacing: '0.02em',
|
|
618
|
+
fontSize: '13px',
|
|
619
|
+
},
|
|
620
|
+
},
|
|
621
|
+
t('buyMeACoffee'),
|
|
622
|
+
),
|
|
623
|
+
),
|
|
624
|
+
|
|
625
|
+
/*
|
|
626
|
+
* The engine recommendation.
|
|
627
|
+
*
|
|
628
|
+
* Both remaining modes run on Brave, for reasons that are about protection rather than speed.
|
|
629
|
+
* A headless mode was built and withdrawn: chat.deepseek.com answers headless browsers with a 403
|
|
630
|
+
* from CloudFront before the application loads, so no configuration could make it work.
|
|
631
|
+
*/
|
|
632
|
+
h(
|
|
633
|
+
'div',
|
|
634
|
+
{
|
|
635
|
+
key: 'rec',
|
|
636
|
+
style: {
|
|
637
|
+
padding: '10px 12px',
|
|
638
|
+
marginBottom: '14px',
|
|
639
|
+
borderRadius: '8px',
|
|
640
|
+
border: '1px solid rgba(128,128,128,0.3)',
|
|
641
|
+
fontSize: '12px',
|
|
642
|
+
lineHeight: 1.55,
|
|
643
|
+
},
|
|
644
|
+
},
|
|
645
|
+
[
|
|
646
|
+
h(
|
|
647
|
+
'div',
|
|
648
|
+
{ key: 't', style: { fontWeight: 600, marginBottom: '4px' } },
|
|
649
|
+
t('braveTitle'),
|
|
650
|
+
),
|
|
651
|
+
h(
|
|
652
|
+
'div',
|
|
653
|
+
{ key: 'b', style: { opacity: 0.8, marginBottom: '6px' } },
|
|
654
|
+
t('braveWhy'),
|
|
655
|
+
),
|
|
656
|
+
h('ol', { key: 'l', style: { margin: '0 0 0 18px', padding: 0, opacity: 0.8 } }, [
|
|
657
|
+
h('li', { key: '1' }, [
|
|
658
|
+
h('strong', { key: 'a' }, t('braveSecurity') + ' '),
|
|
659
|
+
t('braveSecurityBody'),
|
|
660
|
+
]),
|
|
661
|
+
h('li', { key: '2' }, [
|
|
662
|
+
h('strong', { key: 'a' }, t('bravePrivacy') + ' '),
|
|
663
|
+
t('bravePrivacyBody'),
|
|
664
|
+
]),
|
|
665
|
+
h('li', { key: '3' }, [
|
|
666
|
+
h('strong', { key: 'a' }, t('braveQuiet') + ' '),
|
|
667
|
+
t('braveQuietBody'),
|
|
668
|
+
]),
|
|
669
|
+
]),
|
|
670
|
+
h(
|
|
671
|
+
'div',
|
|
672
|
+
{ key: 'n', style: { opacity: 0.6, marginTop: '8px', fontSize: '11px' } },
|
|
673
|
+
t('braveInvisible'),
|
|
674
|
+
),
|
|
675
|
+
],
|
|
676
|
+
),
|
|
677
|
+
|
|
678
|
+
/*
|
|
679
|
+
* Two different facts, so two different labels.
|
|
680
|
+
*
|
|
681
|
+
* "Running" answers "is the browser up right now"; "Using" answers "which browser will it use".
|
|
682
|
+
* Calling both of them "Browser" made the panel look self-contradictory - one row said nothing was
|
|
683
|
+
* listening while the next named an engine - even though both were true.
|
|
684
|
+
*/
|
|
685
|
+
row(
|
|
686
|
+
'running',
|
|
687
|
+
t('statusRunning'),
|
|
688
|
+
d.browserReady,
|
|
689
|
+
d.browserReady ? t('browserListening', d.port) : d.browserError || t('browserNone', d.port),
|
|
690
|
+
d.launching,
|
|
691
|
+
),
|
|
692
|
+
row(
|
|
693
|
+
'session',
|
|
694
|
+
t('statusSession'),
|
|
695
|
+
d.signedIn && d.composer,
|
|
696
|
+
d.signedIn
|
|
697
|
+
? d.composer
|
|
698
|
+
? t('sessionSignedIn')
|
|
699
|
+
: t('sessionSignedInButSignInScreen')
|
|
700
|
+
: t('sessionNotSignedIn'),
|
|
701
|
+
d.signedIn && !d.composer,
|
|
702
|
+
),
|
|
703
|
+
row(
|
|
704
|
+
'provider',
|
|
705
|
+
t('statusProvider'),
|
|
706
|
+
d.adapterRegistered,
|
|
707
|
+
d.adapterRegistered ? t('providerRegistered') : t('providerMissing'),
|
|
708
|
+
),
|
|
709
|
+
row(
|
|
710
|
+
'ready',
|
|
711
|
+
t('statusReady'),
|
|
712
|
+
d.ready,
|
|
713
|
+
d.ready ? t('readyWeb') : t('readyApiFallback'),
|
|
714
|
+
),
|
|
715
|
+
|
|
716
|
+
/*
|
|
717
|
+
* Browser row and upgrade nudge.
|
|
718
|
+
*
|
|
719
|
+
* The plugin works on either browser; it simply says which one it is using and what that costs.
|
|
720
|
+
* Brave is offered, never demanded, and the nudge disappears once Brave is in use.
|
|
721
|
+
*/
|
|
722
|
+
row(
|
|
723
|
+
'engine',
|
|
724
|
+
t('statusUsing'),
|
|
725
|
+
d.recommended === true,
|
|
726
|
+
d.browser === 'brave'
|
|
727
|
+
? t('usingBrave')
|
|
728
|
+
: d.browser === 'edge'
|
|
729
|
+
? t('usingEdge')
|
|
730
|
+
: t('browserNoneFound'),
|
|
731
|
+
d.browser === 'edge'
|
|
732
|
+
),
|
|
733
|
+
d.browser === 'edge'
|
|
734
|
+
? h(
|
|
735
|
+
'div',
|
|
736
|
+
{
|
|
737
|
+
key: 'nudge',
|
|
738
|
+
style: {
|
|
739
|
+
marginTop: '10px',
|
|
740
|
+
padding: '10px 12px',
|
|
741
|
+
borderRadius: '8px',
|
|
742
|
+
border: '1px solid rgba(128,128,128,0.3)',
|
|
743
|
+
fontSize: '12px',
|
|
744
|
+
lineHeight: 1.5,
|
|
745
|
+
},
|
|
746
|
+
},
|
|
747
|
+
[
|
|
748
|
+
h(
|
|
749
|
+
'div',
|
|
750
|
+
{ key: 't', style: { marginBottom: '6px' } },
|
|
751
|
+
t('edgeSwitch'),
|
|
752
|
+
),
|
|
753
|
+
h(
|
|
754
|
+
'a',
|
|
755
|
+
{
|
|
756
|
+
key: 'a',
|
|
757
|
+
href: 'https://brave.com/download/',
|
|
758
|
+
target: '_blank',
|
|
759
|
+
rel: 'noreferrer noopener',
|
|
760
|
+
style: {
|
|
761
|
+
display: 'inline-block',
|
|
762
|
+
padding: '5px 12px',
|
|
763
|
+
borderRadius: '6px',
|
|
764
|
+
background: '#fb542b',
|
|
765
|
+
color: '#fff',
|
|
766
|
+
fontWeight: 600,
|
|
767
|
+
fontSize: '12px',
|
|
768
|
+
textDecoration: 'none',
|
|
769
|
+
},
|
|
770
|
+
},
|
|
771
|
+
t('getBrave'),
|
|
772
|
+
),
|
|
773
|
+
],
|
|
774
|
+
)
|
|
775
|
+
: null,
|
|
776
|
+
/*
|
|
777
|
+
* What this session has saved.
|
|
778
|
+
*
|
|
779
|
+
* The plugin exists to stop you paying per token, and until now that was a claim. This is the
|
|
780
|
+
* number, priced at the API's own list rate, climbing as you use it. It says "estimated" because
|
|
781
|
+
* the page reports no usage and the tokens are derived from character counts - a figure this
|
|
782
|
+
* prominent has to be honest about its basis.
|
|
783
|
+
*/
|
|
784
|
+
h(
|
|
785
|
+
'div',
|
|
786
|
+
{
|
|
787
|
+
key: 'savings',
|
|
788
|
+
style: {
|
|
789
|
+
marginTop: '16px',
|
|
790
|
+
padding: '12px 14px',
|
|
791
|
+
borderRadius: '8px',
|
|
792
|
+
border: '1px solid rgba(128,128,128,0.3)',
|
|
793
|
+
background: 'rgba(34,197,94,0.07)',
|
|
794
|
+
},
|
|
795
|
+
},
|
|
796
|
+
[
|
|
797
|
+
h('div', { key: 't', style: { fontSize: '11px', opacity: 0.6, marginBottom: '2px' } }, t('savingsTitle')),
|
|
798
|
+
h(
|
|
799
|
+
'div',
|
|
800
|
+
{ key: 'v', style: { fontSize: '24px', fontWeight: 700, color: '#16a34a', lineHeight: 1.2 } },
|
|
801
|
+
d.savings && d.savings.calls > 0 ? moneySaved(d.savings) : '—',
|
|
802
|
+
),
|
|
803
|
+
h(
|
|
804
|
+
'div',
|
|
805
|
+
{ key: 'd', style: { fontSize: '11px', opacity: 0.6, marginTop: '4px' } },
|
|
806
|
+
d.savings && d.savings.calls > 0
|
|
807
|
+
? `${t('savingsDetail', d.savings.calls, d.savings.totalTokens.toLocaleString(), d.savings.priceLabel)} · ` +
|
|
808
|
+
t('savingsEstimatedNote')
|
|
809
|
+
: t('savingsEmpty'),
|
|
810
|
+
),
|
|
811
|
+
],
|
|
812
|
+
),
|
|
813
|
+
|
|
814
|
+
/*
|
|
815
|
+
* Trust panel.
|
|
816
|
+
*
|
|
817
|
+
* A plugin that drives a browser on someone's own account deserves the same suspicion people give
|
|
818
|
+
* a random installer. Saying plainly what it does and does not touch is quicker than answering that
|
|
819
|
+
* suspicion one support question at a time.
|
|
820
|
+
*/
|
|
821
|
+
h(
|
|
822
|
+
'div',
|
|
823
|
+
{
|
|
824
|
+
key: 'trust',
|
|
825
|
+
style: { marginTop: '16px', fontSize: '12px', lineHeight: 1.7, opacity: 0.85 },
|
|
826
|
+
},
|
|
827
|
+
[
|
|
828
|
+
h('div', { key: 't', style: { fontWeight: 600, marginBottom: '4px' } }, t('trustTitle')),
|
|
829
|
+
h('div', { key: 'y', style: { color: '#16a34a' } }, [
|
|
830
|
+
`✓ ${t('trustUsesSession')}`,
|
|
831
|
+
h('br', { key: '1' }),
|
|
832
|
+
`✓ ${t('trustLocal')}`,
|
|
833
|
+
h('br', { key: '2' }),
|
|
834
|
+
`✓ ${t('trustCounts')}`,
|
|
835
|
+
]),
|
|
836
|
+
h('div', { key: 'n', style: { opacity: 0.75, marginTop: '4px' } }, [
|
|
837
|
+
`· ${t('trustNoPassword')}`,
|
|
838
|
+
h('br', { key: '1' }),
|
|
839
|
+
`· ${t('trustNoProxy')}`,
|
|
840
|
+
h('br', { key: '2' }),
|
|
841
|
+
`· ${t('trustNoTelemetry')}`,
|
|
842
|
+
]),
|
|
843
|
+
],
|
|
844
|
+
),
|
|
845
|
+
|
|
846
|
+
/*
|
|
847
|
+
* What was actually typed into the browser.
|
|
848
|
+
*
|
|
849
|
+
* Every confusion about this plugin came from not being able to see what it put in the chat box.
|
|
850
|
+
* Showing it here answers the question without making anyone open the tab.
|
|
851
|
+
*/
|
|
852
|
+
d.lastPrompt
|
|
853
|
+
? h(
|
|
854
|
+
'div',
|
|
855
|
+
{ key: 'sent', style: { marginTop: '16px' } },
|
|
856
|
+
[
|
|
857
|
+
h(
|
|
858
|
+
'div',
|
|
859
|
+
{ key: 't', style: { fontSize: '12px', fontWeight: 600, marginBottom: '4px' } },
|
|
860
|
+
t('lastSentTitle', d.lastPrompt.chars.toLocaleString()),
|
|
861
|
+
),
|
|
862
|
+
/*
|
|
863
|
+
* The tool line, stated plainly.
|
|
864
|
+
*
|
|
865
|
+
* The chat box has no system channel, so a model can only call a tool that was named in the
|
|
866
|
+
* message. That line is therefore unavoidable - but it should be inspectable here rather
|
|
867
|
+
* than surprising someone reading their own conversation.
|
|
868
|
+
*/
|
|
869
|
+
d.lastPrompt.tools
|
|
870
|
+
? h(
|
|
871
|
+
'div',
|
|
872
|
+
{ key: 'tools', style: { fontSize: '11px', opacity: 0.65, marginBottom: '6px' } },
|
|
873
|
+
t('toolsOffered', d.lastPrompt.tools),
|
|
874
|
+
)
|
|
875
|
+
: null,
|
|
876
|
+
h(
|
|
877
|
+
'pre',
|
|
878
|
+
{
|
|
879
|
+
key: 'p',
|
|
880
|
+
style: {
|
|
881
|
+
margin: 0,
|
|
882
|
+
padding: '10px 12px',
|
|
883
|
+
borderRadius: '6px',
|
|
884
|
+
background: 'rgba(128,128,128,0.12)',
|
|
885
|
+
fontSize: '11px',
|
|
886
|
+
lineHeight: 1.5,
|
|
887
|
+
maxHeight: '180px',
|
|
888
|
+
overflow: 'auto',
|
|
889
|
+
whiteSpace: 'pre-wrap',
|
|
890
|
+
wordBreak: 'break-word',
|
|
891
|
+
},
|
|
892
|
+
},
|
|
893
|
+
d.lastPrompt.text,
|
|
894
|
+
),
|
|
895
|
+
],
|
|
896
|
+
)
|
|
897
|
+
: null,
|
|
898
|
+
|
|
899
|
+
h('div', { key: 'actions', style: { display: 'flex', gap: '8px', marginTop: '16px', flexWrap: 'wrap' } }, [
|
|
900
|
+
button(d.signedIn && d.composer ? t('buttonOpenChat') : t('buttonSignIn'), 'signin', !d.ready),
|
|
901
|
+
button(t('buttonCheck'), 'check', false),
|
|
902
|
+
// Both need a live browser; disabled rather than failing when there is none.
|
|
903
|
+
button(t('buttonFresh'), 'fresh', false, !d.browserReady),
|
|
904
|
+
button(t('buttonShow'), 'show', false, !d.browserReady),
|
|
905
|
+
]),
|
|
906
|
+
|
|
907
|
+
h(
|
|
908
|
+
'div',
|
|
909
|
+
{ key: 'note', style: { fontSize: '11px', opacity: 0.5, marginTop: '14px', lineHeight: 1.5 } },
|
|
910
|
+
t('noteFirstRun'),
|
|
911
|
+
),
|
|
912
|
+
|
|
913
|
+
// Support note.
|
|
914
|
+
//
|
|
915
|
+
// Written as an explanation rather than a plea. People fund work they understand and ignore appeals
|
|
916
|
+
// to pity, so this says what the plugin does, what it would take to do it better, and leaves the
|
|
917
|
+
// decision alone. No fake urgency, no guilt, and no link that goes nowhere.
|
|
918
|
+
h(
|
|
919
|
+
'div',
|
|
920
|
+
{
|
|
921
|
+
key: 'support',
|
|
922
|
+
style: {
|
|
923
|
+
marginTop: '18px',
|
|
924
|
+
paddingTop: '12px',
|
|
925
|
+
borderTop: '1px solid rgba(128,128,128,0.25)',
|
|
926
|
+
fontSize: '12px',
|
|
927
|
+
lineHeight: 1.6,
|
|
928
|
+
opacity: 0.8,
|
|
929
|
+
},
|
|
930
|
+
},
|
|
931
|
+
[
|
|
932
|
+
/*
|
|
933
|
+
* The ask comes first, as a link rather than a heading.
|
|
934
|
+
*
|
|
935
|
+
* There is only one person behind this, the reader already knows who, so naming a handle would
|
|
936
|
+
* be noise in the one place the reader looks first. Everything after this is the case: what the
|
|
937
|
+
* money is for, why it matters, and no pressure applied.
|
|
938
|
+
*/
|
|
939
|
+
h(
|
|
940
|
+
'div',
|
|
941
|
+
{ key: 't', style: { marginBottom: '8px' } },
|
|
942
|
+
h(
|
|
943
|
+
'a',
|
|
944
|
+
{
|
|
945
|
+
key: 'l',
|
|
946
|
+
href: 'https://revolut.me/operatingtag',
|
|
947
|
+
target: '_blank',
|
|
948
|
+
rel: 'noreferrer noopener',
|
|
949
|
+
style: {
|
|
950
|
+
color: '#3b82f6',
|
|
951
|
+
fontWeight: 700,
|
|
952
|
+
textDecoration: 'none',
|
|
953
|
+
},
|
|
954
|
+
},
|
|
955
|
+
t('buyMeACoffee'),
|
|
956
|
+
),
|
|
957
|
+
),
|
|
958
|
+
h(
|
|
959
|
+
'div',
|
|
960
|
+
{ key: 'b' },
|
|
961
|
+
t('authorIntro'),
|
|
962
|
+
),
|
|
963
|
+
h(
|
|
964
|
+
'div',
|
|
965
|
+
{ key: 'c', style: { marginTop: '6px' } },
|
|
966
|
+
t('authorAsk'),
|
|
967
|
+
),
|
|
968
|
+
|
|
969
|
+
/*
|
|
970
|
+
* What the money is for.
|
|
971
|
+
*
|
|
972
|
+
* A spec sheet, not a plea: every line is a fact about the machine, and the reader decides what
|
|
973
|
+
* that is worth without being told how to feel about it.
|
|
974
|
+
*/
|
|
975
|
+
h(
|
|
976
|
+
'div',
|
|
977
|
+
{ key: 'goal', style: { marginTop: '12px' } },
|
|
978
|
+
[
|
|
979
|
+
h(
|
|
980
|
+
'div',
|
|
981
|
+
{
|
|
982
|
+
key: 'specs',
|
|
983
|
+
style: {
|
|
984
|
+
padding: '10px 12px',
|
|
985
|
+
borderRadius: '6px',
|
|
986
|
+
background: 'rgba(128,128,128,0.12)',
|
|
987
|
+
fontSize: '11.5px',
|
|
988
|
+
lineHeight: 1.7,
|
|
989
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
|
990
|
+
whiteSpace: 'pre-wrap',
|
|
991
|
+
},
|
|
992
|
+
},
|
|
993
|
+
[
|
|
994
|
+
t('specTitle'),
|
|
995
|
+
/*
|
|
996
|
+
* Labels are translated, values are not.
|
|
997
|
+
*
|
|
998
|
+
* Translations differ wildly in length ("Speicher" against "Storage"), so the column is
|
|
999
|
+
* padded after translation rather than inside the string, and the block still lines up.
|
|
1000
|
+
*/
|
|
1001
|
+
` ${t('specCpu').padEnd(9)} Ryzen AI 9 HX 370 · 12 cores · 5.1 GHz`,
|
|
1002
|
+
` ${t('specNpu').padEnd(9)} AMD XDNA · 50 TOPS`,
|
|
1003
|
+
` ${t('specGpu').padEnd(9)} RTX 5090 Laptop · 24 GB GDDR7`,
|
|
1004
|
+
` ${t('specRam').padEnd(9)} 64 GB LPDDR5X`,
|
|
1005
|
+
` ${t('specStorage').padEnd(9)} 2 TB PCIe 4.0 NVMe`,
|
|
1006
|
+
` ${t('specDisplay').padEnd(9)} 16" 4K OLED · 120 Hz · 1600 nits · touch`,
|
|
1007
|
+
` ${t('specPorts').padEnd(9)} USB4 40 Gbps · HDMI 2.1 · SD Express 7`,
|
|
1008
|
+
` ${t('specPower').padEnd(9)} 240 W · 1.95 kg`,
|
|
1009
|
+
].join('\n'),
|
|
1010
|
+
),
|
|
1011
|
+
h(
|
|
1012
|
+
'div',
|
|
1013
|
+
{ key: 'a', style: { marginTop: '8px', fontWeight: 600 } },
|
|
1014
|
+
t('authorCloser'),
|
|
1015
|
+
),
|
|
1016
|
+
],
|
|
1017
|
+
),
|
|
1018
|
+
],
|
|
1019
|
+
),
|
|
1020
|
+
]);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
return {
|
|
1024
|
+
inject: ['slots', 'sessions'],
|
|
1025
|
+
apply(ctx) {
|
|
1026
|
+
pluginCtx = ctx;
|
|
1027
|
+
/*
|
|
1028
|
+
* The tab label is static - it is set at registration, before any status reply can bring the
|
|
1029
|
+
* dictionary. The harness's own locale service covers en and zh, so the label follows that; every
|
|
1030
|
+
* other language sees the brand name, which is how product names behave anyway.
|
|
1031
|
+
*/
|
|
1032
|
+
let tabLabel = 'Uncle Sam';
|
|
1033
|
+
try {
|
|
1034
|
+
const active = ctx.get('locale')?.getSnapshot?.()?.active;
|
|
1035
|
+
if (active === 'zh') tabLabel = '山姆大叔';
|
|
1036
|
+
} catch {
|
|
1037
|
+
/* no locale service: the brand name is fine */
|
|
1038
|
+
}
|
|
1039
|
+
/*
|
|
1040
|
+
* Inside the composer's tool row, on the left.
|
|
1041
|
+
*
|
|
1042
|
+
* It used to sit in `conversation.composer.dock`, which renders BELOW the card - visible, but in
|
|
1043
|
+
* its own strip, floating. The tool row is where a choice about how to send the message belongs,
|
|
1044
|
+
* and its LEFT side is the only part of it with room: the right half is already permission, model,
|
|
1045
|
+
* reasoning, usage and submit, so anything added there squeezes the model name or pushes the send
|
|
1046
|
+
* button off. The left holds a single small button.
|
|
1047
|
+
*
|
|
1048
|
+
* Both slots are `scope: 'session'` and carry no replacement risk, so the session id keeps arriving
|
|
1049
|
+
* as a prop - which is what makes the switch change THIS conversation rather than only the default.
|
|
1050
|
+
*/
|
|
1051
|
+
ctx.slots.inject('conversation.input.left', () =>
|
|
1052
|
+
ctx.slots.register(
|
|
1053
|
+
{ name: 'conversation.input.left', id: 'deepseek-web-switch', order: -10 },
|
|
1054
|
+
DockSwitch,
|
|
1055
|
+
),
|
|
1056
|
+
);
|
|
1057
|
+
ctx.slots.inject('settings.models.footer', () =>
|
|
1058
|
+
ctx.slots.register(
|
|
1059
|
+
{ name: 'settings.models.footer', id: 'deepseek-web-switch-settings', order: 40 },
|
|
1060
|
+
SettingsSwitch,
|
|
1061
|
+
),
|
|
1062
|
+
);
|
|
1063
|
+
ctx.slots.inject('settings.plugins.tab', () =>
|
|
1064
|
+
ctx.slots.register(
|
|
1065
|
+
{
|
|
1066
|
+
name: 'settings.plugins.tab',
|
|
1067
|
+
id: 'deepseek-web-setup',
|
|
1068
|
+
order: 60,
|
|
1069
|
+
label: tabLabel,
|
|
1070
|
+
},
|
|
1071
|
+
SetupPage,
|
|
1072
|
+
),
|
|
1073
|
+
);
|
|
1074
|
+
/*
|
|
1075
|
+
* Also a top-level settings page.
|
|
1076
|
+
*
|
|
1077
|
+
* `settings.plugins.tab` is a sub-tab of the Plugins settings SECTION, which is not where a person
|
|
1078
|
+
* lands after clicking a plugin in the Plugins LIST - that list is the bundle inventory, showing
|
|
1079
|
+
* Uninstall, components and the source path. The tab also only appears if that section exists at
|
|
1080
|
+
* all, so on a profile without it the panel is invisible with no error at all.
|
|
1081
|
+
*
|
|
1082
|
+
* `settings.section` is documented as "one settings page per list entry", which is unambiguous, so
|
|
1083
|
+
* the same component is registered there too. Two seats, one component, no duplicated logic.
|
|
1084
|
+
*/
|
|
1085
|
+
ctx.slots.inject('settings.section', () =>
|
|
1086
|
+
ctx.slots.register(
|
|
1087
|
+
{
|
|
1088
|
+
name: 'settings.section',
|
|
1089
|
+
id: 'deepseek-web-setup-page',
|
|
1090
|
+
order: 60,
|
|
1091
|
+
label: tabLabel,
|
|
1092
|
+
},
|
|
1093
|
+
SetupPage,
|
|
1094
|
+
),
|
|
1095
|
+
);
|
|
1096
|
+
},
|
|
1097
|
+
};
|
|
1098
|
+
},
|
|
1099
|
+
});
|