@xingjisoft/neutrino 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -397
- package/assets/neutrino.ico +0 -0
- package/assets/neutrino.svg +7 -0
- package/dist/agents.js +22 -3
- package/dist/app.js +218 -26
- package/dist/background.js +358 -0
- package/dist/bin.js +200 -42
- package/dist/config.js +7 -3
- package/dist/header.js +2 -1
- package/dist/llm/tools.js +3 -1
- package/dist/migration.js +33 -0
- package/dist/pro/config.js +135 -9
- package/dist/screens/agentConversation.js +48 -5
- package/dist/screens/agentHistory.js +4 -2
- package/dist/screens/settings.js +43 -2
- package/dist/screens/start.js +26 -14
- package/dist/screens/updatePrompt.js +200 -0
- package/dist/state.js +1 -0
- package/dist/terminal/restore.js +3 -12
- package/dist/tray.js +138 -0
- package/dist/ui.js +32 -16
- package/dist/update.js +213 -0
- package/dist/web/clientState.js +82 -0
- package/dist/web/hub.js +4 -0
- package/dist/web/server.js +330 -37
- package/dist/web/service.js +160 -0
- package/dist/web/session.js +24 -6
- package/dist/web/settings.js +15 -0
- package/dist/web/terminalBridge.js +250 -0
- package/package.json +2 -2
- package/web/README.md +102 -23
- package/web/app.js +558 -22
- package/web/i18n.js +34 -0
- package/web/index.html +71 -9
- package/web/settings.js +223 -46
- package/web/style.css +145 -78
- package/assets/pro/model-template.json +0 -376
package/web/i18n.js
CHANGED
|
@@ -106,6 +106,15 @@ const ZH = {
|
|
|
106
106
|
"Professional model availability": "专业模型可用性检查",
|
|
107
107
|
"These model substitutions apply only to this task in this conversation. Your preset configuration is unchanged.": "以下模型替换仅对当前对话的本次任务生效,不会修改预设配置文件。",
|
|
108
108
|
"Got it": "知道了",
|
|
109
|
+
"A new version is available": "有新版本可用",
|
|
110
|
+
"Neutrino updates itself with npm. Restart it afterwards to use the new version.": "Neutrino 通过 npm 自动更新,更新完成后请重新启动以使用新版本。",
|
|
111
|
+
"Running {current} · published {latest}": "当前版本 {current} · 最新版本 {latest}",
|
|
112
|
+
"Update now": "立即更新",
|
|
113
|
+
"Later": "稍后",
|
|
114
|
+
"Updating with npm…": "正在通过 npm 更新…",
|
|
115
|
+
"Updated to {version}. Restart Neutrino to use it.": "已更新到 {version},请重新启动 Neutrino 以使用新版本。",
|
|
116
|
+
"Update failed: {error}": "更新失败:{error}",
|
|
117
|
+
"Update by hand with:": "也可以手动更新:",
|
|
109
118
|
// -- chrome -------------------------------------------------------------
|
|
110
119
|
"Neutrino": "Neutrino",
|
|
111
120
|
"Switch to Pro mode": "切换至Pro模式",
|
|
@@ -132,6 +141,12 @@ const ZH = {
|
|
|
132
141
|
"Working directory": "工作目录",
|
|
133
142
|
"Working directories": "工作目录",
|
|
134
143
|
"Other agents": "其他 Agent",
|
|
144
|
+
"Migrate session": "迁移会话",
|
|
145
|
+
"Migrating…": "迁移中…",
|
|
146
|
+
"Copy this history into a new Neutrino conversation in the same folder": "将历史复制为同一工作目录下的新 Neutrino 会话",
|
|
147
|
+
"Could not migrate this session: {error}": "无法迁移此会话:{error}",
|
|
148
|
+
"session not found in this working directory": "此工作目录中未找到该会话",
|
|
149
|
+
"this session has no messages to migrate": "此会话没有可迁移的消息",
|
|
135
150
|
"Read-only sessions recorded by other coding agents in this working directory.": "其他编码 Agent 在此工作目录中记录的只读会话。",
|
|
136
151
|
"No saved sessions from other agents in this folder.": "此文件夹中暂无其他 Agent 保存的会话。",
|
|
137
152
|
"Claude Code": "Claude Code",
|
|
@@ -172,6 +187,15 @@ const ZH = {
|
|
|
172
187
|
"Creating…": "正在创建…",
|
|
173
188
|
"Provider": "服务商",
|
|
174
189
|
"Model": "模型",
|
|
190
|
+
"Conversation model": "对话模型",
|
|
191
|
+
"Conversation model: {selection}": "对话模型:{selection}",
|
|
192
|
+
"Choose a model": "选择模型",
|
|
193
|
+
"(unavailable)": "(不可用)",
|
|
194
|
+
"Low": "低",
|
|
195
|
+
"Medium": "中",
|
|
196
|
+
"High": "高",
|
|
197
|
+
"Max": "极高",
|
|
198
|
+
"Ultra": "超高",
|
|
175
199
|
"Thinking": "思考强度",
|
|
176
200
|
"Default approval mode": "默认审批模式",
|
|
177
201
|
"Request Approval": "请求审批",
|
|
@@ -215,6 +239,8 @@ const ZH = {
|
|
|
215
239
|
"Show less": "收起",
|
|
216
240
|
"Show {n} more": "再显示 {n} 个",
|
|
217
241
|
"Live": "实时",
|
|
242
|
+
"Running": "运行中",
|
|
243
|
+
"Waiting for approval": "等待审批",
|
|
218
244
|
"Earlier conversation · {n} messages": "更早的对话 · {n} 条消息",
|
|
219
245
|
"Context summary · {n} messages": "上下文摘要 · {n} 条消息",
|
|
220
246
|
"You": "你",
|
|
@@ -298,6 +324,14 @@ const ZH = {
|
|
|
298
324
|
"Extreme-risk auto-approval (del class, 3 separate review rounds)": "极高风险自动审批(删除类命令,独立审查 3 轮)",
|
|
299
325
|
"Sound alerts (turn finished, approval waiting)": "声音提醒(回合结束、等待审批)",
|
|
300
326
|
"Start the Web console with the terminal": "随终端启动 Web 控制台(下次启动生效)",
|
|
327
|
+
"Background residency": "后台常驻",
|
|
328
|
+
"Run the Web console in the background and keep browser tasks alive after the CLI exits": "让 Web 控制台在后台运行,CLI 退出后浏览器中的任务继续执行",
|
|
329
|
+
"Off by default. Changes take effect immediately, including for running browser tasks. The regular and Pro workspaces share this setting. Terminal conversations still end when the terminal closes.": "默认关闭。切换立即生效,正在运行的浏览器任务也会受到保护;普通模式与 Pro 模式共用此设置。终端内的对话仍会在终端关闭时结束。",
|
|
330
|
+
"Enable background residency to avoid accidentally interrupting tasks": "建议开启后台常驻,避免误触中断任务",
|
|
331
|
+
"Enable": "开启",
|
|
332
|
+
"Background residency is on. Browser tasks keep running after the terminal closes.": "后台常驻已立即开启。关闭终端后,浏览器任务会继续执行。",
|
|
333
|
+
"Background residency is off. Current tasks can finish before the console closes.": "后台常驻已立即关闭。控制台退出前会等待当前任务完成。",
|
|
334
|
+
"Background residency could not be applied. Restart the console and try again.": "后台常驻未能生效,请重启控制台后重试。",
|
|
301
335
|
"Safety determines automatic approval; task relevance only blocks unanimously confirmed, extreme mismatches. Delete-class automatic approval also requires this setting.": "自动审批取决于安全性;任务相关性只有在两位审查者一致确认极端不匹配时才会阻止执行。删除类命令的自动审批还需要开启此设置。",
|
|
302
336
|
"Web access": "Web 访问",
|
|
303
337
|
"Allow access from other devices (LAN, 0.0.0.0)": "允许其他设备访问(局域网,0.0.0.0)",
|
package/web/index.html
CHANGED
|
@@ -102,14 +102,6 @@
|
|
|
102
102
|
</button>
|
|
103
103
|
</header>
|
|
104
104
|
|
|
105
|
-
<form id="model-picker" class="model-picker hidden" aria-label="Conversation model">
|
|
106
|
-
<label><span data-i18n="Provider">Provider</span><select id="model-provider"></select></label>
|
|
107
|
-
<label><span data-i18n="Model">Model</span><select id="model-name"></select></label>
|
|
108
|
-
<label><span data-i18n="Thinking">Thinking</span><select id="model-intensity"></select></label>
|
|
109
|
-
<button id="model-apply" class="control" type="submit" data-i18n="Apply">Apply</button>
|
|
110
|
-
<p id="model-feedback" class="form-feedback" role="status" aria-live="polite"></p>
|
|
111
|
-
</form>
|
|
112
|
-
|
|
113
105
|
<aside id="plan-card" class="plan-card hidden" aria-live="polite"></aside>
|
|
114
106
|
|
|
115
107
|
<section id="messages" class="messages">
|
|
@@ -128,10 +120,33 @@
|
|
|
128
120
|
|
|
129
121
|
<div id="approval" class="approval hidden" role="alert" aria-live="assertive"></div>
|
|
130
122
|
|
|
123
|
+
<footer id="agent-actions" class="agent-actions hidden">
|
|
124
|
+
<p id="agent-migrate-feedback" class="form-feedback error" role="status" aria-live="polite"></p>
|
|
125
|
+
<button id="agent-migrate" class="control primary" type="button" data-i18n="Migrate session" title="Copy this history into a new Neutrino conversation in the same folder" data-i18n-title="Copy this history into a new Neutrino conversation in the same folder">Migrate session</button>
|
|
126
|
+
</footer>
|
|
127
|
+
|
|
131
128
|
<footer id="composer" class="composer hidden">
|
|
132
129
|
<div class="composer-inner">
|
|
133
130
|
<div class="composer-card">
|
|
134
131
|
<textarea id="input" rows="1" placeholder="Message Neutrino…" aria-label="Message" data-i18n-placeholder="Message Neutrino…"></textarea>
|
|
132
|
+
<details id="model-picker" class="model-picker pro-preset-control hidden">
|
|
133
|
+
<summary id="model-picker-summary" aria-label="Conversation model" data-i18n-aria-label="Conversation model"><span id="model-picker-label"></span><svg class="pro-preset-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7 10 5 5 5-5" /></svg></summary>
|
|
134
|
+
<div class="pro-preset-card model-picker-card">
|
|
135
|
+
<div class="pro-preset-heading"><span data-i18n="Conversation model">Conversation model</span><strong id="model-intensity-label"></strong></div>
|
|
136
|
+
<div class="model-picker-fields">
|
|
137
|
+
<label><span data-i18n="Provider">Provider</span><select id="model-provider"></select></label>
|
|
138
|
+
<label><span data-i18n="Model">Model</span><select id="model-name"></select></label>
|
|
139
|
+
</div>
|
|
140
|
+
<label class="pro-slider-label" for="model-intensity" data-i18n="Thinking intensity">Thinking intensity</label>
|
|
141
|
+
<div id="model-intensity-slider" class="pro-preset-slider">
|
|
142
|
+
<div class="pro-slider-track" aria-hidden="true"><div class="pro-slider-fill"></div></div>
|
|
143
|
+
<div id="model-intensity-stops" class="pro-slider-stops" aria-hidden="true"></div>
|
|
144
|
+
<input id="model-intensity" type="range" min="0" max="0" step="1" value="0" aria-label="Thinking intensity" data-i18n-aria-label="Thinking intensity" />
|
|
145
|
+
</div>
|
|
146
|
+
<div id="model-intensity-ticks" class="pro-preset-ticks"></div>
|
|
147
|
+
<p id="model-feedback" class="form-feedback" role="status" aria-live="polite"></p>
|
|
148
|
+
</div>
|
|
149
|
+
</details>
|
|
135
150
|
<details id="pro-preset-control" class="pro-preset-control hidden">
|
|
136
151
|
<summary aria-label="Pro preset" data-i18n-aria-label="Pro preset"><span id="pro-preset-label">Pro</span><svg class="pro-preset-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7 10 5 5 5-5" /></svg></summary>
|
|
137
152
|
<div class="pro-preset-card">
|
|
@@ -157,7 +172,7 @@
|
|
|
157
172
|
</div>
|
|
158
173
|
</details>
|
|
159
174
|
<button id="send" class="primary" type="button" title="Send message" aria-label="Send message" data-i18n-title="Send message" data-i18n-aria-label="Send message">
|
|
160
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5M5 12l7-7 7 7" /></svg>
|
|
175
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path class="send-icon" d="M12 19V5M5 12l7-7 7 7" /><rect class="pause-icon" x="5" y="5" width="14" height="14" rx="2" fill="currentColor" stroke="none" /></svg>
|
|
161
176
|
</button>
|
|
162
177
|
</div>
|
|
163
178
|
<div class="composer-hint">
|
|
@@ -222,6 +237,9 @@
|
|
|
222
237
|
<label class="checkbox-label"><input id="settings-sound" type="checkbox" /> <span data-i18n="Sound alerts (turn finished, approval waiting)">Sound alerts (turn finished, approval waiting)</span></label>
|
|
223
238
|
<label class="checkbox-label"><input id="settings-web-console" type="checkbox" /> <span data-i18n="Start the Web console with the terminal">Start the Web console with the terminal</span></label>
|
|
224
239
|
<p class="settings-note" data-i18n="Safety determines automatic approval; task relevance only blocks unanimously confirmed, extreme mismatches. Delete-class automatic approval also requires this setting.">Safety determines automatic approval; task relevance only blocks unanimously confirmed, extreme mismatches. Delete-class automatic approval also requires this setting.</p>
|
|
240
|
+
<h3 data-i18n="Background residency">Background residency</h3>
|
|
241
|
+
<label class="checkbox-label"><input id="settings-background" type="checkbox" /> <span data-i18n="Run the Web console in the background and keep browser tasks alive after the CLI exits">Run the Web console in the background and keep browser tasks alive after the CLI exits</span></label>
|
|
242
|
+
<p class="settings-note" data-i18n="Off by default. Changes take effect immediately, including for running browser tasks. The regular and Pro workspaces share this setting. Terminal conversations still end when the terminal closes.">Off by default. Changes take effect immediately, including for running browser tasks. The regular and Pro workspaces share this setting. Terminal conversations still end when the terminal closes.</p>
|
|
225
243
|
<h3 data-i18n="Web access">Web access</h3>
|
|
226
244
|
<label class="checkbox-label"><input id="settings-web-lan" type="checkbox" /> <span data-i18n="Allow access from other devices (LAN, 0.0.0.0)">Allow access from other devices (LAN, 0.0.0.0)</span></label>
|
|
227
245
|
<p class="settings-note" data-i18n="Binds the Web console to 0.0.0.0 so other devices on your network can open it, like --lan. Takes effect immediately; turning it off returns the console to 127.0.0.1. Keep the Web password on: anyone who can reach this port can drive the agent.">Binds the Web console to 0.0.0.0 so other devices on your network can open it, like --lan. Takes effect immediately; turning it off returns the console to 127.0.0.1. Keep the Web password on: anyone who can reach this port can drive the agent.</p>
|
|
@@ -239,6 +257,27 @@
|
|
|
239
257
|
</form>
|
|
240
258
|
</dialog>
|
|
241
259
|
|
|
260
|
+
<dialog id="background-dialog" class="sheet-dialog background-dialog" aria-labelledby="background-title">
|
|
261
|
+
<div class="sheet background-sheet">
|
|
262
|
+
<header class="sheet-header background-header">
|
|
263
|
+
<span class="background-mark" aria-hidden="true">
|
|
264
|
+
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
265
|
+
<ellipse cx="12" cy="12" rx="9.4" ry="3.8" transform="rotate(-30 12 12)" stroke="currentColor" stroke-width="1.7" />
|
|
266
|
+
<circle cx="12" cy="12" r="3.1" stroke="currentColor" stroke-width="1.7" />
|
|
267
|
+
</svg>
|
|
268
|
+
</span>
|
|
269
|
+
<div class="background-copy">
|
|
270
|
+
<h2 id="background-title" data-i18n="Enable background residency to avoid accidentally interrupting tasks">Enable background residency to avoid accidentally interrupting tasks</h2>
|
|
271
|
+
</div>
|
|
272
|
+
</header>
|
|
273
|
+
<p id="background-feedback" class="form-feedback background-feedback" role="status" aria-live="polite"></p>
|
|
274
|
+
<footer class="sheet-actions background-actions">
|
|
275
|
+
<button id="background-cancel" class="control" type="button" data-i18n="Cancel">Cancel</button>
|
|
276
|
+
<button id="background-enable" class="primary" type="button" data-i18n="Enable" autofocus>Enable</button>
|
|
277
|
+
</footer>
|
|
278
|
+
</div>
|
|
279
|
+
</dialog>
|
|
280
|
+
|
|
242
281
|
<dialog id="new-chat-dialog" class="sheet-dialog" aria-labelledby="new-chat-title">
|
|
243
282
|
<form id="new-chat-form" class="sheet">
|
|
244
283
|
<header class="sheet-header">
|
|
@@ -375,6 +414,29 @@
|
|
|
375
414
|
</div>
|
|
376
415
|
</dialog>
|
|
377
416
|
|
|
417
|
+
<dialog id="update-dialog" class="sheet-dialog" aria-labelledby="update-title">
|
|
418
|
+
<div class="sheet">
|
|
419
|
+
<header class="sheet-header">
|
|
420
|
+
<div>
|
|
421
|
+
<h2 id="update-title" data-i18n="A new version is available">A new version is available</h2>
|
|
422
|
+
<p id="update-versions" class="sheet-sub"></p>
|
|
423
|
+
</div>
|
|
424
|
+
<button id="update-close" class="icon-btn" type="button" aria-label="Close" data-i18n-aria-label="Close">
|
|
425
|
+
<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="M18 6 6 18M6 6l12 12" /></svg>
|
|
426
|
+
</button>
|
|
427
|
+
</header>
|
|
428
|
+
<div class="sheet-body">
|
|
429
|
+
<p id="update-note" class="settings-note" data-i18n="Neutrino updates itself with npm. Restart it afterwards to use the new version.">Neutrino updates itself with npm. Restart it afterwards to use the new version.</p>
|
|
430
|
+
<p id="update-command-block" class="settings-note hidden"><span data-i18n="Update by hand with:">Update by hand with:</span> <code id="update-command">npm install -g @xingjisoft/neutrino</code></p>
|
|
431
|
+
<p id="update-feedback" class="form-feedback" role="status" aria-live="polite"></p>
|
|
432
|
+
</div>
|
|
433
|
+
<footer class="sheet-actions">
|
|
434
|
+
<button id="update-later" class="control" type="button" data-i18n="Later">Later</button>
|
|
435
|
+
<button id="update-now" class="primary" type="button" data-i18n="Update now" autofocus>Update now</button>
|
|
436
|
+
</footer>
|
|
437
|
+
</div>
|
|
438
|
+
</dialog>
|
|
439
|
+
|
|
378
440
|
<script src="app.js" type="module"></script>
|
|
379
441
|
</body>
|
|
380
442
|
</html>
|
package/web/settings.js
CHANGED
|
@@ -3,24 +3,32 @@
|
|
|
3
3
|
import { t, getLanguage } from "./i18n.js";
|
|
4
4
|
|
|
5
5
|
export function createSettingsUI({ apiFetch, applyModel, onLanguageChange, proMode = false, onCatalogChange }) {
|
|
6
|
-
const ids = ["model-picker", "model-
|
|
6
|
+
const ids = ["model-picker", "model-picker-summary", "model-picker-label", "model-provider", "model-name", "model-intensity",
|
|
7
|
+
"model-intensity-label", "model-intensity-slider", "model-intensity-stops", "model-intensity-ticks", "model-feedback",
|
|
7
8
|
"settings-open", "settings-dialog", "settings-form", "settings-close", "settings-cancel", "settings-save", "settings-feedback",
|
|
8
9
|
"default-provider", "default-model", "default-intensity", "settings-context", "settings-compress-note", "settings-reviewer",
|
|
9
10
|
"settings-extreme", "settings-sound", "settings-password", "settings-password-confirm",
|
|
10
|
-
"settings-language", "settings-plan", "settings-approval", "settings-web-console", "settings-web-lan",
|
|
11
|
+
"settings-language", "settings-plan", "settings-approval", "settings-web-console", "settings-web-lan", "settings-background",
|
|
11
12
|
"settings-pro", "settings-pro-preset", "settings-pro-depth"];
|
|
12
13
|
const el = Object.fromEntries(ids.map(id => [id, document.getElementById(id)]));
|
|
13
14
|
let config = null;
|
|
14
15
|
let target = null;
|
|
15
16
|
let conversation = null;
|
|
16
17
|
let dirty = false;
|
|
17
|
-
let
|
|
18
|
+
let draftIntensity = "";
|
|
19
|
+
let applying = null;
|
|
20
|
+
let generation = 0;
|
|
21
|
+
let failure = null;
|
|
18
22
|
let feedback = "";
|
|
19
23
|
let feedbackError = false;
|
|
20
24
|
// Value the LAN switch had when the dialog opened. Only an actual flip is
|
|
21
25
|
// saved, so editing another setting never pins an interface that came from
|
|
22
26
|
// --lan/--host on the command line.
|
|
23
27
|
let lanInitial = false;
|
|
28
|
+
// Same idea for background residency: the dialog must not overwrite a value
|
|
29
|
+
// changed from the terminal while it was open.
|
|
30
|
+
let backgroundInitial = false;
|
|
31
|
+
let backgroundChanging = false;
|
|
24
32
|
|
|
25
33
|
async function json(path, body) {
|
|
26
34
|
const response = await apiFetch(path, body === undefined ? { cache: "no-store" } : {
|
|
@@ -84,32 +92,90 @@ export function createSettingsUI({ apiFetch, applyModel, onLanguageChange, proMo
|
|
|
84
92
|
value: preset.name, label: getLanguage() === "zh" ? preset.chinese_name || preset.name : preset.name,
|
|
85
93
|
})), selected);
|
|
86
94
|
}
|
|
95
|
+
function unavailableOption(select, value) {
|
|
96
|
+
const option = document.createElement("option");
|
|
97
|
+
option.value = value;
|
|
98
|
+
option.textContent = `${value} ${t("(unavailable)")}`;
|
|
99
|
+
option.disabled = true;
|
|
100
|
+
select.appendChild(option);
|
|
101
|
+
select.value = value;
|
|
102
|
+
}
|
|
87
103
|
function choice() {
|
|
88
|
-
return { provider: el["model-provider"].value, model: el["model-name"].value, intensity:
|
|
104
|
+
return { provider: el["model-provider"].value, model: el["model-name"].value, intensity: draftIntensity };
|
|
89
105
|
}
|
|
90
106
|
function renderPicker() {
|
|
91
107
|
el["model-picker"].classList.toggle("hidden", proMode || !config || !conversation);
|
|
92
108
|
if (proMode || !config || !conversation) return;
|
|
93
109
|
if (!dirty) {
|
|
94
|
-
providers(el["model-provider"], conversation.provider);
|
|
95
|
-
|
|
96
|
-
|
|
110
|
+
providers(el["model-provider"], conversation.provider || config.selection?.provider);
|
|
111
|
+
if (conversation.provider && !config.providers.some(entry => entry.name === conversation.provider)) {
|
|
112
|
+
unavailableOption(el["model-provider"], conversation.provider);
|
|
113
|
+
}
|
|
114
|
+
models(el["model-provider"], el["model-name"], conversation.model || config.selection?.model);
|
|
115
|
+
if (conversation.model && !modelEntry(el["model-provider"].value, conversation.model)) {
|
|
116
|
+
unavailableOption(el["model-name"], conversation.model);
|
|
117
|
+
}
|
|
118
|
+
draftIntensity = pickIntensity(el["model-provider"].value, el["model-name"].value,
|
|
119
|
+
conversation.intensity || config.selection?.intensity);
|
|
97
120
|
} else {
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
intensities(el["model-intensity"], el["model-intensity"].value,
|
|
101
|
-
el["model-provider"].value, el["model-name"].value);
|
|
121
|
+
// Keep a draft across live snapshots, while honoring the chosen model's limits.
|
|
122
|
+
draftIntensity = pickIntensity(el["model-provider"].value, el["model-name"].value, draftIntensity);
|
|
102
123
|
}
|
|
124
|
+
const selection = choice();
|
|
125
|
+
const displayed = dirty || target?.startsWith("new:") ? selection : {
|
|
126
|
+
model: conversation.model || selection.model,
|
|
127
|
+
intensity: conversation.intensity || selection.intensity,
|
|
128
|
+
};
|
|
129
|
+
el["model-picker-label"].textContent = [displayed.model, displayed.intensity && t(displayed.intensity)]
|
|
130
|
+
.filter(Boolean).join(" · ") || t("Choose a model");
|
|
131
|
+
const summary = t("Conversation model: {selection}", { selection: el["model-picker-label"].textContent });
|
|
132
|
+
el["model-picker-summary"].setAttribute("aria-label", summary);
|
|
133
|
+
el["model-picker-summary"].title = summary;
|
|
134
|
+
el["model-intensity-label"].textContent = selection.intensity ? t(selection.intensity) : "";
|
|
135
|
+
|
|
136
|
+
const levels = modelEntry(selection.provider, selection.model) ? intensitiesFor(selection.provider, selection.model) : [];
|
|
137
|
+
const index = Math.max(0, levels.indexOf(selection.intensity));
|
|
138
|
+
const last = Math.max(0, levels.length - 1);
|
|
103
139
|
const busy = Boolean(conversation.busy || conversation.approval);
|
|
104
|
-
|
|
105
|
-
el["model-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
);
|
|
140
|
+
const locked = busy || Boolean(applying && applying.generation === generation);
|
|
141
|
+
const slider = el["model-intensity-slider"];
|
|
142
|
+
slider.setAttribute("style", `--pro-slider-position: ${last ? index / last : 0}`);
|
|
143
|
+
slider.classList.toggle("is-disabled", locked || !levels.length);
|
|
144
|
+
el["model-picker"].classList.toggle("is-max", last > 0 && index === last);
|
|
145
|
+
el["model-provider"].disabled = locked;
|
|
146
|
+
el["model-name"].disabled = locked || !selection.provider;
|
|
147
|
+
el["model-intensity"].disabled = locked || !levels.length;
|
|
148
|
+
el["model-intensity"].max = String(last);
|
|
149
|
+
el["model-intensity"].value = String(index);
|
|
150
|
+
el["model-intensity"].setAttribute("aria-valuetext", selection.intensity ? t(selection.intensity) : "");
|
|
151
|
+
|
|
152
|
+
const stops = el["model-intensity-stops"];
|
|
153
|
+
const ticks = el["model-intensity-ticks"];
|
|
154
|
+
stops.replaceChildren();
|
|
155
|
+
ticks.replaceChildren();
|
|
156
|
+
levels.forEach((level, levelIndex) => {
|
|
157
|
+
const position = last ? levelIndex / last * 100 : 0;
|
|
158
|
+
const stop = document.createElement("span");
|
|
159
|
+
stop.className = "pro-slider-stop";
|
|
160
|
+
stop.setAttribute("style", `left: ${position}%`);
|
|
161
|
+
stop.classList.toggle("is-filled", levelIndex <= index);
|
|
162
|
+
stops.appendChild(stop);
|
|
163
|
+
const tick = document.createElement("button");
|
|
164
|
+
tick.type = "button";
|
|
165
|
+
tick.textContent = t(level);
|
|
166
|
+
tick.disabled = locked;
|
|
167
|
+
tick.setAttribute("style", `left: ${position}%`);
|
|
168
|
+
tick.setAttribute("aria-pressed", String(level === selection.intensity));
|
|
169
|
+
tick.addEventListener("click", () => {
|
|
170
|
+
if (tick.disabled) return;
|
|
171
|
+
draftIntensity = level;
|
|
172
|
+
changeChoice();
|
|
173
|
+
});
|
|
174
|
+
ticks.appendChild(tick);
|
|
175
|
+
});
|
|
176
|
+
const status = busy ? t("Pause the current turn to change models.")
|
|
177
|
+
: feedback || (locked ? t("Applying…") : t("Applies to the next turn."));
|
|
178
|
+
message(el["model-feedback"], status, !busy && feedbackError);
|
|
113
179
|
}
|
|
114
180
|
async function load() {
|
|
115
181
|
config = await json("/api/settings");
|
|
@@ -118,44 +184,109 @@ export function createSettingsUI({ apiFetch, applyModel, onLanguageChange, proMo
|
|
|
118
184
|
return config;
|
|
119
185
|
}
|
|
120
186
|
function showConversation(info, key) {
|
|
121
|
-
if (target !== key) {
|
|
187
|
+
if (target !== key) {
|
|
188
|
+
generation++;
|
|
189
|
+
dirty = false;
|
|
190
|
+
failure = null;
|
|
191
|
+
feedback = "";
|
|
192
|
+
feedbackError = false;
|
|
193
|
+
el["model-picker"].open = false;
|
|
194
|
+
}
|
|
122
195
|
target = key;
|
|
123
196
|
conversation = info;
|
|
197
|
+
// A turn may start elsewhere while the slider is being dragged. Discard
|
|
198
|
+
// that uncommitted preview so it cannot masquerade as the active setting.
|
|
199
|
+
if (target?.startsWith("live:") && dirty && (info?.busy || info?.approval) && applying?.generation !== generation) {
|
|
200
|
+
dirty = false;
|
|
201
|
+
feedback = "";
|
|
202
|
+
feedbackError = false;
|
|
203
|
+
}
|
|
124
204
|
renderPicker();
|
|
125
205
|
}
|
|
126
206
|
function clear() {
|
|
207
|
+
generation++;
|
|
127
208
|
target = null;
|
|
128
209
|
conversation = null;
|
|
129
210
|
dirty = false;
|
|
211
|
+
failure = null;
|
|
212
|
+
feedback = "";
|
|
213
|
+
el["model-picker"].open = false;
|
|
214
|
+
renderPicker();
|
|
215
|
+
}
|
|
216
|
+
function changeChoice() {
|
|
217
|
+
if (!config || !conversation || conversation.busy || conversation.approval || applying?.generation === generation) return;
|
|
218
|
+
dirty = true;
|
|
219
|
+
failure = null;
|
|
130
220
|
feedback = "";
|
|
221
|
+
feedbackError = false;
|
|
222
|
+
renderPicker();
|
|
223
|
+
// Saved and not-yet-created conversations keep the choice as a local draft.
|
|
224
|
+
if (!target?.startsWith("live:")) return;
|
|
225
|
+
const selection = choice();
|
|
226
|
+
if (!selection.provider || !modelEntry(selection.provider, selection.model) || !selection.intensity) return;
|
|
227
|
+
if (selection.provider === conversation.provider && selection.model === conversation.model && selection.intensity === conversation.intensity) {
|
|
228
|
+
dirty = false;
|
|
229
|
+
renderPicker();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const request = { generation, promise: null };
|
|
233
|
+
applying = request;
|
|
131
234
|
renderPicker();
|
|
235
|
+
request.promise = (async () => {
|
|
236
|
+
try {
|
|
237
|
+
await applyModel(selection);
|
|
238
|
+
if (request.generation !== generation) return;
|
|
239
|
+
conversation = { ...conversation, ...selection };
|
|
240
|
+
dirty = false;
|
|
241
|
+
feedback = t("Model and intensity updated.");
|
|
242
|
+
} catch (error) {
|
|
243
|
+
if (request.generation !== generation) return;
|
|
244
|
+
dirty = false;
|
|
245
|
+
failure = error;
|
|
246
|
+
feedback = t(error.message);
|
|
247
|
+
feedbackError = true;
|
|
248
|
+
} finally {
|
|
249
|
+
if (applying === request) applying = null;
|
|
250
|
+
renderPicker();
|
|
251
|
+
}
|
|
252
|
+
})();
|
|
132
253
|
}
|
|
133
254
|
el["model-provider"].addEventListener("change", () => {
|
|
134
255
|
if (!config) return;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
256
|
+
const provider = config.providers.find(entry => entry.name === el["model-provider"].value);
|
|
257
|
+
models(el["model-provider"], el["model-name"], provider?.models.find(model => model.configured)?.name);
|
|
258
|
+
changeChoice();
|
|
138
259
|
});
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
applying = true;
|
|
260
|
+
el["model-name"].addEventListener("change", changeChoice);
|
|
261
|
+
el["model-intensity"].addEventListener("input", () => {
|
|
262
|
+
if (!config || !conversation || conversation.busy || conversation.approval || applying?.generation === generation) return;
|
|
263
|
+
const levels = intensitiesFor(el["model-provider"].value, el["model-name"].value);
|
|
264
|
+
draftIntensity = levels[Number(el["model-intensity"].value)] || draftIntensity;
|
|
265
|
+
dirty = true;
|
|
266
|
+
failure = null;
|
|
147
267
|
feedback = "";
|
|
268
|
+
feedbackError = false;
|
|
148
269
|
renderPicker();
|
|
149
|
-
try {
|
|
150
|
-
await applyModel(choice());
|
|
151
|
-
if (target === originalTarget) { dirty = false; feedback = t("Model and intensity updated."); feedbackError = false; }
|
|
152
|
-
} catch (error) {
|
|
153
|
-
if (target === originalTarget) { feedback = t(error.message); feedbackError = true; }
|
|
154
|
-
} finally {
|
|
155
|
-
applying = false;
|
|
156
|
-
renderPicker();
|
|
157
|
-
}
|
|
158
270
|
});
|
|
271
|
+
el["model-intensity"].addEventListener("change", changeChoice);
|
|
272
|
+
el["model-picker"].addEventListener("keydown", event => {
|
|
273
|
+
if (event.key === "Escape") { el["model-picker"].open = false; event.stopPropagation(); }
|
|
274
|
+
});
|
|
275
|
+
document.addEventListener?.("pointerdown", event => {
|
|
276
|
+
if (el["model-picker"].open && !el["model-picker"].contains(event.target)) el["model-picker"].open = false;
|
|
277
|
+
});
|
|
278
|
+
async function flush() {
|
|
279
|
+
// A keyboard send can happen before a range input emits its change event.
|
|
280
|
+
if (dirty && target?.startsWith("live:") && applying?.generation !== generation) changeChoice();
|
|
281
|
+
const request = applying;
|
|
282
|
+
const currentGeneration = generation;
|
|
283
|
+
if (request?.generation === currentGeneration) await request.promise;
|
|
284
|
+
if (currentGeneration === generation && failure) {
|
|
285
|
+
const error = failure;
|
|
286
|
+
failure = null;
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
159
290
|
|
|
160
291
|
function renderSettings() {
|
|
161
292
|
el["settings-password"].value = "";
|
|
@@ -178,8 +309,45 @@ export function createSettingsUI({ apiFetch, applyModel, onLanguageChange, proMo
|
|
|
178
309
|
el["settings-sound"].checked = config.settings.sound_alerts !== false;
|
|
179
310
|
el["settings-web-console"].checked = config.settings.web_console !== false;
|
|
180
311
|
el["settings-web-lan"].checked = config.settings.web_lan === true;
|
|
312
|
+
el["settings-background"].checked = config.settings.background_running === true;
|
|
313
|
+
backgroundInitial = el["settings-background"].checked;
|
|
181
314
|
lanInitial = el["settings-web-lan"].checked;
|
|
182
315
|
}
|
|
316
|
+
el["settings-background"].addEventListener("change", async () => {
|
|
317
|
+
if (!config || backgroundChanging) return;
|
|
318
|
+
const enabled = el["settings-background"].checked;
|
|
319
|
+
backgroundChanging = true;
|
|
320
|
+
el["settings-background"].disabled = true;
|
|
321
|
+
el["settings-save"].disabled = true;
|
|
322
|
+
message(el["settings-feedback"], t("Applying…"));
|
|
323
|
+
try {
|
|
324
|
+
const updated = await json("/api/settings", { settings: { background: enabled } });
|
|
325
|
+
if (updated.settings.background_running !== enabled) {
|
|
326
|
+
throw new Error(t("Background residency could not be applied. Restart the console and try again."));
|
|
327
|
+
}
|
|
328
|
+
config = updated;
|
|
329
|
+
backgroundInitial = config.settings.background_running === true;
|
|
330
|
+
el["settings-background"].checked = backgroundInitial;
|
|
331
|
+
onCatalogChange?.(config);
|
|
332
|
+
message(el["settings-feedback"], t(enabled
|
|
333
|
+
? "Background residency is on. Browser tasks keep running after the terminal closes."
|
|
334
|
+
: "Background residency is off. Current tasks can finish before the console closes."));
|
|
335
|
+
} catch (error) {
|
|
336
|
+
// A response can be lost after the service applied the change. Ask the
|
|
337
|
+
// service for its actual state before putting the switch back.
|
|
338
|
+
try {
|
|
339
|
+
config = await json("/api/settings");
|
|
340
|
+
backgroundInitial = config.settings.background_running === true;
|
|
341
|
+
} catch { /* Keep the last confirmed value and show the error. */ }
|
|
342
|
+
el["settings-background"].checked = backgroundInitial;
|
|
343
|
+
message(el["settings-feedback"], t(error.message), true);
|
|
344
|
+
} finally {
|
|
345
|
+
backgroundChanging = false;
|
|
346
|
+
el["settings-background"].disabled = false;
|
|
347
|
+
el["settings-save"].disabled = false;
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
183
351
|
function renderContextWindow() {
|
|
184
352
|
const provider = config.providers.find(entry => entry.name === el["default-provider"].value);
|
|
185
353
|
const model = provider?.models.find(entry => entry.name === el["default-model"].value);
|
|
@@ -200,8 +368,6 @@ export function createSettingsUI({ apiFetch, applyModel, onLanguageChange, proMo
|
|
|
200
368
|
if (!config) return;
|
|
201
369
|
providers(el["model-provider"], el["model-provider"].value);
|
|
202
370
|
models(el["model-provider"], el["model-name"], el["model-name"].value);
|
|
203
|
-
intensities(el["model-intensity"], el["model-intensity"].value,
|
|
204
|
-
el["model-provider"].value, el["model-name"].value);
|
|
205
371
|
if (el["settings-dialog"].open) {
|
|
206
372
|
providers(el["default-provider"], el["default-provider"].value);
|
|
207
373
|
models(el["default-provider"], el["default-model"], el["default-model"].value);
|
|
@@ -260,6 +426,7 @@ export function createSettingsUI({ apiFetch, applyModel, onLanguageChange, proMo
|
|
|
260
426
|
el["settings-save"].disabled = true;
|
|
261
427
|
const wasLan = config?.settings?.web_lan === true;
|
|
262
428
|
const lanChanged = el["settings-web-lan"].checked !== lanInitial;
|
|
429
|
+
const backgroundChanged = el["settings-background"].checked !== backgroundInitial;
|
|
263
430
|
const selection = { provider: el["default-provider"].value, model: el["default-model"].value, intensity: el["default-intensity"].value };
|
|
264
431
|
const settings = {
|
|
265
432
|
...(!proMode ? {
|
|
@@ -275,6 +442,7 @@ export function createSettingsUI({ apiFetch, applyModel, onLanguageChange, proMo
|
|
|
275
442
|
sound_alerts: el["settings-sound"].checked,
|
|
276
443
|
web_console: el["settings-web-console"].checked,
|
|
277
444
|
...(lanChanged ? { web_lan: el["settings-web-lan"].checked } : {}),
|
|
445
|
+
...(backgroundChanged ? { background: el["settings-background"].checked } : {}),
|
|
278
446
|
language: el["settings-language"].value,
|
|
279
447
|
approval_mode: el["settings-approval"].value,
|
|
280
448
|
};
|
|
@@ -289,16 +457,24 @@ export function createSettingsUI({ apiFetch, applyModel, onLanguageChange, proMo
|
|
|
289
457
|
// server bound and (when available) the address to open elsewhere.
|
|
290
458
|
el["settings-web-lan"].checked = config.settings.web_lan === true;
|
|
291
459
|
lanInitial = el["settings-web-lan"].checked;
|
|
460
|
+
el["settings-background"].checked = config.settings.background_running === true;
|
|
461
|
+
backgroundInitial = el["settings-background"].checked;
|
|
292
462
|
const urls = Array.isArray(config.settings.web_urls) ? config.settings.web_urls : [];
|
|
293
463
|
const lanNote = config.settings.web_lan === wasLan ? "" : config.settings.web_lan
|
|
294
464
|
? urls.length
|
|
295
465
|
? t("LAN access is on. Open {urls} from another device.", { urls: urls.join(" · ") })
|
|
296
466
|
: t("LAN access is on, but no network address was found. Only this machine can connect.")
|
|
297
467
|
: t("LAN access is off. Only this machine can connect.");
|
|
468
|
+
const backgroundNote = !backgroundChanged
|
|
469
|
+
? ""
|
|
470
|
+
: config.settings.background
|
|
471
|
+
? t("Background residency is on. Browser tasks keep running after the terminal closes.")
|
|
472
|
+
: t("Background residency is off. Current tasks can finish before the console closes.");
|
|
298
473
|
const savedText = password
|
|
299
474
|
? t("Settings saved. Web password updated permanently.")
|
|
300
475
|
: t("Settings saved.");
|
|
301
|
-
|
|
476
|
+
const notes = [lanNote, backgroundNote].filter(Boolean).join(" ");
|
|
477
|
+
message(el["settings-feedback"], notes ? `${savedText} ${notes}` : savedText);
|
|
302
478
|
} catch (error) { message(el["settings-feedback"], t(error.message), true); }
|
|
303
479
|
finally { el["settings-save"].disabled = false; }
|
|
304
480
|
});
|
|
@@ -311,12 +487,13 @@ export function createSettingsUI({ apiFetch, applyModel, onLanguageChange, proMo
|
|
|
311
487
|
clear,
|
|
312
488
|
soundAlerts,
|
|
313
489
|
refreshTexts,
|
|
490
|
+
flush,
|
|
314
491
|
catalog: () => config,
|
|
315
|
-
defaultSelection: () => (config ? {
|
|
492
|
+
defaultSelection: () => (config ? (target?.startsWith("new:") && dirty ? choice() : {
|
|
316
493
|
provider: config.selection.provider,
|
|
317
494
|
model: config.selection.model,
|
|
318
495
|
intensity: config.selection.intensity,
|
|
319
|
-
} : undefined),
|
|
496
|
+
}) : undefined),
|
|
320
497
|
selectionForFile: id => target === `file:${id}` && dirty ? choice() : undefined,
|
|
321
498
|
};
|
|
322
499
|
}
|