@xingjisoft/neutrino 0.4.1 → 0.5.1

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/app.js CHANGED
@@ -257,6 +257,87 @@ async function answerBackgroundPrompt(enable) {
257
257
  }
258
258
  }
259
259
 
260
+ // ------------------------------------------------------------- update notice
261
+ //
262
+ // The server compares the version it runs with the one npm publishes, and both
263
+ // workspaces read that from the settings payload: a Pro tab announces a new
264
+ // release exactly like the regular console. Confirming installs it on this
265
+ // machine; the running process keeps the old code either way, so a successful
266
+ // update ends by telling the user to restart.
267
+ //
268
+ // Two dialogs never open at once: when an update is offered, the background
269
+ // invitation waits for the next page load (it is not consumed by being skipped).
270
+
271
+ let updatePrompted = false;
272
+
273
+ function maybePromptUpdate(config) {
274
+ const update = config?.update;
275
+ if (updatePrompted || !update?.has_update) return false;
276
+ if (!el.updateDialog || typeof el.updateDialog.showModal !== "function") return false;
277
+ updatePrompted = true;
278
+ if (el.updateVersions) {
279
+ el.updateVersions.textContent = tr("Running {current} · published {latest}", {
280
+ current: update.current_version, latest: update.latest_version,
281
+ });
282
+ }
283
+ if (el.updateFeedback) {
284
+ el.updateFeedback.textContent = "";
285
+ el.updateFeedback.classList.remove("error");
286
+ }
287
+ el.updateCommandBlock?.classList.add("hidden");
288
+ el.updateLater?.classList.remove("hidden");
289
+ el.updateNow?.classList.remove("hidden");
290
+ for (const button of [el.updateLater, el.updateNow, el.updateClose]) {
291
+ if (button) button.disabled = false;
292
+ }
293
+ if (el.updateNow) el.updateNow.textContent = tr("Update now");
294
+ try {
295
+ if (!el.updateDialog.open) el.updateDialog.showModal();
296
+ } catch {
297
+ return false; // An embedder without modal support still gets the console.
298
+ }
299
+ return true;
300
+ }
301
+
302
+ /** Run npm through the console's own server and report what happened. */
303
+ async function runUpdate() {
304
+ if (!el.updateNow || el.updateNow.disabled) return;
305
+ const buttons = [el.updateNow, el.updateLater, el.updateClose].filter(Boolean);
306
+ for (const button of buttons) button.disabled = true;
307
+ const feedback = el.updateFeedback;
308
+ if (feedback) {
309
+ feedback.textContent = tr("Updating with npm…");
310
+ feedback.classList.remove("error");
311
+ }
312
+ let body = {};
313
+ try {
314
+ const response = await apiFetch("/api/update", { method: "POST" });
315
+ body = await response.json().catch(() => ({}));
316
+ if (!response.ok || body.ok !== true) throw new Error(body.error || `HTTP ${response.status}`);
317
+ if (feedback) {
318
+ feedback.textContent = tr("Updated to {version}. Restart Neutrino to use it.", { version: body.version || "" });
319
+ feedback.classList.remove("error");
320
+ }
321
+ el.updateCommandBlock?.classList.add("hidden");
322
+ // The install is done: the only thing left is to close the notice.
323
+ el.updateLater?.classList.add("hidden");
324
+ el.updateNow?.classList.add("hidden");
325
+ } catch (error) {
326
+ if (feedback) {
327
+ feedback.textContent = tr("Update failed: {error}", { error: error.message });
328
+ feedback.classList.add("error");
329
+ }
330
+ // npm failures are usually something the user has to fix, so the dialog
331
+ // keeps the command that finishes the job by hand.
332
+ if (el.updateCommand && typeof body?.command === "string" && body.command) {
333
+ el.updateCommand.textContent = body.command;
334
+ }
335
+ el.updateCommandBlock?.classList.remove("hidden");
336
+ } finally {
337
+ for (const button of buttons) button.disabled = false;
338
+ }
339
+ }
340
+
260
341
  const el = {
261
342
  app: document.getElementById("app"),
262
343
  back: document.getElementById("back-to-menu"),
@@ -333,6 +414,14 @@ const el = {
333
414
  backgroundFeedback: document.getElementById("background-feedback"),
334
415
  backgroundCancel: document.getElementById("background-cancel"),
335
416
  backgroundEnable: document.getElementById("background-enable"),
417
+ updateDialog: document.getElementById("update-dialog"),
418
+ updateVersions: document.getElementById("update-versions"),
419
+ updateCommandBlock: document.getElementById("update-command-block"),
420
+ updateCommand: document.getElementById("update-command"),
421
+ updateFeedback: document.getElementById("update-feedback"),
422
+ updateClose: document.getElementById("update-close"),
423
+ updateLater: document.getElementById("update-later"),
424
+ updateNow: document.getElementById("update-now"),
336
425
  };
337
426
 
338
427
  if (el.backgroundCancel) {
@@ -348,6 +437,17 @@ if (el.backgroundDialog) {
348
437
  });
349
438
  }
350
439
 
440
+ if (el.updateNow) el.updateNow.addEventListener("click", () => { void runUpdate(); });
441
+ if (el.updateLater) el.updateLater.addEventListener("click", () => el.updateDialog?.close?.());
442
+ if (el.updateClose) el.updateClose.addEventListener("click", () => el.updateDialog?.close?.());
443
+ if (el.updateDialog) {
444
+ // Esc closes it like "Later": the notice is an offer, never a gate.
445
+ el.updateDialog.addEventListener("cancel", (event) => {
446
+ event.preventDefault();
447
+ el.updateDialog.close();
448
+ });
449
+ }
450
+
351
451
  const state = {
352
452
  overview: null,
353
453
  // { kind: "live", key } | { kind: "file", id } | { kind: "new", cwd } |
@@ -378,6 +478,9 @@ const state = {
378
478
  workspaces: null,
379
479
  // Translation key and colour class of the sidebar status line.
380
480
  status: { key: "connecting…", kind: "" },
481
+ // { identity: 1 } of finished conversations the reader has already opened,
482
+ // restored from the server before the first sidebar paint (see loadSeen).
483
+ seen: {},
381
484
  };
382
485
 
383
486
  function storedWorkspace() {
@@ -509,6 +612,11 @@ function announce(previous, next) {
509
612
  else if (previous.busy && !next.busy && !next.approval) chime("turn");
510
613
  }
511
614
 
615
+ /** Whether a live conversation has anything to show (and so belongs in the list). */
616
+ function liveHasContent(snapshot) {
617
+ return Boolean(snapshot.messages?.length || snapshot.streamingContent);
618
+ }
619
+
512
620
  // ------------------------------------------------------------------ helpers
513
621
 
514
622
  function escapeHtml(value) {
@@ -745,6 +853,154 @@ function isSelected(session) {
745
853
  return !session.liveKey && session.id === selection.id;
746
854
  }
747
855
 
856
+ // --------------------------------------------------- the "already seen" record
857
+ //
858
+ // A blue dot means "this conversation finished and you have not looked at it
859
+ // since". Opening a row records that, so the dot is not a permanent decoration;
860
+ // new activity forgets the record again, so the next finished turn gets a mark
861
+ // of its own. The record survives a reload and a restart of the console: a
862
+ // conversation the reader has already read must not keep asking.
863
+
864
+ /**
865
+ * Identity of a row in the record. A conversation keeps its id while it becomes
866
+ * live, so the id is the stable name; a conversation with no history yet has
867
+ * only its live key.
868
+ */
869
+ function sessionIdentity(session) {
870
+ if (session.id) return `id:${session.id}`;
871
+ return session.liveKey ? `live:${session.liveKey}` : "";
872
+ }
873
+
874
+ // Key of the record an older console kept in localStorage. It is only read once
875
+ // now, to hand that history over to the server.
876
+ const SEEN_KEY = "neutrino.seen";
877
+
878
+ /**
879
+ * Turn the server's list into the lookup the renderer uses.
880
+ *
881
+ * The record lives in ~/.neutrino/web.json (src/web/clientState.ts): one file
882
+ * the console owns, so the terminal UI never carries data it does not read and
883
+ * the answer survives a restart. localStorage could not do that — it is scoped
884
+ * to the address and port the console happened to listen on.
885
+ */
886
+ function seenMap(list) {
887
+ const out = {};
888
+ if (Array.isArray(list)) {
889
+ for (const identity of list) {
890
+ if (typeof identity === "string" && identity) out[identity] = 1;
891
+ }
892
+ }
893
+ return out;
894
+ }
895
+
896
+ /** Report a change. A write that fails costs one dot, so it is never surfaced. */
897
+ async function persistSeen(identities, seen) {
898
+ if (!identities.length) return true;
899
+ try {
900
+ const response = await apiFetch("/api/seen", {
901
+ method: "POST",
902
+ headers: { "Content-Type": "application/json" },
903
+ body: JSON.stringify({ identities, seen }),
904
+ });
905
+ return response.ok;
906
+ } catch {
907
+ return false;
908
+ }
909
+ }
910
+
911
+ /** The record left in localStorage by an older console, if any. */
912
+ function legacySeen() {
913
+ try {
914
+ const stored = JSON.parse(readStorage(modeStorageKey(SEEN_KEY)) || "{}");
915
+ return stored && typeof stored === "object" && !Array.isArray(stored) ? stored : {};
916
+ } catch {
917
+ return {};
918
+ }
919
+ }
920
+
921
+ /**
922
+ * Load the record before the first sidebar paint, so a reload never starts by
923
+ * marking every finished conversation again. The localStorage record of an
924
+ * older console is handed over once, and dropped as soon as the server has it.
925
+ */
926
+ async function loadSeen() {
927
+ let stored;
928
+ try {
929
+ const response = await apiFetch("/api/seen", { cache: "no-store" });
930
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
931
+ stored = seenMap((await response.json())?.seen);
932
+ } catch {
933
+ // Without a record every finished conversation shows its dot again, which
934
+ // is a readable page rather than a broken one.
935
+ return;
936
+ }
937
+ state.seen = stored;
938
+ const identities = Object.keys(legacySeen());
939
+ if (!identities.length) return;
940
+ const missing = identities.filter((identity) => !stored[identity]);
941
+ for (const identity of missing) state.seen[identity] = 1;
942
+ if (await persistSeen(missing, true)) writeStorage(modeStorageKey(SEEN_KEY), "");
943
+ }
944
+
945
+ function isSeen(identity) {
946
+ return Boolean(identity) && Boolean(state.seen[identity]);
947
+ }
948
+
949
+ /** The reader opened this row. */
950
+ function rememberSeen(identity) {
951
+ if (!identity || state.seen[identity]) return;
952
+ state.seen[identity] = 1;
953
+ // The dot is already gone from the page; the write only has to reach the disk.
954
+ void persistSeen([identity], true);
955
+ }
956
+
957
+ /** New activity: the turn that comes next deserves a mark of its own. */
958
+ function forgetSeen(identity) {
959
+ if (!identity || !state.seen[identity]) return;
960
+ delete state.seen[identity];
961
+ void persistSeen([identity], false);
962
+ }
963
+
964
+ /**
965
+ * Status of a live conversation: a tool call waiting for the user's decision, a
966
+ * turn in flight, or nothing left to run. An automated review answers for the
967
+ * user, so only manual approval leaves a conversation waiting on a human.
968
+ */
969
+ function statusOfLive(live) {
970
+ if (!live) return "done";
971
+ if (live.approval && (live.approvalMode ?? "request") === "request") return "approval";
972
+ return live.busy || live.paused || live.queued || live.compressing ? "running" : "done";
973
+ }
974
+
975
+ /** Status of a sidebar row; a saved transcript has finished by definition. */
976
+ function sessionStatus(session) {
977
+ if (!session.liveKey) return "done";
978
+ // The open stream is newer than the last overview poll.
979
+ const snapshot = state.snapshot;
980
+ if (snapshot && snapshot.key === session.liveKey) return statusOfLive(snapshot);
981
+ return statusOfLive((state.overview?.live ?? []).find((entry) => entry.key === session.liveKey));
982
+ }
983
+
984
+ /**
985
+ * The status mark of a row: blue dot when done, ring when running, amber when
986
+ * waiting. The blue dot asks for attention once — opening the conversation takes
987
+ * it away. Running and waiting are live states, so they are always drawn, and
988
+ * seeing one clears the record for the turn that follows.
989
+ */
990
+ function sessionStatusMark(session) {
991
+ const status = sessionStatus(session);
992
+ const identity = sessionIdentity(session);
993
+ if (status === "done") {
994
+ if (isSeen(identity)) return "";
995
+ } else {
996
+ forgetSeen(identity);
997
+ }
998
+ const label = status === "running"
999
+ ? tr("Running")
1000
+ : status === "approval" ? tr("Waiting for approval") : tr("Done");
1001
+ return `<span class="session-state ${status}" title="${escapeHtml(label)}"></span>`;
1002
+ }
1003
+
748
1004
  function renderSession(session) {
749
1005
  const item = document.createElement("button");
750
1006
  item.className = "session";
@@ -756,15 +1012,16 @@ function renderSession(session) {
756
1012
  item.title = title;
757
1013
  item.innerHTML =
758
1014
  `<div class="session-top"><span class="session-title">${escapeHtml(title)}</span>` +
759
- `${session.live ? `<span class="dot" title="${escapeHtml(tr("Live"))}"></span>` : ""}</div>` +
1015
+ `${sessionStatusMark(session)}</div>` +
760
1016
  `<div class="session-meta"><span class="model">${escapeHtml(session.model || "?")}</span>` +
761
1017
  `<span class="time">${escapeHtml(formatTime(session.updated_at))}</span>` +
762
1018
  `<span class="count">${escapeHtml(count === 1 ? tr("{n} message", { n: count }) : tr("{n} messages", { n: count }))}</span></div>`;
763
1019
 
764
1020
  item.addEventListener("click", () => {
1021
+ if (!session.liveKey && !session.id) return;
1022
+ rememberSeen(sessionIdentity(session));
765
1023
  if (session.liveKey) selectLive(session.liveKey);
766
- else if (session.id) selectFile(session.id);
767
- else return;
1024
+ else selectFile(session.id);
768
1025
  setConversationView(true);
769
1026
  });
770
1027
  return item;
@@ -774,13 +1031,99 @@ function renderSession(session) {
774
1031
 
775
1032
  // Only explicit navigation changes the mobile pane. Background refreshes and
776
1033
  // live-to-saved transitions must not take the user away from the menu.
777
- function setConversationView(open) {
1034
+ //
1035
+ // On a phone the two panes share one screen, so the browser's own Back button
1036
+ // has to walk the same levels as the back arrow in the header: from an open
1037
+ // conversation to the list, and Forward back into that conversation. Each
1038
+ // explicit navigation records one marked entry, and the entries beneath it are
1039
+ // the list.
1040
+ const CONVERSATION_ENTRY = "conversation";
1041
+
1042
+ function isConversationEntry(entry) {
1043
+ return Boolean(entry) && entry.neutrinoView === CONVERSATION_ENTRY;
1044
+ }
1045
+
1046
+ /** The narrow layout that swaps the panes; wider screens show both at once. */
1047
+ function stackedPanes() {
1048
+ try {
1049
+ return typeof window !== "undefined" && typeof window.matchMedia === "function"
1050
+ && window.matchMedia("(max-width: 760px)").matches;
1051
+ } catch {
1052
+ return false;
1053
+ }
1054
+ }
1055
+
1056
+ /** Record the open conversation as a level the browser can return from. */
1057
+ function pushConversationEntry() {
1058
+ try {
1059
+ // Forward restored this entry already; recording it twice would take two
1060
+ // presses to leave the conversation.
1061
+ if (isConversationEntry(history.state)) return;
1062
+ history.pushState({ neutrinoView: CONVERSATION_ENTRY }, "", location.href);
1063
+ } catch {
1064
+ // Without history the header's back arrow still closes the pane.
1065
+ }
1066
+ }
1067
+
1068
+ /** The list is the entry beneath an open conversation: step back onto it. */
1069
+ function stepBackToList() {
1070
+ try {
1071
+ if (isConversationEntry(history.state)) history.back();
1072
+ } catch {
1073
+ // Without history the header's back arrow still closes the pane.
1074
+ }
1075
+ }
1076
+
1077
+ function setConversationView(open, { record = true } = {}) {
1078
+ const wasOpen = el.app.classList.contains("conversation-open");
778
1079
  el.app.classList.toggle("conversation-open", open);
779
- if (!window.matchMedia("(max-width: 760px)").matches) return;
1080
+ const stacked = stackedPanes();
1081
+ // A click records one level; Back and Forward already moved the browser.
1082
+ if (record && stacked && open !== wasOpen) {
1083
+ if (open) pushConversationEntry();
1084
+ else stepBackToList();
1085
+ }
1086
+ if (!stacked) return;
780
1087
  if (open) el.back.focus();
781
1088
  else (el.sessions.querySelector(".session.selected") || el.refresh).focus();
782
1089
  }
783
1090
 
1091
+ /** Back and Forward: show the level the browser landed on, without recording it. */
1092
+ function followViewHistory(event) {
1093
+ setConversationView(isConversationEntry(event?.state), { record: false });
1094
+ }
1095
+
1096
+ /**
1097
+ * A reload starts on the list, but the browser is still on the entry that
1098
+ * recorded a conversation. Forget the mark, so the first Back press tells the
1099
+ * truth instead of leaving a screen that never looked like a conversation.
1100
+ */
1101
+ function forgetStaleConversationEntry() {
1102
+ try {
1103
+ if (!isConversationEntry(history.state)) return;
1104
+ history.replaceState(null, "", location.href);
1105
+ } catch {
1106
+ // No history in this embedder: there is nothing to correct.
1107
+ }
1108
+ }
1109
+
1110
+ /**
1111
+ * Rotating a phone crosses the width that swaps the panes. A conversation
1112
+ * opened while both panes were shown never recorded a level, so record it on
1113
+ * the way into the single-pane layout: otherwise Back would leave the console
1114
+ * from a screen that shows nothing but a conversation.
1115
+ */
1116
+ function watchPanes() {
1117
+ const query = typeof window !== "undefined" && typeof window.matchMedia === "function"
1118
+ ? window.matchMedia("(max-width: 760px)")
1119
+ : null;
1120
+ if (!query || typeof query.addEventListener !== "function") return;
1121
+ query.addEventListener("change", (event) => {
1122
+ if (!event?.matches) return;
1123
+ if (el.app.classList.contains("conversation-open")) pushConversationEntry();
1124
+ });
1125
+ }
1126
+
784
1127
  function resetMessagePane(text) {
785
1128
  el.messages.innerHTML = "";
786
1129
  const empty = document.createElement("div");
@@ -832,6 +1175,16 @@ function openStream(key) {
832
1175
  announce(previous, snapshot);
833
1176
  setStatus("live", "live");
834
1177
  renderLive();
1178
+ // A turn that finishes in the conversation on screen needs no blue dot:
1179
+ // recording it before the repaint keeps the mark off as the turn ends.
1180
+ if (snapshot.sessionId && statusOfLive(snapshot) === "done") rememberSeen(`id:${snapshot.sessionId}`);
1181
+ // The sidebar mirrors the open conversation, so a turn that starts, waits
1182
+ // for approval or finishes shows there instead of on the next poll.
1183
+ if (statusOfLive(previous) !== statusOfLive(snapshot)) renderSidebar();
1184
+ // A conversation stays out of the sidebar until it has content; the first
1185
+ // message makes it history, so list it now instead of on the next poll.
1186
+ const started = previous && !liveHasContent(previous) && liveHasContent(snapshot);
1187
+ if (started) loadOverview({ silent: true });
835
1188
  });
836
1189
 
837
1190
  source.addEventListener("gone", () => {
@@ -1522,10 +1875,11 @@ async function sendMessage() {
1522
1875
  if (!selection) return;
1523
1876
  const text = el.input.value;
1524
1877
  if (!text.trim()) return;
1525
- if (PRO_MODE) {
1526
- try { await proUI?.flush(); }
1527
- catch (error) { el.sub.textContent = error.message; return; }
1528
- }
1878
+ try {
1879
+ if (PRO_MODE) await proUI?.flush();
1880
+ else await preferences.flush?.();
1881
+ } catch (error) { el.sub.textContent = tr(error.message); return; }
1882
+ if (state.selection !== selection) return;
1529
1883
 
1530
1884
  if (selection.kind === "live") {
1531
1885
  el.input.value = "";
@@ -1655,13 +2009,14 @@ function showHome({ menu = true } = {}) {
1655
2009
 
1656
2010
  /** Show a folder as an empty workspace ready for its first message. */
1657
2011
  function renderWorkspaceLanding(path) {
2012
+ const sameLanding = state.selection?.kind === "new" && state.selection.cwd === path;
1658
2013
  state.selection = { kind: "new", cwd: path };
1659
2014
  state.snapshot = null;
1660
2015
  state.fileData = null;
1661
2016
  state.planOpen = false;
1662
2017
  state.planSeen = "";
1663
2018
  closeStream();
1664
- preferences.clear();
2019
+ if (!sameLanding) preferences.clear();
1665
2020
  renderSidebar();
1666
2021
  renderWorkspaceChrome();
1667
2022
  el.controls.innerHTML = "";
@@ -1670,6 +2025,7 @@ function renderWorkspaceLanding(path) {
1670
2025
  el.title.textContent = workspaceName(path) || tr("Working directory");
1671
2026
  el.sub.textContent = path;
1672
2027
  setComposerMode("new");
2028
+ preferences.showConversation({}, `new:${path}`);
1673
2029
  proUI?.show(null, `new:${path}`);
1674
2030
  el.messages.innerHTML = "";
1675
2031
 
@@ -2776,6 +3132,11 @@ el.folderDialog.addEventListener("cancel", (event) => {
2776
3132
  });
2777
3133
 
2778
3134
  window.addEventListener("beforeunload", closeStream);
3135
+ // On the phone layout the browser's Back and Forward buttons walk the same
3136
+ // levels as the header's back arrow: an open conversation returns to the list.
3137
+ window.addEventListener("popstate", followViewHistory);
3138
+ watchPanes();
3139
+ forgetStaleConversationEntry();
2779
3140
 
2780
3141
  initializeLanguage();
2781
3142
  initializeProWorkspace();
@@ -2785,12 +3146,12 @@ preferences.load()
2785
3146
  if (language && language !== uiLanguage()) {
2786
3147
  await changeInterfaceLanguage(language, { persist: false });
2787
3148
  }
2788
- // Offer background residency only after the interface language is settled,
2789
- // so the invitation is never briefly shown in the wrong language.
2790
- maybePromptBackground(config);
3149
+ // Offer the update first, and only then the background invitation: two
3150
+ // modal dialogs must never stack on top of each other.
3151
+ if (!maybePromptUpdate(config)) maybePromptBackground(config);
2791
3152
  })
2792
3153
  .catch(error => console.error(error));
2793
- loadOverview().then(() => {
3154
+ loadSeen().then(() => loadOverview()).then(() => {
2794
3155
  // Reopen the folder the user had open last time, without stealing a click
2795
3156
  // that landed on a saved conversation while the overview was loading.
2796
3157
  if (state.workspace && !state.selection) renderWorkspaceLanding(state.workspace.path);
package/web/i18n.js CHANGED
@@ -106,6 +106,15 @@ const ZH = {
106
106
  "Professional model availability": "专业模型可用性检查",
107
107
  "These model substitutions apply only to this task in this conversation. Your preset configuration is unchanged.": "以下模型替换仅对当前对话的本次任务生效,不会修改预设配置文件。",
108
108
  "Got it": "知道了",
109
+ "A new version is available": "有新版本可用",
110
+ "Neutrino updates itself with npm. Restart it afterwards to use the new version.": "Neutrino 通过 npm 自动更新,更新完成后请重新启动以使用新版本。",
111
+ "Running {current} · published {latest}": "当前版本 {current} · 最新版本 {latest}",
112
+ "Update now": "立即更新",
113
+ "Later": "稍后",
114
+ "Updating with npm…": "正在通过 npm 更新…",
115
+ "Updated to {version}. Restart Neutrino to use it.": "已更新到 {version},请重新启动 Neutrino 以使用新版本。",
116
+ "Update failed: {error}": "更新失败:{error}",
117
+ "Update by hand with:": "也可以手动更新:",
109
118
  // -- chrome -------------------------------------------------------------
110
119
  "Neutrino": "Neutrino",
111
120
  "Switch to Pro mode": "切换至Pro模式",
@@ -178,6 +187,15 @@ const ZH = {
178
187
  "Creating…": "正在创建…",
179
188
  "Provider": "服务商",
180
189
  "Model": "模型",
190
+ "Conversation model": "对话模型",
191
+ "Conversation model: {selection}": "对话模型:{selection}",
192
+ "Choose a model": "选择模型",
193
+ "(unavailable)": "(不可用)",
194
+ "Low": "低",
195
+ "Medium": "中",
196
+ "High": "高",
197
+ "Max": "极高",
198
+ "Ultra": "超高",
181
199
  "Thinking": "思考强度",
182
200
  "Default approval mode": "默认审批模式",
183
201
  "Request Approval": "请求审批",
@@ -221,6 +239,8 @@ const ZH = {
221
239
  "Show less": "收起",
222
240
  "Show {n} more": "再显示 {n} 个",
223
241
  "Live": "实时",
242
+ "Running": "运行中",
243
+ "Waiting for approval": "等待审批",
224
244
  "Earlier conversation · {n} messages": "更早的对话 · {n} 条消息",
225
245
  "Context summary · {n} messages": "上下文摘要 · {n} 条消息",
226
246
  "You": "你",
package/web/index.html CHANGED
@@ -102,14 +102,6 @@
102
102
  </button>
103
103
  </header>
104
104
 
105
- <form id="model-picker" class="model-picker hidden" aria-label="Conversation model">
106
- <label><span data-i18n="Provider">Provider</span><select id="model-provider"></select></label>
107
- <label><span data-i18n="Model">Model</span><select id="model-name"></select></label>
108
- <label><span data-i18n="Thinking">Thinking</span><select id="model-intensity"></select></label>
109
- <button id="model-apply" class="control" type="submit" data-i18n="Apply">Apply</button>
110
- <p id="model-feedback" class="form-feedback" role="status" aria-live="polite"></p>
111
- </form>
112
-
113
105
  <aside id="plan-card" class="plan-card hidden" aria-live="polite"></aside>
114
106
 
115
107
  <section id="messages" class="messages">
@@ -137,6 +129,24 @@
137
129
  <div class="composer-inner">
138
130
  <div class="composer-card">
139
131
  <textarea id="input" rows="1" placeholder="Message Neutrino…" aria-label="Message" data-i18n-placeholder="Message Neutrino…"></textarea>
132
+ <details id="model-picker" class="model-picker pro-preset-control hidden">
133
+ <summary id="model-picker-summary" aria-label="Conversation model" data-i18n-aria-label="Conversation model"><span id="model-picker-label"></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>
134
+ <div class="pro-preset-card model-picker-card">
135
+ <div class="pro-preset-heading"><span data-i18n="Conversation model">Conversation model</span><strong id="model-intensity-label"></strong></div>
136
+ <div class="model-picker-fields">
137
+ <label><span data-i18n="Provider">Provider</span><select id="model-provider"></select></label>
138
+ <label><span data-i18n="Model">Model</span><select id="model-name"></select></label>
139
+ </div>
140
+ <label class="pro-slider-label" for="model-intensity" data-i18n="Thinking intensity">Thinking intensity</label>
141
+ <div id="model-intensity-slider" class="pro-preset-slider">
142
+ <div class="pro-slider-track" aria-hidden="true"><div class="pro-slider-fill"></div></div>
143
+ <div id="model-intensity-stops" class="pro-slider-stops" aria-hidden="true"></div>
144
+ <input id="model-intensity" type="range" min="0" max="0" step="1" value="0" aria-label="Thinking intensity" data-i18n-aria-label="Thinking intensity" />
145
+ </div>
146
+ <div id="model-intensity-ticks" class="pro-preset-ticks"></div>
147
+ <p id="model-feedback" class="form-feedback" role="status" aria-live="polite"></p>
148
+ </div>
149
+ </details>
140
150
  <details id="pro-preset-control" class="pro-preset-control hidden">
141
151
  <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>
142
152
  <div class="pro-preset-card">
@@ -404,6 +414,29 @@
404
414
  </div>
405
415
  </dialog>
406
416
 
417
+ <dialog id="update-dialog" class="sheet-dialog" aria-labelledby="update-title">
418
+ <div class="sheet">
419
+ <header class="sheet-header">
420
+ <div>
421
+ <h2 id="update-title" data-i18n="A new version is available">A new version is available</h2>
422
+ <p id="update-versions" class="sheet-sub"></p>
423
+ </div>
424
+ <button id="update-close" class="icon-btn" type="button" aria-label="Close" data-i18n-aria-label="Close">
425
+ <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>
426
+ </button>
427
+ </header>
428
+ <div class="sheet-body">
429
+ <p id="update-note" class="settings-note" data-i18n="Neutrino updates itself with npm. Restart it afterwards to use the new version.">Neutrino updates itself with npm. Restart it afterwards to use the new version.</p>
430
+ <p id="update-command-block" class="settings-note hidden"><span data-i18n="Update by hand with:">Update by hand with:</span> <code id="update-command">npm install -g @xingjisoft/neutrino</code></p>
431
+ <p id="update-feedback" class="form-feedback" role="status" aria-live="polite"></p>
432
+ </div>
433
+ <footer class="sheet-actions">
434
+ <button id="update-later" class="control" type="button" data-i18n="Later">Later</button>
435
+ <button id="update-now" class="primary" type="button" data-i18n="Update now" autofocus>Update now</button>
436
+ </footer>
437
+ </div>
438
+ </dialog>
439
+
407
440
  <script src="app.js" type="module"></script>
408
441
  </body>
409
442
  </html>