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,445 @@
|
|
|
1
|
+
// extension/shared/extension-settings.js - the extension runtime's own
|
|
2
|
+
// settings: which provider the tutor uses and where it runs, the fields of
|
|
3
|
+
// the providers that can run inside the extension, and how a backend is built
|
|
4
|
+
// from them. The background worker keeps them in chrome.storage.local (see
|
|
5
|
+
// background.js); the companion's settings (companion/settings.js) are only
|
|
6
|
+
// used for the helper runtime.
|
|
7
|
+
//
|
|
8
|
+
// Two stored records, so a key can never ride along with anything else:
|
|
9
|
+
// settings: { version, provider, runtimes, providers, options, profiles,
|
|
10
|
+
// activeProfile }, no secrets
|
|
11
|
+
// secrets: { local, anthropic, openai }, the API keys
|
|
12
|
+
// publicView() is all the options page is ever given: `hasKey`, never a key.
|
|
13
|
+
//
|
|
14
|
+
// `profiles` are the saved models the Tutor panel's switcher lists (see
|
|
15
|
+
// profiles.js); `activeProfile` is the one in use. `provider` and the active
|
|
16
|
+
// provider's `model` always match the active profile, so everything that
|
|
17
|
+
// reads them (runtimeFor, buildBackend, statusSummary) needs no change. A
|
|
18
|
+
// record from before profiles becomes one profile (migrateProfiles).
|
|
19
|
+
//
|
|
20
|
+
// Pure JavaScript with no Node API (extension/shared.test.js checks that).
|
|
21
|
+
|
|
22
|
+
import { PROVIDERS, OLLAMA_BASE_URL, OPENAI_BASE_URL, defaultRuntime } from './providers.js';
|
|
23
|
+
import { LocalBackend } from './local-backend.js';
|
|
24
|
+
import { AnthropicBackend, ANTHROPIC_BASE_URL, DEFAULT_ANTHROPIC_MODEL } from './anthropic-backend.js';
|
|
25
|
+
import { ProfileError, cleanActiveProfile, cleanProfiles, findProfile, migrateProfiles, profileName } from './profiles.js';
|
|
26
|
+
|
|
27
|
+
export const EXTENSION_SETTINGS_VERSION = 2;
|
|
28
|
+
// The providers the extension can run itself.
|
|
29
|
+
export const EXTENSION_PROVIDERS = Object.keys(PROVIDERS).filter((id) => PROVIDERS[id].runtimes.includes('extension'));
|
|
30
|
+
export const DEFAULT_LOCAL_CONTEXT_TOKENS = 8192;
|
|
31
|
+
// Same assumption as the companion (companion/settings.js): 128k is the
|
|
32
|
+
// floor for current hosted OpenAI models.
|
|
33
|
+
export const OPENAI_CONTEXT_TOKENS = 128000;
|
|
34
|
+
export const DEFAULT_OPTIONS = { autoClearContext: true, localMaxHistoryTurns: 6 };
|
|
35
|
+
// What the context-window field is called in errors the tutor shows.
|
|
36
|
+
export const WINDOW_SETTING = 'the "Context window (tokens)" setting';
|
|
37
|
+
|
|
38
|
+
const DEFAULT_FIELDS = {
|
|
39
|
+
local: { baseUrl: OLLAMA_BASE_URL, model: null, contextTokens: DEFAULT_LOCAL_CONTEXT_TOKENS },
|
|
40
|
+
anthropic: { model: null },
|
|
41
|
+
openai: { model: null, baseUrl: OPENAI_BASE_URL },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export class SettingsError extends Error {
|
|
45
|
+
constructor(message, field) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = 'SettingsError';
|
|
48
|
+
this.field = field || null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function emptySettings() {
|
|
53
|
+
const providers = {};
|
|
54
|
+
const runtimes = {};
|
|
55
|
+
for (const id of EXTENSION_PROVIDERS) {
|
|
56
|
+
providers[id] = { ...DEFAULT_FIELDS[id] };
|
|
57
|
+
runtimes[id] = defaultRuntime(id);
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
version: EXTENSION_SETTINGS_VERSION,
|
|
61
|
+
provider: null,
|
|
62
|
+
runtimes,
|
|
63
|
+
providers,
|
|
64
|
+
options: { ...DEFAULT_OPTIONS },
|
|
65
|
+
profiles: [],
|
|
66
|
+
activeProfile: null,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function emptySecrets() {
|
|
71
|
+
const secrets = {};
|
|
72
|
+
for (const id of EXTENSION_PROVIDERS) secrets[id] = null;
|
|
73
|
+
return secrets;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---- validation ------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
function cleanString(value, max, field) {
|
|
79
|
+
if (value === null || value === undefined) return null;
|
|
80
|
+
if (typeof value !== 'string') throw new SettingsError(`${field} must be text`, field);
|
|
81
|
+
const trimmed = value.trim();
|
|
82
|
+
if (!trimmed) return null;
|
|
83
|
+
if (trimmed.length > max) throw new SettingsError(`${field} is too long`, field);
|
|
84
|
+
// eslint-disable-next-line no-control-regex
|
|
85
|
+
if (/[\u0000-\u001f\u007f]/.test(trimmed)) throw new SettingsError(`${field} contains control characters`, field);
|
|
86
|
+
return trimmed;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function cleanUrl(value, field = 'baseUrl') {
|
|
90
|
+
const text = cleanString(value, 500, field);
|
|
91
|
+
if (text === null) return null;
|
|
92
|
+
let url;
|
|
93
|
+
try {
|
|
94
|
+
url = new URL(text);
|
|
95
|
+
} catch {
|
|
96
|
+
throw new SettingsError(`${field} must be a full URL like http://localhost:11434/v1`, field);
|
|
97
|
+
}
|
|
98
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
99
|
+
throw new SettingsError(`${field} must start with http:// or https://`, field);
|
|
100
|
+
}
|
|
101
|
+
if (url.username || url.password) throw new SettingsError(`${field} must not contain a user name or password`, field);
|
|
102
|
+
return text.replace(/\/+$/, '');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function cleanApiKey(value) {
|
|
106
|
+
const text = cleanString(value, 400, 'apiKey');
|
|
107
|
+
if (text !== null && /\s/.test(text)) throw new SettingsError('apiKey must not contain spaces', 'apiKey');
|
|
108
|
+
return text;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function cleanInt(value, min, max, field) {
|
|
112
|
+
if (value === null || value === undefined || value === '') return null;
|
|
113
|
+
const n = Number(value);
|
|
114
|
+
if (!Number.isInteger(n) || n < min || n > max) {
|
|
115
|
+
throw new SettingsError(`${field} must be a whole number between ${min} and ${max}`, field);
|
|
116
|
+
}
|
|
117
|
+
return n;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Stored values from an older or hand-edited record are taken field by field;
|
|
121
|
+
// anything invalid falls back to the default rather than breaking the page.
|
|
122
|
+
export function normalizeSettings(raw) {
|
|
123
|
+
const out = emptySettings();
|
|
124
|
+
if (!raw || typeof raw !== 'object') return out;
|
|
125
|
+
if (typeof raw.provider === 'string' && PROVIDERS[raw.provider]) out.provider = raw.provider;
|
|
126
|
+
for (const id of EXTENSION_PROVIDERS) {
|
|
127
|
+
const runtime = raw.runtimes && raw.runtimes[id];
|
|
128
|
+
if (PROVIDERS[id].runtimes.includes(runtime)) out.runtimes[id] = runtime;
|
|
129
|
+
const fields = (raw.providers && raw.providers[id]) || {};
|
|
130
|
+
for (const field of Object.keys(DEFAULT_FIELDS[id])) {
|
|
131
|
+
try {
|
|
132
|
+
const value = applyField(field, fields[field]);
|
|
133
|
+
if (value !== undefined && value !== null) out.providers[id][field] = value;
|
|
134
|
+
} catch {
|
|
135
|
+
// keep the default
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const options = raw.options || {};
|
|
140
|
+
if (typeof options.autoClearContext === 'boolean') out.options.autoClearContext = options.autoClearContext;
|
|
141
|
+
try {
|
|
142
|
+
const turns = cleanInt(options.localMaxHistoryTurns, 0, 100, 'localMaxHistoryTurns');
|
|
143
|
+
if (turns !== null) out.options.localMaxHistoryTurns = turns;
|
|
144
|
+
} catch {
|
|
145
|
+
// keep the default
|
|
146
|
+
}
|
|
147
|
+
const providerModel = out.provider && out.providers[out.provider] ? out.providers[out.provider].model : null;
|
|
148
|
+
const migrated = migrateProfiles({
|
|
149
|
+
profiles: cleanProfiles(raw.profiles, PROVIDERS),
|
|
150
|
+
activeProfile: raw.activeProfile,
|
|
151
|
+
provider: out.provider,
|
|
152
|
+
model: providerModel,
|
|
153
|
+
});
|
|
154
|
+
out.profiles = migrated.profiles;
|
|
155
|
+
out.activeProfile = migrated.activeProfile;
|
|
156
|
+
syncActive(out);
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Makes `provider` and that provider's model match the active profile.
|
|
161
|
+
function syncActive(settings) {
|
|
162
|
+
const profile = findProfile(settings.profiles, settings.activeProfile);
|
|
163
|
+
if (!profile) return;
|
|
164
|
+
settings.provider = profile.provider;
|
|
165
|
+
if (settings.providers[profile.provider]) settings.providers[profile.provider].model = profile.model;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// An id for a profile the store makes itself, unused in `profiles`.
|
|
169
|
+
function freeProfileId(profiles, base) {
|
|
170
|
+
const stem = base.replace(/[^a-z0-9-]/g, '').slice(0, 30) || 'model';
|
|
171
|
+
for (let n = 1; ; n += 1) {
|
|
172
|
+
const id = n === 1 ? stem : `${stem}-${n}`;
|
|
173
|
+
if (!findProfile(profiles, id)) return id;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// A patch from before profiles (a provider and its fields, no profile list):
|
|
178
|
+
// the active profile follows it, as it would have been migrated.
|
|
179
|
+
function followLegacyPatch(next) {
|
|
180
|
+
if (!next.provider) {
|
|
181
|
+
next.activeProfile = null;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const model = next.providers[next.provider] ? next.providers[next.provider].model : null;
|
|
185
|
+
const active = findProfile(next.profiles, next.activeProfile);
|
|
186
|
+
if (active && active.provider === next.provider) {
|
|
187
|
+
if (next.providers[next.provider]) active.model = model;
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const same = next.profiles.find((p) => p.provider === next.provider && (!next.providers[next.provider] || p.model === model));
|
|
191
|
+
if (same) {
|
|
192
|
+
next.activeProfile = same.id;
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const profile = { id: freeProfileId(next.profiles, next.profiles.length ? next.provider : 'default'), provider: next.provider, model, label: null };
|
|
196
|
+
next.profiles.push(profile);
|
|
197
|
+
next.activeProfile = profile.id;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function normalizeSecrets(raw) {
|
|
201
|
+
const out = emptySecrets();
|
|
202
|
+
if (!raw || typeof raw !== 'object') return out;
|
|
203
|
+
for (const id of EXTENSION_PROVIDERS) {
|
|
204
|
+
try {
|
|
205
|
+
out[id] = cleanApiKey(raw[id]);
|
|
206
|
+
} catch {
|
|
207
|
+
out[id] = null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function applyField(field, value) {
|
|
214
|
+
if (field === 'baseUrl') return cleanUrl(value);
|
|
215
|
+
if (field === 'model') return cleanString(value, 200, 'model');
|
|
216
|
+
if (field === 'contextTokens') return cleanInt(value, 1024, 2_000_000, 'contextTokens');
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Applies an options-page patch:
|
|
221
|
+
// { provider?, runtime?, providers?: { id: { field: value, apiKey?: string|null } }, options? }
|
|
222
|
+
// `runtime` applies to the chosen provider. A provider field set to null goes
|
|
223
|
+
// back to its default; an apiKey of null removes the key, a missing one keeps
|
|
224
|
+
// it. Returns new { settings, secrets }; throws SettingsError naming the field.
|
|
225
|
+
export function applyPatch(settings, secrets, patch) {
|
|
226
|
+
const next = normalizeSettings(settings);
|
|
227
|
+
const nextSecrets = { ...normalizeSecrets(secrets) };
|
|
228
|
+
if (!patch || typeof patch !== 'object') return { settings: next, secrets: nextSecrets };
|
|
229
|
+
if (patch.provider !== undefined) {
|
|
230
|
+
if (patch.provider !== null && !PROVIDERS[patch.provider]) throw new SettingsError('unknown provider', 'provider');
|
|
231
|
+
next.provider = patch.provider;
|
|
232
|
+
}
|
|
233
|
+
if (patch.runtime !== undefined) {
|
|
234
|
+
const id = next.provider;
|
|
235
|
+
if (!id || !PROVIDERS[id].runtimes.includes(patch.runtime)) {
|
|
236
|
+
throw new SettingsError(`${id ? PROVIDERS[id].label : 'This provider'} cannot run there`, 'runtime');
|
|
237
|
+
}
|
|
238
|
+
if (next.runtimes[id] !== undefined) next.runtimes[id] = patch.runtime;
|
|
239
|
+
}
|
|
240
|
+
for (const [id, fields] of Object.entries(patch.providers || {})) {
|
|
241
|
+
if (!EXTENSION_PROVIDERS.includes(id) || !fields || typeof fields !== 'object') continue;
|
|
242
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
243
|
+
if (field === 'apiKey') {
|
|
244
|
+
if (value === undefined) continue;
|
|
245
|
+
nextSecrets[id] = value === null ? null : cleanApiKey(value);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (!(field in DEFAULT_FIELDS[id])) continue;
|
|
249
|
+
const cleaned = applyField(field, value);
|
|
250
|
+
next.providers[id][field] = cleaned === null ? DEFAULT_FIELDS[id][field] : cleaned;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (patch.profiles !== undefined) {
|
|
254
|
+
try {
|
|
255
|
+
next.profiles = cleanProfiles(patch.profiles, PROVIDERS, { strict: true });
|
|
256
|
+
} catch (err) {
|
|
257
|
+
throw new SettingsError(err instanceof ProfileError ? err.message : 'profiles are not valid', 'profiles');
|
|
258
|
+
}
|
|
259
|
+
next.activeProfile = cleanActiveProfile(next.profiles, next.activeProfile);
|
|
260
|
+
}
|
|
261
|
+
if (patch.activeProfile !== undefined) {
|
|
262
|
+
if (patch.activeProfile === null) next.activeProfile = null;
|
|
263
|
+
else if (!findProfile(next.profiles, patch.activeProfile)) throw new SettingsError('That model is not in the saved list.', 'activeProfile');
|
|
264
|
+
else next.activeProfile = patch.activeProfile;
|
|
265
|
+
}
|
|
266
|
+
if (patch.profiles === undefined && patch.activeProfile === undefined && (patch.provider !== undefined || patch.providers !== undefined)) {
|
|
267
|
+
followLegacyPatch(next);
|
|
268
|
+
}
|
|
269
|
+
syncActive(next);
|
|
270
|
+
const options = patch.options || {};
|
|
271
|
+
if (options.autoClearContext !== undefined) {
|
|
272
|
+
if (typeof options.autoClearContext !== 'boolean') throw new SettingsError('autoClearContext must be on or off', 'autoClearContext');
|
|
273
|
+
next.options.autoClearContext = options.autoClearContext;
|
|
274
|
+
}
|
|
275
|
+
if (options.localMaxHistoryTurns !== undefined) {
|
|
276
|
+
const turns = cleanInt(options.localMaxHistoryTurns, 0, 100, 'localMaxHistoryTurns');
|
|
277
|
+
next.options.localMaxHistoryTurns = turns === null ? DEFAULT_OPTIONS.localMaxHistoryTurns : turns;
|
|
278
|
+
}
|
|
279
|
+
return { settings: next, secrets: nextSecrets };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---- what runs where -------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
// 'extension' or 'helper'. Nothing chosen yet means the helper, which is how
|
|
285
|
+
// the extension behaved before it could run the tutor itself, so an existing
|
|
286
|
+
// companion setup keeps working untouched.
|
|
287
|
+
export function runtimeFor(settings) {
|
|
288
|
+
const id = settings && settings.provider;
|
|
289
|
+
if (!id || !PROVIDERS[id]) return 'helper';
|
|
290
|
+
const runtime = settings.runtimes && settings.runtimes[id];
|
|
291
|
+
return PROVIDERS[id].runtimes.includes(runtime) ? runtime : defaultRuntime(id);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// What is missing before the extension can run the chosen provider, as a
|
|
295
|
+
// sentence for the options page and the panel, or null.
|
|
296
|
+
export function missingRequirement(settings, secrets) {
|
|
297
|
+
const id = settings.provider;
|
|
298
|
+
if (!id) return 'Choose a provider in the extension options.';
|
|
299
|
+
const fields = settings.providers[id] || {};
|
|
300
|
+
for (const need of PROVIDERS[id].requires) {
|
|
301
|
+
if (need === 'apiKey' ? !secrets[id] : !fields[need]) {
|
|
302
|
+
return `${PROVIDERS[id].label} needs ${need === 'apiKey' ? 'an API key' : `a ${need === 'model' ? 'model name' : need}`}.`;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// The server the chosen provider talks to.
|
|
309
|
+
export function serverUrlFor(settings) {
|
|
310
|
+
const id = settings.provider;
|
|
311
|
+
if (id === 'anthropic') return ANTHROPIC_BASE_URL;
|
|
312
|
+
if (id === 'local' || id === 'openai') return settings.providers[id].baseUrl || DEFAULT_FIELDS[id].baseUrl;
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// The host permission (an optional_host_permissions match pattern) the
|
|
317
|
+
// extension needs before it may reach that server.
|
|
318
|
+
export function originPatternFor(url) {
|
|
319
|
+
if (!url) return null;
|
|
320
|
+
try {
|
|
321
|
+
const u = new URL(url);
|
|
322
|
+
return `${u.protocol}//${u.hostname}/*`;
|
|
323
|
+
} catch {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// The non-secret summary for the panel header ({ provider, label, model }).
|
|
329
|
+
export function statusSummary(settings) {
|
|
330
|
+
const id = settings.provider;
|
|
331
|
+
if (!id) return null;
|
|
332
|
+
const fields = settings.providers[id] || {};
|
|
333
|
+
const model = fields.model || (id === 'anthropic' ? DEFAULT_ANTHROPIC_MODEL : null);
|
|
334
|
+
return { provider: id, label: PROVIDERS[id].label, model };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// What a backend is built from; unchanged means the running one is kept.
|
|
338
|
+
export function backendKey(settings, secrets) {
|
|
339
|
+
const id = settings.provider;
|
|
340
|
+
const fields = settings.providers[id] || {};
|
|
341
|
+
return JSON.stringify([id, fields.model, fields.baseUrl, fields.contextTokens, secrets[id] || null, settings.options.localMaxHistoryTurns]);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Builds the backend for the chosen provider. Throws SettingsError when it
|
|
345
|
+
// cannot be built from these settings.
|
|
346
|
+
export function buildBackend(settings, secrets, { probe } = {}) {
|
|
347
|
+
const id = settings.provider;
|
|
348
|
+
const problem = missingRequirement(settings, secrets);
|
|
349
|
+
if (problem) throw new SettingsError(problem, 'provider');
|
|
350
|
+
const fields = settings.providers[id];
|
|
351
|
+
const maxHistoryTurns = settings.options.localMaxHistoryTurns;
|
|
352
|
+
if (id === 'anthropic') {
|
|
353
|
+
return new AnthropicBackend({ apiKey: secrets.anthropic, model: fields.model, maxHistoryTurns });
|
|
354
|
+
}
|
|
355
|
+
if (id === 'local' || id === 'openai') {
|
|
356
|
+
return new LocalBackend({
|
|
357
|
+
baseUrl: fields.baseUrl || DEFAULT_FIELDS[id].baseUrl,
|
|
358
|
+
model: fields.model,
|
|
359
|
+
apiKey: secrets[id],
|
|
360
|
+
maxHistoryTurns,
|
|
361
|
+
// A hosted OpenAI API always speaks the OpenAI shape; a local server
|
|
362
|
+
// is probed (Ollama gets its native API, which honors num_ctx).
|
|
363
|
+
apiStyle: id === 'openai' ? 'openai' : null,
|
|
364
|
+
numCtx: id === 'openai' ? OPENAI_CONTEXT_TOKENS : fields.contextTokens || DEFAULT_LOCAL_CONTEXT_TOKENS,
|
|
365
|
+
windowSetting: WINDOW_SETTING,
|
|
366
|
+
...(probe ? { probe } : {}),
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
throw new SettingsError(`${PROVIDERS[id].label} needs the helper app`, 'runtime');
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function publicView(settings, secrets) {
|
|
373
|
+
const s = normalizeSettings(settings);
|
|
374
|
+
const keys = normalizeSecrets(secrets);
|
|
375
|
+
const providers = {};
|
|
376
|
+
for (const id of EXTENSION_PROVIDERS) providers[id] = { ...s.providers[id], hasKey: Boolean(keys[id]) };
|
|
377
|
+
return {
|
|
378
|
+
provider: s.provider,
|
|
379
|
+
runtime: runtimeFor(s),
|
|
380
|
+
runtimes: { ...s.runtimes },
|
|
381
|
+
providers,
|
|
382
|
+
options: { ...s.options },
|
|
383
|
+
profiles: s.profiles.map((p) => ({ ...p })),
|
|
384
|
+
activeProfile: s.activeProfile,
|
|
385
|
+
defaults: { local: { ...DEFAULT_FIELDS.local }, openai: { ...DEFAULT_FIELDS.openai }, anthropic: { model: DEFAULT_ANTHROPIC_MODEL } },
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ---- profiles for the panel's switcher -------------------------------------
|
|
390
|
+
|
|
391
|
+
// The settings with `profile` made the active one (nothing is saved).
|
|
392
|
+
export function withProfile(settings, profile) {
|
|
393
|
+
const next = normalizeSettings(settings);
|
|
394
|
+
next.activeProfile = profile.id;
|
|
395
|
+
if (!findProfile(next.profiles, profile.id)) next.profiles.push({ ...profile });
|
|
396
|
+
syncActive(next);
|
|
397
|
+
return next;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Why a profile that runs in the browser cannot be used yet, as a short
|
|
401
|
+
// line for the switcher, or null.
|
|
402
|
+
export function profileProblem(settings, secrets) {
|
|
403
|
+
const id = settings.provider;
|
|
404
|
+
const fields = settings.providers[id] || {};
|
|
405
|
+
for (const need of PROVIDERS[id].requires) {
|
|
406
|
+
if (need === 'apiKey' && !secrets[id]) return 'No API key saved';
|
|
407
|
+
if (need === 'model' && !fields.model) return 'No model chosen';
|
|
408
|
+
}
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// One switcher entry, without availability (the service adds it).
|
|
413
|
+
export function profileEntry(settings, profile) {
|
|
414
|
+
const spec = PROVIDERS[profile.provider];
|
|
415
|
+
const model = profile.model || (profile.provider === 'anthropic' ? DEFAULT_ANTHROPIC_MODEL : null);
|
|
416
|
+
return {
|
|
417
|
+
id: profile.id,
|
|
418
|
+
name: profileName({ ...profile, model }, PROVIDERS),
|
|
419
|
+
provider: profile.provider,
|
|
420
|
+
providerLabel: spec.label,
|
|
421
|
+
model,
|
|
422
|
+
runtime: runtimeFor(withProfile(settings, profile)),
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ---- plain error messages -------------------------------------------------
|
|
427
|
+
|
|
428
|
+
// Turns a failed request to the chosen provider into a message that says what
|
|
429
|
+
// to do. Ollama refuses requests from an extension's origin unless
|
|
430
|
+
// OLLAMA_ORIGINS allows it (it answers 403, or the browser reports a network
|
|
431
|
+
// error), which is the most likely first-run failure for a local model.
|
|
432
|
+
export function explainError(message, { provider, serverUrl, extensionOrigin }) {
|
|
433
|
+
const text = String(message || '');
|
|
434
|
+
if (provider !== 'local') return text;
|
|
435
|
+
const forbidden = /\b403\b/.test(text);
|
|
436
|
+
const unreachable = /could not reach|Failed to fetch|NetworkError|network error|fetch failed/i.test(text);
|
|
437
|
+
if (!forbidden && !unreachable) return text;
|
|
438
|
+
const origin = extensionOrigin || 'chrome-extension://<this extension id>';
|
|
439
|
+
const fix =
|
|
440
|
+
`If it is Ollama, it only answers browser extensions it is told to trust: set the environment variable ` +
|
|
441
|
+
`OLLAMA_ORIGINS=${origin} for the Ollama server (for example with "systemctl edit ollama.service" on Linux, ` +
|
|
442
|
+
`or "launchctl setenv OLLAMA_ORIGINS ${origin}" on macOS) and restart Ollama.`;
|
|
443
|
+
if (forbidden) return `The local server at ${serverUrl} refused the request (403). ${fix}`;
|
|
444
|
+
return `Couldn't reach the local server at ${serverUrl}. Check that it is running. ${fix}`;
|
|
445
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// The background worker's side of the Native Messaging helper (helper/ at the
|
|
2
|
+
// repository root): while the helper runtime is chosen, the nativeMessaging
|
|
3
|
+
// permission is granted and at least one LeetCode problem tab is open, keep a
|
|
4
|
+
// native port to the helper, which runs the companion and relay for as long
|
|
5
|
+
// as that port stays open.
|
|
6
|
+
//
|
|
7
|
+
// Tabs are counted through each tab's long-lived Tutor panel port (a hidden
|
|
8
|
+
// tab drops its event stream but keeps that port). The rules:
|
|
9
|
+
// - The first tab connects the helper and asks it to start.
|
|
10
|
+
// - After the last tab closes, wait GRACE_MS, then disconnect (the helper
|
|
11
|
+
// then stops the companion and relay). A tab opening meanwhile cancels it.
|
|
12
|
+
// - The active problem is the tab the student last sent a message, Run or
|
|
13
|
+
// Submit from. If that tab closes while others stay open, wait GRACE_MS,
|
|
14
|
+
// then ask the helper to reset the tutor's context (the processes keep
|
|
15
|
+
// running). Reloading that tab, or opening the same problem again,
|
|
16
|
+
// meanwhile cancels it.
|
|
17
|
+
// - A ping every PING_MS tells the helper someone is still there; it exits
|
|
18
|
+
// on its own after a few quiet minutes (a crashed browser, say).
|
|
19
|
+
// - A port that drops for another reason is retried with a growing delay.
|
|
20
|
+
// A helper that is not installed is not retried until retry().
|
|
21
|
+
//
|
|
22
|
+
// Pure JavaScript with no Node or browser API: connectNative, the timers and
|
|
23
|
+
// the error lookup are passed in (extension/shared.test.js checks that).
|
|
24
|
+
|
|
25
|
+
export const HELPER_HOST_NAME = 'dev.thomsonklee.leetcode_capture';
|
|
26
|
+
export const GRACE_MS = 30_000;
|
|
27
|
+
export const PING_MS = 60_000;
|
|
28
|
+
const RETRY_MIN_MS = 5000;
|
|
29
|
+
const RETRY_MAX_MS = 60_000;
|
|
30
|
+
|
|
31
|
+
// How Chrome and Firefox say the host is not registered.
|
|
32
|
+
const NOT_INSTALLED_RE = /not found|no such native application|not registered|access to the specified native messaging host is forbidden/i;
|
|
33
|
+
|
|
34
|
+
export function problemSlugFromUrl(url) {
|
|
35
|
+
const m = /^https:\/\/leetcode\.com\/problems\/([^/?#]+)/.exec(String(url || ''));
|
|
36
|
+
return m ? m[1] : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createHelperLauncher({
|
|
40
|
+
connectNative,
|
|
41
|
+
lastError = () => null,
|
|
42
|
+
timers,
|
|
43
|
+
graceMs = GRACE_MS,
|
|
44
|
+
pingMs = PING_MS,
|
|
45
|
+
log = () => {},
|
|
46
|
+
}) {
|
|
47
|
+
const tabs = new Map(); // tabId -> { ports, slug }
|
|
48
|
+
const listeners = new Set();
|
|
49
|
+
let wanted = false;
|
|
50
|
+
let port = null;
|
|
51
|
+
let state = 'idle'; // idle | connecting | running | not-installed | error
|
|
52
|
+
let host = null; // the helper's newest status message
|
|
53
|
+
let error = null;
|
|
54
|
+
let active = null; // { tabId, slug }
|
|
55
|
+
let stopTimer = null;
|
|
56
|
+
let resetTimer = null;
|
|
57
|
+
let resetFor = null; // { tabId, slug } the pending reset is about
|
|
58
|
+
let pingTimer = null;
|
|
59
|
+
let retryTimer = null;
|
|
60
|
+
let retryDelay = RETRY_MIN_MS;
|
|
61
|
+
let nextId = 1;
|
|
62
|
+
|
|
63
|
+
function emit() {
|
|
64
|
+
const snapshot = view();
|
|
65
|
+
for (const listener of listeners) listener(snapshot);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function view() {
|
|
69
|
+
return { wanted, state, tabs: tabs.size, host, error };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function post(message) {
|
|
73
|
+
if (!port) return false;
|
|
74
|
+
try {
|
|
75
|
+
port.postMessage({ id: nextId++, ...message });
|
|
76
|
+
return true;
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function clear(timer) {
|
|
83
|
+
if (timer !== null) timers.clearTimeout(timer);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function disconnect(nextState = 'idle') {
|
|
88
|
+
pingTimer = pingTimer !== null ? (timers.clearInterval(pingTimer), null) : null;
|
|
89
|
+
retryTimer = clear(retryTimer);
|
|
90
|
+
stopTimer = clear(stopTimer);
|
|
91
|
+
resetTimer = clear(resetTimer);
|
|
92
|
+
resetFor = null;
|
|
93
|
+
const current = port;
|
|
94
|
+
port = null;
|
|
95
|
+
if (current) {
|
|
96
|
+
try {
|
|
97
|
+
current.disconnect();
|
|
98
|
+
} catch {
|
|
99
|
+
// already gone
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
host = null;
|
|
103
|
+
if (state !== 'not-installed' || nextState !== 'idle') state = nextState;
|
|
104
|
+
emit();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function connect() {
|
|
108
|
+
if (port || state === 'not-installed') return;
|
|
109
|
+
retryTimer = clear(retryTimer);
|
|
110
|
+
let next;
|
|
111
|
+
try {
|
|
112
|
+
next = connectNative(HELPER_HOST_NAME);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
state = 'error';
|
|
115
|
+
error = err && err.message ? err.message : String(err);
|
|
116
|
+
emit();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
port = next;
|
|
120
|
+
state = 'connecting';
|
|
121
|
+
error = null;
|
|
122
|
+
next.onMessage.addListener((message) => {
|
|
123
|
+
if (port !== next || !message || typeof message !== 'object') return;
|
|
124
|
+
if (message.type === 'status') {
|
|
125
|
+
host = message;
|
|
126
|
+
error = message.error || null;
|
|
127
|
+
state = 'running';
|
|
128
|
+
retryDelay = RETRY_MIN_MS;
|
|
129
|
+
emit();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
next.onDisconnect.addListener(() => {
|
|
133
|
+
if (port !== next) return;
|
|
134
|
+
port = null;
|
|
135
|
+
pingTimer = pingTimer !== null ? (timers.clearInterval(pingTimer), null) : null;
|
|
136
|
+
host = null;
|
|
137
|
+
const reason = lastError(next);
|
|
138
|
+
if (reason && NOT_INSTALLED_RE.test(reason)) {
|
|
139
|
+
state = 'not-installed';
|
|
140
|
+
error = reason;
|
|
141
|
+
} else {
|
|
142
|
+
state = 'error';
|
|
143
|
+
error = reason || 'The helper stopped.';
|
|
144
|
+
log(`helper disconnected: ${error}`);
|
|
145
|
+
if (wanted && tabs.size > 0) {
|
|
146
|
+
retryTimer = timers.setTimeout(() => {
|
|
147
|
+
retryTimer = null;
|
|
148
|
+
if (wanted && tabs.size > 0) connect();
|
|
149
|
+
}, retryDelay);
|
|
150
|
+
retryDelay = Math.min(retryDelay * 2, RETRY_MAX_MS);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
emit();
|
|
154
|
+
});
|
|
155
|
+
post({ type: 'start' });
|
|
156
|
+
pingTimer = timers.setInterval(() => post({ type: 'ping' }), pingMs);
|
|
157
|
+
emit();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Whether the processes should be running right now, applied.
|
|
161
|
+
function update() {
|
|
162
|
+
if (!wanted) {
|
|
163
|
+
if (port || state === 'connecting' || retryTimer !== null) disconnect();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (tabs.size > 0) {
|
|
167
|
+
stopTimer = clear(stopTimer);
|
|
168
|
+
connect();
|
|
169
|
+
} else if (port && stopTimer === null) {
|
|
170
|
+
stopTimer = timers.setTimeout(() => {
|
|
171
|
+
stopTimer = null;
|
|
172
|
+
if (tabs.size === 0) disconnect();
|
|
173
|
+
}, graceMs);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
// A Tutor panel port opened in this tab.
|
|
179
|
+
tabOpened(tabId, url) {
|
|
180
|
+
const entry = tabs.get(tabId) || { ports: 0, slug: null };
|
|
181
|
+
entry.ports += 1;
|
|
182
|
+
entry.slug = problemSlugFromUrl(url) || entry.slug;
|
|
183
|
+
tabs.set(tabId, entry);
|
|
184
|
+
if (resetFor && (resetFor.tabId === tabId || (entry.slug && entry.slug === resetFor.slug))) {
|
|
185
|
+
resetTimer = clear(resetTimer);
|
|
186
|
+
resetFor = null;
|
|
187
|
+
active = { tabId, slug: entry.slug };
|
|
188
|
+
}
|
|
189
|
+
update();
|
|
190
|
+
emit();
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
tabClosed(tabId) {
|
|
194
|
+
const entry = tabs.get(tabId);
|
|
195
|
+
if (!entry) return;
|
|
196
|
+
entry.ports -= 1;
|
|
197
|
+
if (entry.ports > 0) return;
|
|
198
|
+
tabs.delete(tabId);
|
|
199
|
+
if (tabs.size > 0 && active && active.tabId === tabId && port) {
|
|
200
|
+
resetTimer = clear(resetTimer);
|
|
201
|
+
resetFor = active;
|
|
202
|
+
active = null;
|
|
203
|
+
resetTimer = timers.setTimeout(() => {
|
|
204
|
+
resetTimer = null;
|
|
205
|
+
resetFor = null;
|
|
206
|
+
post({ type: 'reset' });
|
|
207
|
+
}, graceMs);
|
|
208
|
+
}
|
|
209
|
+
update();
|
|
210
|
+
emit();
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
// The student sent a message, Run or Submit from this tab.
|
|
214
|
+
markActive(tabId) {
|
|
215
|
+
const entry = tabs.get(tabId);
|
|
216
|
+
if (!entry) return;
|
|
217
|
+
active = { tabId, slug: entry.slug };
|
|
218
|
+
// The pending reset was about the old problem; whatever the companion
|
|
219
|
+
// does on a switch of problem now applies instead.
|
|
220
|
+
resetTimer = clear(resetTimer);
|
|
221
|
+
resetFor = null;
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
// Whether the helper runtime is chosen and the permission granted.
|
|
225
|
+
setWanted(next) {
|
|
226
|
+
wanted = Boolean(next);
|
|
227
|
+
if (wanted && state === 'not-installed' && !port) state = 'idle';
|
|
228
|
+
update();
|
|
229
|
+
emit();
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
// Try again after the student installed the helper.
|
|
233
|
+
retry() {
|
|
234
|
+
if (state === 'not-installed' || state === 'error') state = 'idle';
|
|
235
|
+
retryDelay = RETRY_MIN_MS;
|
|
236
|
+
update();
|
|
237
|
+
emit();
|
|
238
|
+
return view();
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
view,
|
|
242
|
+
|
|
243
|
+
onChange(listener) {
|
|
244
|
+
listeners.add(listener);
|
|
245
|
+
return () => listeners.delete(listener);
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|