@xingjisoft/neutrino 0.1.4 → 0.1.5

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/web/index.html ADDED
@@ -0,0 +1,87 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="color-scheme" content="dark" />
7
+ <title>Neutrino</title>
8
+ <link rel="stylesheet" href="style.css" />
9
+ </head>
10
+ <body>
11
+ <div id="app">
12
+ <aside id="sidebar">
13
+ <header class="brand">
14
+ <div class="logo" aria-hidden="true">◈</div>
15
+ <div class="brand-text">
16
+ <strong>Neutrino</strong>
17
+ <span id="status" class="status">connecting…</span>
18
+ </div>
19
+ <button id="refresh" class="icon-btn" title="Refresh conversations" aria-label="Refresh">⟳</button>
20
+ <button id="settings-open" class="icon-btn" title="Settings" aria-label="Settings">⚙</button>
21
+ </header>
22
+ <div id="sessions" class="sessions"></div>
23
+ </aside>
24
+
25
+ <main id="main">
26
+ <header id="main-header" class="main-header">
27
+ <div class="main-title">
28
+ <h1 id="session-title">Select a conversation</h1>
29
+ <div id="session-sub" class="sub"></div>
30
+ </div>
31
+ <div id="controls" class="controls"></div>
32
+ </header>
33
+
34
+ <form id="model-picker" class="model-picker hidden" aria-label="Conversation model">
35
+ <label>Provider<select id="model-provider"></select></label>
36
+ <label>Model<select id="model-name"></select></label>
37
+ <label>Thinking intensity<select id="model-intensity"></select></label>
38
+ <button id="model-apply" class="control" type="submit">Apply to conversation</button>
39
+ <p id="model-feedback" class="form-feedback" role="status" aria-live="polite"></p>
40
+ </form>
41
+
42
+ <section id="messages" class="messages">
43
+ <div class="empty">Choose a conversation from the sidebar to view it.</div>
44
+ </section>
45
+
46
+ <div id="approval" class="approval hidden" role="alert" aria-live="assertive"></div>
47
+ <footer id="composer" class="composer hidden">
48
+ <textarea id="input" rows="2" placeholder="Send a message… (Enter to send, Shift+Enter for newline)"></textarea>
49
+ <button id="send" class="primary">Send</button>
50
+ </footer>
51
+ </main>
52
+ </div>
53
+
54
+ <dialog id="settings-dialog" class="settings-dialog" aria-labelledby="settings-title">
55
+ <form id="settings-form">
56
+ <header class="settings-header">
57
+ <h2 id="settings-title">Settings</h2>
58
+ <button id="settings-close" class="icon-btn" type="button" aria-label="Close settings">×</button>
59
+ </header>
60
+ <div class="settings-fields">
61
+ <h3>Defaults for new conversations</h3>
62
+ <label>Provider<select id="default-provider"></select></label>
63
+ <label>Model<select id="default-model"></select></label>
64
+ <label>Thinking intensity<select id="default-intensity"></select></label>
65
+ <h3>Conversation and approvals</h3>
66
+ <label>Model context window (tokens)<input id="settings-context" type="number" min="1" max="9007199254740991" step="1" required></label>
67
+ <p id="settings-compress-note" class="settings-note">Auto-compress at 80% of this model's context window. Default: 1,000,000 tokens.</p>
68
+ <label>Approval reviewer model<select id="settings-reviewer"></select></label>
69
+ <label class="checkbox-label"><input id="settings-extreme" type="checkbox"> Extreme-risk auto-approval (del class, 3 separate review rounds)</label>
70
+ <label class="checkbox-label"><input id="settings-sound" type="checkbox"> Sound alerts (turn finished, approval waiting)</label>
71
+ <p class="settings-note">Safety determines automatic approval; task relevance only blocks unanimously confirmed, extreme mismatches. Delete-class automatic approval also requires this setting.</p>
72
+ <h3>Web access</h3>
73
+ <label>New Web password<input id="settings-password" type="password" autocomplete="new-password" maxlength="256" placeholder="Leave blank to keep current password"></label>
74
+ <label>Confirm password<input id="settings-password-confirm" type="password" autocomplete="new-password" maxlength="256" placeholder="Repeat new password"></label>
75
+ <p class="settings-note">The password is permanent and survives restarts. Default: 114514. Saving a new password signs out other browsers.</p>
76
+ <p id="settings-feedback" class="form-feedback" role="status" aria-live="polite"></p>
77
+ </div>
78
+ <footer class="settings-actions">
79
+ <button id="settings-cancel" class="control" type="button">Cancel</button>
80
+ <button id="settings-save" class="primary" type="submit">Save settings</button>
81
+ </footer>
82
+ </form>
83
+ </dialog>
84
+
85
+ <script src="app.js" type="module"></script>
86
+ </body>
87
+ </html>
@@ -0,0 +1,193 @@
1
+ // Settings and model controls share the same server-side catalog. No API keys
2
+ // are returned to this controller or kept in browser storage.
3
+ export function createSettingsUI({ apiFetch, applyModel }) {
4
+ const ids = ["model-picker", "model-provider", "model-name", "model-intensity", "model-apply", "model-feedback",
5
+ "settings-open", "settings-dialog", "settings-form", "settings-close", "settings-cancel", "settings-save", "settings-feedback",
6
+ "default-provider", "default-model", "default-intensity", "settings-context", "settings-compress-note", "settings-reviewer", "settings-extreme", "settings-sound", "settings-password", "settings-password-confirm"];
7
+ const el = Object.fromEntries(ids.map(id => [id, document.getElementById(id)]));
8
+ let config = null;
9
+ let target = null;
10
+ let conversation = null;
11
+ let dirty = false;
12
+ let applying = false;
13
+ let feedback = "";
14
+ let feedbackError = false;
15
+
16
+ async function json(path, body) {
17
+ const response = await apiFetch(path, body === undefined ? { cache: "no-store" } : {
18
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
19
+ });
20
+ const data = await response.json();
21
+ if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
22
+ return data;
23
+ }
24
+ function message(node, text, error = false) {
25
+ node.textContent = text;
26
+ node.classList.toggle("error", error);
27
+ }
28
+ function options(select, values, selected) {
29
+ select.replaceChildren();
30
+ for (const entry of values) {
31
+ const option = document.createElement("option");
32
+ option.value = entry.value;
33
+ option.textContent = entry.label;
34
+ select.appendChild(option);
35
+ }
36
+ select.value = values.some(entry => entry.value === selected) ? selected : values[0]?.value ?? "";
37
+ }
38
+ function providers(select, selected) {
39
+ options(select, config.providers.map(provider => ({ value: provider.name, label: provider.name })), selected);
40
+ }
41
+ function models(providerSelect, modelSelect, selected) {
42
+ const provider = config.providers.find(entry => entry.name === providerSelect.value);
43
+ options(modelSelect, (provider?.models ?? []).map(model => ({
44
+ value: model.name, label: model.name + (model.configured ? "" : " (API key missing)"),
45
+ })), selected);
46
+ }
47
+ function intensities(select, selected) {
48
+ options(select, config.intensities.map(value => ({ value, label: value })), selected);
49
+ }
50
+ function choice() {
51
+ return { provider: el["model-provider"].value, model: el["model-name"].value, intensity: el["model-intensity"].value };
52
+ }
53
+ function renderPicker() {
54
+ el["model-picker"].classList.toggle("hidden", !config || !conversation);
55
+ if (!config || !conversation) return;
56
+ if (!dirty) {
57
+ providers(el["model-provider"], conversation.provider);
58
+ models(el["model-provider"], el["model-name"], conversation.model);
59
+ intensities(el["model-intensity"], conversation.intensity);
60
+ }
61
+ const busy = Boolean(conversation.busy || conversation.approval);
62
+ el["model-apply"].disabled = applying || busy || !choice().provider || !choice().model;
63
+ el["model-apply"].textContent = applying ? "Applying…" : target?.startsWith("file:") ? "Continue with model" : "Apply to conversation";
64
+ message(el["model-feedback"], feedback || (busy ? "Pause the current turn to change models." : "Applies to the next turn."), feedbackError);
65
+ }
66
+ async function load() {
67
+ config = await json("/api/settings");
68
+ renderPicker();
69
+ return config;
70
+ }
71
+ function showConversation(info, key) {
72
+ if (target !== key) { dirty = false; feedback = ""; feedbackError = false; }
73
+ target = key;
74
+ conversation = info;
75
+ renderPicker();
76
+ }
77
+ function clear() {
78
+ target = null;
79
+ conversation = null;
80
+ dirty = false;
81
+ feedback = "";
82
+ renderPicker();
83
+ }
84
+ el["model-provider"].addEventListener("change", () => {
85
+ if (!config) return;
86
+ models(el["model-provider"], el["model-name"]);
87
+ dirty = true; feedback = ""; feedbackError = false;
88
+ renderPicker();
89
+ });
90
+ for (const id of ["model-name", "model-intensity"]) {
91
+ el[id].addEventListener("change", () => { dirty = true; feedback = ""; feedbackError = false; renderPicker(); });
92
+ }
93
+ el["model-picker"].addEventListener("submit", async event => {
94
+ event.preventDefault();
95
+ if (el["model-apply"].disabled) return;
96
+ const originalTarget = target;
97
+ applying = true;
98
+ feedback = "";
99
+ renderPicker();
100
+ try {
101
+ await applyModel(choice());
102
+ if (target === originalTarget) { dirty = false; feedback = "Model and intensity updated."; feedbackError = false; }
103
+ } catch (error) {
104
+ if (target === originalTarget) { feedback = error.message; feedbackError = true; }
105
+ } finally {
106
+ applying = false;
107
+ renderPicker();
108
+ }
109
+ });
110
+
111
+ function renderSettings() {
112
+ el["settings-password"].value = "";
113
+ el["settings-password-confirm"].value = "";
114
+ providers(el["default-provider"], config.selection.provider);
115
+ models(el["default-provider"], el["default-model"], config.selection.model);
116
+ intensities(el["default-intensity"], config.selection.intensity);
117
+ renderContextWindow();
118
+ const reviewers = [...new Set(config.providers.flatMap(provider => provider.models.map(model => model.name)))];
119
+ options(el["settings-reviewer"], [{ value: "", label: "Same as conversation" }, ...reviewers.map(value => ({ value, label: value }))], config.settings.approval_model);
120
+ el["settings-extreme"].checked = config.settings.auto_approve_extreme_risk;
121
+ el["settings-sound"].checked = config.settings.sound_alerts !== false;
122
+ }
123
+ function renderContextWindow() {
124
+ const provider = config.providers.find(entry => entry.name === el["default-provider"].value);
125
+ const model = provider?.models.find(entry => entry.name === el["default-model"].value);
126
+ el["settings-context"].disabled = !model;
127
+ el["settings-context"].value = String(model?.context_window ?? 1_000_000);
128
+ renderCompressionNote();
129
+ }
130
+ function renderCompressionNote() {
131
+ const tokens = Number(el["settings-context"].value);
132
+ el["settings-compress-note"].textContent = Number.isSafeInteger(tokens) && tokens > 0
133
+ ? `Auto-compress at 80%: ${Math.max(1, Math.floor(tokens * 0.8)).toLocaleString("en-US")} tokens. Applies to this model in all conversations.`
134
+ : "Enter a positive whole number of tokens. Default: 1,000,000.";
135
+ }
136
+ el["settings-context"].addEventListener("input", renderCompressionNote);
137
+ el["default-model"].addEventListener("change", () => { if (config) renderContextWindow(); });
138
+ el["settings-open"].addEventListener("click", async () => {
139
+ if (!el["settings-dialog"].open) el["settings-dialog"].showModal();
140
+ el["settings-save"].disabled = true;
141
+ message(el["settings-feedback"], "Loading…");
142
+ try {
143
+ await load();
144
+ renderSettings();
145
+ message(el["settings-feedback"], "Defaults apply to new conversations. Other settings apply to future turns.");
146
+ el["settings-save"].disabled = false;
147
+ } catch (error) { message(el["settings-feedback"], error.message, true); }
148
+ });
149
+ for (const id of ["settings-close", "settings-cancel"]) {
150
+ el[id].addEventListener("click", () => el["settings-dialog"].close());
151
+ }
152
+ el["default-provider"].addEventListener("change", () => {
153
+ if (config) {
154
+ models(el["default-provider"], el["default-model"]);
155
+ renderContextWindow();
156
+ }
157
+ });
158
+ el["settings-form"].addEventListener("submit", async event => {
159
+ event.preventDefault();
160
+ if (el["settings-save"].disabled) return;
161
+ const password = el["settings-password"].value;
162
+ if (password !== el["settings-password-confirm"].value) {
163
+ message(el["settings-feedback"], "Passwords do not match.", true);
164
+ return;
165
+ }
166
+ if (password && !password.trim()) {
167
+ message(el["settings-feedback"], "Password cannot be blank.", true);
168
+ return;
169
+ }
170
+ el["settings-save"].disabled = true;
171
+ const selection = { provider: el["default-provider"].value, model: el["default-model"].value, intensity: el["default-intensity"].value };
172
+ const settings = {
173
+ default_intensity: selection.intensity,
174
+ ...(!el["settings-context"].disabled ? { model_context_window: Number(el["settings-context"].value) } : {}),
175
+ approval_model: el["settings-reviewer"].value,
176
+ auto_approve_extreme_risk: el["settings-extreme"].checked,
177
+ sound_alerts: el["settings-sound"].checked,
178
+ };
179
+ if (password) settings.web_password = password;
180
+ try {
181
+ config = await json("/api/settings", { ...(selection.provider && selection.model ? { selection } : {}), settings });
182
+ renderPicker();
183
+ el["settings-password"].value = "";
184
+ el["settings-password-confirm"].value = "";
185
+ message(el["settings-feedback"], password ? "Settings saved. Web password updated permanently." : "Settings saved.");
186
+ } catch (error) { message(el["settings-feedback"], error.message, true); }
187
+ finally { el["settings-save"].disabled = false; }
188
+ });
189
+ // Read by the sound cues in app.js. Until the settings land, the feature is
190
+ // on, matching the server-side default.
191
+ const soundAlerts = () => config?.settings?.sound_alerts !== false;
192
+ return { load, showConversation, clear, soundAlerts, selectionForFile: id => target === `file:${id}` && dirty ? choice() : undefined };
193
+ }