@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/README.md +1 -1
- package/dist/app.js +55 -0
- package/dist/bin.js +21 -0
- package/dist/pro/config.js +135 -9
- package/dist/screens/start.js +26 -14
- package/dist/screens/updatePrompt.js +200 -0
- package/dist/ui.js +32 -16
- package/dist/update.js +213 -0
- package/dist/web/clientState.js +82 -0
- package/dist/web/server.js +87 -2
- package/dist/web/service.js +25 -0
- package/dist/web/session.js +1 -0
- package/dist/web/settings.js +9 -0
- package/package.json +2 -2
- package/web/README.md +28 -9
- package/web/app.js +375 -14
- package/web/i18n.js +20 -0
- package/web/index.html +41 -8
- package/web/settings.js +170 -44
- package/web/style.css +44 -78
- package/assets/pro/model-template.json +0 -376
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
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
|
|
2789
|
-
//
|
|
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>
|