@xingjisoft/neutrino 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/assets/fast.txt +18 -0
- package/assets/pro/common.txt +4 -0
- package/assets/pro/main_model.txt +2 -0
- package/assets/pro/plan_model.txt +3 -1
- package/assets/prompt.txt +9 -0
- package/assets/ptc.txt +27 -0
- package/assets/subagent.txt +22 -1
- package/dist/app.js +2 -0
- package/dist/config.js +30 -1
- package/dist/llm/client.js +61 -8
- package/dist/llm/codeExecutor.js +239 -0
- package/dist/llm/codemode.js +164 -0
- package/dist/llm/context.js +97 -0
- package/dist/llm/plan.js +97 -9
- package/dist/llm/risk.js +10 -0
- package/dist/llm/runtime.js +301 -0
- package/dist/llm/search.js +426 -0
- package/dist/llm/searchNetwork.js +129 -0
- package/dist/llm/subagent.js +236 -112
- package/dist/llm/tools.js +101 -4
- package/dist/pro/agents.js +49 -19
- package/dist/pro/preflight.js +24 -2
- package/dist/screens/chat.js +299 -27
- package/dist/update.js +8 -2
- package/dist/updateWatch.js +76 -0
- package/dist/web/hub.js +9 -0
- package/dist/web/server.js +183 -13
- package/dist/web/service.js +22 -0
- package/dist/web/session.js +327 -62
- package/dist/web/terminalBridge.js +10 -1
- package/package.json +42 -37
- package/web/app.js +347 -29
- package/web/i18n.js +27 -0
- package/web/index.html +465 -442
- package/web/settings.js +31 -1
- package/web/style.css +3581 -3473
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 });
|
|
@@ -356,6 +445,9 @@ const el = {
|
|
|
356
445
|
messages: document.getElementById("messages"),
|
|
357
446
|
composer: document.getElementById("composer"),
|
|
358
447
|
input: document.getElementById("input"),
|
|
448
|
+
modePicker: document.getElementById("agent-mode-picker"),
|
|
449
|
+
modeLabel: document.getElementById("agent-mode-label"),
|
|
450
|
+
modeFeedback: document.getElementById("agent-mode-feedback"),
|
|
359
451
|
send: document.getElementById("send"),
|
|
360
452
|
approval: document.getElementById("approval"),
|
|
361
453
|
emptyNewChat: document.getElementById("empty-new-chat"),
|
|
@@ -422,6 +514,7 @@ const el = {
|
|
|
422
514
|
updateClose: document.getElementById("update-close"),
|
|
423
515
|
updateLater: document.getElementById("update-later"),
|
|
424
516
|
updateNow: document.getElementById("update-now"),
|
|
517
|
+
updateBadge: document.getElementById("update-badge"),
|
|
425
518
|
};
|
|
426
519
|
|
|
427
520
|
if (el.backgroundCancel) {
|
|
@@ -435,6 +528,14 @@ if (el.backgroundDialog) {
|
|
|
435
528
|
event.preventDefault();
|
|
436
529
|
void answerBackgroundPrompt(false);
|
|
437
530
|
});
|
|
531
|
+
// A release announced while the invitation was up is offered right after it.
|
|
532
|
+
el.backgroundDialog.addEventListener("close", () => {
|
|
533
|
+
const waiting = pendingUpdate;
|
|
534
|
+
pendingUpdate = null;
|
|
535
|
+
// The login gate closes this dialog on its way in, and nothing may sit on
|
|
536
|
+
// top of it.
|
|
537
|
+
if (waiting && !gateShown) openUpdateDialog(waiting);
|
|
538
|
+
});
|
|
438
539
|
}
|
|
439
540
|
|
|
440
541
|
if (el.updateNow) el.updateNow.addEventListener("click", () => { void runUpdate(); });
|
|
@@ -447,6 +548,12 @@ if (el.updateDialog) {
|
|
|
447
548
|
el.updateDialog.close();
|
|
448
549
|
});
|
|
449
550
|
}
|
|
551
|
+
// The corner disc reopens the same notice, so a dismissal is never final.
|
|
552
|
+
if (el.updateBadge) {
|
|
553
|
+
el.updateBadge.addEventListener("click", () => {
|
|
554
|
+
if (updateOffer) openUpdateDialog(updateOffer);
|
|
555
|
+
});
|
|
556
|
+
}
|
|
450
557
|
|
|
451
558
|
const state = {
|
|
452
559
|
overview: null,
|
|
@@ -489,12 +596,160 @@ function storedWorkspace() {
|
|
|
489
596
|
return { key: pathKey(path), path };
|
|
490
597
|
}
|
|
491
598
|
|
|
599
|
+
const AGENT_MODE_LABELS = { standard: "Standard mode", ptc: "PTC mode", fast: "Fast mode", pro: "Professional mode" };
|
|
600
|
+
let newAgentMode = readStorage("neutrino.agent-mode");
|
|
601
|
+
if (!["standard", "ptc", "fast"].includes(newAgentMode)) newAgentMode = "standard";
|
|
602
|
+
let agentModePending = Promise.resolve();
|
|
603
|
+
let agentModeChanging = false;
|
|
604
|
+
let workspaceNavigation = false;
|
|
605
|
+
let agentModeError = "";
|
|
606
|
+
|
|
607
|
+
function clearAgentModeFeedback() {
|
|
608
|
+
agentModeError = "";
|
|
609
|
+
agentModePending = Promise.resolve();
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function currentAgentMode() {
|
|
613
|
+
if (PRO_MODE) return "pro";
|
|
614
|
+
if (state.selection?.kind === "live") return state.snapshot?.agentMode || "standard";
|
|
615
|
+
if (state.selection?.kind === "file") return state.fileData?.agent_mode || "standard";
|
|
616
|
+
return newAgentMode;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function renderAgentMode() {
|
|
620
|
+
if (!el.modePicker || !el.modeLabel) return;
|
|
621
|
+
const mode = currentAgentMode();
|
|
622
|
+
el.modeLabel.removeAttribute?.("data-i18n");
|
|
623
|
+
el.modeLabel.textContent = tr(AGENT_MODE_LABELS[mode] || AGENT_MODE_LABELS.standard);
|
|
624
|
+
const nextTurn = state.snapshot?.busy && state.snapshot.activeAgentMode !== state.snapshot.agentMode;
|
|
625
|
+
el.modePicker.classList.toggle("is-pending", Boolean(agentModeChanging || nextTurn));
|
|
626
|
+
for (const button of el.modePicker.querySelectorAll?.("[data-agent-mode]") || []) {
|
|
627
|
+
button.setAttribute("aria-checked", String(button.dataset.agentMode === mode));
|
|
628
|
+
button.disabled = agentModeChanging || workspaceNavigation;
|
|
629
|
+
}
|
|
630
|
+
if (el.modeFeedback) {
|
|
631
|
+
el.modeFeedback.textContent = agentModeError || (agentModeChanging ? tr("Changing mode…") : nextTurn ? tr("Applies to the next turn") : "");
|
|
632
|
+
el.modeFeedback.classList.toggle("error", Boolean(agentModeError));
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
async function chooseAgentMode(mode) {
|
|
637
|
+
if (!Object.hasOwn(AGENT_MODE_LABELS, mode) || agentModeChanging || workspaceNavigation || mode === currentAgentMode()) return;
|
|
638
|
+
const selected = state.selection;
|
|
639
|
+
agentModeChanging = true;
|
|
640
|
+
agentModeError = "";
|
|
641
|
+
if (el.modePicker) renderAgentMode();
|
|
642
|
+
el.modePicker.open = false;
|
|
643
|
+
try {
|
|
644
|
+
if (mode === "pro" || PRO_MODE) {
|
|
645
|
+
await switchWorkspaceMode(mode);
|
|
646
|
+
} else if (selected?.kind === "live") {
|
|
647
|
+
const response = await apiFetch(`/api/live/${encodeURIComponent(selected.key)}/agent-mode`, {
|
|
648
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode }),
|
|
649
|
+
});
|
|
650
|
+
const body = await response.json();
|
|
651
|
+
if (!response.ok) throw new Error(apiError(body, `HTTP ${response.status}`));
|
|
652
|
+
if (state.selection === selected) { state.snapshot = body.snapshot; renderLive(); }
|
|
653
|
+
} else if (selected?.kind === "file") {
|
|
654
|
+
await continueSession(selected.id, preferences.selectionForFile(selected.id), mode);
|
|
655
|
+
} else {
|
|
656
|
+
newAgentMode = mode;
|
|
657
|
+
}
|
|
658
|
+
if (mode !== "pro") { newAgentMode = mode; writeStorage("neutrino.agent-mode", mode); }
|
|
659
|
+
} catch (error) {
|
|
660
|
+
if (state.selection !== selected) return;
|
|
661
|
+
agentModeError = tr("Could not change mode: {error}", { error: error.message });
|
|
662
|
+
throw error;
|
|
663
|
+
} finally { agentModeChanging = false; renderAgentMode(); }
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
async function switchWorkspaceMode(mode) {
|
|
667
|
+
const cwd = selectionCwd() || state.workspace?.path;
|
|
668
|
+
if (!cwd) throw new Error(tr("Select a working directory first."));
|
|
669
|
+
const targetPro = mode === "pro";
|
|
670
|
+
const response = await fetch(targetPro ? "/pro/api/conversations" : "/api/conversations", {
|
|
671
|
+
method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" },
|
|
672
|
+
body: JSON.stringify({ cwd, ...(targetPro ? {} : { agent_mode: mode }) }),
|
|
673
|
+
});
|
|
674
|
+
const body = await response.json();
|
|
675
|
+
if (response.status === 401) showGate();
|
|
676
|
+
if (!response.ok) throw new Error(apiError(body, `HTTP ${response.status}`));
|
|
677
|
+
workspaceNavigation = true;
|
|
678
|
+
if (el.modePicker) renderAgentMode();
|
|
679
|
+
try { sessionStorage.setItem(`neutrino.workspace-draft.${body.key}`, el.input.value); } catch { /* Navigation still works without storage. */ }
|
|
680
|
+
const target = `${targetPro ? "/pro" : "/"}?live=${encodeURIComponent(body.key)}`;
|
|
681
|
+
if (targetPro) startProTransition(target);
|
|
682
|
+
else { try { location.assign(target); } catch { location.href = target; } }
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** A transferred chat remains reloadable until the user chooses another view. */
|
|
686
|
+
function clearLinkedConversation(keepKey) {
|
|
687
|
+
const params = new URLSearchParams(location.search);
|
|
688
|
+
if (!params.has("live") || params.get("live") === keepKey) return;
|
|
689
|
+
params.delete("live");
|
|
690
|
+
const query = params.toString();
|
|
691
|
+
try { history.replaceState(history.state, "", location.pathname + (query ? `?${query}` : "") + location.hash); } catch { /* History is optional. */ }
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async function openLinkedConversation() {
|
|
695
|
+
const key = new URLSearchParams(location.search).get("live");
|
|
696
|
+
if (!key || state.selection) return false;
|
|
697
|
+
try {
|
|
698
|
+
const response = await apiFetch(`/api/live/${encodeURIComponent(key)}`, { cache: "no-store" });
|
|
699
|
+
const body = await response.json();
|
|
700
|
+
if (!response.ok) throw new Error(apiError(body, `HTTP ${response.status}`));
|
|
701
|
+
if (state.selection) return false;
|
|
702
|
+
setWorkspace({ path: body.cwd });
|
|
703
|
+
await selectLive(key);
|
|
704
|
+
state.snapshot = body;
|
|
705
|
+
renderLive();
|
|
706
|
+
setConversationView(true);
|
|
707
|
+
try {
|
|
708
|
+
const storageKey = `neutrino.workspace-draft.${key}`;
|
|
709
|
+
el.input.value = sessionStorage.getItem(storageKey) || "";
|
|
710
|
+
sessionStorage.removeItem(storageKey);
|
|
711
|
+
} catch { /* Storage is optional. */ }
|
|
712
|
+
autosize(); el.input.focus();
|
|
713
|
+
return true;
|
|
714
|
+
} catch (error) { el.sub.textContent = error.message; return false; }
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function initializeAgentMode() {
|
|
718
|
+
if (!el.modePicker?.querySelectorAll) return;
|
|
719
|
+
for (const button of el.modePicker.querySelectorAll("[data-agent-mode]")) {
|
|
720
|
+
button.addEventListener("click", () => {
|
|
721
|
+
agentModePending = chooseAgentMode(button.dataset.agentMode);
|
|
722
|
+
// Keep the rejection available to sendMessage while avoiding an unhandled promise.
|
|
723
|
+
void agentModePending.catch(() => {});
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
el.modePicker.addEventListener("keydown", event => {
|
|
727
|
+
if (event.key === "Escape") { el.modePicker.open = false; el.modePicker.querySelector("summary")?.focus(); event.stopPropagation(); }
|
|
728
|
+
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
|
|
729
|
+
event.preventDefault(); el.modePicker.open = true;
|
|
730
|
+
const buttons = [...el.modePicker.querySelectorAll("[data-agent-mode]")];
|
|
731
|
+
let index = buttons.indexOf(document.activeElement);
|
|
732
|
+
index = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : (index + (event.key === "ArrowDown" ? 1 : -1) + buttons.length) % buttons.length;
|
|
733
|
+
buttons[index]?.focus();
|
|
734
|
+
});
|
|
735
|
+
document.addEventListener("pointerdown", event => {
|
|
736
|
+
if (el.modePicker.open && !el.modePicker.contains(event.target)) el.modePicker.open = false;
|
|
737
|
+
});
|
|
738
|
+
if (el.modePicker) renderAgentMode();
|
|
739
|
+
}
|
|
740
|
+
|
|
492
741
|
let proUI = null;
|
|
493
742
|
const preferences = createSettingsUI({
|
|
494
743
|
apiFetch,
|
|
495
744
|
proMode: PRO_MODE,
|
|
496
745
|
onCatalogChange: (catalog) => proUI?.load(catalog),
|
|
497
746
|
onLanguageChange: (language) => changeInterfaceLanguage(language),
|
|
747
|
+
// A check started from Settings offers its result through the same dialog as
|
|
748
|
+
// the notice shown on load, and lights the corner badge with it.
|
|
749
|
+
onUpdateFound: (update) => {
|
|
750
|
+
noteUpdateAvailable(update);
|
|
751
|
+
return openUpdateDialog(update);
|
|
752
|
+
},
|
|
498
753
|
async applyModel(selection) {
|
|
499
754
|
const current = state.selection;
|
|
500
755
|
if (!current) throw new Error("Select a conversation first.");
|
|
@@ -799,20 +1054,42 @@ function renderGroup(group, options = {}) {
|
|
|
799
1054
|
wrap.className = "group";
|
|
800
1055
|
|
|
801
1056
|
const collapsed = !options.forceOpen && state.collapsed.has(group.key);
|
|
1057
|
+
// The row answers "start something here", not "how much is already here": a
|
|
1058
|
+
// plus beside the folder opens a fresh conversation in it, the way Codex puts
|
|
1059
|
+
// a project's own new-chat control next to the project. Two actions cannot
|
|
1060
|
+
// share one button, so the row holds the folder and the plus side by side.
|
|
1061
|
+
const head = document.createElement("div");
|
|
1062
|
+
head.className = "group-head";
|
|
1063
|
+
|
|
802
1064
|
const header = document.createElement("button");
|
|
803
1065
|
header.className = "group-header";
|
|
1066
|
+
header.type = "button";
|
|
1067
|
+
header.setAttribute("aria-expanded", String(!collapsed));
|
|
804
1068
|
header.innerHTML =
|
|
805
1069
|
`<span class="chevron${collapsed ? " collapsed" : ""}">` +
|
|
806
1070
|
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>' +
|
|
807
1071
|
"</span>" +
|
|
808
|
-
`<span class="path" title="${escapeHtml(group.cwd)}">${escapeHtml(shortenPath(group.cwd))}</span
|
|
809
|
-
`<span class="count">${group.sessions.length}</span>`;
|
|
1072
|
+
`<span class="path" title="${escapeHtml(group.cwd)}">${escapeHtml(shortenPath(group.cwd))}</span>`;
|
|
810
1073
|
header.addEventListener("click", () => {
|
|
811
1074
|
if (state.collapsed.has(group.key)) state.collapsed.delete(group.key);
|
|
812
1075
|
else state.collapsed.add(group.key);
|
|
813
1076
|
renderSidebar();
|
|
814
1077
|
});
|
|
815
|
-
|
|
1078
|
+
head.appendChild(header);
|
|
1079
|
+
|
|
1080
|
+
if (group.cwd) {
|
|
1081
|
+
const start = document.createElement("button");
|
|
1082
|
+
start.className = "icon-btn group-new";
|
|
1083
|
+
start.type = "button";
|
|
1084
|
+
start.title = tr("New conversation in this folder");
|
|
1085
|
+
start.setAttribute("aria-label", tr("New conversation in this folder"));
|
|
1086
|
+
start.innerHTML =
|
|
1087
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
|
|
1088
|
+
start.addEventListener("click", () => startConversationIn(group.cwd));
|
|
1089
|
+
head.appendChild(start);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
wrap.appendChild(head);
|
|
816
1093
|
|
|
817
1094
|
if (collapsed) return wrap;
|
|
818
1095
|
|
|
@@ -846,6 +1123,19 @@ function renderGroup(group, options = {}) {
|
|
|
846
1123
|
return wrap;
|
|
847
1124
|
}
|
|
848
1125
|
|
|
1126
|
+
/**
|
|
1127
|
+
* The plus on a folder header. It opens that folder's landing view, where the
|
|
1128
|
+
* next message becomes a new conversation in that directory, so starting one
|
|
1129
|
+
* costs a click instead of a dialog.
|
|
1130
|
+
*/
|
|
1131
|
+
function startConversationIn(cwd) {
|
|
1132
|
+
const path = String(cwd ?? "").trim();
|
|
1133
|
+
if (!path) return;
|
|
1134
|
+
renderWorkspaceLanding(path);
|
|
1135
|
+
setConversationView(true);
|
|
1136
|
+
el.input?.focus?.();
|
|
1137
|
+
}
|
|
1138
|
+
|
|
849
1139
|
function isSelected(session) {
|
|
850
1140
|
const selection = state.selection;
|
|
851
1141
|
if (!selection) return false;
|
|
@@ -1133,6 +1423,7 @@ function resetMessagePane(text) {
|
|
|
1133
1423
|
}
|
|
1134
1424
|
|
|
1135
1425
|
async function selectLive(key) {
|
|
1426
|
+
if (el.modePicker) { clearAgentModeFeedback(); clearLinkedConversation(key); }
|
|
1136
1427
|
preferences.clear();
|
|
1137
1428
|
closeStream();
|
|
1138
1429
|
state.selection = { kind: "live", key };
|
|
@@ -1209,6 +1500,7 @@ function closeStream() {
|
|
|
1209
1500
|
}
|
|
1210
1501
|
|
|
1211
1502
|
async function selectFile(id) {
|
|
1503
|
+
if (el.modePicker) { clearAgentModeFeedback(); clearLinkedConversation(); }
|
|
1212
1504
|
preferences.clear();
|
|
1213
1505
|
closeStream();
|
|
1214
1506
|
state.selection = { kind: "file", id };
|
|
@@ -1254,6 +1546,7 @@ function renderFile(data) {
|
|
|
1254
1546
|
renderFileControls(data);
|
|
1255
1547
|
maybeOpenPlan(data.plan);
|
|
1256
1548
|
renderPlanCard(data);
|
|
1549
|
+
if (el.modePicker) renderAgentMode();
|
|
1257
1550
|
}
|
|
1258
1551
|
|
|
1259
1552
|
/**
|
|
@@ -1300,8 +1593,10 @@ function renderFileControls(data) {
|
|
|
1300
1593
|
}
|
|
1301
1594
|
|
|
1302
1595
|
/** Attach to a resumed conversation, creating it server-side when needed. */
|
|
1303
|
-
async function continueSession(id, selection = preferences.selectionForFile(id)) {
|
|
1304
|
-
const payload = PRO_MODE ? { pro_preset: currentProPreset() } :
|
|
1596
|
+
async function continueSession(id, selection = preferences.selectionForFile(id), agentMode) {
|
|
1597
|
+
const payload = PRO_MODE ? { pro_preset: currentProPreset() } : {
|
|
1598
|
+
...(selection ? { selection } : {}), ...(agentMode ? { agent_mode: agentMode } : {}),
|
|
1599
|
+
};
|
|
1305
1600
|
const response = await apiFetch(`/api/session/${encodeURIComponent(id)}/continue`, {
|
|
1306
1601
|
method: "POST",
|
|
1307
1602
|
headers: payload ? { "Content-Type": "application/json" } : undefined,
|
|
@@ -1315,6 +1610,7 @@ async function continueSession(id, selection = preferences.selectionForFile(id))
|
|
|
1315
1610
|
|
|
1316
1611
|
function setComposerMode(mode) {
|
|
1317
1612
|
state.composerMode = mode;
|
|
1613
|
+
if (el.modePicker) renderAgentMode();
|
|
1318
1614
|
el.agentActions.classList.add("hidden");
|
|
1319
1615
|
el.composer.classList.remove("hidden");
|
|
1320
1616
|
el.input.placeholder = mode === "resume"
|
|
@@ -1516,6 +1812,7 @@ function renderLive() {
|
|
|
1516
1812
|
});
|
|
1517
1813
|
maybeOpenPlan(snapshot.plan);
|
|
1518
1814
|
renderPlanCard(snapshot);
|
|
1815
|
+
if (el.modePicker) renderAgentMode();
|
|
1519
1816
|
}
|
|
1520
1817
|
|
|
1521
1818
|
async function changeApprovalMode(key, mode) {
|
|
@@ -1577,7 +1874,7 @@ function renderControls(snapshot) {
|
|
|
1577
1874
|
// review callback is still winding down.
|
|
1578
1875
|
const reviewing = snapshot.approval ? null : snapshot.reviewing;
|
|
1579
1876
|
const subagent = snapshot.subagent;
|
|
1580
|
-
badge.textContent = snapshot.paused
|
|
1877
|
+
badge.textContent = snapshot.task?.state === "cancelling" ? tr("Stopping tasks…") : snapshot.paused
|
|
1581
1878
|
? tr("paused")
|
|
1582
1879
|
: snapshot.busy
|
|
1583
1880
|
? snapshot.compressing
|
|
@@ -1592,10 +1889,10 @@ function renderControls(snapshot) {
|
|
|
1592
1889
|
: tr("idle");
|
|
1593
1890
|
el.controls.appendChild(badge);
|
|
1594
1891
|
|
|
1595
|
-
if (snapshot.saveError) {
|
|
1892
|
+
if (snapshot.saveError || snapshot.task?.persistenceError) {
|
|
1596
1893
|
const warning = document.createElement("span");
|
|
1597
1894
|
warning.className = "badge paused";
|
|
1598
|
-
warning.textContent = tr("History save failed: {error}", { error: snapshot.saveError });
|
|
1895
|
+
warning.textContent = tr("History save failed: {error}", { error: snapshot.saveError || snapshot.task?.persistenceError });
|
|
1599
1896
|
el.controls.appendChild(warning);
|
|
1600
1897
|
}
|
|
1601
1898
|
|
|
@@ -1732,6 +2029,12 @@ function renderMessage(message, index) {
|
|
|
1732
2029
|
`<span class="tool-args">${escapeHtml(truncate(safeJson(message.arguments), 120))}</span>`;
|
|
1733
2030
|
details.appendChild(summary);
|
|
1734
2031
|
|
|
2032
|
+
if (message.name === "execute_ts" && typeof message.arguments?.code === "string") {
|
|
2033
|
+
const program = document.createElement("pre");
|
|
2034
|
+
program.className = "tool-result program-source";
|
|
2035
|
+
program.textContent = message.arguments.code;
|
|
2036
|
+
details.appendChild(program);
|
|
2037
|
+
}
|
|
1735
2038
|
const result = document.createElement("pre");
|
|
1736
2039
|
result.className = "tool-result";
|
|
1737
2040
|
result.textContent = message.result || "(no output)";
|
|
@@ -1871,6 +2174,7 @@ function activateComposer() {
|
|
|
1871
2174
|
}
|
|
1872
2175
|
|
|
1873
2176
|
async function sendMessage() {
|
|
2177
|
+
try { await agentModePending; } catch (error) { el.sub.textContent = error.message; return; }
|
|
1874
2178
|
const selection = state.selection;
|
|
1875
2179
|
if (!selection) return;
|
|
1876
2180
|
const text = el.input.value;
|
|
@@ -2009,6 +2313,7 @@ function showHome({ menu = true } = {}) {
|
|
|
2009
2313
|
|
|
2010
2314
|
/** Show a folder as an empty workspace ready for its first message. */
|
|
2011
2315
|
function renderWorkspaceLanding(path) {
|
|
2316
|
+
if (el.modePicker) { clearAgentModeFeedback(); clearLinkedConversation(); }
|
|
2012
2317
|
const sameLanding = state.selection?.kind === "new" && state.selection.cwd === path;
|
|
2013
2318
|
state.selection = { kind: "new", cwd: path };
|
|
2014
2319
|
state.snapshot = null;
|
|
@@ -2284,11 +2589,14 @@ async function submitNewChat() {
|
|
|
2284
2589
|
}
|
|
2285
2590
|
}
|
|
2286
2591
|
|
|
2287
|
-
/** Create a browser-owned conversation in `cwd` and
|
|
2592
|
+
/** Create a browser-owned conversation in `cwd` and show all sidebar directories. */
|
|
2288
2593
|
async function createConversation(cwd, selection, approvalMode, proPreset = currentProPreset()) {
|
|
2289
2594
|
const payload = { cwd };
|
|
2290
2595
|
if (PRO_MODE) payload.pro_preset = proPreset;
|
|
2291
|
-
else
|
|
2596
|
+
else {
|
|
2597
|
+
payload.agent_mode = currentAgentMode();
|
|
2598
|
+
if (selection && selection.provider && selection.model) payload.selection = selection;
|
|
2599
|
+
}
|
|
2292
2600
|
if (approvalMode === "request" || approvalMode === "auto") payload.approval_mode = approvalMode;
|
|
2293
2601
|
const response = await apiFetch("/api/conversations", {
|
|
2294
2602
|
method: "POST",
|
|
@@ -2297,7 +2605,7 @@ async function createConversation(cwd, selection, approvalMode, proPreset = curr
|
|
|
2297
2605
|
});
|
|
2298
2606
|
const data = await response.json().catch(() => ({}));
|
|
2299
2607
|
if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
|
|
2300
|
-
setWorkspace(
|
|
2608
|
+
setWorkspace(null);
|
|
2301
2609
|
await loadOverview({ silent: true });
|
|
2302
2610
|
return data;
|
|
2303
2611
|
}
|
|
@@ -2478,6 +2786,9 @@ function planStatusLabel(status) {
|
|
|
2478
2786
|
if (status === "done") return tr("Done");
|
|
2479
2787
|
if (status === "active") return tr("Active");
|
|
2480
2788
|
if (status === "failed") return tr("Failed");
|
|
2789
|
+
if (status === "blocked") return tr("Blocked");
|
|
2790
|
+
if (status === "cancelled") return tr("Cancelled");
|
|
2791
|
+
if (status === "skipped") return tr("Skipped");
|
|
2481
2792
|
return tr("Pending");
|
|
2482
2793
|
}
|
|
2483
2794
|
|
|
@@ -2522,7 +2833,7 @@ function renderPlanCard(source) {
|
|
|
2522
2833
|
steps.className = "plan-steps";
|
|
2523
2834
|
let done = 0;
|
|
2524
2835
|
for (const step of plan.steps) {
|
|
2525
|
-
const status = ["done", "active", "failed", "pending"].includes(step.status) ? step.status : "pending";
|
|
2836
|
+
const status = ["done", "active", "failed", "pending", "blocked", "cancelled", "skipped"].includes(step.status) ? step.status : "pending";
|
|
2526
2837
|
if (status === "done") done += 1;
|
|
2527
2838
|
const row = document.createElement("div");
|
|
2528
2839
|
row.className = `plan-step ${status}`;
|
|
@@ -2880,7 +3191,7 @@ function proDelay(callback, ms) {
|
|
|
2880
3191
|
if (typeof setTimeout === "function") setTimeout(callback, ms);
|
|
2881
3192
|
}
|
|
2882
3193
|
|
|
2883
|
-
function startProTransition() {
|
|
3194
|
+
function startProTransition(target = "/pro") {
|
|
2884
3195
|
if (PRO_MODE || proTransitionStarted) return;
|
|
2885
3196
|
proTransitionStarted = true;
|
|
2886
3197
|
const started = Date.now();
|
|
@@ -2896,7 +3207,7 @@ function startProTransition() {
|
|
|
2896
3207
|
curtain.classList.add("pro-curtain-visible");
|
|
2897
3208
|
|
|
2898
3209
|
proDelay(() => {
|
|
2899
|
-
try { location.assign("/pro"); } catch { location.href = "/pro"; }
|
|
3210
|
+
try { location.assign(typeof target === "string" ? target : "/pro"); } catch { location.href = typeof target === "string" ? target : "/pro"; }
|
|
2900
3211
|
}, PRO_TRANSITION_COVER_MS);
|
|
2901
3212
|
}
|
|
2902
3213
|
|
|
@@ -2917,6 +3228,8 @@ function removeProCurtains() {
|
|
|
2917
3228
|
*/
|
|
2918
3229
|
function resetProTransition({ clearRecord = true } = {}) {
|
|
2919
3230
|
removeProCurtains();
|
|
3231
|
+
if (typeof workspaceNavigation !== "undefined") workspaceNavigation = false;
|
|
3232
|
+
if (typeof el !== "undefined" && el.modePicker) renderAgentMode();
|
|
2920
3233
|
document.documentElement?.classList?.remove?.("pro-transitioning");
|
|
2921
3234
|
proTransitionStarted = false;
|
|
2922
3235
|
if (!clearRecord) return;
|
|
@@ -3023,7 +3336,7 @@ function initializeProWorkspace() {
|
|
|
3023
3336
|
// the record and the pre-paint cover behind. Neither may ever affect the
|
|
3024
3337
|
// regular console, so clear them before wiring the badge.
|
|
3025
3338
|
resetProTransition();
|
|
3026
|
-
el.brandLogo?.addEventListener("click", startProTransition);
|
|
3339
|
+
el.brandLogo?.addEventListener("click", () => startProTransition());
|
|
3027
3340
|
initializeProTip();
|
|
3028
3341
|
}
|
|
3029
3342
|
|
|
@@ -3140,18 +3453,23 @@ forgetStaleConversationEntry();
|
|
|
3140
3453
|
|
|
3141
3454
|
initializeLanguage();
|
|
3142
3455
|
initializeProWorkspace();
|
|
3456
|
+
initializeAgentMode();
|
|
3143
3457
|
preferences.load()
|
|
3144
3458
|
.then(async (config) => {
|
|
3145
3459
|
const language = config?.settings?.language;
|
|
3146
3460
|
if (language && language !== uiLanguage()) {
|
|
3147
3461
|
await changeInterfaceLanguage(language, { persist: false });
|
|
3148
3462
|
}
|
|
3149
|
-
//
|
|
3150
|
-
//
|
|
3151
|
-
|
|
3463
|
+
// The announcement stream needs the login cookie, so it opens once the
|
|
3464
|
+
// settings payload has loaded. A release it reports right away opens the
|
|
3465
|
+
// notice; one that lands while the background invitation is up waits for it
|
|
3466
|
+
// to close, because two modal dialogs must never stack on top of each other.
|
|
3467
|
+
openUpdateEvents();
|
|
3468
|
+
maybePromptBackground(config);
|
|
3152
3469
|
})
|
|
3153
3470
|
.catch(error => console.error(error));
|
|
3154
|
-
loadSeen().then(() => loadOverview()).then(() => {
|
|
3471
|
+
loadSeen().then(() => loadOverview()).then(async () => {
|
|
3472
|
+
if (await openLinkedConversation()) return;
|
|
3155
3473
|
// Reopen the folder the user had open last time, without stealing a click
|
|
3156
3474
|
// that landed on a saved conversation while the overview was loading.
|
|
3157
3475
|
if (state.workspace && !state.selection) renderWorkspaceLanding(state.workspace.path);
|
package/web/i18n.js
CHANGED
|
@@ -79,6 +79,21 @@ export function locale() {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
const ZH = {
|
|
82
|
+
"Agent mode": "Agent 模式",
|
|
83
|
+
"Standard mode": "标准模式",
|
|
84
|
+
"PTC mode": "PTC模式",
|
|
85
|
+
"Fast mode": "快速模式",
|
|
86
|
+
"Professional mode": "专业模式",
|
|
87
|
+
"Full-featured Agent for every scenario": "功能完整的Agent,适用于所有场景",
|
|
88
|
+
"All standard capabilities, with code-based planning to save tokens": "拥有标准模式所有功能,通过代码实现规划,节省Token",
|
|
89
|
+
"Less thinking and fewer calls for quick answers": "减少思考和调用,迅速回答问题",
|
|
90
|
+
"Assign tasks to the best-suited models so multiple models complement each other": "适合需要多个模型取长补短的任务,将任务分给最适合他的模型",
|
|
91
|
+
"Applies to the next turn": "从下一轮开始生效",
|
|
92
|
+
"Changing mode…": "正在切换模式…",
|
|
93
|
+
"Could not change mode: {error}": "无法切换模式:{error}",
|
|
94
|
+
"Select a working directory first.": "请先选择工作目录。",
|
|
95
|
+
"Program": "程序",
|
|
96
|
+
|
|
82
97
|
"Pro preset": "Pro 预设",
|
|
83
98
|
"Preset": "预设",
|
|
84
99
|
"Neutrino Pro": "Neutrino Pro",
|
|
@@ -115,6 +130,13 @@ const ZH = {
|
|
|
115
130
|
"Updated to {version}. Restart Neutrino to use it.": "已更新到 {version},请重新启动 Neutrino 以使用新版本。",
|
|
116
131
|
"Update failed: {error}": "更新失败:{error}",
|
|
117
132
|
"Update by hand with:": "也可以手动更新:",
|
|
133
|
+
"Updates": "更新",
|
|
134
|
+
"Check for updates": "检查更新",
|
|
135
|
+
"Checking for updates…": "正在检查更新…",
|
|
136
|
+
"Version {latest} is available. This install runs {current}.": "发现新版本 {latest},当前安装为 {current}。",
|
|
137
|
+
"Neutrino {version} is up to date.": "Neutrino {version} 已是最新版本。",
|
|
138
|
+
"Update check failed: {error}": "检查更新失败:{error}",
|
|
139
|
+
"Neutrino checks npm for new releases every 30 minutes. Both workspaces share that check.": "Neutrino 每 30 分钟通过 npm 检查一次新版本,普通模式与 Pro 模式共用该检查。",
|
|
118
140
|
// -- chrome -------------------------------------------------------------
|
|
119
141
|
"Neutrino": "Neutrino",
|
|
120
142
|
"Switch to Pro mode": "切换至Pro模式",
|
|
@@ -211,6 +233,7 @@ const ZH = {
|
|
|
211
233
|
"Choose a conversation from the sidebar to view it.": "从侧边栏选择一个对话查看。",
|
|
212
234
|
"No conversations yet.": "暂无对话。",
|
|
213
235
|
"No conversations in this folder yet.": "此文件夹中暂无对话。",
|
|
236
|
+
"New conversation in this folder": "在此文件夹中新建对话",
|
|
214
237
|
"Type a message below to start a conversation in this folder.": "在下方输入消息,即可在此文件夹中开始对话。",
|
|
215
238
|
"Send a message to get started.": "发送一条消息开始对话。",
|
|
216
239
|
"This conversation is empty.": "此对话为空。",
|
|
@@ -261,6 +284,10 @@ const ZH = {
|
|
|
261
284
|
|
|
262
285
|
// -- live status --------------------------------------------------------
|
|
263
286
|
"paused": "已暂停",
|
|
287
|
+
"Stopping tasks…": "正在停止任务…",
|
|
288
|
+
"Blocked": "受阻",
|
|
289
|
+
"Cancelled": "已取消",
|
|
290
|
+
"Skipped": "已跳过",
|
|
264
291
|
"working": "处理中",
|
|
265
292
|
"idle": "空闲",
|
|
266
293
|
"compressing": "正在压缩上下文",
|