@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/web/app.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createSettingsUI } from "./settings.js";
|
|
2
|
+
import { createProUI } from "./pro.js";
|
|
2
3
|
import { renderMarkdown as renderMarkdownHtml } from "./markdown.js";
|
|
3
4
|
import { turnAnswerIndex, turnFolds, turnPreview, turnRanges } from "./turns.js";
|
|
4
5
|
import { applyTranslations, getLanguage as currentLanguage, locale as currentLocale, setLanguage as setI18nLanguage, t as translate } from "./i18n.js";
|
|
@@ -24,6 +25,37 @@ const REFRESH_MS = 4000;
|
|
|
24
25
|
// Remove credentials left behind by older versions of the UI.
|
|
25
26
|
try { localStorage.removeItem("neutrino.token"); } catch { /* Storage may be blocked. */ }
|
|
26
27
|
|
|
28
|
+
// ------------------------------------------------------------------ pro mode
|
|
29
|
+
//
|
|
30
|
+
// The "Pro" workspace is served at /pro and owns an isolated API under
|
|
31
|
+
// /pro/api, while this page and the login cookie are shared. Detect the mode
|
|
32
|
+
// before the request helpers below so no call can leak between the workspaces.
|
|
33
|
+
const PRO_MODE = (() => {
|
|
34
|
+
try {
|
|
35
|
+
const pathname = String(location?.pathname ?? "");
|
|
36
|
+
return pathname === "/pro" || pathname.startsWith("/pro/");
|
|
37
|
+
} catch {
|
|
38
|
+
return false; // Test sandboxes and unusual embedders have no location.
|
|
39
|
+
}
|
|
40
|
+
})();
|
|
41
|
+
|
|
42
|
+
const API_BASE = PRO_MODE ? "/pro/api" : "/api";
|
|
43
|
+
// Lets Pro-specific chrome (the wider sidebar needed by the longer brand name)
|
|
44
|
+
// apply before the first paint of this module.
|
|
45
|
+
if (PRO_MODE) document.body?.classList.add("pro-mode");
|
|
46
|
+
|
|
47
|
+
/** Point an /api request at the active workspace; other paths are unchanged. */
|
|
48
|
+
function apiPath(path) {
|
|
49
|
+
if (!PRO_MODE) return path;
|
|
50
|
+
const text = String(path);
|
|
51
|
+
return text.startsWith("/api/") ? `${API_BASE}${text.slice(4)}` : path;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Namespace a remembered preference so both workspaces keep their own copy. */
|
|
55
|
+
function modeStorageKey(key) {
|
|
56
|
+
return PRO_MODE ? `neutrino.pro.${key}` : key;
|
|
57
|
+
}
|
|
58
|
+
|
|
27
59
|
// Translation lookup. `translate` is an ES-module import; the browser tests
|
|
28
60
|
// that evaluate this file in a sandbox strip imports, so the helper degrades to
|
|
29
61
|
// the English source text instead of throwing. Interpolation is kept here too.
|
|
@@ -92,7 +124,7 @@ const initialLogin = (() => {
|
|
|
92
124
|
|
|
93
125
|
async function apiFetch(path, init = {}) {
|
|
94
126
|
if (!await initialLogin) throw new Error("Password login required.");
|
|
95
|
-
const response = await fetch(path, { ...init, credentials: "same-origin" });
|
|
127
|
+
const response = await fetch(apiPath(path), { ...init, credentials: "same-origin" });
|
|
96
128
|
if (response.status === 401) {
|
|
97
129
|
showGate();
|
|
98
130
|
throw new Error("Password login required.");
|
|
@@ -158,6 +190,11 @@ const el = {
|
|
|
158
190
|
sessions: document.getElementById("sessions"),
|
|
159
191
|
status: document.getElementById("status"),
|
|
160
192
|
refresh: document.getElementById("refresh"),
|
|
193
|
+
brandLogo: document.getElementById("brand-logo"),
|
|
194
|
+
brandName: document.getElementById("brand-name"),
|
|
195
|
+
proTip: document.getElementById("pro-tip"),
|
|
196
|
+
proTipClose: document.getElementById("pro-tip-close"),
|
|
197
|
+
proExit: document.getElementById("pro-exit"),
|
|
161
198
|
title: document.getElementById("session-title"),
|
|
162
199
|
sub: document.getElementById("session-sub"),
|
|
163
200
|
controls: document.getElementById("controls"),
|
|
@@ -249,13 +286,16 @@ const state = {
|
|
|
249
286
|
};
|
|
250
287
|
|
|
251
288
|
function storedWorkspace() {
|
|
252
|
-
const path = readStorage("neutrino.workspace");
|
|
289
|
+
const path = readStorage(modeStorageKey("neutrino.workspace"));
|
|
253
290
|
if (!path) return null;
|
|
254
291
|
return { key: pathKey(path), path };
|
|
255
292
|
}
|
|
256
293
|
|
|
294
|
+
let proUI = null;
|
|
257
295
|
const preferences = createSettingsUI({
|
|
258
296
|
apiFetch,
|
|
297
|
+
proMode: PRO_MODE,
|
|
298
|
+
onCatalogChange: (catalog) => proUI?.load(catalog),
|
|
259
299
|
onLanguageChange: (language) => changeInterfaceLanguage(language),
|
|
260
300
|
async applyModel(selection) {
|
|
261
301
|
const current = state.selection;
|
|
@@ -276,6 +316,37 @@ const preferences = createSettingsUI({
|
|
|
276
316
|
},
|
|
277
317
|
});
|
|
278
318
|
|
|
319
|
+
if (PRO_MODE && typeof createProUI === "function") {
|
|
320
|
+
proUI = createProUI({
|
|
321
|
+
async changePreset(preset) {
|
|
322
|
+
const current = state.selection;
|
|
323
|
+
if (current?.kind === "live") {
|
|
324
|
+
const response = await apiFetch(`/api/live/${encodeURIComponent(current.key)}/pro-preset`, {
|
|
325
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ preset }),
|
|
326
|
+
});
|
|
327
|
+
const body = await response.json();
|
|
328
|
+
if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`);
|
|
329
|
+
if (state.selection?.kind === "live" && state.selection.key === current.key) {
|
|
330
|
+
state.snapshot = body.snapshot;
|
|
331
|
+
renderLive();
|
|
332
|
+
}
|
|
333
|
+
} else if (current?.kind !== "file") {
|
|
334
|
+
const response = await apiFetch("/api/settings", {
|
|
335
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
336
|
+
body: JSON.stringify({ settings: { pro_preset: preset } }),
|
|
337
|
+
});
|
|
338
|
+
const body = await response.json();
|
|
339
|
+
if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`);
|
|
340
|
+
await preferences.load();
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function currentProPreset() {
|
|
347
|
+
return proUI?.preset() || preferences.catalog?.()?.pro?.preset;
|
|
348
|
+
}
|
|
349
|
+
|
|
279
350
|
// ------------------------------------------------------------------- sounds
|
|
280
351
|
//
|
|
281
352
|
// A turn that ends while the tab is in the background is invisible, so finished
|
|
@@ -395,7 +466,7 @@ function workspaceName(value) {
|
|
|
395
466
|
|
|
396
467
|
function setWorkspace(workspace, { persist = true } = {}) {
|
|
397
468
|
state.workspace = workspace ? { key: pathKey(workspace.path), path: workspace.path } : null;
|
|
398
|
-
if (persist) writeStorage("neutrino.workspace", state.workspace?.path ?? "");
|
|
469
|
+
if (persist) writeStorage(modeStorageKey("neutrino.workspace"), state.workspace?.path ?? "");
|
|
399
470
|
renderWorkspaceChrome();
|
|
400
471
|
renderSidebar();
|
|
401
472
|
}
|
|
@@ -649,7 +720,7 @@ async function selectLive(key) {
|
|
|
649
720
|
|
|
650
721
|
function openStream(key) {
|
|
651
722
|
closeStream();
|
|
652
|
-
const source = new EventSource(`/api/live/${encodeURIComponent(key)}/stream`);
|
|
723
|
+
const source = new EventSource(apiPath(`/api/live/${encodeURIComponent(key)}/stream`));
|
|
653
724
|
state.eventSource = source;
|
|
654
725
|
|
|
655
726
|
source.addEventListener("snapshot", (event) => {
|
|
@@ -728,6 +799,7 @@ function renderFile(data) {
|
|
|
728
799
|
el.title.textContent = firstUserMessage(messages) || tr("Conversation");
|
|
729
800
|
el.sub.textContent = [data.cwd, data.model, data.provider].filter(Boolean).join(" · ");
|
|
730
801
|
preferences.showConversation(data, `file:${data.id}`);
|
|
802
|
+
proUI?.show(data, `file:${data.id}`);
|
|
731
803
|
renderMessageList(messages, null, { readOnly: true, context: data.context, active: false });
|
|
732
804
|
renderFileControls(data);
|
|
733
805
|
maybeOpenPlan(data.plan);
|
|
@@ -779,10 +851,11 @@ function renderFileControls(data) {
|
|
|
779
851
|
|
|
780
852
|
/** Attach to a resumed conversation, creating it server-side when needed. */
|
|
781
853
|
async function continueSession(id, selection = preferences.selectionForFile(id)) {
|
|
854
|
+
const payload = PRO_MODE ? { pro_preset: currentProPreset() } : selection ? { selection } : undefined;
|
|
782
855
|
const response = await apiFetch(`/api/session/${encodeURIComponent(id)}/continue`, {
|
|
783
856
|
method: "POST",
|
|
784
|
-
headers:
|
|
785
|
-
body:
|
|
857
|
+
headers: payload ? { "Content-Type": "application/json" } : undefined,
|
|
858
|
+
body: payload ? JSON.stringify(payload) : undefined,
|
|
786
859
|
});
|
|
787
860
|
const body = await response.json().catch(() => ({}));
|
|
788
861
|
if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`);
|
|
@@ -959,6 +1032,7 @@ function renderLive() {
|
|
|
959
1032
|
el.title.textContent = snapshot.title || tr("New conversation");
|
|
960
1033
|
el.sub.textContent = [snapshot.cwd, snapshot.model, snapshot.provider].filter(Boolean).join(" · ");
|
|
961
1034
|
preferences.showConversation(snapshot, `live:${snapshot.key}`);
|
|
1035
|
+
proUI?.show(snapshot, `live:${snapshot.key}`);
|
|
962
1036
|
renderControls(snapshot);
|
|
963
1037
|
renderApproval(snapshot);
|
|
964
1038
|
renderMessageList(snapshot.messages ?? [], snapshot.streamingContent, {
|
|
@@ -1324,6 +1398,10 @@ async function sendMessage() {
|
|
|
1324
1398
|
if (!selection) return;
|
|
1325
1399
|
const text = el.input.value;
|
|
1326
1400
|
if (!text.trim()) return;
|
|
1401
|
+
if (PRO_MODE) {
|
|
1402
|
+
try { await proUI?.flush(); }
|
|
1403
|
+
catch (error) { el.sub.textContent = error.message; return; }
|
|
1404
|
+
}
|
|
1327
1405
|
|
|
1328
1406
|
if (selection.kind === "live") {
|
|
1329
1407
|
el.input.value = "";
|
|
@@ -1467,6 +1545,7 @@ function renderWorkspaceLanding(path) {
|
|
|
1467
1545
|
el.title.textContent = workspaceName(path) || tr("Working directory");
|
|
1468
1546
|
el.sub.textContent = path;
|
|
1469
1547
|
setComposerMode("new");
|
|
1548
|
+
proUI?.show(null, `new:${path}`);
|
|
1470
1549
|
el.messages.innerHTML = "";
|
|
1471
1550
|
|
|
1472
1551
|
const empty = document.createElement("div");
|
|
@@ -1616,6 +1695,19 @@ function fillNewChatModels(catalog, selectedModel) {
|
|
|
1616
1695
|
})), selectedModel);
|
|
1617
1696
|
}
|
|
1618
1697
|
|
|
1698
|
+
/** Offer only the levels the chosen model declares, keeping the draft if valid. */
|
|
1699
|
+
function fillNewChatIntensities(catalog, selected) {
|
|
1700
|
+
const provider = catalog.providers.find((entry) => entry.name === el.newChatProvider.value) ?? catalog.providers[0];
|
|
1701
|
+
const model = provider?.models.find((entry) => entry.name === el.newChatModel.value);
|
|
1702
|
+
const levels = Array.isArray(model?.intensities) && model.intensities.length ? model.intensities : catalog.intensities;
|
|
1703
|
+
let wanted = selected;
|
|
1704
|
+
if (!levels.includes(wanted)) {
|
|
1705
|
+
const fallback = catalog.settings?.default_intensity;
|
|
1706
|
+
wanted = levels.includes(fallback) ? fallback : levels.includes("High") ? "High" : levels[0] ?? "";
|
|
1707
|
+
}
|
|
1708
|
+
fillSelect(el.newChatIntensity, levels.map((value) => ({ value, label: value })), wanted);
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1619
1711
|
function updateNewChatNote(catalog) {
|
|
1620
1712
|
const provider = catalog?.providers.find((entry) => entry.name === el.newChatProvider.value);
|
|
1621
1713
|
const model = provider?.models.find((entry) => entry.name === el.newChatModel.value);
|
|
@@ -1626,6 +1718,15 @@ function updateNewChatNote(catalog) {
|
|
|
1626
1718
|
}
|
|
1627
1719
|
}
|
|
1628
1720
|
|
|
1721
|
+
function renderProNewChatPresets(catalog, selected = catalog.pro?.preset) {
|
|
1722
|
+
if (!PRO_MODE) return;
|
|
1723
|
+
const select = document.getElementById("new-chat-pro-preset");
|
|
1724
|
+
if (!select) return;
|
|
1725
|
+
fillSelect(select, (catalog.pro?.presets || []).map(preset => ({
|
|
1726
|
+
value: preset.name, label: uiLanguage() === "zh" ? preset.chinese_name || preset.name : preset.name,
|
|
1727
|
+
})), selected);
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1629
1730
|
/** Relabel the open new-conversation sheet without losing its selections. */
|
|
1630
1731
|
function refreshNewChatTexts() {
|
|
1631
1732
|
if (!el.newChatDialog?.open) return;
|
|
@@ -1637,9 +1738,10 @@ function refreshNewChatTexts() {
|
|
|
1637
1738
|
const approval = el.newChatApproval.value;
|
|
1638
1739
|
fillSelect(el.newChatProvider, catalog.providers.map((entry) => ({ value: entry.name, label: entry.name })), provider);
|
|
1639
1740
|
fillNewChatModels(catalog, model);
|
|
1640
|
-
|
|
1741
|
+
fillNewChatIntensities(catalog, intensity);
|
|
1641
1742
|
el.newChatApproval.value = approval;
|
|
1642
1743
|
updateNewChatNote(catalog);
|
|
1744
|
+
renderProNewChatPresets(catalog, document.getElementById("new-chat-pro-preset")?.value);
|
|
1643
1745
|
renderNewChatRecents();
|
|
1644
1746
|
}
|
|
1645
1747
|
|
|
@@ -1659,13 +1761,14 @@ async function openNewChatDialog(options = {}) {
|
|
|
1659
1761
|
const defaults = catalog.selection;
|
|
1660
1762
|
fillSelect(el.newChatProvider, catalog.providers.map((provider) => ({ value: provider.name, label: provider.name })), defaults.provider);
|
|
1661
1763
|
fillNewChatModels(catalog, defaults.model);
|
|
1662
|
-
|
|
1764
|
+
fillNewChatIntensities(catalog, defaults.intensity);
|
|
1663
1765
|
el.newChatApproval.value = catalog.settings.approval_mode === "auto" ? "auto" : "request";
|
|
1664
1766
|
|
|
1665
1767
|
const start = String(options.cwd || selectionCwd() || state.workspace?.path || state.workspaces?.cwd || "").trim();
|
|
1666
1768
|
el.newChatCwd.value = start;
|
|
1667
1769
|
renderNewChatRecents();
|
|
1668
1770
|
updateNewChatNote(catalog);
|
|
1771
|
+
renderProNewChatPresets(catalog);
|
|
1669
1772
|
if (typeof el.newChatCwd.focus === "function") el.newChatCwd.focus();
|
|
1670
1773
|
}
|
|
1671
1774
|
|
|
@@ -1686,7 +1789,8 @@ async function submitNewChat() {
|
|
|
1686
1789
|
el.newChatStart.textContent = tr("Creating…");
|
|
1687
1790
|
el.newChatFeedback.textContent = "";
|
|
1688
1791
|
try {
|
|
1689
|
-
const created = await createConversation(cwd, selection, el.newChatApproval.value
|
|
1792
|
+
const created = await createConversation(cwd, selection, el.newChatApproval.value,
|
|
1793
|
+
PRO_MODE ? document.getElementById("new-chat-pro-preset")?.value : undefined);
|
|
1690
1794
|
el.newChatDialog.close();
|
|
1691
1795
|
await selectLive(created.key);
|
|
1692
1796
|
setConversationView(true);
|
|
@@ -1700,9 +1804,10 @@ async function submitNewChat() {
|
|
|
1700
1804
|
}
|
|
1701
1805
|
|
|
1702
1806
|
/** Create a browser-owned conversation in `cwd` and reveal its folder. */
|
|
1703
|
-
async function createConversation(cwd, selection, approvalMode) {
|
|
1807
|
+
async function createConversation(cwd, selection, approvalMode, proPreset = currentProPreset()) {
|
|
1704
1808
|
const payload = { cwd };
|
|
1705
|
-
if (
|
|
1809
|
+
if (PRO_MODE) payload.pro_preset = proPreset;
|
|
1810
|
+
else if (selection && selection.provider && selection.model) payload.selection = selection;
|
|
1706
1811
|
if (approvalMode === "request" || approvalMode === "auto") payload.approval_mode = approvalMode;
|
|
1707
1812
|
const response = await apiFetch("/api/conversations", {
|
|
1708
1813
|
method: "POST",
|
|
@@ -2177,11 +2282,12 @@ function syncLanguageSelects(language) {
|
|
|
2177
2282
|
async function changeInterfaceLanguage(language, { persist = true } = {}) {
|
|
2178
2283
|
const normalized = String(language ?? "").toLowerCase().startsWith("zh") ? "zh" : "en";
|
|
2179
2284
|
setUiLanguage(normalized);
|
|
2180
|
-
writeStorage("neutrino.language", normalized);
|
|
2285
|
+
writeStorage(modeStorageKey("neutrino.language"), normalized);
|
|
2181
2286
|
applyStaticTranslations();
|
|
2182
2287
|
syncLanguageSelects(normalized);
|
|
2183
2288
|
if (state.status) setStatus(state.status.key, state.status.kind);
|
|
2184
2289
|
preferences.refreshTexts?.();
|
|
2290
|
+
proUI?.refresh();
|
|
2185
2291
|
refreshNewChatTexts();
|
|
2186
2292
|
renderWorkspaceChrome();
|
|
2187
2293
|
renderSidebar();
|
|
@@ -2209,13 +2315,192 @@ async function changeInterfaceLanguage(language, { persist = true } = {}) {
|
|
|
2209
2315
|
}
|
|
2210
2316
|
|
|
2211
2317
|
function initializeLanguage() {
|
|
2212
|
-
const stored = readStorage("neutrino.language");
|
|
2318
|
+
const stored = readStorage(modeStorageKey("neutrino.language"));
|
|
2213
2319
|
if (stored) setUiLanguage(stored);
|
|
2214
2320
|
applyStaticTranslations();
|
|
2215
2321
|
syncLanguageSelects(uiLanguage());
|
|
2216
2322
|
if (state.status) setStatus(state.status.key, state.status.kind);
|
|
2217
2323
|
}
|
|
2218
2324
|
|
|
2325
|
+
// ------------------------------------------------------------------ pro mode
|
|
2326
|
+
//
|
|
2327
|
+
// Both workspaces reuse this console. Clicking the orbit badge on the normal
|
|
2328
|
+
// console starts a short wordmark curtain; the recorded start time lets /pro
|
|
2329
|
+
// finish the same animation instead of flashing the freshly loaded page.
|
|
2330
|
+
|
|
2331
|
+
const PRO_TRANSITION_KEY = "neutrino.pro.transition";
|
|
2332
|
+
const PRO_TIP_KEY = "neutrino.pro.tip.dismissed";
|
|
2333
|
+
const PRO_TRANSITION_FADE_MS = 350; // curtain fade in / fade out
|
|
2334
|
+
const PRO_TRANSITION_COVER_MS = 450; // navigate once the curtain is fully opaque
|
|
2335
|
+
const PRO_TRANSITION_HOLD_MS = 3000; // wordmark stays up until 3s after the switch began
|
|
2336
|
+
const PRO_TRANSITION_RESUME_MS = 8000; // older records are stale and never replayed
|
|
2337
|
+
const PRO_LOGO_MARK = '<img class="pro-logo-mark" src="/neutrino.png" alt="Neutrino Pro">';
|
|
2338
|
+
|
|
2339
|
+
let proTransitionStarted = false;
|
|
2340
|
+
|
|
2341
|
+
function proCurtain() {
|
|
2342
|
+
const curtain = document.createElement("div");
|
|
2343
|
+
curtain.className = "pro-curtain";
|
|
2344
|
+
curtain.setAttribute("aria-hidden", "true");
|
|
2345
|
+
const image = document.createElement("img");
|
|
2346
|
+
image.src = "/neutrino-pro.gif";
|
|
2347
|
+
image.alt = "";
|
|
2348
|
+
curtain.appendChild(image);
|
|
2349
|
+
return curtain;
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
/** Timers exist in the browser; the test sandboxes load this file without them. */
|
|
2353
|
+
function proDelay(callback, ms) {
|
|
2354
|
+
if (typeof setTimeout === "function") setTimeout(callback, ms);
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
function startProTransition() {
|
|
2358
|
+
if (PRO_MODE || proTransitionStarted) return;
|
|
2359
|
+
proTransitionStarted = true;
|
|
2360
|
+
const started = Date.now();
|
|
2361
|
+
try {
|
|
2362
|
+
sessionStorage.setItem(PRO_TRANSITION_KEY, String(started));
|
|
2363
|
+
} catch { /* Storage may be blocked; navigation still works. */ }
|
|
2364
|
+
|
|
2365
|
+
const curtain = proCurtain();
|
|
2366
|
+
curtain.style.transition = `opacity ${PRO_TRANSITION_FADE_MS}ms ease`;
|
|
2367
|
+
document.body.appendChild(curtain);
|
|
2368
|
+
// Commit the transparent first frame, then let the style transition fade in.
|
|
2369
|
+
void curtain.offsetWidth;
|
|
2370
|
+
curtain.classList.add("pro-curtain-visible");
|
|
2371
|
+
|
|
2372
|
+
proDelay(() => {
|
|
2373
|
+
try { location.assign("/pro"); } catch { location.href = "/pro"; }
|
|
2374
|
+
}, PRO_TRANSITION_COVER_MS);
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
/** Remove every switch curtain left in this document. */
|
|
2378
|
+
function removeProCurtains() {
|
|
2379
|
+
if (typeof document.querySelectorAll !== "function") return;
|
|
2380
|
+
for (const curtain of document.querySelectorAll(".pro-curtain")) curtain.remove?.();
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
/**
|
|
2384
|
+
* Drop the one-shot switch chrome.
|
|
2385
|
+
*
|
|
2386
|
+
* The curtain lives in the document that navigated away, so the browser's
|
|
2387
|
+
* back/forward cache can restore it fully opaque — a Back or Forward would
|
|
2388
|
+
* otherwise land on that frozen frame. `clearRecord` stays false while this
|
|
2389
|
+
* document is being hidden for /pro: the destination still needs the recorded
|
|
2390
|
+
* start time to finish the same animation.
|
|
2391
|
+
*/
|
|
2392
|
+
function resetProTransition({ clearRecord = true } = {}) {
|
|
2393
|
+
removeProCurtains();
|
|
2394
|
+
document.documentElement?.classList?.remove?.("pro-transitioning");
|
|
2395
|
+
proTransitionStarted = false;
|
|
2396
|
+
if (!clearRecord) return;
|
|
2397
|
+
try { sessionStorage.removeItem(PRO_TRANSITION_KEY); } catch { /* Storage may be blocked. */ }
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
/** Undo the switch chrome when the browser restores either page from bfcache. */
|
|
2401
|
+
function watchProNavigation() {
|
|
2402
|
+
if (typeof window === "undefined" || typeof window.addEventListener !== "function") return;
|
|
2403
|
+
window.addEventListener("pagehide", (event) => {
|
|
2404
|
+
if (!event?.persisted) return;
|
|
2405
|
+
// The page is entering the back/forward cache: clean the snapshot, but
|
|
2406
|
+
// keep the record for the /pro page that is currently loading.
|
|
2407
|
+
resetProTransition({ clearRecord: false });
|
|
2408
|
+
});
|
|
2409
|
+
window.addEventListener("pageshow", (event) => {
|
|
2410
|
+
if (!event?.persisted) return;
|
|
2411
|
+
// Restored by Back/Forward: any curtain captured in the snapshot is stale.
|
|
2412
|
+
resetProTransition();
|
|
2413
|
+
});
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
/** Consume a just-recorded switch and finish the animation on /pro. */
|
|
2417
|
+
function resumeProTransition() {
|
|
2418
|
+
if (!PRO_MODE) return;
|
|
2419
|
+
let started = 0;
|
|
2420
|
+
try { started = Number(sessionStorage.getItem(PRO_TRANSITION_KEY)) || 0; } catch { /* Storage may be blocked. */ }
|
|
2421
|
+
// Consume the record even when stale: direct visits never show a curtain.
|
|
2422
|
+
if (started) {
|
|
2423
|
+
try { sessionStorage.removeItem(PRO_TRANSITION_KEY); } catch { /* Ignore. */ }
|
|
2424
|
+
}
|
|
2425
|
+
const now = Date.now();
|
|
2426
|
+
const age = now - started;
|
|
2427
|
+
if (!started || age < 0 || age > PRO_TRANSITION_RESUME_MS) {
|
|
2428
|
+
document.documentElement.classList.remove("pro-transitioning");
|
|
2429
|
+
return;
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
const curtain = proCurtain();
|
|
2433
|
+
// Start fully opaque on the first frame so the new page never flashes through.
|
|
2434
|
+
curtain.style.transition = "none";
|
|
2435
|
+
curtain.style.opacity = "1";
|
|
2436
|
+
document.body.appendChild(curtain);
|
|
2437
|
+
// The real curtain is up: retire the pre-paint cover from index.html so it
|
|
2438
|
+
// cannot remain over the page after the fade completes.
|
|
2439
|
+
document.documentElement.classList.remove("pro-transitioning");
|
|
2440
|
+
|
|
2441
|
+
proDelay(() => {
|
|
2442
|
+
curtain.style.transition = `opacity ${PRO_TRANSITION_FADE_MS}ms ease`;
|
|
2443
|
+
curtain.style.opacity = "0";
|
|
2444
|
+
proDelay(() => curtain.remove(), PRO_TRANSITION_FADE_MS);
|
|
2445
|
+
}, Math.max(0, started + PRO_TRANSITION_HOLD_MS - now));
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
/** In Pro mode the badge is a static "you are here" mark, not a control. */
|
|
2449
|
+
function applyProBrand() {
|
|
2450
|
+
document.title = "Neutrino Pro";
|
|
2451
|
+
if (el.brandName) el.brandName.textContent = "Neutrino Pro";
|
|
2452
|
+
const logo = el.brandLogo;
|
|
2453
|
+
if (!logo) return;
|
|
2454
|
+
logo.innerHTML = PRO_LOGO_MARK;
|
|
2455
|
+
logo.classList.add("pro-logo");
|
|
2456
|
+
logo.tabIndex = -1;
|
|
2457
|
+
// Drop the i18n annotations too, or a language switch restores the tooltip.
|
|
2458
|
+
const annotations = ["title", "aria-label", "data-i18n-title", "data-i18n-aria-label"];
|
|
2459
|
+
for (const attribute of annotations) logo.removeAttribute?.(attribute);
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
/** The blue tip explains the badge on the normal console; Pro never shows it. */
|
|
2463
|
+
function initializeProTip() {
|
|
2464
|
+
if (PRO_MODE || !el.proTip) return;
|
|
2465
|
+
if (readStorage(PRO_TIP_KEY) === "1") return;
|
|
2466
|
+
el.proTip.classList.remove("hidden");
|
|
2467
|
+
el.proTipClose?.addEventListener("click", () => {
|
|
2468
|
+
writeStorage(PRO_TIP_KEY, "1");
|
|
2469
|
+
el.proTip.classList.add("hidden");
|
|
2470
|
+
});
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
/** The Pro workspace's top-right escape hatch back to the regular console. */
|
|
2474
|
+
function initializeProExit() {
|
|
2475
|
+
if (!el.proExit) return;
|
|
2476
|
+
el.proExit.classList.remove("hidden");
|
|
2477
|
+
el.proExit.addEventListener("click", () => {
|
|
2478
|
+
el.proExit.disabled = true;
|
|
2479
|
+
try {
|
|
2480
|
+
location.assign("/");
|
|
2481
|
+
} catch {
|
|
2482
|
+
location.href = "/";
|
|
2483
|
+
}
|
|
2484
|
+
});
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
/** Wire the shared brand and either start the switch or resume its animation. */
|
|
2488
|
+
function initializeProWorkspace() {
|
|
2489
|
+
watchProNavigation();
|
|
2490
|
+
if (PRO_MODE) {
|
|
2491
|
+
applyProBrand();
|
|
2492
|
+
initializeProExit();
|
|
2493
|
+
resumeProTransition();
|
|
2494
|
+
return;
|
|
2495
|
+
}
|
|
2496
|
+
// A switch that was interrupted (Stop, Back, a failed navigation) can leave
|
|
2497
|
+
// the record and the pre-paint cover behind. Neither may ever affect the
|
|
2498
|
+
// regular console, so clear them before wiring the badge.
|
|
2499
|
+
resetProTransition();
|
|
2500
|
+
el.brandLogo?.addEventListener("click", startProTransition);
|
|
2501
|
+
initializeProTip();
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2219
2504
|
// --------------------------------------------------------------------- init
|
|
2220
2505
|
|
|
2221
2506
|
el.refresh.addEventListener("click", () => loadOverview());
|
|
@@ -2274,12 +2559,16 @@ el.newChatProvider.addEventListener("change", () => {
|
|
|
2274
2559
|
const catalog = preferences.catalog?.();
|
|
2275
2560
|
if (catalog) {
|
|
2276
2561
|
fillNewChatModels(catalog, "");
|
|
2562
|
+
fillNewChatIntensities(catalog, el.newChatIntensity.value);
|
|
2277
2563
|
updateNewChatNote(catalog);
|
|
2278
2564
|
}
|
|
2279
2565
|
});
|
|
2280
2566
|
el.newChatModel.addEventListener("change", () => {
|
|
2281
2567
|
const catalog = preferences.catalog?.();
|
|
2282
|
-
if (catalog)
|
|
2568
|
+
if (catalog) {
|
|
2569
|
+
fillNewChatIntensities(catalog, el.newChatIntensity.value);
|
|
2570
|
+
updateNewChatNote(catalog);
|
|
2571
|
+
}
|
|
2283
2572
|
});
|
|
2284
2573
|
|
|
2285
2574
|
el.folderClose.addEventListener("click", () => el.folderDialog.close());
|
|
@@ -2318,6 +2607,7 @@ el.folderDialog.addEventListener("cancel", (event) => {
|
|
|
2318
2607
|
window.addEventListener("beforeunload", closeStream);
|
|
2319
2608
|
|
|
2320
2609
|
initializeLanguage();
|
|
2610
|
+
initializeProWorkspace();
|
|
2321
2611
|
preferences.load()
|
|
2322
2612
|
.then((config) => {
|
|
2323
2613
|
const language = config?.settings?.language;
|