@xingjisoft/neutrino 0.4.1 → 0.5.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/web/settings.js CHANGED
@@ -3,7 +3,8 @@
3
3
  import { t, getLanguage } from "./i18n.js";
4
4
 
5
5
  export function createSettingsUI({ apiFetch, applyModel, onLanguageChange, proMode = false, onCatalogChange }) {
6
- const ids = ["model-picker", "model-provider", "model-name", "model-intensity", "model-apply", "model-feedback",
6
+ const ids = ["model-picker", "model-picker-summary", "model-picker-label", "model-provider", "model-name", "model-intensity",
7
+ "model-intensity-label", "model-intensity-slider", "model-intensity-stops", "model-intensity-ticks", "model-feedback",
7
8
  "settings-open", "settings-dialog", "settings-form", "settings-close", "settings-cancel", "settings-save", "settings-feedback",
8
9
  "default-provider", "default-model", "default-intensity", "settings-context", "settings-compress-note", "settings-reviewer",
9
10
  "settings-extreme", "settings-sound", "settings-password", "settings-password-confirm",
@@ -14,7 +15,10 @@ export function createSettingsUI({ apiFetch, applyModel, onLanguageChange, proMo
14
15
  let target = null;
15
16
  let conversation = null;
16
17
  let dirty = false;
17
- let applying = false;
18
+ let draftIntensity = "";
19
+ let applying = null;
20
+ let generation = 0;
21
+ let failure = null;
18
22
  let feedback = "";
19
23
  let feedbackError = false;
20
24
  // Value the LAN switch had when the dialog opened. Only an actual flip is
@@ -88,32 +92,90 @@ export function createSettingsUI({ apiFetch, applyModel, onLanguageChange, proMo
88
92
  value: preset.name, label: getLanguage() === "zh" ? preset.chinese_name || preset.name : preset.name,
89
93
  })), selected);
90
94
  }
95
+ function unavailableOption(select, value) {
96
+ const option = document.createElement("option");
97
+ option.value = value;
98
+ option.textContent = `${value} ${t("(unavailable)")}`;
99
+ option.disabled = true;
100
+ select.appendChild(option);
101
+ select.value = value;
102
+ }
91
103
  function choice() {
92
- return { provider: el["model-provider"].value, model: el["model-name"].value, intensity: el["model-intensity"].value };
104
+ return { provider: el["model-provider"].value, model: el["model-name"].value, intensity: draftIntensity };
93
105
  }
94
106
  function renderPicker() {
95
107
  el["model-picker"].classList.toggle("hidden", proMode || !config || !conversation);
96
108
  if (proMode || !config || !conversation) return;
97
109
  if (!dirty) {
98
- providers(el["model-provider"], conversation.provider);
99
- models(el["model-provider"], el["model-name"], conversation.model);
100
- intensities(el["model-intensity"], conversation.intensity, conversation.provider, conversation.model);
110
+ providers(el["model-provider"], conversation.provider || config.selection?.provider);
111
+ if (conversation.provider && !config.providers.some(entry => entry.name === conversation.provider)) {
112
+ unavailableOption(el["model-provider"], conversation.provider);
113
+ }
114
+ models(el["model-provider"], el["model-name"], conversation.model || config.selection?.model);
115
+ if (conversation.model && !modelEntry(el["model-provider"].value, conversation.model)) {
116
+ unavailableOption(el["model-name"], conversation.model);
117
+ }
118
+ draftIntensity = pickIntensity(el["model-provider"].value, el["model-name"].value,
119
+ conversation.intensity || config.selection?.intensity);
101
120
  } else {
102
- // A provider/model change narrows the intensity list even though the rest
103
- // of the draft must survive the next server snapshot.
104
- intensities(el["model-intensity"], el["model-intensity"].value,
105
- el["model-provider"].value, el["model-name"].value);
121
+ // Keep a draft across live snapshots, while honoring the chosen model's limits.
122
+ draftIntensity = pickIntensity(el["model-provider"].value, el["model-name"].value, draftIntensity);
106
123
  }
124
+ const selection = choice();
125
+ const displayed = dirty || target?.startsWith("new:") ? selection : {
126
+ model: conversation.model || selection.model,
127
+ intensity: conversation.intensity || selection.intensity,
128
+ };
129
+ el["model-picker-label"].textContent = [displayed.model, displayed.intensity && t(displayed.intensity)]
130
+ .filter(Boolean).join(" · ") || t("Choose a model");
131
+ const summary = t("Conversation model: {selection}", { selection: el["model-picker-label"].textContent });
132
+ el["model-picker-summary"].setAttribute("aria-label", summary);
133
+ el["model-picker-summary"].title = summary;
134
+ el["model-intensity-label"].textContent = selection.intensity ? t(selection.intensity) : "";
135
+
136
+ const levels = modelEntry(selection.provider, selection.model) ? intensitiesFor(selection.provider, selection.model) : [];
137
+ const index = Math.max(0, levels.indexOf(selection.intensity));
138
+ const last = Math.max(0, levels.length - 1);
107
139
  const busy = Boolean(conversation.busy || conversation.approval);
108
- el["model-apply"].disabled = applying || busy || !choice().provider || !choice().model;
109
- el["model-apply"].textContent = applying
110
- ? t("Applying…")
111
- : target?.startsWith("file:") ? t("Continue with model") : t("Apply to conversation");
112
- message(
113
- el["model-feedback"],
114
- feedback || (busy ? t("Pause the current turn to change models.") : t("Applies to the next turn.")),
115
- feedbackError,
116
- );
140
+ const locked = busy || Boolean(applying && applying.generation === generation);
141
+ const slider = el["model-intensity-slider"];
142
+ slider.setAttribute("style", `--pro-slider-position: ${last ? index / last : 0}`);
143
+ slider.classList.toggle("is-disabled", locked || !levels.length);
144
+ el["model-picker"].classList.toggle("is-max", last > 0 && index === last);
145
+ el["model-provider"].disabled = locked;
146
+ el["model-name"].disabled = locked || !selection.provider;
147
+ el["model-intensity"].disabled = locked || !levels.length;
148
+ el["model-intensity"].max = String(last);
149
+ el["model-intensity"].value = String(index);
150
+ el["model-intensity"].setAttribute("aria-valuetext", selection.intensity ? t(selection.intensity) : "");
151
+
152
+ const stops = el["model-intensity-stops"];
153
+ const ticks = el["model-intensity-ticks"];
154
+ stops.replaceChildren();
155
+ ticks.replaceChildren();
156
+ levels.forEach((level, levelIndex) => {
157
+ const position = last ? levelIndex / last * 100 : 0;
158
+ const stop = document.createElement("span");
159
+ stop.className = "pro-slider-stop";
160
+ stop.setAttribute("style", `left: ${position}%`);
161
+ stop.classList.toggle("is-filled", levelIndex <= index);
162
+ stops.appendChild(stop);
163
+ const tick = document.createElement("button");
164
+ tick.type = "button";
165
+ tick.textContent = t(level);
166
+ tick.disabled = locked;
167
+ tick.setAttribute("style", `left: ${position}%`);
168
+ tick.setAttribute("aria-pressed", String(level === selection.intensity));
169
+ tick.addEventListener("click", () => {
170
+ if (tick.disabled) return;
171
+ draftIntensity = level;
172
+ changeChoice();
173
+ });
174
+ ticks.appendChild(tick);
175
+ });
176
+ const status = busy ? t("Pause the current turn to change models.")
177
+ : feedback || (locked ? t("Applying…") : t("Applies to the next turn."));
178
+ message(el["model-feedback"], status, !busy && feedbackError);
117
179
  }
118
180
  async function load() {
119
181
  config = await json("/api/settings");
@@ -122,44 +184,109 @@ export function createSettingsUI({ apiFetch, applyModel, onLanguageChange, proMo
122
184
  return config;
123
185
  }
124
186
  function showConversation(info, key) {
125
- if (target !== key) { dirty = false; feedback = ""; feedbackError = false; }
187
+ if (target !== key) {
188
+ generation++;
189
+ dirty = false;
190
+ failure = null;
191
+ feedback = "";
192
+ feedbackError = false;
193
+ el["model-picker"].open = false;
194
+ }
126
195
  target = key;
127
196
  conversation = info;
197
+ // A turn may start elsewhere while the slider is being dragged. Discard
198
+ // that uncommitted preview so it cannot masquerade as the active setting.
199
+ if (target?.startsWith("live:") && dirty && (info?.busy || info?.approval) && applying?.generation !== generation) {
200
+ dirty = false;
201
+ feedback = "";
202
+ feedbackError = false;
203
+ }
128
204
  renderPicker();
129
205
  }
130
206
  function clear() {
207
+ generation++;
131
208
  target = null;
132
209
  conversation = null;
133
210
  dirty = false;
211
+ failure = null;
212
+ feedback = "";
213
+ el["model-picker"].open = false;
214
+ renderPicker();
215
+ }
216
+ function changeChoice() {
217
+ if (!config || !conversation || conversation.busy || conversation.approval || applying?.generation === generation) return;
218
+ dirty = true;
219
+ failure = null;
134
220
  feedback = "";
221
+ feedbackError = false;
222
+ renderPicker();
223
+ // Saved and not-yet-created conversations keep the choice as a local draft.
224
+ if (!target?.startsWith("live:")) return;
225
+ const selection = choice();
226
+ if (!selection.provider || !modelEntry(selection.provider, selection.model) || !selection.intensity) return;
227
+ if (selection.provider === conversation.provider && selection.model === conversation.model && selection.intensity === conversation.intensity) {
228
+ dirty = false;
229
+ renderPicker();
230
+ return;
231
+ }
232
+ const request = { generation, promise: null };
233
+ applying = request;
135
234
  renderPicker();
235
+ request.promise = (async () => {
236
+ try {
237
+ await applyModel(selection);
238
+ if (request.generation !== generation) return;
239
+ conversation = { ...conversation, ...selection };
240
+ dirty = false;
241
+ feedback = t("Model and intensity updated.");
242
+ } catch (error) {
243
+ if (request.generation !== generation) return;
244
+ dirty = false;
245
+ failure = error;
246
+ feedback = t(error.message);
247
+ feedbackError = true;
248
+ } finally {
249
+ if (applying === request) applying = null;
250
+ renderPicker();
251
+ }
252
+ })();
136
253
  }
137
254
  el["model-provider"].addEventListener("change", () => {
138
255
  if (!config) return;
139
- models(el["model-provider"], el["model-name"]);
140
- dirty = true; feedback = ""; feedbackError = false;
141
- renderPicker();
256
+ const provider = config.providers.find(entry => entry.name === el["model-provider"].value);
257
+ models(el["model-provider"], el["model-name"], provider?.models.find(model => model.configured)?.name);
258
+ changeChoice();
142
259
  });
143
- for (const id of ["model-name", "model-intensity"]) {
144
- el[id].addEventListener("change", () => { dirty = true; feedback = ""; feedbackError = false; renderPicker(); });
145
- }
146
- el["model-picker"].addEventListener("submit", async event => {
147
- event.preventDefault();
148
- if (el["model-apply"].disabled) return;
149
- const originalTarget = target;
150
- applying = true;
260
+ el["model-name"].addEventListener("change", changeChoice);
261
+ el["model-intensity"].addEventListener("input", () => {
262
+ if (!config || !conversation || conversation.busy || conversation.approval || applying?.generation === generation) return;
263
+ const levels = intensitiesFor(el["model-provider"].value, el["model-name"].value);
264
+ draftIntensity = levels[Number(el["model-intensity"].value)] || draftIntensity;
265
+ dirty = true;
266
+ failure = null;
151
267
  feedback = "";
268
+ feedbackError = false;
152
269
  renderPicker();
153
- try {
154
- await applyModel(choice());
155
- if (target === originalTarget) { dirty = false; feedback = t("Model and intensity updated."); feedbackError = false; }
156
- } catch (error) {
157
- if (target === originalTarget) { feedback = t(error.message); feedbackError = true; }
158
- } finally {
159
- applying = false;
160
- renderPicker();
161
- }
162
270
  });
271
+ el["model-intensity"].addEventListener("change", changeChoice);
272
+ el["model-picker"].addEventListener("keydown", event => {
273
+ if (event.key === "Escape") { el["model-picker"].open = false; event.stopPropagation(); }
274
+ });
275
+ document.addEventListener?.("pointerdown", event => {
276
+ if (el["model-picker"].open && !el["model-picker"].contains(event.target)) el["model-picker"].open = false;
277
+ });
278
+ async function flush() {
279
+ // A keyboard send can happen before a range input emits its change event.
280
+ if (dirty && target?.startsWith("live:") && applying?.generation !== generation) changeChoice();
281
+ const request = applying;
282
+ const currentGeneration = generation;
283
+ if (request?.generation === currentGeneration) await request.promise;
284
+ if (currentGeneration === generation && failure) {
285
+ const error = failure;
286
+ failure = null;
287
+ throw error;
288
+ }
289
+ }
163
290
 
164
291
  function renderSettings() {
165
292
  el["settings-password"].value = "";
@@ -241,8 +368,6 @@ export function createSettingsUI({ apiFetch, applyModel, onLanguageChange, proMo
241
368
  if (!config) return;
242
369
  providers(el["model-provider"], el["model-provider"].value);
243
370
  models(el["model-provider"], el["model-name"], el["model-name"].value);
244
- intensities(el["model-intensity"], el["model-intensity"].value,
245
- el["model-provider"].value, el["model-name"].value);
246
371
  if (el["settings-dialog"].open) {
247
372
  providers(el["default-provider"], el["default-provider"].value);
248
373
  models(el["default-provider"], el["default-model"], el["default-model"].value);
@@ -362,12 +487,13 @@ export function createSettingsUI({ apiFetch, applyModel, onLanguageChange, proMo
362
487
  clear,
363
488
  soundAlerts,
364
489
  refreshTexts,
490
+ flush,
365
491
  catalog: () => config,
366
- defaultSelection: () => (config ? {
492
+ defaultSelection: () => (config ? (target?.startsWith("new:") && dirty ? choice() : {
367
493
  provider: config.selection.provider,
368
494
  model: config.selection.model,
369
495
  intensity: config.selection.intensity,
370
- } : undefined),
496
+ }) : undefined),
371
497
  selectionForFile: id => target === `file:${id}` && dirty ? choice() : undefined,
372
498
  };
373
499
  }
package/web/style.css CHANGED
@@ -549,13 +549,28 @@ svg {
549
549
  color: var(--text-tertiary);
550
550
  }
551
551
 
552
- .dot {
552
+ /* Sidebar row status: a finished turn, a turn in flight, or a pending approval. */
553
+ .session-state {
553
554
  flex: none;
554
555
  width: 6px;
555
556
  height: 6px;
556
557
  border-radius: 50%;
557
558
  background: var(--accent);
558
- animation: pulse 2s var(--ease) infinite;
559
+ }
560
+
561
+ .session-state.approval {
562
+ background: var(--amber);
563
+ animation: pulse-amber 1.6s ease-out infinite;
564
+ }
565
+
566
+ /* A running turn is a ring that keeps turning, never a coloured dot. */
567
+ .session-state.running {
568
+ width: 9px;
569
+ height: 9px;
570
+ background: none;
571
+ border: 1.5px solid rgba(122, 170, 255, 0.25);
572
+ border-top-color: var(--accent);
573
+ animation: spin 0.8s linear infinite;
559
574
  }
560
575
 
561
576
  .more {
@@ -1416,6 +1431,7 @@ svg {
1416
1431
 
1417
1432
  .composer-card {
1418
1433
  display: flex;
1434
+ flex-wrap: wrap;
1419
1435
  align-items: flex-end;
1420
1436
  gap: 8px;
1421
1437
  padding: 8px 8px 8px 16px;
@@ -1434,7 +1450,7 @@ svg {
1434
1450
  }
1435
1451
 
1436
1452
  .composer textarea {
1437
- flex: 1;
1453
+ flex: 1 0 100%;
1438
1454
  min-width: 0;
1439
1455
  min-height: 36px;
1440
1456
  max-height: 240px;
@@ -1458,8 +1474,6 @@ svg {
1458
1474
  outline: none;
1459
1475
  }
1460
1476
 
1461
- .pro-mode .composer-card { flex-wrap: wrap; }
1462
- .pro-mode .composer textarea { flex: 1 0 100%; }
1463
1477
  .pro-preset-control { position: relative; margin-left: auto; align-self: center; }
1464
1478
  .pro-preset-control > summary {
1465
1479
  display: flex; align-items: center; gap: 10px; padding: 7px 10px;
@@ -1496,20 +1510,20 @@ svg {
1496
1510
  border-radius: 50%; background: rgba(255, 255, 255, .18);
1497
1511
  }
1498
1512
  .pro-slider-stop.is-filled { background: rgba(255, 255, 255, .35); }
1499
- #pro-preset-range {
1513
+ #pro-preset-range, #model-intensity {
1500
1514
  position: relative; display: block; width: 100%; height: 40px; margin: 0; padding: 0;
1501
1515
  -webkit-appearance: none; appearance: none; border: 0; border-radius: 999px; background: transparent; cursor: pointer;
1502
1516
  }
1503
- #pro-preset-range::-webkit-slider-runnable-track { height: 24px; border: 0; background: transparent; }
1504
- #pro-preset-range::-webkit-slider-thumb {
1517
+ #pro-preset-range::-webkit-slider-runnable-track, #model-intensity::-webkit-slider-runnable-track { height: 24px; border: 0; background: transparent; }
1518
+ #pro-preset-range::-webkit-slider-thumb, #model-intensity::-webkit-slider-thumb {
1505
1519
  -webkit-appearance: none; appearance: none; width: 28px; height: 28px; margin-top: -2px;
1506
1520
  border: 0; border-radius: 50%; background: #fff;
1507
1521
  }
1508
- #pro-preset-range::-moz-range-track { height: 24px; border: 0; background: transparent; }
1509
- #pro-preset-range::-moz-range-progress { background: transparent; }
1510
- #pro-preset-range::-moz-range-thumb { width: 28px; height: 28px; border: 0; border-radius: 50%; background: #fff; }
1511
- #pro-preset-range:focus-visible { outline-offset: 2px; }
1512
- #pro-preset-range:disabled { cursor: default; }
1522
+ #pro-preset-range::-moz-range-track, #model-intensity::-moz-range-track { height: 24px; border: 0; background: transparent; }
1523
+ #pro-preset-range::-moz-range-progress, #model-intensity::-moz-range-progress { background: transparent; }
1524
+ #pro-preset-range::-moz-range-thumb, #model-intensity::-moz-range-thumb { width: 28px; height: 28px; border: 0; border-radius: 50%; background: #fff; }
1525
+ #pro-preset-range:focus-visible, #model-intensity:focus-visible { outline-offset: 2px; }
1526
+ #pro-preset-range:disabled, #model-intensity:disabled { cursor: default; }
1513
1527
  .pro-preset-ticks { position: relative; height: 24px; margin: 6px 14px 16px; }
1514
1528
  .pro-preset-ticks button {
1515
1529
  position: absolute; transform: translateX(-50%); border: 0; background: transparent;
@@ -1931,40 +1945,18 @@ svg {
1931
1945
 
1932
1946
  /* ----------------------------------------------------- model and settings */
1933
1947
 
1934
- .model-picker {
1935
- display: flex;
1936
- flex-shrink: 0;
1937
- flex-wrap: wrap;
1938
- align-items: flex-end;
1939
- gap: 10px;
1940
- padding: 10px clamp(16px, 4vw, 32px) 12px;
1941
- border-bottom: 1px solid var(--border-soft);
1942
- }
1943
-
1944
- .model-picker label {
1948
+ .model-picker { min-width: 0; max-width: min(75%, 340px); }
1949
+ .model-picker > summary span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1950
+ .model-picker-fields { display: grid; gap: 12px; margin-bottom: 18px; }
1951
+ .model-picker-fields label {
1945
1952
  display: flex;
1946
1953
  flex-direction: column;
1947
1954
  gap: 5px;
1948
- flex: 0 1 170px;
1949
1955
  min-width: 0;
1950
- color: var(--text-caption);
1956
+ color: var(--text-tertiary);
1951
1957
  font-size: 11px;
1952
1958
  }
1953
-
1954
- .model-picker label:nth-child(2) {
1955
- flex-grow: 1;
1956
- max-width: 300px;
1957
- }
1958
-
1959
- .model-picker .control {
1960
- height: 34px;
1961
- }
1962
-
1963
- .model-picker .form-feedback {
1964
- flex: 1 1 200px;
1965
- align-self: center;
1966
- margin: 0;
1967
- }
1959
+ .model-picker-card .form-feedback { margin: 0; }
1968
1960
 
1969
1961
  select,
1970
1962
  .settings-fields input[type="password"],
@@ -1981,10 +1973,6 @@ select,
1981
1973
  transition: border-color var(--fast), background-color var(--fast);
1982
1974
  }
1983
1975
 
1984
- .model-picker select {
1985
- height: 34px;
1986
- }
1987
-
1988
1976
  select {
1989
1977
  appearance: none;
1990
1978
  padding-right: 32px;
@@ -2341,11 +2329,6 @@ select option {
2341
2329
  display: none;
2342
2330
  }
2343
2331
 
2344
- /* Sit below the model row instead of covering it. */
2345
- #model-picker:not(.hidden) + #plan-card {
2346
- top: 138px;
2347
- }
2348
-
2349
2332
  .plan-card-head {
2350
2333
  display: flex;
2351
2334
  align-items: center;
@@ -3168,6 +3151,12 @@ html.pro-transitioning body::before {
3168
3151
  }
3169
3152
  }
3170
3153
 
3154
+ @keyframes spin {
3155
+ to {
3156
+ transform: rotate(360deg);
3157
+ }
3158
+ }
3159
+
3171
3160
  @media (prefers-reduced-motion: reduce) {
3172
3161
  .pro-slider-meteors { display: none; }
3173
3162
  *,
@@ -3296,27 +3285,6 @@ html.pro-transitioning body::before {
3296
3285
  height: 34px;
3297
3286
  }
3298
3287
 
3299
- .model-picker {
3300
- gap: 8px;
3301
- padding: 10px 12px;
3302
- max-height: 30vh;
3303
- max-height: 30dvh;
3304
- overflow: auto;
3305
- }
3306
-
3307
- .model-picker label {
3308
- flex: 1 1 calc(50% - 8px);
3309
- }
3310
-
3311
- .model-picker label:nth-child(2) {
3312
- max-width: none;
3313
- }
3314
-
3315
- .model-picker .control {
3316
- flex: 1 1 calc(50% - 8px);
3317
- height: 38px;
3318
- }
3319
-
3320
3288
  .messages {
3321
3289
  padding: 16px 14px 20px;
3322
3290
  }
@@ -3338,6 +3306,11 @@ html.pro-transitioning body::before {
3338
3306
  border-radius: 20px;
3339
3307
  }
3340
3308
 
3309
+ .model-picker-card {
3310
+ right: -48px;
3311
+ width: min(390px, calc(100vw - 32px));
3312
+ }
3313
+
3341
3314
  .composer textarea {
3342
3315
  max-height: min(220px, 25dvh);
3343
3316
  }
@@ -3483,13 +3456,6 @@ html.pro-transitioning body::before {
3483
3456
  overflow-y: auto;
3484
3457
  }
3485
3458
 
3486
- .model-picker {
3487
- flex-shrink: 1;
3488
- min-height: 44px;
3489
- padding-top: 6px;
3490
- padding-bottom: 6px;
3491
- }
3492
-
3493
3459
  .messages {
3494
3460
  min-height: 40px;
3495
3461
  padding-top: 8px;