@xingjisoft/neutrino 0.4.0 → 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 +33 -397
- package/assets/neutrino.ico +0 -0
- package/assets/neutrino.svg +7 -0
- package/dist/agents.js +22 -3
- package/dist/app.js +218 -26
- package/dist/background.js +358 -0
- package/dist/bin.js +200 -42
- package/dist/config.js +7 -3
- package/dist/header.js +2 -1
- package/dist/llm/tools.js +3 -1
- package/dist/migration.js +33 -0
- package/dist/pro/config.js +135 -9
- package/dist/screens/agentConversation.js +48 -5
- package/dist/screens/agentHistory.js +4 -2
- package/dist/screens/settings.js +43 -2
- package/dist/screens/start.js +26 -14
- package/dist/screens/updatePrompt.js +200 -0
- package/dist/state.js +1 -0
- package/dist/terminal/restore.js +3 -12
- package/dist/tray.js +138 -0
- package/dist/ui.js +32 -16
- package/dist/update.js +213 -0
- package/dist/web/clientState.js +82 -0
- package/dist/web/hub.js +4 -0
- package/dist/web/server.js +330 -37
- package/dist/web/service.js +160 -0
- package/dist/web/session.js +24 -6
- package/dist/web/settings.js +15 -0
- package/dist/web/terminalBridge.js +250 -0
- package/package.json +2 -2
- package/web/README.md +102 -23
- package/web/app.js +558 -22
- package/web/i18n.js +34 -0
- package/web/index.html +71 -9
- package/web/settings.js +223 -46
- package/web/style.css +145 -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/hub.js
CHANGED
|
@@ -58,6 +58,10 @@ class LiveHub {
|
|
|
58
58
|
const entry = this.entries.get(key);
|
|
59
59
|
return Boolean(entry) && (!mode || entry.mode === mode);
|
|
60
60
|
}
|
|
61
|
+
remoteAction(key) {
|
|
62
|
+
const agent = this.entries.get(key)?.agent;
|
|
63
|
+
return agent?.remoteAction ? agent.remoteAction.bind(agent) : null;
|
|
64
|
+
}
|
|
61
65
|
/** All live sessions, newest registration last. Optionally one workspace only. */
|
|
62
66
|
list(mode) {
|
|
63
67
|
const out = [];
|