@mikedown/core 0.1.0 → 0.3.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 +20 -0
- package/README.md +183 -3
- package/dist/ai/engines-page.d.ts +130 -0
- package/dist/ai/engines-page.js +10 -0
- package/dist/ai/engines-page.js.map +1 -0
- package/dist/ai/index.d.ts +1531 -0
- package/dist/ai/index.js +395 -0
- package/dist/ai/index.js.map +1 -0
- package/dist/ai-ui-C7ENTWWU.js +3370 -0
- package/dist/ai-ui-C7ENTWWU.js.map +1 -0
- package/dist/chunk-7A5HEPLK.js +4910 -0
- package/dist/chunk-7A5HEPLK.js.map +1 -0
- package/dist/chunk-GWCRPPCX.js +18 -0
- package/dist/chunk-GWCRPPCX.js.map +1 -0
- package/dist/chunk-JZWKEBCI.js +3899 -0
- package/dist/chunk-JZWKEBCI.js.map +1 -0
- package/dist/chunk-L4FO63DF.js +113 -0
- package/dist/chunk-L4FO63DF.js.map +1 -0
- package/dist/chunk-LXZG7IKN.js +39 -0
- package/dist/chunk-LXZG7IKN.js.map +1 -0
- package/dist/chunk-QDVY7WW6.js +165 -0
- package/dist/chunk-QDVY7WW6.js.map +1 -0
- package/dist/chunk-QWKT57TR.js +62 -0
- package/dist/chunk-QWKT57TR.js.map +1 -0
- package/dist/chunk-TSP7BAV3.js +22 -0
- package/dist/chunk-TSP7BAV3.js.map +1 -0
- package/dist/index.d.ts +62 -441
- package/dist/index.js +6871 -1247
- package/dist/index.js.map +1 -1
- package/dist/schema-DYzxkuSb.d.ts +46 -0
- package/dist/shared/anchoring.js +4 -16
- package/dist/shared/anchoring.js.map +1 -1
- package/dist/shared/imagePaste.js +3 -57
- package/dist/shared/imagePaste.js.map +1 -1
- package/dist/shared/sha1.js +3 -57
- package/dist/shared/sha1.js.map +1 -1
- package/dist/shared/wordCount.js +4 -12
- package/dist/shared/wordCount.js.map +1 -1
- package/dist/styles.css +3700 -94
- package/dist/types-CgEeIUhi.d.ts +432 -0
- package/dist/types-CsVNVSNY.d.ts +597 -0
- package/package.json +17 -2
|
@@ -0,0 +1,4910 @@
|
|
|
1
|
+
import {
|
|
2
|
+
aiHostTier
|
|
3
|
+
} from "./chunk-LXZG7IKN.js";
|
|
4
|
+
import {
|
|
5
|
+
AI_CLOUD_PRIVACY,
|
|
6
|
+
AI_ENGINE_KINDS,
|
|
7
|
+
AI_ENGINE_PRESETS,
|
|
8
|
+
AI_FALLBACK_MODES,
|
|
9
|
+
AI_HOST_PRESET_FAMILY,
|
|
10
|
+
AI_HOST_PRIVACY,
|
|
11
|
+
AI_LOCAL_DETECT_TIMEOUT_MS,
|
|
12
|
+
AI_PRESET_FAMILIES,
|
|
13
|
+
AI_TASKS,
|
|
14
|
+
DEFAULT_CONTEXT_TOKENS,
|
|
15
|
+
aiConsentHost,
|
|
16
|
+
aiConsentStepNote,
|
|
17
|
+
aiErrorCopy,
|
|
18
|
+
aiProviderPolicy,
|
|
19
|
+
detectCapabilities,
|
|
20
|
+
formatModelSize,
|
|
21
|
+
generateAiId,
|
|
22
|
+
getAiTask,
|
|
23
|
+
isHostEngineKind,
|
|
24
|
+
isKnownEngineKind,
|
|
25
|
+
localDefaultModel,
|
|
26
|
+
localDetectCandidates,
|
|
27
|
+
mergeDiscoveredModels,
|
|
28
|
+
missingCapabilities,
|
|
29
|
+
modelMoeFastHint,
|
|
30
|
+
modelSizeHint,
|
|
31
|
+
presetForEngine,
|
|
32
|
+
recommendSetupPath,
|
|
33
|
+
resolveRoute,
|
|
34
|
+
serializeAiSettings,
|
|
35
|
+
thinkingChoices,
|
|
36
|
+
thinkingLevelFrom,
|
|
37
|
+
toAiError,
|
|
38
|
+
vscodeLmAvailability,
|
|
39
|
+
whatIsSent,
|
|
40
|
+
withThinkingLevel
|
|
41
|
+
} from "./chunk-JZWKEBCI.js";
|
|
42
|
+
import {
|
|
43
|
+
DEFAULT_LOCAL_NETWORKS,
|
|
44
|
+
detectLocality,
|
|
45
|
+
engineLocality,
|
|
46
|
+
hostOfUrl,
|
|
47
|
+
localityMismatch
|
|
48
|
+
} from "./chunk-QDVY7WW6.js";
|
|
49
|
+
|
|
50
|
+
// src/ai-settings-model.ts
|
|
51
|
+
var AI_ENGINE_KIND_LABELS = {
|
|
52
|
+
"openai-compatible": "OpenAI-compatible",
|
|
53
|
+
lmstudio: "LM Studio",
|
|
54
|
+
ollama: "Ollama",
|
|
55
|
+
anthropic: "Anthropic",
|
|
56
|
+
openai: "OpenAI",
|
|
57
|
+
"vscode-lm": "GitHub Copilot (VS Code)"
|
|
58
|
+
};
|
|
59
|
+
function engineKindLabel(kind) {
|
|
60
|
+
return isKnownEngineKind(kind) ? AI_ENGINE_KIND_LABELS[kind] : `Unknown (${kind})`;
|
|
61
|
+
}
|
|
62
|
+
var AI_CAPABILITY_CHIPS = [
|
|
63
|
+
{ flag: "chat", label: "chat", title: "Chat and instruct completions" },
|
|
64
|
+
{ flag: "vision", label: "vision", title: "Image inputs" },
|
|
65
|
+
{ flag: "json", label: "json", title: "JSON mode or reliable structured output" },
|
|
66
|
+
{ flag: "tools", label: "tools", title: "Function calling" },
|
|
67
|
+
{ flag: "fim", label: "fim", title: "Fill-in-the-middle (inline suggestions)" },
|
|
68
|
+
{ flag: "embed", label: "embed", title: "Embeddings" }
|
|
69
|
+
];
|
|
70
|
+
var CAPABILITY_WORDS = {
|
|
71
|
+
chat: "chat",
|
|
72
|
+
stream: "streaming",
|
|
73
|
+
vision: "vision",
|
|
74
|
+
json: "JSON output",
|
|
75
|
+
tools: "tools",
|
|
76
|
+
fim: "fill-in-the-middle",
|
|
77
|
+
embed: "embeddings",
|
|
78
|
+
contextTokens: "a large enough context window"
|
|
79
|
+
};
|
|
80
|
+
function engineDisplayName(engine) {
|
|
81
|
+
return engine.name.trim() || engine.id;
|
|
82
|
+
}
|
|
83
|
+
function modelDisplayName(model) {
|
|
84
|
+
return model.label?.trim() || model.id;
|
|
85
|
+
}
|
|
86
|
+
function shortUrl(baseUrl) {
|
|
87
|
+
const stripped = baseUrl.trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i, "");
|
|
88
|
+
const authority = stripped.split(/[/?#]/, 1)[0];
|
|
89
|
+
return authority || baseUrl.trim();
|
|
90
|
+
}
|
|
91
|
+
function formatTokens(tokens) {
|
|
92
|
+
const n = tokens ?? DEFAULT_CONTEXT_TOKENS;
|
|
93
|
+
if (n >= 1024 * 1024 && n % (1024 * 1024) === 0) {
|
|
94
|
+
return `${n / (1024 * 1024)}M`;
|
|
95
|
+
}
|
|
96
|
+
if (n >= 1e6 && n % 1e6 === 0) {
|
|
97
|
+
return `${n / 1e6}M`;
|
|
98
|
+
}
|
|
99
|
+
if (n >= 1024 && n % 1024 === 0) {
|
|
100
|
+
return `${n / 1024}k`;
|
|
101
|
+
}
|
|
102
|
+
if (n >= 1e3 && n % 1e3 === 0) {
|
|
103
|
+
return `${n / 1e3}k`;
|
|
104
|
+
}
|
|
105
|
+
return String(n);
|
|
106
|
+
}
|
|
107
|
+
function cloneAiSettings(settings) {
|
|
108
|
+
return JSON.parse(JSON.stringify(settings));
|
|
109
|
+
}
|
|
110
|
+
function uniqueEngineId(name, existing) {
|
|
111
|
+
const taken = new Set(existing);
|
|
112
|
+
let id = generateAiId(name);
|
|
113
|
+
while (taken.has(id)) {
|
|
114
|
+
id = generateAiId(name);
|
|
115
|
+
}
|
|
116
|
+
return id;
|
|
117
|
+
}
|
|
118
|
+
function engineFromPreset(preset, existingIds) {
|
|
119
|
+
const engine = {
|
|
120
|
+
id: uniqueEngineId(preset.name, existingIds),
|
|
121
|
+
name: preset.name,
|
|
122
|
+
kind: preset.kind,
|
|
123
|
+
baseUrl: preset.baseUrl,
|
|
124
|
+
enabled: true,
|
|
125
|
+
locality: "auto",
|
|
126
|
+
discovery: "auto",
|
|
127
|
+
models: []
|
|
128
|
+
};
|
|
129
|
+
if (preset.apiKeyEnv) {
|
|
130
|
+
engine.apiKeyEnv = preset.apiKeyEnv;
|
|
131
|
+
}
|
|
132
|
+
if (preset.apiKeyFile) {
|
|
133
|
+
engine.apiKeyFile = { ...preset.apiKeyFile };
|
|
134
|
+
}
|
|
135
|
+
return engine;
|
|
136
|
+
}
|
|
137
|
+
function groupPresets(presets) {
|
|
138
|
+
const groups = { local: [], cloud: [], host: [] };
|
|
139
|
+
for (const p of presets) {
|
|
140
|
+
groups[p.family].push(p);
|
|
141
|
+
}
|
|
142
|
+
return groups;
|
|
143
|
+
}
|
|
144
|
+
function withEngine(settings, engine) {
|
|
145
|
+
return { ...settings, engines: settings.engines.map((e) => e.id === engine.id ? engine : e) };
|
|
146
|
+
}
|
|
147
|
+
function removeEngine(settings, engineId) {
|
|
148
|
+
const tasks = {};
|
|
149
|
+
for (const [task, route] of Object.entries(settings.routing.tasks)) {
|
|
150
|
+
if (route.engineId !== engineId) {
|
|
151
|
+
tasks[task] = route;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const def = settings.routing.default?.engineId === engineId ? null : settings.routing.default;
|
|
155
|
+
return {
|
|
156
|
+
...settings,
|
|
157
|
+
engines: settings.engines.filter((e) => e.id !== engineId),
|
|
158
|
+
routing: { ...settings.routing, default: def, tasks }
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
function removeRoutesTo(settings, engineId, modelId) {
|
|
162
|
+
const hits = (r) => !!r && r.engineId === engineId && r.modelId === modelId;
|
|
163
|
+
const tasks = {};
|
|
164
|
+
for (const [task, route] of Object.entries(settings.routing.tasks)) {
|
|
165
|
+
if (!hits(route)) {
|
|
166
|
+
tasks[task] = route;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return { ...settings, routing: { ...settings.routing, default: hits(settings.routing.default) ? null : settings.routing.default, tasks } };
|
|
170
|
+
}
|
|
171
|
+
function withModel(engine, model) {
|
|
172
|
+
return { ...engine, models: engine.models.map((m) => m.id === model.id ? model : m) };
|
|
173
|
+
}
|
|
174
|
+
function toggleModelCapability(model, flag) {
|
|
175
|
+
return {
|
|
176
|
+
...model,
|
|
177
|
+
capabilities: { ...model.capabilities, [flag]: !model.capabilities[flag] },
|
|
178
|
+
capabilitiesSource: "user"
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function setModelContextTokens(model, tokens) {
|
|
182
|
+
return { ...model, capabilities: { ...model.capabilities, contextTokens: tokens }, capabilitiesSource: "user" };
|
|
183
|
+
}
|
|
184
|
+
function parseContextTokens(text) {
|
|
185
|
+
const t = text.trim().replace(/[_,\s]/g, "");
|
|
186
|
+
if (!t) {
|
|
187
|
+
return { ok: true, value: null };
|
|
188
|
+
}
|
|
189
|
+
const m = /^(\d+(?:\.\d+)?)([kKmM]?)$/.exec(t);
|
|
190
|
+
if (!m) {
|
|
191
|
+
return { ok: false, error: "Enter a number of tokens, such as 32768 or 32k." };
|
|
192
|
+
}
|
|
193
|
+
const scale = m[2] === "" ? 1 : m[2].toLowerCase() === "k" ? 1024 : 1024 * 1024;
|
|
194
|
+
const value = Math.round(Number(m[1]) * scale);
|
|
195
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
196
|
+
return { ok: false, error: "Enter a number of tokens, such as 32768 or 32k." };
|
|
197
|
+
}
|
|
198
|
+
return { ok: true, value };
|
|
199
|
+
}
|
|
200
|
+
function addManualModel(engine, rawId) {
|
|
201
|
+
const id = rawId.trim();
|
|
202
|
+
if (!id) {
|
|
203
|
+
return { ok: false, error: "Enter the model id the server knows it by." };
|
|
204
|
+
}
|
|
205
|
+
if (engine.models.some((m) => m.id === id)) {
|
|
206
|
+
return { ok: false, error: `${id} is already in the list.` };
|
|
207
|
+
}
|
|
208
|
+
const model = {
|
|
209
|
+
id,
|
|
210
|
+
enabled: true,
|
|
211
|
+
capabilities: detectCapabilities(id, isKnownEngineKind(engine.kind) ? engine.kind : void 0).capabilities,
|
|
212
|
+
capabilitiesSource: "heuristic",
|
|
213
|
+
manual: true
|
|
214
|
+
};
|
|
215
|
+
return { ok: true, engine: { ...engine, models: [...engine.models, model] }, model };
|
|
216
|
+
}
|
|
217
|
+
function parseLocalNetworksText(text) {
|
|
218
|
+
const entries = [...new Set(text.split(/[\s,]+/).map((e) => e.trim().toLowerCase()).filter((e) => e.length > 0))];
|
|
219
|
+
if (entries.length === 0) {
|
|
220
|
+
return { ok: true, value: [...DEFAULT_LOCAL_NETWORKS] };
|
|
221
|
+
}
|
|
222
|
+
const bad = [];
|
|
223
|
+
for (const entry of entries) {
|
|
224
|
+
const numeric = /^\d/.test(entry);
|
|
225
|
+
const valid = numeric ? /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?$/.test(entry) && entry.split("/")[0].split(".").every((p) => Number(p) <= 255) && Number(entry.split("/")[1] ?? 0) <= 32 : /^\.?[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(entry);
|
|
226
|
+
if (!valid) {
|
|
227
|
+
bad.push(entry);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (bad.length > 0) {
|
|
231
|
+
return { ok: false, error: `Not a network or host name: ${bad.join(", ")}. Use an IPv4 range like 10.0.98.0/24 or a suffix like .ts.net.` };
|
|
232
|
+
}
|
|
233
|
+
return { ok: true, value: entries };
|
|
234
|
+
}
|
|
235
|
+
function parseRequestOverrides(text) {
|
|
236
|
+
const t = text.trim();
|
|
237
|
+
if (!t) {
|
|
238
|
+
return { ok: true, value: void 0 };
|
|
239
|
+
}
|
|
240
|
+
let parsed;
|
|
241
|
+
try {
|
|
242
|
+
parsed = JSON.parse(t);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
const detail = err instanceof Error ? err.message.replace(/^JSON\.parse:\s*/, "") : "";
|
|
245
|
+
return { ok: false, error: detail ? `Not valid JSON: ${detail}` : "Not valid JSON." };
|
|
246
|
+
}
|
|
247
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
248
|
+
return { ok: false, error: 'Must be a JSON object, such as { "reasoning_effort": "low" }.' };
|
|
249
|
+
}
|
|
250
|
+
return { ok: true, value: parsed };
|
|
251
|
+
}
|
|
252
|
+
function formatRequestOverrides(value) {
|
|
253
|
+
return value && Object.keys(value).length > 0 ? JSON.stringify(value, null, 2) : "";
|
|
254
|
+
}
|
|
255
|
+
function mergeDiscoveryStatus(engine, status) {
|
|
256
|
+
const discovered = status.models.filter((m) => !m.missing).map((m) => {
|
|
257
|
+
const d = { id: m.id, capabilities: m.capabilities, source: m.source === "endpoint" ? "endpoint" : "heuristic" };
|
|
258
|
+
if (m.label !== void 0) {
|
|
259
|
+
d.label = m.label;
|
|
260
|
+
}
|
|
261
|
+
if (m.loaded !== void 0) {
|
|
262
|
+
d.loaded = m.loaded;
|
|
263
|
+
}
|
|
264
|
+
return d;
|
|
265
|
+
});
|
|
266
|
+
const kind = isKnownEngineKind(engine.kind) ? engine.kind : void 0;
|
|
267
|
+
return { ...engine, models: mergeDiscoveredModels(engine.models, discovered, kind) };
|
|
268
|
+
}
|
|
269
|
+
function connectionDiffers(a, b) {
|
|
270
|
+
if (!a || !b) {
|
|
271
|
+
return a !== b;
|
|
272
|
+
}
|
|
273
|
+
return a.kind !== b.kind || a.baseUrl !== b.baseUrl || (a.apiKeyEnv ?? "") !== (b.apiKeyEnv ?? "") || a.locality !== b.locality || a.enabled !== b.enabled || a.discovery !== b.discovery;
|
|
274
|
+
}
|
|
275
|
+
function changedAiPatch(original, draft) {
|
|
276
|
+
const before = serializeAiSettings(original);
|
|
277
|
+
const after = serializeAiSettings(draft);
|
|
278
|
+
const patch = {};
|
|
279
|
+
for (const [key, value] of Object.entries(after)) {
|
|
280
|
+
if (JSON.stringify(value) !== JSON.stringify(before[key])) {
|
|
281
|
+
patch[key] = value;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return patch;
|
|
285
|
+
}
|
|
286
|
+
function engineLocalityInfo(engine, localNetworks) {
|
|
287
|
+
return {
|
|
288
|
+
locality: engineLocality(engine, localNetworks),
|
|
289
|
+
detected: detectLocality(engine.baseUrl, localNetworks),
|
|
290
|
+
mismatch: localityMismatch(engine, localNetworks)
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
function baseUrlProblem(baseUrl) {
|
|
294
|
+
const t = baseUrl.trim();
|
|
295
|
+
if (!t) {
|
|
296
|
+
return "Enter the server address, such as http://127.0.0.1:8000/v1.";
|
|
297
|
+
}
|
|
298
|
+
if (!/^https?:\/\//i.test(t)) {
|
|
299
|
+
return "Start the address with http:// or https://.";
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
const u = new URL(t);
|
|
303
|
+
if (!u.hostname) {
|
|
304
|
+
return "The address has no host name.";
|
|
305
|
+
}
|
|
306
|
+
} catch {
|
|
307
|
+
return "This is not a valid address.";
|
|
308
|
+
}
|
|
309
|
+
return "";
|
|
310
|
+
}
|
|
311
|
+
function engineIssues(engine, settings, hasKey) {
|
|
312
|
+
const issues = [];
|
|
313
|
+
if (!isKnownEngineKind(engine.kind)) {
|
|
314
|
+
issues.push({ level: "warning", code: "unsupportedKind", text: `This version does not support the engine kind "${String(engine.kind)}". It is kept, but never used.` });
|
|
315
|
+
}
|
|
316
|
+
const hostKind = isHostEngineKind(engine.kind);
|
|
317
|
+
const urlProblem = hostKind ? "" : baseUrlProblem(engine.baseUrl);
|
|
318
|
+
if (urlProblem) {
|
|
319
|
+
issues.push({ level: "warning", code: "badUrl", text: urlProblem });
|
|
320
|
+
}
|
|
321
|
+
const info = engineLocalityInfo(engine, settings.localNetworks);
|
|
322
|
+
if (!hostKind && info.locality === "cloud" && !hasKey && !engine.apiKeyEnv?.trim() && !engine.apiKeyFile?.path.trim()) {
|
|
323
|
+
issues.push({ level: "warning", code: "noKey", text: "No API key. Store one, or name the environment variable or key file that holds it." });
|
|
324
|
+
}
|
|
325
|
+
if (info.mismatch) {
|
|
326
|
+
issues.push({
|
|
327
|
+
level: "warning",
|
|
328
|
+
code: "localityMismatch",
|
|
329
|
+
text: info.locality === "cloud" ? "Pinned to cloud, but the address looks local." : "Pinned to local, but the address looks like a cloud server. Text sent to it leaves this network."
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
if (info.locality === "cloud" && !settings.allowCloud) {
|
|
333
|
+
issues.push({ level: "info", code: "cloudBlocked", text: "Blocked by Allow cloud." });
|
|
334
|
+
}
|
|
335
|
+
return issues;
|
|
336
|
+
}
|
|
337
|
+
var DEFAULT_ROUTE_REQUIREMENTS = { requires: { chat: true } };
|
|
338
|
+
function taskRequirements(taskId) {
|
|
339
|
+
const task = getAiTask(taskId);
|
|
340
|
+
return task ? { requires: task.requires, requiresAny: task.requiresAny } : { requires: {} };
|
|
341
|
+
}
|
|
342
|
+
function lackingText(lacking) {
|
|
343
|
+
return `lacks ${lacking.map((c) => CAPABILITY_WORDS[c] ?? c).join(", ")}`;
|
|
344
|
+
}
|
|
345
|
+
function routePairs(settings, statuses, req) {
|
|
346
|
+
const statusById = new Map(statuses.map((s) => [s.engineId, s]));
|
|
347
|
+
const out = [];
|
|
348
|
+
for (const engine of settings.engines) {
|
|
349
|
+
const status = statusById.get(engine.id);
|
|
350
|
+
const isLocal = engineLocality(engine, settings.localNetworks) === "local";
|
|
351
|
+
for (const model of engine.models) {
|
|
352
|
+
const option = {
|
|
353
|
+
engineId: engine.id,
|
|
354
|
+
modelId: model.id,
|
|
355
|
+
engineName: engineDisplayName(engine),
|
|
356
|
+
modelName: modelDisplayName(model),
|
|
357
|
+
isLocal,
|
|
358
|
+
selectable: false
|
|
359
|
+
};
|
|
360
|
+
const lacking = missingCapabilities(model.capabilities, req.requires, req.requiresAny);
|
|
361
|
+
if (!isKnownEngineKind(engine.kind)) {
|
|
362
|
+
option.note = "engine kind not supported";
|
|
363
|
+
} else if (!engine.enabled) {
|
|
364
|
+
option.note = "engine is off";
|
|
365
|
+
} else if (!model.enabled) {
|
|
366
|
+
option.note = "model is off";
|
|
367
|
+
} else if (model.missing || status?.models.find((m) => m.id === model.id)?.missing) {
|
|
368
|
+
option.note = "no longer listed";
|
|
369
|
+
} else if (lacking.length > 0) {
|
|
370
|
+
option.note = lackingText(lacking);
|
|
371
|
+
} else {
|
|
372
|
+
option.selectable = true;
|
|
373
|
+
if (!isLocal && !settings.allowCloud) {
|
|
374
|
+
option.note = "blocked by Allow cloud";
|
|
375
|
+
} else if (status?.ok === false) {
|
|
376
|
+
option.note = "failed its last test";
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
out.push(option);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return out;
|
|
383
|
+
}
|
|
384
|
+
function routeKey(route) {
|
|
385
|
+
return route ? `${route.engineId}\0${route.modelId}` : "";
|
|
386
|
+
}
|
|
387
|
+
function parseRouteKey(key) {
|
|
388
|
+
const i = key.indexOf("\0");
|
|
389
|
+
if (i <= 0 || i === key.length - 1) {
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
return { engineId: key.slice(0, i), modelId: key.slice(i + 1) };
|
|
393
|
+
}
|
|
394
|
+
function routeLabel(settings, route) {
|
|
395
|
+
const engine = settings.engines.find((e) => e.id === route.engineId);
|
|
396
|
+
const model = engine?.models.find((m) => m.id === route.modelId);
|
|
397
|
+
return `${engine ? engineDisplayName(engine) : route.engineId} \xB7 ${model ? modelDisplayName(model) : route.modelId}`;
|
|
398
|
+
}
|
|
399
|
+
var PREVIEW_FEATURE = "\0aiSettingsPreview";
|
|
400
|
+
function previewTaskRoute(settings, statuses, task) {
|
|
401
|
+
const probe = { ...settings, enabled: true, features: { ...settings.features, [PREVIEW_FEATURE]: { enabled: true } } };
|
|
402
|
+
return resolveRoute(probe, statuses, {}, { feature: PREVIEW_FEATURE, task });
|
|
403
|
+
}
|
|
404
|
+
function taskRouteNote(settings, statuses, task) {
|
|
405
|
+
const own = settings.routing.tasks[task];
|
|
406
|
+
if (!own && !settings.routing.default) {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
const r = previewTaskRoute(settings, statuses, task);
|
|
410
|
+
if ("unusable" in r) {
|
|
411
|
+
if (!own) {
|
|
412
|
+
return { level: "info", text: `No model can serve this yet. ${r.reason}` };
|
|
413
|
+
}
|
|
414
|
+
const tail = settings.routing.fallback === "none" ? "Fallback is off, so this task will not run." : "No fallback can serve it either.";
|
|
415
|
+
return { level: "warning", text: `${r.reason} ${tail}` };
|
|
416
|
+
}
|
|
417
|
+
if (r.viaFallback) {
|
|
418
|
+
return { level: own ? "warning" : "info", text: `${r.reason ?? "The route is unavailable."} Falls back to ${routeLabel(settings, r)}.` };
|
|
419
|
+
}
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
function unknownRoutedTasks(settings) {
|
|
423
|
+
const known = new Set(AI_TASKS.map((t) => t.id));
|
|
424
|
+
return Object.keys(settings.routing.tasks).filter((t) => !known.has(t));
|
|
425
|
+
}
|
|
426
|
+
function defaultRouteForEngine(settings, statuses, engineId) {
|
|
427
|
+
const pair = routePairs(settings, statuses, DEFAULT_ROUTE_REQUIREMENTS).find((p) => p.engineId === engineId && p.selectable);
|
|
428
|
+
return pair ? { engineId: pair.engineId, modelId: pair.modelId } : null;
|
|
429
|
+
}
|
|
430
|
+
function hasUsableDefaultRoute(settings) {
|
|
431
|
+
const route = settings.routing.default;
|
|
432
|
+
const engine = route && settings.engines.find((e) => e.id === route.engineId);
|
|
433
|
+
if (!route || !engine || !engine.enabled || !isKnownEngineKind(engine.kind)) {
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
const model = engine.models.find((m) => m.id === route.modelId);
|
|
437
|
+
return !!model && model.enabled && !model.missing && model.capabilities.chat;
|
|
438
|
+
}
|
|
439
|
+
function addressKey(url) {
|
|
440
|
+
try {
|
|
441
|
+
const u = new URL(url.trim());
|
|
442
|
+
const host = u.hostname === "localhost" || u.hostname === "[::1]" ? "127.0.0.1" : u.hostname;
|
|
443
|
+
return `${u.protocol}//${host}:${u.port || (u.protocol === "https:" ? "443" : "80")}${u.pathname.replace(/\/+$/, "")}`.toLowerCase();
|
|
444
|
+
} catch {
|
|
445
|
+
return url.trim().toLowerCase().replace(/\/+$/, "");
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
function rankLocalCandidates(probes, existingEngines, presets = AI_ENGINE_PRESETS) {
|
|
449
|
+
const cards = probes.map((probe, index) => {
|
|
450
|
+
const preset = presets.find((p) => p.id === probe.presetId);
|
|
451
|
+
const engine = preset && existingEngines.find((e) => e.kind === preset.kind && addressKey(e.baseUrl) === addressKey(probe.baseUrl) && (presetForEngine(e, presets)?.id ?? preset.id) === preset.id);
|
|
452
|
+
const card = { presetId: probe.presetId, baseUrl: probe.baseUrl, running: probe.ok, alreadyAdded: !!engine };
|
|
453
|
+
if (engine) {
|
|
454
|
+
card.engineId = engine.id;
|
|
455
|
+
}
|
|
456
|
+
if (probe.ok && probe.models !== void 0) {
|
|
457
|
+
card.models = probe.models;
|
|
458
|
+
}
|
|
459
|
+
if (probe.latencyMs !== void 0) {
|
|
460
|
+
card.latencyMs = probe.latencyMs;
|
|
461
|
+
}
|
|
462
|
+
return { card, index };
|
|
463
|
+
});
|
|
464
|
+
const rank = (c) => c.alreadyAdded ? 2 : c.running ? 0 : 1;
|
|
465
|
+
return cards.sort((a, b) => rank(a.card) - rank(b.card) || (a.card.running && b.card.running ? (b.card.models ?? 0) - (a.card.models ?? 0) : 0) || a.index - b.index).map((x) => x.card);
|
|
466
|
+
}
|
|
467
|
+
function cloudSentNote(settings, served) {
|
|
468
|
+
if (served.isLocal) {
|
|
469
|
+
return "";
|
|
470
|
+
}
|
|
471
|
+
const engine = served.engineId ? settings.engines.find((e) => e.id === served.engineId) : void 0;
|
|
472
|
+
const host = engine ? aiConsentHost(engine) : null;
|
|
473
|
+
const what = served.feature || served.sends ? whatIsSent(served.feature, served.sends) : "";
|
|
474
|
+
if (host) {
|
|
475
|
+
return what ? ` It sent text to ${host}: ${what}.` : ` It sent text to ${host}.`;
|
|
476
|
+
}
|
|
477
|
+
return what ? ` It sent ${what}.` : "";
|
|
478
|
+
}
|
|
479
|
+
function servedLabels(settings, served) {
|
|
480
|
+
const engine = served.engineId ? settings.engines.find((e) => e.id === served.engineId) : void 0;
|
|
481
|
+
const model = engine?.models.find((m) => m.id === served.modelId);
|
|
482
|
+
return {
|
|
483
|
+
engine: served.engineName || (engine ? engineDisplayName(engine) : served.engineId ?? ""),
|
|
484
|
+
model: model ? modelDisplayName(model) : served.modelId,
|
|
485
|
+
warn: engine ? localityMismatch(engine, settings.localNetworks) : false
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
var AI_CHIP_CLICK_SETTINGS = "Click for AI settings.";
|
|
489
|
+
var AI_CHIP_CLICK_CHOOSE = "Click to choose a model.";
|
|
490
|
+
function indicatorTask() {
|
|
491
|
+
return getAiTask("rewrite") ? "rewrite" : AI_TASKS[0].id;
|
|
492
|
+
}
|
|
493
|
+
function aiNextRouteChanged(before, after, statuses) {
|
|
494
|
+
const key = (s) => {
|
|
495
|
+
const r = previewTaskRoute(s, statuses, indicatorTask());
|
|
496
|
+
return "unusable" in r ? null : `${r.engineId}
|
|
497
|
+
${r.modelId}`;
|
|
498
|
+
};
|
|
499
|
+
return key(before) !== key(after);
|
|
500
|
+
}
|
|
501
|
+
function aiIndicatorState(input) {
|
|
502
|
+
const { settings, statuses, tier, served, viaFallback } = input;
|
|
503
|
+
if (!settings.enabled) {
|
|
504
|
+
if (settings.engines.length === 0) {
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
return { text: "AI: off", tone: "off", title: `AI features are turned off. ${AI_CHIP_CLICK_SETTINGS}`, fallback: false, warn: false };
|
|
508
|
+
}
|
|
509
|
+
if (served) {
|
|
510
|
+
const where2 = served.isLocal ? "local" : "cloud";
|
|
511
|
+
const l = servedLabels(settings, served);
|
|
512
|
+
const parts = [`AI: ${where2}`, l.engine, l.model].filter(Boolean);
|
|
513
|
+
const text = parts.join(" \xB7 ") + (viaFallback ? " (fallback)" : "");
|
|
514
|
+
const title = `Last request served by ${l.engine} \xB7 ${l.model}, ${served.isLocal ? "on this network" : "a cloud engine"}${viaFallback ? ", through the fallback route" : ""}.${cloudSentNote(settings, served)}${l.warn ? " Its pinned locality disagrees with its address." : ""} ${AI_CHIP_CLICK_SETTINGS}`;
|
|
515
|
+
return { text, tone: where2, title, fallback: viaFallback, warn: l.warn };
|
|
516
|
+
}
|
|
517
|
+
if (tier === 1) {
|
|
518
|
+
return { text: "AI: on", tone: "on", title: `AI features are on. ${AI_CHIP_CLICK_SETTINGS}`, fallback: false, warn: false };
|
|
519
|
+
}
|
|
520
|
+
const r = previewTaskRoute(settings, statuses, indicatorTask());
|
|
521
|
+
if ("unusable" in r) {
|
|
522
|
+
return { text: "AI: no model chosen", tone: "none", title: `${r.reason} ${AI_CHIP_CLICK_CHOOSE}`, fallback: false, warn: false };
|
|
523
|
+
}
|
|
524
|
+
const engine = settings.engines.find((e) => e.id === r.engineId);
|
|
525
|
+
const where = r.isLocal ? "local" : "cloud";
|
|
526
|
+
const label = routeLabel(settings, r);
|
|
527
|
+
const warn = engine ? localityMismatch(engine, settings.localNetworks) : false;
|
|
528
|
+
return {
|
|
529
|
+
text: `AI: ${where} \xB7 ${label}${r.viaFallback ? " (fallback)" : ""}`,
|
|
530
|
+
tone: where,
|
|
531
|
+
title: `Requests go to ${label}${r.isLocal ? "" : ", a cloud engine"}. Nothing has been sent yet. ${AI_CHIP_CLICK_SETTINGS}`,
|
|
532
|
+
fallback: r.viaFallback,
|
|
533
|
+
warn
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
function enginePreset(engine, localNetworks) {
|
|
537
|
+
const p = presetForEngine(engine);
|
|
538
|
+
if (p) {
|
|
539
|
+
return {
|
|
540
|
+
id: p.id,
|
|
541
|
+
name: p.name,
|
|
542
|
+
family: p.family,
|
|
543
|
+
needsKey: p.needsKey,
|
|
544
|
+
keyHint: p.keyHint,
|
|
545
|
+
...p.keyUrl ? { keyUrl: p.keyUrl } : {},
|
|
546
|
+
...p.pricingUrl ? { pricingUrl: p.pricingUrl } : {},
|
|
547
|
+
popular: p.popular ?? []
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
const family = engineLocality(engine, localNetworks) === "local" ? "local" : "cloud";
|
|
551
|
+
return {
|
|
552
|
+
id: "custom",
|
|
553
|
+
name: engineDisplayName(engine),
|
|
554
|
+
family,
|
|
555
|
+
needsKey: family === "cloud",
|
|
556
|
+
keyHint: family === "cloud" ? "The API key from the provider\u2019s console." : "Leave blank if the server has no auth.",
|
|
557
|
+
popular: []
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
function expressFields(preset) {
|
|
561
|
+
if (preset.family === "host") {
|
|
562
|
+
return { express: ["name", "models", "defaultModel", "enabled"], advanced: ["discovery", "modelsTable"] };
|
|
563
|
+
}
|
|
564
|
+
if (preset.family === "cloud") {
|
|
565
|
+
return {
|
|
566
|
+
express: ["name", "key", "models", "defaultModel", "thinking", "enabled"],
|
|
567
|
+
advanced: ["kind", "baseUrl", "locality", "keyEnv", "keyFile", "discovery", "modelsTable"]
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
const keyExpress = preset.needsKey || preset.id === "custom";
|
|
571
|
+
return {
|
|
572
|
+
express: keyExpress ? ["name", "baseUrl", "key", "models", "defaultModel", "thinking", "enabled"] : ["name", "baseUrl", "models", "defaultModel", "thinking", "enabled"],
|
|
573
|
+
advanced: keyExpress ? ["kind", "locality", "keyEnv", "keyFile", "discovery", "modelsTable"] : ["kind", "locality", "key", "keyEnv", "keyFile", "discovery", "modelsTable"]
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
function setupFields(preset, o = {}) {
|
|
577
|
+
const { express, advanced } = expressFields(preset);
|
|
578
|
+
const kept = express.filter((f) => f === "key" || f === "baseUrl" || f === "models");
|
|
579
|
+
if (o.key && preset.family === "local" && !kept.includes("key")) {
|
|
580
|
+
kept.splice(kept.indexOf("models"), 0, "key");
|
|
581
|
+
return { express: kept, advanced: advanced.filter((f) => f !== "key" && f !== "modelsTable") };
|
|
582
|
+
}
|
|
583
|
+
return { express: kept, advanced: advanced.filter((f) => f !== "modelsTable") };
|
|
584
|
+
}
|
|
585
|
+
function modelPickerOptions(engine, status, filter = "") {
|
|
586
|
+
const loaded = new Map((status?.models ?? []).map((m) => [m.id, !!m.loaded]));
|
|
587
|
+
const needle = filter.trim().toLowerCase();
|
|
588
|
+
const rows = engine.models.map((m, index) => ({
|
|
589
|
+
id: m.id,
|
|
590
|
+
name: modelDisplayName(m),
|
|
591
|
+
hasLabel: !!m.label?.trim() && m.label.trim() !== m.id,
|
|
592
|
+
enabled: m.enabled,
|
|
593
|
+
loaded: loaded.get(m.id) ?? false,
|
|
594
|
+
missing: !!(m.missing || status?.models.find((s) => s.id === m.id)?.missing),
|
|
595
|
+
manual: !!m.manual,
|
|
596
|
+
chat: !!m.capabilities.chat,
|
|
597
|
+
usable: !!(m.capabilities.chat || m.capabilities.embed),
|
|
598
|
+
index
|
|
599
|
+
})).filter((o) => !needle || o.id.toLowerCase().includes(needle) || o.name.toLowerCase().includes(needle));
|
|
600
|
+
const rank = (o) => o.missing ? 2 : engine.kind === "lmstudio" && o.loaded ? 0 : 1;
|
|
601
|
+
rows.sort((a, b) => rank(a) - rank(b) || a.index - b.index);
|
|
602
|
+
return rows.map(({ index: _index, ...o }) => o);
|
|
603
|
+
}
|
|
604
|
+
function modelCountLine(engine) {
|
|
605
|
+
const listed = engine.models.filter((m) => !m.missing);
|
|
606
|
+
const picked = listed.filter((m) => m.enabled).length;
|
|
607
|
+
if (listed.length === 0) {
|
|
608
|
+
return "No models yet";
|
|
609
|
+
}
|
|
610
|
+
return `${listed.length} ${listed.length === 1 ? "model" : "models"}, ${picked} picked`;
|
|
611
|
+
}
|
|
612
|
+
function applyDiscoveryForFamily(engine, status, family, picked = []) {
|
|
613
|
+
const merged = mergeDiscoveryStatus(engine, status);
|
|
614
|
+
const before = new Set(engine.models.map((m) => m.id));
|
|
615
|
+
const pick = new Set(picked);
|
|
616
|
+
const loaded = new Set(status.models.filter((m) => m.loaded && !m.missing).map((m) => m.id));
|
|
617
|
+
const arrivesOn = (m) => pick.has(m.id) || family === "local" && loaded.has(m.id) && (m.capabilities.chat || m.capabilities.embed);
|
|
618
|
+
let models = merged.models.map((m) => before.has(m.id) ? m : { ...m, enabled: arrivesOn(m) });
|
|
619
|
+
const hadPick = engine.models.some((m) => m.enabled && !m.missing);
|
|
620
|
+
if (family === "local" && !hadPick) {
|
|
621
|
+
const choices = models.filter((m) => !before.has(m.id)).map((m) => ({ id: m.id, chat: m.capabilities.chat, missing: m.missing, loaded: loaded.has(m.id) }));
|
|
622
|
+
const def = localDefaultModel(choices);
|
|
623
|
+
const chosen = models.find((m) => m.id === def);
|
|
624
|
+
if (chosen) {
|
|
625
|
+
models = [{ ...chosen, enabled: true }, ...models.filter((m) => m !== chosen)];
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
return { ...merged, models };
|
|
629
|
+
}
|
|
630
|
+
function versionOf(id) {
|
|
631
|
+
const version = [];
|
|
632
|
+
let date = 0;
|
|
633
|
+
for (const n of id.match(/\d+/g) ?? []) {
|
|
634
|
+
if (n.length >= 6) {
|
|
635
|
+
date = Math.max(date, Number(n));
|
|
636
|
+
} else {
|
|
637
|
+
version.push(Number(n));
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return { version, date };
|
|
641
|
+
}
|
|
642
|
+
function compareNewest(a, b, prefer) {
|
|
643
|
+
if (prefer) {
|
|
644
|
+
const pa = prefer.test(a) ? 0 : 1;
|
|
645
|
+
const pb = prefer.test(b) ? 0 : 1;
|
|
646
|
+
if (pa !== pb) {
|
|
647
|
+
return pa - pb;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
const va = versionOf(a);
|
|
651
|
+
const vb = versionOf(b);
|
|
652
|
+
for (let i = 0; i < Math.max(va.version.length, vb.version.length); i++) {
|
|
653
|
+
const d = (vb.version[i] ?? 0) - (va.version[i] ?? 0);
|
|
654
|
+
if (d !== 0) {
|
|
655
|
+
return d;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
if (va.date !== vb.date) {
|
|
659
|
+
return va.date === 0 ? -1 : vb.date === 0 ? 1 : vb.date - va.date;
|
|
660
|
+
}
|
|
661
|
+
return a.length - b.length || a.localeCompare(b);
|
|
662
|
+
}
|
|
663
|
+
function popularModels(preset, models) {
|
|
664
|
+
const out = [];
|
|
665
|
+
for (const rule of preset.popular ?? []) {
|
|
666
|
+
const hits = models.filter((m) => !m.missing && rule.match.test(m.id) && !rule.exclude?.test(m.id) && !out.includes(m));
|
|
667
|
+
if (hits.length === 0) {
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
out.push(hits.slice().sort((a, b) => compareNewest(a.id, b.id, rule.prefer))[0]);
|
|
671
|
+
}
|
|
672
|
+
return out;
|
|
673
|
+
}
|
|
674
|
+
function autoPickDefault(preset, models) {
|
|
675
|
+
const chat = (m) => !m.missing && m.capabilities.chat;
|
|
676
|
+
const popular = popularModels(preset, models).find(chat);
|
|
677
|
+
if (popular) {
|
|
678
|
+
return popular.id;
|
|
679
|
+
}
|
|
680
|
+
const chatModels = models.filter(chat);
|
|
681
|
+
return chatModels.length === 1 ? chatModels[0].id : null;
|
|
682
|
+
}
|
|
683
|
+
function firstChatModelId(models) {
|
|
684
|
+
return models.find((m) => !m.missing && m.capabilities.chat)?.id ?? null;
|
|
685
|
+
}
|
|
686
|
+
function engineDefaultModelId(settings, engineId) {
|
|
687
|
+
const engine = settings.engines.find((e) => e.id === engineId);
|
|
688
|
+
if (!engine) {
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
const def = settings.routing.default;
|
|
692
|
+
if (def?.engineId === engineId && engine.models.some((m) => m.id === def.modelId)) {
|
|
693
|
+
return def.modelId;
|
|
694
|
+
}
|
|
695
|
+
return engine.models.find((m) => m.enabled && !m.missing)?.id ?? null;
|
|
696
|
+
}
|
|
697
|
+
function setEngineDefaultModel(settings, engineId, modelId) {
|
|
698
|
+
const engine = settings.engines.find((e) => e.id === engineId);
|
|
699
|
+
const model = engine?.models.find((m) => m.id === modelId);
|
|
700
|
+
if (!engine || !model || !model.capabilities.chat) {
|
|
701
|
+
return settings;
|
|
702
|
+
}
|
|
703
|
+
const models = [{ ...model, enabled: true }, ...engine.models.filter((m) => m.id !== modelId)];
|
|
704
|
+
const next = withEngine(settings, { ...engine, models });
|
|
705
|
+
const def = settings.routing.default;
|
|
706
|
+
if (!def || def.engineId === engineId) {
|
|
707
|
+
return { ...next, routing: { ...next.routing, default: { ...def ?? {}, engineId, modelId } } };
|
|
708
|
+
}
|
|
709
|
+
return next;
|
|
710
|
+
}
|
|
711
|
+
function engineNeedsModelPick(engine) {
|
|
712
|
+
if (!engine.enabled) {
|
|
713
|
+
return false;
|
|
714
|
+
}
|
|
715
|
+
const chat = engine.models.filter((m) => !m.missing && m.capabilities.chat);
|
|
716
|
+
return chat.length > 0 && !chat.some((m) => m.enabled);
|
|
717
|
+
}
|
|
718
|
+
function firstEngineNeedingPick(settings) {
|
|
719
|
+
return settings.engines.find(engineNeedsModelPick) ?? null;
|
|
720
|
+
}
|
|
721
|
+
function setModelPicked(settings, engineId, modelId, enabled) {
|
|
722
|
+
const engine = settings.engines.find((e) => e.id === engineId);
|
|
723
|
+
const model = engine?.models.find((m) => m.id === modelId);
|
|
724
|
+
if (!engine || !model) {
|
|
725
|
+
return settings;
|
|
726
|
+
}
|
|
727
|
+
const next = withEngine(settings, withModel(engine, { ...model, enabled }));
|
|
728
|
+
const def = next.routing.default;
|
|
729
|
+
if (enabled && !def && model.capabilities.chat && engine.enabled) {
|
|
730
|
+
return { ...next, routing: { ...next.routing, default: { engineId, modelId } } };
|
|
731
|
+
}
|
|
732
|
+
if (enabled || def?.engineId !== engineId || def.modelId !== modelId) {
|
|
733
|
+
return next;
|
|
734
|
+
}
|
|
735
|
+
const replacement = next.engines.find((e) => e.id === engineId)?.models.find((m) => m.enabled && !m.missing);
|
|
736
|
+
return { ...next, routing: { ...next.routing, default: replacement ? { ...def, modelId: replacement.id } : null } };
|
|
737
|
+
}
|
|
738
|
+
function chooseSetupModel(settings, engineId, modelId, previous) {
|
|
739
|
+
const next = setEngineDefaultModel(settings, engineId, modelId);
|
|
740
|
+
if (next === settings || !previous || previous === modelId) {
|
|
741
|
+
return next;
|
|
742
|
+
}
|
|
743
|
+
return setModelPicked(next, engineId, previous, false);
|
|
744
|
+
}
|
|
745
|
+
function setModelThinking(model, kind, level, local) {
|
|
746
|
+
const next = { ...model };
|
|
747
|
+
const overrides = withThinkingLevel(kind, model.requestOverrides, level, { local });
|
|
748
|
+
if (overrides) {
|
|
749
|
+
next.requestOverrides = overrides;
|
|
750
|
+
} else {
|
|
751
|
+
delete next.requestOverrides;
|
|
752
|
+
}
|
|
753
|
+
return next;
|
|
754
|
+
}
|
|
755
|
+
var AUTO_FETCH_KEY_DELAY_MS = 600;
|
|
756
|
+
function fetchFingerprint(family, key, baseUrl) {
|
|
757
|
+
return family === "cloud" ? `key:${key.trim()}` : `url:${baseUrl.trim()}`;
|
|
758
|
+
}
|
|
759
|
+
var NO_FETCH = (cancelPending, fingerprint = "") => ({ fetch: false, delayMs: 0, storeKey: false, cancelPending, fingerprint });
|
|
760
|
+
function autoFetchDecision(prev, next) {
|
|
761
|
+
const urlOk = next.family === "host" || !baseUrlProblem(next.baseUrl);
|
|
762
|
+
const key = next.key.trim();
|
|
763
|
+
const cloudFingerprint = fetchFingerprint("cloud", key, next.baseUrl);
|
|
764
|
+
const localFingerprint = fetchFingerprint("local", key, next.baseUrl);
|
|
765
|
+
const fingerprint = next.family === "cloud" ? cloudFingerprint : localFingerprint;
|
|
766
|
+
if (next.event === "save") {
|
|
767
|
+
return NO_FETCH(false);
|
|
768
|
+
}
|
|
769
|
+
if (next.event === "manual" || next.event === "retry") {
|
|
770
|
+
if (!urlOk || next.discovery === "manual") {
|
|
771
|
+
return NO_FETCH(false);
|
|
772
|
+
}
|
|
773
|
+
return { fetch: true, delayMs: 0, storeKey: next.family === "cloud" ? !!key : false, cancelPending: true, fingerprint };
|
|
774
|
+
}
|
|
775
|
+
if (next.discovery === "manual" || !urlOk) {
|
|
776
|
+
return NO_FETCH(next.event === "key" && !key);
|
|
777
|
+
}
|
|
778
|
+
if (next.family === "cloud") {
|
|
779
|
+
if (next.event === "open") {
|
|
780
|
+
return next.hasKey && !next.modelCount && next.neverProbed ? { fetch: true, delayMs: 0, storeKey: false, cancelPending: true, fingerprint: cloudFingerprint } : NO_FETCH(false);
|
|
781
|
+
}
|
|
782
|
+
if (next.event !== "key") {
|
|
783
|
+
return NO_FETCH(false);
|
|
784
|
+
}
|
|
785
|
+
if (!key) {
|
|
786
|
+
return NO_FETCH(true);
|
|
787
|
+
}
|
|
788
|
+
const already = (next.fetchedFor ?? prev?.fetchedFor) === cloudFingerprint;
|
|
789
|
+
if (already || prev && prev.key.trim() === key && prev.event === "key") {
|
|
790
|
+
return NO_FETCH(false, cloudFingerprint);
|
|
791
|
+
}
|
|
792
|
+
return { fetch: true, delayMs: AUTO_FETCH_KEY_DELAY_MS, storeKey: true, cancelPending: true, fingerprint: cloudFingerprint };
|
|
793
|
+
}
|
|
794
|
+
if (next.event === "open") {
|
|
795
|
+
return next.neverProbed ? { fetch: true, delayMs: 0, storeKey: false, cancelPending: true, fingerprint: localFingerprint } : NO_FETCH(false, localFingerprint);
|
|
796
|
+
}
|
|
797
|
+
if (next.event === "url") {
|
|
798
|
+
if ((next.fetchedFor ?? prev?.fetchedFor) === localFingerprint) {
|
|
799
|
+
return NO_FETCH(false, localFingerprint);
|
|
800
|
+
}
|
|
801
|
+
return { fetch: true, delayMs: 0, storeKey: false, cancelPending: true, fingerprint: localFingerprint };
|
|
802
|
+
}
|
|
803
|
+
return NO_FETCH(false);
|
|
804
|
+
}
|
|
805
|
+
var AUTH_TEXT = /\bHTTP 40[13]\b|unauthori[sz]ed|forbidden|invalid[^.]*\bkey|incorrect[^.]*\bkey|api key|authentication/i;
|
|
806
|
+
var UNREACHABLE_TEXT = /could not reach|no answer|timed out|ECONNREFUSED|ENOTFOUND|EHOSTUNREACH|fetch failed|connection refused|network/i;
|
|
807
|
+
function discoveryErrorCode(code, message) {
|
|
808
|
+
if (code && code !== "unknown") {
|
|
809
|
+
return code;
|
|
810
|
+
}
|
|
811
|
+
const text = message ?? "";
|
|
812
|
+
if (AUTH_TEXT.test(text)) {
|
|
813
|
+
return "auth";
|
|
814
|
+
}
|
|
815
|
+
if (UNREACHABLE_TEXT.test(text)) {
|
|
816
|
+
return "unreachable";
|
|
817
|
+
}
|
|
818
|
+
return "unknown";
|
|
819
|
+
}
|
|
820
|
+
function discoveryProblem(input) {
|
|
821
|
+
if (input.ok) {
|
|
822
|
+
return input.listed === 0 ? { kind: "empty", text: "Server is up but no models are loaded.", retry: true } : null;
|
|
823
|
+
}
|
|
824
|
+
const code = discoveryErrorCode(input.code, input.message);
|
|
825
|
+
if (input.family === "host") {
|
|
826
|
+
return { kind: code === "auth" ? "auth" : "other", text: input.message || `Could not list ${input.presetName}\u2019s models.`, retry: true };
|
|
827
|
+
}
|
|
828
|
+
if (code === "auth") {
|
|
829
|
+
return { kind: "auth", text: `Key rejected by ${shortUrl(input.baseUrl)}.`, focus: "key", retry: true };
|
|
830
|
+
}
|
|
831
|
+
if (code === "unreachable" && input.family === "local") {
|
|
832
|
+
const name = input.presetName.startsWith("Custom") ? "the server" : input.presetName;
|
|
833
|
+
return { kind: "unreachable", text: `Nothing answered at ${input.baseUrl.trim()}. Is ${name} running?`, retry: true };
|
|
834
|
+
}
|
|
835
|
+
if (code === "unreachable") {
|
|
836
|
+
return { kind: "unreachable", text: `Could not reach ${shortUrl(input.baseUrl)}.`, retry: true };
|
|
837
|
+
}
|
|
838
|
+
return { kind: "other", text: input.message ? `Could not fetch models: ${input.message}` : "Could not fetch models.", retry: true };
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// src/ai-settings-session.ts
|
|
842
|
+
var SAVE_ECHO_TIMEOUT_MS = 4e3;
|
|
843
|
+
function createAiSettingsSession(options) {
|
|
844
|
+
const { ai } = options;
|
|
845
|
+
let original = cloneAiSettings(options.settings);
|
|
846
|
+
let draft = cloneAiSettings(options.settings);
|
|
847
|
+
const statuses = options.statusMap ?? new Map(options.statuses.map((s) => [s.engineId, s]));
|
|
848
|
+
const keyOverride = options.keyOverride ?? /* @__PURE__ */ new Map();
|
|
849
|
+
const deleted = /* @__PURE__ */ new Set();
|
|
850
|
+
const keysStored = /* @__PURE__ */ new Set();
|
|
851
|
+
const added = /* @__PURE__ */ new Set();
|
|
852
|
+
const getEngine = (id) => draft.engines.find((e) => e.id === id);
|
|
853
|
+
const statusList = () => [...statuses.values()];
|
|
854
|
+
const session = {
|
|
855
|
+
ai,
|
|
856
|
+
tier: aiHostTier(ai),
|
|
857
|
+
statuses,
|
|
858
|
+
options,
|
|
859
|
+
get draft() {
|
|
860
|
+
return draft;
|
|
861
|
+
},
|
|
862
|
+
set draft(next) {
|
|
863
|
+
draft = next;
|
|
864
|
+
},
|
|
865
|
+
get original() {
|
|
866
|
+
return original;
|
|
867
|
+
},
|
|
868
|
+
getEngine,
|
|
869
|
+
statusList,
|
|
870
|
+
hasKey: (id) => keyOverride.get(id) ?? statuses.get(id)?.hasKey ?? false,
|
|
871
|
+
keySource: (id) => {
|
|
872
|
+
const stored = keyOverride.get(id);
|
|
873
|
+
return stored === void 0 ? statuses.get(id)?.keySource : stored ? "stored" : void 0;
|
|
874
|
+
},
|
|
875
|
+
async setKey(id, value) {
|
|
876
|
+
if (!getEngine(id) || !ai.setSecret) {
|
|
877
|
+
return "This app cannot store keys.";
|
|
878
|
+
}
|
|
879
|
+
try {
|
|
880
|
+
await ai.setSecret(id, value);
|
|
881
|
+
keyOverride.set(id, value !== null);
|
|
882
|
+
if (value !== null) {
|
|
883
|
+
keysStored.add(id);
|
|
884
|
+
} else {
|
|
885
|
+
keysStored.delete(id);
|
|
886
|
+
}
|
|
887
|
+
return null;
|
|
888
|
+
} catch (err) {
|
|
889
|
+
return `The key could not be ${value !== null ? "stored" : "cleared"}: ${toAiError(err).message}`;
|
|
890
|
+
}
|
|
891
|
+
},
|
|
892
|
+
async ensureSaved(id) {
|
|
893
|
+
const engine = getEngine(id);
|
|
894
|
+
if (!engine || !options.saveNow) {
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
if (!connectionDiffers(options.getHostSettings().engines.find((e) => e.id === id), engine)) {
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
options.saveNow({ "ai.engines": serializeAiSettings(draft)["ai.engines"] });
|
|
901
|
+
await options.waitForSettings((s) => !connectionDiffers(s.engines.find((e) => e.id === id), engine), SAVE_ECHO_TIMEOUT_MS);
|
|
902
|
+
},
|
|
903
|
+
savesFirst(id) {
|
|
904
|
+
const engine = getEngine(id);
|
|
905
|
+
return !!options.saveNow && !!engine && connectionDiffers(options.getHostSettings().engines.find((e) => e.id === id), engine);
|
|
906
|
+
},
|
|
907
|
+
addEngine(engine) {
|
|
908
|
+
draft = { ...draft, engines: [...draft.engines, engine] };
|
|
909
|
+
added.add(engine.id);
|
|
910
|
+
},
|
|
911
|
+
deleteEngine(id) {
|
|
912
|
+
if (!getEngine(id)) {
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
draft = removeEngine(draft, id);
|
|
916
|
+
deleted.add(id);
|
|
917
|
+
},
|
|
918
|
+
updateEngine(id, patch) {
|
|
919
|
+
const current = getEngine(id);
|
|
920
|
+
if (current) {
|
|
921
|
+
draft = withEngine(draft, { ...current, ...patch });
|
|
922
|
+
}
|
|
923
|
+
},
|
|
924
|
+
collect() {
|
|
925
|
+
const patch = changedAiPatch(original, draft);
|
|
926
|
+
if (options.pendingEnable && draft.enabled && Object.keys(patch).length > 0) {
|
|
927
|
+
patch["ai.enabled"] = true;
|
|
928
|
+
}
|
|
929
|
+
return patch;
|
|
930
|
+
},
|
|
931
|
+
dirty() {
|
|
932
|
+
if (Object.keys(changedAiPatch(original, draft)).length > 0) {
|
|
933
|
+
return true;
|
|
934
|
+
}
|
|
935
|
+
return options.getHostSettings().engines.some((e) => added.has(e.id) && !getEngine(e.id));
|
|
936
|
+
},
|
|
937
|
+
async refreshStatuses() {
|
|
938
|
+
if (!ai.listEngines) {
|
|
939
|
+
return false;
|
|
940
|
+
}
|
|
941
|
+
try {
|
|
942
|
+
const list = await ai.listEngines();
|
|
943
|
+
for (const s of list) {
|
|
944
|
+
statuses.set(s.engineId, s);
|
|
945
|
+
}
|
|
946
|
+
options.onStatuses(statusList());
|
|
947
|
+
return true;
|
|
948
|
+
} catch {
|
|
949
|
+
return false;
|
|
950
|
+
}
|
|
951
|
+
},
|
|
952
|
+
fork() {
|
|
953
|
+
return { ...options, settings: cloneAiSettings(draft), statuses: statusList(), keyOverride, statusMap: statuses };
|
|
954
|
+
},
|
|
955
|
+
absorb(saved, patch) {
|
|
956
|
+
draft = cloneAiSettings(saved);
|
|
957
|
+
original = withSavedAiKeys(original, saved, patch);
|
|
958
|
+
},
|
|
959
|
+
discard() {
|
|
960
|
+
const host = options.getHostSettings();
|
|
961
|
+
if (options.saveNow && host.engines.some((e) => added.has(e.id))) {
|
|
962
|
+
const kept = { ...host, engines: host.engines.filter((e) => !added.has(e.id)) };
|
|
963
|
+
options.saveNow({ "ai.engines": serializeAiSettings(kept)["ai.engines"] });
|
|
964
|
+
}
|
|
965
|
+
if (!ai.setSecret) {
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
const saved = new Set(host.engines.filter((e) => !added.has(e.id)).map((e) => e.id));
|
|
969
|
+
for (const id of keysStored) {
|
|
970
|
+
if (!saved.has(id)) {
|
|
971
|
+
ai.setSecret(id, null).catch(() => {
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
},
|
|
976
|
+
commit() {
|
|
977
|
+
if (ai.setSecret) {
|
|
978
|
+
for (const id of deleted) {
|
|
979
|
+
if (getEngine(id)) {
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
ai.setSecret(id, null).catch(() => {
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
return cloneAiSettings(draft);
|
|
987
|
+
}
|
|
988
|
+
};
|
|
989
|
+
return session;
|
|
990
|
+
}
|
|
991
|
+
function withSavedAiKeys(base, saved, patch) {
|
|
992
|
+
const next = { ...base };
|
|
993
|
+
const from = saved;
|
|
994
|
+
for (const key of Object.keys(patch)) {
|
|
995
|
+
const field = key.replace(/^ai\./, "");
|
|
996
|
+
if (field in from) {
|
|
997
|
+
next[field] = JSON.parse(JSON.stringify(from[field]));
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
return next;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// src/ai-local-setup.ts
|
|
1004
|
+
var AI_DETECT_LOOKING = "Looking for local AI servers\u2026";
|
|
1005
|
+
var AI_DETECT_NONE = "No local AI server found. Start LM Studio, Ollama or oMLX, then choose Look again.";
|
|
1006
|
+
var DETECT_ROUND_TRIP_MS = 1500;
|
|
1007
|
+
async function detectLocalServers(ai, signal) {
|
|
1008
|
+
if (!ai.detectLocalServers || signal?.aborted) {
|
|
1009
|
+
return null;
|
|
1010
|
+
}
|
|
1011
|
+
const controller = new AbortController();
|
|
1012
|
+
const onAbort = () => controller.abort();
|
|
1013
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1014
|
+
let timer;
|
|
1015
|
+
const timeout = new Promise((resolve) => {
|
|
1016
|
+
timer = setTimeout(() => {
|
|
1017
|
+
controller.abort();
|
|
1018
|
+
resolve(null);
|
|
1019
|
+
}, AI_LOCAL_DETECT_TIMEOUT_MS + DETECT_ROUND_TRIP_MS);
|
|
1020
|
+
});
|
|
1021
|
+
try {
|
|
1022
|
+
const probes = await Promise.race([ai.detectLocalServers(localDetectCandidates(), controller.signal), timeout]);
|
|
1023
|
+
return !probes || controller.signal.aborted || !Array.isArray(probes) ? null : probes;
|
|
1024
|
+
} catch {
|
|
1025
|
+
return null;
|
|
1026
|
+
} finally {
|
|
1027
|
+
clearTimeout(timer);
|
|
1028
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
function bestLocalServer(probes, engines) {
|
|
1032
|
+
return rankLocalCandidates(probes, engines).find((c) => c.running && !c.alreadyAdded) ?? null;
|
|
1033
|
+
}
|
|
1034
|
+
async function setUpLocalEngine(options, presetId) {
|
|
1035
|
+
const preset = AI_ENGINE_PRESETS.find((p) => p.id === presetId && p.family === "local");
|
|
1036
|
+
if (!preset) {
|
|
1037
|
+
throw new Error("That local server is not one MikeDown knows.");
|
|
1038
|
+
}
|
|
1039
|
+
const { ai } = options;
|
|
1040
|
+
if (!ai.discoverModels) {
|
|
1041
|
+
throw new Error("This app cannot fetch models.");
|
|
1042
|
+
}
|
|
1043
|
+
const session = createAiSettingsSession(options);
|
|
1044
|
+
const engine = engineFromPreset(preset, session.draft.engines.map((e) => e.id));
|
|
1045
|
+
session.addEngine(engine);
|
|
1046
|
+
const fail = (message) => {
|
|
1047
|
+
session.discard();
|
|
1048
|
+
throw new Error(message);
|
|
1049
|
+
};
|
|
1050
|
+
await session.ensureSaved(engine.id);
|
|
1051
|
+
let status;
|
|
1052
|
+
try {
|
|
1053
|
+
status = await ai.discoverModels(engine.id);
|
|
1054
|
+
} catch (err) {
|
|
1055
|
+
return fail(`Could not fetch the models from ${preset.name}: ${toAiError(err).message}`);
|
|
1056
|
+
}
|
|
1057
|
+
if (status.ok === false) {
|
|
1058
|
+
return fail(`Could not fetch the models from ${preset.name}${status.error ? `: ${status.error}` : "."}`);
|
|
1059
|
+
}
|
|
1060
|
+
session.statuses.set(engine.id, status);
|
|
1061
|
+
options.onStatuses(session.statusList());
|
|
1062
|
+
session.draft = withEngine(session.draft, applyDiscoveryForFamily(engine, status, "local"));
|
|
1063
|
+
const route = defaultRouteForEngine(session.draft, session.statusList(), engine.id);
|
|
1064
|
+
if (!route) {
|
|
1065
|
+
const canChat = session.getEngine(engine.id)?.models.some((m) => !m.missing && m.capabilities.chat);
|
|
1066
|
+
return fail(canChat ? `${preset.name} has no model loaded. Load one there, or choose Set up to pick one.` : `${preset.name} has no chat model loaded or downloaded yet.`);
|
|
1067
|
+
}
|
|
1068
|
+
session.draft = { ...session.draft, routing: { ...session.draft.routing, default: route } };
|
|
1069
|
+
const patch = { ...session.collect(), "ai.enabled": true };
|
|
1070
|
+
const settings = { ...session.commit(), enabled: true };
|
|
1071
|
+
return { patch, settings, engineId: engine.id, route };
|
|
1072
|
+
}
|
|
1073
|
+
var HOST_ENGINES_TIMEOUT_MS = 3e3;
|
|
1074
|
+
var hardwareByHost = /* @__PURE__ */ new WeakMap();
|
|
1075
|
+
var scanByHost = /* @__PURE__ */ new WeakMap();
|
|
1076
|
+
function canScanForSetup(ai) {
|
|
1077
|
+
return !!(ai.detectLocalServers || ai.hardwareInfo || ai.detectHostEngines);
|
|
1078
|
+
}
|
|
1079
|
+
var hardwareKnown = /* @__PURE__ */ new WeakMap();
|
|
1080
|
+
function setupHardware(ai) {
|
|
1081
|
+
let info = hardwareByHost.get(ai);
|
|
1082
|
+
if (!info) {
|
|
1083
|
+
info = ai.hardwareInfo ? ai.hardwareInfo().then((h) => h && typeof h === "object" ? h : null, () => null) : Promise.resolve(null);
|
|
1084
|
+
info = info.then((h) => {
|
|
1085
|
+
if (h) {
|
|
1086
|
+
hardwareKnown.set(ai, h);
|
|
1087
|
+
}
|
|
1088
|
+
return h;
|
|
1089
|
+
});
|
|
1090
|
+
hardwareByHost.set(ai, info);
|
|
1091
|
+
}
|
|
1092
|
+
return info;
|
|
1093
|
+
}
|
|
1094
|
+
function knownHardware(ai) {
|
|
1095
|
+
return hardwareKnown.get(ai) ?? null;
|
|
1096
|
+
}
|
|
1097
|
+
async function hostEngineAvailability(ai, signal) {
|
|
1098
|
+
if (!ai.detectHostEngines || signal?.aborted) {
|
|
1099
|
+
return null;
|
|
1100
|
+
}
|
|
1101
|
+
let timer;
|
|
1102
|
+
const timeout = new Promise((resolve) => {
|
|
1103
|
+
timer = setTimeout(() => resolve(null), HOST_ENGINES_TIMEOUT_MS);
|
|
1104
|
+
});
|
|
1105
|
+
try {
|
|
1106
|
+
const probes = await Promise.race([ai.detectHostEngines(), timeout]);
|
|
1107
|
+
return probes && Array.isArray(probes) && !signal?.aborted ? vscodeLmAvailability(probes) : null;
|
|
1108
|
+
} catch {
|
|
1109
|
+
return null;
|
|
1110
|
+
} finally {
|
|
1111
|
+
clearTimeout(timer);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
function lastSetupScan(ai) {
|
|
1115
|
+
return scanByHost.get(ai) ?? null;
|
|
1116
|
+
}
|
|
1117
|
+
function plainSetupScan() {
|
|
1118
|
+
return { recommendation: recommendSetupPath({ running: [], hardware: null }), probes: null, hardware: null, vscodeLm: null };
|
|
1119
|
+
}
|
|
1120
|
+
async function scanForSetup(ai, signal, probes) {
|
|
1121
|
+
const [found, hardware, vscodeLm] = await Promise.all([
|
|
1122
|
+
probes !== void 0 ? Promise.resolve(probes) : detectLocalServers(ai, signal),
|
|
1123
|
+
setupHardware(ai),
|
|
1124
|
+
hostEngineAvailability(ai, signal)
|
|
1125
|
+
]);
|
|
1126
|
+
const recommendation = recommendSetupPath({ running: found ?? [], hardware, ...vscodeLm ? { vscodeLm } : {} });
|
|
1127
|
+
const scan = { recommendation, probes: found, hardware, vscodeLm };
|
|
1128
|
+
if (!signal?.aborted) {
|
|
1129
|
+
scanByHost.set(ai, scan);
|
|
1130
|
+
}
|
|
1131
|
+
return scan;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// src/ai-settings-dom.ts
|
|
1135
|
+
var svg = (body, size = 14, extra = "") => `<svg width="${size}" height="${size}" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"${extra}>${body}</svg>`;
|
|
1136
|
+
var ICONS = {
|
|
1137
|
+
starOff: svg('<path d="M8 1.9l1.86 3.77 4.16.6-3.01 2.94.71 4.14L8 11.4l-3.72 1.95.71-4.14L1.98 6.27l4.16-.6z"/>'),
|
|
1138
|
+
starOn: svg('<path d="M8 1.9l1.86 3.77 4.16.6-3.01 2.94.71 4.14L8 11.4l-3.72 1.95.71-4.14L1.98 6.27l4.16-.6z" fill="currentColor"/>'),
|
|
1139
|
+
trash: svg('<path d="M2.5 4h11M6.5 4V2.5h3V4M4 4l.7 9.5h6.6L12 4M6.6 6.5v4.5M9.4 6.5v4.5"/>'),
|
|
1140
|
+
chevron: svg('<path d="M6 4l4 4-4 4"/>', 12),
|
|
1141
|
+
plus: svg('<path d="M8 3v10M3 8h10"/>', 12),
|
|
1142
|
+
local: svg('<path d="M2.5 7.5L8 3l5.5 4.5M4 6.5V13h8V6.5"/>', 11),
|
|
1143
|
+
cloud: svg('<path d="M4.5 12.5h7a2.75 2.75 0 0 0 .3-5.48A4 4 0 0 0 4.1 7.6 2.5 2.5 0 0 0 4.5 12.5z"/>', 11),
|
|
1144
|
+
warn: svg('<path d="M8 2.2l6.2 11H1.8z"/><path d="M8 6.5v3M8 11.4v.1"/>', 12),
|
|
1145
|
+
info: svg('<circle cx="8" cy="8" r="6"/><path d="M8 7.2v3.8M8 5v.1"/>', 12),
|
|
1146
|
+
check: svg('<path d="M3.5 8.5l3 3 6-7"/>', 12),
|
|
1147
|
+
braces: svg('<path d="M6 2.5c-1.5 0-2 .7-2 2v1.6c0 .9-.5 1.4-1.4 1.9.9.5 1.4 1 1.4 1.9v1.6c0 1.3.5 2 2 2M10 2.5c1.5 0 2 .7 2 2v1.6c0 .9.5 1.4 1.4 1.9-.9.5-1.4 1-1.4 1.9v1.6c0 1.3-.5 2-2 2"/>', 16),
|
|
1148
|
+
close: svg('<path d="M4 4l8 8M12 4l-8 8"/>', 10),
|
|
1149
|
+
refresh: svg('<path d="M13 8a5 5 0 1 1-1.5-3.55M13 2.5v3h-3"/>', 12),
|
|
1150
|
+
search: svg('<circle cx="7" cy="7" r="4.25"/><path d="M10.2 10.2l3.3 3.3"/>', 12),
|
|
1151
|
+
back: svg('<path d="M10 3.5L5.5 8l4.5 4.5"/>', 14),
|
|
1152
|
+
routing: svg('<path d="M3 4h4.5a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2H13M3 12h3M11 2.5L13 4l-2 1.5M11 10.5l2 1.5-2 1.5"/>', 16)
|
|
1153
|
+
};
|
|
1154
|
+
var KIND_MONOGRAMS = {
|
|
1155
|
+
lmstudio: "LM",
|
|
1156
|
+
ollama: "Ol",
|
|
1157
|
+
anthropic: "An",
|
|
1158
|
+
openai: "OA",
|
|
1159
|
+
"vscode-lm": "Co"
|
|
1160
|
+
};
|
|
1161
|
+
var KNOWN_KINDS = /* @__PURE__ */ new Set(["openai-compatible", "lmstudio", "ollama", "anthropic", "openai", "vscode-lm"]);
|
|
1162
|
+
function kindGlyphHtml(kind, extra = "") {
|
|
1163
|
+
const known = KNOWN_KINDS.has(kind);
|
|
1164
|
+
const inner = !known ? "?" : kind === "openai-compatible" ? ICONS.braces : KIND_MONOGRAMS[kind] ?? "?";
|
|
1165
|
+
return `<span class="ai-glyph ${extra}" data-kind="${known ? kind : "unknown"}" aria-hidden="true">${inner}</span>`;
|
|
1166
|
+
}
|
|
1167
|
+
var lowerFirst = (text) => text.charAt(0).toLowerCase() + text.slice(1);
|
|
1168
|
+
var AI_EXPERIMENTAL_DISCLAIMER = `AI features are experimental and may change or be removed. AI output can be wrong, so review it before you keep it. With a cloud engine, ${lowerFirst(AI_CLOUD_PRIVACY.leaves)} ${AI_CLOUD_PRIVACY.local}`;
|
|
1169
|
+
function experimentalBadge() {
|
|
1170
|
+
return el("span", "ai-experimental-badge", "Experimental");
|
|
1171
|
+
}
|
|
1172
|
+
function experimentalDisclaimer(className = "") {
|
|
1173
|
+
return el("p", `ai-disclaimer${className ? ` ${className}` : ""}`, AI_EXPERIMENTAL_DISCLAIMER);
|
|
1174
|
+
}
|
|
1175
|
+
function hostLink(label, url, openLink, className = "") {
|
|
1176
|
+
const a = el("a", `ai-link-btn${className ? ` ${className}` : ""}`, label);
|
|
1177
|
+
a.href = url;
|
|
1178
|
+
a.target = "_blank";
|
|
1179
|
+
a.rel = "noopener noreferrer";
|
|
1180
|
+
a.title = url;
|
|
1181
|
+
if (openLink) {
|
|
1182
|
+
a.addEventListener("click", (e) => {
|
|
1183
|
+
e.preventDefault();
|
|
1184
|
+
openLink(url);
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
return a;
|
|
1188
|
+
}
|
|
1189
|
+
function policyLink(policy, openLink) {
|
|
1190
|
+
return hostLink(policy.label, policy.url, openLink, "ai-privacy-link");
|
|
1191
|
+
}
|
|
1192
|
+
function privacyCard(source, opts = {}) {
|
|
1193
|
+
const list = !source ? [] : Array.isArray(source) ? source : [source];
|
|
1194
|
+
const policies = [];
|
|
1195
|
+
for (const s of list) {
|
|
1196
|
+
if (s.policy && !policies.some((p) => p.url === s.policy.url)) {
|
|
1197
|
+
policies.push(s.policy);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
const body = el("div", "ai-privacy-body");
|
|
1201
|
+
const lines = el("ul", "ai-privacy-lines");
|
|
1202
|
+
const texts = opts.hostEngine ? [AI_HOST_PRIVACY.leaves, AI_HOST_PRIVACY.terms, AI_HOST_PRIVACY.permission, AI_HOST_PRIVACY.local] : [AI_CLOUD_PRIVACY.leaves, AI_CLOUD_PRIVACY.terms, AI_CLOUD_PRIVACY.local, AI_CLOUD_PRIVACY.keys];
|
|
1203
|
+
for (const text of texts) {
|
|
1204
|
+
lines.append(el("li", "", text));
|
|
1205
|
+
}
|
|
1206
|
+
body.append(lines);
|
|
1207
|
+
for (const policy of policies) {
|
|
1208
|
+
const line = el("p", "ai-privacy-policy");
|
|
1209
|
+
line.append(policyLink(policy, opts.openLink));
|
|
1210
|
+
if (policy.note && !opts.hostEngine) {
|
|
1211
|
+
line.append(el("span", "ai-privacy-policy-note", ` ${policy.note}`));
|
|
1212
|
+
}
|
|
1213
|
+
body.append(line);
|
|
1214
|
+
}
|
|
1215
|
+
if (opts.extra) {
|
|
1216
|
+
body.append(el("p", "ai-privacy-extra", opts.extra));
|
|
1217
|
+
}
|
|
1218
|
+
const className = `ai-privacy-card${opts.className ? ` ${opts.className}` : ""}`;
|
|
1219
|
+
const glyph = el("span", "ai-privacy-glyph");
|
|
1220
|
+
glyph.innerHTML = ICONS.cloud;
|
|
1221
|
+
if (opts.collapsed) {
|
|
1222
|
+
const details = el("details", className);
|
|
1223
|
+
details.open = !!opts.open;
|
|
1224
|
+
const summary = el("summary", "ai-privacy-summary");
|
|
1225
|
+
summary.append(glyph, el("span", "", typeof opts.collapsed === "string" ? opts.collapsed : "What is sent"));
|
|
1226
|
+
details.append(summary, body);
|
|
1227
|
+
if (opts.onToggle) {
|
|
1228
|
+
details.addEventListener("toggle", () => opts.onToggle(details.open));
|
|
1229
|
+
}
|
|
1230
|
+
return details;
|
|
1231
|
+
}
|
|
1232
|
+
const card = el("div", className);
|
|
1233
|
+
const head = el("div", "ai-privacy-head");
|
|
1234
|
+
head.append(glyph, el("span", "", opts.hostEngine ? "GitHub Copilot and your text" : "Cloud engines and your text"));
|
|
1235
|
+
card.append(head, body);
|
|
1236
|
+
return card;
|
|
1237
|
+
}
|
|
1238
|
+
function cloudAccessBox(o) {
|
|
1239
|
+
const box = el("div", "ai-cloud-access");
|
|
1240
|
+
const row = el("div", "ai-cloud-access-row");
|
|
1241
|
+
const toggle = switchInput(o.allowed, "Allow cloud engines", o.fk);
|
|
1242
|
+
toggle.id = nextId("ai-allow-cloud");
|
|
1243
|
+
const label = el("label", "ai-cloud-access-label", "Allow cloud engines");
|
|
1244
|
+
label.htmlFor = toggle.id;
|
|
1245
|
+
const line = el("span", "ai-cloud-access-line", "Text you ask about is sent to the provider over the internet.");
|
|
1246
|
+
line.id = nextId("ai-allow-cloud-line");
|
|
1247
|
+
toggle.setAttribute("aria-describedby", line.id);
|
|
1248
|
+
toggle.addEventListener("change", () => o.onChange(toggle.checked));
|
|
1249
|
+
const text = el("div", "ai-cloud-access-text");
|
|
1250
|
+
text.append(label, line);
|
|
1251
|
+
row.append(toggle, text);
|
|
1252
|
+
box.append(row, privacyCard(o.presets, {
|
|
1253
|
+
collapsed: "What this means",
|
|
1254
|
+
open: o.open,
|
|
1255
|
+
onToggle: (open) => o.onToggle?.(open),
|
|
1256
|
+
openLink: o.openLink,
|
|
1257
|
+
extra: aiConsentStepNote(o.hostAsks),
|
|
1258
|
+
className: "ai-cloud-access-privacy"
|
|
1259
|
+
}));
|
|
1260
|
+
return box;
|
|
1261
|
+
}
|
|
1262
|
+
function privacyLine(onLearnMore) {
|
|
1263
|
+
const line = el("p", "ai-privacy-line");
|
|
1264
|
+
const glyph = el("span", "ai-privacy-glyph");
|
|
1265
|
+
glyph.innerHTML = ICONS.cloud;
|
|
1266
|
+
const more = button("ai-link-btn", "Learn more", { fk: "ai-privacy:learn-more" });
|
|
1267
|
+
more.addEventListener("click", () => onLearnMore());
|
|
1268
|
+
line.append(glyph, el("span", "", `A cloud engine sends text to the provider. ${AI_CLOUD_PRIVACY.local} `), more);
|
|
1269
|
+
return line;
|
|
1270
|
+
}
|
|
1271
|
+
var uid = 0;
|
|
1272
|
+
var nextId = (prefix) => `${prefix}-${++uid}`;
|
|
1273
|
+
function el(tag, className, text) {
|
|
1274
|
+
const node = document.createElement(tag);
|
|
1275
|
+
if (className) {
|
|
1276
|
+
node.className = className;
|
|
1277
|
+
}
|
|
1278
|
+
if (text !== void 0) {
|
|
1279
|
+
node.textContent = text;
|
|
1280
|
+
}
|
|
1281
|
+
return node;
|
|
1282
|
+
}
|
|
1283
|
+
function button(className, label, opts = {}) {
|
|
1284
|
+
const b = el("button", className);
|
|
1285
|
+
b.type = "button";
|
|
1286
|
+
if (opts.html !== void 0) {
|
|
1287
|
+
b.innerHTML = opts.html;
|
|
1288
|
+
if (label) {
|
|
1289
|
+
b.append(el("span", "ai-btn-text", label));
|
|
1290
|
+
}
|
|
1291
|
+
} else {
|
|
1292
|
+
b.textContent = label;
|
|
1293
|
+
}
|
|
1294
|
+
if (opts.ariaLabel) {
|
|
1295
|
+
b.setAttribute("aria-label", opts.ariaLabel);
|
|
1296
|
+
}
|
|
1297
|
+
if (opts.title) {
|
|
1298
|
+
b.title = opts.title;
|
|
1299
|
+
}
|
|
1300
|
+
if (opts.fk) {
|
|
1301
|
+
b.dataset.fk = opts.fk;
|
|
1302
|
+
}
|
|
1303
|
+
return b;
|
|
1304
|
+
}
|
|
1305
|
+
function switchInput(checked, ariaLabel, fk) {
|
|
1306
|
+
const input = el("input", "ai-switch");
|
|
1307
|
+
input.type = "checkbox";
|
|
1308
|
+
input.setAttribute("role", "switch");
|
|
1309
|
+
input.checked = checked;
|
|
1310
|
+
input.setAttribute("aria-label", ariaLabel);
|
|
1311
|
+
if (fk) {
|
|
1312
|
+
input.dataset.fk = fk;
|
|
1313
|
+
}
|
|
1314
|
+
return input;
|
|
1315
|
+
}
|
|
1316
|
+
function note(level, text, className = "") {
|
|
1317
|
+
const n = el("div", `ai-note ai-note--${level}${className ? ` ${className}` : ""}`);
|
|
1318
|
+
const icon = el("span", "ai-note-icon");
|
|
1319
|
+
icon.innerHTML = level === "ok" ? ICONS.check : level === "info" ? ICONS.info : ICONS.warn;
|
|
1320
|
+
n.append(icon, el("span", "ai-note-text", text));
|
|
1321
|
+
return n;
|
|
1322
|
+
}
|
|
1323
|
+
function keepFocus(scope, rebuild) {
|
|
1324
|
+
const active = document.activeElement;
|
|
1325
|
+
const key = active && scope.contains(active) ? active.dataset.fk : void 0;
|
|
1326
|
+
let sel = null;
|
|
1327
|
+
if (key && (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement)) {
|
|
1328
|
+
try {
|
|
1329
|
+
sel = [active.selectionStart, active.selectionEnd];
|
|
1330
|
+
} catch {
|
|
1331
|
+
sel = null;
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
const target = rebuild() ?? scope;
|
|
1335
|
+
if (!key) {
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
const next = Array.from(target.querySelectorAll("[data-fk]")).find((n) => n.dataset.fk === key);
|
|
1339
|
+
if (!next) {
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
next.focus({ preventScroll: true });
|
|
1343
|
+
if (sel && (next instanceof HTMLInputElement || next instanceof HTMLTextAreaElement)) {
|
|
1344
|
+
try {
|
|
1345
|
+
next.setSelectionRange(sel[0], sel[1]);
|
|
1346
|
+
} catch {
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
function statusBadge(status, busy, opts = {}) {
|
|
1351
|
+
const badge = el("span", `ai-status${opts.compact ? " ai-status--compact" : ""}`);
|
|
1352
|
+
const dot = el("span", "ai-status-dot");
|
|
1353
|
+
dot.setAttribute("aria-hidden", "true");
|
|
1354
|
+
const text = el("span", "ai-status-text");
|
|
1355
|
+
let state;
|
|
1356
|
+
let label;
|
|
1357
|
+
let title;
|
|
1358
|
+
if (busy) {
|
|
1359
|
+
state = "busy";
|
|
1360
|
+
label = busy === "test" ? "Testing\u2026" : "Fetching\u2026";
|
|
1361
|
+
title = busy === "test" ? "Testing the connection\u2026" : "Fetching models\u2026";
|
|
1362
|
+
} else if (!status || status.ok === null) {
|
|
1363
|
+
state = "unknown";
|
|
1364
|
+
label = "Not tested";
|
|
1365
|
+
title = "Not reached yet. Open the engine and fetch its models, or choose Test connection.";
|
|
1366
|
+
} else if (status.ok) {
|
|
1367
|
+
state = "ok";
|
|
1368
|
+
label = status.latencyMs !== void 0 ? `${Math.round(status.latencyMs)} ms` : "OK";
|
|
1369
|
+
title = `Connected${status.latencyMs !== void 0 ? ` in ${Math.round(status.latencyMs)} ms` : ""} on the last test or model fetch.`;
|
|
1370
|
+
} else {
|
|
1371
|
+
state = "error";
|
|
1372
|
+
label = "Failed";
|
|
1373
|
+
title = status.error ? `Last test failed: ${status.error}` : "The last test failed.";
|
|
1374
|
+
}
|
|
1375
|
+
badge.dataset.state = state;
|
|
1376
|
+
badge.title = title;
|
|
1377
|
+
text.textContent = label;
|
|
1378
|
+
badge.append(dot, text);
|
|
1379
|
+
badge.setAttribute("aria-label", `Status: ${title}`);
|
|
1380
|
+
badge.setAttribute("role", "img");
|
|
1381
|
+
return badge;
|
|
1382
|
+
}
|
|
1383
|
+
function localityPills(engine, settings, opts = {}) {
|
|
1384
|
+
const info = engineLocalityInfo(engine, settings.localNetworks);
|
|
1385
|
+
const pill = el("span", `ai-pill ai-pill--${info.locality}`);
|
|
1386
|
+
pill.innerHTML = info.locality === "local" ? ICONS.local : ICONS.cloud;
|
|
1387
|
+
pill.append(el("span", "", info.locality === "local" ? "local" : "cloud"));
|
|
1388
|
+
if (info.mismatch) {
|
|
1389
|
+
pill.classList.add("ai-pill--mismatch");
|
|
1390
|
+
pill.title = info.locality === "cloud" ? "Pinned to cloud, but the address looks local." : "Pinned to local, but the address looks like a cloud server.";
|
|
1391
|
+
}
|
|
1392
|
+
const pills = [pill];
|
|
1393
|
+
if (info.locality === "cloud" && !settings.allowCloud && opts.blocked !== false) {
|
|
1394
|
+
const blocked = el("span", "ai-pill ai-pill--blocked", "blocked by Allow cloud");
|
|
1395
|
+
blocked.title = "Turn on Allow cloud engines to route tasks here.";
|
|
1396
|
+
pills.push(blocked);
|
|
1397
|
+
}
|
|
1398
|
+
return pills;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// src/ai-engine-form.ts
|
|
1402
|
+
var FILTER_THRESHOLD = 8;
|
|
1403
|
+
var SIZE_GROUPS = [
|
|
1404
|
+
{ tag: "fits", title: "Runs well on this machine" },
|
|
1405
|
+
{ tag: "tight", title: "A tight fit" },
|
|
1406
|
+
{ tag: "too large", title: "Likely too large for this machine" },
|
|
1407
|
+
{ tag: void 0, title: "Size not in the name" }
|
|
1408
|
+
];
|
|
1409
|
+
var SIZE_TITLES = {
|
|
1410
|
+
fits: "Its size should run well in this machine\u2019s memory (a 4-bit estimate).",
|
|
1411
|
+
tight: "Near the limit of this machine\u2019s memory: it may run slowly.",
|
|
1412
|
+
"too large": "Probably more than this machine\u2019s memory holds. You can still pick it."
|
|
1413
|
+
};
|
|
1414
|
+
var LOCALITY_LABELS = {
|
|
1415
|
+
local: "Local",
|
|
1416
|
+
cloud: "Cloud"
|
|
1417
|
+
};
|
|
1418
|
+
function createEngineFormController(deps) {
|
|
1419
|
+
const { ai } = deps;
|
|
1420
|
+
const fetches = /* @__PURE__ */ new Map();
|
|
1421
|
+
const testing = /* @__PURE__ */ new Set();
|
|
1422
|
+
const results = /* @__PURE__ */ new Map();
|
|
1423
|
+
const keyResults = /* @__PURE__ */ new Map();
|
|
1424
|
+
const advancedOpen = /* @__PURE__ */ new Set();
|
|
1425
|
+
const privacyOpen = /* @__PURE__ */ new Set();
|
|
1426
|
+
const keyText = /* @__PURE__ */ new Map();
|
|
1427
|
+
const keyStored = /* @__PURE__ */ new Map();
|
|
1428
|
+
const keyTimers = /* @__PURE__ */ new Map();
|
|
1429
|
+
const pickerFilter = /* @__PURE__ */ new Map();
|
|
1430
|
+
const typeOpen = /* @__PURE__ */ new Set();
|
|
1431
|
+
const openOverrides = /* @__PURE__ */ new Set();
|
|
1432
|
+
const showAll = /* @__PURE__ */ new Set();
|
|
1433
|
+
const formPicked = /* @__PURE__ */ new Map();
|
|
1434
|
+
const overrideErrors = /* @__PURE__ */ new Map();
|
|
1435
|
+
const pickRequired = /* @__PURE__ */ new Set();
|
|
1436
|
+
const getEngine = (id) => deps.getDraft().engines.find((e) => e.id === id);
|
|
1437
|
+
const presetOf = (engine) => enginePreset(engine, deps.getDraft().localNetworks);
|
|
1438
|
+
const isLocal = (engine) => engineLocalityInfo(engine, deps.getDraft().localNetworks).locality === "local";
|
|
1439
|
+
function fetchState(id) {
|
|
1440
|
+
let st = fetches.get(id);
|
|
1441
|
+
if (!st) {
|
|
1442
|
+
st = { seq: 0, running: false, again: null, problem: null, prev: null };
|
|
1443
|
+
fetches.set(id, st);
|
|
1444
|
+
}
|
|
1445
|
+
return st;
|
|
1446
|
+
}
|
|
1447
|
+
function replaceEngine(engine, refresh) {
|
|
1448
|
+
deps.setDraft(withEngine(deps.getDraft(), engine), refresh);
|
|
1449
|
+
}
|
|
1450
|
+
function updateEngine(id, patch, refresh) {
|
|
1451
|
+
const current = getEngine(id);
|
|
1452
|
+
if (current) {
|
|
1453
|
+
replaceEngine({ ...current, ...patch }, refresh);
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
function updateModel(engineId, modelId, fn, refresh) {
|
|
1457
|
+
const engine = getEngine(engineId);
|
|
1458
|
+
const model = engine?.models.find((m) => m.id === modelId);
|
|
1459
|
+
if (engine && model) {
|
|
1460
|
+
replaceEngine(withModel(engine, fn(model)), refresh);
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
function snapshot(engine, event) {
|
|
1464
|
+
const st = fetchState(engine.id);
|
|
1465
|
+
const status = deps.statuses.get(engine.id);
|
|
1466
|
+
return {
|
|
1467
|
+
event,
|
|
1468
|
+
family: presetOf(engine).family,
|
|
1469
|
+
key: keyText.get(engine.id) ?? "",
|
|
1470
|
+
baseUrl: engine.baseUrl,
|
|
1471
|
+
discovery: engine.discovery,
|
|
1472
|
+
neverProbed: !status || status.ok === null,
|
|
1473
|
+
hasKey: deps.hasKey(engine.id),
|
|
1474
|
+
modelCount: engine.models.length,
|
|
1475
|
+
fetchedFor: st.fetchedFor
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
function trigger(id, event) {
|
|
1479
|
+
const engine = getEngine(id);
|
|
1480
|
+
if (!engine || !ai.discoverModels) {
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
const st = fetchState(id);
|
|
1484
|
+
const next = snapshot(engine, event);
|
|
1485
|
+
const decision = autoFetchDecision(st.prev, next);
|
|
1486
|
+
st.prev = next;
|
|
1487
|
+
if (decision.cancelPending) {
|
|
1488
|
+
window.clearTimeout(st.timer);
|
|
1489
|
+
st.timer = void 0;
|
|
1490
|
+
}
|
|
1491
|
+
if (!decision.fetch) {
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
if (decision.delayMs > 0) {
|
|
1495
|
+
st.timer = window.setTimeout(() => {
|
|
1496
|
+
st.timer = void 0;
|
|
1497
|
+
void runFetch(id, event);
|
|
1498
|
+
}, decision.delayMs);
|
|
1499
|
+
} else {
|
|
1500
|
+
void runFetch(id, event);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
async function runFetch(id, event) {
|
|
1504
|
+
const st = fetchState(id);
|
|
1505
|
+
const seq = ++st.seq;
|
|
1506
|
+
st.lastEvent = event;
|
|
1507
|
+
if (st.running) {
|
|
1508
|
+
st.again = event;
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
const engine = getEngine(id);
|
|
1512
|
+
if (!engine || !ai.discoverModels) {
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
st.running = true;
|
|
1516
|
+
st.problem = null;
|
|
1517
|
+
deps.refresh(id, { form: true, head: true });
|
|
1518
|
+
const preset = presetOf(engine);
|
|
1519
|
+
const name = engineDisplayName(engine);
|
|
1520
|
+
try {
|
|
1521
|
+
const typed = (keyText.get(id) ?? "").trim();
|
|
1522
|
+
if (ai.setSecret && typed && keyStored.get(id) !== typed) {
|
|
1523
|
+
const error = await deps.setKey(id, typed);
|
|
1524
|
+
if (error) {
|
|
1525
|
+
throw new Error(error);
|
|
1526
|
+
}
|
|
1527
|
+
keyStored.set(id, typed);
|
|
1528
|
+
}
|
|
1529
|
+
await deps.ensureSaved(id);
|
|
1530
|
+
const status = await ai.discoverModels(id);
|
|
1531
|
+
if (!status || typeof status !== "object") {
|
|
1532
|
+
throw new Error("The app returned no model list.");
|
|
1533
|
+
}
|
|
1534
|
+
deps.statuses.set(id, status);
|
|
1535
|
+
deps.onStatuses();
|
|
1536
|
+
if (seq !== st.seq) {
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
const current = getEngine(id) ?? engine;
|
|
1540
|
+
if (status.ok === false) {
|
|
1541
|
+
st.problem = discoveryProblem({ family: preset.family, presetName: preset.name, baseUrl: current.baseUrl, ok: false, message: status.error, listed: 0 });
|
|
1542
|
+
} else {
|
|
1543
|
+
const hadPick = current.models.some((m) => m.enabled && !m.missing);
|
|
1544
|
+
let draft = withEngine(deps.getDraft(), applyDiscoveryForFamily(current, status, preset.family));
|
|
1545
|
+
const merged = draft.engines.find((e) => e.id === id) ?? current;
|
|
1546
|
+
let picked = null;
|
|
1547
|
+
if (!hadPick) {
|
|
1548
|
+
picked = preset.family === "local" ? merged.models.find((m) => m.enabled && !m.missing && m.capabilities.chat)?.id ?? null : autoPickDefault(preset, merged.models) ?? (preset.family === "host" ? firstChatModelId(merged.models) : null);
|
|
1549
|
+
if (picked) {
|
|
1550
|
+
draft = setEngineDefaultModel(draft, id, picked);
|
|
1551
|
+
formPicked.set(id, picked);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
if (!draft.routing.default) {
|
|
1555
|
+
const route = defaultRouteForEngine(draft, [...deps.statuses.values()], id);
|
|
1556
|
+
if (route) {
|
|
1557
|
+
draft = setEngineDefaultModel(draft, id, route.modelId);
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
const listed = merged.models.filter((m) => !m.missing).length;
|
|
1561
|
+
st.problem = discoveryProblem({ family: preset.family, presetName: preset.name, baseUrl: current.baseUrl, ok: true, listed });
|
|
1562
|
+
st.fetchedFor = fetchFingerprint(preset.family, keyText.get(id) ?? "", current.baseUrl);
|
|
1563
|
+
forgetStoredText(id);
|
|
1564
|
+
deps.setDraft(draft, { head: true, routing: true, list: !hadPick && !!picked });
|
|
1565
|
+
const pickedName = picked ? modelDisplayName(merged.models.find((m) => m.id === picked) ?? { id: picked }) : "";
|
|
1566
|
+
deps.announce(`${name}: ${listed} ${listed === 1 ? "model" : "models"} found${pickedName ? `. ${pickedName} picked as the default model` : ""}.`);
|
|
1567
|
+
}
|
|
1568
|
+
} catch (err) {
|
|
1569
|
+
if (seq !== st.seq) {
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
const e = toAiError(err);
|
|
1573
|
+
const current = getEngine(id) ?? engine;
|
|
1574
|
+
st.problem = discoveryProblem({ family: preset.family, presetName: preset.name, baseUrl: current.baseUrl, ok: false, code: e.code, message: e.message, listed: 0 });
|
|
1575
|
+
} finally {
|
|
1576
|
+
st.running = false;
|
|
1577
|
+
const again = st.again;
|
|
1578
|
+
st.again = null;
|
|
1579
|
+
if (again && seq !== st.seq) {
|
|
1580
|
+
void runFetch(id, again);
|
|
1581
|
+
} else if (getEngine(id)) {
|
|
1582
|
+
if (st.problem && st.problem.kind !== "empty") {
|
|
1583
|
+
deps.announce(`${name}: ${st.problem.text}`);
|
|
1584
|
+
}
|
|
1585
|
+
deps.refresh(id, { form: true, head: true });
|
|
1586
|
+
if (st.problem?.focus === "key") {
|
|
1587
|
+
deps.focusKey(`eng:${id}:key`);
|
|
1588
|
+
const active = document.activeElement;
|
|
1589
|
+
if (active instanceof HTMLInputElement) {
|
|
1590
|
+
active.select();
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
function forgetStoredText(id) {
|
|
1597
|
+
const typed = keyText.get(id);
|
|
1598
|
+
if (typed !== void 0 && typed.trim() === keyStored.get(id)) {
|
|
1599
|
+
keyText.delete(id);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
async function settleKey(id) {
|
|
1603
|
+
window.clearTimeout(keyTimers.get(id));
|
|
1604
|
+
keyTimers.delete(id);
|
|
1605
|
+
const engine = getEngine(id);
|
|
1606
|
+
const typed = (keyText.get(id) ?? "").trim();
|
|
1607
|
+
if (!engine || !typed || keyStored.get(id) === typed || !ai.setSecret) {
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
const error = await deps.setKey(id, typed);
|
|
1611
|
+
if (error) {
|
|
1612
|
+
keyResults.set(id, { level: "error", text: error });
|
|
1613
|
+
deps.refresh(id, { form: true });
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
keyStored.set(id, typed);
|
|
1617
|
+
keyResults.delete(id);
|
|
1618
|
+
const st = fetchState(id);
|
|
1619
|
+
if (st.problem?.kind !== "auth") {
|
|
1620
|
+
forgetStoredText(id);
|
|
1621
|
+
}
|
|
1622
|
+
if (presetOf(engine).family === "local") {
|
|
1623
|
+
if (st.problem?.kind === "auth") {
|
|
1624
|
+
void runFetch(id, "retry");
|
|
1625
|
+
} else {
|
|
1626
|
+
deps.refresh(id, { form: true });
|
|
1627
|
+
}
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
deps.refresh(id, { form: true });
|
|
1631
|
+
}
|
|
1632
|
+
async function runTest(id) {
|
|
1633
|
+
const engine = getEngine(id);
|
|
1634
|
+
if (!engine || !ai.testEngine || testing.has(id)) {
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
const name = engineDisplayName(engine);
|
|
1638
|
+
testing.add(id);
|
|
1639
|
+
results.delete(id);
|
|
1640
|
+
deps.refresh(id, { form: true, head: true });
|
|
1641
|
+
try {
|
|
1642
|
+
await deps.ensureSaved(id);
|
|
1643
|
+
const status = await ai.testEngine(id);
|
|
1644
|
+
deps.statuses.set(id, status);
|
|
1645
|
+
deps.onStatuses();
|
|
1646
|
+
if (status.ok) {
|
|
1647
|
+
const ms = status.latencyMs !== void 0 ? ` in ${Math.round(status.latencyMs)} ms` : "";
|
|
1648
|
+
const n = status.models.filter((m) => !m.missing).length;
|
|
1649
|
+
const text = `Connected${ms}${n ? `. The server lists ${n} ${n === 1 ? "model" : "models"}.` : "."}`;
|
|
1650
|
+
results.set(id, { level: "ok", text });
|
|
1651
|
+
deps.announce(`${name}: ${text}`);
|
|
1652
|
+
} else {
|
|
1653
|
+
const text = `Failed${status.error ? `: ${status.error}` : ". The server did not answer."}`;
|
|
1654
|
+
results.set(id, { level: "error", text });
|
|
1655
|
+
deps.announce(`${name}: connection ${text.toLowerCase()}`);
|
|
1656
|
+
}
|
|
1657
|
+
} catch (err) {
|
|
1658
|
+
const e = toAiError(err);
|
|
1659
|
+
const copy = aiErrorCopy(e.code);
|
|
1660
|
+
const text = `${copy.title}${e.message && e.message !== copy.title ? `: ${e.message}` : "."}`;
|
|
1661
|
+
results.set(id, { level: "error", text });
|
|
1662
|
+
deps.announce(`${name}: ${text}`);
|
|
1663
|
+
} finally {
|
|
1664
|
+
testing.delete(id);
|
|
1665
|
+
deps.refresh(id, { form: true, head: true, routing: true });
|
|
1666
|
+
deps.focusKey(`eng:${id}:test`);
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
async function lookUpKey(id) {
|
|
1670
|
+
const engine = getEngine(id);
|
|
1671
|
+
if (!engine || !ai.listEngines || !ai.discoverModels || deps.hasKey(id) || !deps.savesFirst(id)) {
|
|
1672
|
+
return;
|
|
1673
|
+
}
|
|
1674
|
+
if (!engine.apiKeyEnv?.trim() && !engine.apiKeyFile?.path.trim()) {
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
try {
|
|
1678
|
+
await deps.ensureSaved(id);
|
|
1679
|
+
const status = (await ai.listEngines()).find((s) => s.engineId === id);
|
|
1680
|
+
if (!status?.hasKey || !getEngine(id) || (keyText.get(id) ?? "").trim()) {
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
deps.statuses.set(id, status);
|
|
1684
|
+
deps.onStatuses();
|
|
1685
|
+
deps.refresh(id, { form: true, head: true });
|
|
1686
|
+
trigger(id, "open");
|
|
1687
|
+
} catch {
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
function build(id, opts = {}) {
|
|
1691
|
+
const engine = getEngine(id);
|
|
1692
|
+
const wrap = el("div", "ai-form");
|
|
1693
|
+
wrap.id = `ai-engine-editor-${id}`;
|
|
1694
|
+
if (!engine) {
|
|
1695
|
+
return wrap;
|
|
1696
|
+
}
|
|
1697
|
+
const preset = presetOf(engine);
|
|
1698
|
+
const setup = !!opts.setup;
|
|
1699
|
+
const fields = setup ? setupFields(preset, { key: fetchState(id).problem?.kind === "auth" || deps.hasKey(id) }) : expressFields(preset);
|
|
1700
|
+
wrap.dataset.family = preset.family;
|
|
1701
|
+
if (setup) {
|
|
1702
|
+
wrap.classList.add("ai-form--setup");
|
|
1703
|
+
}
|
|
1704
|
+
wrap.setAttribute("role", "group");
|
|
1705
|
+
wrap.setAttribute("aria-label", `${engineDisplayName(engine)} settings`);
|
|
1706
|
+
if ((preset.family === "cloud" || preset.family === "host") && opts.privacy !== false) {
|
|
1707
|
+
wrap.append(privacyCard({ policy: aiProviderPolicy(engine) }, {
|
|
1708
|
+
hostEngine: preset.family === "host",
|
|
1709
|
+
collapsed: "What is sent",
|
|
1710
|
+
open: privacyOpen.has(id),
|
|
1711
|
+
onToggle: (open) => {
|
|
1712
|
+
if (open) {
|
|
1713
|
+
privacyOpen.add(id);
|
|
1714
|
+
} else {
|
|
1715
|
+
privacyOpen.delete(id);
|
|
1716
|
+
}
|
|
1717
|
+
},
|
|
1718
|
+
openLink: deps.openLink,
|
|
1719
|
+
className: "ai-form-privacy"
|
|
1720
|
+
}));
|
|
1721
|
+
}
|
|
1722
|
+
const sheet = el("div", "ai-sheet");
|
|
1723
|
+
for (const field of fields.express) {
|
|
1724
|
+
const keyNote = field === "key" && ai.setSecret ? deps.keyNote?.(engine, preset) ?? null : null;
|
|
1725
|
+
const row = field === "key" ? buildKey(engine, preset, { hint: !keyNote }) : buildExpressField(engine, preset, field, setup);
|
|
1726
|
+
if (!row) {
|
|
1727
|
+
continue;
|
|
1728
|
+
}
|
|
1729
|
+
if (keyNote) {
|
|
1730
|
+
sheet.append(keyNote);
|
|
1731
|
+
}
|
|
1732
|
+
sheet.append(row);
|
|
1733
|
+
}
|
|
1734
|
+
wrap.append(sheet);
|
|
1735
|
+
if (!setup) {
|
|
1736
|
+
wrap.append(buildActions(engine));
|
|
1737
|
+
}
|
|
1738
|
+
wrap.append(...buildAdvanced(engine, preset, fields.advanced));
|
|
1739
|
+
return wrap;
|
|
1740
|
+
}
|
|
1741
|
+
function sheetRow(label, control, opts = {}) {
|
|
1742
|
+
const row = el("div", `ai-sheet-row${opts.className ? ` ${opts.className}` : ""}`);
|
|
1743
|
+
const lbl = el("label", "ai-sheet-label", label);
|
|
1744
|
+
if (opts.forId) {
|
|
1745
|
+
lbl.htmlFor = opts.forId;
|
|
1746
|
+
}
|
|
1747
|
+
const body = el("div", "ai-sheet-body");
|
|
1748
|
+
body.append(control);
|
|
1749
|
+
if (opts.caption) {
|
|
1750
|
+
body.append(typeof opts.caption === "string" ? el("div", "ai-caption", opts.caption) : opts.caption);
|
|
1751
|
+
}
|
|
1752
|
+
row.append(lbl, body);
|
|
1753
|
+
return row;
|
|
1754
|
+
}
|
|
1755
|
+
function buildExpressField(engine, preset, field, setup) {
|
|
1756
|
+
switch (field) {
|
|
1757
|
+
case "name":
|
|
1758
|
+
return buildName(engine);
|
|
1759
|
+
case "key":
|
|
1760
|
+
return buildKey(engine, preset);
|
|
1761
|
+
case "baseUrl":
|
|
1762
|
+
return buildUrl(engine, preset, "express");
|
|
1763
|
+
case "models":
|
|
1764
|
+
return buildModelsPicker(engine, preset, setup);
|
|
1765
|
+
case "defaultModel":
|
|
1766
|
+
return buildDefaultModel(engine);
|
|
1767
|
+
case "enabled":
|
|
1768
|
+
return buildEnabled(engine);
|
|
1769
|
+
default:
|
|
1770
|
+
return null;
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
function buildName(engine) {
|
|
1774
|
+
const id = engine.id;
|
|
1775
|
+
const input = el("input", "settings-input ai-sheet-input");
|
|
1776
|
+
input.type = "text";
|
|
1777
|
+
input.id = nextId("ai-name");
|
|
1778
|
+
input.value = engine.name;
|
|
1779
|
+
input.placeholder = "Shown in pickers and the indicator";
|
|
1780
|
+
input.dataset.fk = `eng:${id}:name`;
|
|
1781
|
+
input.addEventListener("input", () => updateEngine(id, { name: input.value }, { head: true, routing: true }));
|
|
1782
|
+
return sheetRow("Name", input, { forId: input.id });
|
|
1783
|
+
}
|
|
1784
|
+
function buildKey(engine, preset, o = {}) {
|
|
1785
|
+
if (!ai.setSecret) {
|
|
1786
|
+
return null;
|
|
1787
|
+
}
|
|
1788
|
+
const id = engine.id;
|
|
1789
|
+
const has = deps.hasKey(id);
|
|
1790
|
+
const line = el("div", "ai-key");
|
|
1791
|
+
const input = el("input", "settings-input settings-input--grow ai-mono");
|
|
1792
|
+
input.type = "password";
|
|
1793
|
+
input.id = nextId("ai-key");
|
|
1794
|
+
input.autocomplete = "off";
|
|
1795
|
+
input.spellcheck = false;
|
|
1796
|
+
input.dataset.fk = `eng:${id}:key`;
|
|
1797
|
+
input.setAttribute("aria-label", `API key for ${engineDisplayName(engine)}`);
|
|
1798
|
+
input.value = keyText.get(id) ?? "";
|
|
1799
|
+
const source = has ? deps.keySource?.(id) : void 0;
|
|
1800
|
+
const fromFile = has && (source ? source === "file" : !keyStored.has(id) && !!engine.apiKeyFile?.path);
|
|
1801
|
+
const fromEnv = has && source === "env";
|
|
1802
|
+
input.placeholder = fromFile || fromEnv ? "Found" : has ? "Stored" : "Paste your key";
|
|
1803
|
+
const problem = fetchState(id).problem;
|
|
1804
|
+
if (problem?.kind === "auth") {
|
|
1805
|
+
input.setAttribute("aria-invalid", "true");
|
|
1806
|
+
}
|
|
1807
|
+
input.addEventListener("input", () => {
|
|
1808
|
+
keyText.set(id, input.value);
|
|
1809
|
+
input.removeAttribute("aria-invalid");
|
|
1810
|
+
window.clearTimeout(keyTimers.get(id));
|
|
1811
|
+
const st = fetchState(id);
|
|
1812
|
+
if (presetOf(getEngine(id) ?? engine).family === "cloud") {
|
|
1813
|
+
trigger(id, "key");
|
|
1814
|
+
if (!st.timer && input.value.trim()) {
|
|
1815
|
+
keyTimers.set(id, window.setTimeout(() => {
|
|
1816
|
+
void settleKey(id);
|
|
1817
|
+
}, 600));
|
|
1818
|
+
}
|
|
1819
|
+
} else if (input.value.trim()) {
|
|
1820
|
+
keyTimers.set(id, window.setTimeout(() => {
|
|
1821
|
+
void settleKey(id);
|
|
1822
|
+
}, 600));
|
|
1823
|
+
}
|
|
1824
|
+
});
|
|
1825
|
+
const flush = () => {
|
|
1826
|
+
const st = fetchState(id);
|
|
1827
|
+
if (st.timer) {
|
|
1828
|
+
window.clearTimeout(st.timer);
|
|
1829
|
+
st.timer = void 0;
|
|
1830
|
+
void runFetch(id, "key");
|
|
1831
|
+
} else if (keyTimers.has(id)) {
|
|
1832
|
+
void settleKey(id);
|
|
1833
|
+
}
|
|
1834
|
+
};
|
|
1835
|
+
input.addEventListener("keydown", (e) => {
|
|
1836
|
+
if (e.key === "Enter") {
|
|
1837
|
+
e.preventDefault();
|
|
1838
|
+
flush();
|
|
1839
|
+
}
|
|
1840
|
+
});
|
|
1841
|
+
input.addEventListener("blur", flush);
|
|
1842
|
+
line.append(input);
|
|
1843
|
+
if (has && source !== "env" && source !== "file") {
|
|
1844
|
+
const clear = button("settings-btn", "Clear", { fk: `eng:${id}:key-clear`, ariaLabel: `Clear the stored API key for ${engineDisplayName(engine)}` });
|
|
1845
|
+
clear.addEventListener("click", () => {
|
|
1846
|
+
void clearKey(id);
|
|
1847
|
+
});
|
|
1848
|
+
line.append(clear);
|
|
1849
|
+
}
|
|
1850
|
+
const caption = el("div", "ai-caption ai-key-caption");
|
|
1851
|
+
const result = keyResults.get(id);
|
|
1852
|
+
if (result) {
|
|
1853
|
+
caption.append(note(result.level, result.text));
|
|
1854
|
+
} else if (fromEnv) {
|
|
1855
|
+
caption.innerHTML = `<span class="ai-key-state">${ICONS.check}<span>Key found</span></span>`;
|
|
1856
|
+
caption.append(" in ", el("code", "ai-key-env", engine.apiKeyEnv ?? "a variable"), ". Paste a key to use another one.");
|
|
1857
|
+
} else if (fromFile) {
|
|
1858
|
+
caption.innerHTML = `<span class="ai-key-state">${ICONS.check}<span>Key found</span></span>`;
|
|
1859
|
+
caption.append(` in ${engine.apiKeyFile?.path ?? "the key file"}${source ? "" : " (or stored here)"}. Paste a key to use another one.`);
|
|
1860
|
+
} else if (has) {
|
|
1861
|
+
caption.innerHTML = `<span class="ai-key-state">${ICONS.check}<span>Stored</span></span>`;
|
|
1862
|
+
caption.append(" in the system\u2019s secure storage. Paste a new key to replace it.");
|
|
1863
|
+
} else if (o.hint === false) {
|
|
1864
|
+
} else if (preset.keyUrl) {
|
|
1865
|
+
caption.append(hostLink(preset.keyHint, preset.keyUrl, deps.openLink, "ai-key-link"));
|
|
1866
|
+
} else {
|
|
1867
|
+
caption.textContent = preset.keyHint;
|
|
1868
|
+
}
|
|
1869
|
+
return sheetRow("API key", line, { forId: input.id, caption: caption.childNodes.length > 0 ? caption : void 0, className: "ai-key-row" });
|
|
1870
|
+
}
|
|
1871
|
+
async function clearKey(id) {
|
|
1872
|
+
const engine = getEngine(id);
|
|
1873
|
+
if (!engine) {
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
keyText.delete(id);
|
|
1877
|
+
keyStored.delete(id);
|
|
1878
|
+
const error = await deps.setKey(id, null);
|
|
1879
|
+
keyResults.set(id, error ? { level: "error", text: error } : { level: "info", text: engine.apiKeyEnv ? `Key cleared. ${engine.apiKeyEnv} is used if it is set.` : engine.apiKeyFile ? "Key cleared. The key file is used if it has a key." : "Key cleared." });
|
|
1880
|
+
deps.refresh(id, { form: true, head: true });
|
|
1881
|
+
deps.focusKey(`eng:${id}:key`);
|
|
1882
|
+
}
|
|
1883
|
+
function buildUrl(engine, preset, where) {
|
|
1884
|
+
const id = engine.id;
|
|
1885
|
+
const input = el("input", "settings-input ai-sheet-input ai-mono");
|
|
1886
|
+
input.type = "url";
|
|
1887
|
+
input.id = nextId("ai-url");
|
|
1888
|
+
input.spellcheck = false;
|
|
1889
|
+
input.autocomplete = "off";
|
|
1890
|
+
input.placeholder = "http://127.0.0.1:8000/v1";
|
|
1891
|
+
input.value = engine.baseUrl;
|
|
1892
|
+
input.dataset.fk = `eng:${id}:url`;
|
|
1893
|
+
const error = el("div", "ai-field-error");
|
|
1894
|
+
error.id = nextId("ai-url-error");
|
|
1895
|
+
error.setAttribute("aria-live", "polite");
|
|
1896
|
+
input.setAttribute("aria-describedby", error.id);
|
|
1897
|
+
const show = () => {
|
|
1898
|
+
const problem = baseUrlProblem(input.value);
|
|
1899
|
+
error.textContent = problem;
|
|
1900
|
+
input.setAttribute("aria-invalid", String(!!problem));
|
|
1901
|
+
};
|
|
1902
|
+
input.addEventListener("input", () => {
|
|
1903
|
+
updateEngine(id, { baseUrl: input.value.trim() }, { head: true });
|
|
1904
|
+
if (error.textContent) {
|
|
1905
|
+
show();
|
|
1906
|
+
}
|
|
1907
|
+
});
|
|
1908
|
+
input.addEventListener("change", () => {
|
|
1909
|
+
show();
|
|
1910
|
+
deps.refresh(id, { routing: true });
|
|
1911
|
+
if (preset.family === "local") {
|
|
1912
|
+
trigger(id, "url");
|
|
1913
|
+
}
|
|
1914
|
+
});
|
|
1915
|
+
if (engine.baseUrl) {
|
|
1916
|
+
show();
|
|
1917
|
+
}
|
|
1918
|
+
const body = el("div", "ai-url");
|
|
1919
|
+
body.append(input, error);
|
|
1920
|
+
const caption = where === "express" ? "The one thing that changes per machine. Usually ends in /v1." : "The server address. Cloud presets fill it in.";
|
|
1921
|
+
return sheetRow("Base URL", body, { forId: input.id, caption });
|
|
1922
|
+
}
|
|
1923
|
+
function sizeTagFor(engineId, preset) {
|
|
1924
|
+
if (preset.family !== "local" || !deps.modelSize) {
|
|
1925
|
+
return null;
|
|
1926
|
+
}
|
|
1927
|
+
const bytes = new Map((deps.statuses.get(engineId)?.models ?? []).map((m) => [m.id, m.sizeBytes]));
|
|
1928
|
+
return (modelId) => deps.modelSize?.(modelId, bytes.get(modelId));
|
|
1929
|
+
}
|
|
1930
|
+
function localPickNote(engine, preset, setup) {
|
|
1931
|
+
const chat = engine.models.filter((m) => !m.missing && m.capabilities.chat);
|
|
1932
|
+
if (chat.length === 0) {
|
|
1933
|
+
return null;
|
|
1934
|
+
}
|
|
1935
|
+
const chosenId = engineDefaultModelId(deps.getDraft(), engine.id);
|
|
1936
|
+
const picked = setup ? chat.some((m) => m.id === chosenId && m.enabled) : chat.some((m) => m.enabled);
|
|
1937
|
+
if (picked) {
|
|
1938
|
+
pickRequired.delete(engine.id);
|
|
1939
|
+
return null;
|
|
1940
|
+
}
|
|
1941
|
+
if (pickRequired.has(engine.id)) {
|
|
1942
|
+
const line2 = note("error", `Pick at least one model before you choose Done. ${engineDisplayName(engine)} needs a default model to answer.`, "ai-pick-note");
|
|
1943
|
+
line2.setAttribute("role", "alert");
|
|
1944
|
+
return line2;
|
|
1945
|
+
}
|
|
1946
|
+
if (preset.family !== "local") {
|
|
1947
|
+
return null;
|
|
1948
|
+
}
|
|
1949
|
+
const sizeOf = sizeTagFor(engine.id, preset);
|
|
1950
|
+
const facts = new Map((deps.statuses.get(engine.id)?.models ?? []).map((m) => [m.id, m]));
|
|
1951
|
+
const tagged = chat.some((m) => sizeOf && sizeOf(m.id) !== void 0 || !!facts.get(m.id)?.sizeBytes || !!modelMoeFastHint(m.id, facts.get(m.id)?.moe, sizeOf?.(m.id)));
|
|
1952
|
+
const text = `Pick a model to start with. No chat model is loaded in ${engineDisplayName(engine)}, so none was chosen for you${tagged ? "; the tags give size and speed hints" : ""}.`;
|
|
1953
|
+
const line = note("info", text, "ai-pick-note");
|
|
1954
|
+
line.setAttribute("role", "status");
|
|
1955
|
+
return line;
|
|
1956
|
+
}
|
|
1957
|
+
function buildModelsPicker(engine, preset, setup = false) {
|
|
1958
|
+
const id = engine.id;
|
|
1959
|
+
const st = fetchState(id);
|
|
1960
|
+
const keyless = preset.family === "cloud" && !deps.hasKey(id) && !(keyText.get(id) ?? "").trim();
|
|
1961
|
+
if (setup && keyless && engine.models.length === 0 && !st.running && !st.problem) {
|
|
1962
|
+
return null;
|
|
1963
|
+
}
|
|
1964
|
+
const box = el("div", "ai-picker");
|
|
1965
|
+
const labelId = nextId("ai-models-label");
|
|
1966
|
+
const listId = nextId("ai-models-list");
|
|
1967
|
+
const head = el("div", "ai-picker-head");
|
|
1968
|
+
const label = el("span", "ai-picker-label", setup ? "Model" : "Models");
|
|
1969
|
+
label.id = labelId;
|
|
1970
|
+
if (st.running) {
|
|
1971
|
+
const spin = el("span", "ai-spinner");
|
|
1972
|
+
spin.setAttribute("aria-hidden", "true");
|
|
1973
|
+
label.append(spin);
|
|
1974
|
+
box.setAttribute("aria-busy", "true");
|
|
1975
|
+
}
|
|
1976
|
+
const countLine = (e) => st.running ? "Fetching\u2026" : setup ? "" : modelCountLine(e);
|
|
1977
|
+
const count = el("span", "ai-models-count", countLine(engine));
|
|
1978
|
+
head.append(label, count);
|
|
1979
|
+
if (ai.discoverModels && !(setup && keyless)) {
|
|
1980
|
+
const fetchBtn = button("ai-link-btn", engine.models.length > 0 ? "Refresh models" : "Fetch models", { html: ICONS.refresh, fk: `eng:${id}:fetch` });
|
|
1981
|
+
const urlBad = !isHostEngineKind(engine.kind) && !!baseUrlProblem(engine.baseUrl);
|
|
1982
|
+
if (st.running) {
|
|
1983
|
+
fetchBtn.setAttribute("aria-disabled", "true");
|
|
1984
|
+
}
|
|
1985
|
+
fetchBtn.disabled = engine.discovery === "manual" || urlBad;
|
|
1986
|
+
if (engine.discovery === "manual") {
|
|
1987
|
+
fetchBtn.title = "The model list is manual for this engine (see Advanced).";
|
|
1988
|
+
}
|
|
1989
|
+
fetchBtn.addEventListener("click", () => {
|
|
1990
|
+
if (!st.running) {
|
|
1991
|
+
trigger(id, "manual");
|
|
1992
|
+
}
|
|
1993
|
+
});
|
|
1994
|
+
head.append(fetchBtn);
|
|
1995
|
+
}
|
|
1996
|
+
box.append(head);
|
|
1997
|
+
const pickNote = localPickNote(engine, preset, setup);
|
|
1998
|
+
if (pickNote && !st.running && !st.problem) {
|
|
1999
|
+
box.append(pickNote);
|
|
2000
|
+
}
|
|
2001
|
+
const listed = engine.models.filter((m) => !m.missing).length;
|
|
2002
|
+
const popularIds = preset.family === "cloud" ? popularModels(preset, engine.models).map((m) => m.id) : [];
|
|
2003
|
+
const chosen = () => setup ? engineDefaultModelId(deps.getDraft(), id) : null;
|
|
2004
|
+
const chosenAtBuild = chosen();
|
|
2005
|
+
const collapsed = setup && popularIds.length > 0 && !showAll.has(id) && (!chosenAtBuild || popularIds.includes(chosenAtBuild));
|
|
2006
|
+
const showInput = setup ? !collapsed && (engine.models.length > 0 ? engine.models.length > FILTER_THRESHOLD || typeOpen.has(id) || !!pickerFilter.get(id) : !keyless && !st.running) : engine.models.length > FILTER_THRESHOLD || engine.models.length === 0 || typeOpen.has(id) || !!pickerFilter.get(id);
|
|
2007
|
+
const sizeOf = sizeTagFor(id, preset);
|
|
2008
|
+
let input = null;
|
|
2009
|
+
if (showInput) {
|
|
2010
|
+
const field = el("div", "ai-combo");
|
|
2011
|
+
const icon = el("span", "ai-combo-icon");
|
|
2012
|
+
icon.innerHTML = ICONS.search;
|
|
2013
|
+
input = el("input", "settings-input ai-combo-input");
|
|
2014
|
+
input.type = "text";
|
|
2015
|
+
input.spellcheck = false;
|
|
2016
|
+
input.autocomplete = "off";
|
|
2017
|
+
input.dataset.fk = `eng:${id}:pick`;
|
|
2018
|
+
input.setAttribute("role", "combobox");
|
|
2019
|
+
input.setAttribute("aria-expanded", "true");
|
|
2020
|
+
input.setAttribute("aria-controls", listId);
|
|
2021
|
+
input.setAttribute("aria-autocomplete", "list");
|
|
2022
|
+
input.setAttribute("aria-label", engine.models.length > 0 ? `Filter models of ${engineDisplayName(engine)}, or type a model id to add it` : `Type a model id for ${engineDisplayName(engine)}`);
|
|
2023
|
+
input.placeholder = engine.models.length > FILTER_THRESHOLD ? `Search ${listed} models, or type an id` : "Type a model id, then Enter";
|
|
2024
|
+
input.value = pickerFilter.get(id) ?? "";
|
|
2025
|
+
field.append(icon, input);
|
|
2026
|
+
box.append(field);
|
|
2027
|
+
}
|
|
2028
|
+
const list = el("ul", "ai-listbox");
|
|
2029
|
+
list.id = listId;
|
|
2030
|
+
list.setAttribute("role", "listbox");
|
|
2031
|
+
list.setAttribute("aria-multiselectable", String(!setup));
|
|
2032
|
+
if (setup) {
|
|
2033
|
+
list.classList.add("ai-listbox--single");
|
|
2034
|
+
}
|
|
2035
|
+
list.setAttribute("aria-labelledby", labelId);
|
|
2036
|
+
if (!input) {
|
|
2037
|
+
list.tabIndex = 0;
|
|
2038
|
+
list.dataset.fk = `eng:${id}:models`;
|
|
2039
|
+
}
|
|
2040
|
+
box.append(list);
|
|
2041
|
+
let active = -1;
|
|
2042
|
+
let options = [];
|
|
2043
|
+
const optionId = (i) => `${listId}-o${i}`;
|
|
2044
|
+
const focusTarget = () => input ?? list;
|
|
2045
|
+
function setActive(i) {
|
|
2046
|
+
active = i >= 0 && i < options.length ? i : -1;
|
|
2047
|
+
list.querySelectorAll(".ai-option.is-active").forEach((n) => n.classList.remove("is-active"));
|
|
2048
|
+
const target = focusTarget();
|
|
2049
|
+
if (active < 0) {
|
|
2050
|
+
target.removeAttribute("aria-activedescendant");
|
|
2051
|
+
return;
|
|
2052
|
+
}
|
|
2053
|
+
const node = Array.from(list.querySelectorAll(".ai-option")).find((n) => n.id === optionId(active));
|
|
2054
|
+
node?.classList.add("is-active");
|
|
2055
|
+
node?.scrollIntoView?.({ block: "nearest" });
|
|
2056
|
+
target.setAttribute("aria-activedescendant", optionId(active));
|
|
2057
|
+
}
|
|
2058
|
+
function renderList() {
|
|
2059
|
+
const filter = collapsed ? "" : pickerFilter.get(id) ?? "";
|
|
2060
|
+
const current = getEngine(id) ?? engine;
|
|
2061
|
+
const all = modelPickerOptions(current, deps.statuses.get(id), filter);
|
|
2062
|
+
const popular = all.filter((o) => popularIds.includes(o.id)).sort((a, b) => popularIds.indexOf(a.id) - popularIds.indexOf(b.id));
|
|
2063
|
+
const rest = collapsed ? [] : all.filter((o) => !popularIds.includes(o.id));
|
|
2064
|
+
const sizes = sizeOf ? new Map(rest.map((o) => [o.id, sizeOf(o.id)])) : null;
|
|
2065
|
+
const sized = !!sizes && [...sizes.values()].some((t) => t !== void 0);
|
|
2066
|
+
const bySize = (tag) => rest.filter((o) => sizes?.get(o.id) === tag);
|
|
2067
|
+
options = sized ? [...SIZE_GROUPS.flatMap((g) => bySize(g.tag))] : [...popular, ...rest];
|
|
2068
|
+
list.replaceChildren();
|
|
2069
|
+
let index = 0;
|
|
2070
|
+
const group = (title, items) => {
|
|
2071
|
+
if (items.length === 0) {
|
|
2072
|
+
return;
|
|
2073
|
+
}
|
|
2074
|
+
const g = el("li", "ai-listbox-group");
|
|
2075
|
+
g.setAttribute("role", "presentation");
|
|
2076
|
+
const heading = el("div", "ai-listbox-heading", title);
|
|
2077
|
+
heading.id = nextId("ai-group");
|
|
2078
|
+
const ul = el("ul", "ai-listbox-items");
|
|
2079
|
+
ul.setAttribute("role", "group");
|
|
2080
|
+
ul.setAttribute("aria-labelledby", heading.id);
|
|
2081
|
+
for (const o of items) {
|
|
2082
|
+
ul.append(optionNode(o, index++));
|
|
2083
|
+
}
|
|
2084
|
+
g.append(heading, ul);
|
|
2085
|
+
list.append(g);
|
|
2086
|
+
};
|
|
2087
|
+
if (sized) {
|
|
2088
|
+
for (const g of SIZE_GROUPS) {
|
|
2089
|
+
group(g.title, bySize(g.tag));
|
|
2090
|
+
}
|
|
2091
|
+
} else if (popular.length > 0) {
|
|
2092
|
+
group("Popular", popular);
|
|
2093
|
+
group("All models", rest);
|
|
2094
|
+
} else {
|
|
2095
|
+
for (const o of rest) {
|
|
2096
|
+
list.append(optionNode(o, index++));
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
if (options.length === 0) {
|
|
2100
|
+
const empty = el("li", "ai-listbox-empty");
|
|
2101
|
+
empty.setAttribute("role", "presentation");
|
|
2102
|
+
empty.textContent = filter.trim() ? `No model matches \u201C${filter.trim()}\u201D. Press Enter to add it by id.` : st.running ? "Fetching models\u2026" : keyless ? "Paste your key and the models appear here." : setup ? "No models yet. Fetch them from the server." : "No models yet. Fetch them from the server, or type an id.";
|
|
2103
|
+
list.append(empty);
|
|
2104
|
+
}
|
|
2105
|
+
setActive(Math.min(active, options.length - 1));
|
|
2106
|
+
}
|
|
2107
|
+
function optionNode(o, i) {
|
|
2108
|
+
const li = el("li", "ai-option");
|
|
2109
|
+
li.id = optionId(i);
|
|
2110
|
+
li.setAttribute("role", "option");
|
|
2111
|
+
const on = setup ? o.id === chosen() : o.enabled;
|
|
2112
|
+
li.setAttribute("aria-selected", String(on));
|
|
2113
|
+
li.dataset.modelId = o.id;
|
|
2114
|
+
li.classList.toggle("is-missing", o.missing);
|
|
2115
|
+
li.classList.toggle("is-on", on);
|
|
2116
|
+
const unusable = !(setup ? o.chat : o.usable);
|
|
2117
|
+
if (unusable) {
|
|
2118
|
+
li.classList.add("is-unusable");
|
|
2119
|
+
li.setAttribute("aria-disabled", "true");
|
|
2120
|
+
}
|
|
2121
|
+
const check = el("span", "ai-option-check");
|
|
2122
|
+
check.setAttribute("aria-hidden", "true");
|
|
2123
|
+
check.innerHTML = ICONS.check;
|
|
2124
|
+
const names = el("span", "ai-option-names");
|
|
2125
|
+
names.append(el("span", "ai-option-name", o.name));
|
|
2126
|
+
if (o.hasLabel) {
|
|
2127
|
+
names.append(el("span", "ai-option-id", o.id));
|
|
2128
|
+
}
|
|
2129
|
+
li.append(check, names);
|
|
2130
|
+
const tags = el("span", "ai-option-tags");
|
|
2131
|
+
if (o.loaded) {
|
|
2132
|
+
const t = el("span", "ai-tag ai-tag--loaded", "loaded");
|
|
2133
|
+
t.title = "Loaded in memory on the server right now.";
|
|
2134
|
+
tags.append(t);
|
|
2135
|
+
}
|
|
2136
|
+
if (o.missing) {
|
|
2137
|
+
const t = el("span", "ai-tag ai-tag--missing", "not listed");
|
|
2138
|
+
t.title = "The last fetch did not list this model. It is kept, but never routed.";
|
|
2139
|
+
tags.append(t);
|
|
2140
|
+
}
|
|
2141
|
+
if (o.manual) {
|
|
2142
|
+
tags.append(el("span", "ai-tag", "typed in"));
|
|
2143
|
+
}
|
|
2144
|
+
if (unusable) {
|
|
2145
|
+
const t = el("span", "ai-tag ai-tag--unusable", o.usable ? "not for chat" : "not a chat model");
|
|
2146
|
+
t.title = o.usable ? "An embeddings model: it cannot be the model this engine answers with." : "This model cannot answer (a converter or a draft model for another model), so it cannot be picked.";
|
|
2147
|
+
tags.append(t);
|
|
2148
|
+
}
|
|
2149
|
+
const facts = preset.family === "local" ? deps.statuses.get(id)?.models.find((m) => m.id === o.id) : void 0;
|
|
2150
|
+
const size = sizeOf?.(o.id);
|
|
2151
|
+
const moe = preset.family === "local" ? modelMoeFastHint(o.id, facts?.moe, size) : null;
|
|
2152
|
+
if (moe) {
|
|
2153
|
+
const t = el("span", "ai-tag ai-tag--moe", "MoE \xB7 fast");
|
|
2154
|
+
t.title = moe.activeBillions !== null ? `A mixture of experts: about ${moe.activeBillions}B parameters work per token, so it runs much faster than its size suggests.` : "A mixture of experts: only part of it works per token, so it runs much faster than its size suggests.";
|
|
2155
|
+
tags.append(t);
|
|
2156
|
+
}
|
|
2157
|
+
if (facts?.sizeBytes) {
|
|
2158
|
+
const t = el("span", "ai-tag ai-tag--bytes", formatModelSize(facts.sizeBytes));
|
|
2159
|
+
t.title = "Its size, as the server reports it.";
|
|
2160
|
+
tags.append(t);
|
|
2161
|
+
}
|
|
2162
|
+
if (size) {
|
|
2163
|
+
const t = el("span", `ai-tag ai-tag--size ai-tag--${size.replace(" ", "-")}`, size);
|
|
2164
|
+
t.title = SIZE_TITLES[size];
|
|
2165
|
+
tags.append(t);
|
|
2166
|
+
}
|
|
2167
|
+
if (tags.childElementCount > 0) {
|
|
2168
|
+
li.append(tags);
|
|
2169
|
+
}
|
|
2170
|
+
li.addEventListener("mousedown", (e) => e.preventDefault());
|
|
2171
|
+
li.addEventListener("click", () => {
|
|
2172
|
+
active = i;
|
|
2173
|
+
pick(o.id);
|
|
2174
|
+
});
|
|
2175
|
+
return li;
|
|
2176
|
+
}
|
|
2177
|
+
function pick(modelId) {
|
|
2178
|
+
const o = options.find((x) => x.id === modelId);
|
|
2179
|
+
if (o && !(setup ? o.chat : o.usable)) {
|
|
2180
|
+
deps.announce(`${o.name} is not a chat model, so it cannot be picked.`);
|
|
2181
|
+
return;
|
|
2182
|
+
}
|
|
2183
|
+
if (setup) {
|
|
2184
|
+
choose(modelId);
|
|
2185
|
+
} else {
|
|
2186
|
+
toggle(modelId);
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
function choose(modelId) {
|
|
2190
|
+
const model = getEngine(id)?.models.find((m) => m.id === modelId);
|
|
2191
|
+
if (!model || chosen() === modelId) {
|
|
2192
|
+
return;
|
|
2193
|
+
}
|
|
2194
|
+
deps.setDraft(chooseSetupModel(deps.getDraft(), id, modelId, formPicked.get(id)), { head: true, list: true, routing: true });
|
|
2195
|
+
formPicked.set(id, modelId);
|
|
2196
|
+
deps.announce(`${modelDisplayName(model)} chosen.`);
|
|
2197
|
+
refreshAfterPick();
|
|
2198
|
+
}
|
|
2199
|
+
function toggle(modelId) {
|
|
2200
|
+
const current = getEngine(id);
|
|
2201
|
+
const model = current?.models.find((m) => m.id === modelId);
|
|
2202
|
+
if (!current || !model) {
|
|
2203
|
+
return;
|
|
2204
|
+
}
|
|
2205
|
+
const enabled = !model.enabled;
|
|
2206
|
+
deps.setDraft(setModelPicked(deps.getDraft(), id, modelId, enabled), { head: true, routing: true });
|
|
2207
|
+
deps.announce(`${modelDisplayName(model)} ${enabled ? "picked" : "removed"}.`);
|
|
2208
|
+
refreshAfterPick();
|
|
2209
|
+
}
|
|
2210
|
+
function refreshAfterPick() {
|
|
2211
|
+
const scroll = list.scrollTop;
|
|
2212
|
+
const current = getEngine(id) ?? engine;
|
|
2213
|
+
count.textContent = countLine(current);
|
|
2214
|
+
renderList();
|
|
2215
|
+
list.scrollTop = scroll;
|
|
2216
|
+
const pickNote2 = localPickNote(current, preset, setup);
|
|
2217
|
+
const shown = box.querySelector(":scope > .ai-pick-note");
|
|
2218
|
+
if (shown && !pickNote2) {
|
|
2219
|
+
shown.remove();
|
|
2220
|
+
} else if (!shown && pickNote2) {
|
|
2221
|
+
head.after(pickNote2);
|
|
2222
|
+
}
|
|
2223
|
+
const form = box.closest(".ai-form");
|
|
2224
|
+
const def = form?.querySelector(".ai-default-row");
|
|
2225
|
+
const fresh = buildDefaultModel(current);
|
|
2226
|
+
if (def && fresh) {
|
|
2227
|
+
keepFocus(def, () => {
|
|
2228
|
+
def.replaceWith(fresh);
|
|
2229
|
+
return fresh;
|
|
2230
|
+
});
|
|
2231
|
+
}
|
|
2232
|
+
focusTarget().focus({ preventScroll: true });
|
|
2233
|
+
}
|
|
2234
|
+
function addTyped(text) {
|
|
2235
|
+
const current = getEngine(id);
|
|
2236
|
+
if (!current) {
|
|
2237
|
+
return;
|
|
2238
|
+
}
|
|
2239
|
+
const exact = current.models.find((m) => m.id === text);
|
|
2240
|
+
if (exact) {
|
|
2241
|
+
if (setup) {
|
|
2242
|
+
choose(exact.id);
|
|
2243
|
+
} else if (!exact.enabled) {
|
|
2244
|
+
toggle(exact.id);
|
|
2245
|
+
}
|
|
2246
|
+
return;
|
|
2247
|
+
}
|
|
2248
|
+
const r = addManualModel(current, text);
|
|
2249
|
+
if (!r.ok) {
|
|
2250
|
+
deps.announce(r.error);
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
let next = withEngine(deps.getDraft(), r.engine);
|
|
2254
|
+
if (!next.routing.default && r.model.capabilities.chat && r.engine.enabled) {
|
|
2255
|
+
next = setEngineDefaultModel(next, id, r.model.id);
|
|
2256
|
+
}
|
|
2257
|
+
if (setup) {
|
|
2258
|
+
next = chooseSetupModel(next, id, r.model.id, formPicked.get(id));
|
|
2259
|
+
formPicked.set(id, r.model.id);
|
|
2260
|
+
}
|
|
2261
|
+
deps.setDraft(next, { head: true, list: !deps.getDraft().routing.default && !!next.routing.default, routing: true });
|
|
2262
|
+
pickerFilter.delete(id);
|
|
2263
|
+
typeOpen.add(id);
|
|
2264
|
+
deps.announce(`Added ${r.model.id}.`);
|
|
2265
|
+
deps.refresh(id, { form: true });
|
|
2266
|
+
deps.focusKey(`eng:${id}:pick`);
|
|
2267
|
+
}
|
|
2268
|
+
const onKey = (e) => {
|
|
2269
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
2270
|
+
e.preventDefault();
|
|
2271
|
+
if (options.length === 0) {
|
|
2272
|
+
return;
|
|
2273
|
+
}
|
|
2274
|
+
const step = e.key === "ArrowDown" ? 1 : -1;
|
|
2275
|
+
setActive(active < 0 ? step > 0 ? 0 : options.length - 1 : (active + step + options.length) % options.length);
|
|
2276
|
+
} else if (e.key === "Home" && !input) {
|
|
2277
|
+
e.preventDefault();
|
|
2278
|
+
setActive(0);
|
|
2279
|
+
} else if (e.key === "End" && !input) {
|
|
2280
|
+
e.preventDefault();
|
|
2281
|
+
setActive(options.length - 1);
|
|
2282
|
+
} else if (e.key === "Enter" || e.key === " " && !input) {
|
|
2283
|
+
e.preventDefault();
|
|
2284
|
+
const typed = input?.value.trim() ?? "";
|
|
2285
|
+
if (active >= 0 && options[active]) {
|
|
2286
|
+
pick(options[active].id);
|
|
2287
|
+
} else if (typed) {
|
|
2288
|
+
addTyped(typed);
|
|
2289
|
+
}
|
|
2290
|
+
} else if (e.key === "Escape") {
|
|
2291
|
+
if (input?.value) {
|
|
2292
|
+
e.preventDefault();
|
|
2293
|
+
e.stopPropagation();
|
|
2294
|
+
input.value = "";
|
|
2295
|
+
pickerFilter.delete(id);
|
|
2296
|
+
active = -1;
|
|
2297
|
+
renderList();
|
|
2298
|
+
} else if (active >= 0) {
|
|
2299
|
+
e.preventDefault();
|
|
2300
|
+
e.stopPropagation();
|
|
2301
|
+
setActive(-1);
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
};
|
|
2305
|
+
if (input) {
|
|
2306
|
+
const field = input;
|
|
2307
|
+
field.addEventListener("keydown", onKey);
|
|
2308
|
+
field.addEventListener("input", () => {
|
|
2309
|
+
pickerFilter.set(id, field.value);
|
|
2310
|
+
active = field.value.trim() ? 0 : -1;
|
|
2311
|
+
renderList();
|
|
2312
|
+
});
|
|
2313
|
+
} else {
|
|
2314
|
+
list.addEventListener("keydown", onKey);
|
|
2315
|
+
list.addEventListener("focus", () => {
|
|
2316
|
+
if (active < 0 && options.length > 0) {
|
|
2317
|
+
setActive(0);
|
|
2318
|
+
}
|
|
2319
|
+
});
|
|
2320
|
+
}
|
|
2321
|
+
renderList();
|
|
2322
|
+
if (setup && popularIds.length > 0 && (collapsed || showAll.has(id))) {
|
|
2323
|
+
const more = button("ai-link-btn ai-show-all", collapsed ? `Show all models (${listed})` : "Show popular only", { fk: `eng:${id}:show-all` });
|
|
2324
|
+
more.setAttribute("aria-expanded", String(!collapsed));
|
|
2325
|
+
more.setAttribute("aria-controls", listId);
|
|
2326
|
+
more.addEventListener("click", () => {
|
|
2327
|
+
if (collapsed) {
|
|
2328
|
+
showAll.add(id);
|
|
2329
|
+
} else {
|
|
2330
|
+
showAll.delete(id);
|
|
2331
|
+
pickerFilter.delete(id);
|
|
2332
|
+
}
|
|
2333
|
+
deps.refresh(id, { form: true });
|
|
2334
|
+
deps.focusKey(`eng:${id}:show-all`);
|
|
2335
|
+
});
|
|
2336
|
+
box.append(more);
|
|
2337
|
+
}
|
|
2338
|
+
if (!input && (!setup || !collapsed && engine.models.length > 0)) {
|
|
2339
|
+
const typeBtn = button("ai-link-btn ai-type-btn", "Type a model id\u2026", { fk: `eng:${id}:type-open` });
|
|
2340
|
+
typeBtn.addEventListener("click", () => {
|
|
2341
|
+
typeOpen.add(id);
|
|
2342
|
+
deps.refresh(id, { form: true });
|
|
2343
|
+
deps.focusKey(`eng:${id}:pick`);
|
|
2344
|
+
});
|
|
2345
|
+
box.append(typeBtn);
|
|
2346
|
+
}
|
|
2347
|
+
const problem = st.problem;
|
|
2348
|
+
if (problem && !st.running) {
|
|
2349
|
+
const line = el("div", `ai-fetch-note ai-fetch-note--${problem.kind}`);
|
|
2350
|
+
line.setAttribute("role", "status");
|
|
2351
|
+
line.append(note(problem.kind === "empty" ? "info" : "error", problem.text));
|
|
2352
|
+
if (problem.retry && ai.discoverModels) {
|
|
2353
|
+
const retry = button("ai-link-btn", "Retry", { fk: `eng:${id}:retry` });
|
|
2354
|
+
retry.addEventListener("click", () => {
|
|
2355
|
+
void runFetch(id, st.lastEvent ?? "retry");
|
|
2356
|
+
});
|
|
2357
|
+
line.append(retry);
|
|
2358
|
+
}
|
|
2359
|
+
box.append(line);
|
|
2360
|
+
}
|
|
2361
|
+
const row = el("div", "ai-sheet-row ai-sheet-row--block");
|
|
2362
|
+
row.append(box);
|
|
2363
|
+
return row;
|
|
2364
|
+
}
|
|
2365
|
+
function buildDefaultModel(engine) {
|
|
2366
|
+
const id = engine.id;
|
|
2367
|
+
const draft = deps.getDraft();
|
|
2368
|
+
const enabled = engine.models.filter((m) => m.enabled && !m.missing && m.capabilities.chat);
|
|
2369
|
+
const stored = engineDefaultModelId(draft, id);
|
|
2370
|
+
const current = stored && enabled.some((m) => m.id === stored) ? stored : null;
|
|
2371
|
+
const line = el("div", "ai-default");
|
|
2372
|
+
const select = el("select", "settings-input ai-default-select");
|
|
2373
|
+
select.id = nextId("ai-default");
|
|
2374
|
+
select.dataset.fk = `eng:${id}:default-model`;
|
|
2375
|
+
if (enabled.length === 0) {
|
|
2376
|
+
const o = el("option", "", "Pick a model above");
|
|
2377
|
+
o.value = "";
|
|
2378
|
+
select.append(o);
|
|
2379
|
+
select.disabled = true;
|
|
2380
|
+
} else {
|
|
2381
|
+
for (const m of enabled) {
|
|
2382
|
+
const o = el("option", "", modelDisplayName(m));
|
|
2383
|
+
o.value = m.id;
|
|
2384
|
+
select.append(o);
|
|
2385
|
+
}
|
|
2386
|
+
select.value = current ?? enabled[0].id;
|
|
2387
|
+
}
|
|
2388
|
+
select.addEventListener("change", () => {
|
|
2389
|
+
deps.setDraft(setEngineDefaultModel(deps.getDraft(), id, select.value), { head: true, list: true, routing: true });
|
|
2390
|
+
const model2 = getEngine(id)?.models.find((m) => m.id === select.value);
|
|
2391
|
+
if (model2) {
|
|
2392
|
+
deps.announce(`Default model: ${modelDisplayName(model2)}.`);
|
|
2393
|
+
}
|
|
2394
|
+
});
|
|
2395
|
+
line.append(select);
|
|
2396
|
+
const model = engine.models.find((m) => m.id === (current ?? enabled[0]?.id));
|
|
2397
|
+
const thinking = model ? buildThinkingSelect(engine, model, "express") : null;
|
|
2398
|
+
if (thinking) {
|
|
2399
|
+
const wrap = el("label", "ai-thinking");
|
|
2400
|
+
wrap.append(el("span", "ai-thinking-label", "Thinking"), thinking);
|
|
2401
|
+
line.append(wrap);
|
|
2402
|
+
}
|
|
2403
|
+
const isDefaultEngine = draft.routing.default?.engineId === id;
|
|
2404
|
+
const caption = enabled.length === 0 ? "Pick at least one model to use this engine." : isDefaultEngine ? "Serves every task without its own route." : draft.routing.default ? "Used when this engine is picked for a task, or made the default (the star)." : "Becomes the default route once you pick it.";
|
|
2405
|
+
return sheetRow("Default model", line, { forId: select.id, caption, className: "ai-default-row" });
|
|
2406
|
+
}
|
|
2407
|
+
function buildThinkingSelect(engine, model, where) {
|
|
2408
|
+
const choices = thinkingChoices(engine.kind);
|
|
2409
|
+
if (choices.length === 0) {
|
|
2410
|
+
return null;
|
|
2411
|
+
}
|
|
2412
|
+
const id = engine.id;
|
|
2413
|
+
const select = el("select", `settings-input ${where === "table" ? "settings-input--small " : ""}ai-thinking-select`);
|
|
2414
|
+
select.dataset.fk = where === "express" ? `eng:${id}:thinking` : `model:${id}:${model.id}:thinking`;
|
|
2415
|
+
select.setAttribute("aria-label", `Thinking for ${modelDisplayName(model)}`);
|
|
2416
|
+
select.title = "How hard the model thinks before it answers. Default sends nothing (the model decides).";
|
|
2417
|
+
const dflt = el("option", "", "Default");
|
|
2418
|
+
dflt.value = "";
|
|
2419
|
+
select.append(dflt);
|
|
2420
|
+
for (const c of choices) {
|
|
2421
|
+
const o = el("option", "", c.label);
|
|
2422
|
+
o.value = c.level;
|
|
2423
|
+
select.append(o);
|
|
2424
|
+
}
|
|
2425
|
+
select.value = thinkingLevelFrom(engine.kind, model.requestOverrides) ?? "";
|
|
2426
|
+
select.addEventListener("change", () => {
|
|
2427
|
+
const level = select.value || null;
|
|
2428
|
+
const current = getEngine(id);
|
|
2429
|
+
if (!current) {
|
|
2430
|
+
return;
|
|
2431
|
+
}
|
|
2432
|
+
updateModel(id, model.id, (m) => setModelThinking(m, current.kind, level, isLocal(current)), {});
|
|
2433
|
+
if (advancedOpen.has(id)) {
|
|
2434
|
+
deps.refresh(id, { form: true });
|
|
2435
|
+
}
|
|
2436
|
+
});
|
|
2437
|
+
return select;
|
|
2438
|
+
}
|
|
2439
|
+
function buildEnabled(engine) {
|
|
2440
|
+
const id = engine.id;
|
|
2441
|
+
const toggle = switchInput(engine.enabled, `Enable ${engineDisplayName(engine)}`, `eng:${id}:enabled`);
|
|
2442
|
+
toggle.id = nextId("ai-enabled");
|
|
2443
|
+
toggle.addEventListener("change", () => updateEngine(id, { enabled: toggle.checked }, { list: true, routing: true }));
|
|
2444
|
+
const line = el("div", "ai-enabled-line");
|
|
2445
|
+
line.append(toggle, el("span", "ai-caption", "Engines that are off are never routed."));
|
|
2446
|
+
return sheetRow("Enabled", line, { forId: toggle.id });
|
|
2447
|
+
}
|
|
2448
|
+
function buildActions(engine) {
|
|
2449
|
+
const id = engine.id;
|
|
2450
|
+
const row = el("div", "ai-form-actions");
|
|
2451
|
+
if (ai.testEngine) {
|
|
2452
|
+
const busy = testing.has(id);
|
|
2453
|
+
const test = button("settings-btn settings-btn--secondary", busy ? "Testing\u2026" : "Test connection", { fk: `eng:${id}:test` });
|
|
2454
|
+
if (busy) {
|
|
2455
|
+
test.setAttribute("aria-disabled", "true");
|
|
2456
|
+
}
|
|
2457
|
+
test.disabled = !isHostEngineKind(engine.kind) && !!baseUrlProblem(engine.baseUrl);
|
|
2458
|
+
test.addEventListener("click", () => {
|
|
2459
|
+
void runTest(id);
|
|
2460
|
+
});
|
|
2461
|
+
row.append(test);
|
|
2462
|
+
if (deps.savesFirst(id)) {
|
|
2463
|
+
row.append(el("span", "ai-caption", "Testing saves this engine first, so the app can reach it."));
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
const result = results.get(id);
|
|
2467
|
+
if (result) {
|
|
2468
|
+
row.append(note(result.level, result.text, "ai-result"));
|
|
2469
|
+
}
|
|
2470
|
+
return row;
|
|
2471
|
+
}
|
|
2472
|
+
function buildAdvanced(engine, preset, fields) {
|
|
2473
|
+
const id = engine.id;
|
|
2474
|
+
const open = advancedOpen.has(id);
|
|
2475
|
+
const panelId = `ai-advanced-${id}`;
|
|
2476
|
+
const toggle = button("ai-disclosure", "Advanced", { html: ICONS.chevron, fk: `eng:${id}:advanced` });
|
|
2477
|
+
toggle.setAttribute("aria-expanded", String(open));
|
|
2478
|
+
toggle.setAttribute("aria-controls", panelId);
|
|
2479
|
+
toggle.addEventListener("click", () => {
|
|
2480
|
+
if (advancedOpen.has(id)) {
|
|
2481
|
+
advancedOpen.delete(id);
|
|
2482
|
+
} else {
|
|
2483
|
+
advancedOpen.add(id);
|
|
2484
|
+
}
|
|
2485
|
+
deps.refresh(id, { form: true });
|
|
2486
|
+
deps.focusKey(`eng:${id}:advanced`);
|
|
2487
|
+
});
|
|
2488
|
+
const panel = el("div", "ai-advanced");
|
|
2489
|
+
panel.id = panelId;
|
|
2490
|
+
panel.hidden = !open;
|
|
2491
|
+
if (!open) {
|
|
2492
|
+
return [toggle, panel];
|
|
2493
|
+
}
|
|
2494
|
+
const rows = [];
|
|
2495
|
+
for (const field of fields) {
|
|
2496
|
+
if (field === "kind") {
|
|
2497
|
+
rows.push(buildKind(engine));
|
|
2498
|
+
}
|
|
2499
|
+
if (field === "baseUrl") {
|
|
2500
|
+
rows.push(buildUrl(engine, preset, "advanced"));
|
|
2501
|
+
}
|
|
2502
|
+
if (field === "locality") {
|
|
2503
|
+
rows.push(buildLocality(engine));
|
|
2504
|
+
}
|
|
2505
|
+
if (field === "key") {
|
|
2506
|
+
const key = buildKey(engine, preset);
|
|
2507
|
+
if (key) {
|
|
2508
|
+
rows.push(key);
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
if (field === "keyEnv") {
|
|
2512
|
+
rows.push(buildKeyEnv(engine));
|
|
2513
|
+
}
|
|
2514
|
+
if (field === "keyFile") {
|
|
2515
|
+
rows.push(buildKeyFile(engine));
|
|
2516
|
+
}
|
|
2517
|
+
if (field === "discovery") {
|
|
2518
|
+
rows.push(buildDiscovery(engine));
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
const sheet = el("div", "ai-sheet ai-sheet--advanced");
|
|
2522
|
+
sheet.append(...rows);
|
|
2523
|
+
panel.append(sheet);
|
|
2524
|
+
if (fields.includes("modelsTable")) {
|
|
2525
|
+
panel.append(buildModelsTable(engine));
|
|
2526
|
+
}
|
|
2527
|
+
return [toggle, panel];
|
|
2528
|
+
}
|
|
2529
|
+
function buildKind(engine) {
|
|
2530
|
+
const id = engine.id;
|
|
2531
|
+
const select = el("select", "settings-input");
|
|
2532
|
+
select.id = nextId("ai-kind");
|
|
2533
|
+
select.dataset.fk = `eng:${id}:kind`;
|
|
2534
|
+
for (const kind of AI_ENGINE_KINDS) {
|
|
2535
|
+
if (isHostEngineKind(kind) && kind !== engine.kind) {
|
|
2536
|
+
continue;
|
|
2537
|
+
}
|
|
2538
|
+
const o = el("option", "", AI_ENGINE_KIND_LABELS[kind]);
|
|
2539
|
+
o.value = kind;
|
|
2540
|
+
select.append(o);
|
|
2541
|
+
}
|
|
2542
|
+
if (!isKnownEngineKind(engine.kind)) {
|
|
2543
|
+
const o = el("option", "", engineKindLabel(engine.kind));
|
|
2544
|
+
o.value = engine.kind;
|
|
2545
|
+
select.prepend(o);
|
|
2546
|
+
}
|
|
2547
|
+
select.value = engine.kind;
|
|
2548
|
+
select.addEventListener("change", () => updateEngine(id, { kind: select.value }, { form: true, head: true, routing: true }));
|
|
2549
|
+
return sheetRow("Kind", select, { forId: select.id, caption: "How MikeDown talks to the server." });
|
|
2550
|
+
}
|
|
2551
|
+
function buildLocality(engine) {
|
|
2552
|
+
const id = engine.id;
|
|
2553
|
+
const select = el("select", "settings-input");
|
|
2554
|
+
select.id = nextId("ai-locality");
|
|
2555
|
+
select.dataset.fk = `eng:${id}:locality`;
|
|
2556
|
+
const autoOption = el("option");
|
|
2557
|
+
autoOption.value = "auto";
|
|
2558
|
+
select.append(autoOption);
|
|
2559
|
+
for (const [value, label] of Object.entries(LOCALITY_LABELS)) {
|
|
2560
|
+
const o = el("option", "", label);
|
|
2561
|
+
o.value = value;
|
|
2562
|
+
select.append(o);
|
|
2563
|
+
}
|
|
2564
|
+
select.value = engine.locality;
|
|
2565
|
+
const info = engineLocalityInfo(engine, deps.getDraft().localNetworks);
|
|
2566
|
+
autoOption.textContent = `Auto (detected: ${info.detected})`;
|
|
2567
|
+
const body = el("div", "ai-locality");
|
|
2568
|
+
body.append(select);
|
|
2569
|
+
if (info.mismatch) {
|
|
2570
|
+
body.append(note("warning", info.locality === "cloud" ? "Pinned to cloud, but the address looks local." : "Pinned to local, but the address looks like a cloud server. Text sent to it leaves this network.", "ai-locality-note"));
|
|
2571
|
+
}
|
|
2572
|
+
select.addEventListener("change", () => updateEngine(id, { locality: select.value }, { form: true, head: true, routing: true }));
|
|
2573
|
+
return sheetRow("Locality", body, { forId: select.id, caption: "Auto decides from the address. Cloud engines need Allow cloud." });
|
|
2574
|
+
}
|
|
2575
|
+
function buildKeyEnv(engine) {
|
|
2576
|
+
const id = engine.id;
|
|
2577
|
+
const input = el("input", "settings-input settings-input--medium ai-mono");
|
|
2578
|
+
input.type = "text";
|
|
2579
|
+
input.id = nextId("ai-env");
|
|
2580
|
+
input.spellcheck = false;
|
|
2581
|
+
input.autocomplete = "off";
|
|
2582
|
+
input.placeholder = "NAME_OF_VARIABLE";
|
|
2583
|
+
input.value = engine.apiKeyEnv ?? "";
|
|
2584
|
+
input.dataset.fk = `eng:${id}:env`;
|
|
2585
|
+
input.addEventListener("input", () => {
|
|
2586
|
+
const v = input.value.trim();
|
|
2587
|
+
const current = getEngine(id);
|
|
2588
|
+
if (!current) {
|
|
2589
|
+
return;
|
|
2590
|
+
}
|
|
2591
|
+
const next = { ...current };
|
|
2592
|
+
if (v) {
|
|
2593
|
+
next.apiKeyEnv = v;
|
|
2594
|
+
} else {
|
|
2595
|
+
delete next.apiKeyEnv;
|
|
2596
|
+
}
|
|
2597
|
+
replaceEngine(next, { head: true });
|
|
2598
|
+
});
|
|
2599
|
+
return sheetRow("Key from variable", input, {
|
|
2600
|
+
forId: input.id,
|
|
2601
|
+
// Desktop opened from the Dock has no shell environment (F10).
|
|
2602
|
+
caption: ai.shellEnv === false ? "This app does not read shell variables when opened from the Dock; use the key file or paste the key." : "Used when no key is stored, for example OMLX_API_KEY. Apps opened from the Dock or Start menu may not see variables set in a shell profile."
|
|
2603
|
+
});
|
|
2604
|
+
}
|
|
2605
|
+
function buildKeyFile(engine) {
|
|
2606
|
+
const id = engine.id;
|
|
2607
|
+
const line = el("div", "ai-key");
|
|
2608
|
+
const path = el("input", "settings-input settings-input--grow ai-mono");
|
|
2609
|
+
const json = el("input", "settings-input settings-input--medium ai-mono");
|
|
2610
|
+
for (const input of [path, json]) {
|
|
2611
|
+
input.type = "text";
|
|
2612
|
+
input.spellcheck = false;
|
|
2613
|
+
input.autocomplete = "off";
|
|
2614
|
+
}
|
|
2615
|
+
path.id = nextId("ai-keyfile");
|
|
2616
|
+
path.placeholder = "~/path/to/settings.json";
|
|
2617
|
+
json.placeholder = "auth.api_key";
|
|
2618
|
+
path.value = engine.apiKeyFile?.path ?? "";
|
|
2619
|
+
json.value = engine.apiKeyFile?.jsonPath ?? "";
|
|
2620
|
+
path.dataset.fk = `eng:${id}:keyfile-path`;
|
|
2621
|
+
json.dataset.fk = `eng:${id}:keyfile-json`;
|
|
2622
|
+
path.setAttribute("aria-label", `Key file path for ${engineDisplayName(engine)}`);
|
|
2623
|
+
json.setAttribute("aria-label", `Key file JSON path for ${engineDisplayName(engine)}`);
|
|
2624
|
+
const save = () => {
|
|
2625
|
+
const current = getEngine(id);
|
|
2626
|
+
if (!current) {
|
|
2627
|
+
return;
|
|
2628
|
+
}
|
|
2629
|
+
const p = path.value.trim();
|
|
2630
|
+
const j = json.value.trim();
|
|
2631
|
+
const next = { ...current };
|
|
2632
|
+
if (p && j) {
|
|
2633
|
+
next.apiKeyFile = { ...current.apiKeyFile, path: p, jsonPath: j };
|
|
2634
|
+
} else {
|
|
2635
|
+
delete next.apiKeyFile;
|
|
2636
|
+
}
|
|
2637
|
+
replaceEngine(next, { head: true });
|
|
2638
|
+
};
|
|
2639
|
+
path.addEventListener("input", save);
|
|
2640
|
+
json.addEventListener("input", save);
|
|
2641
|
+
line.append(path, json);
|
|
2642
|
+
return sheetRow("Key file", line, {
|
|
2643
|
+
forId: path.id,
|
|
2644
|
+
caption: "Used when no key is stored and the variable is unset: a JSON file in your home folder and the dotted path to the key inside it. Works for apps opened from the Dock or Start menu."
|
|
2645
|
+
});
|
|
2646
|
+
}
|
|
2647
|
+
function buildDiscovery(engine) {
|
|
2648
|
+
const id = engine.id;
|
|
2649
|
+
const select = el("select", "settings-input");
|
|
2650
|
+
select.id = nextId("ai-discovery");
|
|
2651
|
+
select.dataset.fk = `eng:${id}:discovery`;
|
|
2652
|
+
for (const [value, label] of [["auto", "Ask the server"], ["manual", "Manual only"]]) {
|
|
2653
|
+
const o = el("option", "", label);
|
|
2654
|
+
o.value = value;
|
|
2655
|
+
select.append(o);
|
|
2656
|
+
}
|
|
2657
|
+
select.value = engine.discovery;
|
|
2658
|
+
select.addEventListener("change", () => updateEngine(id, { discovery: select.value }, { form: true }));
|
|
2659
|
+
return sheetRow("Model list", select, { forId: select.id, caption: "Ask the server which models it has (merged with any you add), or keep only the models you add by hand." });
|
|
2660
|
+
}
|
|
2661
|
+
function buildModelsTable(engine) {
|
|
2662
|
+
const id = engine.id;
|
|
2663
|
+
const box = el("div", "ai-models");
|
|
2664
|
+
const head = el("div", "ai-models-head");
|
|
2665
|
+
const missing = engine.models.filter((m) => m.missing).length;
|
|
2666
|
+
head.append(el("span", "settings-label", "All models"));
|
|
2667
|
+
head.append(el("span", "ai-models-count", engine.models.length === 0 ? "none yet" : `${engine.models.length}${missing ? `, ${missing} not listed` : ""}`));
|
|
2668
|
+
box.append(head);
|
|
2669
|
+
if (engine.models.length === 0) {
|
|
2670
|
+
box.append(el("div", "ai-models-empty", engine.discovery === "auto" && ai.discoverModels ? "Fetch models to list what the server offers, or add one by id." : "Add the models this engine serves by id."));
|
|
2671
|
+
} else {
|
|
2672
|
+
const list = el("div", "ai-model-list");
|
|
2673
|
+
list.setAttribute("role", "list");
|
|
2674
|
+
list.setAttribute("aria-label", `Models of ${engineDisplayName(engine)}`);
|
|
2675
|
+
const loaded = new Map((deps.statuses.get(id)?.models ?? []).map((m) => [m.id, m.loaded]));
|
|
2676
|
+
for (const model of engine.models) {
|
|
2677
|
+
list.append(buildModelItem(engine, model, loaded.get(model.id)));
|
|
2678
|
+
}
|
|
2679
|
+
box.append(list);
|
|
2680
|
+
}
|
|
2681
|
+
const add = el("div", "ai-model-add");
|
|
2682
|
+
const addInput = el("input", "settings-input settings-input--small settings-input--grow ai-mono");
|
|
2683
|
+
addInput.type = "text";
|
|
2684
|
+
addInput.placeholder = "Model id, such as qwen3-coder-30b";
|
|
2685
|
+
addInput.spellcheck = false;
|
|
2686
|
+
addInput.setAttribute("aria-label", `Add a model to ${engineDisplayName(engine)} by id`);
|
|
2687
|
+
addInput.dataset.fk = `eng:${id}:add-model`;
|
|
2688
|
+
const addBtn = button("settings-btn", "Add model", { html: ICONS.plus, fk: `eng:${id}:add-model-btn` });
|
|
2689
|
+
const addError = el("div", "ai-field-error");
|
|
2690
|
+
addError.id = nextId("ai-add-model-error");
|
|
2691
|
+
addInput.setAttribute("aria-describedby", addError.id);
|
|
2692
|
+
const doAdd = () => {
|
|
2693
|
+
const current = getEngine(id);
|
|
2694
|
+
if (!current) {
|
|
2695
|
+
return;
|
|
2696
|
+
}
|
|
2697
|
+
const r = addManualModel(current, addInput.value);
|
|
2698
|
+
if (!r.ok) {
|
|
2699
|
+
addError.textContent = r.error;
|
|
2700
|
+
addInput.setAttribute("aria-invalid", "true");
|
|
2701
|
+
addInput.focus();
|
|
2702
|
+
return;
|
|
2703
|
+
}
|
|
2704
|
+
replaceEngine(r.engine, { form: true, routing: true });
|
|
2705
|
+
deps.announce(`Added ${r.model.id}.`);
|
|
2706
|
+
deps.focusKey(`eng:${id}:add-model`);
|
|
2707
|
+
};
|
|
2708
|
+
addBtn.addEventListener("click", doAdd);
|
|
2709
|
+
addInput.addEventListener("keydown", (e) => {
|
|
2710
|
+
if (e.key === "Enter") {
|
|
2711
|
+
e.preventDefault();
|
|
2712
|
+
doAdd();
|
|
2713
|
+
}
|
|
2714
|
+
});
|
|
2715
|
+
addInput.addEventListener("input", () => {
|
|
2716
|
+
addError.textContent = "";
|
|
2717
|
+
addInput.removeAttribute("aria-invalid");
|
|
2718
|
+
});
|
|
2719
|
+
add.append(addInput, addBtn);
|
|
2720
|
+
box.append(add, addError);
|
|
2721
|
+
return box;
|
|
2722
|
+
}
|
|
2723
|
+
function buildModelItem(engine, model, loaded) {
|
|
2724
|
+
const engineId = engine.id;
|
|
2725
|
+
const mfk = (name) => `model:${engineId}:${model.id}:${name}`;
|
|
2726
|
+
const shown = modelDisplayName(model);
|
|
2727
|
+
const item = el("div", "ai-model");
|
|
2728
|
+
item.setAttribute("role", "listitem");
|
|
2729
|
+
item.classList.toggle("is-missing", !!model.missing);
|
|
2730
|
+
item.classList.toggle("is-off", !model.enabled);
|
|
2731
|
+
const top = el("div", "ai-model-top");
|
|
2732
|
+
const enabled = el("input", "ai-model-check");
|
|
2733
|
+
enabled.type = "checkbox";
|
|
2734
|
+
enabled.checked = model.enabled;
|
|
2735
|
+
enabled.dataset.fk = mfk("enabled");
|
|
2736
|
+
enabled.setAttribute("aria-label", `Use ${shown}`);
|
|
2737
|
+
if (!model.capabilities.chat && !model.capabilities.embed && !model.enabled) {
|
|
2738
|
+
enabled.disabled = true;
|
|
2739
|
+
enabled.title = "Not a chat model: it cannot be picked.";
|
|
2740
|
+
}
|
|
2741
|
+
enabled.addEventListener("change", () => {
|
|
2742
|
+
deps.setDraft(setModelPicked(deps.getDraft(), engineId, model.id, enabled.checked), { form: true, head: true, routing: true });
|
|
2743
|
+
});
|
|
2744
|
+
const names = el("div", "ai-model-names");
|
|
2745
|
+
const label = el("input", "ai-model-label");
|
|
2746
|
+
label.type = "text";
|
|
2747
|
+
label.value = model.label ?? "";
|
|
2748
|
+
label.placeholder = model.id;
|
|
2749
|
+
label.spellcheck = false;
|
|
2750
|
+
label.dataset.fk = mfk("label");
|
|
2751
|
+
label.setAttribute("aria-label", `Display name for ${model.id}`);
|
|
2752
|
+
label.addEventListener("input", () => updateModel(engineId, model.id, (m) => {
|
|
2753
|
+
const next = { ...m };
|
|
2754
|
+
if (label.value.trim()) {
|
|
2755
|
+
next.label = label.value;
|
|
2756
|
+
} else {
|
|
2757
|
+
delete next.label;
|
|
2758
|
+
}
|
|
2759
|
+
return next;
|
|
2760
|
+
}, { routing: true }));
|
|
2761
|
+
names.append(label);
|
|
2762
|
+
if (model.label) {
|
|
2763
|
+
names.append(el("span", "ai-model-id", model.id));
|
|
2764
|
+
}
|
|
2765
|
+
const tags = el("div", "ai-model-tags");
|
|
2766
|
+
if (loaded) {
|
|
2767
|
+
const t = el("span", "ai-tag ai-tag--loaded", "loaded");
|
|
2768
|
+
t.title = "Loaded in memory on the server right now.";
|
|
2769
|
+
tags.append(t);
|
|
2770
|
+
}
|
|
2771
|
+
if (model.missing) {
|
|
2772
|
+
const t = el("span", "ai-tag ai-tag--missing", "not listed");
|
|
2773
|
+
t.title = "The last discovery did not list this model. It is kept, but never routed.";
|
|
2774
|
+
tags.append(t);
|
|
2775
|
+
}
|
|
2776
|
+
if (model.manual) {
|
|
2777
|
+
tags.append(el("span", "ai-tag", "added by hand"));
|
|
2778
|
+
}
|
|
2779
|
+
const ctxWrap = el("label", "ai-model-context");
|
|
2780
|
+
const ctxInput = el("input", "settings-input settings-input--small ai-context-input");
|
|
2781
|
+
ctxInput.type = "text";
|
|
2782
|
+
ctxInput.inputMode = "numeric";
|
|
2783
|
+
ctxInput.value = model.capabilities.contextTokens !== null ? String(model.capabilities.contextTokens) : "";
|
|
2784
|
+
ctxInput.placeholder = formatTokens(null);
|
|
2785
|
+
ctxInput.dataset.fk = mfk("context");
|
|
2786
|
+
ctxInput.setAttribute("aria-label", `Context window of ${shown}, in tokens`);
|
|
2787
|
+
ctxInput.title = `Context window in tokens (${formatTokens(model.capabilities.contextTokens)}). Blank means unknown.`;
|
|
2788
|
+
ctxInput.addEventListener("change", () => {
|
|
2789
|
+
const r = parseContextTokens(ctxInput.value);
|
|
2790
|
+
if (!r.ok) {
|
|
2791
|
+
ctxInput.setAttribute("aria-invalid", "true");
|
|
2792
|
+
ctxInput.title = r.error;
|
|
2793
|
+
deps.announce(r.error);
|
|
2794
|
+
return;
|
|
2795
|
+
}
|
|
2796
|
+
ctxInput.removeAttribute("aria-invalid");
|
|
2797
|
+
updateModel(engineId, model.id, (m) => setModelContextTokens(m, r.value), { form: true, routing: true });
|
|
2798
|
+
});
|
|
2799
|
+
ctxWrap.append(ctxInput, el("span", "ai-context-unit", "ctx"));
|
|
2800
|
+
const hasOverrides = !!model.requestOverrides && Object.keys(model.requestOverrides).length > 0;
|
|
2801
|
+
const ovKey = `${engineId}\0${model.id}`;
|
|
2802
|
+
const ovOpen = openOverrides.has(ovKey) || overrideErrors.has(ovKey);
|
|
2803
|
+
const overridesId = nextId("ai-overrides");
|
|
2804
|
+
const ovBtn = button(`ai-icon-btn ai-overrides-btn${hasOverrides ? " has-value" : ""}`, "", {
|
|
2805
|
+
html: '<span aria-hidden="true">{ }</span>',
|
|
2806
|
+
ariaLabel: `Request overrides for ${shown}`,
|
|
2807
|
+
title: hasOverrides ? "Request overrides (set)" : "Request overrides",
|
|
2808
|
+
fk: mfk("overrides")
|
|
2809
|
+
});
|
|
2810
|
+
ovBtn.setAttribute("aria-expanded", String(ovOpen));
|
|
2811
|
+
ovBtn.setAttribute("aria-controls", overridesId);
|
|
2812
|
+
ovBtn.addEventListener("click", () => {
|
|
2813
|
+
if (openOverrides.has(ovKey)) {
|
|
2814
|
+
openOverrides.delete(ovKey);
|
|
2815
|
+
} else {
|
|
2816
|
+
openOverrides.add(ovKey);
|
|
2817
|
+
}
|
|
2818
|
+
deps.refresh(engineId, { form: true });
|
|
2819
|
+
if (openOverrides.has(ovKey)) {
|
|
2820
|
+
deps.focusKey(mfk("overrides-text"));
|
|
2821
|
+
}
|
|
2822
|
+
});
|
|
2823
|
+
const right = el("div", "ai-model-right");
|
|
2824
|
+
const thinking = buildThinkingSelect(engine, model, "table");
|
|
2825
|
+
if (thinking) {
|
|
2826
|
+
right.append(thinking);
|
|
2827
|
+
}
|
|
2828
|
+
right.append(ctxWrap, ovBtn);
|
|
2829
|
+
if (model.manual || model.missing) {
|
|
2830
|
+
const remove = button("ai-icon-btn ai-model-remove", "", { html: ICONS.close, ariaLabel: `Remove ${shown}`, title: "Remove model", fk: mfk("remove") });
|
|
2831
|
+
remove.addEventListener("click", () => {
|
|
2832
|
+
const current = getEngine(engineId);
|
|
2833
|
+
if (!current) {
|
|
2834
|
+
return;
|
|
2835
|
+
}
|
|
2836
|
+
const index = current.models.findIndex((m) => m.id === model.id);
|
|
2837
|
+
const draft = removeRoutesTo(deps.getDraft(), engineId, model.id);
|
|
2838
|
+
deps.setDraft(withEngine(draft, { ...current, models: current.models.filter((m) => m.id !== model.id) }), { form: true, head: true, routing: true });
|
|
2839
|
+
deps.announce(`Removed ${shown}.`);
|
|
2840
|
+
const rest = getEngine(engineId)?.models ?? [];
|
|
2841
|
+
const next = rest[Math.min(index, rest.length - 1)];
|
|
2842
|
+
deps.focusKey(next ? `model:${engineId}:${next.id}:enabled` : `eng:${engineId}:add-model`);
|
|
2843
|
+
});
|
|
2844
|
+
right.append(remove);
|
|
2845
|
+
}
|
|
2846
|
+
top.append(enabled, names, tags, right);
|
|
2847
|
+
const chips = el("div", "ai-chips");
|
|
2848
|
+
chips.setAttribute("role", "group");
|
|
2849
|
+
chips.setAttribute("aria-label", `Capabilities of ${shown}`);
|
|
2850
|
+
for (const chip of AI_CAPABILITY_CHIPS) {
|
|
2851
|
+
const on = model.capabilities[chip.flag];
|
|
2852
|
+
const b = button(`ai-chip${on ? " is-on" : ""}`, chip.label, { title: `${chip.title}. Click to ${on ? "turn off" : "turn on"}.`, fk: mfk(`chip-${chip.flag}`) });
|
|
2853
|
+
b.setAttribute("aria-pressed", String(on));
|
|
2854
|
+
b.setAttribute("aria-label", `${chip.label}: ${chip.title}`);
|
|
2855
|
+
b.addEventListener("click", () => updateModel(engineId, model.id, (m) => toggleModelCapability(m, chip.flag), { form: true, routing: true }));
|
|
2856
|
+
chips.append(b);
|
|
2857
|
+
}
|
|
2858
|
+
const source = el("span", `ai-source ai-source--${model.capabilitiesSource}`, model.capabilitiesSource === "user" ? "edited" : model.capabilitiesSource === "endpoint" ? "from server" : "guessed");
|
|
2859
|
+
source.title = model.capabilitiesSource === "user" ? "You edited these; discovery leaves them alone." : model.capabilitiesSource === "endpoint" ? "Reported by the server; refreshed on discovery." : "Guessed from the model id; refreshed on discovery.";
|
|
2860
|
+
chips.append(source);
|
|
2861
|
+
item.append(top, chips);
|
|
2862
|
+
if (ovOpen) {
|
|
2863
|
+
const ov = el("div", "ai-overrides");
|
|
2864
|
+
ov.id = overridesId;
|
|
2865
|
+
const area = el("textarea", "settings-input ai-mono ai-overrides-text");
|
|
2866
|
+
area.rows = 3;
|
|
2867
|
+
area.spellcheck = false;
|
|
2868
|
+
area.dataset.fk = mfk("overrides-text");
|
|
2869
|
+
area.placeholder = '{ "reasoning_effort": "low" }';
|
|
2870
|
+
const pending = overrideErrors.get(ovKey);
|
|
2871
|
+
area.value = pending ? pending.text : formatRequestOverrides(model.requestOverrides);
|
|
2872
|
+
const help = el("div", "settings-desc", "JSON merged into every request body for this model, last, so it wins. The Thinking menu writes its fields here too.");
|
|
2873
|
+
const err = el("div", "ai-field-error");
|
|
2874
|
+
err.id = nextId("ai-overrides-error");
|
|
2875
|
+
err.setAttribute("aria-live", "polite");
|
|
2876
|
+
area.setAttribute("aria-describedby", err.id);
|
|
2877
|
+
area.setAttribute("aria-label", `Request overrides for ${shown}, as JSON`);
|
|
2878
|
+
if (pending) {
|
|
2879
|
+
err.textContent = pending.error;
|
|
2880
|
+
area.setAttribute("aria-invalid", "true");
|
|
2881
|
+
}
|
|
2882
|
+
area.addEventListener("input", () => {
|
|
2883
|
+
const r = parseRequestOverrides(area.value);
|
|
2884
|
+
if (!r.ok) {
|
|
2885
|
+
overrideErrors.set(ovKey, { text: area.value, error: r.error });
|
|
2886
|
+
err.textContent = r.error;
|
|
2887
|
+
area.setAttribute("aria-invalid", "true");
|
|
2888
|
+
return;
|
|
2889
|
+
}
|
|
2890
|
+
overrideErrors.delete(ovKey);
|
|
2891
|
+
err.textContent = "";
|
|
2892
|
+
area.removeAttribute("aria-invalid");
|
|
2893
|
+
updateModel(engineId, model.id, (m) => {
|
|
2894
|
+
const next = { ...m };
|
|
2895
|
+
if (r.value) {
|
|
2896
|
+
next.requestOverrides = r.value;
|
|
2897
|
+
} else {
|
|
2898
|
+
delete next.requestOverrides;
|
|
2899
|
+
}
|
|
2900
|
+
return next;
|
|
2901
|
+
}, {});
|
|
2902
|
+
ovBtn.classList.toggle("has-value", !!r.value);
|
|
2903
|
+
});
|
|
2904
|
+
ov.append(area, err, help);
|
|
2905
|
+
item.append(ov);
|
|
2906
|
+
}
|
|
2907
|
+
return item;
|
|
2908
|
+
}
|
|
2909
|
+
return {
|
|
2910
|
+
build,
|
|
2911
|
+
opened(id, opts = {}) {
|
|
2912
|
+
const engine = getEngine(id);
|
|
2913
|
+
if (!engine) {
|
|
2914
|
+
return `eng:${id}:name`;
|
|
2915
|
+
}
|
|
2916
|
+
const family = presetOf(engine).family;
|
|
2917
|
+
trigger(id, "open");
|
|
2918
|
+
if (opts.isNew && family === "cloud") {
|
|
2919
|
+
void lookUpKey(id);
|
|
2920
|
+
}
|
|
2921
|
+
if (!opts.isNew) {
|
|
2922
|
+
return `eng:${id}:name`;
|
|
2923
|
+
}
|
|
2924
|
+
if (family === "cloud" && ai.setSecret) {
|
|
2925
|
+
return `eng:${id}:key`;
|
|
2926
|
+
}
|
|
2927
|
+
if (family !== "local") {
|
|
2928
|
+
return `eng:${id}:name`;
|
|
2929
|
+
}
|
|
2930
|
+
return presetOf(engine).id === "custom" ? `eng:${id}:url` : null;
|
|
2931
|
+
},
|
|
2932
|
+
busy(id) {
|
|
2933
|
+
if (testing.has(id)) {
|
|
2934
|
+
return "test";
|
|
2935
|
+
}
|
|
2936
|
+
return fetches.get(id)?.running ? "discover" : void 0;
|
|
2937
|
+
},
|
|
2938
|
+
requirePick(id) {
|
|
2939
|
+
pickRequired.add(id);
|
|
2940
|
+
},
|
|
2941
|
+
forget(id) {
|
|
2942
|
+
pickRequired.delete(id);
|
|
2943
|
+
const st = fetches.get(id);
|
|
2944
|
+
if (st) {
|
|
2945
|
+
window.clearTimeout(st.timer);
|
|
2946
|
+
st.seq++;
|
|
2947
|
+
}
|
|
2948
|
+
window.clearTimeout(keyTimers.get(id));
|
|
2949
|
+
for (const map of [fetches, keyText, keyStored, keyTimers, pickerFilter, results]) {
|
|
2950
|
+
map.delete(id);
|
|
2951
|
+
}
|
|
2952
|
+
advancedOpen.delete(id);
|
|
2953
|
+
typeOpen.delete(id);
|
|
2954
|
+
showAll.delete(id);
|
|
2955
|
+
formPicked.delete(id);
|
|
2956
|
+
},
|
|
2957
|
+
dispose() {
|
|
2958
|
+
for (const st of fetches.values()) {
|
|
2959
|
+
window.clearTimeout(st.timer);
|
|
2960
|
+
st.seq++;
|
|
2961
|
+
}
|
|
2962
|
+
for (const t of keyTimers.values()) {
|
|
2963
|
+
window.clearTimeout(t);
|
|
2964
|
+
}
|
|
2965
|
+
keyTimers.clear();
|
|
2966
|
+
keyText.clear();
|
|
2967
|
+
}
|
|
2968
|
+
};
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2971
|
+
// src/ai-routing-section.ts
|
|
2972
|
+
var FALLBACK_LABELS = {
|
|
2973
|
+
none: "Nothing (the feature is unavailable)",
|
|
2974
|
+
default: "Use the default model",
|
|
2975
|
+
anyLocal: "Default, then any local model",
|
|
2976
|
+
any: "Default, any local, then cloud"
|
|
2977
|
+
};
|
|
2978
|
+
function createRoutingSection(ctx, deps) {
|
|
2979
|
+
const body = el("div", "ai-routing");
|
|
2980
|
+
function buildRouteSelect(current, req, o) {
|
|
2981
|
+
const draft = deps.getDraft();
|
|
2982
|
+
const select = el("select", "settings-input ai-route-select");
|
|
2983
|
+
select.dataset.fk = o.fk;
|
|
2984
|
+
select.setAttribute("aria-label", o.ariaLabel);
|
|
2985
|
+
const pairs = routePairs(draft, deps.statusList(), req);
|
|
2986
|
+
const first = el("option", "", o.defaultText ?? (pairs.some((p) => p.selectable) ? "Choose a model\u2026" : draft.engines.length === 0 ? "Add an engine first" : "No model can serve this"));
|
|
2987
|
+
first.value = "";
|
|
2988
|
+
select.append(first);
|
|
2989
|
+
const currentKey = routeKey(current);
|
|
2990
|
+
if (current && !pairs.some((p) => routeKey(p) === currentKey)) {
|
|
2991
|
+
const gone = el("option", "", `${routeLabel(draft, current)} (not found)`);
|
|
2992
|
+
gone.value = currentKey;
|
|
2993
|
+
select.append(gone);
|
|
2994
|
+
}
|
|
2995
|
+
const byEngine = /* @__PURE__ */ new Map();
|
|
2996
|
+
for (const p of pairs) {
|
|
2997
|
+
const list = byEngine.get(p.engineId) ?? [];
|
|
2998
|
+
list.push(p);
|
|
2999
|
+
byEngine.set(p.engineId, list);
|
|
3000
|
+
}
|
|
3001
|
+
for (const list of byEngine.values()) {
|
|
3002
|
+
const group = el("optgroup");
|
|
3003
|
+
group.label = `${list[0].engineName} (${list[0].isLocal ? "local" : "cloud"})`;
|
|
3004
|
+
for (const p of list) {
|
|
3005
|
+
const o2 = el("option", "", `${p.engineName} \xB7 ${p.modelName}${p.note ? ` (${p.note})` : ""}`);
|
|
3006
|
+
o2.value = routeKey(p);
|
|
3007
|
+
o2.disabled = !p.selectable && routeKey(p) !== currentKey;
|
|
3008
|
+
group.append(o2);
|
|
3009
|
+
}
|
|
3010
|
+
select.append(group);
|
|
3011
|
+
}
|
|
3012
|
+
select.value = currentKey;
|
|
3013
|
+
if (select.value !== currentKey) {
|
|
3014
|
+
select.value = "";
|
|
3015
|
+
}
|
|
3016
|
+
return select;
|
|
3017
|
+
}
|
|
3018
|
+
function render() {
|
|
3019
|
+
keepFocus(body, () => {
|
|
3020
|
+
body.replaceChildren();
|
|
3021
|
+
const draft = deps.getDraft();
|
|
3022
|
+
const defRow = ctx.makeRow("Default model", "Serves every task without its own route. Needs chat.", "inline");
|
|
3023
|
+
const defSelect = buildRouteSelect(draft.routing.default, DEFAULT_ROUTE_REQUIREMENTS, { fk: "route:default", ariaLabel: "Default model" });
|
|
3024
|
+
defSelect.addEventListener("change", () => {
|
|
3025
|
+
const d = deps.getDraft();
|
|
3026
|
+
deps.setDraft({ ...d, routing: { ...d.routing, default: parseRouteKey(defSelect.value) } }, { defaultChanged: true });
|
|
3027
|
+
render();
|
|
3028
|
+
});
|
|
3029
|
+
defRow.append(defSelect);
|
|
3030
|
+
const defNote = taskRouteNote({ ...draft, routing: { ...draft.routing, tasks: {} } }, deps.statusList(), "chat");
|
|
3031
|
+
if (draft.routing.default && defNote) {
|
|
3032
|
+
defRow.append(note(defNote.level, defNote.text, "ai-row-note"));
|
|
3033
|
+
}
|
|
3034
|
+
const fbField = ctx.makeSelectRow(
|
|
3035
|
+
"When a route is unavailable",
|
|
3036
|
+
"What to try when a task\u2019s model is off, unreachable or lacks what the task needs. Fallback never moves a local route to a cloud engine unless you pick the last option.",
|
|
3037
|
+
draft.routing.fallback,
|
|
3038
|
+
AI_FALLBACK_MODES.map((m) => ({ value: m, label: FALLBACK_LABELS[m] }))
|
|
3039
|
+
);
|
|
3040
|
+
fbField.select.dataset.fk = "route:fallback";
|
|
3041
|
+
fbField.select.addEventListener("change", () => {
|
|
3042
|
+
const d = deps.getDraft();
|
|
3043
|
+
deps.setDraft({ ...d, routing: { ...d.routing, fallback: fbField.select.value } }, { defaultChanged: false });
|
|
3044
|
+
render();
|
|
3045
|
+
});
|
|
3046
|
+
const top = ctx.makeGroup(defRow, fbField.row);
|
|
3047
|
+
const defaultText = draft.routing.default ? `Default: ${routeLabel(draft, draft.routing.default)}` : "Default (none set)";
|
|
3048
|
+
const taskRows = [];
|
|
3049
|
+
const taskRow = (taskId, label, description, req, hostOnly) => {
|
|
3050
|
+
const row = ctx.makeRow(label, description, "inline");
|
|
3051
|
+
row.classList.add("ai-task-row");
|
|
3052
|
+
if (hostOnly) {
|
|
3053
|
+
row.append(el("span", "ai-muted", "Handled by the app"));
|
|
3054
|
+
taskRows.push(row);
|
|
3055
|
+
return;
|
|
3056
|
+
}
|
|
3057
|
+
const select = buildRouteSelect(draft.routing.tasks[taskId], req, { fk: `route:task:${taskId}`, ariaLabel: `Model for ${label}`, defaultText });
|
|
3058
|
+
select.addEventListener("change", () => {
|
|
3059
|
+
const d = deps.getDraft();
|
|
3060
|
+
const tasks = { ...d.routing.tasks };
|
|
3061
|
+
const route = parseRouteKey(select.value);
|
|
3062
|
+
if (route) {
|
|
3063
|
+
tasks[taskId] = { ...tasks[taskId] ?? {}, ...route };
|
|
3064
|
+
} else {
|
|
3065
|
+
delete tasks[taskId];
|
|
3066
|
+
}
|
|
3067
|
+
deps.setDraft({ ...d, routing: { ...d.routing, tasks } }, { defaultChanged: false });
|
|
3068
|
+
render();
|
|
3069
|
+
});
|
|
3070
|
+
row.append(select);
|
|
3071
|
+
const n = taskRouteNote(draft, deps.statusList(), taskId);
|
|
3072
|
+
if (n) {
|
|
3073
|
+
row.append(note(n.level, n.text, "ai-row-note"));
|
|
3074
|
+
}
|
|
3075
|
+
taskRows.push(row);
|
|
3076
|
+
};
|
|
3077
|
+
for (const task of AI_TASKS) {
|
|
3078
|
+
taskRow(task.id, task.label, task.description, taskRequirements(task.id), task.hostMethod === "transcribe");
|
|
3079
|
+
}
|
|
3080
|
+
for (const taskId of unknownRoutedTasks(draft)) {
|
|
3081
|
+
taskRow(taskId, taskId, "Saved by a newer version of MikeDown. Kept as is.", { requires: {} }, false);
|
|
3082
|
+
}
|
|
3083
|
+
const tasksHead = el("div", "ai-subhead", "Model per task");
|
|
3084
|
+
body.append(top, tasksHead, ctx.makeGroup(...taskRows));
|
|
3085
|
+
});
|
|
3086
|
+
}
|
|
3087
|
+
return { element: body, render };
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
// src/ai-setup-wizard.ts
|
|
3091
|
+
var AI_WIZARD_STEPS = 3;
|
|
3092
|
+
function wizardStepNumber(step) {
|
|
3093
|
+
return step === "path" ? 1 : step === "done" ? 3 : 2;
|
|
3094
|
+
}
|
|
3095
|
+
var AI_SUBSCRIPTION_NOTE = "Consumer subscriptions such as ChatGPT Plus or Claude Pro do not include API access. An API key is billed separately by the provider, per use.";
|
|
3096
|
+
function subscriptionNote(preset, openLink, policy) {
|
|
3097
|
+
const box = el("div", "ai-wizard-subscription");
|
|
3098
|
+
box.append(el("p", "ai-wizard-subscription-text", AI_SUBSCRIPTION_NOTE));
|
|
3099
|
+
const links = [];
|
|
3100
|
+
if (preset.keyUrl) {
|
|
3101
|
+
links.push([`${preset.name} API keys`, preset.keyUrl]);
|
|
3102
|
+
}
|
|
3103
|
+
if (preset.pricingUrl) {
|
|
3104
|
+
links.push(["Pricing", preset.pricingUrl]);
|
|
3105
|
+
}
|
|
3106
|
+
if (policy?.url) {
|
|
3107
|
+
links.push(["Privacy", policy.url]);
|
|
3108
|
+
}
|
|
3109
|
+
if (links.length > 0) {
|
|
3110
|
+
const line = el("p", "ai-wizard-subscription-links");
|
|
3111
|
+
line.append("Get a key: ");
|
|
3112
|
+
links.forEach(([label, url], i) => {
|
|
3113
|
+
if (i > 0) {
|
|
3114
|
+
line.append(el("span", "ai-wizard-sep", " \xB7 "));
|
|
3115
|
+
}
|
|
3116
|
+
line.append(hostLink(label, url, openLink, "ai-wizard-link"));
|
|
3117
|
+
});
|
|
3118
|
+
box.append(line);
|
|
3119
|
+
}
|
|
3120
|
+
if (policy?.note) {
|
|
3121
|
+
box.append(el("p", "ai-wizard-subscription-links", policy.note));
|
|
3122
|
+
}
|
|
3123
|
+
return box;
|
|
3124
|
+
}
|
|
3125
|
+
function createSetupWizard(deps) {
|
|
3126
|
+
const { session, form, ctx } = deps;
|
|
3127
|
+
const { ai } = session;
|
|
3128
|
+
const groups = groupPresets(AI_ENGINE_PRESETS);
|
|
3129
|
+
let step = "path";
|
|
3130
|
+
const history = [];
|
|
3131
|
+
let engineId = null;
|
|
3132
|
+
const added = /* @__PURE__ */ new Set();
|
|
3133
|
+
let scan = canScanForSetup(ai) ? lastSetupScan(ai) : plainSetupScan();
|
|
3134
|
+
let scanning = false;
|
|
3135
|
+
let scanAbort = null;
|
|
3136
|
+
let connecting = null;
|
|
3137
|
+
let connectError = null;
|
|
3138
|
+
let advancedOpen = false;
|
|
3139
|
+
let cloudPrivacyOpen = false;
|
|
3140
|
+
let disposed = false;
|
|
3141
|
+
const element = el("div", "ai-wizard");
|
|
3142
|
+
const column = el("div", "ai-wizard-column");
|
|
3143
|
+
element.append(column);
|
|
3144
|
+
function rescan() {
|
|
3145
|
+
if (!canScanForSetup(ai) || disposed) {
|
|
3146
|
+
return;
|
|
3147
|
+
}
|
|
3148
|
+
scanAbort?.abort();
|
|
3149
|
+
const controller = new AbortController();
|
|
3150
|
+
scanAbort = controller;
|
|
3151
|
+
scanning = true;
|
|
3152
|
+
if (step === "local") {
|
|
3153
|
+
deps.announce(AI_DETECT_LOOKING);
|
|
3154
|
+
}
|
|
3155
|
+
redrawScanned();
|
|
3156
|
+
void scanForSetup(ai, controller.signal).then((fresh) => {
|
|
3157
|
+
if (disposed || controller.signal.aborted) {
|
|
3158
|
+
return;
|
|
3159
|
+
}
|
|
3160
|
+
scanAbort = null;
|
|
3161
|
+
scanning = false;
|
|
3162
|
+
scan = fresh;
|
|
3163
|
+
redrawScanned();
|
|
3164
|
+
if (step === "local") {
|
|
3165
|
+
deps.announce(detectionSummary());
|
|
3166
|
+
}
|
|
3167
|
+
});
|
|
3168
|
+
}
|
|
3169
|
+
function redrawScanned() {
|
|
3170
|
+
if (step === "path" || step === "local") {
|
|
3171
|
+
render();
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
function localCards() {
|
|
3175
|
+
return scan?.probes ? rankLocalCandidates(scan.probes, session.draft.engines) : null;
|
|
3176
|
+
}
|
|
3177
|
+
function detectionSummary() {
|
|
3178
|
+
const cards = localCards();
|
|
3179
|
+
if (!cards) {
|
|
3180
|
+
return "Could not look for local AI servers.";
|
|
3181
|
+
}
|
|
3182
|
+
const running = cards.filter((c) => c.running);
|
|
3183
|
+
return running.length === 0 ? AI_DETECT_NONE : `Running on this computer: ${running.map((c) => presetName(c.presetId)).join(", ")}.`;
|
|
3184
|
+
}
|
|
3185
|
+
const presetById = (id) => AI_ENGINE_PRESETS.find((p) => p.id === id);
|
|
3186
|
+
const presetName = (id) => presetById(id)?.name ?? id;
|
|
3187
|
+
const machine = () => scan?.hardware?.platform === "darwin" ? "Mac" : "computer";
|
|
3188
|
+
function engineFor(preset) {
|
|
3189
|
+
const card = preset.family === "local" ? localCards()?.find((c) => c.presetId === preset.id && c.alreadyAdded && c.engineId) : void 0;
|
|
3190
|
+
const existing = card?.engineId && session.getEngine(card.engineId) ? session.getEngine(card.engineId) : preset.family !== "local" ? session.draft.engines.find((e) => presetForEngine(e)?.id === preset.id) : void 0;
|
|
3191
|
+
if (existing) {
|
|
3192
|
+
return { id: existing.id, isNew: false };
|
|
3193
|
+
}
|
|
3194
|
+
const engine = engineFromPreset(preset, session.draft.engines.map((e) => e.id));
|
|
3195
|
+
session.addEngine(engine);
|
|
3196
|
+
added.add(engine.id);
|
|
3197
|
+
return { id: engine.id, isNew: true };
|
|
3198
|
+
}
|
|
3199
|
+
function useEngine(id) {
|
|
3200
|
+
if (engineId && engineId !== id && added.has(engineId)) {
|
|
3201
|
+
session.deleteEngine(engineId);
|
|
3202
|
+
form.forget(engineId);
|
|
3203
|
+
added.delete(engineId);
|
|
3204
|
+
}
|
|
3205
|
+
engineId = id;
|
|
3206
|
+
connectError = null;
|
|
3207
|
+
}
|
|
3208
|
+
const currentEngine = () => engineId ? session.getEngine(engineId) : void 0;
|
|
3209
|
+
function readyModel(engine) {
|
|
3210
|
+
if (!engine || !engine.enabled || !isKnownEngineKind(engine.kind)) {
|
|
3211
|
+
return null;
|
|
3212
|
+
}
|
|
3213
|
+
const usable = (m) => !!m && m.enabled && !m.missing && m.capabilities.chat;
|
|
3214
|
+
const id = engineDefaultModelId(session.draft, engine.id);
|
|
3215
|
+
const model = id ? engine.models.find((m) => m.id === id) : void 0;
|
|
3216
|
+
return usable(model) ? model.id : engine.models.find(usable)?.id ?? null;
|
|
3217
|
+
}
|
|
3218
|
+
function cloudBlocked(engine) {
|
|
3219
|
+
return !session.draft.allowCloud && engineLocalityInfo(engine, session.draft.localNetworks).locality === "cloud";
|
|
3220
|
+
}
|
|
3221
|
+
function makeDefault(id, modelId) {
|
|
3222
|
+
const draft = setEngineDefaultModel(session.draft, id, modelId);
|
|
3223
|
+
session.draft = { ...draft, routing: { ...draft.routing, default: { engineId: id, modelId } } };
|
|
3224
|
+
}
|
|
3225
|
+
function allowCloudFor(preset) {
|
|
3226
|
+
if (preset.family !== "local" && !session.draft.allowCloud) {
|
|
3227
|
+
deps.setAllowCloud(true);
|
|
3228
|
+
}
|
|
3229
|
+
}
|
|
3230
|
+
function go(next, o = {}) {
|
|
3231
|
+
if (o.push !== false && next !== step) {
|
|
3232
|
+
history.push(step);
|
|
3233
|
+
}
|
|
3234
|
+
step = next;
|
|
3235
|
+
render({ fresh: true });
|
|
3236
|
+
deps.onStep(step);
|
|
3237
|
+
}
|
|
3238
|
+
async function connect(preset) {
|
|
3239
|
+
if (connecting) {
|
|
3240
|
+
return;
|
|
3241
|
+
}
|
|
3242
|
+
allowCloudFor(preset);
|
|
3243
|
+
const { id } = engineFor(preset);
|
|
3244
|
+
useEngine(id);
|
|
3245
|
+
const fallback = preset.family === "host" ? "copilot" : "local";
|
|
3246
|
+
if (!ai.discoverModels) {
|
|
3247
|
+
go(fallback);
|
|
3248
|
+
return;
|
|
3249
|
+
}
|
|
3250
|
+
connecting = preset.id;
|
|
3251
|
+
render();
|
|
3252
|
+
deps.announce(`Setting up ${preset.name}\u2026`);
|
|
3253
|
+
try {
|
|
3254
|
+
await session.ensureSaved(id);
|
|
3255
|
+
const status = await ai.discoverModels(id);
|
|
3256
|
+
if (disposed || engineId !== id) {
|
|
3257
|
+
return;
|
|
3258
|
+
}
|
|
3259
|
+
if (status && typeof status === "object") {
|
|
3260
|
+
session.statuses.set(id, status);
|
|
3261
|
+
deps.onStatuses();
|
|
3262
|
+
}
|
|
3263
|
+
if (!status || status.ok === false) {
|
|
3264
|
+
throw new Error(status?.error || `Could not fetch the models from ${preset.name}.`);
|
|
3265
|
+
}
|
|
3266
|
+
const engine = session.getEngine(id);
|
|
3267
|
+
if (!engine) {
|
|
3268
|
+
return;
|
|
3269
|
+
}
|
|
3270
|
+
session.draft = withEngine(session.draft, applyDiscoveryForFamily(engine, status, preset.family));
|
|
3271
|
+
const fresh = session.getEngine(id);
|
|
3272
|
+
const modelId = preset.family === "local" ? defaultRouteForEngine(session.draft, session.statusList(), id)?.modelId ?? null : firstChatModelId(fresh.models);
|
|
3273
|
+
if (!modelId && preset.family === "local" && fresh.models.some((m) => !m.missing && m.capabilities.chat)) {
|
|
3274
|
+
connecting = null;
|
|
3275
|
+
go(fallback);
|
|
3276
|
+
form.opened(id);
|
|
3277
|
+
deps.announce(`${preset.name}: pick a model to start with.`);
|
|
3278
|
+
return;
|
|
3279
|
+
}
|
|
3280
|
+
if (!modelId) {
|
|
3281
|
+
throw new Error(preset.family === "host" ? "Copilot lists no chat model for this account yet." : `${preset.name} has no chat model loaded or downloaded yet.`);
|
|
3282
|
+
}
|
|
3283
|
+
makeDefault(id, modelId);
|
|
3284
|
+
connecting = null;
|
|
3285
|
+
go("done");
|
|
3286
|
+
} catch (err) {
|
|
3287
|
+
if (disposed) {
|
|
3288
|
+
return;
|
|
3289
|
+
}
|
|
3290
|
+
connecting = null;
|
|
3291
|
+
connectError = err instanceof Error && err.message ? err.message : `${preset.name} could not be set up.`;
|
|
3292
|
+
deps.announce(connectError);
|
|
3293
|
+
go(fallback);
|
|
3294
|
+
form.opened(id);
|
|
3295
|
+
}
|
|
3296
|
+
}
|
|
3297
|
+
function pick(preset) {
|
|
3298
|
+
allowCloudFor(preset);
|
|
3299
|
+
const { id, isNew } = engineFor(preset);
|
|
3300
|
+
useEngine(id);
|
|
3301
|
+
const target = preset.family === "local" ? "local" : preset.family === "host" ? "copilot" : "cloud";
|
|
3302
|
+
if (step !== target) {
|
|
3303
|
+
go(target);
|
|
3304
|
+
} else {
|
|
3305
|
+
render();
|
|
3306
|
+
}
|
|
3307
|
+
const first = form.opened(id, { isNew: isNew && preset.family === "cloud" });
|
|
3308
|
+
if (isNew && preset.family === "cloud" && first) {
|
|
3309
|
+
deps.focusKey(first);
|
|
3310
|
+
}
|
|
3311
|
+
deps.announce(preset.family === "cloud" ? `${preset.name}: paste your API key.` : `${preset.name}: fetching its models.`);
|
|
3312
|
+
}
|
|
3313
|
+
function continueToDone() {
|
|
3314
|
+
const engine = currentEngine();
|
|
3315
|
+
const modelId = readyModel(engine);
|
|
3316
|
+
if (!engine || !modelId || cloudBlocked(engine)) {
|
|
3317
|
+
return;
|
|
3318
|
+
}
|
|
3319
|
+
makeDefault(engine.id, modelId);
|
|
3320
|
+
go("done");
|
|
3321
|
+
}
|
|
3322
|
+
function render(o = {}) {
|
|
3323
|
+
if (disposed) {
|
|
3324
|
+
return;
|
|
3325
|
+
}
|
|
3326
|
+
keepFocus(column, () => {
|
|
3327
|
+
column.replaceChildren(...buildStep());
|
|
3328
|
+
});
|
|
3329
|
+
element.dataset.step = step;
|
|
3330
|
+
if (o.fresh) {
|
|
3331
|
+
element.scrollTop = 0;
|
|
3332
|
+
column.querySelector(".ai-wizard-title")?.focus({ preventScroll: true });
|
|
3333
|
+
}
|
|
3334
|
+
}
|
|
3335
|
+
function buildStep() {
|
|
3336
|
+
switch (step) {
|
|
3337
|
+
case "path":
|
|
3338
|
+
return buildPath();
|
|
3339
|
+
case "cloud":
|
|
3340
|
+
return buildCloud();
|
|
3341
|
+
case "local":
|
|
3342
|
+
return buildLocal();
|
|
3343
|
+
case "copilot":
|
|
3344
|
+
return buildCopilot();
|
|
3345
|
+
case "done":
|
|
3346
|
+
return buildDone();
|
|
3347
|
+
}
|
|
3348
|
+
}
|
|
3349
|
+
function heading(text, lede) {
|
|
3350
|
+
const head = el("div", "ai-wizard-head");
|
|
3351
|
+
const h = el("h3", "ai-wizard-title", text);
|
|
3352
|
+
h.id = nextId("ai-wizard-title");
|
|
3353
|
+
h.tabIndex = -1;
|
|
3354
|
+
element.setAttribute("aria-labelledby", h.id);
|
|
3355
|
+
head.append(h);
|
|
3356
|
+
if (lede) {
|
|
3357
|
+
head.append(el("p", "ai-wizard-lede", lede));
|
|
3358
|
+
}
|
|
3359
|
+
return head;
|
|
3360
|
+
}
|
|
3361
|
+
function option(o) {
|
|
3362
|
+
const b = button(`ai-wizard-option${o.lead ? " is-lead" : ""}`, "", { fk: o.fk });
|
|
3363
|
+
if (o.state) {
|
|
3364
|
+
b.dataset.state = o.state;
|
|
3365
|
+
}
|
|
3366
|
+
const glyph = el("span", "ai-wizard-option-glyph");
|
|
3367
|
+
glyph.innerHTML = o.glyph;
|
|
3368
|
+
const text = el("span", "ai-wizard-option-text");
|
|
3369
|
+
const name = el("span", "ai-wizard-option-name");
|
|
3370
|
+
if (o.state === "running") {
|
|
3371
|
+
const dot = el("span", "ai-card-dot");
|
|
3372
|
+
dot.setAttribute("aria-hidden", "true");
|
|
3373
|
+
name.append(dot);
|
|
3374
|
+
}
|
|
3375
|
+
name.append(el("span", "", o.name));
|
|
3376
|
+
text.append(name, el("span", "ai-wizard-option-caption", o.caption));
|
|
3377
|
+
const chevron = el("span", "ai-wizard-option-chevron");
|
|
3378
|
+
if (o.state === "busy") {
|
|
3379
|
+
chevron.append(el("span", "ai-spinner"));
|
|
3380
|
+
b.setAttribute("aria-busy", "true");
|
|
3381
|
+
} else {
|
|
3382
|
+
chevron.innerHTML = ICONS.chevron;
|
|
3383
|
+
}
|
|
3384
|
+
chevron.setAttribute("aria-hidden", "true");
|
|
3385
|
+
b.append(glyph, text, chevron);
|
|
3386
|
+
b.addEventListener("click", () => {
|
|
3387
|
+
if (!connecting) {
|
|
3388
|
+
o.onClick();
|
|
3389
|
+
}
|
|
3390
|
+
});
|
|
3391
|
+
return b;
|
|
3392
|
+
}
|
|
3393
|
+
function cloudGlyph() {
|
|
3394
|
+
return `<span class="ai-glyph ai-wizard-glyph-cloud" aria-hidden="true">${ICONS.cloud}</span>`;
|
|
3395
|
+
}
|
|
3396
|
+
function buildPath() {
|
|
3397
|
+
const out = [heading("How should MikeDown reach a model?", "You can change it later in AI Engines.")];
|
|
3398
|
+
if (!scan) {
|
|
3399
|
+
const wait = el("div", "ai-wizard-scanning");
|
|
3400
|
+
wait.setAttribute("role", "status");
|
|
3401
|
+
wait.append(el("span", "ai-spinner"), el("span", "", "Checking this machine\u2026"));
|
|
3402
|
+
out.push(wait);
|
|
3403
|
+
return out;
|
|
3404
|
+
}
|
|
3405
|
+
const rec = scan.recommendation;
|
|
3406
|
+
const copilot = scan.vscodeLm?.available ? groups.host.find((p) => p.kind === "vscode-lm") : void 0;
|
|
3407
|
+
const running = rec.path === "runningLocal" && rec.running ? presetById(rec.running.presetId) : void 0;
|
|
3408
|
+
const options = el("div", "ai-wizard-options");
|
|
3409
|
+
if (running && rec.running) {
|
|
3410
|
+
const models = rec.running.models;
|
|
3411
|
+
options.append(option({
|
|
3412
|
+
fk: `wiz:use:${running.id}`,
|
|
3413
|
+
glyph: kindGlyphHtml(running.kind),
|
|
3414
|
+
name: `${running.name} is running on this ${machine()}${models !== void 0 ? ` \xB7 ${models} ${models === 1 ? "model" : "models"}` : ""}`,
|
|
3415
|
+
caption: connecting === running.id ? `Setting up ${running.name}\u2026` : "Use it. Text stays on this machine.",
|
|
3416
|
+
lead: true,
|
|
3417
|
+
state: connecting === running.id ? "busy" : "running",
|
|
3418
|
+
onClick: () => {
|
|
3419
|
+
void connect(running);
|
|
3420
|
+
}
|
|
3421
|
+
}));
|
|
3422
|
+
}
|
|
3423
|
+
if (copilot) {
|
|
3424
|
+
options.append(option({
|
|
3425
|
+
fk: "wiz:use:copilot",
|
|
3426
|
+
glyph: kindGlyphHtml(copilot.kind),
|
|
3427
|
+
name: "Use GitHub Copilot, no key needed",
|
|
3428
|
+
caption: connecting === copilot.id ? "Fetching Copilot\u2019s models\u2026" : `Through your Copilot plan in VS Code. ${AI_HOST_PRIVACY.permission}`,
|
|
3429
|
+
lead: rec.path === "vscodeLm",
|
|
3430
|
+
...connecting === copilot.id ? { state: "busy" } : {},
|
|
3431
|
+
onClick: () => {
|
|
3432
|
+
void connect(copilot);
|
|
3433
|
+
}
|
|
3434
|
+
}));
|
|
3435
|
+
}
|
|
3436
|
+
if (options.childElementCount > 0) {
|
|
3437
|
+
options.append(option({
|
|
3438
|
+
fk: "wiz:cloud",
|
|
3439
|
+
glyph: cloudGlyph(),
|
|
3440
|
+
name: "Connect a cloud provider",
|
|
3441
|
+
caption: "Paste an API key from Anthropic, OpenAI or another provider.",
|
|
3442
|
+
onClick: () => go("cloud")
|
|
3443
|
+
}));
|
|
3444
|
+
out.push(options);
|
|
3445
|
+
} else {
|
|
3446
|
+
out.push(buildCloudGroup());
|
|
3447
|
+
}
|
|
3448
|
+
out.push(buildAdvanced());
|
|
3449
|
+
if (scanning) {
|
|
3450
|
+
const line = el("div", "ai-wizard-quiet", "Checking this machine again\u2026");
|
|
3451
|
+
line.setAttribute("role", "status");
|
|
3452
|
+
out.push(line);
|
|
3453
|
+
}
|
|
3454
|
+
return out;
|
|
3455
|
+
}
|
|
3456
|
+
function buildCloudGroup(o = {}) {
|
|
3457
|
+
const section = el("section", "ai-wizard-group");
|
|
3458
|
+
const headingId = nextId("ai-wizard-group");
|
|
3459
|
+
if (o.title !== false) {
|
|
3460
|
+
const h = el("div", "ai-cards-heading");
|
|
3461
|
+
h.id = headingId;
|
|
3462
|
+
h.innerHTML = ICONS.cloud;
|
|
3463
|
+
h.append(el("span", "", "Connect a cloud provider"));
|
|
3464
|
+
section.append(h, el("div", "ai-cards-explainer", "Paste a key from the provider. It bills your account for what you use."));
|
|
3465
|
+
section.setAttribute("aria-labelledby", headingId);
|
|
3466
|
+
}
|
|
3467
|
+
const access = el("div", "ai-cloud-access ai-wizard-cloud-access");
|
|
3468
|
+
access.append(
|
|
3469
|
+
el("p", "ai-wizard-cloud-line", ai.requestCloudConsent ? "Choosing a provider turns on cloud engines; MikeDown still asks before the first request in each workspace." : "Choosing a provider turns on cloud engines."),
|
|
3470
|
+
// No policy links here: the chosen provider's note and the consent dialog carry its own.
|
|
3471
|
+
privacyCard(null, {
|
|
3472
|
+
collapsed: "What this means",
|
|
3473
|
+
open: cloudPrivacyOpen,
|
|
3474
|
+
onToggle: (open) => {
|
|
3475
|
+
cloudPrivacyOpen = open;
|
|
3476
|
+
},
|
|
3477
|
+
openLink: ctx.openLink,
|
|
3478
|
+
// The line above already says the host asks first.
|
|
3479
|
+
...ai.requestCloudConsent ? {} : { extra: aiConsentStepNote(false) },
|
|
3480
|
+
className: "ai-cloud-access-privacy"
|
|
3481
|
+
})
|
|
3482
|
+
);
|
|
3483
|
+
section.append(access);
|
|
3484
|
+
const grid = el("div", "ai-cards-grid");
|
|
3485
|
+
grid.setAttribute("role", "group");
|
|
3486
|
+
grid.setAttribute("aria-label", "Cloud providers");
|
|
3487
|
+
const picked = currentEngine();
|
|
3488
|
+
const pickedPreset = picked ? presetForEngine(picked)?.id : void 0;
|
|
3489
|
+
for (const preset of groups.cloud) {
|
|
3490
|
+
const card = button("ai-card", "", { fk: `wiz:card:${preset.id}` });
|
|
3491
|
+
card.innerHTML = kindGlyphHtml(preset.kind);
|
|
3492
|
+
const text = el("span", "ai-card-text");
|
|
3493
|
+
text.append(el("span", "ai-card-name", preset.name), el("span", "ai-card-caption", preset.caption));
|
|
3494
|
+
card.append(text);
|
|
3495
|
+
const on = step === "cloud" && pickedPreset === preset.id;
|
|
3496
|
+
card.setAttribute("aria-pressed", String(on));
|
|
3497
|
+
card.classList.toggle("is-selected", on);
|
|
3498
|
+
card.addEventListener("click", () => pick(preset));
|
|
3499
|
+
grid.append(card);
|
|
3500
|
+
}
|
|
3501
|
+
section.append(grid);
|
|
3502
|
+
return section;
|
|
3503
|
+
}
|
|
3504
|
+
function buildAdvanced() {
|
|
3505
|
+
const box = el("div", "ai-wizard-advanced");
|
|
3506
|
+
const panelId = nextId("ai-wizard-advanced");
|
|
3507
|
+
const toggle = button("ai-disclosure", "Advanced: run models on this machine", { html: ICONS.chevron, fk: "wiz:advanced" });
|
|
3508
|
+
toggle.setAttribute("aria-expanded", String(advancedOpen));
|
|
3509
|
+
toggle.setAttribute("aria-controls", panelId);
|
|
3510
|
+
toggle.addEventListener("click", () => {
|
|
3511
|
+
advancedOpen = !advancedOpen;
|
|
3512
|
+
render();
|
|
3513
|
+
});
|
|
3514
|
+
const panel = el("div", "ai-wizard-advanced-panel");
|
|
3515
|
+
panel.id = panelId;
|
|
3516
|
+
panel.hidden = !advancedOpen;
|
|
3517
|
+
const hw = scan?.recommendation.hardwareLine;
|
|
3518
|
+
if (hw) {
|
|
3519
|
+
panel.append(el("p", "ai-wizard-hardware", hw));
|
|
3520
|
+
}
|
|
3521
|
+
panel.append(el("p", "ai-wizard-quiet", "Your own model server, such as LM Studio or Ollama. Text never leaves this machine."));
|
|
3522
|
+
const choose = button("settings-btn", "Choose a local server", { fk: "wiz:local" });
|
|
3523
|
+
choose.addEventListener("click", () => go("local"));
|
|
3524
|
+
panel.append(choose);
|
|
3525
|
+
box.append(toggle, panel);
|
|
3526
|
+
return box;
|
|
3527
|
+
}
|
|
3528
|
+
function formHolder() {
|
|
3529
|
+
const engine = currentEngine();
|
|
3530
|
+
if (!engine) {
|
|
3531
|
+
return null;
|
|
3532
|
+
}
|
|
3533
|
+
const holder = el("div", "ai-wizard-form");
|
|
3534
|
+
if (connectError) {
|
|
3535
|
+
holder.append(note("error", connectError, "ai-wizard-error"));
|
|
3536
|
+
}
|
|
3537
|
+
holder.append(buildForm(engine.id));
|
|
3538
|
+
return holder;
|
|
3539
|
+
}
|
|
3540
|
+
function buildForm(id) {
|
|
3541
|
+
return form.build(id, { privacy: step !== "cloud", setup: true });
|
|
3542
|
+
}
|
|
3543
|
+
function footer(o) {
|
|
3544
|
+
const bar = el("div", "ai-wizard-footer");
|
|
3545
|
+
const hint = el("span", "ai-wizard-hint", o.ready ? "" : o.hint ?? "");
|
|
3546
|
+
hint.id = nextId("ai-wizard-hint");
|
|
3547
|
+
const primary = button("settings-btn settings-btn--primary ai-wizard-next", o.label, { fk: o.fk });
|
|
3548
|
+
if (!o.ready) {
|
|
3549
|
+
primary.setAttribute("aria-disabled", "true");
|
|
3550
|
+
primary.setAttribute("aria-describedby", hint.id);
|
|
3551
|
+
}
|
|
3552
|
+
primary.addEventListener("click", () => {
|
|
3553
|
+
if (o.ready) {
|
|
3554
|
+
o.onClick();
|
|
3555
|
+
} else if (o.hint) {
|
|
3556
|
+
deps.announce(o.hint);
|
|
3557
|
+
}
|
|
3558
|
+
});
|
|
3559
|
+
bar.append(hint, primary);
|
|
3560
|
+
return bar;
|
|
3561
|
+
}
|
|
3562
|
+
function continueFooter() {
|
|
3563
|
+
const engine = currentEngine();
|
|
3564
|
+
const blocked = engine ? cloudBlocked(engine) : false;
|
|
3565
|
+
const ready = !!engine && !!readyModel(engine) && !blocked;
|
|
3566
|
+
const hint = !engine ? step === "local" ? "Pick a server to continue." : "Pick a provider to continue." : blocked ? "Turn on Allow cloud engines to use it." : step === "cloud" && !session.hasKey(engine.id) ? "Paste your API key; the models appear, then Continue." : "Pick a chat model to continue.";
|
|
3567
|
+
return footer({ label: "Continue", fk: "wiz:continue", ready, hint, onClick: continueToDone });
|
|
3568
|
+
}
|
|
3569
|
+
function buildCloud() {
|
|
3570
|
+
const out = [
|
|
3571
|
+
heading("Connect a cloud provider", "Pick a provider and paste an API key. Text you ask about is sent to that provider."),
|
|
3572
|
+
buildCloudGroup({ title: false })
|
|
3573
|
+
];
|
|
3574
|
+
const holder = formHolder();
|
|
3575
|
+
if (holder) {
|
|
3576
|
+
out.push(holder);
|
|
3577
|
+
}
|
|
3578
|
+
out.push(continueFooter());
|
|
3579
|
+
return out;
|
|
3580
|
+
}
|
|
3581
|
+
function buildLocal() {
|
|
3582
|
+
const lede = scan?.recommendation.hardwareLine || "Your own model server, on this computer or your network. Text never leaves it.";
|
|
3583
|
+
const out = [heading("Run models on this machine", lede)];
|
|
3584
|
+
const list = el("div", "ai-wizard-servers");
|
|
3585
|
+
list.setAttribute("role", "group");
|
|
3586
|
+
list.setAttribute("aria-label", "Local servers");
|
|
3587
|
+
const cards = localCards();
|
|
3588
|
+
const byId = new Map(cards?.map((c) => [c.presetId, c]) ?? []);
|
|
3589
|
+
const ordered = cards ? [...cards.map((c) => presetById(c.presetId)).filter((p) => !!p && p.family === "local"), ...groups.local.filter((p) => !byId.has(p.id))] : groups.local;
|
|
3590
|
+
for (const preset of ordered) {
|
|
3591
|
+
list.append(buildServer(preset, byId.get(preset.id)));
|
|
3592
|
+
}
|
|
3593
|
+
out.push(list);
|
|
3594
|
+
const detect = el("div", "ai-cards-detect");
|
|
3595
|
+
const status = el("span", "ai-cards-detect-text", scanning ? AI_DETECT_LOOKING : cards && !cards.some((c) => c.running) ? AI_DETECT_NONE : "");
|
|
3596
|
+
detect.append(status);
|
|
3597
|
+
if (ai.detectLocalServers) {
|
|
3598
|
+
const again = button("ai-link-btn ai-cards-again", "Look again", { fk: "wiz:look-again" });
|
|
3599
|
+
if (scanning) {
|
|
3600
|
+
again.setAttribute("aria-disabled", "true");
|
|
3601
|
+
}
|
|
3602
|
+
again.addEventListener("click", () => {
|
|
3603
|
+
if (!scanning) {
|
|
3604
|
+
rescan();
|
|
3605
|
+
}
|
|
3606
|
+
});
|
|
3607
|
+
detect.append(again);
|
|
3608
|
+
}
|
|
3609
|
+
out.push(detect);
|
|
3610
|
+
const holder = formHolder();
|
|
3611
|
+
if (holder) {
|
|
3612
|
+
out.push(holder);
|
|
3613
|
+
}
|
|
3614
|
+
out.push(continueFooter());
|
|
3615
|
+
return out;
|
|
3616
|
+
}
|
|
3617
|
+
function buildServer(preset, found) {
|
|
3618
|
+
const row = el("div", "ai-wizard-server");
|
|
3619
|
+
const card = button("ai-card", "", { fk: `wiz:card:${preset.id}` });
|
|
3620
|
+
card.innerHTML = kindGlyphHtml(preset.kind);
|
|
3621
|
+
const text = el("span", "ai-card-text");
|
|
3622
|
+
text.append(el("span", "ai-card-name", preset.name), el("span", "ai-card-caption", preset.caption));
|
|
3623
|
+
const state = !found ? null : found.alreadyAdded && found.engineId && session.getEngine(found.engineId) ? "added" : found.running ? "running" : "stopped";
|
|
3624
|
+
if (state) {
|
|
3625
|
+
card.dataset.state = state;
|
|
3626
|
+
const line = el("span", "ai-card-state");
|
|
3627
|
+
if (state === "running") {
|
|
3628
|
+
const dot = el("span", "ai-card-dot");
|
|
3629
|
+
dot.setAttribute("aria-hidden", "true");
|
|
3630
|
+
line.append(dot);
|
|
3631
|
+
}
|
|
3632
|
+
const count = found?.models !== void 0 ? ` \xB7 ${found.models} ${found.models === 1 ? "model" : "models"}` : "";
|
|
3633
|
+
line.append(el("span", "", state === "added" ? "Added" : state === "running" ? `Running${count}` : "Not running"));
|
|
3634
|
+
text.append(line);
|
|
3635
|
+
}
|
|
3636
|
+
card.append(text);
|
|
3637
|
+
const picked = currentEngine();
|
|
3638
|
+
const on = !!picked && (picked.id === found?.engineId || added.has(picked.id) && presetForEngine(picked)?.id === preset.id);
|
|
3639
|
+
card.setAttribute("aria-pressed", String(on));
|
|
3640
|
+
card.classList.toggle("is-selected", on);
|
|
3641
|
+
card.addEventListener("click", () => pick(preset));
|
|
3642
|
+
row.append(card);
|
|
3643
|
+
if (state === "stopped" && preset.installUrl) {
|
|
3644
|
+
const help = el("div", "ai-wizard-install");
|
|
3645
|
+
help.append(hostLink(`Install ${preset.name}`, preset.installUrl, ctx.openLink, "ai-wizard-link"), el("span", "", ", then come back and choose Look again."));
|
|
3646
|
+
row.append(help);
|
|
3647
|
+
}
|
|
3648
|
+
return row;
|
|
3649
|
+
}
|
|
3650
|
+
function buildCopilot() {
|
|
3651
|
+
const out = [heading("GitHub Copilot", `Copilot\u2019s models through your Copilot plan in VS Code. No key needed. ${AI_HOST_PRIVACY.permission}`)];
|
|
3652
|
+
const holder = formHolder();
|
|
3653
|
+
if (holder) {
|
|
3654
|
+
out.push(holder);
|
|
3655
|
+
}
|
|
3656
|
+
out.push(continueFooter());
|
|
3657
|
+
return out;
|
|
3658
|
+
}
|
|
3659
|
+
function buildDone() {
|
|
3660
|
+
const engine = currentEngine();
|
|
3661
|
+
const modelId = readyModel(engine);
|
|
3662
|
+
if (!engine || !modelId) {
|
|
3663
|
+
return [heading("Set up AI", "Something changed. Go back and choose again.")];
|
|
3664
|
+
}
|
|
3665
|
+
const local = engineLocalityInfo(engine, session.draft.localNetworks).locality === "local";
|
|
3666
|
+
const model = engine.models.find((m) => m.id === modelId);
|
|
3667
|
+
const out = [];
|
|
3668
|
+
const icon = el("span", "ai-wizard-done-icon");
|
|
3669
|
+
icon.innerHTML = ICONS.check;
|
|
3670
|
+
icon.setAttribute("aria-hidden", "true");
|
|
3671
|
+
const head = heading(`Ready: ${engineDisplayName(engine)} \xB7 ${modelDisplayName(model)} \xB7 ${local ? "Local" : "Cloud"}`);
|
|
3672
|
+
head.prepend(icon);
|
|
3673
|
+
out.push(head);
|
|
3674
|
+
const chat = engine.models.filter((m) => m.enabled && !m.missing && m.capabilities.chat);
|
|
3675
|
+
if (chat.length > 1) {
|
|
3676
|
+
const row = el("div", "ai-wizard-model");
|
|
3677
|
+
const select = el("select", "settings-input ai-wizard-model-select");
|
|
3678
|
+
select.id = nextId("ai-wizard-model");
|
|
3679
|
+
select.dataset.fk = "wiz:model";
|
|
3680
|
+
for (const m of chat) {
|
|
3681
|
+
const o = el("option", "", modelDisplayName(m));
|
|
3682
|
+
o.value = m.id;
|
|
3683
|
+
select.append(o);
|
|
3684
|
+
}
|
|
3685
|
+
select.value = modelId;
|
|
3686
|
+
select.addEventListener("change", () => {
|
|
3687
|
+
makeDefault(engine.id, select.value);
|
|
3688
|
+
render();
|
|
3689
|
+
const picked = engine.models.find((m) => m.id === select.value);
|
|
3690
|
+
if (picked) {
|
|
3691
|
+
deps.announce(`Default model: ${modelDisplayName(picked)}.`);
|
|
3692
|
+
}
|
|
3693
|
+
});
|
|
3694
|
+
const label = el("label", "ai-wizard-model-label", "Model");
|
|
3695
|
+
label.htmlFor = select.id;
|
|
3696
|
+
row.append(label, select);
|
|
3697
|
+
out.push(row);
|
|
3698
|
+
}
|
|
3699
|
+
const privacy = el("p", "ai-wizard-privacy");
|
|
3700
|
+
const glyph = el("span", "ai-privacy-glyph");
|
|
3701
|
+
glyph.innerHTML = local ? ICONS.local : ICONS.cloud;
|
|
3702
|
+
privacy.append(glyph, el("span", "", privacyText(engine, local)));
|
|
3703
|
+
out.push(privacy);
|
|
3704
|
+
const more = el("p", "ai-wizard-quiet");
|
|
3705
|
+
if (!deps.turnsOn) {
|
|
3706
|
+
more.append("Choose Done to save. ");
|
|
3707
|
+
}
|
|
3708
|
+
const fine = button("ai-link-btn", "Change settings in AI Engines", { fk: "wiz:open-engine" });
|
|
3709
|
+
fine.addEventListener("click", () => deps.openEngine(engine.id));
|
|
3710
|
+
more.append(fine);
|
|
3711
|
+
out.push(more);
|
|
3712
|
+
out.push(footer({ label: deps.turnsOn ? "Turn on AI" : "Done", fk: "wiz:done", ready: true, onClick: () => deps.finish() }));
|
|
3713
|
+
return out;
|
|
3714
|
+
}
|
|
3715
|
+
function privacyText(engine, local) {
|
|
3716
|
+
if (local) {
|
|
3717
|
+
return AI_CLOUD_PRIVACY.local;
|
|
3718
|
+
}
|
|
3719
|
+
if (presetForEngine(engine)?.family === "host") {
|
|
3720
|
+
return `${AI_HOST_PRIVACY.leaves} ${aiConsentStepNote(!!ai.requestCloudConsent)}`;
|
|
3721
|
+
}
|
|
3722
|
+
const where = aiConsentHost(engine) || hostOfUrl(engine.baseUrl) || "the provider";
|
|
3723
|
+
return `Text you ask about is sent over the internet to ${where}. ${aiConsentStepNote(!!ai.requestCloudConsent)}`;
|
|
3724
|
+
}
|
|
3725
|
+
render();
|
|
3726
|
+
rescan();
|
|
3727
|
+
return {
|
|
3728
|
+
element,
|
|
3729
|
+
step: () => step,
|
|
3730
|
+
engineId: () => engineId,
|
|
3731
|
+
back() {
|
|
3732
|
+
if (connecting) {
|
|
3733
|
+
return;
|
|
3734
|
+
}
|
|
3735
|
+
const previous = history.pop();
|
|
3736
|
+
if (!previous) {
|
|
3737
|
+
deps.leave();
|
|
3738
|
+
return;
|
|
3739
|
+
}
|
|
3740
|
+
step = previous;
|
|
3741
|
+
connectError = null;
|
|
3742
|
+
render({ fresh: true });
|
|
3743
|
+
deps.onStep(step);
|
|
3744
|
+
},
|
|
3745
|
+
refresh(id, refresh) {
|
|
3746
|
+
if (disposed) {
|
|
3747
|
+
return;
|
|
3748
|
+
}
|
|
3749
|
+
if (refresh.form && id && id === engineId) {
|
|
3750
|
+
const current = column.querySelector(".ai-form");
|
|
3751
|
+
if (current) {
|
|
3752
|
+
keepFocus(current, () => {
|
|
3753
|
+
const fresh = buildForm(id);
|
|
3754
|
+
current.replaceWith(fresh);
|
|
3755
|
+
return fresh;
|
|
3756
|
+
});
|
|
3757
|
+
}
|
|
3758
|
+
}
|
|
3759
|
+
const bar = column.querySelector(".ai-wizard-footer");
|
|
3760
|
+
if (bar && step !== "done" && step !== "path") {
|
|
3761
|
+
keepFocus(bar, () => {
|
|
3762
|
+
const next = continueFooter();
|
|
3763
|
+
bar.replaceWith(next);
|
|
3764
|
+
return next;
|
|
3765
|
+
});
|
|
3766
|
+
}
|
|
3767
|
+
},
|
|
3768
|
+
focus() {
|
|
3769
|
+
const target = column.querySelector(".ai-wizard-option.is-lead") ?? column.querySelector(".ai-wizard-option, .ai-card, .ai-wizard-next") ?? column.querySelector(".ai-wizard-title");
|
|
3770
|
+
target?.focus({ preventScroll: true });
|
|
3771
|
+
},
|
|
3772
|
+
dispose() {
|
|
3773
|
+
disposed = true;
|
|
3774
|
+
scanAbort?.abort();
|
|
3775
|
+
element.remove();
|
|
3776
|
+
}
|
|
3777
|
+
};
|
|
3778
|
+
}
|
|
3779
|
+
|
|
3780
|
+
// src/ai-engines-page.ts
|
|
3781
|
+
var NARROW_PX = 720;
|
|
3782
|
+
function mountAiEnginesPage(container, ctx, opts) {
|
|
3783
|
+
const session = createAiSettingsSession(opts);
|
|
3784
|
+
const { ai } = session;
|
|
3785
|
+
const getEngine = (id) => session.getEngine(id);
|
|
3786
|
+
let wizard = null;
|
|
3787
|
+
let hardware = knownHardware(ai);
|
|
3788
|
+
let selection = initialSelection();
|
|
3789
|
+
let narrow = false;
|
|
3790
|
+
let view = opts.initial?.engineId || opts.initial?.section === "routing" ? "detail" : "list";
|
|
3791
|
+
let confirmDeleteId = null;
|
|
3792
|
+
let confirmDiscard = false;
|
|
3793
|
+
let finished = false;
|
|
3794
|
+
const headNotes = /* @__PURE__ */ new Map();
|
|
3795
|
+
function initialSelection() {
|
|
3796
|
+
const engines = session.draft.engines;
|
|
3797
|
+
if (opts.initial?.section === "routing") {
|
|
3798
|
+
return { kind: "routing" };
|
|
3799
|
+
}
|
|
3800
|
+
const wanted = opts.initial?.engineId && engines.find((e) => e.id === opts.initial?.engineId);
|
|
3801
|
+
if (wanted) {
|
|
3802
|
+
return { kind: "engine", id: wanted.id };
|
|
3803
|
+
}
|
|
3804
|
+
return engines[0] ? { kind: "engine", id: engines[0].id } : { kind: "add" };
|
|
3805
|
+
}
|
|
3806
|
+
const selectedEngineId = () => selection.kind === "engine" ? selection.id : null;
|
|
3807
|
+
const root = el("div", "ai-page");
|
|
3808
|
+
const titleId = nextId("ai-page-title");
|
|
3809
|
+
const header = el("div", "ai-page-header");
|
|
3810
|
+
const backBtn = button("ai-page-back", "Back", { html: ICONS.back, fk: "page:back" });
|
|
3811
|
+
backBtn.addEventListener("click", () => back());
|
|
3812
|
+
const title = el("h2", "ai-page-title", "AI Engines");
|
|
3813
|
+
title.id = titleId;
|
|
3814
|
+
const doneBtn = button("settings-btn settings-btn--primary ai-page-done", "Done", { fk: "page:done" });
|
|
3815
|
+
doneBtn.addEventListener("click", () => done());
|
|
3816
|
+
const skipBtn = button("settings-btn ai-page-done ai-page-skip", "Skip", { fk: "page:skip", title: "Leave setup; AI stays off" });
|
|
3817
|
+
skipBtn.addEventListener("click", () => leave());
|
|
3818
|
+
skipBtn.hidden = true;
|
|
3819
|
+
const titleLine = el("div", "ai-page-title-line");
|
|
3820
|
+
const titleNote = opts.turnOnWhenDone ? el("span", "ai-page-title-note", "AI turns on when you choose Done") : null;
|
|
3821
|
+
const stepper = el("span", "ai-wizard-steps");
|
|
3822
|
+
stepper.hidden = true;
|
|
3823
|
+
titleLine.append(title, experimentalBadge(), stepper);
|
|
3824
|
+
if (titleNote) {
|
|
3825
|
+
titleLine.append(titleNote);
|
|
3826
|
+
}
|
|
3827
|
+
header.append(backBtn, titleLine, doneBtn, skipBtn);
|
|
3828
|
+
const disclaimer = experimentalDisclaimer("ai-page-disclaimer");
|
|
3829
|
+
const body = el("div", "ai-page-body");
|
|
3830
|
+
const nav = el("nav", "ai-page-nav");
|
|
3831
|
+
nav.setAttribute("aria-label", "Engines and tasks");
|
|
3832
|
+
const detail = el("section", "ai-page-detail");
|
|
3833
|
+
const detailId = nextId("ai-page-detail");
|
|
3834
|
+
detail.id = detailId;
|
|
3835
|
+
body.append(nav, detail);
|
|
3836
|
+
const live = el("div", "ai-live");
|
|
3837
|
+
live.setAttribute("role", "status");
|
|
3838
|
+
live.setAttribute("aria-live", "polite");
|
|
3839
|
+
let liveTimer;
|
|
3840
|
+
function announce(text) {
|
|
3841
|
+
window.clearTimeout(liveTimer);
|
|
3842
|
+
live.textContent = "";
|
|
3843
|
+
liveTimer = window.setTimeout(() => {
|
|
3844
|
+
live.textContent = text;
|
|
3845
|
+
}, 40);
|
|
3846
|
+
}
|
|
3847
|
+
const confirmHost = el("div", "ai-page-confirm-host");
|
|
3848
|
+
root.append(header, disclaimer, body, confirmHost, live);
|
|
3849
|
+
const form = createEngineFormController({
|
|
3850
|
+
ai,
|
|
3851
|
+
getDraft: () => session.draft,
|
|
3852
|
+
setDraft: (next, refresh) => {
|
|
3853
|
+
session.draft = next;
|
|
3854
|
+
applyRefresh(wizard ? wizard.engineId() : selectedEngineId(), refresh);
|
|
3855
|
+
},
|
|
3856
|
+
refresh: (engineId, refresh) => applyRefresh(engineId, refresh),
|
|
3857
|
+
statuses: session.statuses,
|
|
3858
|
+
onStatuses: () => opts.onStatuses(session.statusList()),
|
|
3859
|
+
hasKey: (id) => session.hasKey(id),
|
|
3860
|
+
keySource: (id) => session.keySource(id),
|
|
3861
|
+
setKey,
|
|
3862
|
+
ensureSaved: (id) => session.ensureSaved(id),
|
|
3863
|
+
savesFirst: (id) => session.savesFirst(id),
|
|
3864
|
+
announce,
|
|
3865
|
+
focusKey,
|
|
3866
|
+
openLink: ctx.openLink,
|
|
3867
|
+
// The subscription vs API key note sits over a cloud key field. The wizard always shows it; the full form shows it until a key is stored.
|
|
3868
|
+
keyNote: (engine, preset) => preset.family === "cloud" && (wizard || !session.hasKey(engine.id)) ? subscriptionNote(preset, ctx.openLink, aiProviderPolicy(engine)) : null,
|
|
3869
|
+
modelSize: (modelId, sizeBytes) => hardware ? modelSizeHint(modelId, hardware, sizeBytes) : void 0
|
|
3870
|
+
});
|
|
3871
|
+
const routing = createRoutingSection(ctx, {
|
|
3872
|
+
getDraft: () => session.draft,
|
|
3873
|
+
setDraft: (next, change) => {
|
|
3874
|
+
session.draft = next;
|
|
3875
|
+
if (change.defaultChanged) {
|
|
3876
|
+
renderNav();
|
|
3877
|
+
} else {
|
|
3878
|
+
refreshRoutingEntry();
|
|
3879
|
+
}
|
|
3880
|
+
},
|
|
3881
|
+
statusList: () => session.statusList()
|
|
3882
|
+
});
|
|
3883
|
+
async function setKey(id, value) {
|
|
3884
|
+
const engine = getEngine(id);
|
|
3885
|
+
const error = await session.setKey(id, value);
|
|
3886
|
+
if (engine) {
|
|
3887
|
+
const name = engineDisplayName(engine);
|
|
3888
|
+
announce(error ? `The key for ${name} could not be ${value !== null ? "stored" : "cleared"}.` : value !== null ? `API key stored for ${name}.` : `API key cleared for ${name}.`);
|
|
3889
|
+
refreshHead(id);
|
|
3890
|
+
}
|
|
3891
|
+
return error;
|
|
3892
|
+
}
|
|
3893
|
+
function applyRefresh(engineId, refresh) {
|
|
3894
|
+
if (wizard) {
|
|
3895
|
+
wizard.refresh(engineId, refresh);
|
|
3896
|
+
return;
|
|
3897
|
+
}
|
|
3898
|
+
if (refresh.list) {
|
|
3899
|
+
renderNav();
|
|
3900
|
+
renderDetailHead();
|
|
3901
|
+
} else if (refresh.head) {
|
|
3902
|
+
for (const e of session.draft.engines) {
|
|
3903
|
+
refreshNavItem(e.id);
|
|
3904
|
+
}
|
|
3905
|
+
refreshRoutingEntry();
|
|
3906
|
+
renderDetailHead();
|
|
3907
|
+
}
|
|
3908
|
+
if (refresh.form && engineId) {
|
|
3909
|
+
rerenderForm(engineId);
|
|
3910
|
+
}
|
|
3911
|
+
if (refresh.routing || refresh.list) {
|
|
3912
|
+
scheduleRouting();
|
|
3913
|
+
}
|
|
3914
|
+
}
|
|
3915
|
+
let routingQueued = false;
|
|
3916
|
+
function scheduleRouting() {
|
|
3917
|
+
if (routingQueued) {
|
|
3918
|
+
return;
|
|
3919
|
+
}
|
|
3920
|
+
routingQueued = true;
|
|
3921
|
+
queueMicrotask(() => {
|
|
3922
|
+
routingQueued = false;
|
|
3923
|
+
if (finished || wizard) {
|
|
3924
|
+
return;
|
|
3925
|
+
}
|
|
3926
|
+
refreshRoutingEntry();
|
|
3927
|
+
if (selection.kind === "routing") {
|
|
3928
|
+
routing.render();
|
|
3929
|
+
}
|
|
3930
|
+
});
|
|
3931
|
+
}
|
|
3932
|
+
function refreshHead(engineId) {
|
|
3933
|
+
refreshNavItem(engineId);
|
|
3934
|
+
if (selectedEngineId() === engineId) {
|
|
3935
|
+
renderDetailHead();
|
|
3936
|
+
}
|
|
3937
|
+
}
|
|
3938
|
+
function focusKey(key) {
|
|
3939
|
+
const n = Array.from(root.querySelectorAll("[data-fk]")).find((x) => x.dataset.fk === key);
|
|
3940
|
+
n?.focus({ preventScroll: true });
|
|
3941
|
+
}
|
|
3942
|
+
const navList = el("div", "ai-nav-list");
|
|
3943
|
+
navList.setAttribute("role", "list");
|
|
3944
|
+
navList.setAttribute("aria-label", "Engines");
|
|
3945
|
+
const navEngines = el("div", "ai-nav-group");
|
|
3946
|
+
const routingEntry = el("div", "ai-nav-group ai-nav-group--tools");
|
|
3947
|
+
nav.append(navEngines, routingEntry);
|
|
3948
|
+
nav.addEventListener("keydown", onNavKeydown);
|
|
3949
|
+
function navButton(fk, selected) {
|
|
3950
|
+
const b = button("ai-nav-btn", "", { fk });
|
|
3951
|
+
b.setAttribute("aria-controls", detailId);
|
|
3952
|
+
if (selected) {
|
|
3953
|
+
b.setAttribute("aria-current", "true");
|
|
3954
|
+
}
|
|
3955
|
+
return b;
|
|
3956
|
+
}
|
|
3957
|
+
function renderNav() {
|
|
3958
|
+
if (wizard) {
|
|
3959
|
+
return;
|
|
3960
|
+
}
|
|
3961
|
+
keepFocus(nav, () => {
|
|
3962
|
+
navEngines.replaceChildren();
|
|
3963
|
+
const heading = el("div", "ai-nav-heading", "Engines");
|
|
3964
|
+
heading.setAttribute("aria-hidden", "true");
|
|
3965
|
+
navEngines.append(heading);
|
|
3966
|
+
navList.replaceChildren();
|
|
3967
|
+
if (session.draft.engines.length === 0) {
|
|
3968
|
+
navEngines.append(el("div", "ai-nav-empty", "No engines yet"));
|
|
3969
|
+
} else {
|
|
3970
|
+
for (const engine of session.draft.engines) {
|
|
3971
|
+
navList.append(buildNavItem(engine));
|
|
3972
|
+
}
|
|
3973
|
+
navEngines.append(navList);
|
|
3974
|
+
}
|
|
3975
|
+
routingEntry.replaceChildren(buildRoutingEntry(), buildAddEntry());
|
|
3976
|
+
});
|
|
3977
|
+
}
|
|
3978
|
+
function buildNavItem(engine) {
|
|
3979
|
+
const item = el("div", "ai-nav-item");
|
|
3980
|
+
item.setAttribute("role", "listitem");
|
|
3981
|
+
item.dataset.engineId = engine.id;
|
|
3982
|
+
fillNavItem(item, engine);
|
|
3983
|
+
return item;
|
|
3984
|
+
}
|
|
3985
|
+
function fillNavItem(item, engine) {
|
|
3986
|
+
item.replaceChildren();
|
|
3987
|
+
const selected = selection.kind === "engine" && selection.id === engine.id;
|
|
3988
|
+
const name = engineDisplayName(engine);
|
|
3989
|
+
item.classList.toggle("is-selected", selected);
|
|
3990
|
+
item.classList.toggle("is-disabled", !engine.enabled);
|
|
3991
|
+
const btn = navButton(`nav:eng:${engine.id}`, selected);
|
|
3992
|
+
btn.setAttribute("aria-label", `${name}, ${engineKindLabel(engine.kind)}`);
|
|
3993
|
+
btn.title = name;
|
|
3994
|
+
btn.innerHTML = kindGlyphHtml(engine.kind);
|
|
3995
|
+
const text = el("span", "ai-nav-text");
|
|
3996
|
+
const line = el("span", "ai-nav-name-line");
|
|
3997
|
+
line.append(el("span", "ai-nav-name", name));
|
|
3998
|
+
const isDefault = session.draft.routing.default?.engineId === engine.id;
|
|
3999
|
+
if (isDefault) {
|
|
4000
|
+
const star = el("span", "ai-nav-star");
|
|
4001
|
+
star.innerHTML = ICONS.starOn;
|
|
4002
|
+
star.title = "Default engine";
|
|
4003
|
+
line.append(star);
|
|
4004
|
+
}
|
|
4005
|
+
const subLine = el("span", "ai-nav-sub-line");
|
|
4006
|
+
subLine.append(...localityPills(engine, session.draft).slice(0, 1), el("span", "ai-nav-sub", shortUrl(engine.baseUrl) || "no address"));
|
|
4007
|
+
text.append(line, subLine);
|
|
4008
|
+
btn.append(text, statusBadge(session.statuses.get(engine.id), form.busy(engine.id), { compact: true }));
|
|
4009
|
+
btn.addEventListener("click", () => select({ kind: "engine", id: engine.id }));
|
|
4010
|
+
const enabled = switchInput(engine.enabled, `Enable ${name}`, `nav:eng:${engine.id}:enabled`);
|
|
4011
|
+
enabled.addEventListener("change", () => {
|
|
4012
|
+
session.updateEngine(engine.id, { enabled: enabled.checked });
|
|
4013
|
+
applyRefresh(engine.id, { list: true, form: true, routing: true });
|
|
4014
|
+
});
|
|
4015
|
+
item.append(btn, enabled);
|
|
4016
|
+
}
|
|
4017
|
+
function refreshNavItem(engineId) {
|
|
4018
|
+
if (wizard) {
|
|
4019
|
+
return;
|
|
4020
|
+
}
|
|
4021
|
+
const engine = getEngine(engineId);
|
|
4022
|
+
const item = Array.from(navList.querySelectorAll(".ai-nav-item")).find((n) => n.dataset.engineId === engineId);
|
|
4023
|
+
if (!engine || !item) {
|
|
4024
|
+
return;
|
|
4025
|
+
}
|
|
4026
|
+
keepFocus(item, () => fillNavItem(item, engine));
|
|
4027
|
+
}
|
|
4028
|
+
function routingSubline() {
|
|
4029
|
+
const d = session.draft.routing.default;
|
|
4030
|
+
return d ? routeLabel(session.draft, d) : "No default model";
|
|
4031
|
+
}
|
|
4032
|
+
function buildRoutingEntry() {
|
|
4033
|
+
const btn = navButton("nav:routing", selection.kind === "routing");
|
|
4034
|
+
btn.classList.add("ai-nav-btn--routing");
|
|
4035
|
+
const icon = el("span", "ai-nav-icon");
|
|
4036
|
+
icon.innerHTML = ICONS.routing;
|
|
4037
|
+
const text = el("span", "ai-nav-text");
|
|
4038
|
+
text.append(el("span", "ai-nav-name", "Tasks"), el("span", "ai-nav-sub", routingSubline()));
|
|
4039
|
+
btn.append(icon, text);
|
|
4040
|
+
btn.addEventListener("click", () => select({ kind: "routing" }));
|
|
4041
|
+
return btn;
|
|
4042
|
+
}
|
|
4043
|
+
function refreshRoutingEntry() {
|
|
4044
|
+
const sub = routingEntry.querySelector(".ai-nav-btn--routing .ai-nav-sub");
|
|
4045
|
+
if (sub) {
|
|
4046
|
+
sub.textContent = routingSubline();
|
|
4047
|
+
}
|
|
4048
|
+
}
|
|
4049
|
+
function buildAddEntry() {
|
|
4050
|
+
const btn = navButton("nav:add", selection.kind === "add");
|
|
4051
|
+
btn.classList.add("ai-nav-btn--add");
|
|
4052
|
+
const icon = el("span", "ai-nav-icon");
|
|
4053
|
+
icon.innerHTML = ICONS.plus;
|
|
4054
|
+
btn.append(icon, el("span", "ai-nav-name", "Add engine"));
|
|
4055
|
+
btn.addEventListener("click", () => select({ kind: "add" }));
|
|
4056
|
+
return btn;
|
|
4057
|
+
}
|
|
4058
|
+
function onNavKeydown(e) {
|
|
4059
|
+
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) {
|
|
4060
|
+
return;
|
|
4061
|
+
}
|
|
4062
|
+
const buttons = Array.from(nav.querySelectorAll(".ai-nav-btn"));
|
|
4063
|
+
const at = buttons.indexOf(e.target);
|
|
4064
|
+
if (at < 0) {
|
|
4065
|
+
return;
|
|
4066
|
+
}
|
|
4067
|
+
e.preventDefault();
|
|
4068
|
+
const next = e.key === "Home" ? 0 : e.key === "End" ? buttons.length - 1 : Math.max(0, Math.min(buttons.length - 1, at + (e.key === "ArrowDown" ? 1 : -1)));
|
|
4069
|
+
buttons[next]?.focus();
|
|
4070
|
+
}
|
|
4071
|
+
const detailHead = el("div", "ai-detail-head");
|
|
4072
|
+
const detailBody = el("div", "ai-detail-body");
|
|
4073
|
+
detail.append(detailHead, detailBody);
|
|
4074
|
+
function select(next, o = {}) {
|
|
4075
|
+
const sameEngine = next.kind === "engine" && selection.kind === "engine" && selection.id === next.id;
|
|
4076
|
+
selection = next;
|
|
4077
|
+
confirmDeleteId = null;
|
|
4078
|
+
if (next.kind === "engine") {
|
|
4079
|
+
headNotes.delete(next.id);
|
|
4080
|
+
}
|
|
4081
|
+
if (narrow) {
|
|
4082
|
+
view = "detail";
|
|
4083
|
+
}
|
|
4084
|
+
applyLayout();
|
|
4085
|
+
renderNav();
|
|
4086
|
+
if (!sameEngine || o.isNew) {
|
|
4087
|
+
renderDetail();
|
|
4088
|
+
}
|
|
4089
|
+
let first = null;
|
|
4090
|
+
if (next.kind === "engine" && (!sameEngine || o.isNew)) {
|
|
4091
|
+
first = form.opened(next.id, o);
|
|
4092
|
+
}
|
|
4093
|
+
if (o.isNew && first) {
|
|
4094
|
+
focusKey(first);
|
|
4095
|
+
if (first.endsWith(":url")) {
|
|
4096
|
+
const input = root.querySelector(`[data-fk="${first}"]`);
|
|
4097
|
+
input?.select();
|
|
4098
|
+
}
|
|
4099
|
+
} else if (o.isNew || narrow) {
|
|
4100
|
+
focusDetailTitle();
|
|
4101
|
+
}
|
|
4102
|
+
detail.scrollTop = 0;
|
|
4103
|
+
}
|
|
4104
|
+
function focusDetailTitle() {
|
|
4105
|
+
detailHead.querySelector(".ai-detail-title")?.focus({ preventScroll: true });
|
|
4106
|
+
}
|
|
4107
|
+
function renderDetail() {
|
|
4108
|
+
if (wizard) {
|
|
4109
|
+
return;
|
|
4110
|
+
}
|
|
4111
|
+
renderDetailHead();
|
|
4112
|
+
detailBody.replaceChildren();
|
|
4113
|
+
detail.dataset.kind = selection.kind;
|
|
4114
|
+
if (selection.kind === "engine") {
|
|
4115
|
+
const engine = getEngine(selection.id);
|
|
4116
|
+
if (engine) {
|
|
4117
|
+
detailBody.append(form.build(engine.id));
|
|
4118
|
+
}
|
|
4119
|
+
} else if (selection.kind === "routing") {
|
|
4120
|
+
routing.render();
|
|
4121
|
+
detailBody.append(routing.element);
|
|
4122
|
+
} else {
|
|
4123
|
+
detailBody.append(buildPresetCards());
|
|
4124
|
+
if (canDetect && !detectedOnce) {
|
|
4125
|
+
lookForLocalServers();
|
|
4126
|
+
}
|
|
4127
|
+
if (ai.detectHostEngines && hostAvailable === null) {
|
|
4128
|
+
lookForHostEngines();
|
|
4129
|
+
}
|
|
4130
|
+
}
|
|
4131
|
+
}
|
|
4132
|
+
function detailTitle(text) {
|
|
4133
|
+
const h = el("h3", "ai-detail-title", text);
|
|
4134
|
+
h.id = nextId("ai-detail-title");
|
|
4135
|
+
h.tabIndex = -1;
|
|
4136
|
+
detail.setAttribute("aria-labelledby", h.id);
|
|
4137
|
+
return h;
|
|
4138
|
+
}
|
|
4139
|
+
function renderDetailHead() {
|
|
4140
|
+
if (wizard) {
|
|
4141
|
+
return;
|
|
4142
|
+
}
|
|
4143
|
+
keepFocus(detailHead, () => {
|
|
4144
|
+
detailHead.replaceChildren();
|
|
4145
|
+
if (selection.kind === "routing") {
|
|
4146
|
+
const icon = el("span", "ai-detail-icon");
|
|
4147
|
+
icon.innerHTML = ICONS.routing;
|
|
4148
|
+
const main = el("div", "ai-detail-main");
|
|
4149
|
+
main.append(detailTitle("Tasks"), el("div", "ai-detail-sub", "Which model does what. Tasks on Default use the default model."));
|
|
4150
|
+
detailHead.append(icon, main);
|
|
4151
|
+
return;
|
|
4152
|
+
}
|
|
4153
|
+
if (selection.kind === "add") {
|
|
4154
|
+
const icon = el("span", "ai-detail-icon");
|
|
4155
|
+
icon.innerHTML = ICONS.plus;
|
|
4156
|
+
const main = el("div", "ai-detail-main");
|
|
4157
|
+
const empty = session.draft.engines.length === 0;
|
|
4158
|
+
main.append(
|
|
4159
|
+
detailTitle(empty ? "No engines yet" : "Add an engine"),
|
|
4160
|
+
el("div", "ai-detail-sub", `Two ways to connect: paste a cloud API key, or run models on your own machine.${session.draft.enabled ? "" : " Nothing is sent until AI is on."}`)
|
|
4161
|
+
);
|
|
4162
|
+
detailHead.append(icon, main);
|
|
4163
|
+
return;
|
|
4164
|
+
}
|
|
4165
|
+
const engine = getEngine(selection.id);
|
|
4166
|
+
if (!engine) {
|
|
4167
|
+
return;
|
|
4168
|
+
}
|
|
4169
|
+
fillEngineHead(engine);
|
|
4170
|
+
});
|
|
4171
|
+
}
|
|
4172
|
+
function fillEngineHead(engine) {
|
|
4173
|
+
const name = engineDisplayName(engine);
|
|
4174
|
+
const glyph = el("span", "ai-detail-glyph");
|
|
4175
|
+
glyph.innerHTML = kindGlyphHtml(engine.kind);
|
|
4176
|
+
const main = el("div", "ai-detail-main");
|
|
4177
|
+
const titleLine2 = el("div", "ai-detail-title-line");
|
|
4178
|
+
titleLine2.append(detailTitle(name), ...localityPills(engine, session.draft, { blocked: false }));
|
|
4179
|
+
const models = engine.models.filter((m) => !m.missing).length;
|
|
4180
|
+
const picked = engine.models.filter((m) => !m.missing && m.enabled).length;
|
|
4181
|
+
const subParts = [engineKindLabel(engine.kind), shortUrl(engine.baseUrl) || "no address"];
|
|
4182
|
+
if (engine.models.length > 0) {
|
|
4183
|
+
subParts.push(picked === models ? `${models} ${models === 1 ? "model" : "models"}` : `${picked} of ${models} models`);
|
|
4184
|
+
}
|
|
4185
|
+
main.append(titleLine2, el("div", "ai-detail-sub", subParts.join(" \xB7 ")));
|
|
4186
|
+
const isDefault = session.draft.routing.default?.engineId === engine.id;
|
|
4187
|
+
let star;
|
|
4188
|
+
if (isDefault) {
|
|
4189
|
+
star = el("span", "ai-default-badge");
|
|
4190
|
+
star.innerHTML = ICONS.starOn;
|
|
4191
|
+
star.append(el("span", "ai-btn-text", "Default"));
|
|
4192
|
+
star.title = `Default: ${routeLabel(session.draft, session.draft.routing.default)}. To change it, choose Use by default on another engine, or pick the default model under Tasks.`;
|
|
4193
|
+
star.setAttribute("aria-label", `${name} is the default engine`);
|
|
4194
|
+
star.dataset.fk = `eng:${engine.id}:star`;
|
|
4195
|
+
star.tabIndex = -1;
|
|
4196
|
+
} else {
|
|
4197
|
+
star = button("settings-btn ai-default-btn", "Use by default", {
|
|
4198
|
+
html: ICONS.starOff,
|
|
4199
|
+
title: "Use this engine by default",
|
|
4200
|
+
fk: `eng:${engine.id}:star`
|
|
4201
|
+
});
|
|
4202
|
+
star.addEventListener("click", () => makeDefault(engine.id));
|
|
4203
|
+
}
|
|
4204
|
+
const del = button("ai-icon-btn ai-delete-btn", "", { html: ICONS.trash, ariaLabel: `Delete ${name}`, title: "Delete engine", fk: `eng:${engine.id}:delete` });
|
|
4205
|
+
del.addEventListener("click", () => {
|
|
4206
|
+
confirmDeleteId = engine.id;
|
|
4207
|
+
renderDetailHead();
|
|
4208
|
+
focusKey(`eng:${engine.id}:confirm-cancel`);
|
|
4209
|
+
});
|
|
4210
|
+
const actions = el("div", "ai-detail-actions");
|
|
4211
|
+
actions.append(statusBadge(session.statuses.get(engine.id), form.busy(engine.id)), star, del);
|
|
4212
|
+
const top = el("div", "ai-detail-top");
|
|
4213
|
+
top.append(glyph, main, actions);
|
|
4214
|
+
detailHead.append(top);
|
|
4215
|
+
const issues = engineIssues(engine, session.draft, session.hasKey(engine.id)).filter((i) => !["cloudBlocked", "badUrl", "localityMismatch", "noKey"].includes(i.code));
|
|
4216
|
+
const headNote = headNotes.get(engine.id);
|
|
4217
|
+
if (issues.length > 0 || headNote) {
|
|
4218
|
+
const box = el("div", "ai-engine-issues");
|
|
4219
|
+
for (const i of issues) {
|
|
4220
|
+
box.append(note(i.level, i.text));
|
|
4221
|
+
}
|
|
4222
|
+
if (headNote) {
|
|
4223
|
+
box.append(note(headNote.level, headNote.text, "ai-result"));
|
|
4224
|
+
}
|
|
4225
|
+
detailHead.append(box);
|
|
4226
|
+
}
|
|
4227
|
+
if (cloudBlocked(engine)) {
|
|
4228
|
+
detailHead.append(buildCloudOffNote(engine));
|
|
4229
|
+
}
|
|
4230
|
+
if (confirmDeleteId === engine.id) {
|
|
4231
|
+
detailHead.append(buildDeleteConfirm(engine));
|
|
4232
|
+
}
|
|
4233
|
+
}
|
|
4234
|
+
function cloudBlocked(engine) {
|
|
4235
|
+
return !session.draft.allowCloud && engineLocalityInfo(engine, session.draft.localNetworks).locality === "cloud";
|
|
4236
|
+
}
|
|
4237
|
+
function buildCloudOffNote(engine) {
|
|
4238
|
+
const box = el("div", "ai-cloud-off");
|
|
4239
|
+
box.append(note("info", `Cloud engines are off, so nothing is sent to ${engineDisplayName(engine)} yet.`));
|
|
4240
|
+
const on = button("settings-btn ai-cloud-off-btn", "Turn on cloud engines", { fk: `eng:${engine.id}:allow-cloud` });
|
|
4241
|
+
on.addEventListener("click", () => {
|
|
4242
|
+
setAllowCloud(true);
|
|
4243
|
+
headNotes.set(engine.id, { level: "info", text: "Cloud engines are on. Choose Done to save." });
|
|
4244
|
+
renderDetailHead();
|
|
4245
|
+
focusKey(`eng:${engine.id}:star`);
|
|
4246
|
+
});
|
|
4247
|
+
box.append(on);
|
|
4248
|
+
return box;
|
|
4249
|
+
}
|
|
4250
|
+
function setAllowCloud(on) {
|
|
4251
|
+
if (session.draft.allowCloud === on) {
|
|
4252
|
+
return;
|
|
4253
|
+
}
|
|
4254
|
+
session.draft = { ...session.draft, allowCloud: on };
|
|
4255
|
+
renderNav();
|
|
4256
|
+
const formEngine = wizard ? wizard.engineId() : selectedEngineId();
|
|
4257
|
+
if (formEngine) {
|
|
4258
|
+
rerenderForm(formEngine);
|
|
4259
|
+
}
|
|
4260
|
+
scheduleRouting();
|
|
4261
|
+
announce(on ? "Cloud engines turned on. Choose Done to save." : "Cloud engines turned off. Choose Done to save.");
|
|
4262
|
+
}
|
|
4263
|
+
function rerenderForm(engineId) {
|
|
4264
|
+
if (wizard) {
|
|
4265
|
+
wizard.refresh(engineId, { form: true });
|
|
4266
|
+
return;
|
|
4267
|
+
}
|
|
4268
|
+
if (selectedEngineId() !== engineId) {
|
|
4269
|
+
return;
|
|
4270
|
+
}
|
|
4271
|
+
const current = detailBody.querySelector(".ai-form");
|
|
4272
|
+
if (!current) {
|
|
4273
|
+
return;
|
|
4274
|
+
}
|
|
4275
|
+
keepFocus(current, () => {
|
|
4276
|
+
const fresh = form.build(engineId);
|
|
4277
|
+
current.replaceWith(fresh);
|
|
4278
|
+
return fresh;
|
|
4279
|
+
});
|
|
4280
|
+
}
|
|
4281
|
+
function buildDeleteConfirm(engine) {
|
|
4282
|
+
const box = el("div", "ai-confirm");
|
|
4283
|
+
box.setAttribute("role", "group");
|
|
4284
|
+
const name = engineDisplayName(engine);
|
|
4285
|
+
box.setAttribute("aria-label", `Delete ${name}?`);
|
|
4286
|
+
const msg = el("span", "ai-confirm-text", `Delete ${name}? Routes to it are cleared${ai.setSecret ? " and its stored API key is removed" : ""} when you choose Done.`);
|
|
4287
|
+
const yes = button("settings-btn ai-btn--danger", "Delete", { fk: `eng:${engine.id}:confirm-delete` });
|
|
4288
|
+
const no = button("settings-btn", "Cancel", { fk: `eng:${engine.id}:confirm-cancel` });
|
|
4289
|
+
yes.addEventListener("click", () => deleteEngine(engine.id));
|
|
4290
|
+
no.addEventListener("click", () => {
|
|
4291
|
+
confirmDeleteId = null;
|
|
4292
|
+
renderDetailHead();
|
|
4293
|
+
focusKey(`eng:${engine.id}:delete`);
|
|
4294
|
+
});
|
|
4295
|
+
box.addEventListener("keydown", (e) => {
|
|
4296
|
+
if (e.key === "Escape") {
|
|
4297
|
+
e.preventDefault();
|
|
4298
|
+
e.stopPropagation();
|
|
4299
|
+
no.click();
|
|
4300
|
+
}
|
|
4301
|
+
});
|
|
4302
|
+
box.append(msg, yes, no);
|
|
4303
|
+
return box;
|
|
4304
|
+
}
|
|
4305
|
+
let probes = null;
|
|
4306
|
+
let detectedOnce = false;
|
|
4307
|
+
let detecting = false;
|
|
4308
|
+
let detectAbort = null;
|
|
4309
|
+
const canDetect = typeof ai.detectLocalServers === "function";
|
|
4310
|
+
function lookForLocalServers() {
|
|
4311
|
+
if (!canDetect || finished) {
|
|
4312
|
+
return;
|
|
4313
|
+
}
|
|
4314
|
+
detectAbort?.abort();
|
|
4315
|
+
const controller = new AbortController();
|
|
4316
|
+
detectAbort = controller;
|
|
4317
|
+
detectedOnce = true;
|
|
4318
|
+
detecting = true;
|
|
4319
|
+
refreshLocalCards();
|
|
4320
|
+
announce(AI_DETECT_LOOKING);
|
|
4321
|
+
void detectLocalServers(ai, controller.signal).then((found) => {
|
|
4322
|
+
if (finished || controller.signal.aborted) {
|
|
4323
|
+
return;
|
|
4324
|
+
}
|
|
4325
|
+
detecting = false;
|
|
4326
|
+
detectAbort = null;
|
|
4327
|
+
probes = found;
|
|
4328
|
+
refreshLocalCards();
|
|
4329
|
+
announce(detectionSummary(localCards()));
|
|
4330
|
+
});
|
|
4331
|
+
}
|
|
4332
|
+
function localCards() {
|
|
4333
|
+
return probes ? rankLocalCandidates(probes, session.draft.engines) : null;
|
|
4334
|
+
}
|
|
4335
|
+
function detectionSummary(cards) {
|
|
4336
|
+
if (!cards) {
|
|
4337
|
+
return "Could not look for local AI servers.";
|
|
4338
|
+
}
|
|
4339
|
+
const running = cards.filter((c) => c.running);
|
|
4340
|
+
if (running.length === 0) {
|
|
4341
|
+
return AI_DETECT_NONE;
|
|
4342
|
+
}
|
|
4343
|
+
return `Running on this computer: ${running.map((c) => `${presetName(c.presetId)}${c.models !== void 0 ? `, ${countText(c.models)}` : ""}${c.alreadyAdded ? ", added" : ""}`).join("; ")}.`;
|
|
4344
|
+
}
|
|
4345
|
+
function presetName(id) {
|
|
4346
|
+
return AI_ENGINE_PRESETS.find((p) => p.id === id)?.name ?? id;
|
|
4347
|
+
}
|
|
4348
|
+
function countText(models) {
|
|
4349
|
+
return `${models} ${models === 1 ? "model" : "models"}`;
|
|
4350
|
+
}
|
|
4351
|
+
let hostAvailable = null;
|
|
4352
|
+
function lookForHostEngines() {
|
|
4353
|
+
hostAvailable = false;
|
|
4354
|
+
void hostEngineAvailability(ai).then((found) => {
|
|
4355
|
+
if (finished || !found?.available) {
|
|
4356
|
+
return;
|
|
4357
|
+
}
|
|
4358
|
+
hostAvailable = true;
|
|
4359
|
+
if (selection.kind === "add" && !wizard) {
|
|
4360
|
+
keepFocus(detailBody, () => {
|
|
4361
|
+
detailBody.replaceChildren(buildPresetCards());
|
|
4362
|
+
});
|
|
4363
|
+
}
|
|
4364
|
+
});
|
|
4365
|
+
}
|
|
4366
|
+
function buildPresetCards() {
|
|
4367
|
+
const groups = groupPresets(AI_ENGINE_PRESETS);
|
|
4368
|
+
const box = el("div", "ai-cards");
|
|
4369
|
+
const families = hostAvailable ? [AI_HOST_PRESET_FAMILY, ...AI_PRESET_FAMILIES] : [...AI_PRESET_FAMILIES];
|
|
4370
|
+
for (const family of families) {
|
|
4371
|
+
const presets = groups[family.family];
|
|
4372
|
+
if (presets.length === 0) {
|
|
4373
|
+
continue;
|
|
4374
|
+
}
|
|
4375
|
+
const section = el("section", "ai-cards-group");
|
|
4376
|
+
section.dataset.family = family.family;
|
|
4377
|
+
const headingId = nextId("ai-cards-heading");
|
|
4378
|
+
const heading = el("div", "ai-cards-heading");
|
|
4379
|
+
heading.id = headingId;
|
|
4380
|
+
heading.innerHTML = family.family === "local" ? ICONS.local : ICONS.cloud;
|
|
4381
|
+
heading.append(el("span", "", family.label));
|
|
4382
|
+
section.setAttribute("aria-labelledby", headingId);
|
|
4383
|
+
const grid = el("div", "ai-cards-grid");
|
|
4384
|
+
grid.setAttribute("role", "group");
|
|
4385
|
+
grid.setAttribute("aria-labelledby", headingId);
|
|
4386
|
+
section.append(heading, el("div", "ai-cards-explainer", family.explainer));
|
|
4387
|
+
if (family.family === "local") {
|
|
4388
|
+
section.append(grid);
|
|
4389
|
+
fillLocalGrid(grid, presets);
|
|
4390
|
+
if (canDetect) {
|
|
4391
|
+
section.append(buildDetectLine());
|
|
4392
|
+
}
|
|
4393
|
+
} else if (family.family === "host") {
|
|
4394
|
+
section.append(grid);
|
|
4395
|
+
for (const preset of presets) {
|
|
4396
|
+
grid.append(buildCard(preset));
|
|
4397
|
+
}
|
|
4398
|
+
} else {
|
|
4399
|
+
section.append(buildCloudAccess(presets), grid);
|
|
4400
|
+
for (const preset of presets) {
|
|
4401
|
+
grid.append(buildCard(preset));
|
|
4402
|
+
}
|
|
4403
|
+
}
|
|
4404
|
+
box.append(section);
|
|
4405
|
+
}
|
|
4406
|
+
placeGroups(box);
|
|
4407
|
+
return box;
|
|
4408
|
+
}
|
|
4409
|
+
function placeGroups(box) {
|
|
4410
|
+
const localLeads = !!localCards()?.some((c) => c.running && !c.alreadyAdded);
|
|
4411
|
+
const order = localLeads ? ["host", "local", "cloud"] : ["host", "cloud", "local"];
|
|
4412
|
+
for (const family of order) {
|
|
4413
|
+
const section = box.querySelector(`:scope > .ai-cards-group[data-family="${family}"]`);
|
|
4414
|
+
if (section) {
|
|
4415
|
+
box.append(section);
|
|
4416
|
+
}
|
|
4417
|
+
}
|
|
4418
|
+
}
|
|
4419
|
+
let cloudPrivacyOpen = false;
|
|
4420
|
+
function buildCloudAccess(presets) {
|
|
4421
|
+
return cloudAccessBox({
|
|
4422
|
+
allowed: session.draft.allowCloud,
|
|
4423
|
+
onChange: (on) => setAllowCloud(on),
|
|
4424
|
+
presets,
|
|
4425
|
+
hostAsks: !!ai.requestCloudConsent,
|
|
4426
|
+
open: cloudPrivacyOpen,
|
|
4427
|
+
onToggle: (open) => {
|
|
4428
|
+
cloudPrivacyOpen = open;
|
|
4429
|
+
},
|
|
4430
|
+
openLink: ctx.openLink,
|
|
4431
|
+
fk: "cards:allow-cloud"
|
|
4432
|
+
});
|
|
4433
|
+
}
|
|
4434
|
+
function fillLocalGrid(grid, presets) {
|
|
4435
|
+
grid.replaceChildren();
|
|
4436
|
+
const cards = localCards();
|
|
4437
|
+
const byId = new Map(cards?.map((c) => [c.presetId, c]) ?? []);
|
|
4438
|
+
const ordered = cards ? [...cards.map((c) => presets.find((p) => p.id === c.presetId)).filter((p) => !!p), ...presets.filter((p) => !byId.has(p.id))] : presets;
|
|
4439
|
+
for (const preset of ordered) {
|
|
4440
|
+
grid.append(buildCard(preset, byId.get(preset.id)));
|
|
4441
|
+
}
|
|
4442
|
+
}
|
|
4443
|
+
function buildCard(preset, found) {
|
|
4444
|
+
const card = button("ai-card", "", { fk: `card:${preset.id}`, ariaLabel: `Add ${preset.name}` });
|
|
4445
|
+
card.innerHTML = kindGlyphHtml(preset.kind);
|
|
4446
|
+
const text = el("span", "ai-card-text");
|
|
4447
|
+
text.append(el("span", "ai-card-name", preset.name), el("span", "ai-card-caption", preset.caption));
|
|
4448
|
+
card.append(text);
|
|
4449
|
+
card.title = preset.caption;
|
|
4450
|
+
const cloudEngine = preset.family === "cloud" ? session.draft.engines.find((e) => presetForEngine(e)?.id === preset.id) : void 0;
|
|
4451
|
+
const added = cloudEngine ? cloudEngine.id : found?.alreadyAdded && found.engineId && getEngine(found.engineId) ? found.engineId : null;
|
|
4452
|
+
if (cloudEngine) {
|
|
4453
|
+
card.dataset.state = "added";
|
|
4454
|
+
text.append(el("span", "ai-card-state", "Added"));
|
|
4455
|
+
card.setAttribute("aria-label", `${preset.name}, added. Open it`);
|
|
4456
|
+
card.title = `${preset.name} is already set up. Open it.`;
|
|
4457
|
+
} else if (found) {
|
|
4458
|
+
const state = added ? "added" : found.running ? "running" : "stopped";
|
|
4459
|
+
card.dataset.state = state;
|
|
4460
|
+
const label = state === "added" ? "Added" : state === "running" ? `Running${found.models !== void 0 ? ` \xB7 ${countText(found.models)}` : ""}` : "Not running";
|
|
4461
|
+
const line = el("span", "ai-card-state");
|
|
4462
|
+
if (state === "running") {
|
|
4463
|
+
const dot = el("span", "ai-card-dot");
|
|
4464
|
+
dot.setAttribute("aria-hidden", "true");
|
|
4465
|
+
line.append(dot);
|
|
4466
|
+
}
|
|
4467
|
+
line.append(el("span", "", label));
|
|
4468
|
+
text.append(line);
|
|
4469
|
+
card.setAttribute("aria-label", added ? `${preset.name}, added. Open it` : `Add ${preset.name}, ${label.replace(" \xB7 ", ", ").toLowerCase()}`);
|
|
4470
|
+
if (added) {
|
|
4471
|
+
card.title = `${preset.name} is already set up. Open it.`;
|
|
4472
|
+
}
|
|
4473
|
+
}
|
|
4474
|
+
card.addEventListener("click", () => {
|
|
4475
|
+
if (added && getEngine(added)) {
|
|
4476
|
+
select({ kind: "engine", id: added });
|
|
4477
|
+
} else {
|
|
4478
|
+
addEngine(preset);
|
|
4479
|
+
}
|
|
4480
|
+
});
|
|
4481
|
+
return card;
|
|
4482
|
+
}
|
|
4483
|
+
function buildDetectLine() {
|
|
4484
|
+
const line = el("div", "ai-cards-detect");
|
|
4485
|
+
const status = el("span", "ai-cards-detect-text", detecting ? AI_DETECT_LOOKING : probes && !probes.some((p) => p.ok) ? AI_DETECT_NONE : "");
|
|
4486
|
+
const again = button("ai-link-btn ai-cards-again", "Look again", { fk: "cards:look-again" });
|
|
4487
|
+
if (detecting) {
|
|
4488
|
+
again.setAttribute("aria-disabled", "true");
|
|
4489
|
+
}
|
|
4490
|
+
again.addEventListener("click", () => {
|
|
4491
|
+
if (!detecting) {
|
|
4492
|
+
lookForLocalServers();
|
|
4493
|
+
}
|
|
4494
|
+
});
|
|
4495
|
+
line.append(status, again);
|
|
4496
|
+
return line;
|
|
4497
|
+
}
|
|
4498
|
+
function refreshLocalCards() {
|
|
4499
|
+
const section = detailBody.querySelector('.ai-cards-group[data-family="local"]');
|
|
4500
|
+
if (!section || selection.kind !== "add") {
|
|
4501
|
+
return;
|
|
4502
|
+
}
|
|
4503
|
+
keepFocus(section, () => {
|
|
4504
|
+
const grid = section.querySelector(".ai-cards-grid");
|
|
4505
|
+
if (grid) {
|
|
4506
|
+
fillLocalGrid(grid, groupPresets(AI_ENGINE_PRESETS).local);
|
|
4507
|
+
}
|
|
4508
|
+
const line = section.querySelector(".ai-cards-detect");
|
|
4509
|
+
if (line) {
|
|
4510
|
+
line.replaceWith(buildDetectLine());
|
|
4511
|
+
}
|
|
4512
|
+
});
|
|
4513
|
+
const box = section.parentElement;
|
|
4514
|
+
if (box) {
|
|
4515
|
+
keepFocus(box, () => {
|
|
4516
|
+
placeGroups(box);
|
|
4517
|
+
});
|
|
4518
|
+
}
|
|
4519
|
+
}
|
|
4520
|
+
function addEngine(preset) {
|
|
4521
|
+
const engine = engineFromPreset(preset, session.draft.engines.map((e) => e.id));
|
|
4522
|
+
session.addEngine(engine);
|
|
4523
|
+
select({ kind: "engine", id: engine.id }, { isNew: true });
|
|
4524
|
+
scheduleRouting();
|
|
4525
|
+
announce(preset.family === "cloud" ? `Added ${engine.name}. Paste your API key.` : preset.family === "host" ? `Added ${engine.name}. Fetching its models.` : `Added ${engine.name}. Check the address; models are fetched from it.`);
|
|
4526
|
+
}
|
|
4527
|
+
function deleteEngine(id) {
|
|
4528
|
+
const engine = getEngine(id);
|
|
4529
|
+
if (!engine) {
|
|
4530
|
+
return;
|
|
4531
|
+
}
|
|
4532
|
+
const index = session.draft.engines.findIndex((e) => e.id === id);
|
|
4533
|
+
session.deleteEngine(id);
|
|
4534
|
+
headNotes.delete(id);
|
|
4535
|
+
form.forget(id);
|
|
4536
|
+
confirmDeleteId = null;
|
|
4537
|
+
const next = session.draft.engines[Math.min(index, session.draft.engines.length - 1)];
|
|
4538
|
+
selection = next ? { kind: "engine", id: next.id } : { kind: "add" };
|
|
4539
|
+
renderNav();
|
|
4540
|
+
renderDetail();
|
|
4541
|
+
if (next) {
|
|
4542
|
+
form.opened(next.id);
|
|
4543
|
+
}
|
|
4544
|
+
announce(`Deleted ${engineDisplayName(engine)}. Choose Done to apply.`);
|
|
4545
|
+
focusKey(next ? `nav:eng:${next.id}` : "nav:add");
|
|
4546
|
+
}
|
|
4547
|
+
function makeDefault(engineId) {
|
|
4548
|
+
const engine = getEngine(engineId);
|
|
4549
|
+
if (!engine) {
|
|
4550
|
+
return;
|
|
4551
|
+
}
|
|
4552
|
+
const name = engineDisplayName(engine);
|
|
4553
|
+
const draft = session.draft;
|
|
4554
|
+
if (draft.routing.default?.engineId === engineId) {
|
|
4555
|
+
return;
|
|
4556
|
+
}
|
|
4557
|
+
const route = defaultRouteForEngine(draft, session.statusList(), engineId);
|
|
4558
|
+
if (!route) {
|
|
4559
|
+
headNotes.set(engineId, { level: "info", text: "No model here can be the default yet. Pick or add a chat model first." });
|
|
4560
|
+
announce(`${name} has no chat model to make the default yet.`);
|
|
4561
|
+
renderDetailHead();
|
|
4562
|
+
return;
|
|
4563
|
+
}
|
|
4564
|
+
session.draft = { ...draft, routing: { ...draft.routing, default: route } };
|
|
4565
|
+
announce(`Default model: ${routeLabel(session.draft, route)}.`);
|
|
4566
|
+
headNotes.delete(engineId);
|
|
4567
|
+
renderNav();
|
|
4568
|
+
renderDetailHead();
|
|
4569
|
+
rerenderForm(engineId);
|
|
4570
|
+
focusKey(`eng:${engineId}:star`);
|
|
4571
|
+
}
|
|
4572
|
+
function back() {
|
|
4573
|
+
if (confirmDiscard) {
|
|
4574
|
+
hideDiscard();
|
|
4575
|
+
return;
|
|
4576
|
+
}
|
|
4577
|
+
if (wizard) {
|
|
4578
|
+
wizard.back();
|
|
4579
|
+
return;
|
|
4580
|
+
}
|
|
4581
|
+
if (narrow && view === "detail") {
|
|
4582
|
+
view = "list";
|
|
4583
|
+
applyLayout();
|
|
4584
|
+
focusSelectedEntry();
|
|
4585
|
+
return;
|
|
4586
|
+
}
|
|
4587
|
+
if (session.dirty()) {
|
|
4588
|
+
showDiscard();
|
|
4589
|
+
return;
|
|
4590
|
+
}
|
|
4591
|
+
cancel();
|
|
4592
|
+
}
|
|
4593
|
+
function leave() {
|
|
4594
|
+
if (session.dirty()) {
|
|
4595
|
+
showDiscard();
|
|
4596
|
+
} else {
|
|
4597
|
+
cancel();
|
|
4598
|
+
}
|
|
4599
|
+
}
|
|
4600
|
+
function cancel() {
|
|
4601
|
+
if (finished) {
|
|
4602
|
+
return;
|
|
4603
|
+
}
|
|
4604
|
+
finished = true;
|
|
4605
|
+
session.discard();
|
|
4606
|
+
opts.onCancel();
|
|
4607
|
+
}
|
|
4608
|
+
function done() {
|
|
4609
|
+
if (finished) {
|
|
4610
|
+
return;
|
|
4611
|
+
}
|
|
4612
|
+
const unpicked = firstEngineNeedingPick(session.draft);
|
|
4613
|
+
if (unpicked) {
|
|
4614
|
+
requirePick(unpicked.id);
|
|
4615
|
+
return;
|
|
4616
|
+
}
|
|
4617
|
+
finished = true;
|
|
4618
|
+
const patch = session.collect();
|
|
4619
|
+
const saved = session.commit();
|
|
4620
|
+
if (opts.turnOnWhenDone && !saved.enabled && hasUsableDefaultRoute(saved)) {
|
|
4621
|
+
patch["ai.enabled"] = true;
|
|
4622
|
+
saved.enabled = true;
|
|
4623
|
+
}
|
|
4624
|
+
opts.onDone(patch, saved);
|
|
4625
|
+
}
|
|
4626
|
+
function requirePick(engineId) {
|
|
4627
|
+
const engine = getEngine(engineId);
|
|
4628
|
+
if (!engine) {
|
|
4629
|
+
return;
|
|
4630
|
+
}
|
|
4631
|
+
form.requirePick(engineId);
|
|
4632
|
+
if (wizard) {
|
|
4633
|
+
leaveWizardFor(engineId);
|
|
4634
|
+
} else if (selection.kind !== "engine" || selection.id !== engineId) {
|
|
4635
|
+
selection = { kind: "engine", id: engineId };
|
|
4636
|
+
view = "detail";
|
|
4637
|
+
applyLayout();
|
|
4638
|
+
renderNav();
|
|
4639
|
+
renderDetail();
|
|
4640
|
+
} else {
|
|
4641
|
+
rerenderForm(engineId);
|
|
4642
|
+
}
|
|
4643
|
+
announce(`Pick at least one model for ${engineDisplayName(engine)} before you choose Done.`);
|
|
4644
|
+
const target = Array.from(root.querySelectorAll("[data-fk]")).find((n) => n.dataset.fk === `eng:${engineId}:pick` || n.dataset.fk === `eng:${engineId}:models`);
|
|
4645
|
+
target?.focus({ preventScroll: false });
|
|
4646
|
+
}
|
|
4647
|
+
function showDiscard() {
|
|
4648
|
+
confirmDiscard = true;
|
|
4649
|
+
const scrim = el("div", "ai-page-confirm-scrim");
|
|
4650
|
+
const box = el("div", "ai-page-confirm");
|
|
4651
|
+
box.setAttribute("role", "alertdialog");
|
|
4652
|
+
const headId = nextId("ai-discard-title");
|
|
4653
|
+
const textId = nextId("ai-discard-text");
|
|
4654
|
+
box.setAttribute("aria-labelledby", headId);
|
|
4655
|
+
box.setAttribute("aria-describedby", textId);
|
|
4656
|
+
const head = el("div", "ai-page-confirm-title", "Discard changes?");
|
|
4657
|
+
head.id = headId;
|
|
4658
|
+
const text = el("div", "ai-page-confirm-text", "Your changes to engines and tasks have not been saved. Engines added here and their keys are removed.");
|
|
4659
|
+
text.id = textId;
|
|
4660
|
+
const keep = button("settings-btn", "Keep editing", { fk: "page:keep" });
|
|
4661
|
+
keep.addEventListener("click", () => hideDiscard());
|
|
4662
|
+
const discard = button("settings-btn ai-btn--danger", "Discard", { fk: "page:discard" });
|
|
4663
|
+
discard.addEventListener("click", () => cancel());
|
|
4664
|
+
const actions = el("div", "ai-page-confirm-actions");
|
|
4665
|
+
actions.append(keep, discard);
|
|
4666
|
+
box.append(head, text, actions);
|
|
4667
|
+
scrim.append(box);
|
|
4668
|
+
confirmHost.replaceChildren(scrim);
|
|
4669
|
+
header.inert = true;
|
|
4670
|
+
body.inert = true;
|
|
4671
|
+
if (wizard) {
|
|
4672
|
+
wizard.element.inert = true;
|
|
4673
|
+
}
|
|
4674
|
+
keep.focus();
|
|
4675
|
+
}
|
|
4676
|
+
function hideDiscard() {
|
|
4677
|
+
confirmDiscard = false;
|
|
4678
|
+
confirmHost.replaceChildren();
|
|
4679
|
+
header.inert = false;
|
|
4680
|
+
body.inert = false;
|
|
4681
|
+
if (wizard) {
|
|
4682
|
+
wizard.element.inert = false;
|
|
4683
|
+
}
|
|
4684
|
+
backBtn.focus();
|
|
4685
|
+
}
|
|
4686
|
+
root.addEventListener("keydown", (e) => {
|
|
4687
|
+
if (e.key !== "Escape" || e.defaultPrevented) {
|
|
4688
|
+
return;
|
|
4689
|
+
}
|
|
4690
|
+
e.preventDefault();
|
|
4691
|
+
e.stopPropagation();
|
|
4692
|
+
back();
|
|
4693
|
+
});
|
|
4694
|
+
function applyLayout() {
|
|
4695
|
+
root.classList.toggle("is-narrow", narrow);
|
|
4696
|
+
root.dataset.view = narrow ? view : "both";
|
|
4697
|
+
backBtn.setAttribute("aria-label", narrow && view === "detail" ? "Back to the list" : "Back");
|
|
4698
|
+
}
|
|
4699
|
+
function measure(width) {
|
|
4700
|
+
const next = width > 0 && width < NARROW_PX;
|
|
4701
|
+
if (next === narrow) {
|
|
4702
|
+
return;
|
|
4703
|
+
}
|
|
4704
|
+
narrow = next;
|
|
4705
|
+
applyLayout();
|
|
4706
|
+
}
|
|
4707
|
+
function focusSelectedEntry() {
|
|
4708
|
+
const fk = selection.kind === "engine" ? `nav:eng:${selection.id}` : selection.kind === "routing" ? "nav:routing" : "nav:add";
|
|
4709
|
+
focusKey(fk);
|
|
4710
|
+
}
|
|
4711
|
+
let resizeObserver = null;
|
|
4712
|
+
if (typeof ResizeObserver === "function") {
|
|
4713
|
+
resizeObserver = new ResizeObserver((entries) => {
|
|
4714
|
+
const entry = entries[entries.length - 1];
|
|
4715
|
+
if (entry) {
|
|
4716
|
+
measure(entry.contentRect.width);
|
|
4717
|
+
}
|
|
4718
|
+
});
|
|
4719
|
+
resizeObserver.observe(container);
|
|
4720
|
+
}
|
|
4721
|
+
const unsubscribe = opts.subscribeStatuses?.((list) => {
|
|
4722
|
+
if (finished) {
|
|
4723
|
+
return;
|
|
4724
|
+
}
|
|
4725
|
+
for (const s of list) {
|
|
4726
|
+
if (!form.busy(s.engineId)) {
|
|
4727
|
+
session.statuses.set(s.engineId, s);
|
|
4728
|
+
}
|
|
4729
|
+
}
|
|
4730
|
+
for (const engine of session.draft.engines) {
|
|
4731
|
+
refreshNavItem(engine.id);
|
|
4732
|
+
}
|
|
4733
|
+
renderDetailHead();
|
|
4734
|
+
scheduleRouting();
|
|
4735
|
+
wizard?.refresh(null, {});
|
|
4736
|
+
});
|
|
4737
|
+
function startWizard() {
|
|
4738
|
+
const w = createSetupWizard({
|
|
4739
|
+
session,
|
|
4740
|
+
form,
|
|
4741
|
+
ctx,
|
|
4742
|
+
announce,
|
|
4743
|
+
focusKey,
|
|
4744
|
+
setAllowCloud,
|
|
4745
|
+
onStatuses: () => opts.onStatuses(session.statusList()),
|
|
4746
|
+
onStep: (step) => wizardHeader(step),
|
|
4747
|
+
finish: () => done(),
|
|
4748
|
+
leave,
|
|
4749
|
+
openEngine: (id) => leaveWizardFor(id),
|
|
4750
|
+
turnsOn: !!opts.turnOnWhenDone
|
|
4751
|
+
});
|
|
4752
|
+
wizard = w;
|
|
4753
|
+
root.classList.add("ai-page--wizard");
|
|
4754
|
+
disclaimer.hidden = true;
|
|
4755
|
+
body.hidden = true;
|
|
4756
|
+
root.insertBefore(w.element, body);
|
|
4757
|
+
wizardHeader(w.step());
|
|
4758
|
+
}
|
|
4759
|
+
function wizardHeader(step) {
|
|
4760
|
+
const n = wizardStepNumber(step);
|
|
4761
|
+
title.textContent = "Set up AI";
|
|
4762
|
+
stepper.hidden = false;
|
|
4763
|
+
stepper.replaceChildren();
|
|
4764
|
+
stepper.setAttribute("role", "img");
|
|
4765
|
+
stepper.setAttribute("aria-label", `Step ${n} of ${AI_WIZARD_STEPS}`);
|
|
4766
|
+
for (let i = 1; i <= AI_WIZARD_STEPS; i++) {
|
|
4767
|
+
const dot = el("span", `ai-wizard-dot${i === n ? " is-current" : i < n ? " is-done" : ""}`);
|
|
4768
|
+
stepper.append(dot);
|
|
4769
|
+
}
|
|
4770
|
+
stepper.append(el("span", "ai-wizard-steps-text", `${n} of ${AI_WIZARD_STEPS}`));
|
|
4771
|
+
if (titleNote) {
|
|
4772
|
+
titleNote.hidden = true;
|
|
4773
|
+
}
|
|
4774
|
+
doneBtn.hidden = true;
|
|
4775
|
+
skipBtn.hidden = step === "done";
|
|
4776
|
+
backBtn.setAttribute("aria-label", n === 1 ? "Back" : "Back to the previous step");
|
|
4777
|
+
}
|
|
4778
|
+
function leaveWizardFor(engineId) {
|
|
4779
|
+
const w = wizard;
|
|
4780
|
+
if (!w) {
|
|
4781
|
+
return;
|
|
4782
|
+
}
|
|
4783
|
+
wizard = null;
|
|
4784
|
+
w.dispose();
|
|
4785
|
+
root.classList.remove("ai-page--wizard");
|
|
4786
|
+
disclaimer.hidden = false;
|
|
4787
|
+
body.hidden = false;
|
|
4788
|
+
title.textContent = "AI Engines";
|
|
4789
|
+
stepper.hidden = true;
|
|
4790
|
+
if (titleNote) {
|
|
4791
|
+
titleNote.hidden = false;
|
|
4792
|
+
}
|
|
4793
|
+
doneBtn.hidden = false;
|
|
4794
|
+
skipBtn.hidden = true;
|
|
4795
|
+
selection = getEngine(engineId) ? { kind: "engine", id: engineId } : initialSelection();
|
|
4796
|
+
view = "detail";
|
|
4797
|
+
applyLayout();
|
|
4798
|
+
renderNav();
|
|
4799
|
+
renderDetail();
|
|
4800
|
+
if (selection.kind === "engine") {
|
|
4801
|
+
form.opened(selection.id);
|
|
4802
|
+
}
|
|
4803
|
+
if (narrow) {
|
|
4804
|
+
focusDetailTitle();
|
|
4805
|
+
} else {
|
|
4806
|
+
focusSelectedEntry();
|
|
4807
|
+
}
|
|
4808
|
+
}
|
|
4809
|
+
container.replaceChildren(root);
|
|
4810
|
+
measure(container.getBoundingClientRect().width);
|
|
4811
|
+
applyLayout();
|
|
4812
|
+
if (opts.initial?.section === "wizard" && session.tier === 2) {
|
|
4813
|
+
startWizard();
|
|
4814
|
+
} else {
|
|
4815
|
+
renderNav();
|
|
4816
|
+
renderDetail();
|
|
4817
|
+
if (selection.kind === "engine") {
|
|
4818
|
+
form.opened(selection.id);
|
|
4819
|
+
}
|
|
4820
|
+
}
|
|
4821
|
+
if (ai.hardwareInfo && !hardware) {
|
|
4822
|
+
void setupHardware(ai).then((info) => {
|
|
4823
|
+
if (finished || !info) {
|
|
4824
|
+
return;
|
|
4825
|
+
}
|
|
4826
|
+
hardware = info;
|
|
4827
|
+
const formEngine = wizard ? wizard.engineId() : selectedEngineId();
|
|
4828
|
+
if (formEngine) {
|
|
4829
|
+
rerenderForm(formEngine);
|
|
4830
|
+
}
|
|
4831
|
+
});
|
|
4832
|
+
}
|
|
4833
|
+
if (session.tier === 2) {
|
|
4834
|
+
void session.refreshStatuses().then((ok) => {
|
|
4835
|
+
if (!ok || finished) {
|
|
4836
|
+
return;
|
|
4837
|
+
}
|
|
4838
|
+
for (const engine of session.draft.engines) {
|
|
4839
|
+
refreshNavItem(engine.id);
|
|
4840
|
+
}
|
|
4841
|
+
renderDetailHead();
|
|
4842
|
+
scheduleRouting();
|
|
4843
|
+
});
|
|
4844
|
+
}
|
|
4845
|
+
return {
|
|
4846
|
+
dispose() {
|
|
4847
|
+
if (!finished) {
|
|
4848
|
+
finished = true;
|
|
4849
|
+
session.discard();
|
|
4850
|
+
}
|
|
4851
|
+
finished = true;
|
|
4852
|
+
detectAbort?.abort();
|
|
4853
|
+
wizard?.dispose();
|
|
4854
|
+
window.clearTimeout(liveTimer);
|
|
4855
|
+
unsubscribe?.();
|
|
4856
|
+
resizeObserver?.disconnect();
|
|
4857
|
+
form.dispose();
|
|
4858
|
+
container.replaceChildren();
|
|
4859
|
+
},
|
|
4860
|
+
focus() {
|
|
4861
|
+
if (confirmDiscard) {
|
|
4862
|
+
focusKey("page:keep");
|
|
4863
|
+
} else if (wizard) {
|
|
4864
|
+
wizard.focus();
|
|
4865
|
+
} else if (narrow && view === "detail") {
|
|
4866
|
+
focusDetailTitle();
|
|
4867
|
+
} else if (!narrow && selection.kind === "routing") {
|
|
4868
|
+
focusKey("route:default");
|
|
4869
|
+
} else if (!narrow && selection.kind === "add" && session.draft.engines.length === 0) {
|
|
4870
|
+
detailBody.querySelector(".ai-card")?.focus({ preventScroll: true });
|
|
4871
|
+
} else {
|
|
4872
|
+
focusSelectedEntry();
|
|
4873
|
+
}
|
|
4874
|
+
}
|
|
4875
|
+
};
|
|
4876
|
+
}
|
|
4877
|
+
|
|
4878
|
+
export {
|
|
4879
|
+
engineKindLabel,
|
|
4880
|
+
engineDisplayName,
|
|
4881
|
+
parseLocalNetworksText,
|
|
4882
|
+
routeLabel,
|
|
4883
|
+
taskRouteNote,
|
|
4884
|
+
aiNextRouteChanged,
|
|
4885
|
+
aiIndicatorState,
|
|
4886
|
+
createAiSettingsSession,
|
|
4887
|
+
withSavedAiKeys,
|
|
4888
|
+
ICONS,
|
|
4889
|
+
kindGlyphHtml,
|
|
4890
|
+
AI_EXPERIMENTAL_DISCLAIMER,
|
|
4891
|
+
experimentalBadge,
|
|
4892
|
+
experimentalDisclaimer,
|
|
4893
|
+
privacyCard,
|
|
4894
|
+
privacyLine,
|
|
4895
|
+
nextId,
|
|
4896
|
+
el,
|
|
4897
|
+
button,
|
|
4898
|
+
switchInput,
|
|
4899
|
+
note,
|
|
4900
|
+
keepFocus,
|
|
4901
|
+
statusBadge,
|
|
4902
|
+
localityPills,
|
|
4903
|
+
AI_DETECT_LOOKING,
|
|
4904
|
+
detectLocalServers,
|
|
4905
|
+
bestLocalServer,
|
|
4906
|
+
setUpLocalEngine,
|
|
4907
|
+
scanForSetup,
|
|
4908
|
+
mountAiEnginesPage
|
|
4909
|
+
};
|
|
4910
|
+
//# sourceMappingURL=chunk-7A5HEPLK.js.map
|