@xingjisoft/neutrino 0.3.0 → 0.4.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 +312 -464
- package/assets/pro/backend_output_model.txt +5 -0
- package/assets/pro/claude-module-prompt.txt +65 -0
- package/assets/pro/common.txt +9 -0
- package/assets/pro/confirm_model.txt +5 -0
- package/assets/pro/front_output_model.txt +9 -0
- package/assets/pro/genimage_model.txt +1 -0
- package/assets/pro/main_model.txt +5 -0
- package/assets/pro/model-template.json +376 -0
- package/assets/pro/modeling_model.txt +5 -0
- package/assets/pro/plan_model.txt +8 -0
- package/assets/pro/thinking_model.txt +3 -0
- package/dist/app.js +43 -7
- package/dist/bin.js +5 -2
- package/dist/browser.js +57 -0
- package/dist/config.js +170 -26
- package/dist/index.js +1 -1
- package/dist/llm/approval.js +6 -2
- package/dist/llm/client.js +197 -28
- package/dist/llm/subagent.js +37 -1
- package/dist/pro/agents.js +104 -0
- package/dist/pro/config.js +88 -0
- package/dist/pro/preflight.js +172 -0
- package/dist/screens/chat.js +14 -8
- package/dist/screens/modelSelect.js +15 -4
- package/dist/screens/settings.js +9 -7
- package/dist/screens/start.js +48 -15
- package/dist/ui.js +58 -0
- package/dist/web/hub.js +22 -4
- package/dist/web/server.js +279 -66
- package/dist/web/session.js +258 -22
- package/dist/web/settings.js +91 -24
- package/package.json +37 -37
- package/web/README.md +87 -13
- package/web/app.js +304 -14
- package/web/i18n.js +382 -345
- package/web/index.html +90 -14
- package/web/neutrino-pro.gif +0 -0
- package/web/neutrino.png +0 -0
- package/web/pro.js +184 -0
- package/web/settings.js +322 -247
- package/web/style.css +297 -0
package/dist/web/server.js
CHANGED
|
@@ -16,13 +16,14 @@ import http from "node:http";
|
|
|
16
16
|
import os from "node:os";
|
|
17
17
|
import path from "node:path";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
|
-
import {
|
|
19
|
+
import { deleteSession, listSessions, loadConfig, normalizeApprovalMode, resolveModelSelection, saveConfig, sessionDir, } from "../config.js";
|
|
20
20
|
import { getWebSettings, updateWebSettings } from "./settings.js";
|
|
21
21
|
import { WebAuth } from "./auth.js";
|
|
22
22
|
import { normalizeCwd, sameCwd } from "../cwd.js";
|
|
23
23
|
import { hub } from "./hub.js";
|
|
24
24
|
import { writeCrashLog } from "../terminal/restore.js";
|
|
25
|
-
import { createWebSession, resumeSession } from "./session.js";
|
|
25
|
+
import { createWebSession, proInitialSelection, resumeSession } from "./session.js";
|
|
26
|
+
import { defaultProPreset, requireProPreset } from "../pro/config.js";
|
|
26
27
|
import { listAgentSessions, loadAgentMessages } from "../agents.js";
|
|
27
28
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
28
29
|
// dist/web -> package root (mirrors the same trick used in config.ts).
|
|
@@ -43,6 +44,9 @@ function closeWebSession(key) {
|
|
|
43
44
|
}
|
|
44
45
|
/** Bind addresses that mean "every interface" rather than one host. */
|
|
45
46
|
const WILDCARD_HOSTS = new Set(["0.0.0.0", "::", "[::]", ""]);
|
|
47
|
+
/** The two interfaces the Web access switch moves between. */
|
|
48
|
+
const LOOPBACK_HOST = "127.0.0.1";
|
|
49
|
+
const WILDCARD_HOST = "0.0.0.0";
|
|
46
50
|
/** True when `host` only accepts connections from this machine. */
|
|
47
51
|
export function isLoopbackHost(host) {
|
|
48
52
|
const h = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
|
|
@@ -50,6 +54,17 @@ export function isLoopbackHost(host) {
|
|
|
50
54
|
return false;
|
|
51
55
|
return h === "localhost" || h === "::1" || h.startsWith("127.");
|
|
52
56
|
}
|
|
57
|
+
/** True when `host` listens on every interface, i.e. what --lan selects. */
|
|
58
|
+
export function isWildcardHost(host) {
|
|
59
|
+
return WILDCARD_HOSTS.has(host.trim().toLowerCase().replace(/^\[|\]$/g, ""));
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The interface a fresh listener uses when the command line did not pick one.
|
|
63
|
+
* The saved Web access switch behaves exactly like `--lan`.
|
|
64
|
+
*/
|
|
65
|
+
function defaultWebHost() {
|
|
66
|
+
return loadConfig().web_lan === true ? WILDCARD_HOST : LOOPBACK_HOST;
|
|
67
|
+
}
|
|
53
68
|
/**
|
|
54
69
|
* Adapters that only ever talk to this machine: hypervisor host-only switches,
|
|
55
70
|
* container bridges and loopback variants. Listing them would bury the address
|
|
@@ -99,6 +114,26 @@ export function webUrls(host, port) {
|
|
|
99
114
|
urls: hosts.map((h) => `http://${h}:${port}/`),
|
|
100
115
|
};
|
|
101
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Settings plus the live socket. The `web_lan` switch reports and changes what
|
|
119
|
+
* is listening right now, so a run started with --lan shows as on even though
|
|
120
|
+
* nothing was saved yet.
|
|
121
|
+
*/
|
|
122
|
+
function settingsPayload(mode, binding) {
|
|
123
|
+
const payload = getWebSettings(mode);
|
|
124
|
+
const lan = isWildcardHost(binding.host);
|
|
125
|
+
const { urls } = webUrls(binding.host, binding.port);
|
|
126
|
+
return {
|
|
127
|
+
...payload,
|
|
128
|
+
settings: {
|
|
129
|
+
...payload.settings,
|
|
130
|
+
web_lan: lan,
|
|
131
|
+
web_host: binding.host,
|
|
132
|
+
// Addresses another device can open; empty unless every interface is bound.
|
|
133
|
+
web_urls: lan ? urls.filter((url) => !url.includes("//127.0.0.1:")) : [],
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
102
137
|
const MIME_TYPES = {
|
|
103
138
|
".html": "text/html; charset=utf-8",
|
|
104
139
|
".css": "text/css; charset=utf-8",
|
|
@@ -120,11 +155,12 @@ const SESSION_ID_RE = /^[A-Za-z0-9._-]{1,200}$/;
|
|
|
120
155
|
function isSafeSessionId(id) {
|
|
121
156
|
return SESSION_ID_RE.test(id) && id !== "." && id !== ".." && !id.includes("..");
|
|
122
157
|
}
|
|
123
|
-
function loadSessionSafe(id) {
|
|
158
|
+
function loadSessionSafe(id, mode = "standard") {
|
|
124
159
|
if (!isSafeSessionId(id))
|
|
125
160
|
return null;
|
|
126
|
-
const
|
|
127
|
-
|
|
161
|
+
const dir = sessionDir(mode);
|
|
162
|
+
const filePath = path.join(dir, `${id}.json`);
|
|
163
|
+
if (path.dirname(filePath) !== dir)
|
|
128
164
|
return null;
|
|
129
165
|
try {
|
|
130
166
|
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
@@ -242,12 +278,12 @@ function readFolder(raw) {
|
|
|
242
278
|
};
|
|
243
279
|
}
|
|
244
280
|
/** Recent working directories: explicitly opened ones first, then saved-session folders. */
|
|
245
|
-
function recentWorkspacePaths() {
|
|
246
|
-
const config = loadConfig();
|
|
281
|
+
function recentWorkspacePaths(mode = "standard") {
|
|
282
|
+
const config = loadConfig(mode);
|
|
247
283
|
const stored = Array.isArray(config.web_workspaces)
|
|
248
284
|
? config.web_workspaces.filter((entry) => typeof entry === "string" && entry.trim() !== "")
|
|
249
285
|
: [];
|
|
250
|
-
const saved = listSessions().map((session) => session.cwd).filter(Boolean);
|
|
286
|
+
const saved = listSessions(mode).map((session) => session.cwd).filter(Boolean);
|
|
251
287
|
const seen = new Set();
|
|
252
288
|
const out = [];
|
|
253
289
|
for (const dir of [...stored, ...saved, process.cwd()]) {
|
|
@@ -261,9 +297,9 @@ function recentWorkspacePaths() {
|
|
|
261
297
|
}
|
|
262
298
|
return out;
|
|
263
299
|
}
|
|
264
|
-
function workspaceList() {
|
|
265
|
-
const sessions = listSessions();
|
|
266
|
-
return recentWorkspacePaths().map((dir) => {
|
|
300
|
+
function workspaceList(mode = "standard") {
|
|
301
|
+
const sessions = listSessions(mode);
|
|
302
|
+
return recentWorkspacePaths(mode).map((dir) => {
|
|
267
303
|
let exists = false;
|
|
268
304
|
try {
|
|
269
305
|
exists = fs.statSync(dir).isDirectory();
|
|
@@ -280,15 +316,15 @@ function workspaceList() {
|
|
|
280
316
|
});
|
|
281
317
|
}
|
|
282
318
|
/** Best-effort persistence: failing to remember a folder must not fail the conversation. */
|
|
283
|
-
function recordWorkspace(dir) {
|
|
319
|
+
function recordWorkspace(dir, mode = "standard") {
|
|
284
320
|
try {
|
|
285
|
-
const config = loadConfig();
|
|
321
|
+
const config = loadConfig(mode);
|
|
286
322
|
const stored = Array.isArray(config.web_workspaces)
|
|
287
323
|
? config.web_workspaces.filter((entry) => typeof entry === "string")
|
|
288
324
|
: [];
|
|
289
325
|
config.web_workspaces = [dir, ...stored.filter((entry) => !sameCwd(entry, dir))]
|
|
290
326
|
.slice(0, MAX_RECENT_WORKSPACES);
|
|
291
|
-
saveConfig(config);
|
|
327
|
+
saveConfig(config, mode);
|
|
292
328
|
}
|
|
293
329
|
catch {
|
|
294
330
|
// The picked folder still works for this conversation.
|
|
@@ -321,10 +357,10 @@ function isAgentId(value) {
|
|
|
321
357
|
function safeAgentSessionId(id) {
|
|
322
358
|
return AGENT_SESSION_ID_RE.test(id) && !id.includes("..") && id !== "." && id !== "..";
|
|
323
359
|
}
|
|
324
|
-
/** Build the sidebar payload:
|
|
325
|
-
function buildOverview() {
|
|
326
|
-
const sessions = listSessions();
|
|
327
|
-
const lives = hub.list();
|
|
360
|
+
/** Build the sidebar payload: the selected workspace's sessions grouped by directory. */
|
|
361
|
+
function buildOverview(mode = "standard") {
|
|
362
|
+
const sessions = listSessions(mode);
|
|
363
|
+
const lives = hub.list(mode);
|
|
328
364
|
const liveBySession = new Map();
|
|
329
365
|
for (const live of lives) {
|
|
330
366
|
if (live.sessionId)
|
|
@@ -530,8 +566,7 @@ function streamLive(key, req, res) {
|
|
|
530
566
|
// Without this, a client reset during a write is an uncaught 'error' event.
|
|
531
567
|
res.on("error", cleanup);
|
|
532
568
|
}
|
|
533
|
-
async function handleApi(req, res, url, auth) {
|
|
534
|
-
const { pathname } = url;
|
|
569
|
+
async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
535
570
|
if (!pathname.startsWith("/api/"))
|
|
536
571
|
return false;
|
|
537
572
|
if (req.method === "OPTIONS") {
|
|
@@ -577,22 +612,46 @@ async function handleApi(req, res, url, auth) {
|
|
|
577
612
|
}
|
|
578
613
|
if (pathname === "/api/settings") {
|
|
579
614
|
if (req.method === "GET") {
|
|
580
|
-
sendJson(res, 200,
|
|
615
|
+
sendJson(res, 200, settingsPayload(mode, binding));
|
|
581
616
|
}
|
|
582
617
|
else if (req.method === "POST") {
|
|
583
618
|
if (!isSameOrigin(req)) {
|
|
584
619
|
sendJson(res, 403, { error: "cross-origin request rejected" });
|
|
585
620
|
return true;
|
|
586
621
|
}
|
|
622
|
+
const wasLan = loadConfig(mode).web_lan === true;
|
|
587
623
|
try {
|
|
588
624
|
const body = await readJsonBody(req);
|
|
589
|
-
updateWebSettings(body);
|
|
625
|
+
updateWebSettings(body, mode);
|
|
626
|
+
// `web_lan` is both a saved preference and a live switch: apply it to
|
|
627
|
+
// the socket now so the console starts or stops answering on the LAN
|
|
628
|
+
// without a restart. Other settings never move the listener, and an
|
|
629
|
+
// explicit --host still decides the interface until this is changed.
|
|
630
|
+
const patch = body?.settings;
|
|
631
|
+
const desired = patch && Object.prototype.hasOwnProperty.call(patch, "web_lan")
|
|
632
|
+
? patch.web_lan === true
|
|
633
|
+
: isWildcardHost(binding.host);
|
|
634
|
+
if (desired !== isWildcardHost(binding.host)) {
|
|
635
|
+
await binding.setHost(desired ? WILDCARD_HOST : LOOPBACK_HOST);
|
|
636
|
+
}
|
|
590
637
|
if (body.settings?.web_password !== undefined) {
|
|
591
638
|
auth.remember(res);
|
|
592
639
|
}
|
|
593
|
-
sendJson(res, 200,
|
|
640
|
+
sendJson(res, 200, settingsPayload(mode, binding));
|
|
594
641
|
}
|
|
595
642
|
catch (err) {
|
|
643
|
+
// A switch that could not bind must not pretend the socket moved:
|
|
644
|
+
// restore the saved preference so the file matches what is running.
|
|
645
|
+
if ((loadConfig(mode).web_lan === true) !== wasLan) {
|
|
646
|
+
try {
|
|
647
|
+
const config = loadConfig(mode);
|
|
648
|
+
config.web_lan = wasLan;
|
|
649
|
+
saveConfig(config, mode);
|
|
650
|
+
}
|
|
651
|
+
catch {
|
|
652
|
+
// Reporting the original failure matters more than the rollback.
|
|
653
|
+
}
|
|
654
|
+
}
|
|
596
655
|
sendJson(res, err?.code ? 500 : 400, {
|
|
597
656
|
error: err instanceof Error ? err.message : "invalid settings",
|
|
598
657
|
});
|
|
@@ -604,15 +663,15 @@ async function handleApi(req, res, url, auth) {
|
|
|
604
663
|
return true;
|
|
605
664
|
}
|
|
606
665
|
if (req.method === "GET" && pathname === "/api/health") {
|
|
607
|
-
sendJson(res, 200, { ok: true, live: hub.list().length });
|
|
666
|
+
sendJson(res, 200, { ok: true, live: hub.list(mode).length });
|
|
608
667
|
return true;
|
|
609
668
|
}
|
|
610
669
|
if (req.method === "GET" && pathname === "/api/overview") {
|
|
611
|
-
sendJson(res, 200, buildOverview());
|
|
670
|
+
sendJson(res, 200, buildOverview(mode));
|
|
612
671
|
return true;
|
|
613
672
|
}
|
|
614
673
|
if (req.method === "GET" && pathname === "/api/sessions") {
|
|
615
|
-
sendJson(res, 200, { sessions: listSessions() });
|
|
674
|
+
sendJson(res, 200, { sessions: listSessions(mode) });
|
|
616
675
|
return true;
|
|
617
676
|
}
|
|
618
677
|
if (pathname === "/api/workspaces") {
|
|
@@ -620,7 +679,7 @@ async function handleApi(req, res, url, auth) {
|
|
|
620
679
|
sendJson(res, 200, {
|
|
621
680
|
cwd: process.cwd(),
|
|
622
681
|
home: os.homedir(),
|
|
623
|
-
workspaces: workspaceList(),
|
|
682
|
+
workspaces: workspaceList(mode),
|
|
624
683
|
});
|
|
625
684
|
return true;
|
|
626
685
|
}
|
|
@@ -632,8 +691,8 @@ async function handleApi(req, res, url, auth) {
|
|
|
632
691
|
try {
|
|
633
692
|
const body = await readJsonBody(req);
|
|
634
693
|
const dir = existingDirectory(body?.path);
|
|
635
|
-
recordWorkspace(dir);
|
|
636
|
-
sendJson(res, 200, { ok: true, path: dir, workspaces: workspaceList() });
|
|
694
|
+
recordWorkspace(dir, mode);
|
|
695
|
+
sendJson(res, 200, { ok: true, path: dir, workspaces: workspaceList(mode) });
|
|
637
696
|
}
|
|
638
697
|
catch (err) {
|
|
639
698
|
sendApiError(res, err);
|
|
@@ -694,9 +753,10 @@ async function handleApi(req, res, url, auth) {
|
|
|
694
753
|
const input = body;
|
|
695
754
|
const cwd = existingDirectory(input.cwd ?? process.cwd());
|
|
696
755
|
const requested = input.selection === undefined
|
|
697
|
-
? getWebSettings().selection
|
|
756
|
+
? getWebSettings(mode).selection
|
|
698
757
|
: input.selection;
|
|
699
|
-
const
|
|
758
|
+
const preset = mode === "pro" ? requireProPreset(input.pro_preset ?? defaultProPreset()) : undefined;
|
|
759
|
+
const selection = mode === "pro" ? proInitialSelection(preset) : resolveModelSelection(requested, false);
|
|
700
760
|
let approvalMode;
|
|
701
761
|
if (input.approval_mode !== undefined) {
|
|
702
762
|
if (input.approval_mode !== "request" && input.approval_mode !== "auto") {
|
|
@@ -704,10 +764,10 @@ async function handleApi(req, res, url, auth) {
|
|
|
704
764
|
}
|
|
705
765
|
approvalMode = normalizeApprovalMode(input.approval_mode);
|
|
706
766
|
}
|
|
707
|
-
const session = createWebSession(cwd, selection, approvalMode);
|
|
767
|
+
const session = createWebSession(cwd, selection, approvalMode, mode, preset);
|
|
708
768
|
const key = session.start();
|
|
709
769
|
webSessions.set(key, session);
|
|
710
|
-
recordWorkspace(cwd);
|
|
770
|
+
recordWorkspace(cwd, mode);
|
|
711
771
|
sendJson(res, 200, { ok: true, key, cwd, snapshot: hub.snapshot(key) });
|
|
712
772
|
}
|
|
713
773
|
catch (err) {
|
|
@@ -726,11 +786,11 @@ async function handleApi(req, res, url, auth) {
|
|
|
726
786
|
sendJson(res, 400, { error: "invalid session id" });
|
|
727
787
|
return true;
|
|
728
788
|
}
|
|
729
|
-
if (hub.list().some((entry) => entry.sessionId === id)) {
|
|
789
|
+
if (hub.list(mode).some((entry) => entry.sessionId === id)) {
|
|
730
790
|
sendJson(res, 409, { error: "cannot delete a running conversation" });
|
|
731
791
|
return true;
|
|
732
792
|
}
|
|
733
|
-
if (!deleteSession(id)) {
|
|
793
|
+
if (!deleteSession(id, mode)) {
|
|
734
794
|
sendJson(res, 404, { error: "session not found" });
|
|
735
795
|
return true;
|
|
736
796
|
}
|
|
@@ -771,7 +831,7 @@ async function handleApi(req, res, url, auth) {
|
|
|
771
831
|
sendJson(res, 400, { error: "invalid session id" });
|
|
772
832
|
return true;
|
|
773
833
|
}
|
|
774
|
-
const data = loadSessionSafe(id);
|
|
834
|
+
const data = loadSessionSafe(id, mode);
|
|
775
835
|
if (!data) {
|
|
776
836
|
sendJson(res, 404, { error: "session not found" });
|
|
777
837
|
return true;
|
|
@@ -791,21 +851,32 @@ async function handleApi(req, res, url, auth) {
|
|
|
791
851
|
return true;
|
|
792
852
|
}
|
|
793
853
|
let selection;
|
|
854
|
+
let preset;
|
|
794
855
|
try {
|
|
795
856
|
const body = await readJsonBody(req);
|
|
796
857
|
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
797
858
|
throw new Error("invalid JSON body");
|
|
798
859
|
const requested = body.selection;
|
|
799
|
-
if (requested !== undefined)
|
|
860
|
+
if (requested !== undefined && mode !== "pro")
|
|
800
861
|
selection = resolveModelSelection(requested);
|
|
862
|
+
const requestedPreset = body.pro_preset;
|
|
863
|
+
if (requestedPreset !== undefined && mode === "pro")
|
|
864
|
+
preset = requireProPreset(requestedPreset);
|
|
801
865
|
}
|
|
802
866
|
catch (err) {
|
|
803
867
|
sendJson(res, 400, { error: err instanceof Error ? err.message : "invalid model selection" });
|
|
804
868
|
return true;
|
|
805
869
|
}
|
|
806
870
|
// Already running (TUI or another tab)? Attach instead of duplicating.
|
|
807
|
-
const existing = hub.list().find((entry) => entry.sessionId === id);
|
|
871
|
+
const existing = hub.list(mode).find((entry) => entry.sessionId === id);
|
|
808
872
|
if (existing) {
|
|
873
|
+
if (preset !== undefined) {
|
|
874
|
+
const changed = hub.setProPreset(existing.key, preset);
|
|
875
|
+
if (!changed.ok) {
|
|
876
|
+
sendJson(res, 409, { error: changed.error });
|
|
877
|
+
return true;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
809
880
|
if (selection) {
|
|
810
881
|
const changed = hub.configure(existing.key, selection);
|
|
811
882
|
if (!changed.ok) {
|
|
@@ -816,15 +887,17 @@ async function handleApi(req, res, url, auth) {
|
|
|
816
887
|
sendJson(res, 200, { key: existing.key, attached: true });
|
|
817
888
|
return true;
|
|
818
889
|
}
|
|
819
|
-
const data = loadSessionSafe(id);
|
|
890
|
+
const data = loadSessionSafe(id, mode);
|
|
820
891
|
if (!data) {
|
|
821
892
|
sendJson(res, 404, { error: "session not found" });
|
|
822
893
|
return true;
|
|
823
894
|
}
|
|
895
|
+
if (preset !== undefined)
|
|
896
|
+
data.pro_preset = preset;
|
|
824
897
|
const requested = selection ? {
|
|
825
898
|
provider: selection.provider, model: selection.model.name, intensity: selection.intensity,
|
|
826
899
|
} : undefined;
|
|
827
|
-
const outcome = resumeSession(data, requested);
|
|
900
|
+
const outcome = resumeSession(data, requested, mode);
|
|
828
901
|
if (!outcome.ok) {
|
|
829
902
|
sendJson(res, 400, { error: outcome.error });
|
|
830
903
|
return true;
|
|
@@ -836,15 +909,22 @@ async function handleApi(req, res, url, auth) {
|
|
|
836
909
|
}
|
|
837
910
|
const streamMatch = /^\/api\/live\/([^/]+)\/stream$/.exec(pathname);
|
|
838
911
|
if (req.method === "GET" && streamMatch) {
|
|
839
|
-
|
|
912
|
+
const key = decodeURIComponent(streamMatch[1]);
|
|
913
|
+
// A Pro tab must never observe (or be handed) a session from the other
|
|
914
|
+
// workspace just because it guessed the key.
|
|
915
|
+
if (!hub.has(key, mode)) {
|
|
916
|
+
sendJson(res, 404, { error: "unknown live session" });
|
|
917
|
+
return true;
|
|
918
|
+
}
|
|
919
|
+
streamLive(key, req, res);
|
|
840
920
|
return true;
|
|
841
921
|
}
|
|
842
|
-
const liveMatch = /^\/api\/live\/([^/]+)(?:\/(pause|resume|message|approve|close|model|approval-mode|clear))?$/.exec(pathname);
|
|
922
|
+
const liveMatch = /^\/api\/live\/([^/]+)(?:\/(pause|resume|message|approve|close|model|pro-preset|approval-mode|clear))?$/.exec(pathname);
|
|
843
923
|
if (liveMatch) {
|
|
844
924
|
const key = decodeURIComponent(liveMatch[1]);
|
|
845
925
|
const action = liveMatch[2];
|
|
846
926
|
if (req.method === "GET" && !action) {
|
|
847
|
-
const snapshot = hub.snapshot(key);
|
|
927
|
+
const snapshot = hub.has(key, mode) ? hub.snapshot(key) : null;
|
|
848
928
|
if (!snapshot) {
|
|
849
929
|
sendJson(res, 404, { error: "unknown live session" });
|
|
850
930
|
return true;
|
|
@@ -857,10 +937,29 @@ async function handleApi(req, res, url, auth) {
|
|
|
857
937
|
sendJson(res, 403, { error: "cross-origin request rejected" });
|
|
858
938
|
return true;
|
|
859
939
|
}
|
|
860
|
-
if (!hub.has(key)) {
|
|
940
|
+
if (!hub.has(key, mode)) {
|
|
861
941
|
sendJson(res, 404, { error: "unknown live session" });
|
|
862
942
|
return true;
|
|
863
943
|
}
|
|
944
|
+
if (action === "pro-preset") {
|
|
945
|
+
if (mode !== "pro") {
|
|
946
|
+
sendJson(res, 404, { error: "Pro presets are only available in Pro mode" });
|
|
947
|
+
return true;
|
|
948
|
+
}
|
|
949
|
+
try {
|
|
950
|
+
const body = await readJsonBody(req);
|
|
951
|
+
const preset = requireProPreset(body?.preset);
|
|
952
|
+
const changed = hub.setProPreset(key, preset);
|
|
953
|
+
if (!changed.ok)
|
|
954
|
+
sendJson(res, 409, { error: changed.error });
|
|
955
|
+
else
|
|
956
|
+
sendJson(res, 200, { ok: true, snapshot: hub.snapshot(key) });
|
|
957
|
+
}
|
|
958
|
+
catch (error) {
|
|
959
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : "invalid Pro preset" });
|
|
960
|
+
}
|
|
961
|
+
return true;
|
|
962
|
+
}
|
|
864
963
|
if (action === "approval-mode") {
|
|
865
964
|
let body;
|
|
866
965
|
try {
|
|
@@ -965,7 +1064,7 @@ async function handleApi(req, res, url, auth) {
|
|
|
965
1064
|
sendJson(res, 404, { error: "not found" });
|
|
966
1065
|
return true;
|
|
967
1066
|
}
|
|
968
|
-
async function handleRequest(req, res, auth) {
|
|
1067
|
+
async function handleRequest(req, res, auth, binding) {
|
|
969
1068
|
let url;
|
|
970
1069
|
try {
|
|
971
1070
|
url = new URL(req.url ?? "/", "http://localhost");
|
|
@@ -974,20 +1073,59 @@ async function handleRequest(req, res, auth) {
|
|
|
974
1073
|
sendText(res, 400, "Bad request");
|
|
975
1074
|
return;
|
|
976
1075
|
}
|
|
977
|
-
|
|
1076
|
+
// The Pro workspace is the same single-page app at /pro, talking to an
|
|
1077
|
+
// isolated API under /pro/api. Static assets stay at the root (the app uses
|
|
1078
|
+
// root-absolute URLs), so only the page route and the API prefix are special.
|
|
1079
|
+
const pathname = url.pathname;
|
|
1080
|
+
if (pathname === "/pro" || pathname === "/pro/") {
|
|
1081
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1082
|
+
sendText(res, 405, "Method not allowed");
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
if (pathname === "/pro/") {
|
|
1086
|
+
// Canonical URL without the trailing slash keeps relative links rooted.
|
|
1087
|
+
res.writeHead(308, { Location: `/pro${url.search}` });
|
|
1088
|
+
res.end();
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
serveStatic(res, "/index.html");
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
let mode = "standard";
|
|
1095
|
+
let apiPathname = pathname;
|
|
1096
|
+
if (pathname.startsWith("/pro/api/")) {
|
|
1097
|
+
mode = "pro";
|
|
1098
|
+
apiPathname = pathname.slice("/pro".length);
|
|
1099
|
+
}
|
|
1100
|
+
if (await handleApi(req, res, url, auth, mode, apiPathname, binding))
|
|
978
1101
|
return;
|
|
1102
|
+
if (mode === "pro") {
|
|
1103
|
+
// Nothing else lives under /pro but the page and its API.
|
|
1104
|
+
sendText(res, 404, "Not found");
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
979
1107
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
980
1108
|
sendText(res, 405, "Method not allowed");
|
|
981
1109
|
return;
|
|
982
1110
|
}
|
|
983
|
-
serveStatic(res,
|
|
1111
|
+
serveStatic(res, pathname);
|
|
984
1112
|
}
|
|
985
1113
|
/** Start the web UI. Resolves once the socket is actually listening. */
|
|
986
1114
|
export async function startWebServer(options) {
|
|
987
|
-
const host = options.host ?? "127.0.0.1";
|
|
988
1115
|
const auth = new WebAuth(options.auth !== false);
|
|
1116
|
+
// An explicit --host/--lan wins for this run; otherwise the saved Web access
|
|
1117
|
+
// switch is applied exactly as if `--lan` had been passed.
|
|
1118
|
+
let host = options.host?.trim() || defaultWebHost();
|
|
1119
|
+
let port = options.port;
|
|
1120
|
+
let closed = false;
|
|
1121
|
+
// The settings page reads and changes this binding while the process runs.
|
|
1122
|
+
const binding = {
|
|
1123
|
+
host,
|
|
1124
|
+
port,
|
|
1125
|
+
setHost: (next) => rebind(next),
|
|
1126
|
+
};
|
|
989
1127
|
const server = http.createServer((req, res) => {
|
|
990
|
-
handleRequest(req, res, auth).catch((err) => {
|
|
1128
|
+
handleRequest(req, res, auth, binding).catch((err) => {
|
|
991
1129
|
if (!res.headersSent) {
|
|
992
1130
|
sendJson(res, 500, { error: err instanceof Error ? err.message : "internal error" });
|
|
993
1131
|
}
|
|
@@ -996,41 +1134,116 @@ export async function startWebServer(options) {
|
|
|
996
1134
|
}
|
|
997
1135
|
});
|
|
998
1136
|
});
|
|
999
|
-
|
|
1137
|
+
/** Wait for the socket to actually listen, or for it to fail. */
|
|
1138
|
+
const listen = (onHost) => new Promise((resolve, reject) => {
|
|
1000
1139
|
const onError = (err) => {
|
|
1001
1140
|
server.removeListener("listening", onListening);
|
|
1002
1141
|
reject(err);
|
|
1003
1142
|
};
|
|
1004
1143
|
const onListening = () => {
|
|
1005
1144
|
server.removeListener("error", onError);
|
|
1006
|
-
// Once listening, the server must tolerate connection-level failures
|
|
1007
|
-
// (resets, EMFILE, half-open sockets) without the process dying. An
|
|
1008
|
-
// 'error' event with no listener is fatal in Node.
|
|
1009
|
-
server.on("error", (err) => {
|
|
1010
|
-
writeCrashLog("web server", err);
|
|
1011
|
-
});
|
|
1012
|
-
server.on("clientError", (_err, socket) => {
|
|
1013
|
-
if (socket.writable)
|
|
1014
|
-
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
1015
|
-
socket.destroy();
|
|
1016
|
-
});
|
|
1017
1145
|
resolve();
|
|
1018
1146
|
};
|
|
1019
1147
|
server.once("error", onError);
|
|
1020
1148
|
server.once("listening", onListening);
|
|
1021
|
-
server.listen(
|
|
1149
|
+
server.listen(port, onHost);
|
|
1022
1150
|
});
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1151
|
+
/** A just-closed port can need a moment to reappear: retry only that race. */
|
|
1152
|
+
async function listenRetry(onHost) {
|
|
1153
|
+
for (let attempt = 0;; attempt += 1) {
|
|
1154
|
+
try {
|
|
1155
|
+
await listen(onHost);
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
catch (err) {
|
|
1159
|
+
if (err.code !== "EADDRINUSE" || attempt >= 20)
|
|
1160
|
+
throw err;
|
|
1161
|
+
await new Promise((done) => setTimeout(done, 10));
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
const syncAddress = () => {
|
|
1166
|
+
const address = webUrls(host, port);
|
|
1167
|
+
binding.host = host;
|
|
1168
|
+
binding.port = port;
|
|
1169
|
+
handle.host = host;
|
|
1170
|
+
handle.port = port;
|
|
1171
|
+
handle.url = address.url;
|
|
1172
|
+
handle.urls = address.urls;
|
|
1173
|
+
};
|
|
1174
|
+
// Two tabs saving at once must not interleave close/listen on one socket.
|
|
1175
|
+
let switches = Promise.resolve();
|
|
1176
|
+
function rebind(next) {
|
|
1177
|
+
const target = next.trim() || LOOPBACK_HOST;
|
|
1178
|
+
const task = switches.then(async () => {
|
|
1179
|
+
if (closed)
|
|
1180
|
+
throw new Error("the Web console is shutting down");
|
|
1181
|
+
if (target === host && server.listening)
|
|
1182
|
+
return;
|
|
1183
|
+
// `close` ends acceptance, not the connections already accepted. The
|
|
1184
|
+
// listener is released immediately, which is all the switch needs, so a
|
|
1185
|
+
// streaming turn and the tab that asked for the change stay connected.
|
|
1186
|
+
if (server.listening)
|
|
1187
|
+
server.close();
|
|
1188
|
+
try {
|
|
1189
|
+
await listenRetry(target);
|
|
1190
|
+
}
|
|
1191
|
+
catch (err) {
|
|
1192
|
+
// Put the console back where it was rather than leaving it dark.
|
|
1193
|
+
if (!closed) {
|
|
1194
|
+
try {
|
|
1195
|
+
await listenRetry(host);
|
|
1196
|
+
}
|
|
1197
|
+
catch {
|
|
1198
|
+
// The settings response still reports the original failure.
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
throw err;
|
|
1202
|
+
}
|
|
1203
|
+
// A concurrent shutdown wins: never resurrect a console being closed.
|
|
1204
|
+
if (closed) {
|
|
1205
|
+
server.close();
|
|
1206
|
+
throw new Error("the Web Console is shutting down");
|
|
1207
|
+
}
|
|
1208
|
+
host = target;
|
|
1209
|
+
syncAddress();
|
|
1210
|
+
});
|
|
1211
|
+
switches = task.catch(() => undefined);
|
|
1212
|
+
return task;
|
|
1213
|
+
}
|
|
1214
|
+
const handle = {
|
|
1026
1215
|
host,
|
|
1027
1216
|
port,
|
|
1028
1217
|
authRequired: auth.enabled,
|
|
1029
|
-
|
|
1218
|
+
url: "",
|
|
1219
|
+
urls: [],
|
|
1220
|
+
setHost: (next) => rebind(next),
|
|
1030
1221
|
close: () => new Promise((resolve) => {
|
|
1222
|
+
closed = true;
|
|
1031
1223
|
for (const key of [...webSessions.keys()])
|
|
1032
1224
|
closeWebSession(key);
|
|
1225
|
+
if (!server.listening) {
|
|
1226
|
+
resolve();
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1033
1229
|
server.close(() => resolve());
|
|
1034
1230
|
}),
|
|
1035
1231
|
};
|
|
1232
|
+
await listenRetry(host);
|
|
1233
|
+
// Port 0 means "let the OS pick": remember the real one for every URL.
|
|
1234
|
+
const address = server.address();
|
|
1235
|
+
port = typeof address === "object" && address ? address.port : options.port;
|
|
1236
|
+
syncAddress();
|
|
1237
|
+
// Once listening, the server must tolerate connection-level failures
|
|
1238
|
+
// (resets, EMFILE, half-open sockets) without the process dying: an 'error'
|
|
1239
|
+
// event with no listener is fatal in Node.
|
|
1240
|
+
server.on("error", (err) => {
|
|
1241
|
+
writeCrashLog("web server", err);
|
|
1242
|
+
});
|
|
1243
|
+
server.on("clientError", (_err, socket) => {
|
|
1244
|
+
if (socket.writable)
|
|
1245
|
+
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
1246
|
+
socket.destroy();
|
|
1247
|
+
});
|
|
1248
|
+
return handle;
|
|
1036
1249
|
}
|