@xingjisoft/neutrino 0.3.0 → 0.4.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.
Files changed (42) hide show
  1. package/README.md +312 -464
  2. package/assets/pro/backend_output_model.txt +5 -0
  3. package/assets/pro/claude-module-prompt.txt +65 -0
  4. package/assets/pro/common.txt +9 -0
  5. package/assets/pro/confirm_model.txt +5 -0
  6. package/assets/pro/front_output_model.txt +9 -0
  7. package/assets/pro/genimage_model.txt +1 -0
  8. package/assets/pro/main_model.txt +5 -0
  9. package/assets/pro/model-template.json +376 -0
  10. package/assets/pro/modeling_model.txt +5 -0
  11. package/assets/pro/plan_model.txt +8 -0
  12. package/assets/pro/thinking_model.txt +3 -0
  13. package/dist/app.js +43 -7
  14. package/dist/bin.js +5 -2
  15. package/dist/browser.js +57 -0
  16. package/dist/config.js +170 -26
  17. package/dist/index.js +1 -1
  18. package/dist/llm/approval.js +6 -2
  19. package/dist/llm/client.js +197 -28
  20. package/dist/llm/subagent.js +37 -1
  21. package/dist/pro/agents.js +104 -0
  22. package/dist/pro/config.js +88 -0
  23. package/dist/pro/preflight.js +172 -0
  24. package/dist/screens/chat.js +14 -8
  25. package/dist/screens/modelSelect.js +15 -4
  26. package/dist/screens/settings.js +9 -7
  27. package/dist/screens/start.js +48 -15
  28. package/dist/ui.js +58 -0
  29. package/dist/web/hub.js +22 -4
  30. package/dist/web/server.js +279 -66
  31. package/dist/web/session.js +258 -22
  32. package/dist/web/settings.js +91 -24
  33. package/package.json +37 -37
  34. package/web/README.md +87 -13
  35. package/web/app.js +304 -14
  36. package/web/i18n.js +382 -345
  37. package/web/index.html +90 -14
  38. package/web/neutrino-pro.gif +0 -0
  39. package/web/neutrino.png +0 -0
  40. package/web/pro.js +184 -0
  41. package/web/settings.js +322 -247
  42. package/web/style.css +297 -0
package/web/index.html CHANGED
@@ -5,6 +5,23 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
6
6
  <meta name="color-scheme" content="dark" />
7
7
  <meta name="theme-color" content="#151517" />
8
+ <script>
9
+ // Switching to the Pro workspace navigates only once the #151515 curtain
10
+ // is opaque. Cover the page here, before the first paint, so a slow
11
+ // module load cannot flash the freshly loaded /pro page before app.js
12
+ // takes the curtain over. app.js removes the class; the timer is a
13
+ // safety net for when the module cannot run at all.
14
+ try {
15
+ var proPath = location.pathname === "/pro" || location.pathname.startsWith("/pro/");
16
+ var proStart = Number(sessionStorage.getItem("neutrino.pro.transition")) || 0;
17
+ if (proPath && proStart && Date.now() - proStart < 8000) {
18
+ document.documentElement.classList.add("pro-transitioning");
19
+ setTimeout(function () {
20
+ document.documentElement.classList.remove("pro-transitioning");
21
+ }, 8000);
22
+ }
23
+ } catch (error) { /* Storage may be blocked; the page still works. */ }
24
+ </script>
8
25
  <title>Neutrino</title>
9
26
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23f9fafb' stroke-width='1.7'%3E%3Cellipse cx='12' cy='12' rx='9.4' ry='3.8' transform='rotate(-30 12 12)'/%3E%3Ccircle cx='12' cy='12' r='3.1'/%3E%3C/svg%3E" />
10
27
  <link rel="stylesheet" href="style.css" />
@@ -13,14 +30,14 @@
13
30
  <div id="app">
14
31
  <aside id="sidebar">
15
32
  <header class="brand">
16
- <div class="logo" aria-hidden="true">
33
+ <button id="brand-logo" class="logo brand-logo" type="button" title="Switch to Pro mode" aria-label="Switch to Pro mode" data-i18n-title="Switch to Pro mode" data-i18n-aria-label="Switch to Pro mode">
17
34
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
18
35
  <ellipse cx="12" cy="12" rx="9.4" ry="3.8" transform="rotate(-30 12 12)" stroke="currentColor" stroke-width="1.7" />
19
36
  <circle cx="12" cy="12" r="3.1" stroke="currentColor" stroke-width="1.7" />
20
37
  </svg>
21
- </div>
38
+ </button>
22
39
  <div class="brand-text">
23
- <strong>Neutrino</strong>
40
+ <strong id="brand-name">Neutrino</strong>
24
41
  <span id="status" class="status">connecting…</span>
25
42
  </div>
26
43
  <div class="brand-actions">
@@ -32,6 +49,12 @@
32
49
  </button>
33
50
  </div>
34
51
  </header>
52
+ <div id="pro-tip" class="pro-tip hidden" role="note">
53
+ <span data-i18n="Click the badge to switch to Pro mode">Click the badge to switch to Pro mode</span>
54
+ <button id="pro-tip-close" class="pro-tip-close" type="button" title="Dismiss" aria-label="Dismiss" data-i18n-title="Dismiss" data-i18n-aria-label="Dismiss">
55
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>
56
+ </button>
57
+ </div>
35
58
 
36
59
  <div class="sidebar-tools">
37
60
  <button id="new-chat" class="new-chat" type="button" title="New conversation" data-i18n-title="New conversation">
@@ -73,6 +96,10 @@
73
96
  <div id="session-sub" class="sub"></div>
74
97
  </div>
75
98
  <div id="controls" class="controls"></div>
99
+ <button id="pro-exit" class="control pro-exit hidden" type="button" title="Exit Pro mode" aria-label="Exit Pro mode" data-i18n-title="Exit Pro mode" data-i18n-aria-label="Exit Pro mode">
100
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m12 5-7 7 7 7M5 12h14" /></svg>
101
+ <span data-i18n="Exit Pro mode">Exit Pro mode</span>
102
+ </button>
76
103
  </header>
77
104
 
78
105
  <form id="model-picker" class="model-picker hidden" aria-label="Conversation model">
@@ -105,6 +132,30 @@
105
132
  <div class="composer-inner">
106
133
  <div class="composer-card">
107
134
  <textarea id="input" rows="1" placeholder="Message Neutrino…" aria-label="Message" data-i18n-placeholder="Message Neutrino…"></textarea>
135
+ <details id="pro-preset-control" class="pro-preset-control hidden">
136
+ <summary aria-label="Pro preset" data-i18n-aria-label="Pro preset"><span id="pro-preset-label">Pro</span><svg class="pro-preset-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7 10 5 5 5-5" /></svg></summary>
137
+ <div class="pro-preset-card">
138
+ <div class="pro-preset-heading"><span data-i18n="Neutrino Pro">Neutrino Pro</span><strong id="pro-preset-name"></strong></div>
139
+ <label class="pro-slider-label" for="pro-preset-range" data-i18n="Preset">Preset</label>
140
+ <div id="pro-preset-slider" class="pro-preset-slider">
141
+ <div class="pro-slider-track" aria-hidden="true">
142
+ <div class="pro-slider-fill"></div>
143
+ <div class="pro-slider-meteors">
144
+ <span class="pro-slider-meteor"></span><span class="pro-slider-meteor"></span><span class="pro-slider-meteor"></span><span class="pro-slider-meteor"></span>
145
+ </div>
146
+ </div>
147
+ <div id="pro-slider-stops" class="pro-slider-stops" aria-hidden="true"></div>
148
+ <input id="pro-preset-range" type="range" min="0" max="0" step="1" value="0" aria-label="Pro preset" data-i18n-aria-label="Pro preset" />
149
+ </div>
150
+ <div id="pro-preset-ticks" class="pro-preset-ticks"></div>
151
+ <details class="pro-model-details" id="pro-model-details">
152
+ <summary><span data-i18n="Model details">Model details</span><svg class="pro-model-details-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7 10 5 5 5-5" /></svg></summary>
153
+ <div id="pro-preset-models" class="pro-preset-models"></div>
154
+ </details>
155
+ <p id="pro-preset-note" class="settings-note"></p>
156
+ <p id="pro-preset-feedback" class="form-feedback error" role="status" aria-live="polite"></p>
157
+ </div>
158
+ </details>
108
159
  <button id="send" class="primary" type="button" title="Send message" aria-label="Send message" data-i18n-title="Send message" data-i18n-aria-label="Send message">
109
160
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5M5 12l7-7 7 7" /></svg>
110
161
  </button>
@@ -146,23 +197,34 @@
146
197
  </select>
147
198
  </label>
148
199
  </div>
149
- <label class="checkbox-label"><input id="settings-plan" type="checkbox" /> <span data-i18n="Plan-first turns">Plan-first turns</span></label>
150
- <p class="settings-note" data-i18n="Split multi-step requests into a plan before answering.">Split multi-step requests into a plan before answering.</p>
151
- <h3 data-i18n="Defaults for new conversations">Defaults for new conversations</h3>
152
- <div class="settings-grid">
200
+ <label class="checkbox-label standard-only"><input id="settings-plan" type="checkbox" /> <span data-i18n="Plan-first turns">Plan-first turns</span></label>
201
+ <p class="settings-note standard-only" data-i18n="Split multi-step requests into a plan before answering.">Split multi-step requests into a plan before answering.</p>
202
+ <h3 class="standard-only" data-i18n="Defaults for new conversations">Defaults for new conversations</h3>
203
+ <div class="settings-grid standard-only">
153
204
  <label><span data-i18n="Provider">Provider</span><select id="default-provider"></select></label>
154
205
  <label><span data-i18n="Model">Model</span><select id="default-model"></select></label>
155
206
  <label><span data-i18n="Thinking intensity">Thinking intensity</span><select id="default-intensity"></select></label>
156
207
  </div>
208
+ <section id="settings-pro" class="hidden">
209
+ <h3 data-i18n="Pro orchestration">Pro orchestration</h3>
210
+ <div class="settings-grid">
211
+ <label><span data-i18n="Default Pro preset">Default Pro preset</span><select id="settings-pro-preset"></select></label>
212
+ <label><span data-i18n="Maximum delegation depth">Maximum delegation depth</span><input id="settings-pro-depth" type="number" min="1" max="100" step="1" value="10" /></label>
213
+ </div>
214
+ <p class="settings-note" data-i18n="Every Pro task is planned first. All roles share the regular provider configuration. Image generation is reserved.">Every Pro task is planned first. All roles share the regular provider configuration. Image generation is reserved.</p>
215
+ <p class="settings-note" data-i18n="Edit role models, intensity and context windows in ~/.neutrino/pro/model.json. Default delegation depth: 10.">Edit role models, intensity and context windows in ~/.neutrino/pro/model.json. Default delegation depth: 10.</p>
216
+ </section>
157
217
  <h3 data-i18n="Conversation and approvals">Conversation and approvals</h3>
158
- <label><span data-i18n="Model context window (tokens)">Model context window (tokens)</span><input id="settings-context" type="number" min="1" max="9007199254740991" step="1" required /></label>
159
- <p id="settings-compress-note" class="settings-note" data-i18n="Auto-compress at 80% of this model's context window. Default: 1,000,000 tokens.">Auto-compress at 80% of this model's context window. Default: 1,000,000 tokens.</p>
160
- <label><span data-i18n="Approval reviewer model">Approval reviewer model</span><select id="settings-reviewer"></select></label>
218
+ <label class="standard-only"><span data-i18n="Model context window (tokens)">Model context window (tokens)</span><input id="settings-context" type="number" min="1" max="9007199254740991" step="1" required /></label>
219
+ <p id="settings-compress-note" class="settings-note standard-only" data-i18n="Auto-compress at 80% of this model's context window. Default: 1,000,000 tokens.">Auto-compress at 80% of this model's context window. Default: 1,000,000 tokens.</p>
220
+ <label class="standard-only"><span data-i18n="Approval reviewer model">Approval reviewer model</span><select id="settings-reviewer"></select></label>
161
221
  <label class="checkbox-label"><input id="settings-extreme" type="checkbox" /> <span data-i18n="Extreme-risk auto-approval (del class, 3 separate review rounds)">Extreme-risk auto-approval (del class, 3 separate review rounds)</span></label>
162
222
  <label class="checkbox-label"><input id="settings-sound" type="checkbox" /> <span data-i18n="Sound alerts (turn finished, approval waiting)">Sound alerts (turn finished, approval waiting)</span></label>
163
223
  <label class="checkbox-label"><input id="settings-web-console" type="checkbox" /> <span data-i18n="Start the Web console with the terminal">Start the Web console with the terminal</span></label>
164
224
  <p class="settings-note" data-i18n="Safety determines automatic approval; task relevance only blocks unanimously confirmed, extreme mismatches. Delete-class automatic approval also requires this setting.">Safety determines automatic approval; task relevance only blocks unanimously confirmed, extreme mismatches. Delete-class automatic approval also requires this setting.</p>
165
225
  <h3 data-i18n="Web access">Web access</h3>
226
+ <label class="checkbox-label"><input id="settings-web-lan" type="checkbox" /> <span data-i18n="Allow access from other devices (LAN, 0.0.0.0)">Allow access from other devices (LAN, 0.0.0.0)</span></label>
227
+ <p class="settings-note" data-i18n="Binds the Web console to 0.0.0.0 so other devices on your network can open it, like --lan. Takes effect immediately; turning it off returns the console to 127.0.0.1. Keep the Web password on: anyone who can reach this port can drive the agent.">Binds the Web console to 0.0.0.0 so other devices on your network can open it, like --lan. Takes effect immediately; turning it off returns the console to 127.0.0.1. Keep the Web password on: anyone who can reach this port can drive the agent.</p>
166
228
  <div class="settings-grid">
167
229
  <label><span data-i18n="New Web password">New Web password</span><input id="settings-password" type="password" autocomplete="new-password" maxlength="256" placeholder="Leave blank to keep current password" data-i18n-placeholder="Leave blank to keep current password" /></label>
168
230
  <label><span data-i18n="Confirm password">Confirm password</span><input id="settings-password-confirm" type="password" autocomplete="new-password" maxlength="256" placeholder="Repeat new password" data-i18n-placeholder="Repeat new password" /></label>
@@ -182,7 +244,8 @@
182
244
  <header class="sheet-header">
183
245
  <div>
184
246
  <h2 id="new-chat-title" data-i18n="New conversation">New conversation</h2>
185
- <p class="sheet-sub" data-i18n="Choose a working directory and a model.">Choose a working directory and a model.</p>
247
+ <p class="sheet-sub standard-only" data-i18n="Choose a working directory and a model.">Choose a working directory and a model.</p>
248
+ <p class="sheet-sub pro-only" data-i18n="Choose a working directory and a professional team preset.">Choose a working directory and a professional team preset.</p>
186
249
  </div>
187
250
  <button id="new-chat-close" class="icon-btn" type="button" aria-label="Close" data-i18n-aria-label="Close">
188
251
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>
@@ -200,19 +263,20 @@
200
263
  <div id="new-chat-recents" class="workspace-recents"></div>
201
264
  </section>
202
265
  <section class="field-group">
203
- <h3 data-i18n="Model">Model</h3>
204
- <div class="settings-grid">
266
+ <h3 class="standard-only" data-i18n="Model">Model</h3>
267
+ <div class="settings-grid standard-only">
205
268
  <label><span data-i18n="Provider">Provider</span><select id="new-chat-provider"></select></label>
206
269
  <label><span data-i18n="Model">Model</span><select id="new-chat-model"></select></label>
207
270
  <label><span data-i18n="Thinking">Thinking</span><select id="new-chat-intensity"></select></label>
208
271
  </div>
272
+ <label class="pro-only"><span data-i18n="Pro preset">Pro preset</span><select id="new-chat-pro-preset"></select></label>
209
273
  <label><span data-i18n="Default approval mode">Default approval mode</span>
210
274
  <select id="new-chat-approval">
211
275
  <option value="request" data-i18n="Request Approval">Request Approval</option>
212
276
  <option value="auto" data-i18n="Auto Approval">Auto Approval</option>
213
277
  </select>
214
278
  </label>
215
- <p id="new-chat-note" class="settings-note"></p>
279
+ <p id="new-chat-note" class="settings-note standard-only"></p>
216
280
  </section>
217
281
  <p id="new-chat-feedback" class="form-feedback" role="status" aria-live="polite"></p>
218
282
  </div>
@@ -299,6 +363,18 @@
299
363
  </div>
300
364
  </dialog>
301
365
 
366
+ <dialog id="pro-fallback-dialog" class="sheet-dialog pro-fallback-dialog" aria-labelledby="pro-fallback-title">
367
+ <div class="sheet">
368
+ <header class="sheet-header"><h2 id="pro-fallback-title" data-i18n="Professional model availability">Professional model availability</h2></header>
369
+ <div class="sheet-body">
370
+ <p class="settings-note" data-i18n="These model substitutions apply only to this task in this conversation. Your preset configuration is unchanged.">These model substitutions apply only to this task in this conversation. Your preset configuration is unchanged.</p>
371
+ <div id="pro-fallback-rows" class="pro-fallback-rows"></div>
372
+ <p id="pro-fallback-error" class="form-feedback error hidden" role="alert"></p>
373
+ </div>
374
+ <footer class="sheet-actions"><button id="pro-fallback-close" class="primary" type="button" data-i18n="Got it">Got it</button></footer>
375
+ </div>
376
+ </dialog>
377
+
302
378
  <script src="app.js" type="module"></script>
303
379
  </body>
304
380
  </html>
Binary file
Binary file
package/web/pro.js ADDED
@@ -0,0 +1,184 @@
1
+ import { t, getLanguage } from "./i18n.js";
2
+
3
+ const ROLES = {
4
+ main_model: "Main model", thinking_model: "Reasoning model",
5
+ front_output_model: "Frontend model", backend_output_model: "Backend model",
6
+ confirm_model: "Approval model", genimage_model: "Image model",
7
+ plan_model: "Planning model", modeling_model: "Modeling model",
8
+ };
9
+
10
+ /** The preset is a conversation choice; effective fallbacks belong to one task. */
11
+ export function createProUI({ changePreset }) {
12
+ const control = document.getElementById("pro-preset-control");
13
+ if (!control) return null;
14
+ const label = document.getElementById("pro-preset-label");
15
+ const slider = document.getElementById("pro-preset-slider");
16
+ const stops = document.getElementById("pro-slider-stops");
17
+ const range = document.getElementById("pro-preset-range");
18
+ const name = document.getElementById("pro-preset-name");
19
+ const ticks = document.getElementById("pro-preset-ticks");
20
+ const models = document.getElementById("pro-preset-models");
21
+ const note = document.getElementById("pro-preset-note");
22
+ const feedback = document.getElementById("pro-preset-feedback");
23
+ const fallbackDialog = document.getElementById("pro-fallback-dialog");
24
+ const fallbackRows = document.getElementById("pro-fallback-rows");
25
+ const fallbackError = document.getElementById("pro-fallback-error");
26
+ const seenChecks = new Set();
27
+ let presets = [];
28
+ let selected = "";
29
+ let committed = "";
30
+ let defaultPreset = "";
31
+ let target = null;
32
+ let snapshot = null;
33
+ let applying = false;
34
+ let pending = Promise.resolve();
35
+ let failure = null;
36
+ const title = preset => getLanguage() === "zh" ? preset.chinese_name || preset.name : preset.name;
37
+ const presetFor = value => presets.find(entry => entry.name === value);
38
+ const modelName = value => value == null || value === "null" || value === "" ? t("Reserved") : value;
39
+ const reference = value => value?.provider ? `${value.provider}/${modelName(value.model)}` : modelName(value?.model);
40
+
41
+ function render() {
42
+ const preset = presetFor(selected) || presets[0];
43
+ control.classList.toggle("hidden", !preset);
44
+ if (!preset) return;
45
+ selected = preset.name;
46
+ const busy = Boolean(snapshot?.busy || snapshot?.approval || applying);
47
+ label.textContent = `Pro · ${title(preset)}`;
48
+ name.textContent = title(preset);
49
+ const selectedIndex = presets.indexOf(preset);
50
+ const lastIndex = presets.length - 1;
51
+ const position = lastIndex > 0 ? selectedIndex / lastIndex : 0;
52
+ slider.setAttribute("style", `--pro-slider-position: ${position}`);
53
+ slider.classList.toggle("is-disabled", busy);
54
+ control.classList.toggle("is-max", lastIndex > 0 && selectedIndex === lastIndex);
55
+ stops.replaceChildren();
56
+ presets.forEach((_entry, index) => {
57
+ const stop = document.createElement("span");
58
+ stop.className = "pro-slider-stop";
59
+ stop.setAttribute("style", `left: ${lastIndex > 0 ? index / lastIndex * 100 : 0}%`);
60
+ stop.classList.toggle("is-filled", index <= selectedIndex);
61
+ stops.appendChild(stop);
62
+ });
63
+ range.max = String(Math.max(0, presets.length - 1));
64
+ range.value = String(selectedIndex);
65
+ range.disabled = busy;
66
+ range.setAttribute("aria-valuetext", title(preset));
67
+ ticks.replaceChildren();
68
+ presets.forEach((entry, index) => {
69
+ const tick = document.createElement("button");
70
+ tick.type = "button";
71
+ tick.textContent = title(entry);
72
+ tick.disabled = busy;
73
+ tick.setAttribute("style", `left: ${lastIndex > 0 ? index / lastIndex * 100 : 0}%`);
74
+ tick.setAttribute("aria-pressed", String(entry.name === selected));
75
+ tick.addEventListener("click", () => choose(entry.name));
76
+ ticks.appendChild(tick);
77
+ });
78
+ models.replaceChildren();
79
+ for (const role of Object.keys(ROLES)) {
80
+ const configured = preset["model-setting"]?.find(entry => entry.type === role);
81
+ const effective = snapshot?.pro?.preset === selected && !snapshot.pro.checking
82
+ ? snapshot.pro.models?.find(entry => entry.role === role) : null;
83
+ const row = document.createElement("div");
84
+ row.className = "pro-model-row";
85
+ const roleLabel = document.createElement("span");
86
+ roleLabel.textContent = t(ROLES[role]);
87
+ const value = document.createElement("span");
88
+ value.textContent = modelName(effective?.model ?? configured?.model);
89
+ value.title = [effective?.provider ?? configured?.provider, effective?.intensity ?? configured?.intensity].filter(Boolean).join(" · ");
90
+ if (effective && effective.tier !== selected) {
91
+ value.classList.add("pro-model-fallback");
92
+ value.title += ` · ${t("Temporary fallback")}: ${effective.tier}`;
93
+ }
94
+ row.append(roleLabel, value);
95
+ models.appendChild(row);
96
+ }
97
+ note.textContent = snapshot?.pro?.checking
98
+ ? t("Checking every professional model before this task…")
99
+ : busy ? t("Pause the current turn to change presets.")
100
+ : t("Models are checked before each task. Unavailable roles temporarily use a lower preset.");
101
+ }
102
+
103
+ function choose(value) {
104
+ if (snapshot?.busy || snapshot?.approval || applying || !presetFor(value)) return;
105
+ const previous = committed || selected;
106
+ const originalTarget = target;
107
+ selected = value;
108
+ failure = null;
109
+ feedback.textContent = "";
110
+ applying = true;
111
+ render();
112
+ pending = Promise.resolve().then(() => changePreset(value)).then(() => {
113
+ if (target === originalTarget) committed = value;
114
+ }).catch(error => {
115
+ if (target !== originalTarget) return;
116
+ failure = error;
117
+ selected = previous;
118
+ feedback.textContent = error.message;
119
+ }).finally(() => { applying = false; render(); });
120
+ }
121
+
122
+ function showFallbacks(pro, key) {
123
+ if (!pro || pro.checking || !pro.checkId) return;
124
+ const id = `${key || ""}:${pro.checkId}`;
125
+ if (seenChecks.has(id)) return;
126
+ seenChecks.add(id);
127
+ if (!(pro.fallbacks?.length) && !pro.error) return;
128
+ fallbackRows.replaceChildren();
129
+ for (const fallback of pro.fallbacks || []) {
130
+ const row = document.createElement("div");
131
+ row.className = "pro-fallback-row";
132
+ const role = document.createElement("strong");
133
+ role.textContent = t(ROLES[fallback.role] || fallback.role);
134
+ const from = document.createElement("span");
135
+ from.textContent = `${reference(fallback.requested)} → ${fallback.replacement ? reference(fallback.replacement) : t("No available model")}`;
136
+ const detail = document.createElement("small");
137
+ detail.textContent = [fallback.reason, fallback.replacement?.tier].filter(Boolean).join(" · ");
138
+ row.append(role, from, detail);
139
+ fallbackRows.appendChild(row);
140
+ }
141
+ fallbackError.textContent = pro.error || "";
142
+ fallbackError.classList.toggle("hidden", !pro.error);
143
+ if (!fallbackDialog.open) fallbackDialog.showModal();
144
+ }
145
+
146
+ range.addEventListener("input", () => {
147
+ if (snapshot?.busy || applying) return;
148
+ selected = presets[Number(range.value)]?.name || selected;
149
+ render();
150
+ });
151
+ range.addEventListener("change", () => choose(selected));
152
+ control.addEventListener("keydown", event => {
153
+ if (event.key === "Escape") { control.open = false; event.stopPropagation(); }
154
+ });
155
+ document.addEventListener("pointerdown", event => {
156
+ if (control.open && !control.contains(event.target)) control.open = false;
157
+ });
158
+ document.getElementById("pro-fallback-close").addEventListener("click", () => fallbackDialog.close());
159
+
160
+ return {
161
+ load(catalog) {
162
+ presets = Array.isArray(catalog?.pro?.presets) ? catalog.pro.presets : [];
163
+ defaultPreset = catalog?.pro?.preset || presets[0]?.name || "";
164
+ if (!presetFor(selected) || (!target && !applying)) selected = defaultPreset;
165
+ if (!committed) committed = selected;
166
+ render();
167
+ },
168
+ show(info, key) {
169
+ if (target !== key) {
170
+ selected = info?.pro?.preset || info?.pro_preset || defaultPreset;
171
+ committed = selected;
172
+ target = key;
173
+ failure = null;
174
+ feedback.textContent = "";
175
+ } else if (!applying && info?.pro?.preset) committed = selected = info.pro.preset;
176
+ snapshot = info;
177
+ render();
178
+ showFallbacks(info?.pro, key);
179
+ },
180
+ refresh: render,
181
+ preset: () => selected,
182
+ async flush() { await pending; if (failure) throw failure; },
183
+ };
184
+ }