@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/README.md +1 -1
- package/dist/app.js +55 -0
- package/dist/bin.js +21 -0
- package/dist/pro/config.js +139 -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/dist/ui.js
CHANGED
|
@@ -295,35 +295,51 @@ export function drawTabButton(buf, x, y, w, label, selected, align = "center") {
|
|
|
295
295
|
}
|
|
296
296
|
/** Label of the CLI entry point that opens the browser console's Pro workspace. */
|
|
297
297
|
export const PRO_MODE_LABEL = "启用Pro模式";
|
|
298
|
+
/** Label of the CLI entry point that opens the browser console itself. */
|
|
299
|
+
export const WEB_MODE_LABEL = "打开Web端";
|
|
298
300
|
/**
|
|
299
|
-
* Geometry of
|
|
300
|
-
*
|
|
301
|
-
* always lands on the box that is drawn.
|
|
301
|
+
* Geometry of a box in the top-left corner, directly below the status bar
|
|
302
|
+
* (row 0), starting at column `x`. Rendering and hit testing share it so a
|
|
303
|
+
* click always lands on the box that is drawn.
|
|
302
304
|
*
|
|
303
305
|
* Returns null when the terminal is too small to fit the box with a little
|
|
304
|
-
* breathing room
|
|
305
|
-
* centered block (which is pushed below the
|
|
306
|
-
* on very short terminals, where the original layout is preserved
|
|
306
|
+
* breathing room to its right. The height floor also keeps the start screen's
|
|
307
|
+
* centered block (which is pushed below the boxes) clear of the approval
|
|
308
|
+
* toggle on very short terminals, where the original layout is preserved.
|
|
307
309
|
*/
|
|
308
|
-
|
|
309
|
-
// One blank column at the very edge, then the border: "flush to the edge
|
|
310
|
-
// with a single space", as the start screen expects.
|
|
311
|
-
const x = 1;
|
|
310
|
+
function headerButton(width, height, x, label) {
|
|
312
311
|
const y = 1;
|
|
313
312
|
const h = 3;
|
|
314
313
|
// Two cells of padding, one inside each border.
|
|
315
|
-
const w = displayWidth(
|
|
316
|
-
if (width < w +
|
|
314
|
+
const w = displayWidth(label) + 4;
|
|
315
|
+
if (width < x + w + 3 || height < 22)
|
|
316
|
+
return null;
|
|
317
|
+
return { x, y, w, h, label };
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* The Pro-mode button: flush to the left edge with a single blank column, as
|
|
321
|
+
* the start screen expects.
|
|
322
|
+
*/
|
|
323
|
+
export function proModeButton(width, height) {
|
|
324
|
+
return headerButton(width, height, 1, PRO_MODE_LABEL);
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* The Web-mode button: glued to the right border of the Pro-mode button so the
|
|
328
|
+
* two read as one row of entry points. It starts where the Pro box ends, which
|
|
329
|
+
* also means it is hidden together with it on terminals too small for the row.
|
|
330
|
+
*/
|
|
331
|
+
export function webModeButton(width, height) {
|
|
332
|
+
const pro = proModeButton(width, height);
|
|
333
|
+
if (!pro)
|
|
317
334
|
return null;
|
|
318
|
-
return
|
|
335
|
+
return headerButton(width, height, pro.x + pro.w, WEB_MODE_LABEL);
|
|
319
336
|
}
|
|
320
337
|
/**
|
|
321
|
-
* Draw
|
|
338
|
+
* Draw one top-left button. It is painted as opaque chrome so content behind it
|
|
322
339
|
* can never bleed through; the label row turns into the highlight background
|
|
323
340
|
* while the pointer is over it.
|
|
324
341
|
*/
|
|
325
|
-
export function
|
|
326
|
-
const button = proModeButton(buf.width, buf.height);
|
|
342
|
+
export function drawHeaderButton(buf, button, hovered = false) {
|
|
327
343
|
if (!button)
|
|
328
344
|
return;
|
|
329
345
|
const { x, y, w, h, label } = button;
|
package/dist/update.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { loadVersion } from "./config.js";
|
|
4
|
+
/** The published package whose registry version decides whether an update exists. */
|
|
5
|
+
export const UPDATE_PACKAGE = "@xingjisoft/neutrino";
|
|
6
|
+
/** What a user can run by hand when the automatic update fails. */
|
|
7
|
+
export const MANUAL_UPDATE_COMMAND = `npm install -g ${UPDATE_PACKAGE}`;
|
|
8
|
+
const LOOKUP_TIMEOUT_MS = 20_000;
|
|
9
|
+
const INSTALL_TIMEOUT_MS = 300_000;
|
|
10
|
+
let published = "";
|
|
11
|
+
let pending = null;
|
|
12
|
+
let installing = null;
|
|
13
|
+
/**
|
|
14
|
+
* The last known answer, for the terminal screen and the Web console payload.
|
|
15
|
+
* Reading this never touches the network: the lookup runs once per process (see
|
|
16
|
+
* checkForUpdate) and costs nothing afterwards.
|
|
17
|
+
*/
|
|
18
|
+
export function updateStatus() {
|
|
19
|
+
const currentVersion = loadVersion();
|
|
20
|
+
return {
|
|
21
|
+
currentVersion,
|
|
22
|
+
latestVersion: published,
|
|
23
|
+
hasUpdate: isNewerVersion(published, currentVersion),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Ask npm which version is published and remember it. This never rejects: an
|
|
28
|
+
* offline machine, a missing npm or a registry hiccup only means "no update to
|
|
29
|
+
* announce", which is what the next start is for. Concurrent callers share the
|
|
30
|
+
* one lookup, and a successful answer is kept for the rest of the process so
|
|
31
|
+
* the terminal and the Web console cannot disagree.
|
|
32
|
+
*/
|
|
33
|
+
export function checkForUpdate() {
|
|
34
|
+
if (published)
|
|
35
|
+
return Promise.resolve(updateStatus());
|
|
36
|
+
pending ??= lookupLatestVersion()
|
|
37
|
+
.then((version) => {
|
|
38
|
+
published = version;
|
|
39
|
+
return updateStatus();
|
|
40
|
+
})
|
|
41
|
+
.catch((error) => ({
|
|
42
|
+
...updateStatus(),
|
|
43
|
+
error: error instanceof Error ? error.message : String(error),
|
|
44
|
+
}))
|
|
45
|
+
.finally(() => { pending = null; });
|
|
46
|
+
return pending;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Install the latest published release with npm.
|
|
50
|
+
*
|
|
51
|
+
* The terminal and both Web workspaces can ask for this, so requests share one
|
|
52
|
+
* install instead of running npm twice over the same files. It never rejects:
|
|
53
|
+
* whoever asked gets a result and, on failure, the command to run by hand.
|
|
54
|
+
*
|
|
55
|
+
* The version this process runs is not replaced — the running code stays on
|
|
56
|
+
* disk until the user restarts — so callers must tell the user to restart.
|
|
57
|
+
*/
|
|
58
|
+
export function installUpdate() {
|
|
59
|
+
installing ??= runNpm(["install", "-g", UPDATE_PACKAGE], INSTALL_TIMEOUT_MS)
|
|
60
|
+
.then((run) => npmResult(run, `install -g ${UPDATE_PACKAGE}`))
|
|
61
|
+
.catch((error) => ({
|
|
62
|
+
ok: false,
|
|
63
|
+
version: updateStatus().latestVersion,
|
|
64
|
+
error: error instanceof Error ? error.message : String(error),
|
|
65
|
+
}))
|
|
66
|
+
.finally(() => { installing = null; });
|
|
67
|
+
return installing;
|
|
68
|
+
}
|
|
69
|
+
/** `npm view <package> version`, with a deadline so a hung npm cannot hold a start-up. */
|
|
70
|
+
async function lookupLatestVersion() {
|
|
71
|
+
const run = await runNpm(["view", UPDATE_PACKAGE, "version"], LOOKUP_TIMEOUT_MS);
|
|
72
|
+
const version = firstVersionLine(run.stdout);
|
|
73
|
+
if (version)
|
|
74
|
+
return version;
|
|
75
|
+
throw new Error(npmFailure(run, `view ${UPDATE_PACKAGE} version`));
|
|
76
|
+
}
|
|
77
|
+
/** Turn one finished npm run into the answer callers see. */
|
|
78
|
+
function npmResult(run, what) {
|
|
79
|
+
const version = updateStatus().latestVersion;
|
|
80
|
+
return run.code === 0
|
|
81
|
+
? { ok: true, version }
|
|
82
|
+
: { ok: false, version, error: npmFailure(run, what) };
|
|
83
|
+
}
|
|
84
|
+
/** The last line npm printed is the one that says what went wrong. */
|
|
85
|
+
function npmFailure(run, what) {
|
|
86
|
+
const reason = run.stderr.trim().split("\n").pop()?.trim();
|
|
87
|
+
return `npm ${what} failed${run.code === null ? " (timed out)" : run.code === 0 ? "" : ` with code ${run.code}`}`
|
|
88
|
+
+ (reason ? `: ${reason}` : "");
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Run npm and collect a bounded amount of output. Rejects only when npm cannot
|
|
92
|
+
* be started at all or does not finish in time; a failing npm resolves with its
|
|
93
|
+
* exit code so the caller decides what it means.
|
|
94
|
+
*/
|
|
95
|
+
function runNpm(args, timeoutMs) {
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
let child;
|
|
98
|
+
try {
|
|
99
|
+
// On Windows npm is a .cmd shim, which Node only starts through a shell;
|
|
100
|
+
// the command is built from fixed literals, so nothing needs quoting.
|
|
101
|
+
const windows = process.platform === "win32";
|
|
102
|
+
child = spawn(windows ? process.env.ComSpec || "cmd.exe" : "npm", windows ? ["/d", "/s", "/c", `npm ${args.join(" ")}`] : args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
reject(new Error(`could not run npm: ${error instanceof Error ? error.message : String(error)}`));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
let stdout = "";
|
|
109
|
+
let stderr = "";
|
|
110
|
+
let settled = false;
|
|
111
|
+
const finish = (error, run) => {
|
|
112
|
+
if (settled)
|
|
113
|
+
return;
|
|
114
|
+
settled = true;
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
if (error)
|
|
117
|
+
reject(error);
|
|
118
|
+
else
|
|
119
|
+
resolve(run);
|
|
120
|
+
};
|
|
121
|
+
const timer = setTimeout(() => {
|
|
122
|
+
// Stop waiting on a hung npm: its output is discarded either way.
|
|
123
|
+
child.kill();
|
|
124
|
+
finish(new Error(`npm ${args.join(" ")} timed out after ${timeoutMs / 1000}s`));
|
|
125
|
+
}, timeoutMs);
|
|
126
|
+
timer.unref();
|
|
127
|
+
child.stdout?.setEncoding("utf8");
|
|
128
|
+
child.stderr?.setEncoding("utf8");
|
|
129
|
+
child.stdout?.on("data", (chunk) => {
|
|
130
|
+
if (stdout.length < 8192)
|
|
131
|
+
stdout += chunk;
|
|
132
|
+
});
|
|
133
|
+
child.stderr?.on("data", (chunk) => {
|
|
134
|
+
if (stderr.length < 8192)
|
|
135
|
+
stderr += chunk;
|
|
136
|
+
});
|
|
137
|
+
// A missing npm is reported asynchronously; without this listener the
|
|
138
|
+
// 'error' event would crash the whole process.
|
|
139
|
+
child.on("error", (error) => finish(new Error(`could not run npm: ${error.message}`)));
|
|
140
|
+
child.on("close", (code) => finish(null, { code, stdout, stderr }));
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/** npm prints the version alone, but warnings on stdout must not be mistaken for one. */
|
|
144
|
+
function firstVersionLine(text) {
|
|
145
|
+
for (const line of text.split(/\r?\n/)) {
|
|
146
|
+
const value = line.trim();
|
|
147
|
+
if (/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?$/.test(value))
|
|
148
|
+
return value;
|
|
149
|
+
}
|
|
150
|
+
return "";
|
|
151
|
+
}
|
|
152
|
+
function parseVersion(value) {
|
|
153
|
+
const text = value.trim().replace(/^v/i, "").split("+")[0];
|
|
154
|
+
if (!text)
|
|
155
|
+
return null;
|
|
156
|
+
const dash = text.indexOf("-");
|
|
157
|
+
const core = (dash === -1 ? text : text.slice(0, dash)).split(".");
|
|
158
|
+
if (!core.length || core.some((part) => !/^\d+$/.test(part)))
|
|
159
|
+
return null;
|
|
160
|
+
const prerelease = dash === -1 ? null : text.slice(dash + 1).split(".");
|
|
161
|
+
if (prerelease && prerelease.some((part) => part === ""))
|
|
162
|
+
return null;
|
|
163
|
+
return { core: core.map(Number), prerelease };
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Semantic-version order: returns 1 when `left` is newer, -1 when older and 0
|
|
167
|
+
* when they are the same release. A version that cannot be parsed compares as
|
|
168
|
+
* equal, so a malformed registry answer can never claim an update is due.
|
|
169
|
+
*/
|
|
170
|
+
export function compareVersions(left, right) {
|
|
171
|
+
const a = parseVersion(left);
|
|
172
|
+
const b = parseVersion(right);
|
|
173
|
+
if (!a || !b)
|
|
174
|
+
return 0;
|
|
175
|
+
const length = Math.max(a.core.length, b.core.length);
|
|
176
|
+
for (let i = 0; i < length; i++) {
|
|
177
|
+
const difference = (a.core[i] ?? 0) - (b.core[i] ?? 0);
|
|
178
|
+
if (difference !== 0)
|
|
179
|
+
return difference > 0 ? 1 : -1;
|
|
180
|
+
}
|
|
181
|
+
// Numeric identifiers compare numerically and rank below alphanumeric ones.
|
|
182
|
+
const compareIdentifiers = (one, other) => {
|
|
183
|
+
const numeric = /^\d+$/.test(one);
|
|
184
|
+
const otherNumeric = /^\d+$/.test(other);
|
|
185
|
+
if (numeric && otherNumeric)
|
|
186
|
+
return Math.sign(Number(one) - Number(other));
|
|
187
|
+
if (numeric !== otherNumeric)
|
|
188
|
+
return numeric ? -1 : 1;
|
|
189
|
+
return one < other ? -1 : one > other ? 1 : 0;
|
|
190
|
+
};
|
|
191
|
+
if (!a.prerelease && !b.prerelease)
|
|
192
|
+
return 0;
|
|
193
|
+
if (!a.prerelease)
|
|
194
|
+
return 1;
|
|
195
|
+
if (!b.prerelease)
|
|
196
|
+
return -1;
|
|
197
|
+
for (let i = 0; i < Math.max(a.prerelease.length, b.prerelease.length); i++) {
|
|
198
|
+
const one = a.prerelease[i];
|
|
199
|
+
const other = b.prerelease[i];
|
|
200
|
+
if (one === undefined)
|
|
201
|
+
return -1;
|
|
202
|
+
if (other === undefined)
|
|
203
|
+
return 1;
|
|
204
|
+
const difference = compareIdentifiers(one, other);
|
|
205
|
+
if (difference !== 0)
|
|
206
|
+
return difference;
|
|
207
|
+
}
|
|
208
|
+
return 0;
|
|
209
|
+
}
|
|
210
|
+
/** Whether `candidate` is a strictly newer release than `current`. */
|
|
211
|
+
export function isNewerVersion(candidate, current) {
|
|
212
|
+
return compareVersions(candidate, current) > 0;
|
|
213
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State the browser console keeps about itself.
|
|
3
|
+
*
|
|
4
|
+
* The sidebar's blue dot asks for attention once: it marks a conversation that
|
|
5
|
+
* finished after the reader last opened it. Remembering that answer is neither a
|
|
6
|
+
* conversation setting (config.json is read by the terminal UI on every tool
|
|
7
|
+
* call) nor part of a transcript, so the console owns a file of its own:
|
|
8
|
+
*
|
|
9
|
+
* ~/.neutrino/web.json
|
|
10
|
+
* { "seen": { "standard": ["id:2025-01-02T03-04-05-678Z", "live:L3"], "pro": [] } }
|
|
11
|
+
*
|
|
12
|
+
* Both workspaces keep their own record, because each has its own sidebar. The
|
|
13
|
+
* file is a convenience: a missing, unreadable or hand-mangled one costs a dot,
|
|
14
|
+
* never a request.
|
|
15
|
+
*/
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { NEUTRINO_DIR, ensureDirs } from "../config.js";
|
|
19
|
+
export const WEB_STATE_FILE = path.join(NEUTRINO_DIR, "web.json");
|
|
20
|
+
/**
|
|
21
|
+
* Identities are `id:<session id>` for a conversation with history and
|
|
22
|
+
* `live:<key>` for one that has nothing saved yet (see `sessionIdentity` in
|
|
23
|
+
* web/app.js). Anything else is refused, so a crafted request body cannot grow
|
|
24
|
+
* the file without bound.
|
|
25
|
+
*/
|
|
26
|
+
const IDENTITY_RE = /^(?:id|live):[A-Za-z0-9._-]{1,200}$/;
|
|
27
|
+
/** How many finished conversations one workspace remembers, newest last. */
|
|
28
|
+
export const SEEN_LIMIT = 300;
|
|
29
|
+
/** Whether a value from a request is an identity this file can hold. */
|
|
30
|
+
export function isSeenIdentity(value) {
|
|
31
|
+
return typeof value === "string" && IDENTITY_RE.test(value);
|
|
32
|
+
}
|
|
33
|
+
/** The whole file, never throwing: an unreadable record simply holds nothing. */
|
|
34
|
+
function readState() {
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(fs.readFileSync(WEB_STATE_FILE, "utf-8"));
|
|
37
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
38
|
+
? parsed
|
|
39
|
+
: {};
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** The `seen` map of the file, or an empty one when it is missing or malformed. */
|
|
46
|
+
function seenState(state) {
|
|
47
|
+
const seen = state.seen;
|
|
48
|
+
return seen && typeof seen === "object" && !Array.isArray(seen)
|
|
49
|
+
? seen
|
|
50
|
+
: {};
|
|
51
|
+
}
|
|
52
|
+
/** Identities of one workspace, oldest first, with unusable entries dropped. */
|
|
53
|
+
function workspaceSeen(state, mode) {
|
|
54
|
+
const list = seenState(state)[mode];
|
|
55
|
+
if (!Array.isArray(list))
|
|
56
|
+
return [];
|
|
57
|
+
return list.filter(isSeenIdentity).slice(-SEEN_LIMIT);
|
|
58
|
+
}
|
|
59
|
+
/** The conversations this workspace has already shown the reader, oldest first. */
|
|
60
|
+
export function loadSeen(mode = "standard") {
|
|
61
|
+
return workspaceSeen(readState(), mode);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Record (`seen: true`) or forget (`seen: false`) the given conversations and
|
|
65
|
+
* write the file back. Returns the workspace's new record.
|
|
66
|
+
*
|
|
67
|
+
* Identities that are not valid are ignored rather than rejected: the route
|
|
68
|
+
* already validated them, so this is only a guard on what reaches the disk.
|
|
69
|
+
*/
|
|
70
|
+
export function updateSeen(mode, identities, seen) {
|
|
71
|
+
const wanted = new Set(identities.filter(isSeenIdentity));
|
|
72
|
+
if (wanted.size === 0)
|
|
73
|
+
return loadSeen(mode);
|
|
74
|
+
const state = readState();
|
|
75
|
+
// A recorded conversation moves to the end, so trimming keeps the newest.
|
|
76
|
+
const kept = workspaceSeen(state, mode).filter((identity) => !wanted.has(identity));
|
|
77
|
+
// Trim after the merge: recording a new conversation can push the oldest out.
|
|
78
|
+
const next = (seen ? [...kept, ...wanted] : kept).slice(-SEEN_LIMIT);
|
|
79
|
+
ensureDirs();
|
|
80
|
+
fs.writeFileSync(WEB_STATE_FILE, JSON.stringify({ ...state, seen: { ...seenState(state), [mode]: next } }, null, 2), "utf-8");
|
|
81
|
+
return next;
|
|
82
|
+
}
|
package/dist/web/server.js
CHANGED
|
@@ -19,15 +19,17 @@ import { timingSafeEqual } from "node:crypto";
|
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
20
20
|
import { deleteSession, listSessions, loadConfig, normalizeApprovalMode, resolveModelSelection, saveConfig, sessionDir, } from "../config.js";
|
|
21
21
|
import { getWebSettings, updateWebSettings } from "./settings.js";
|
|
22
|
+
import { isSeenIdentity, loadSeen, updateSeen, SEEN_LIMIT } from "./clientState.js";
|
|
22
23
|
import { WebAuth } from "./auth.js";
|
|
23
24
|
import { normalizeCwd, sameCwd } from "../cwd.js";
|
|
24
25
|
import { hub } from "./hub.js";
|
|
25
26
|
import { writeCrashLog } from "../terminal/restore.js";
|
|
26
27
|
import { createWebSession, proInitialSelection, resumeSession, WebSession } from "./session.js";
|
|
27
|
-
import { defaultProPreset, requireProPreset } from "../pro/config.js";
|
|
28
|
+
import { defaultProPreset, ensureProModels, requireProPreset } from "../pro/config.js";
|
|
28
29
|
import { listAgentSessions, loadAgentMessages, loadAgentSession, safeAgentSessionId } from "../agents.js";
|
|
29
30
|
import { migrateAgentSession, SessionMigrationError } from "../migration.js";
|
|
30
31
|
import { runningBackground } from "../background.js";
|
|
32
|
+
import { MANUAL_UPDATE_COMMAND, installUpdate, updateStatus } from "../update.js";
|
|
31
33
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
32
34
|
// dist/web -> package root (mirrors the same trick used in config.ts).
|
|
33
35
|
const PKG_ROOT = path.resolve(__dirname, "..", "..");
|
|
@@ -405,10 +407,15 @@ function buildOverview(mode = "standard") {
|
|
|
405
407
|
seen.add(session.id);
|
|
406
408
|
}
|
|
407
409
|
// Live conversations that have not been saved yet (or whose newest save has
|
|
408
|
-
// not landed) still deserve a spot at the top of their directory group.
|
|
410
|
+
// not landed) still deserve a spot at the top of their directory group. A
|
|
411
|
+
// conversation nobody has said anything in yet is not history: it stays out
|
|
412
|
+
// of the list (but in `live`, so the browser keeps its stream) until it has
|
|
413
|
+
// content.
|
|
409
414
|
for (const live of lives) {
|
|
410
415
|
if (live.sessionId && seen.has(live.sessionId))
|
|
411
416
|
continue;
|
|
417
|
+
if (live.empty)
|
|
418
|
+
continue;
|
|
412
419
|
const key = normalizeCwd(live.cwd) || "(unknown)";
|
|
413
420
|
let group = groups.get(key);
|
|
414
421
|
if (!group) {
|
|
@@ -600,6 +607,10 @@ function streamLive(key, req, res) {
|
|
|
600
607
|
async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
601
608
|
if (!pathname.startsWith("/api/"))
|
|
602
609
|
return false;
|
|
610
|
+
// The Pro presets are not bundled either: the first Pro request makes sure they
|
|
611
|
+
// are on disk. When the download fails the route below reports the missing file.
|
|
612
|
+
if (mode === "pro")
|
|
613
|
+
await ensureProModels().catch(() => undefined);
|
|
603
614
|
if (req.method === "OPTIONS") {
|
|
604
615
|
// Same-origin only: no CORS headers, so cross-site preflights are rejected.
|
|
605
616
|
// Answered before the login check because it reveals nothing.
|
|
@@ -737,6 +748,33 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
|
737
748
|
}
|
|
738
749
|
return true;
|
|
739
750
|
}
|
|
751
|
+
// The update prompt's confirm button: npm runs on this machine, so the route
|
|
752
|
+
// sits behind the login cookie and the same-origin check like every other
|
|
753
|
+
// mutation, and only a release the registry announced can be installed.
|
|
754
|
+
if (pathname === "/api/update") {
|
|
755
|
+
if (req.method !== "POST") {
|
|
756
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
757
|
+
return true;
|
|
758
|
+
}
|
|
759
|
+
if (!isSameOrigin(req)) {
|
|
760
|
+
sendJson(res, 403, { error: "cross-origin request rejected" });
|
|
761
|
+
return true;
|
|
762
|
+
}
|
|
763
|
+
if (!updateStatus().hasUpdate) {
|
|
764
|
+
sendJson(res, 409, { error: "No newer version is published." });
|
|
765
|
+
return true;
|
|
766
|
+
}
|
|
767
|
+
// Never rejects: a failure comes back as a result, carrying the command the
|
|
768
|
+
// browser shows so the user can finish the update by hand.
|
|
769
|
+
const result = await installUpdate();
|
|
770
|
+
sendJson(res, result.ok ? 200 : 500, {
|
|
771
|
+
ok: result.ok,
|
|
772
|
+
version: result.version,
|
|
773
|
+
error: result.error ?? "",
|
|
774
|
+
command: MANUAL_UPDATE_COMMAND,
|
|
775
|
+
});
|
|
776
|
+
return true;
|
|
777
|
+
}
|
|
740
778
|
if (req.method === "GET" && pathname === "/api/health") {
|
|
741
779
|
sendJson(res, 200, { ok: true, live: hub.list(mode).length });
|
|
742
780
|
return true;
|
|
@@ -745,6 +783,53 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
|
745
783
|
sendJson(res, 200, buildOverview(mode));
|
|
746
784
|
return true;
|
|
747
785
|
}
|
|
786
|
+
// The sidebar's blue dot is the browser's own memory of what it has read. It
|
|
787
|
+
// belongs to the console rather than to a conversation, so it is stored in
|
|
788
|
+
// ~/.neutrino/web.json (see clientState.ts) and never in the config or the
|
|
789
|
+
// transcript. Each workspace keeps its own record.
|
|
790
|
+
if (pathname === "/api/seen") {
|
|
791
|
+
if (req.method === "GET") {
|
|
792
|
+
sendJson(res, 200, { seen: loadSeen(mode) });
|
|
793
|
+
return true;
|
|
794
|
+
}
|
|
795
|
+
if (req.method === "POST") {
|
|
796
|
+
if (!isSameOrigin(req)) {
|
|
797
|
+
sendJson(res, 403, { error: "cross-origin request rejected" });
|
|
798
|
+
return true;
|
|
799
|
+
}
|
|
800
|
+
try {
|
|
801
|
+
const body = await readJsonBody(req, 64 * 1024);
|
|
802
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
803
|
+
throw new ApiError(400, "invalid request", "invalid request");
|
|
804
|
+
}
|
|
805
|
+
const input = body;
|
|
806
|
+
if (Object.keys(body).some((key) => key !== "identities" && key !== "seen")) {
|
|
807
|
+
throw new ApiError(400, "unknown field", "unknown field");
|
|
808
|
+
}
|
|
809
|
+
const identities = input.identities;
|
|
810
|
+
if (!Array.isArray(identities) || identities.length === 0 || identities.length > SEEN_LIMIT) {
|
|
811
|
+
throw new ApiError(400, `identities must be a list of 1 to ${SEEN_LIMIT}`, "invalid identities");
|
|
812
|
+
}
|
|
813
|
+
if (!identities.every(isSeenIdentity)) {
|
|
814
|
+
throw new ApiError(400, "invalid conversation id", "invalid conversation id");
|
|
815
|
+
}
|
|
816
|
+
if (typeof input.seen !== "boolean") {
|
|
817
|
+
throw new ApiError(400, "seen must be a boolean", "seen must be a boolean");
|
|
818
|
+
}
|
|
819
|
+
sendJson(res, 200, { ok: true, seen: updateSeen(mode, identities, input.seen) });
|
|
820
|
+
}
|
|
821
|
+
catch (err) {
|
|
822
|
+
// A rejected body is the caller's fault; a file that cannot be written
|
|
823
|
+
// is ours, and must not read as a malformed request.
|
|
824
|
+
sendJson(res, err instanceof ApiError ? err.status : 500, {
|
|
825
|
+
error: err instanceof Error ? err.message : "invalid request",
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
return true;
|
|
829
|
+
}
|
|
830
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
831
|
+
return true;
|
|
832
|
+
}
|
|
748
833
|
if (req.method === "GET" && pathname === "/api/sessions") {
|
|
749
834
|
sendJson(res, 200, { sessions: listSessions(mode) });
|
|
750
835
|
return true;
|
package/dist/web/service.js
CHANGED
|
@@ -3,6 +3,8 @@ import process from "node:process";
|
|
|
3
3
|
import { backgroundLog, clearBackgroundStatus, isProcessAlive, writeBackgroundStatus, } from "../background.js";
|
|
4
4
|
import { loadConfig, saveConfig } from "../config.js";
|
|
5
5
|
import { startBackgroundTray } from "../tray.js";
|
|
6
|
+
import { PRO_MODELS_URL, syncProModels } from "../pro/config.js";
|
|
7
|
+
import { checkForUpdate, UPDATE_PACKAGE } from "../update.js";
|
|
6
8
|
import { hub } from "./hub.js";
|
|
7
9
|
import { startWebServer } from "./server.js";
|
|
8
10
|
import { TerminalBridges } from "./terminalBridge.js";
|
|
@@ -88,6 +90,29 @@ export async function runWebService(options) {
|
|
|
88
90
|
syncTray();
|
|
89
91
|
return status();
|
|
90
92
|
};
|
|
93
|
+
// Nothing ships with the package any more: reconcile the presets with the
|
|
94
|
+
// official copy now, so the first browser to open /pro already finds them and
|
|
95
|
+
// an outdated local set is replaced. A failure is logged and reported there,
|
|
96
|
+
// so this never holds up the console itself.
|
|
97
|
+
void syncProModels().then((result) => {
|
|
98
|
+
if (result.status === "updated")
|
|
99
|
+
backgroundLog(`pro presets: updated from ${PRO_MODELS_URL}`);
|
|
100
|
+
else if (result.status === "kept")
|
|
101
|
+
backgroundLog(`pro presets: kept the local copy (${result.detail})`);
|
|
102
|
+
}).catch((error) => {
|
|
103
|
+
backgroundLog(`pro presets: ${error instanceof Error ? error.message : String(error)}`);
|
|
104
|
+
});
|
|
105
|
+
// The registry lookup runs alongside the listener so a slow or unreachable
|
|
106
|
+
// registry never delays the console; the pages read the answer from the
|
|
107
|
+
// settings payload. Failures are logged, as with the presets above.
|
|
108
|
+
void checkForUpdate().then((result) => {
|
|
109
|
+
if (result.hasUpdate) {
|
|
110
|
+
backgroundLog(`${UPDATE_PACKAGE} ${result.latestVersion} is available (running ${result.currentVersion})`);
|
|
111
|
+
}
|
|
112
|
+
else if (result.error) {
|
|
113
|
+
backgroundLog(`update check: ${result.error}`);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
91
116
|
try {
|
|
92
117
|
handle = await startWebServer({
|
|
93
118
|
port: options.port, host: options.host, auth: options.auth,
|
package/dist/web/session.js
CHANGED
package/dist/web/settings.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { DEFAULT_CONFIG, INTENSITY_LEVELS, WEB_LANGUAGES, contextWindow, intensityForModel, isContextWindow, loadConfig, loadModels, modelContextWindow, modelContextWindowKey, modelIntensities, normalizeApprovalMode, normalizeIntensity, normalizeWebLanguage, resolveModelSelection, saveConfig, saveModels, } from "../config.js";
|
|
2
2
|
import { hashWebPassword, validateWebPassword } from "./auth.js";
|
|
3
3
|
import { defaultProPreset, loadProModels, proMaxRecursionDepth, requireProPreset } from "../pro/config.js";
|
|
4
|
+
import { updateStatus } from "../update.js";
|
|
4
5
|
/**
|
|
5
6
|
* Public settings use a whitelist: API keys and cached credentials stay on disk.
|
|
6
7
|
*
|
|
@@ -31,7 +32,15 @@ export function getWebSettings(mode = "standard") {
|
|
|
31
32
|
? catalog.providers.find((p) => p.name === provider.name)?.models.find((m) => m.name === model.name) ?? null
|
|
32
33
|
: null;
|
|
33
34
|
const intensity = intensityForModel(configuredModel, config.selected_intensity ?? config.default_intensity, config.default_intensity);
|
|
35
|
+
// Read-only runtime state for the update notice: which version runs, which
|
|
36
|
+
// one is published, and whether the two differ. Never persisted.
|
|
37
|
+
const update = updateStatus();
|
|
34
38
|
return {
|
|
39
|
+
update: {
|
|
40
|
+
current_version: update.currentVersion,
|
|
41
|
+
latest_version: update.latestVersion,
|
|
42
|
+
has_update: update.hasUpdate,
|
|
43
|
+
},
|
|
35
44
|
...(mode === "pro" ? { pro: {
|
|
36
45
|
presets: loadProModels().tier.map((preset) => ({
|
|
37
46
|
name: preset.name, chinese_name: preset.chinese_name,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xingjisoft/neutrino",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "XINGJI Neutrino - a CLI coding agent in the terminal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
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-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-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-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",
|
|
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
22
|
"prepare": "npm run build",
|
|
23
23
|
"web": "node dist/bin.js web"
|
|
24
24
|
},
|
package/web/README.md
CHANGED
|
@@ -116,6 +116,11 @@ in-page switch takes over from there for the running process.
|
|
|
116
116
|
The console can read every transcript and drive an agent with shell access, so
|
|
117
117
|
keep the Web password on: anyone who can reach the port can use it.
|
|
118
118
|
|
|
119
|
+
On a narrow screen the console shows one pane at a time: the conversation list,
|
|
120
|
+
and then the open conversation. The back arrow in the header and the browser's
|
|
121
|
+
own Back button both return to the list, and Forward reopens the conversation
|
|
122
|
+
that was open.
|
|
123
|
+
|
|
119
124
|
Startup prints the addresses to open:
|
|
120
125
|
|
|
121
126
|
```
|
|
@@ -223,16 +228,15 @@ login/logout routes are shared and stay unprefixed.
|
|
|
223
228
|
|
|
224
229
|
## Models and settings
|
|
225
230
|
|
|
226
|
-
Open
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
controls
|
|
230
|
-
|
|
231
|
+
Open the model card at the lower right of the composer to choose a **Provider**
|
|
232
|
+
and **Model**, and adjust **Thinking intensity** with the slider. Changes to an
|
|
233
|
+
idle or paused conversation apply immediately to its next turn and are saved
|
|
234
|
+
with the transcript. The same controls update a conversation running in the
|
|
235
|
+
terminal. Pause an active turn before changing its model.
|
|
231
236
|
|
|
232
|
-
For saved history
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
removed can still be continued.
|
|
237
|
+
For saved history or a new workspace conversation, the card keeps the selection
|
|
238
|
+
as a draft. **Continue here** or sending a message starts the conversation with
|
|
239
|
+
that selection, so a transcript whose old model was removed can still continue.
|
|
236
240
|
|
|
237
241
|
The **Settings** (gear) button in the sidebar opens the interface language,
|
|
238
242
|
plan-first turns, the default approval mode, defaults for new conversations, the
|
|
@@ -277,6 +281,21 @@ regular and Pro workspaces remember their own choice. The terminal UI stays in
|
|
|
277
281
|
English. New conversation content, of course, remains in the language the model
|
|
278
282
|
writes.
|
|
279
283
|
|
|
284
|
+
## Sidebar status marks
|
|
285
|
+
|
|
286
|
+
Every row in the sidebar carries one mark. A **blue dot** means the conversation
|
|
287
|
+
finished and has not been opened since, a **turning ring** means a turn is
|
|
288
|
+
running, and an **amber dot** means a tool call is waiting for your answer. The
|
|
289
|
+
amber mark only appears while the conversation really asks for approval — with
|
|
290
|
+
automatic review the reviewers answer for you, so the row keeps showing the
|
|
291
|
+
running ring.
|
|
292
|
+
|
|
293
|
+
The blue dot asks once: opening the row answers it, and the next turn that
|
|
294
|
+
finishes asks again. The console remembers those answers in a file of its own,
|
|
295
|
+
`~/.neutrino/web.json` (the regular and Pro workspaces each keep their own
|
|
296
|
+
record), so the terminal UI never carries data it does not read, and a reload, a
|
|
297
|
+
restart of the console or a change of port does not bring every dot back.
|
|
298
|
+
|
|
280
299
|
## History vs. live sessions
|
|
281
300
|
|
|
282
301
|
A saved transcript on disk is a finished record: nothing is running, so the
|