leetcode-capture-helper 0.0.0-stage → 0.1.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/cli.js +92 -0
- package/config.js +25 -0
- package/framing.js +57 -0
- package/host.js +346 -0
- package/install.js +290 -0
- package/package.json +31 -4
- package/runtime/bundle.json +42 -0
- package/runtime/companion/capture-log.js +83 -0
- package/runtime/companion/codex-backend.js +337 -0
- package/runtime/companion/companion.js +2193 -0
- package/runtime/companion/mouse-input.js +154 -0
- package/runtime/companion/package.json +4 -0
- package/runtime/companion/panel-bridge.js +273 -0
- package/runtime/companion/settings-rpc.js +228 -0
- package/runtime/companion/settings.js +464 -0
- package/runtime/companion/terminal-format.js +330 -0
- package/runtime/companion/vault-summary.js +169 -0
- package/runtime/companion/virtual-terminal.js +138 -0
- package/runtime/extension/shared/anthropic-backend.js +193 -0
- package/runtime/extension/shared/catalog.js +415 -0
- package/runtime/extension/shared/code-ledger.js +333 -0
- package/runtime/extension/shared/context-budget.js +259 -0
- package/runtime/extension/shared/context-summary.js +271 -0
- package/runtime/extension/shared/conversation.js +81 -0
- package/runtime/extension/shared/event-hub.js +126 -0
- package/runtime/extension/shared/extension-settings.js +445 -0
- package/runtime/extension/shared/helper-launcher.js +248 -0
- package/runtime/extension/shared/local-backend.js +706 -0
- package/runtime/extension/shared/local-server.js +149 -0
- package/runtime/extension/shared/package.json +4 -0
- package/runtime/extension/shared/practice.js +228 -0
- package/runtime/extension/shared/problem-index.js +4128 -0
- package/runtime/extension/shared/profiles.js +106 -0
- package/runtime/extension/shared/providers.js +58 -0
- package/runtime/extension/shared/reply-guard.js +864 -0
- package/runtime/extension/shared/roadmap.js +1344 -0
- package/runtime/extension/shared/sanitize.js +68 -0
- package/runtime/extension/shared/solved.js +277 -0
- package/runtime/extension/shared/tutor-engine.js +369 -0
- package/runtime/extension/shared/tutor-prompt.js +108 -0
- package/runtime/extension/shared/tutor-service.js +520 -0
- package/runtime/relay-server/package.json +4 -0
- package/runtime/relay-server/panel-broker.js +797 -0
- package/runtime/relay-server/server.js +241 -0
- package/runtime/vault-tool/log-session.js +230 -0
- package/runtime/vault-tool/package.json +4 -0
- package/runtime/vault-tool/vault-notes.js +206 -0
- package/README.md +0 -3
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
// extension/shared/tutor-service.js - everything the background worker does
|
|
2
|
+
// for the extension runtime, with the browser APIs passed in so it runs (and
|
|
3
|
+
// is tested) under Node too. background.js only binds chrome.* to it.
|
|
4
|
+
//
|
|
5
|
+
// - Keeps the extension's settings and API keys in storage (two records,
|
|
6
|
+
// see extension-settings.js) and builds the backend from them.
|
|
7
|
+
// - Runs the tutor (tutor-engine.js) and its event hub (event-hub.js).
|
|
8
|
+
// - Speaks the panel's port protocol (extension/panel.js, "extension"
|
|
9
|
+
// transport). Panel to worker:
|
|
10
|
+
// { type: 'subscribe', after, epoch } replay, then follow live
|
|
11
|
+
// { type: 'message', id, text, problemSlug, snapshot }
|
|
12
|
+
// { type: 'switch-profile', reqId, id } the model switcher picked a profile
|
|
13
|
+
// { type: 'profile-switched', id } the helper's companion switched to it
|
|
14
|
+
// { type: 'ping' } keeps the worker awake
|
|
15
|
+
// Worker to panel:
|
|
16
|
+
// { type: 'runtime', runtime, chosen } on connect and on every change
|
|
17
|
+
// { type: 'profiles', profiles, active } the switcher's entries (no secrets)
|
|
18
|
+
// { type: 'event', event } one hub event
|
|
19
|
+
// { type: 'reply', id, ok, error? } answer to a message
|
|
20
|
+
// { type: 'switch-result', reqId, ok, runtime?, message? }
|
|
21
|
+
// - Answers runtime messages: a capture from content.js
|
|
22
|
+
// ('lc-tutor:capture'), and the options page's settings calls
|
|
23
|
+
// ('lc-tutor:settings-get|save|test|models'), which only the extension's
|
|
24
|
+
// own pages may make. No answer to a content script ever carries a key.
|
|
25
|
+
//
|
|
26
|
+
// Pure JavaScript with no Node API (extension/shared.test.js checks that).
|
|
27
|
+
|
|
28
|
+
import { PROVIDERS } from './providers.js';
|
|
29
|
+
import { createEventHub } from './event-hub.js';
|
|
30
|
+
import { createTutorEngine } from './tutor-engine.js';
|
|
31
|
+
import { CONNECTION_TEST_PROMPT } from './tutor-prompt.js';
|
|
32
|
+
import { listAnthropicModels } from './anthropic-backend.js';
|
|
33
|
+
import { findProfile } from './profiles.js';
|
|
34
|
+
import {
|
|
35
|
+
SettingsError,
|
|
36
|
+
applyPatch,
|
|
37
|
+
backendKey,
|
|
38
|
+
buildBackend,
|
|
39
|
+
explainError,
|
|
40
|
+
missingRequirement,
|
|
41
|
+
normalizeSecrets,
|
|
42
|
+
normalizeSettings,
|
|
43
|
+
originPatternFor,
|
|
44
|
+
profileEntry,
|
|
45
|
+
profileProblem,
|
|
46
|
+
publicView,
|
|
47
|
+
runtimeFor,
|
|
48
|
+
serverUrlFor,
|
|
49
|
+
statusSummary,
|
|
50
|
+
withProfile,
|
|
51
|
+
} from './extension-settings.js';
|
|
52
|
+
|
|
53
|
+
export const SETTINGS_KEY = 'tutorSettings';
|
|
54
|
+
export const SECRETS_KEY = 'tutorSecrets';
|
|
55
|
+
export const TEST_TIMEOUT_MS = 90_000;
|
|
56
|
+
export const MODELS_TIMEOUT_MS = 6000;
|
|
57
|
+
const MAX_MODELS = 200;
|
|
58
|
+
|
|
59
|
+
// Replaces every occurrence of a secret in a message with ***.
|
|
60
|
+
function scrub(message, secrets) {
|
|
61
|
+
let out = String(message);
|
|
62
|
+
for (const secret of secrets) {
|
|
63
|
+
if (secret && secret.length >= 6) out = out.split(secret).join('***');
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function withTimeout(promise, ms, what) {
|
|
69
|
+
let timer;
|
|
70
|
+
const timeout = new Promise((_, reject) => {
|
|
71
|
+
timer = setTimeout(() => reject(new Error(`${what} did not answer within ${Math.round(ms / 1000)} seconds`)), ms);
|
|
72
|
+
});
|
|
73
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// storage: { get(keys) -> Promise<object>, set(object) -> Promise }
|
|
77
|
+
// permissions: { contains({ origins }) -> Promise<boolean> }
|
|
78
|
+
// extensionOrigin: 'chrome-extension://<id>' (or moz-extension://<uuid>)
|
|
79
|
+
// onRuntime(runtime): called once the settings are read and after every save,
|
|
80
|
+
// with 'extension' or 'helper' (the background's helper launcher follows it).
|
|
81
|
+
// helperProblem(currentRuntime): why a profile that runs through the helper
|
|
82
|
+
// cannot be switched to right now, as a short line, or null (async allowed).
|
|
83
|
+
export function createTutorService({
|
|
84
|
+
storage,
|
|
85
|
+
permissions,
|
|
86
|
+
extensionOrigin,
|
|
87
|
+
log = () => {},
|
|
88
|
+
buildBackendImpl = buildBackend,
|
|
89
|
+
onRuntime = () => {},
|
|
90
|
+
helperProblem = async () => null,
|
|
91
|
+
}) {
|
|
92
|
+
const hub = createEventHub();
|
|
93
|
+
const engine = createTutorEngine({ hub, log });
|
|
94
|
+
const ports = new Set();
|
|
95
|
+
let settings = normalizeSettings(null);
|
|
96
|
+
let secrets = normalizeSecrets(null);
|
|
97
|
+
let configuredKey = null;
|
|
98
|
+
|
|
99
|
+
const ready = (async () => {
|
|
100
|
+
try {
|
|
101
|
+
const stored = await storage.get([SETTINGS_KEY, SECRETS_KEY]);
|
|
102
|
+
settings = normalizeSettings(stored && stored[SETTINGS_KEY]);
|
|
103
|
+
secrets = normalizeSecrets(stored && stored[SECRETS_KEY]);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
log(`could not read settings: ${err.message}`);
|
|
106
|
+
}
|
|
107
|
+
await applyToEngine();
|
|
108
|
+
onRuntime(runtimeFor(settings));
|
|
109
|
+
})();
|
|
110
|
+
|
|
111
|
+
async function permissionFor(current) {
|
|
112
|
+
const pattern = originPatternFor(serverUrlFor(current));
|
|
113
|
+
if (!pattern) return { pattern: null, granted: true };
|
|
114
|
+
try {
|
|
115
|
+
return { pattern, granted: Boolean(await permissions.contains({ origins: [pattern] })) };
|
|
116
|
+
} catch {
|
|
117
|
+
return { pattern, granted: false };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function permissionMessage(current) {
|
|
122
|
+
return (
|
|
123
|
+
`The extension isn't allowed to reach ${serverUrlFor(current)} yet. ` +
|
|
124
|
+
'Open the extension options and press Save (or Test connection) to allow it.'
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Hands the engine the backend for the current settings, or the reason
|
|
129
|
+
// there is none. A configuration whose backend key is unchanged keeps the
|
|
130
|
+
// running conversation.
|
|
131
|
+
async function applyToEngine() {
|
|
132
|
+
const config = statusSummary(settings);
|
|
133
|
+
const label = config ? config.label : 'tutor';
|
|
134
|
+
const options = { autoClearContext: settings.options.autoClearContext };
|
|
135
|
+
if (runtimeFor(settings) !== 'extension') {
|
|
136
|
+
configuredKey = null;
|
|
137
|
+
await engine.configure({ backend: null, label, options, config, error: 'The tutor runs through the helper app for this provider.' });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const problem = missingRequirement(settings, secrets);
|
|
141
|
+
if (problem) {
|
|
142
|
+
configuredKey = null;
|
|
143
|
+
await engine.configure({ backend: null, label, options, config, error: `${problem} Open the extension options to finish setting it up.` });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const permission = await permissionFor(settings);
|
|
147
|
+
if (!permission.granted) {
|
|
148
|
+
configuredKey = null;
|
|
149
|
+
await engine.configure({ backend: null, label, options, config, error: permissionMessage(settings) });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const key = backendKey(settings, secrets);
|
|
153
|
+
const explain = makeExplainer(settings, secrets);
|
|
154
|
+
if (key === configuredKey) {
|
|
155
|
+
await engine.configure({ keep: true, key, label, options, config, explainError: explain });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
let backend;
|
|
159
|
+
try {
|
|
160
|
+
backend = buildBackendImpl(settings, secrets);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
configuredKey = null;
|
|
163
|
+
await engine.configure({ backend: null, label, options, config, error: scrub(err.message, [secrets[settings.provider]]) });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
configuredKey = key;
|
|
167
|
+
await engine.configure({ backend, key, label, options, config, explainError: explain });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function makeExplainer(current, keys) {
|
|
171
|
+
const provider = current.provider;
|
|
172
|
+
const serverUrl = serverUrlFor(current);
|
|
173
|
+
const secret = keys[provider];
|
|
174
|
+
return (message) => explainError(scrub(message, [secret]), { provider, serverUrl, extensionOrigin });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// `chosen`: whether a provider is chosen at all, so a panel with no helper
|
|
178
|
+
// running can say to pick one rather than to start the helper.
|
|
179
|
+
function runtimeMessage() {
|
|
180
|
+
return { type: 'runtime', runtime: runtimeFor(settings), chosen: Boolean(settings.provider) };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function broadcast(message) {
|
|
184
|
+
for (const port of ports) {
|
|
185
|
+
try {
|
|
186
|
+
port.postMessage(message);
|
|
187
|
+
} catch {
|
|
188
|
+
// gone; its disconnect handler removes it
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function broadcastRuntime() {
|
|
194
|
+
broadcast(runtimeMessage());
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---- the panel's model switcher ------------------------------------------
|
|
198
|
+
|
|
199
|
+
// Every saved profile with whether it can be switched to now and, if not,
|
|
200
|
+
// a one-line reason. Only non-secret fields.
|
|
201
|
+
async function profilesMessage() {
|
|
202
|
+
const current = runtimeFor(settings);
|
|
203
|
+
const entries = [];
|
|
204
|
+
for (const profile of settings.profiles) {
|
|
205
|
+
const entry = profileEntry(settings, profile);
|
|
206
|
+
let reason = null;
|
|
207
|
+
if (entry.runtime === 'extension') {
|
|
208
|
+
const draft = withProfile(settings, profile);
|
|
209
|
+
reason = profileProblem(draft, secrets);
|
|
210
|
+
if (!reason) {
|
|
211
|
+
const permission = await permissionFor(draft);
|
|
212
|
+
if (!permission.granted) reason = `Not allowed to reach ${permission.pattern.replace(/\/\*$/, '')} yet`;
|
|
213
|
+
}
|
|
214
|
+
} else {
|
|
215
|
+
try {
|
|
216
|
+
reason = (await helperProblem(current)) || null;
|
|
217
|
+
} catch {
|
|
218
|
+
reason = null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
entries.push({ ...entry, available: !reason, reason });
|
|
222
|
+
}
|
|
223
|
+
return { type: 'profiles', profiles: entries, active: settings.activeProfile };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function broadcastProfiles() {
|
|
227
|
+
if (ports.size === 0) return;
|
|
228
|
+
broadcast(await profilesMessage());
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Every change to the settings (a switch, a helper's switch noted, an
|
|
232
|
+
// options-page save) reads the current settings, checks, writes storage and
|
|
233
|
+
// takes the result in one step on this queue, so none of them works from a
|
|
234
|
+
// copy another one replaced while it awaited (a permission check, the
|
|
235
|
+
// storage write) and overwrites it. Only that step is queued: the engine
|
|
236
|
+
// swap after it, which may wait for a running reply, is not, so a save is
|
|
237
|
+
// never held up behind a reply.
|
|
238
|
+
let commitChain = Promise.resolve();
|
|
239
|
+
function commit(step) {
|
|
240
|
+
const run = commitChain.then(step);
|
|
241
|
+
commitChain = run.catch(() => {});
|
|
242
|
+
return run;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Switches to a saved profile: only the active profile changes (never a
|
|
246
|
+
// key, a URL or anything else), saved like any other setting, and the new
|
|
247
|
+
// backend goes in through the engine's turn queue, so a reply already
|
|
248
|
+
// running finishes first and the chat, statement and code move over.
|
|
249
|
+
// Resolves once the swap has happened. One switch at a time.
|
|
250
|
+
let switchChain = Promise.resolve();
|
|
251
|
+
function switchProfile(id) {
|
|
252
|
+
const run = switchChain.then(() => doSwitchProfile(id));
|
|
253
|
+
switchChain = run.catch(() => {});
|
|
254
|
+
return run;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function doSwitchProfile(id) {
|
|
258
|
+
const result = await commit(async () => {
|
|
259
|
+
const profile = findProfile(settings.profiles, id);
|
|
260
|
+
if (!profile) return { ok: false, code: 'unknown', message: 'That model is no longer saved. Open Manage models to add it again.' };
|
|
261
|
+
if (settings.activeProfile === id) return { ok: true, runtime: runtimeFor(settings), unchanged: true };
|
|
262
|
+
const next = applyPatch(settings, secrets, { activeProfile: id });
|
|
263
|
+
const runtime = runtimeFor(next.settings);
|
|
264
|
+
if (runtime === 'extension') {
|
|
265
|
+
const problem = profileProblem(next.settings, next.secrets);
|
|
266
|
+
if (problem) return { ok: false, code: 'unavailable', message: `${problem}. Open Manage models to finish setting it up.` };
|
|
267
|
+
const permission = await permissionFor(next.settings);
|
|
268
|
+
if (!permission.granted) return { ok: false, code: 'unavailable', message: permissionMessage(next.settings) };
|
|
269
|
+
try {
|
|
270
|
+
buildBackend(next.settings, next.secrets, { probe: async () => null });
|
|
271
|
+
} catch (err) {
|
|
272
|
+
return { ok: false, code: 'unavailable', message: scrub(err.message, [next.secrets[next.settings.provider]]) };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
await storage.set({ [SETTINGS_KEY]: next.settings });
|
|
276
|
+
settings = next.settings;
|
|
277
|
+
return { ok: true, runtime };
|
|
278
|
+
});
|
|
279
|
+
if (!result.ok) return result;
|
|
280
|
+
if (result.unchanged) return { ok: true, runtime: result.runtime };
|
|
281
|
+
await applyToEngine();
|
|
282
|
+
broadcastRuntime();
|
|
283
|
+
onRuntime(runtimeFor(settings));
|
|
284
|
+
await broadcastProfiles();
|
|
285
|
+
return { ok: true, runtime: result.runtime };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// The helper's companion switched to a profile (the panel asked it through
|
|
289
|
+
// the relay); the extension remembers which one is active. Nothing else
|
|
290
|
+
// changes: the tutor keeps running through the helper.
|
|
291
|
+
async function noteHelperSwitch(id) {
|
|
292
|
+
const changed = await commit(async () => {
|
|
293
|
+
const profile = findProfile(settings.profiles, id);
|
|
294
|
+
if (!profile || settings.activeProfile === id || runtimeFor(settings) !== 'helper') return false;
|
|
295
|
+
const next = applyPatch(settings, secrets, { activeProfile: id });
|
|
296
|
+
if (runtimeFor(next.settings) !== 'helper') return false;
|
|
297
|
+
await storage.set({ [SETTINGS_KEY]: next.settings });
|
|
298
|
+
settings = next.settings;
|
|
299
|
+
return true;
|
|
300
|
+
});
|
|
301
|
+
if (!changed) return;
|
|
302
|
+
await applyToEngine();
|
|
303
|
+
await broadcastProfiles();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---- the options page -------------------------------------------------
|
|
307
|
+
|
|
308
|
+
async function view(extra = {}) {
|
|
309
|
+
const permission = await permissionFor(settings);
|
|
310
|
+
return { settings: publicView(settings, secrets), active: statusSummary(settings), permission, catalog: PROVIDERS, ...extra };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function save({ patch }) {
|
|
314
|
+
await commit(async () => {
|
|
315
|
+
const next = applyPatch(settings, secrets, patch);
|
|
316
|
+
if (runtimeFor(next.settings) === 'extension') {
|
|
317
|
+
const problem = missingRequirement(next.settings, next.secrets);
|
|
318
|
+
if (problem) throw new SettingsError(problem, 'provider');
|
|
319
|
+
try {
|
|
320
|
+
// Only checks that a backend can be built; the probe stays off.
|
|
321
|
+
buildBackend(next.settings, next.secrets, { probe: async () => null });
|
|
322
|
+
} catch (err) {
|
|
323
|
+
throw new SettingsError(scrub(err.message, [next.secrets[next.settings.provider]]), err.field || 'provider');
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
await storage.set({ [SETTINGS_KEY]: next.settings, [SECRETS_KEY]: next.secrets });
|
|
327
|
+
settings = next.settings;
|
|
328
|
+
secrets = next.secrets;
|
|
329
|
+
});
|
|
330
|
+
await applyToEngine();
|
|
331
|
+
broadcastRuntime();
|
|
332
|
+
onRuntime(runtimeFor(settings));
|
|
333
|
+
await broadcastProfiles();
|
|
334
|
+
return view({ saved: true });
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function test({ patch }) {
|
|
338
|
+
let draft;
|
|
339
|
+
try {
|
|
340
|
+
draft = applyPatch(settings, secrets, patch || {});
|
|
341
|
+
} catch (err) {
|
|
342
|
+
return { ok: false, message: err.message };
|
|
343
|
+
}
|
|
344
|
+
const secret = draft.secrets[draft.settings.provider];
|
|
345
|
+
const problem = missingRequirement(draft.settings, draft.secrets);
|
|
346
|
+
if (problem) return { ok: false, message: problem };
|
|
347
|
+
if (runtimeFor(draft.settings) !== 'extension') return { ok: false, message: 'This provider is tested through the helper app.' };
|
|
348
|
+
const permission = await permissionFor(draft.settings);
|
|
349
|
+
if (!permission.granted) return { ok: false, message: permissionMessage(draft.settings), permission };
|
|
350
|
+
const started = Date.now();
|
|
351
|
+
try {
|
|
352
|
+
const backend = buildBackendImpl(draft.settings, draft.secrets);
|
|
353
|
+
const reply = await withTimeout(backend.sendMessage(CONNECTION_TEST_PROMPT, {}), TEST_TIMEOUT_MS, PROVIDERS[draft.settings.provider].label);
|
|
354
|
+
if (!reply || !String(reply).trim()) return { ok: false, message: 'The model answered with nothing.' };
|
|
355
|
+
return { ok: true, message: `Connected in ${((Date.now() - started) / 1000).toFixed(1)}s`, reply: scrub(String(reply).trim().slice(0, 120), [secret]) };
|
|
356
|
+
} catch (err) {
|
|
357
|
+
return { ok: false, message: makeExplainer(draft.settings, draft.secrets)(err.message) };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function models({ provider, baseUrl, apiKey }) {
|
|
362
|
+
const id = provider === 'anthropic' || provider === 'openai' ? provider : 'local';
|
|
363
|
+
const patch = { provider: id, providers: { [id]: {} } };
|
|
364
|
+
if (baseUrl && id !== 'anthropic') patch.providers[id].baseUrl = baseUrl;
|
|
365
|
+
if (typeof apiKey === 'string' && apiKey) patch.providers[id].apiKey = apiKey;
|
|
366
|
+
let draft;
|
|
367
|
+
try {
|
|
368
|
+
draft = applyPatch(settings, secrets, patch);
|
|
369
|
+
} catch (err) {
|
|
370
|
+
return { models: [], message: err.message };
|
|
371
|
+
}
|
|
372
|
+
const key = draft.secrets[id];
|
|
373
|
+
const permission = await permissionFor(draft.settings);
|
|
374
|
+
if (!permission.granted) return { models: [], message: permissionMessage(draft.settings), permission };
|
|
375
|
+
const signal = AbortSignal.timeout(MODELS_TIMEOUT_MS);
|
|
376
|
+
try {
|
|
377
|
+
let names = [];
|
|
378
|
+
if (id === 'anthropic') {
|
|
379
|
+
if (!key) return { models: [], message: 'Enter the API key first.' };
|
|
380
|
+
names = await listAnthropicModels({ apiKey: key, signal });
|
|
381
|
+
} else {
|
|
382
|
+
const base = draft.settings.providers[id].baseUrl.replace(/\/+$/, '');
|
|
383
|
+
const headers = key ? { Authorization: `Bearer ${key}` } : {};
|
|
384
|
+
const ask = async (url) => {
|
|
385
|
+
const res = await fetch(url, { headers, signal });
|
|
386
|
+
if (!res.ok) throw new Error(`${url} answered ${res.status}`);
|
|
387
|
+
return res.json();
|
|
388
|
+
};
|
|
389
|
+
if (id === 'local' && /\/v1$/.test(base)) {
|
|
390
|
+
try {
|
|
391
|
+
const tags = await ask(`${base.replace(/\/v1$/, '')}/api/tags`);
|
|
392
|
+
names = (tags.models || []).map((m) => m.name);
|
|
393
|
+
} catch {
|
|
394
|
+
names = [];
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (names.length === 0) {
|
|
398
|
+
const list = await ask(`${base}/models`);
|
|
399
|
+
names = (list.data || []).map((m) => m.id);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return { models: [...new Set(names.filter((n) => typeof n === 'string' && n))].sort().slice(0, MAX_MODELS) };
|
|
403
|
+
} catch (err) {
|
|
404
|
+
const message = err && err.name === 'TimeoutError' ? 'The server did not answer in time.' : err.message;
|
|
405
|
+
return { models: [], message: makeExplainer(draft.settings, draft.secrets)(scrub(message, [key])) };
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const SETTINGS_METHODS = {
|
|
410
|
+
'lc-tutor:settings-get': () => view(),
|
|
411
|
+
'lc-tutor:settings-save': save,
|
|
412
|
+
'lc-tutor:settings-test': test,
|
|
413
|
+
'lc-tutor:settings-models': models,
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
function isExtensionPage(sender) {
|
|
417
|
+
return Boolean(sender && typeof sender.url === 'string' && sender.url.startsWith(`${extensionOrigin}/`));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
ready,
|
|
422
|
+
hub,
|
|
423
|
+
engine,
|
|
424
|
+
|
|
425
|
+
// A runtime message. Resolves to the answer, or undefined when the
|
|
426
|
+
// message is not for this service.
|
|
427
|
+
async handleMessage(message, sender) {
|
|
428
|
+
if (!message || typeof message !== 'object') return undefined;
|
|
429
|
+
await ready;
|
|
430
|
+
if (message.type === 'lc-tutor:capture') {
|
|
431
|
+
if (runtimeFor(settings) !== 'extension') return { handled: false };
|
|
432
|
+
engine.handleCapture(message.capture);
|
|
433
|
+
return { handled: true };
|
|
434
|
+
}
|
|
435
|
+
if (message.type === 'lc-tutor:runtime') return { runtime: runtimeFor(settings) };
|
|
436
|
+
const method = Object.prototype.hasOwnProperty.call(SETTINGS_METHODS, message.type) ? SETTINGS_METHODS[message.type] : null;
|
|
437
|
+
if (!method) return undefined;
|
|
438
|
+
// Only the extension's own pages (the options page) may read or change
|
|
439
|
+
// settings, never a content script on leetcode.com.
|
|
440
|
+
if (!isExtensionPage(sender)) return { ok: false, error: { message: 'not allowed' } };
|
|
441
|
+
try {
|
|
442
|
+
return { ok: true, result: await method(message.params && typeof message.params === 'object' ? message.params : {}) };
|
|
443
|
+
} catch (err) {
|
|
444
|
+
if (err instanceof SettingsError) return { ok: false, error: { message: err.message, field: err.field } };
|
|
445
|
+
return { ok: false, error: { message: scrub(err.message, Object.values(secrets)) } };
|
|
446
|
+
}
|
|
447
|
+
},
|
|
448
|
+
|
|
449
|
+
// A Tutor panel's port.
|
|
450
|
+
connectPort(port) {
|
|
451
|
+
ports.add(port);
|
|
452
|
+
let unsubscribe = null;
|
|
453
|
+
const post = (message) => {
|
|
454
|
+
try {
|
|
455
|
+
port.postMessage(message);
|
|
456
|
+
} catch {
|
|
457
|
+
// disconnected
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
ready.then(async () => {
|
|
461
|
+
post(runtimeMessage());
|
|
462
|
+
post(await profilesMessage());
|
|
463
|
+
});
|
|
464
|
+
port.onMessage.addListener((message) => {
|
|
465
|
+
if (!message || typeof message !== 'object') return;
|
|
466
|
+
ready.then(() => {
|
|
467
|
+
if (message.type === 'subscribe') {
|
|
468
|
+
if (unsubscribe) unsubscribe();
|
|
469
|
+
unsubscribe = hub.subscribe((event) => post({ type: 'event', event }), {
|
|
470
|
+
after: Number(message.after) || 0,
|
|
471
|
+
epoch: typeof message.epoch === 'string' ? message.epoch : null,
|
|
472
|
+
});
|
|
473
|
+
} else if (message.type === 'unsubscribe') {
|
|
474
|
+
if (unsubscribe) unsubscribe();
|
|
475
|
+
unsubscribe = null;
|
|
476
|
+
} else if (message.type === 'message') {
|
|
477
|
+
const answer =
|
|
478
|
+
runtimeFor(settings) === 'extension'
|
|
479
|
+
? engine.handleMessage(message)
|
|
480
|
+
: { ok: false, error: 'helper' };
|
|
481
|
+
post({ type: 'reply', id: message.id, ...answer });
|
|
482
|
+
} else if (message.type === 'switch-profile') {
|
|
483
|
+
const reqId = message.reqId;
|
|
484
|
+
switchProfile(typeof message.id === 'string' ? message.id : '').then(
|
|
485
|
+
(result) => post({ type: 'switch-result', reqId, ...result }),
|
|
486
|
+
(err) => post({ type: 'switch-result', reqId, ok: false, code: 'failed', message: scrub(err.message, Object.values(secrets)) })
|
|
487
|
+
);
|
|
488
|
+
} else if (message.type === 'profile-switched' && typeof message.id === 'string') {
|
|
489
|
+
noteHelperSwitch(message.id).catch((err) => log(`could not note a model switch: ${err.message}`));
|
|
490
|
+
}
|
|
491
|
+
// 'ping' needs no answer: receiving it is what keeps the worker up.
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
port.onDisconnect.addListener(() => {
|
|
495
|
+
ports.delete(port);
|
|
496
|
+
if (unsubscribe) unsubscribe();
|
|
497
|
+
});
|
|
498
|
+
},
|
|
499
|
+
|
|
500
|
+
// Re-checks what the current settings need (call when host permissions
|
|
501
|
+
// change).
|
|
502
|
+
refresh() {
|
|
503
|
+
return ready.then(async () => {
|
|
504
|
+
await applyToEngine();
|
|
505
|
+
await broadcastProfiles();
|
|
506
|
+
});
|
|
507
|
+
},
|
|
508
|
+
|
|
509
|
+
// Sends every panel the switcher's entries again (call when what the
|
|
510
|
+
// helper can do changed).
|
|
511
|
+
refreshProfiles() {
|
|
512
|
+
return ready.then(() => broadcastProfiles());
|
|
513
|
+
},
|
|
514
|
+
|
|
515
|
+
// For tests: the same as a panel's 'switch-profile'.
|
|
516
|
+
switchProfile(id) {
|
|
517
|
+
return ready.then(() => switchProfile(id));
|
|
518
|
+
},
|
|
519
|
+
};
|
|
520
|
+
}
|