@xingjisoft/neutrino 0.5.0 → 0.5.2
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/dist/pro/config.js +7 -3
- package/dist/update.js +8 -2
- package/dist/updateWatch.js +76 -0
- package/dist/web/server.js +126 -5
- package/dist/web/service.js +22 -0
- package/package.json +37 -37
- package/web/app.js +126 -13
- package/web/i18n.js +7 -0
- package/web/index.html +455 -442
- package/web/settings.js +31 -1
- package/web/style.css +3535 -3473
package/dist/pro/config.js
CHANGED
|
@@ -170,16 +170,20 @@ export function loadProModels() {
|
|
|
170
170
|
throw new Error(`Pro model.json is missing; it is downloaded from ${PRO_MODELS_URL} on first use`);
|
|
171
171
|
}
|
|
172
172
|
const { tier } = readProModels(PRO_MODEL_FILE, "model.json");
|
|
173
|
-
// Presets the user keeps in model-custom.json are merged in
|
|
174
|
-
//
|
|
173
|
+
// Presets the user keeps in model-custom.json are merged in. Their place in the
|
|
174
|
+
// list is read as strength (the slider runs from weak to strong), so entries that
|
|
175
|
+
// are not official rungs lead it: a preset named after an official one edits that
|
|
176
|
+
// rung in place, every other one sits left of the graded presets.
|
|
175
177
|
if (fs.existsSync(PRO_CUSTOM_MODEL_FILE)) {
|
|
178
|
+
const own = [];
|
|
176
179
|
for (const preset of readProModels(PRO_CUSTOM_MODEL_FILE, "model-custom.json").tier) {
|
|
177
180
|
const index = tier.findIndex((entry) => entry.name === preset.name);
|
|
178
181
|
if (index === -1)
|
|
179
|
-
|
|
182
|
+
own.push(preset);
|
|
180
183
|
else
|
|
181
184
|
tier[index] = preset;
|
|
182
185
|
}
|
|
186
|
+
tier.unshift(...own);
|
|
183
187
|
}
|
|
184
188
|
if (tier.length === 0)
|
|
185
189
|
throw new Error("Pro model.json must contain a nonempty tier array");
|
package/dist/update.js
CHANGED
|
@@ -29,9 +29,15 @@ export function updateStatus() {
|
|
|
29
29
|
* announce", which is what the next start is for. Concurrent callers share the
|
|
30
30
|
* one lookup, and a successful answer is kept for the rest of the process so
|
|
31
31
|
* the terminal and the Web console cannot disagree.
|
|
32
|
+
*
|
|
33
|
+
* Start-up asks once and is served from that answer from then on. A caller that
|
|
34
|
+
* passes `refresh` — the settings button, the background timer — queries the
|
|
35
|
+
* registry again, so an answer from an hour ago cannot make a check look idle.
|
|
36
|
+
* A failed refresh changes nothing: the last good answer stays, and the caller
|
|
37
|
+
* learns what went wrong from `error`.
|
|
32
38
|
*/
|
|
33
|
-
export function checkForUpdate() {
|
|
34
|
-
if (published)
|
|
39
|
+
export function checkForUpdate(options = {}) {
|
|
40
|
+
if (published && options.refresh !== true)
|
|
35
41
|
return Promise.resolve(updateStatus());
|
|
36
42
|
pending ??= lookupLatestVersion()
|
|
37
43
|
.then((version) => {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The periodic update check.
|
|
3
|
+
*
|
|
4
|
+
* A Web console can stay open for days, so the start-up lookup alone would
|
|
5
|
+
* never tell its windows about a release published afterwards. This module asks
|
|
6
|
+
* the registry again on a timer, in the process that serves those windows, and
|
|
7
|
+
* hands a release it has not seen before to whoever is listening: the console
|
|
8
|
+
* forwards it to every open browser (GET /api/update/events) and the first-open
|
|
9
|
+
* dialog reads the same answer.
|
|
10
|
+
*/
|
|
11
|
+
import { checkForUpdate, updateStatus } from "./update.js";
|
|
12
|
+
/** How often the background service asks the registry again. */
|
|
13
|
+
export const UPDATE_CHECK_INTERVAL_MS = 30 * 60 * 1000;
|
|
14
|
+
const listeners = new Set();
|
|
15
|
+
let running = null;
|
|
16
|
+
/**
|
|
17
|
+
* Listen for a release the periodic check finds. Returns the unsubscribe
|
|
18
|
+
* function.
|
|
19
|
+
*
|
|
20
|
+
* Only news is announced: a version this process already knows — from the
|
|
21
|
+
* start-up lookup, the settings button or an earlier tick — is not sent again,
|
|
22
|
+
* so the dialogs cannot reopen every half hour for a release the user has
|
|
23
|
+
* already declined.
|
|
24
|
+
*/
|
|
25
|
+
export function subscribeUpdateAnnouncements(listener) {
|
|
26
|
+
listeners.add(listener);
|
|
27
|
+
return () => {
|
|
28
|
+
listeners.delete(listener);
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Start asking the registry every `intervalMs` (30 minutes by default).
|
|
33
|
+
*
|
|
34
|
+
* Idempotent, and the returned function stops the timer and leaves the module
|
|
35
|
+
* ready to start again. Checks run off the timer rather than at start-up: both
|
|
36
|
+
* the console and the terminal UI already look once when they start, and a
|
|
37
|
+
* second immediate lookup would only spend npm calls on the same answer.
|
|
38
|
+
*/
|
|
39
|
+
export function startUpdateWatch(options = {}) {
|
|
40
|
+
if (running)
|
|
41
|
+
return running;
|
|
42
|
+
const timer = setInterval(() => {
|
|
43
|
+
// The remembered answer is read before the lookup, because the lookup itself
|
|
44
|
+
// updates it: what matters is whether this check is what discovered the
|
|
45
|
+
// release.
|
|
46
|
+
const known = updateStatus().latestVersion;
|
|
47
|
+
// `refresh` is what makes a timer useful: without it checkForUpdate keeps
|
|
48
|
+
// returning the answer this process already has and would never see a new
|
|
49
|
+
// release.
|
|
50
|
+
void checkForUpdate({ refresh: true }).then((check) => {
|
|
51
|
+
try {
|
|
52
|
+
options.onCheck?.(check);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// A failing log must not stop the check that follows it.
|
|
56
|
+
}
|
|
57
|
+
if (!check.hasUpdate || check.latestVersion === known)
|
|
58
|
+
return;
|
|
59
|
+
for (const listener of [...listeners]) {
|
|
60
|
+
try {
|
|
61
|
+
listener(check);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// One broken window must not starve the others.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}, options.intervalMs ?? UPDATE_CHECK_INTERVAL_MS);
|
|
69
|
+
// A check in flight must never keep a shutting-down process alive.
|
|
70
|
+
timer.unref?.();
|
|
71
|
+
running = () => {
|
|
72
|
+
clearInterval(timer);
|
|
73
|
+
running = null;
|
|
74
|
+
};
|
|
75
|
+
return running;
|
|
76
|
+
}
|
package/dist/web/server.js
CHANGED
|
@@ -29,7 +29,8 @@ import { defaultProPreset, ensureProModels, requireProPreset } from "../pro/conf
|
|
|
29
29
|
import { listAgentSessions, loadAgentMessages, loadAgentSession, safeAgentSessionId } from "../agents.js";
|
|
30
30
|
import { migrateAgentSession, SessionMigrationError } from "../migration.js";
|
|
31
31
|
import { runningBackground } from "../background.js";
|
|
32
|
-
import { MANUAL_UPDATE_COMMAND, installUpdate, updateStatus } from "../update.js";
|
|
32
|
+
import { MANUAL_UPDATE_COMMAND, checkForUpdate, installUpdate, updateStatus } from "../update.js";
|
|
33
|
+
import { subscribeUpdateAnnouncements } from "../updateWatch.js";
|
|
33
34
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
34
35
|
// dist/web -> package root (mirrors the same trick used in config.ts).
|
|
35
36
|
const PKG_ROOT = path.resolve(__dirname, "..", "..");
|
|
@@ -604,7 +605,88 @@ function streamLive(key, req, res) {
|
|
|
604
605
|
// Without this, a client reset during a write is an uncaught 'error' event.
|
|
605
606
|
res.on("error", cleanup);
|
|
606
607
|
}
|
|
607
|
-
|
|
608
|
+
/**
|
|
609
|
+
* The windows watching for update announcements. The console owns one of these
|
|
610
|
+
* so closing it can end those streams: a browser reconnects an EventSource by
|
|
611
|
+
* itself, so an open tab would otherwise keep `close` waiting for a socket that
|
|
612
|
+
* will never go quiet on its own.
|
|
613
|
+
*/
|
|
614
|
+
function createUpdateStreams() {
|
|
615
|
+
const open = new Set();
|
|
616
|
+
return {
|
|
617
|
+
add(res) {
|
|
618
|
+
open.add(res);
|
|
619
|
+
},
|
|
620
|
+
remove(res) {
|
|
621
|
+
open.delete(res);
|
|
622
|
+
},
|
|
623
|
+
closeAll() {
|
|
624
|
+
for (const res of [...open]) {
|
|
625
|
+
open.delete(res);
|
|
626
|
+
try {
|
|
627
|
+
res.end();
|
|
628
|
+
}
|
|
629
|
+
catch { /* the window is already gone */ }
|
|
630
|
+
}
|
|
631
|
+
},
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* The periodic check's findings, as an event stream. A window that opens after
|
|
636
|
+
* a release was already found still has to learn about it, so the answer this
|
|
637
|
+
* process holds goes out first: the same status /api/settings hands the
|
|
638
|
+
* first-open dialog, with the same fields the manual check returns.
|
|
639
|
+
*
|
|
640
|
+
* Both workspaces read it. An update belongs to the process, not to a
|
|
641
|
+
* conversation, so a Pro tab and a standard tab are told about the same release.
|
|
642
|
+
*/
|
|
643
|
+
function streamUpdateEvents(req, res, streams) {
|
|
644
|
+
res.writeHead(200, {
|
|
645
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
646
|
+
"Cache-Control": "no-cache, no-transform",
|
|
647
|
+
Connection: "keep-alive",
|
|
648
|
+
"X-Accel-Buffering": "no",
|
|
649
|
+
});
|
|
650
|
+
// Same guard as streamLive: a browser that closed or reloaded the tab leaves
|
|
651
|
+
// a socket writes fail on, and an unhandled 'error' event would stop the process.
|
|
652
|
+
const write = (chunk) => {
|
|
653
|
+
if (res.destroyed || res.writableEnded)
|
|
654
|
+
return false;
|
|
655
|
+
try {
|
|
656
|
+
res.write(chunk);
|
|
657
|
+
return true;
|
|
658
|
+
}
|
|
659
|
+
catch {
|
|
660
|
+
return false;
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
const send = (status, error = "") => {
|
|
664
|
+
write(`event: update\ndata: ${JSON.stringify({
|
|
665
|
+
has_update: status.hasUpdate,
|
|
666
|
+
current_version: status.currentVersion,
|
|
667
|
+
latest_version: status.latestVersion,
|
|
668
|
+
error,
|
|
669
|
+
})}\n\n`);
|
|
670
|
+
};
|
|
671
|
+
write("retry: 2000\n\n");
|
|
672
|
+
send(updateStatus());
|
|
673
|
+
streams.add(res);
|
|
674
|
+
const unsubscribe = subscribeUpdateAnnouncements((check) => send(check, check.error ?? ""));
|
|
675
|
+
const keepAlive = setInterval(() => {
|
|
676
|
+
if (!write(": ping\n\n"))
|
|
677
|
+
cleanup();
|
|
678
|
+
}, 25_000);
|
|
679
|
+
keepAlive.unref?.();
|
|
680
|
+
function cleanup() {
|
|
681
|
+
clearInterval(keepAlive);
|
|
682
|
+
unsubscribe();
|
|
683
|
+
streams.remove(res);
|
|
684
|
+
}
|
|
685
|
+
req.on("close", cleanup);
|
|
686
|
+
req.on("error", cleanup);
|
|
687
|
+
res.on("error", cleanup);
|
|
688
|
+
}
|
|
689
|
+
async function handleApi(req, res, url, auth, mode, pathname, binding, updates) {
|
|
608
690
|
if (!pathname.startsWith("/api/"))
|
|
609
691
|
return false;
|
|
610
692
|
// The Pro presets are not bundled either: the first Pro request makes sure they
|
|
@@ -748,6 +830,39 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
|
748
830
|
}
|
|
749
831
|
return true;
|
|
750
832
|
}
|
|
833
|
+
// The 30-minute background check announces a release here, so a window that
|
|
834
|
+
// stays open learns about it without reloading. Both workspaces open it.
|
|
835
|
+
if (req.method === "GET" && pathname === "/api/update/events") {
|
|
836
|
+
streamUpdateEvents(req, res, updates);
|
|
837
|
+
return true;
|
|
838
|
+
}
|
|
839
|
+
// The settings page's "Check for updates" button. Start-up reads the registry
|
|
840
|
+
// once and the Web console is served from that answer, so a manual check has
|
|
841
|
+
// to ask again; otherwise the button would keep showing whatever was true
|
|
842
|
+
// when the process started. Like the install below it runs npm on this
|
|
843
|
+
// machine, so it sits behind the login cookie and the same-origin check.
|
|
844
|
+
//
|
|
845
|
+
// The answer is a status, not an HTTP failure: an unreachable registry leaves
|
|
846
|
+
// `error` set on a 200 response, which is what the browser shows next to the
|
|
847
|
+
// button. A failure also changes nothing — the last good answer stays.
|
|
848
|
+
if (pathname === "/api/update/check") {
|
|
849
|
+
if (req.method !== "POST") {
|
|
850
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
851
|
+
return true;
|
|
852
|
+
}
|
|
853
|
+
if (!isSameOrigin(req)) {
|
|
854
|
+
sendJson(res, 403, { error: "cross-origin request rejected" });
|
|
855
|
+
return true;
|
|
856
|
+
}
|
|
857
|
+
const check = await checkForUpdate({ refresh: true });
|
|
858
|
+
sendJson(res, 200, {
|
|
859
|
+
has_update: check.hasUpdate,
|
|
860
|
+
current_version: check.currentVersion,
|
|
861
|
+
latest_version: check.latestVersion,
|
|
862
|
+
error: check.error ?? "",
|
|
863
|
+
});
|
|
864
|
+
return true;
|
|
865
|
+
}
|
|
751
866
|
// The update prompt's confirm button: npm runs on this machine, so the route
|
|
752
867
|
// sits behind the login cookie and the same-origin check like every other
|
|
753
868
|
// mutation, and only a release the registry announced can be installed.
|
|
@@ -1308,7 +1423,7 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
|
1308
1423
|
sendJson(res, 404, { error: "not found" });
|
|
1309
1424
|
return true;
|
|
1310
1425
|
}
|
|
1311
|
-
async function handleRequest(req, res, auth, binding) {
|
|
1426
|
+
async function handleRequest(req, res, auth, binding, updates) {
|
|
1312
1427
|
let url;
|
|
1313
1428
|
try {
|
|
1314
1429
|
url = new URL(req.url ?? "/", "http://localhost");
|
|
@@ -1341,7 +1456,7 @@ async function handleRequest(req, res, auth, binding) {
|
|
|
1341
1456
|
mode = "pro";
|
|
1342
1457
|
apiPathname = pathname.slice("/pro".length);
|
|
1343
1458
|
}
|
|
1344
|
-
if (await handleApi(req, res, url, auth, mode, apiPathname, binding))
|
|
1459
|
+
if (await handleApi(req, res, url, auth, mode, apiPathname, binding, updates))
|
|
1345
1460
|
return;
|
|
1346
1461
|
if (mode === "pro") {
|
|
1347
1462
|
// Nothing else lives under /pro but the page and its API.
|
|
@@ -1371,8 +1486,11 @@ export async function startWebServer(options) {
|
|
|
1371
1486
|
service: options.backgroundService,
|
|
1372
1487
|
updateSettings: (body, mode) => updateSettings(body, mode),
|
|
1373
1488
|
};
|
|
1489
|
+
// Every window watching for an update belongs to this console, not the module,
|
|
1490
|
+
// so a second embedded server (a test, an embedding) keeps its own set.
|
|
1491
|
+
const updates = createUpdateStreams();
|
|
1374
1492
|
const server = http.createServer((req, res) => {
|
|
1375
|
-
handleRequest(req, res, auth, binding).catch((err) => {
|
|
1493
|
+
handleRequest(req, res, auth, binding, updates).catch((err) => {
|
|
1376
1494
|
if (!res.headersSent) {
|
|
1377
1495
|
sendJson(res, 500, { error: err instanceof Error ? err.message : "internal error" });
|
|
1378
1496
|
}
|
|
@@ -1513,6 +1631,9 @@ export async function startWebServer(options) {
|
|
|
1513
1631
|
setHost: (next) => rebind(next),
|
|
1514
1632
|
close: () => new Promise((resolve) => {
|
|
1515
1633
|
closed = true;
|
|
1634
|
+
// These streams never end on their own, so they are closed explicitly;
|
|
1635
|
+
// otherwise server.close() would wait for a window that stays open.
|
|
1636
|
+
updates.closeAll();
|
|
1516
1637
|
for (const key of [...webSessions.keys()])
|
|
1517
1638
|
closeWebSession(key);
|
|
1518
1639
|
if (!server.listening) {
|
package/dist/web/service.js
CHANGED
|
@@ -5,6 +5,7 @@ import { loadConfig, saveConfig } from "../config.js";
|
|
|
5
5
|
import { startBackgroundTray } from "../tray.js";
|
|
6
6
|
import { PRO_MODELS_URL, syncProModels } from "../pro/config.js";
|
|
7
7
|
import { checkForUpdate, UPDATE_PACKAGE } from "../update.js";
|
|
8
|
+
import { startUpdateWatch } from "../updateWatch.js";
|
|
8
9
|
import { hub } from "./hub.js";
|
|
9
10
|
import { startWebServer } from "./server.js";
|
|
10
11
|
import { TerminalBridges } from "./terminalBridge.js";
|
|
@@ -18,6 +19,7 @@ export async function runWebService(options) {
|
|
|
18
19
|
let handle = null;
|
|
19
20
|
let tray = null;
|
|
20
21
|
let monitor = null;
|
|
22
|
+
let stopUpdateWatch = null;
|
|
21
23
|
let stopping = false;
|
|
22
24
|
const bridges = new TerminalBridges();
|
|
23
25
|
const status = () => {
|
|
@@ -44,6 +46,8 @@ export async function runWebService(options) {
|
|
|
44
46
|
stopping = true;
|
|
45
47
|
if (monitor)
|
|
46
48
|
clearInterval(monitor);
|
|
49
|
+
stopUpdateWatch?.();
|
|
50
|
+
stopUpdateWatch = null;
|
|
47
51
|
tray?.close();
|
|
48
52
|
bridges.close();
|
|
49
53
|
backgroundLog(`stopping pid=${process.pid}`);
|
|
@@ -131,6 +135,24 @@ export async function runWebService(options) {
|
|
|
131
135
|
}
|
|
132
136
|
backgroundLog(`started pid=${process.pid} on ${handle.url} (residency ${resident ? "on" : "off"})`);
|
|
133
137
|
syncTray();
|
|
138
|
+
// The start-up lookup above is one answer; a console that stays open needs
|
|
139
|
+
// more than that. Every 30 minutes the registry is asked again and a release
|
|
140
|
+
// it has not seen before is announced to every open window
|
|
141
|
+
// (GET /api/update/events), which is the same status the first-open dialog
|
|
142
|
+
// reads, so both ways of telling the user come from one source.
|
|
143
|
+
let loggedUpdate = "";
|
|
144
|
+
stopUpdateWatch = startUpdateWatch({
|
|
145
|
+
onCheck: (result) => {
|
|
146
|
+
// Log a change, not the same answer every half hour.
|
|
147
|
+
const line = result.hasUpdate
|
|
148
|
+
? `${UPDATE_PACKAGE} ${result.latestVersion} is available (running ${result.currentVersion})`
|
|
149
|
+
: result.error ? `update check: ${result.error}` : "";
|
|
150
|
+
if (!line || line === loggedUpdate)
|
|
151
|
+
return;
|
|
152
|
+
loggedUpdate = line;
|
|
153
|
+
backgroundLog(line);
|
|
154
|
+
},
|
|
155
|
+
});
|
|
134
156
|
// No helper processes: polling pids must remain invisible on Windows. An
|
|
135
157
|
// orphaned, non-resident console lets an in-flight browser turn finish before
|
|
136
158
|
// exiting, so disabling residency also cannot throw away running work.
|
package/package.json
CHANGED
|
@@ -1,37 +1,37 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@xingjisoft/neutrino",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "XINGJI Neutrino - a CLI coding agent in the terminal.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"license": "Apache-2.0",
|
|
7
|
-
"bin": {
|
|
8
|
-
"neutrino": "dist/bin.js"
|
|
9
|
-
},
|
|
10
|
-
"main": "dist/app.js",
|
|
11
|
-
"files": [
|
|
12
|
-
"dist",
|
|
13
|
-
"assets",
|
|
14
|
-
"web"
|
|
15
|
-
],
|
|
16
|
-
"engines": {
|
|
17
|
-
"node": ">=18.0.0"
|
|
18
|
-
},
|
|
19
|
-
"scripts": {
|
|
20
|
-
"build": "tsc -p tsconfig.json",
|
|
21
|
-
"test": "npm run build && node scripts/test-paste.mjs && node scripts/test-mouse.mjs && node scripts/test-cursor.mjs && node scripts/test-selection.mjs && node scripts/test-drag-scroll.mjs && node scripts/test-selection-performance.mjs && node scripts/test-copy.mjs && node scripts/test-scrollbar.mjs && node scripts/test-render.mjs && node scripts/test-drift.mjs && node scripts/test-sanitize.mjs && node scripts/test-markdown.mjs && node scripts/test-wrap.mjs && node scripts/test-fetch.mjs && node scripts/test-compact.mjs && node scripts/test-history-fold.mjs && node scripts/test-web-history.mjs && node scripts/test-web-mobile-history.mjs && node scripts/test-session-migration.mjs && node scripts/test-web-session-migration.mjs && node scripts/test-web-default.mjs && node scripts/test-turn-fold.mjs && node scripts/test-long-turn.mjs && node scripts/test-plan.mjs && node scripts/test-subagent.mjs && node scripts/test-web-settings.mjs && node scripts/test-update.mjs && node scripts/test-update-ui.mjs && node scripts/test-update-web.mjs && node scripts/test-model-intensity.mjs && node scripts/test-background.mjs && node scripts/test-background-lifetime.mjs && node scripts/test-web-markdown.mjs && node scripts/test-web-i18n.mjs && node scripts/test-web-workspaces.mjs && node scripts/test-tools.mjs && node scripts/test-approval.mjs && node scripts/test-approval-review.mjs && node scripts/test-approval-ui.mjs && node scripts/test-web-approval-details.mjs && node scripts/test-web-sidebar-status.mjs && node scripts/test-web-seen.mjs && node scripts/test-approval-mode.mjs && node scripts/test-approval-context.mjs && node scripts/test-settings-render.mjs && node scripts/test-approval-flow.mjs && node scripts/test-web.mjs && node scripts/test-web-settings-http.mjs && node scripts/test-web-lan.mjs && node scripts/test-web-auth.mjs && node scripts/test-web-auth-ui.mjs && node scripts/test-web-pro.mjs && node scripts/test-pro-preflight.mjs && node scripts/test-pro-runtime.mjs && node scripts/test-pro-team.mjs && node scripts/test-pro-ui.mjs && node scripts/test-interrupt.mjs && node scripts/test-terminal-lifecycle.mjs && node scripts/test-persistence.mjs && node scripts/test-resilience.mjs && node scripts/test-approval-timeout.mjs && node scripts/test-approval-hardening.mjs && node scripts/test-approval-extreme.mjs && node scripts/test-approval-reason.mjs",
|
|
22
|
-
"prepare": "npm run build",
|
|
23
|
-
"web": "node dist/bin.js web"
|
|
24
|
-
},
|
|
25
|
-
"keywords": [
|
|
26
|
-
"cli",
|
|
27
|
-
"agent",
|
|
28
|
-
"ai",
|
|
29
|
-
"terminal",
|
|
30
|
-
"llm",
|
|
31
|
-
"coding"
|
|
32
|
-
],
|
|
33
|
-
"devDependencies": {
|
|
34
|
-
"@types/node": "^22.0.0",
|
|
35
|
-
"typescript": "^5.5.0"
|
|
36
|
-
}
|
|
37
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@xingjisoft/neutrino",
|
|
3
|
+
"version": "0.5.2",
|
|
4
|
+
"description": "XINGJI Neutrino - a CLI coding agent in the terminal.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"bin": {
|
|
8
|
+
"neutrino": "dist/bin.js"
|
|
9
|
+
},
|
|
10
|
+
"main": "dist/app.js",
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"assets",
|
|
14
|
+
"web"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18.0.0"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc -p tsconfig.json",
|
|
21
|
+
"test": "npm run build && node scripts/test-paste.mjs && node scripts/test-mouse.mjs && node scripts/test-cursor.mjs && node scripts/test-selection.mjs && node scripts/test-drag-scroll.mjs && node scripts/test-selection-performance.mjs && node scripts/test-copy.mjs && node scripts/test-scrollbar.mjs && node scripts/test-render.mjs && node scripts/test-drift.mjs && node scripts/test-sanitize.mjs && node scripts/test-markdown.mjs && node scripts/test-wrap.mjs && node scripts/test-fetch.mjs && node scripts/test-compact.mjs && node scripts/test-history-fold.mjs && node scripts/test-web-history.mjs && node scripts/test-web-mobile-history.mjs && node scripts/test-session-migration.mjs && node scripts/test-web-session-migration.mjs && node scripts/test-web-default.mjs && node scripts/test-turn-fold.mjs && node scripts/test-long-turn.mjs && node scripts/test-plan.mjs && node scripts/test-subagent.mjs && node scripts/test-web-settings.mjs && node scripts/test-update.mjs && node scripts/test-update-ui.mjs && node scripts/test-update-web.mjs && node scripts/test-settings-update.mjs && node scripts/test-update-check.mjs && node scripts/test-update-watch.mjs && node scripts/test-model-intensity.mjs && node scripts/test-background.mjs && node scripts/test-background-lifetime.mjs && node scripts/test-web-markdown.mjs && node scripts/test-web-i18n.mjs && node scripts/test-web-workspaces.mjs && node scripts/test-tools.mjs && node scripts/test-approval.mjs && node scripts/test-approval-review.mjs && node scripts/test-approval-ui.mjs && node scripts/test-web-approval-details.mjs && node scripts/test-web-sidebar-status.mjs && node scripts/test-web-seen.mjs && node scripts/test-approval-mode.mjs && node scripts/test-approval-context.mjs && node scripts/test-settings-render.mjs && node scripts/test-approval-flow.mjs && node scripts/test-web.mjs && node scripts/test-web-settings-http.mjs && node scripts/test-web-lan.mjs && node scripts/test-web-auth.mjs && node scripts/test-web-auth-ui.mjs && node scripts/test-web-pro.mjs && node scripts/test-pro-preflight.mjs && node scripts/test-pro-runtime.mjs && node scripts/test-pro-team.mjs && node scripts/test-pro-ui.mjs && node scripts/test-interrupt.mjs && node scripts/test-terminal-lifecycle.mjs && node scripts/test-persistence.mjs && node scripts/test-resilience.mjs && node scripts/test-approval-timeout.mjs && node scripts/test-approval-hardening.mjs && node scripts/test-approval-extreme.mjs && node scripts/test-approval-reason.mjs",
|
|
22
|
+
"prepare": "npm run build",
|
|
23
|
+
"web": "node dist/bin.js web"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"cli",
|
|
27
|
+
"agent",
|
|
28
|
+
"ai",
|
|
29
|
+
"terminal",
|
|
30
|
+
"llm",
|
|
31
|
+
"coding"
|
|
32
|
+
],
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^22.0.0",
|
|
35
|
+
"typescript": "^5.5.0"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/web/app.js
CHANGED
|
@@ -145,6 +145,7 @@ function showGate(message = "") {
|
|
|
145
145
|
if (gateShown) return;
|
|
146
146
|
gateShown = true;
|
|
147
147
|
closeStream();
|
|
148
|
+
closeUpdateEvents();
|
|
148
149
|
setStatus("locked", "offline");
|
|
149
150
|
document.getElementById("settings-dialog").close();
|
|
150
151
|
document.getElementById("background-dialog")?.close?.();
|
|
@@ -259,20 +260,57 @@ async function answerBackgroundPrompt(enable) {
|
|
|
259
260
|
|
|
260
261
|
// ------------------------------------------------------------- update notice
|
|
261
262
|
//
|
|
262
|
-
// The server compares the version it runs with the one npm publishes
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
263
|
+
// The server compares the version it runs with the one npm publishes — at
|
|
264
|
+
// start-up, every 30 minutes and on demand from Settings — and every answer
|
|
265
|
+
// reaches the page through one announcement stream, so the notice shown when
|
|
266
|
+
// the console opens and the corner badge cannot disagree about the release. A
|
|
267
|
+
// Pro tab listens to the same stream under its own API prefix. Confirming
|
|
268
|
+
// installs it on this machine; the running process keeps the old code either
|
|
269
|
+
// way, so a successful update ends by telling the user to restart.
|
|
267
270
|
//
|
|
268
|
-
// Two dialogs never open at once:
|
|
269
|
-
//
|
|
271
|
+
// Two dialogs never open at once: the background invitation is modal too, so a
|
|
272
|
+
// release announced while it is up is offered once it is out of the way.
|
|
270
273
|
|
|
271
274
|
let updatePrompted = false;
|
|
272
275
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
+
// The release the page currently knows about. The notice and the corner badge
|
|
277
|
+
// read this one value, so the reminder can never name a different version.
|
|
278
|
+
let updateOffer = null;
|
|
279
|
+
|
|
280
|
+
// The console's own announcement stream, and the release that arrived while the
|
|
281
|
+
// background invitation was holding the screen.
|
|
282
|
+
let updateSource = null;
|
|
283
|
+
let pendingUpdate = null;
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Remember a release and light the corner badge. The announcement stream and a
|
|
287
|
+
* check started in Settings both report here.
|
|
288
|
+
*/
|
|
289
|
+
function noteUpdateAvailable(update) {
|
|
290
|
+
if (!update?.has_update) return false;
|
|
291
|
+
updateOffer = update;
|
|
292
|
+
if (el.updateBadge) {
|
|
293
|
+
const label = tr("A new version is available");
|
|
294
|
+
el.updateBadge.title = label;
|
|
295
|
+
el.updateBadge.setAttribute("aria-label", label);
|
|
296
|
+
el.updateBadge.classList.remove("hidden");
|
|
297
|
+
}
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** The release is installed or was never on offer: drop the reminder. */
|
|
302
|
+
function clearUpdateBadge() {
|
|
303
|
+
updateOffer = null;
|
|
304
|
+
pendingUpdate = null;
|
|
305
|
+
el.updateBadge?.classList.add("hidden");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Offer a known release. The notice shown on load and a check started from
|
|
310
|
+
* Settings both end up here, so an update is announced with the same words and
|
|
311
|
+
* the same buttons however the user found out about it.
|
|
312
|
+
*/
|
|
313
|
+
function openUpdateDialog(update) {
|
|
276
314
|
if (!el.updateDialog || typeof el.updateDialog.showModal !== "function") return false;
|
|
277
315
|
updatePrompted = true;
|
|
278
316
|
if (el.updateVersions) {
|
|
@@ -299,6 +337,56 @@ function maybePromptUpdate(config) {
|
|
|
299
337
|
return true;
|
|
300
338
|
}
|
|
301
339
|
|
|
340
|
+
/**
|
|
341
|
+
* Announce a release. The console pushes what it knows when a tab connects and
|
|
342
|
+
* again whenever the 30-minute background check finds a new one, so a window
|
|
343
|
+
* that stays open is told in the same words as one that has just loaded.
|
|
344
|
+
*/
|
|
345
|
+
function applyUpdateStatus(update) {
|
|
346
|
+
// The badge is lit even when the notice was already shown, so a dismissed
|
|
347
|
+
// offer can still be picked up again from the corner.
|
|
348
|
+
if (!noteUpdateAvailable(update)) {
|
|
349
|
+
// The console reports no release any more, so the reminder has nothing
|
|
350
|
+
// left to stand for.
|
|
351
|
+
clearUpdateBadge();
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
if (updatePrompted) return false;
|
|
355
|
+
// The background invitation is a modal dialog too, and two of them must never
|
|
356
|
+
// stack: this release is offered once that one is out of the way.
|
|
357
|
+
if (el.backgroundDialog?.open) {
|
|
358
|
+
pendingUpdate = update;
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
return openUpdateDialog(update);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Listen for the console's announcements. EventSource reconnects by itself, so
|
|
366
|
+
* a dropped connection only leaves the page with the release it already knows.
|
|
367
|
+
*/
|
|
368
|
+
function openUpdateEvents() {
|
|
369
|
+
if (updateSource) return;
|
|
370
|
+
const source = new EventSource(apiPath("/api/update/events"));
|
|
371
|
+
updateSource = source;
|
|
372
|
+
source.addEventListener("update", (event) => {
|
|
373
|
+
let update;
|
|
374
|
+
try {
|
|
375
|
+
update = JSON.parse(event.data);
|
|
376
|
+
} catch {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
applyUpdateStatus(update);
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** The console is locked behind the login gate: its announcements are moot. */
|
|
384
|
+
function closeUpdateEvents() {
|
|
385
|
+
if (!updateSource) return;
|
|
386
|
+
updateSource.close();
|
|
387
|
+
updateSource = null;
|
|
388
|
+
}
|
|
389
|
+
|
|
302
390
|
/** Run npm through the console's own server and report what happened. */
|
|
303
391
|
async function runUpdate() {
|
|
304
392
|
if (!el.updateNow || el.updateNow.disabled) return;
|
|
@@ -322,6 +410,7 @@ async function runUpdate() {
|
|
|
322
410
|
// The install is done: the only thing left is to close the notice.
|
|
323
411
|
el.updateLater?.classList.add("hidden");
|
|
324
412
|
el.updateNow?.classList.add("hidden");
|
|
413
|
+
clearUpdateBadge();
|
|
325
414
|
} catch (error) {
|
|
326
415
|
if (feedback) {
|
|
327
416
|
feedback.textContent = tr("Update failed: {error}", { error: error.message });
|
|
@@ -422,6 +511,7 @@ const el = {
|
|
|
422
511
|
updateClose: document.getElementById("update-close"),
|
|
423
512
|
updateLater: document.getElementById("update-later"),
|
|
424
513
|
updateNow: document.getElementById("update-now"),
|
|
514
|
+
updateBadge: document.getElementById("update-badge"),
|
|
425
515
|
};
|
|
426
516
|
|
|
427
517
|
if (el.backgroundCancel) {
|
|
@@ -435,6 +525,14 @@ if (el.backgroundDialog) {
|
|
|
435
525
|
event.preventDefault();
|
|
436
526
|
void answerBackgroundPrompt(false);
|
|
437
527
|
});
|
|
528
|
+
// A release announced while the invitation was up is offered right after it.
|
|
529
|
+
el.backgroundDialog.addEventListener("close", () => {
|
|
530
|
+
const waiting = pendingUpdate;
|
|
531
|
+
pendingUpdate = null;
|
|
532
|
+
// The login gate closes this dialog on its way in, and nothing may sit on
|
|
533
|
+
// top of it.
|
|
534
|
+
if (waiting && !gateShown) openUpdateDialog(waiting);
|
|
535
|
+
});
|
|
438
536
|
}
|
|
439
537
|
|
|
440
538
|
if (el.updateNow) el.updateNow.addEventListener("click", () => { void runUpdate(); });
|
|
@@ -447,6 +545,12 @@ if (el.updateDialog) {
|
|
|
447
545
|
el.updateDialog.close();
|
|
448
546
|
});
|
|
449
547
|
}
|
|
548
|
+
// The corner disc reopens the same notice, so a dismissal is never final.
|
|
549
|
+
if (el.updateBadge) {
|
|
550
|
+
el.updateBadge.addEventListener("click", () => {
|
|
551
|
+
if (updateOffer) openUpdateDialog(updateOffer);
|
|
552
|
+
});
|
|
553
|
+
}
|
|
450
554
|
|
|
451
555
|
const state = {
|
|
452
556
|
overview: null,
|
|
@@ -495,6 +599,12 @@ const preferences = createSettingsUI({
|
|
|
495
599
|
proMode: PRO_MODE,
|
|
496
600
|
onCatalogChange: (catalog) => proUI?.load(catalog),
|
|
497
601
|
onLanguageChange: (language) => changeInterfaceLanguage(language),
|
|
602
|
+
// A check started from Settings offers its result through the same dialog as
|
|
603
|
+
// the notice shown on load, and lights the corner badge with it.
|
|
604
|
+
onUpdateFound: (update) => {
|
|
605
|
+
noteUpdateAvailable(update);
|
|
606
|
+
return openUpdateDialog(update);
|
|
607
|
+
},
|
|
498
608
|
async applyModel(selection) {
|
|
499
609
|
const current = state.selection;
|
|
500
610
|
if (!current) throw new Error("Select a conversation first.");
|
|
@@ -3146,9 +3256,12 @@ preferences.load()
|
|
|
3146
3256
|
if (language && language !== uiLanguage()) {
|
|
3147
3257
|
await changeInterfaceLanguage(language, { persist: false });
|
|
3148
3258
|
}
|
|
3149
|
-
//
|
|
3150
|
-
//
|
|
3151
|
-
|
|
3259
|
+
// The announcement stream needs the login cookie, so it opens once the
|
|
3260
|
+
// settings payload has loaded. A release it reports right away opens the
|
|
3261
|
+
// notice; one that lands while the background invitation is up waits for it
|
|
3262
|
+
// to close, because two modal dialogs must never stack on top of each other.
|
|
3263
|
+
openUpdateEvents();
|
|
3264
|
+
maybePromptBackground(config);
|
|
3152
3265
|
})
|
|
3153
3266
|
.catch(error => console.error(error));
|
|
3154
3267
|
loadSeen().then(() => loadOverview()).then(() => {
|
package/web/i18n.js
CHANGED
|
@@ -115,6 +115,13 @@ const ZH = {
|
|
|
115
115
|
"Updated to {version}. Restart Neutrino to use it.": "已更新到 {version},请重新启动 Neutrino 以使用新版本。",
|
|
116
116
|
"Update failed: {error}": "更新失败:{error}",
|
|
117
117
|
"Update by hand with:": "也可以手动更新:",
|
|
118
|
+
"Updates": "更新",
|
|
119
|
+
"Check for updates": "检查更新",
|
|
120
|
+
"Checking for updates…": "正在检查更新…",
|
|
121
|
+
"Version {latest} is available. This install runs {current}.": "发现新版本 {latest},当前安装为 {current}。",
|
|
122
|
+
"Neutrino {version} is up to date.": "Neutrino {version} 已是最新版本。",
|
|
123
|
+
"Update check failed: {error}": "检查更新失败:{error}",
|
|
124
|
+
"Neutrino checks npm for new releases every 30 minutes. Both workspaces share that check.": "Neutrino 每 30 分钟通过 npm 检查一次新版本,普通模式与 Pro 模式共用该检查。",
|
|
118
125
|
// -- chrome -------------------------------------------------------------
|
|
119
126
|
"Neutrino": "Neutrino",
|
|
120
127
|
"Switch to Pro mode": "切换至Pro模式",
|