llm-api-gateway-cli 1.0.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.
Files changed (50) hide show
  1. package/.env.example +10 -0
  2. package/README.md +1127 -0
  3. package/cli-agent.js +666 -0
  4. package/cli-anthropic.js +236 -0
  5. package/cli-claude-code.js +317 -0
  6. package/cli-openai.js +212 -0
  7. package/completions/_llm-api-gateway-cli +65 -0
  8. package/completions/llm-api-gateway-cli.bash +64 -0
  9. package/completions/llm-api-gateway-cli.fish +43 -0
  10. package/images/chat.png +0 -0
  11. package/images/settings.png +0 -0
  12. package/images/task.png +0 -0
  13. package/lib/agent.js +607 -0
  14. package/lib/commands.js +468 -0
  15. package/lib/common.js +196 -0
  16. package/lib/config.js +70 -0
  17. package/lib/configcmd.js +230 -0
  18. package/lib/hub.js +1494 -0
  19. package/lib/jsonstore.js +49 -0
  20. package/lib/mcp.js +375 -0
  21. package/lib/memory.js +109 -0
  22. package/lib/plandoc.js +178 -0
  23. package/lib/pricing.js +52 -0
  24. package/lib/runner.js +234 -0
  25. package/lib/runstore.js +96 -0
  26. package/lib/secrets.js +198 -0
  27. package/lib/sessionstore.js +269 -0
  28. package/lib/settings.js +517 -0
  29. package/lib/tasksession.js +594 -0
  30. package/lib/taskstore.js +740 -0
  31. package/lib/tools.js +927 -0
  32. package/package.json +55 -0
  33. package/public/app.js +1055 -0
  34. package/public/index.html +167 -0
  35. package/public/manual.css +215 -0
  36. package/public/manual.html +381 -0
  37. package/public/manual.js +186 -0
  38. package/public/models.js +121 -0
  39. package/public/render.js +250 -0
  40. package/public/styles.css +955 -0
  41. package/public/task-slash.js +493 -0
  42. package/public/task.css +739 -0
  43. package/public/task.html +220 -0
  44. package/public/task.js +3127 -0
  45. package/public/theme.js +91 -0
  46. package/public/tint.js +261 -0
  47. package/scripts/install.ps1 +537 -0
  48. package/scripts/install.sh +510 -0
  49. package/server.js +14 -0
  50. package/task-server.js +15 -0
package/public/task.js ADDED
@@ -0,0 +1,3127 @@
1
+ 'use strict';
2
+
3
+ /* ---------- 常量与状态 ---------- */
4
+
5
+ // 任务数据存在磁盘上(服务端),浏览器里只留设置与「当前打开哪条」
6
+ const STORE_TASK_SETTINGS = 'lgw.task.settings.v1';
7
+
8
+ const DEFAULTS = { model: '', system: '', temperature: 0.3, maxTokens: 0, showReasoning: true };
9
+
10
+ // 三种审批模式:手动每次问、自动直接写、计划只读先出方案
11
+ const MODES = ['manual', 'auto', 'plan'];
12
+ const MODE_LABELS = { manual: '手动', auto: '自动', plan: '计划' };
13
+ const MODE_NOTES = {
14
+ manual: '每次写入都要你确认',
15
+ auto: '写入直接执行,界面仍逐条显示',
16
+ plan: '只读调研,计划写入 docs/ 后再执行',
17
+ };
18
+
19
+ function normalizeMode(m) {
20
+ return MODES.includes(m) ? m : 'manual';
21
+ }
22
+
23
+ const state = {
24
+ tasks: [], // 索引项 { id, title, workDir, mode, createdAt, updatedAt, renamed }
25
+ activeId: null, // 当前任务 id;null 表示草稿态(还没落盘)
26
+ draft: true,
27
+ draftWorkDir: null, // 草稿态下已选的工作目录(刷新后不丢)
28
+ draftMode: 'manual',
29
+ mode: 'manual', // 当前审批模式
30
+ defaultMode: 'manual',// 服务端默认模式(新任务用)
31
+ workDir: null, // 当前任务的工作目录(绝对路径),任务模式下必需
32
+ messages: [], // { role, content, reasoning, steps[], pending, streaming?, error?, el? }
33
+ settings: { ...DEFAULTS },
34
+ models: [], // 最近一次从 /api/models 拉到的可选模型(设置面板与 /model 指令都只认它)
35
+ settingsOnServer: false, // 设置是否已经住在磁盘上(连不上服务端时为 false,退回本地兜底)
36
+ settingsPersist: 'server', // ui.persist 的当前值:server / browser
37
+ settingsFile: null, // 服务端告诉我们配置文件在哪(设置面板里显示给用户看)
38
+ settingsMigrated: false, // 本次启动是否刚把浏览器里的配置搬到了磁盘
39
+ uiFromServer: null, // ui.persist=server 时服务端下发的界面状态,稍后覆盖本地那份
40
+ streaming: false,
41
+ runningId: null, // 正在跑的任务 id(侧边栏显示「进行中」)
42
+ controller: null,
43
+ streamError: null,
44
+ // 每个任务各自留一份,切走再切回来直接恢复,不用重新拉服务端(更不会打断正在跑的)
45
+ cache: new Map(), // taskId -> { messages, workDir, mode, usageTotal }
46
+ // taskId -> 上次成功保存时发出去的那份请求体。内容和它一模一样时就不必再存一次:
47
+ // 切任务/点标题只是换了个视图,不该被当成一次修改(否则时间戳会被刷新成「刚刚」)
48
+ savedBodies: new Map(),
49
+ runs: new Map(), // taskId -> AbortController(正在跑的任务;可以有多个)
50
+ usageTotal: { prompt: 0, completion: 0 },
51
+ config: null,
52
+ storeInfo: null, // 服务端返回的存储信息 { dir, taskCount, maxTasks, ttlDays, bytes, ... }
53
+ storeExposed: false, // 存储目录是否落在工作目录内(模型能看到任务历史)
54
+ prunedCount: 0, // 本次打开自动清理掉的任务数
55
+ migrated: 0, // 从浏览器存储搬到磁盘的任务数
56
+ loadError: null, // 任务列表都读不到(存储整个不可用)
57
+ taskLoadError: null, // 列表能读,但某条任务的正文读不出来
58
+ restoredPending: 0, // 刷新后恢复出来的待批准数量
59
+ collapsedDirs: [], // 侧边栏里被折叠的工作目录分组(按目录记,存在浏览器里)
60
+ collapsedTasks: [], // 侧边栏里被折叠的父任务(子任务收起来,按任务 id 记)
61
+ draftParentId: null, // 草稿要挂在哪条任务下(侧边栏任务行上的「+」派生出的子任务)
62
+ configRows: [], // 服务端白名单行(GET /api/settings 的 rows)——「其他配置」照它渲染,页面不抄第二份
63
+ configControls: {}, // 配置键 → 渲染出来的控件(回填与测试都要按写这个映射)
64
+ };
65
+
66
+ const dom = {
67
+ sidebar: document.getElementById('sidebar'),
68
+ taskList: document.getElementById('task-list'),
69
+ tasksCount: document.getElementById('tasks-count'),
70
+ tasksRetention: document.getElementById('tasks-retention'),
71
+ btnPrune: document.getElementById('btn-prune'),
72
+ btnToggleSidebar: document.getElementById('btn-toggle-sidebar'),
73
+ messages: document.getElementById('messages'),
74
+ empty: document.getElementById('empty-hint'),
75
+ emptyTitle: document.getElementById('empty-title'),
76
+ emptyDesc: document.getElementById('empty-desc'),
77
+ chipPick: document.getElementById('chip-pick'),
78
+ slashMenu: document.getElementById('slash-menu'),
79
+ input: document.getElementById('input'),
80
+ send: document.getElementById('btn-send'),
81
+ stop: document.getElementById('btn-stop'),
82
+ status: document.getElementById('status'),
83
+ modeSwitch: document.getElementById('mode-switch'),
84
+ modeNote: document.getElementById('mode-note'),
85
+ dot: document.getElementById('status-dot'),
86
+ workdirChip: document.getElementById('workdir-chip'),
87
+ workdirPath: document.getElementById('workdir-path'),
88
+ newTask: document.getElementById('btn-new-task'),
89
+ btnOpenChat: document.getElementById('btn-open-chat'),
90
+ settings: document.getElementById('settings'),
91
+ btnSettings: document.getElementById('btn-settings'),
92
+ btnSettingsClose: document.getElementById('btn-settings-close'),
93
+ setModel: document.getElementById('set-model'),
94
+ modelsHint: document.getElementById('models-hint'),
95
+ setSystem: document.getElementById('set-system'),
96
+ setTemperature: document.getElementById('set-temperature'),
97
+ setMaxTokens: document.getElementById('set-max-tokens'),
98
+ setShowReasoning: document.getElementById('set-show-reasoning'),
99
+ configExtraGroup: document.getElementById('config-extra-group'),
100
+ configExtra: document.getElementById('config-extra'),
101
+ btnRefreshModels: document.getElementById('btn-refresh-models'),
102
+ btnReset: document.getElementById('btn-reset'),
103
+ infoBase: document.getElementById('info-base'),
104
+ infoKey: document.getElementById('info-key'),
105
+ infoTools: document.getElementById('info-tools'),
106
+ infoUsage: document.getElementById('info-usage'),
107
+ infoLimit: document.getElementById('info-limit'),
108
+ infoTasks: document.getElementById('info-tasks'),
109
+ infoRetention: document.getElementById('info-retention'),
110
+ infoStore: document.getElementById('info-store'),
111
+ infoStoreSize: document.getElementById('info-store-size'),
112
+ storePath: document.getElementById('store-path'),
113
+ dirModal: document.getElementById('dir-modal'),
114
+ dirRoots: document.getElementById('dir-roots'),
115
+ dirList: document.getElementById('dir-list'),
116
+ dirUp: document.getElementById('dir-up'),
117
+ dirGo: document.getElementById('dir-go'),
118
+ dirPathInput: document.getElementById('dir-path-input'),
119
+ dirNote: document.getElementById('dir-note'),
120
+ dirCancel: document.getElementById('dir-cancel'),
121
+ dirConfirm: document.getElementById('dir-confirm'),
122
+ };
123
+
124
+ /* ---------- 通用小工具 ---------- */
125
+
126
+ function setStatus(text, kind = '') {
127
+ dom.status.textContent = text;
128
+ dom.status.className = `hint${kind ? ' ' + kind : ''}`;
129
+ }
130
+
131
+ /** 状态栏是否已经有「比泛泛提示更有价值」的内容(空着或只是「就绪」都算没有) */
132
+ function statusHasContent() {
133
+ const text = String(dom.status?.textContent || '').trim();
134
+ return Boolean(text) && text !== '就绪';
135
+ }
136
+
137
+ function setDot(kind) {
138
+ dom.dot.className = `dot${kind ? ' ' + kind : ''}`;
139
+ }
140
+
141
+ /**
142
+ * 信息行的值:面板窄,长值(网关地址、可用工具清单…)按 CSS 截成省略号,
143
+ * 所以顺手把完整值挂到 title 上 —— 悬停就能看全,不用去翻 config list。
144
+ */
145
+ function setInfo(el, text) {
146
+ if (!el) return;
147
+ el.textContent = text;
148
+ el.title = text;
149
+ }
150
+
151
+ function fmtBytes(n) {
152
+ if (!n) return '0 B';
153
+ if (n < 1024) return `${n} B`;
154
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
155
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
156
+ }
157
+
158
+ function scrollToBottom(force = false) {
159
+ const nearBottom = dom.messages.scrollHeight - dom.messages.scrollTop - dom.messages.clientHeight < 120;
160
+ if (force || nearBottom) dom.messages.scrollTop = dom.messages.scrollHeight;
161
+ }
162
+
163
+ function readJson(key, fallback) {
164
+ try {
165
+ const raw = localStorage.getItem(key);
166
+ if (!raw) return fallback;
167
+ const v = JSON.parse(raw);
168
+ return v === null || v === undefined ? fallback : v;
169
+ } catch {
170
+ return fallback;
171
+ }
172
+ }
173
+
174
+ function writeJson(key, value) {
175
+ try {
176
+ localStorage.setItem(key, JSON.stringify(value));
177
+ return true;
178
+ } catch (e) {
179
+ setStatus(`浏览器存储写入失败(可能已满):${e?.name || e}`, 'err');
180
+ return false;
181
+ }
182
+ }
183
+
184
+ function removeKey(key) {
185
+ try {
186
+ localStorage.removeItem(key);
187
+ } catch {
188
+ /* 忽略 */
189
+ }
190
+ }
191
+
192
+ /* ---------- 持久化:任务在磁盘,由服务端管 ---------- */
193
+
194
+ /**
195
+ * 任务数据现在存在磁盘上(服务端 <store>/tasks/<id>.json),不再塞 localStorage ——
196
+ * 一次任务可能带十几条工具输出,浏览器那 ~5MB 装不下,之前只能一路截断降级。
197
+ * 页面这边只留「哪个任务是当前打开的」这类 UI 状态,它本来就该是每个浏览器各自的。
198
+ */
199
+
200
+ const UI_STATE_KEY = 'lgw.task.ui.v1';
201
+ const LEGACY_INDEX = 'lgw.tasks.v2'; // 上一版存在浏览器的任务索引,仅用于一次性迁移
202
+ const LEGACY_TASK_ITEM = 'lgw.task.';
203
+ const LEGACY_SINGLE = 'lgw.task.v1';
204
+
205
+ function newId() {
206
+ if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
207
+ // 兜底也要凑成 UUID 形状:服务端按这个格式校验 id(防目录穿越)
208
+ const h = () => Math.floor(Math.random() * 65536).toString(16).padStart(4, '0');
209
+ return `${h()}${h()}-${h()}-4${h().slice(1)}-a${h().slice(1)}-${h()}${h()}${h()}`;
210
+ }
211
+
212
+ /** 统一的接口调用:非 2xx 时把服务端的错误文案抛出来,界面才能说清原因 */
213
+ async function api(path, options = {}) {
214
+ const resp = await fetch(path, {
215
+ headers: options.body ? { 'content-type': 'application/json' } : undefined,
216
+ ...options,
217
+ });
218
+ let data = null;
219
+ try {
220
+ data = await resp.json();
221
+ } catch {
222
+ /* 空响应或无 JSON */
223
+ }
224
+ if (!resp.ok) {
225
+ const err = new Error(data?.error || `HTTP ${resp.status}`);
226
+ err.status = resp.status;
227
+ throw err;
228
+ }
229
+ return data;
230
+ }
231
+
232
+ /**
233
+ * 设置面板的值 ↔ 服务端配置键。
234
+ * 注意 maxTokens:页面上 0 表示「不设」,磁盘上用 null 表示同一件事。
235
+ */
236
+ function settingsToPatch(s) {
237
+ const patch = {
238
+ system: s.system || '',
239
+ temperature: Number.isFinite(s.temperature) ? s.temperature : null,
240
+ maxTokens: Number.isFinite(s.maxTokens) && s.maxTokens > 0 ? Math.floor(s.maxTokens) : null,
241
+ 'ui.showReasoning': s.showReasoning !== false,
242
+ };
243
+ // 模型留空表示「用服务端默认」:这时不要写空串上去(服务端会拒,空模型没有意义)
244
+ if (s.model && String(s.model).trim()) patch.model = String(s.model).trim();
245
+ return patch;
246
+ }
247
+
248
+ /** 服务端的值 → 设置面板。缺的项保留页面默认,行为与搬到磁盘之前一致 */
249
+ function applyServerValues(values) {
250
+ if (!values || typeof values !== 'object') return;
251
+ if (values.model) state.settings.model = values.model;
252
+ if (typeof values.system === 'string' && values.system) state.settings.system = values.system;
253
+ if (Number.isFinite(values.temperature)) state.settings.temperature = values.temperature;
254
+ if (Number.isFinite(values.maxTokens) && values.maxTokens > 0) state.settings.maxTokens = values.maxTokens;
255
+ if (values.ui && typeof values.ui.showReasoning === 'boolean') state.settings.showReasoning = values.ui.showReasoning;
256
+ }
257
+
258
+ /** 服务端 ui 段 → 页面状态(只认页面自己会写的那几项) */
259
+ function uiStateToPatch(ui) {
260
+ return {
261
+ 'ui.activeId': typeof ui.activeId === 'string' && ui.activeId ? ui.activeId : null,
262
+ 'ui.collapsedDirs': Array.isArray(ui.collapsedDirs) ? ui.collapsedDirs.slice(0, 200) : [],
263
+ 'ui.collapsedTasks': Array.isArray(ui.collapsedTasks) ? ui.collapsedTasks.slice(0, 200) : [],
264
+ };
265
+ }
266
+
267
+ function uiStateFromServer(values) {
268
+ const ui = values?.ui || {};
269
+ return {
270
+ activeId: typeof ui.activeId === 'string' ? ui.activeId : null,
271
+ collapsedDirs: Array.isArray(ui.collapsedDirs) ? ui.collapsedDirs : null,
272
+ collapsedTasks: Array.isArray(ui.collapsedTasks) ? ui.collapsedTasks : null,
273
+ };
274
+ }
275
+
276
+ /** 搬家成功后,本地只保留「草稿态」这类临时信息(服务端已经拿走的部分就别留双份了) */
277
+ function stripUiStateLocal() {
278
+ const ui = readJson(UI_STATE_KEY, null);
279
+ const keep = {};
280
+ for (const k of ['draft', 'draftWorkDir', 'draftMode', 'draftParentId']) {
281
+ if (ui && ui[k] !== undefined) keep[k] = ui[k];
282
+ }
283
+ // 什么都没留下就整个删掉:留一个 {draft: undefined} 会被当成「不是草稿态」,
284
+ // 于是空态文案和新建流程都会走错分支
285
+ if (Object.keys(keep).length) writeJson(UI_STATE_KEY, keep);
286
+ else removeKey(UI_STATE_KEY);
287
+ }
288
+
289
+ /**
290
+ * 保存设置。
291
+ *
292
+ * 配置现在住在磁盘上(服务端 config.json):能连上服务端就不再往 localStorage 写,
293
+ * 否则会出现两个真相(清了缓存又发现设置「变回去了」)。连不上时留一份本地兜底,
294
+ * 不能因为服务没起就让用户白改一通。
295
+ */
296
+ /**
297
+ * 读本地那份设置镜像。
298
+ *
299
+ * 它现在是**迁移源**:服务端第一次见到这个页面时,把这份搬到磁盘上;
300
+ * 同时它也是服务端连不上时的兜底。真相在磁盘上(见 syncSettings)。
301
+ */
302
+ function loadSettings() {
303
+ const raw = readJson(STORE_TASK_SETTINGS, null);
304
+ if (raw && typeof raw === 'object') Object.assign(state.settings, raw);
305
+ }
306
+
307
+ function saveSettings() {
308
+ if (!state.settingsOnServer) writeJson(STORE_TASK_SETTINGS, state.settings);
309
+ pushSettings();
310
+ }
311
+
312
+ async function pushSettings() {
313
+ try {
314
+ await api('/api/settings', { method: 'PUT', body: JSON.stringify({ values: settingsToPatch(state.settings) }) });
315
+ state.settingsOnServer = true;
316
+ } catch (e) {
317
+ state.settingsOnServer = false;
318
+ writeJson(STORE_TASK_SETTINGS, state.settings);
319
+ setStatus(`设置没写进磁盘(先存在浏览器里):${e.message}`, 'err');
320
+ }
321
+ }
322
+
323
+ /**
324
+ * 启动时与服务端对齐配置。
325
+ *
326
+ * 服务端还空着(响应里 fresh)→ 把浏览器里这份「有数据就直接搬过去」,搬完就清本地 key;
327
+ * 服务端已经有一份 → 以服务端为准,**不覆盖**(多个浏览器共用一个磁盘配置,谁也别盖谁)。
328
+ */
329
+ async function syncSettings() {
330
+ let data;
331
+ try {
332
+ data = await api('/api/settings');
333
+ } catch {
334
+ // 服务端没起 / 这个地址被 403:沿用本地那份,不阻断整个页面
335
+ state.settingsOnServer = false;
336
+ return;
337
+ }
338
+ // 「其他配置」的服务端快照(渲染用;下面的搬家会把更新的一份盖上来)
339
+ let extraRows = data.rows;
340
+ let extraValues = data.values;
341
+ state.settingsOnServer = true;
342
+ state.settingsFile = data.file || null;
343
+ const persist = data.values?.ui?.persist === 'browser' ? 'browser' : 'server';
344
+ state.settingsPersist = persist;
345
+
346
+ if (data.fresh) {
347
+ const localUi = readJson(UI_STATE_KEY, null) || {};
348
+ const patch = { ...settingsToPatch(state.settings), ...uiStateToPatch(localUi) };
349
+ try {
350
+ const r = await api('/api/settings', { method: 'PUT', body: JSON.stringify({ values: patch }) });
351
+ removeKey(STORE_TASK_SETTINGS);
352
+ if (r?.values?.ui?.persist !== 'browser') {
353
+ // 服务端现在拿着「刚才那一份界面状态」,接下来 loadTasks 要以它为准 ——
354
+ // 否则本地刚被清掉,页面就忘了自己刚才打开的是哪条任务
355
+ state.uiFromServer = uiStateFromServer(r?.values);
356
+ stripUiStateLocal();
357
+ }
358
+ // 搬家这次响应里就带着白名单行与全套值,直接用它,省一次 GET
359
+ extraRows = r?.rows || extraRows;
360
+ extraValues = r?.values || extraValues;
361
+ state.settingsMigrated = true;
362
+ } catch {
363
+ /* 搬不动就继续用本地的,下次进页面再试 */
364
+ }
365
+ } else {
366
+ applyServerValues(data.values);
367
+ if (persist === 'server') state.uiFromServer = uiStateFromServer(data.values);
368
+ }
369
+
370
+ // 白名单里的「其他配置」:服务端给什么就渲染什么。连不上时 rows 为空 → 整组隐藏
371
+ renderExtraConfig(extraRows, extraValues);
372
+ }
373
+
374
+ /**
375
+ * 界面状态回写(只在不含草稿态的那部分)。
376
+ *
377
+ * 用微任务合并同一轮里的多次调用,而不是 setTimeout:界面状态的写入点很多
378
+ * (切任务、折叠、开草稿都会调),但每次写都很小;同一轮里合并成一次 PUT 就够,
379
+ * 也省得测试里还要假装推进时钟。
380
+ */
381
+ let uiPushQueued = false;
382
+ function queueUiPush() {
383
+ if (uiPushQueued || !state.settingsOnServer) return;
384
+ uiPushQueued = true;
385
+ Promise.resolve().then(async () => {
386
+ uiPushQueued = false;
387
+ try {
388
+ await api('/api/settings', {
389
+ method: 'PUT',
390
+ body: JSON.stringify({
391
+ values: uiStateToPatch({
392
+ activeId: state.activeId,
393
+ collapsedDirs: state.collapsedDirs,
394
+ collapsedTasks: state.collapsedTasks,
395
+ }),
396
+ }),
397
+ });
398
+ } catch {
399
+ /* 界面偏好丢一次无所谓,不值得为此打扰用户 */
400
+ }
401
+ });
402
+ }
403
+
404
+ /**
405
+ * 只存「当前打开哪条」这类界面状态,任务内容一律在服务端。
406
+ *
407
+ * `ui.persist` = `server`(默认)时写磁盘,清缓存/换浏览器都还在;
408
+ * 切成 `browser` 就退回「每个浏览器各自一份」的老语义。
409
+ * 草稿态(还没落盘的任务)无论如何都留在浏览器 —— 它是临时的,不该跨浏览器同步。
410
+ */
411
+ function saveUiState() {
412
+ const ui = {
413
+ activeId: state.activeId,
414
+ draft: state.draft,
415
+ draftWorkDir: state.draftWorkDir,
416
+ draftMode: state.draftMode,
417
+ draftParentId: state.draftParentId,
418
+ collapsedDirs: state.collapsedDirs,
419
+ collapsedTasks: state.collapsedTasks,
420
+ };
421
+ if (state.settingsPersist === 'browser' || !state.settingsOnServer) {
422
+ writeJson(UI_STATE_KEY, ui);
423
+ return;
424
+ }
425
+ writeJson(UI_STATE_KEY, {
426
+ draft: ui.draft,
427
+ draftWorkDir: ui.draftWorkDir,
428
+ draftMode: ui.draftMode,
429
+ draftParentId: ui.draftParentId,
430
+ });
431
+ queueUiPush();
432
+ }
433
+
434
+ function normalizeTask(t) {
435
+ if (!t || typeof t.id !== 'string' || !t.id) return null;
436
+ const created = Number(t.createdAt);
437
+ const updated = Number(t.updatedAt);
438
+ const base = Number.isFinite(created) ? created : Date.now();
439
+ return {
440
+ id: t.id,
441
+ title: typeof t.title === 'string' && t.title.trim() ? t.title.trim() : '未命名任务',
442
+ workDir: typeof t.workDir === 'string' && t.workDir ? t.workDir : null,
443
+ mode: normalizeMode(t.mode),
444
+ createdAt: base,
445
+ updatedAt: Number.isFinite(updated) ? updated : base,
446
+ renamed: Boolean(t.renamed),
447
+ // 服务端在保存时算好的回答轮数,侧边栏据此显示「已完成 / 未开始」
448
+ replies: Number.isFinite(Number(t.replies)) && Number(t.replies) > 0 ? Number(t.replies) : 0,
449
+ // 任务树:指向父任务(同一工作目录下的另一条),没有就是顶层
450
+ parentId: typeof t.parentId === 'string' && t.parentId && t.parentId !== t.id ? t.parentId : null,
451
+ };
452
+ }
453
+
454
+ /** 从首条用户消息生成标题(本地先算一次,省得等往返;服务端会算同样的结果) */
455
+ function titleFromMessages(messages) {
456
+ const first = messages.find((m) => m.role === 'user' && m.content);
457
+ if (!first) return '新任务';
458
+ const t = first.content.replace(/\s+/g, ' ').trim();
459
+ if (!t) return '新任务';
460
+ return t.length > 24 ? `${t.slice(0, 24)}…` : t;
461
+ }
462
+
463
+ /** 把服务端返回的消息换成界面用的形状 */
464
+ function adoptMessages(list) {
465
+ return (Array.isArray(list) ? list : [])
466
+ .filter((m) => m && (m.content || m.reasoning || m.steps?.length || m.notices?.length))
467
+ .map((m) => ({
468
+ role: m.role === 'assistant' ? 'assistant' : 'user',
469
+ content: String(m.content || ''),
470
+ reasoning: String(m.reasoning || ''),
471
+ steps: Array.isArray(m.steps) ? m.steps : [],
472
+ stepOpen: new Set(),
473
+ pending: m.pending && m.pending.runId ? m.pending : null,
474
+ // 撞上限的提示和「继续执行」按钮跟着一起恢复
475
+ notices: Array.isArray(m.notices) ? m.notices : [],
476
+ canResume: m.canResume === true,
477
+ }));
478
+ }
479
+
480
+ /** 落盘当前任务。草稿态(还没发第一条指令)不创建任务,只记下选好的目录 */
481
+ async function persistActive() {
482
+ if (!state.activeId) {
483
+ state.draftWorkDir = state.workDir;
484
+ saveUiState();
485
+ return null;
486
+ }
487
+ return persistTask(state.activeId, state.messages);
488
+ }
489
+
490
+ /**
491
+ * 组一次保存(PUT)的请求体,字符串形式。
492
+ * 同一个字符串还兼作「这次保存和上次是不是同一份」的指纹 —— 页面在切任务、切模式、
493
+ * 点目录时都会顺手保存一次,内容没变就完全没必要发这个请求(大任务的历史可能几十 MB)。
494
+ */
495
+ function taskPutBody(id, messages) {
496
+ const t = state.tasks.find((x) => x.id === id);
497
+ const active = id === state.activeId;
498
+ const cached = state.cache?.get(id);
499
+ return JSON.stringify({
500
+ workDir: active ? state.workDir : cached?.workDir,
501
+ mode: active ? state.mode : cached?.mode,
502
+ title: t?.title,
503
+ renamed: t?.renamed,
504
+ messages: (Array.isArray(messages) ? messages : []).map((m) => ({
505
+ role: m.role,
506
+ content: m.content || '',
507
+ reasoning: m.reasoning || '',
508
+ steps: m.steps || [],
509
+ pending: m.pending || null,
510
+ notices: m.notices || [],
511
+ canResume: m.canResume === true,
512
+ })),
513
+ });
514
+ }
515
+
516
+ /** 指纹表(测试里的 state 可能是手写的,缺了就现补一个) */
517
+ function savedBodies() {
518
+ if (!(state.savedBodies instanceof Map)) state.savedBodies = new Map();
519
+ return state.savedBodies;
520
+ }
521
+
522
+ /** 记下「服务端现在就是这份内容」:刚读回来的任务可以直接记,省掉切走时那一次原样重存 */
523
+ function markSaved(id, messages) {
524
+ if (id) savedBodies().set(id, taskPutBody(id, messages));
525
+ }
526
+
527
+ /** 把某一条任务的历史落盘。后台跑完的任务也用它,存的必须是它自己的历史 */
528
+ async function persistTask(id, messages) {
529
+ if (!id || !Array.isArray(messages)) return null;
530
+ const body = taskPutBody(id, messages);
531
+ // 和上次存过的一模一样:这不是一次修改(多半只是点了下别的任务),请求都不发
532
+ if (savedBodies().get(id) === body) return state.tasks.find((x) => x.id === id) || null;
533
+ const t = state.tasks.find((x) => x.id === id);
534
+ const active = id === state.activeId;
535
+ try {
536
+ const data = await api(`/api/tasks/${id}`, { method: 'PUT', body });
537
+ // 采用服务端算出来的标题/时间(自动命名规则只在服务端实现一份)
538
+ if (t && data?.task) {
539
+ t.title = data.task.title;
540
+ t.workDir = data.task.workDir;
541
+ t.mode = data.task.mode;
542
+ t.updatedAt = data.task.updatedAt;
543
+ // 回答轮数由服务端算,拿到后侧边栏就能从「未开始」翻成「已完成」
544
+ t.replies = Number(data.task.replies) || 0;
545
+ }
546
+ // 以服务端真正存下来的那份为准记指纹(标题可能被服务端自动改了名)
547
+ markSaved(id, Array.isArray(data?.task?.messages) ? data.task.messages : messages);
548
+ return data?.task || null;
549
+ } catch (e) {
550
+ setStatus(`保存任务失败:${e.message}`, 'err');
551
+ setDot('err');
552
+ return null;
553
+ }
554
+ }
555
+
556
+ /** 草稿态在首条指令发出时落成一条真实任务(避免留下一堆空任务) */
557
+ async function ensureTask() {
558
+ if (state.activeId && state.tasks.some((t) => t.id === state.activeId)) {
559
+ state.draft = false;
560
+ return state.activeId;
561
+ }
562
+ const id = newId();
563
+ const title = titleFromMessages(state.messages);
564
+ try {
565
+ const data = await api('/api/tasks', {
566
+ method: 'POST',
567
+ body: JSON.stringify({
568
+ id,
569
+ title,
570
+ workDir: state.workDir,
571
+ mode: state.mode,
572
+ messages: [],
573
+ // 派生出的子任务:服务端会校验父任务同目录且存在,不合法就落成顶层
574
+ parentId: state.draftParentId,
575
+ }),
576
+ });
577
+ const meta = normalizeTask(data.task);
578
+ state.tasks = [meta, ...state.tasks.filter((t) => t.id !== meta.id)];
579
+ state.activeId = meta.id;
580
+ state.draft = false;
581
+ state.draftWorkDir = null;
582
+ state.draftParentId = null;
583
+ saveUiState();
584
+ return meta.id;
585
+ } catch (e) {
586
+ setStatus(`新建任务失败:${e.message}`, 'err');
587
+ setDot('err');
588
+ return null;
589
+ }
590
+ }
591
+
592
+ /** 拉一条任务的完整内容 */
593
+ async function fetchTask(id) {
594
+ const data = await api(`/api/tasks/${id}`);
595
+ return data?.task || null;
596
+ }
597
+
598
+ /* ---------- 从浏览器存储迁移到磁盘 ---------- */
599
+
600
+ function readLegacyTasks() {
601
+ const out = [];
602
+ // 上一版:索引 + 每条任务一个 key
603
+ const idx = readJson(LEGACY_INDEX, null);
604
+ if (idx && Array.isArray(idx.tasks) && idx.tasks.length) {
605
+ for (const t of idx.tasks) {
606
+ if (!t?.id) continue;
607
+ const rec = readJson(LEGACY_TASK_ITEM + t.id, null);
608
+ out.push({
609
+ title: t.title,
610
+ workDir: t.workDir,
611
+ renamed: Boolean(t.renamed),
612
+ createdAt: Number(t.createdAt) || Date.now(),
613
+ updatedAt: Number(t.updatedAt) || Date.now(),
614
+ messages: Array.isArray(rec?.messages) ? rec.messages : [],
615
+ });
616
+ }
617
+ } else {
618
+ // 更早的一版:只有一条任务
619
+ const single = readJson(LEGACY_SINGLE, null);
620
+ if (single && Array.isArray(single.messages) && single.messages.length) {
621
+ out.push({
622
+ title: null,
623
+ workDir: single.workDir,
624
+ renamed: false,
625
+ createdAt: Date.now(),
626
+ updatedAt: Date.now(),
627
+ messages: single.messages,
628
+ });
629
+ }
630
+ }
631
+ return out;
632
+ }
633
+
634
+ function clearLegacyTasks() {
635
+ const idx = readJson(LEGACY_INDEX, null);
636
+ if (idx && Array.isArray(idx.tasks)) {
637
+ for (const t of idx.tasks) if (t?.id) removeKey(LEGACY_TASK_ITEM + t.id);
638
+ }
639
+ removeKey(LEGACY_INDEX);
640
+ removeKey(LEGACY_SINGLE);
641
+ }
642
+
643
+ /** 服务端一条任务都没有时,把浏览器里的旧数据搬上去(只做一次) */
644
+ async function migrateLegacyTasks() {
645
+ const legacy = readLegacyTasks();
646
+ if (!legacy.length) return 0;
647
+ let moved = 0;
648
+ for (const t of legacy) {
649
+ const id = newId();
650
+ try {
651
+ await api('/api/tasks', {
652
+ method: 'POST',
653
+ body: JSON.stringify({ id, title: t.title, workDir: t.workDir, messages: t.messages }),
654
+ });
655
+ moved++;
656
+ } catch {
657
+ /* 单条失败不阻断其余 */
658
+ }
659
+ }
660
+ if (moved) clearLegacyTasks();
661
+ return moved;
662
+ }
663
+
664
+ /* ---------- 载入 ---------- */
665
+
666
+ async function loadTasks() {
667
+ const ui = readJson(UI_STATE_KEY, null);
668
+ state.draft = ui ? Boolean(ui.draft) : true;
669
+ state.draftWorkDir = typeof ui?.draftWorkDir === 'string' ? ui.draftWorkDir : null;
670
+ state.draftMode = normalizeMode(ui?.draftMode);
671
+ state.draftParentId = typeof ui?.draftParentId === 'string' && ui.draftParentId ? ui.draftParentId : null;
672
+ // 折叠状态是纯界面偏好,存浏览器里;数量封顶,避免这份 JSON 无限长
673
+ state.collapsedDirs = Array.isArray(ui?.collapsedDirs)
674
+ ? ui.collapsedDirs.filter((d) => typeof d === 'string').slice(0, 200)
675
+ : [];
676
+ state.collapsedTasks = Array.isArray(ui?.collapsedTasks)
677
+ ? ui.collapsedTasks.filter((d) => typeof d === 'string').slice(0, 200)
678
+ : [];
679
+ state.activeId = typeof ui?.activeId === 'string' ? ui.activeId : null;
680
+
681
+ // 服务端也存了一份界面状态(ui.persist=server)时以它为准:换个浏览器也能接着上次看。
682
+ // 草稿态不在此列 —— 它属于「这个浏览器还没落盘的东西」。
683
+ if (state.uiFromServer) {
684
+ state.activeId = state.uiFromServer.activeId;
685
+ if (state.uiFromServer.collapsedDirs) {
686
+ state.collapsedDirs = state.uiFromServer.collapsedDirs.filter((d) => typeof d === 'string').slice(0, 200);
687
+ }
688
+ if (state.uiFromServer.collapsedTasks) {
689
+ state.collapsedTasks = state.uiFromServer.collapsedTasks.filter((d) => typeof d === 'string').slice(0, 200);
690
+ }
691
+ }
692
+
693
+ let list;
694
+ let info;
695
+ try {
696
+ const data = await api('/api/tasks');
697
+ list = Array.isArray(data?.tasks) ? data.tasks : [];
698
+ state.prunedCount = Array.isArray(data?.removed) ? data.removed.length : 0;
699
+ info = data;
700
+ } catch (e) {
701
+ state.tasks = [];
702
+ state.loadError = e.message;
703
+ setStatus(`无法读取任务记录:${e.message}`, 'err');
704
+ setDot('err');
705
+ return;
706
+ }
707
+ state.storeInfo = info || null;
708
+
709
+ // 服务端还空着,而浏览器里有旧数据 → 搬过去
710
+ if (!list.length && readLegacyTasks().length) {
711
+ const moved = await migrateLegacyTasks();
712
+ if (moved) {
713
+ state.migrated = moved;
714
+ try {
715
+ const again = await api('/api/tasks');
716
+ list = Array.isArray(again?.tasks) ? again.tasks : [];
717
+ state.storeInfo = again;
718
+ } catch {
719
+ /* 迁移后重读失败就先空着 */
720
+ }
721
+ }
722
+ }
723
+
724
+ state.tasks = list.map(normalizeTask).filter(Boolean);
725
+ // 折叠状态是纯界面偏好,但删掉的任务 id 会一直留在这份 JSON 里,越积越长。
726
+ // 列表已经拿全,顺手把指向不存在任务的 id 剔掉(顶层任务被删时子任务会升层,也一并处理)。
727
+ const liveIds = new Set(state.tasks.map((t) => t.id));
728
+ state.collapsedTasks = state.collapsedTasks.filter((id) => liveIds.has(id));
729
+
730
+ // 用户主动开了新任务(draft)就保持空白;否则恢复上次打开的那条
731
+ if (state.draft && !state.tasks.length) {
732
+ state.activeId = null;
733
+ state.workDir = state.draftWorkDir;
734
+ state.mode = state.draftMode;
735
+ state.messages = [];
736
+ return;
737
+ }
738
+ state.draft = false;
739
+ if (!state.activeId || !state.tasks.some((t) => t.id === state.activeId)) {
740
+ state.activeId = state.tasks[0]?.id ?? null;
741
+ }
742
+ if (!state.activeId) {
743
+ state.workDir = state.draftWorkDir;
744
+ state.mode = state.draftMode;
745
+ state.messages = [];
746
+ return;
747
+ }
748
+ // 先用列表里的元信息把界面撑起来 —— 列表只有元数据,读不到正文也能有目录和模式可用。
749
+ // (之前这两行放在 await 之后,正文一旦读失败,用户就会莫名其妙被要求重选工作目录。)
750
+ const meta = state.tasks.find((t) => t.id === state.activeId);
751
+ state.workDir = meta?.workDir || null;
752
+ state.mode = normalizeMode(meta?.mode || state.defaultMode);
753
+ state.messages = [];
754
+ try {
755
+ const task = await fetchTask(state.activeId);
756
+ if (task) {
757
+ state.workDir = task.workDir || state.workDir;
758
+ // 模式跟着任务走:切回某个任务时还是当初用的那个模式
759
+ state.mode = normalizeMode(task.mode || state.mode);
760
+ state.messages = adoptMessages(task.messages);
761
+ // 刚读回来的就是盘上那一份:记下指纹,切走时不必再原样存一遍
762
+ markSaved(state.activeId, state.messages);
763
+ }
764
+ } catch (e) {
765
+ if (e.status === 404) {
766
+ // 任务在别处被删了(或过期清理),退回收起草态而不是卡住
767
+ state.tasks = state.tasks.filter((t) => t.id !== state.activeId);
768
+ state.activeId = null;
769
+ state.messages = [];
770
+ } else {
771
+ // 记下来交给 init 的状态栏统一呈现:loadTasks 里设的状态会被后面覆盖掉
772
+ state.taskLoadError = e.message;
773
+ setStatus(`读取任务内容失败:${e.message}`, 'err');
774
+ setDot('err');
775
+ }
776
+ }
777
+ saveUiState();
778
+ }
779
+
780
+ /* ---------- 审批模式 ---------- */
781
+
782
+ /**
783
+ * 输入框提示由「有没有选工作目录」和「当前模式」共同决定。
784
+ * 这两件事分别由 setWorkDir 和 renderMode 改,所以收在一个函数里 ——
785
+ * 否则后调用的那个会把前一个的提示覆盖掉。
786
+ */
787
+ function renderInputHint() {
788
+ if (!state.workDir) {
789
+ dom.input.placeholder = '先选择工作目录后再输入任务';
790
+ return;
791
+ }
792
+ dom.input.placeholder =
793
+ state.mode === 'plan'
794
+ ? '描述你想调研的目标:模型只读调研,计划会写入工作目录的 docs/,Enter 执行'
795
+ : state.mode === 'auto'
796
+ ? '描述要做的任务(自动模式:写入不再逐个确认),Enter 执行,Shift+Enter 换行'
797
+ : '描述要做的任务,Enter 执行,Shift+Enter 换行';
798
+ }
799
+
800
+ function renderMode() {
801
+ for (const btn of dom.modeSwitch.querySelectorAll('.mode-btn')) {
802
+ const on = btn.dataset.mode === state.mode;
803
+ btn.classList.toggle('active', on);
804
+ btn.setAttribute('aria-pressed', on ? 'true' : 'false');
805
+ }
806
+ dom.modeSwitch.dataset.mode = state.mode;
807
+ dom.modeNote.textContent = MODE_NOTES[state.mode];
808
+ // 自动模式直接写盘,必须在界面上一直看得见,免得用户忘了自己开过
809
+ dom.modeNote.className = `mode-note${state.mode === 'auto' ? ' warn' : ''}`;
810
+ renderInputHint();
811
+ }
812
+
813
+ /** 切模式:顺手把当前任务记住,下次打开还是这个模式 */
814
+ function setMode(mode, persist = true) {
815
+ const next = normalizeMode(mode);
816
+ if (next === state.mode) return;
817
+ state.mode = next;
818
+ if (state.activeId) {
819
+ const t = state.tasks.find((x) => x.id === state.activeId);
820
+ if (t) t.mode = next;
821
+ if (persist) persistActive();
822
+ } else {
823
+ state.draftMode = next;
824
+ if (persist) saveUiState();
825
+ }
826
+ renderMode();
827
+ setStatus(`已切到${MODE_LABELS[next]}模式:${MODE_NOTES[next]}`, next === 'auto' ? 'err' : '');
828
+ if (next === 'auto') {
829
+ setDot('warn');
830
+ }
831
+ }
832
+
833
+ function wireModeSwitch() {
834
+ dom.modeSwitch.addEventListener('click', (e) => {
835
+ const btn = e.target.closest('.mode-btn');
836
+ if (btn) setMode(btn.dataset.mode);
837
+ });
838
+ }
839
+
840
+ /* ---------- 刷新后恢复待批准 ---------- */
841
+
842
+ /**
843
+ * 挂起态已经落盘了,所以刷新页面甚至重启服务之后,待批准的写入还在。
844
+ * 打开任务时把它捞回来,重建批准卡片,用户不用重跑整个任务。
845
+ */
846
+ async function restorePending() {
847
+ if (!state.activeId) return 0;
848
+ // 已经有待批准的卡片就不用再来一次(同一次会话内的切换)
849
+ if (findPendingMessage()) return 0;
850
+ let data;
851
+ try {
852
+ data = await api(`/api/task/pending?taskId=${encodeURIComponent(state.activeId)}`);
853
+ } catch {
854
+ return 0; // 查不到就当没有,不影响正常使用
855
+ }
856
+ const runs = Array.isArray(data?.runs) ? data.runs : [];
857
+ if (!runs.length) return 0;
858
+
859
+ const minutes = data.ttlMinutes ?? 60;
860
+ const revived = runs.filter((r) => r.pending);
861
+ for (const r of revived) {
862
+ const msg = {
863
+ role: 'assistant',
864
+ content: '',
865
+ reasoning: '',
866
+ steps: [],
867
+ stepOpen: new Set(),
868
+ pending: {
869
+ runId: r.runId,
870
+ callId: r.pending.id,
871
+ name: r.pending.name,
872
+ label: r.pending.label,
873
+ preview: r.preview,
874
+ },
875
+ };
876
+ state.messages.push(msg);
877
+ appendMessage(msg);
878
+ }
879
+ if (revived.length) {
880
+ state.restoredPending = revived.length;
881
+ setStatus(`恢复了 ${revived.length} 个待批准的写入(挂起态可保留 ${minutes} 分钟)`, '');
882
+ setDot('warn');
883
+ }
884
+ return revived.length;
885
+ }
886
+
887
+ /* ---------- 任务侧边栏 ---------- */
888
+
889
+ function fmtRelative(ts) {
890
+ const min = Math.floor((Date.now() - ts) / 60000);
891
+ if (min < 1) return '刚刚';
892
+ if (min < 60) return `${min} 分钟前`;
893
+ const h = Math.floor(min / 60);
894
+ if (h < 24) return `${h} 小时前`;
895
+ return `${Math.floor(h / 24)} 天前`;
896
+ }
897
+
898
+ function fmtDateTime(ts) {
899
+ const d = new Date(ts);
900
+ const p = (n) => String(n).padStart(2, '0');
901
+ return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
902
+ }
903
+
904
+ /** 侧边栏里显示目录名就够了,完整路径放 title 里 */
905
+ function baseName(p) {
906
+ if (!p) return '未选目录';
907
+ const parts = p.replace(/[\\/]+$/, '').split(/[\\/]/);
908
+ return parts[parts.length - 1] || p;
909
+ }
910
+
911
+ function updateRetentionInfo() {
912
+ const info = state.storeInfo || {};
913
+ const max = info.maxTasks ?? 20;
914
+ const days = info.ttlDays ?? 15; // 服务端会报真实天数,这里只是拿不到时的兜底
915
+ dom.tasksCount.textContent = `任务 ${state.tasks.length} / ${max}`;
916
+ setInfo(dom.infoTasks, `${state.tasks.length} / ${max}(滑动 ${days} 天)`);
917
+
918
+ // 存储位置放在设置面板里给用户看;侧边栏底部空间小,只显示目录名
919
+ setInfo(dom.infoStore, info.dir || '(未连接)');
920
+ if (dom.storePath) {
921
+ dom.storePath.textContent = info.dir ? `存储 ${baseName(info.dir)}` : '存储未连接';
922
+ dom.storePath.title = info.dir ? `任务数据存放于:${info.dir}` : '';
923
+ }
924
+ setInfo(dom.infoStoreSize, info.bytes ? fmtBytes(info.bytes) : '—');
925
+
926
+ if (!state.tasks.length) {
927
+ dom.tasksRetention.textContent = `滑动保留 ${days} 天`;
928
+ setInfo(dom.infoRetention, '暂无任务');
929
+ return;
930
+ }
931
+ const oldest = state.tasks.reduce((a, b) => (a.updatedAt < b.updatedAt ? a : b));
932
+ const text = `最早 ${fmtDateTime(oldest.updatedAt + days * 86400000)} 过期`;
933
+ dom.tasksRetention.textContent = text;
934
+ setInfo(dom.infoRetention, text);
935
+ }
936
+
937
+ /* ---------- 侧边栏:按工作目录分组 ---------- */
938
+
939
+ /** 分组键:没有工作目录的任务归到一组(空串) */
940
+ const dirKey = (d) => (typeof d === 'string' && d ? d : '');
941
+
942
+ function dirLabel(dir) {
943
+ return dir ? baseName(dir) : '未指定目录';
944
+ }
945
+
946
+ /** 不同目录可能同名(a\src 与 b\src),重名时用最后两段区分 */
947
+ function shortTail(p) {
948
+ const sep = String(p).includes('\\') ? '\\' : '/';
949
+ const parts = String(p).split(/[\\/]+/).filter(Boolean);
950
+ return parts.length >= 2 ? parts.slice(-2).join(sep) : baseName(p);
951
+ }
952
+
953
+ function isDirCollapsed(dir) {
954
+ return state.collapsedDirs.includes(dirKey(dir));
955
+ }
956
+
957
+ /** 折叠/展开一组;纯界面偏好,存浏览器里,不进服务端 */
958
+ function toggleDirCollapsed(dir) {
959
+ const key = dirKey(dir);
960
+ state.collapsedDirs = state.collapsedDirs.includes(key)
961
+ ? state.collapsedDirs.filter((d) => d !== key)
962
+ : [...state.collapsedDirs, key];
963
+ saveUiState();
964
+ renderTasks();
965
+ }
966
+
967
+ /** 展开某组:切到该组的任务、或在该组下开新任务时调一下,免得「点了没反应」 */
968
+ function expandDir(dir) {
969
+ const key = dirKey(dir);
970
+ if (!state.collapsedDirs.includes(key)) return;
971
+ state.collapsedDirs = state.collapsedDirs.filter((d) => d !== key);
972
+ saveUiState();
973
+ }
974
+
975
+ /**
976
+ * 按工作目录把任务归堆。
977
+ * 一个目录下本来就可以有多条任务(磁盘上是按任务 id 存的,目录只是分组维度),
978
+ * 这里只是把这条关系在界面上显式地「映射」出来。
979
+ */
980
+ function groupTasksByDir(tasks) {
981
+ const groups = new Map();
982
+ for (const t of tasks) {
983
+ const key = dirKey(t.workDir);
984
+ if (!groups.has(key)) groups.set(key, { key, dir: t.workDir || null, tasks: [] });
985
+ groups.get(key).tasks.push(t);
986
+ }
987
+ for (const g of groups.values()) g.tasks.sort((a, b) => b.updatedAt - a.updatedAt);
988
+ return [...groups.values()];
989
+ }
990
+
991
+ /* ---------- 侧边栏:组内再按「派生关系」成树 ---------- */
992
+
993
+ /**
994
+ * 把一个目录组里的任务按 parentId 排成树,返回扁平的渲染序列。
995
+ * 层级只由 parentId 表达,且父任务必须在同一组、不能自指/成环;
996
+ * 指向不存在或跨目录的父任务一律当顶层 —— 父任务被删掉时,子任务只是升一层,不会消失。
997
+ * 每层内部仍按最近更新排序,与之前「组内按时间平铺」的观感一致(没有父子关系时就是平铺)。
998
+ */
999
+ function flattenTree(tasks) {
1000
+ const ids = new Set(tasks.map((t) => t.id));
1001
+ const childrenOf = new Map();
1002
+ const roots = [];
1003
+ for (const t of tasks) {
1004
+ const pid = t.parentId && ids.has(t.parentId) && t.parentId !== t.id ? t.parentId : null;
1005
+ if (pid) {
1006
+ if (!childrenOf.has(pid)) childrenOf.set(pid, []);
1007
+ childrenOf.get(pid).push(t);
1008
+ } else {
1009
+ roots.push(t);
1010
+ }
1011
+ }
1012
+ const byRecent = (a, b) => b.updatedAt - a.updatedAt;
1013
+ roots.sort(byRecent);
1014
+ for (const list of childrenOf.values()) list.sort(byRecent);
1015
+
1016
+ const out = [];
1017
+ const seen = new Set();
1018
+ const walk = (t, depth) => {
1019
+ if (seen.has(t.id)) return; // 防御:数据里万一成了环,也不会无限递归
1020
+ seen.add(t.id);
1021
+ const kids = childrenOf.get(t.id) || [];
1022
+ out.push({ task: t, depth, childCount: kids.length });
1023
+ if (isTaskCollapsed(t.id)) return; // 折叠的父任务:整棵子树藏起来
1024
+ for (const kid of kids) walk(kid, depth + 1);
1025
+ };
1026
+ for (const r of roots) walk(r, 0);
1027
+ return out;
1028
+ }
1029
+
1030
+ function isTaskCollapsed(id) {
1031
+ return state.collapsedTasks.includes(id);
1032
+ }
1033
+
1034
+ /** 折叠/展开一条父任务(纯界面偏好,存浏览器里) */
1035
+ function toggleTaskCollapsed(id) {
1036
+ state.collapsedTasks = state.collapsedTasks.includes(id)
1037
+ ? state.collapsedTasks.filter((x) => x !== id)
1038
+ : [...state.collapsedTasks, id].slice(-200);
1039
+ saveUiState();
1040
+ renderTasks();
1041
+ }
1042
+
1043
+ /** 某条任务的祖先链若折叠着就展开 —— 切过去 / 在它下面派生时调,免得「点了没反应」 */
1044
+ function expandTaskAncestors(id) {
1045
+ if (!id) return;
1046
+ let changed = false;
1047
+ const guard = new Set();
1048
+ let cur = state.tasks.find((t) => t.id === id);
1049
+ while (cur?.parentId && !guard.has(cur.id)) {
1050
+ guard.add(cur.id);
1051
+ if (state.collapsedTasks.includes(cur.parentId)) {
1052
+ state.collapsedTasks = state.collapsedTasks.filter((x) => x !== cur.parentId);
1053
+ changed = true;
1054
+ }
1055
+ cur = state.tasks.find((t) => t.id === cur.parentId);
1056
+ }
1057
+ if (changed) saveUiState();
1058
+ }
1059
+
1060
+ function buildGroupHead(group, label, count) {
1061
+ const head = document.createElement('div');
1062
+ head.className = 'task-group-head';
1063
+ const collapsed = isDirCollapsed(group.dir);
1064
+
1065
+ const toggle = document.createElement('button');
1066
+ toggle.className = 'task-group-toggle';
1067
+ toggle.type = 'button';
1068
+ toggle.title = group.dir
1069
+ ? `${group.dir}\n点击${collapsed ? '展开' : '折叠'}`
1070
+ : '这些任务还没指定工作目录';
1071
+ const caret = document.createElement('span');
1072
+ caret.className = 'task-group-caret';
1073
+ caret.textContent = collapsed ? '▸' : '▾';
1074
+ const icon = document.createElement('span');
1075
+ icon.className = 'task-group-icon';
1076
+ icon.textContent = group.dir ? '📁' : '📂';
1077
+ const name = document.createElement('span');
1078
+ name.className = 'task-group-name';
1079
+ name.textContent = label;
1080
+ const badge = document.createElement('span');
1081
+ badge.className = 'task-group-count';
1082
+ badge.textContent = String(count);
1083
+ badge.title = `${count} 个任务`;
1084
+ toggle.append(caret, icon, name, badge);
1085
+ toggle.addEventListener('click', () => toggleDirCollapsed(group.dir));
1086
+
1087
+ // 分组行上的「+」:直接在这个目录下再加一条任务
1088
+ const add = document.createElement('button');
1089
+ add.className = 'icon-btn task-group-add';
1090
+ add.type = 'button';
1091
+ add.title = group.dir ? `在 ${group.dir} 下再加一条任务` : '再加一条任务';
1092
+ add.textContent = '+';
1093
+ add.addEventListener('click', (e) => {
1094
+ e.stopPropagation();
1095
+ startDraft(group.dir);
1096
+ });
1097
+
1098
+ head.append(toggle, add);
1099
+ return head;
1100
+ }
1101
+
1102
+ /**
1103
+ * 一条任务行。depth 是它在任务树里的层级(0 = 顶层),childCount 是直接子任务数。
1104
+ * 有子任务的行首多一个展开/折叠箭头,于是「一个任务是另一个任务的延续」在列表里看得见。
1105
+ */
1106
+ function buildTaskRow(t, { depth = 0, childCount = 0 } = {}) {
1107
+ const item = document.createElement('div');
1108
+ // 用 runs 而不是 runningId:后台跑着的任务(当前没在看)也要显示成进行中
1109
+ const running = state.runs.has(t.id) || t.id === state.runningId;
1110
+ const classes = ['session-item'];
1111
+ if (t.id === state.activeId) classes.push('active');
1112
+ if (running) classes.push('running');
1113
+ // 派生出来的子任务缩进一级,并画一条竖线表明从属关系
1114
+ if (depth > 0) classes.push('child', `depth-${Math.min(depth, 3)}`);
1115
+ if (childCount) classes.push('has-children');
1116
+ item.className = classes.join(' ');
1117
+
1118
+ // 有子任务的父任务:可以整棵收起(列表太长时很有用)。放在 main 之外,避免按钮嵌套按钮
1119
+ if (childCount) {
1120
+ const collapsed = isTaskCollapsed(t.id);
1121
+ const caret = document.createElement('button');
1122
+ caret.className = 'task-caret';
1123
+ caret.type = 'button';
1124
+ caret.textContent = collapsed ? '▸' : '▾';
1125
+ caret.title = collapsed ? `展开 ${childCount} 个子任务` : `收起 ${childCount} 个子任务`;
1126
+ caret.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
1127
+ caret.addEventListener('click', (e) => {
1128
+ e.stopPropagation();
1129
+ toggleTaskCollapsed(t.id);
1130
+ });
1131
+ item.appendChild(caret);
1132
+ }
1133
+
1134
+ const main = document.createElement('button');
1135
+ main.className = 'session-main';
1136
+ main.type = 'button';
1137
+ const title = document.createElement('span');
1138
+ title.className = 'session-title';
1139
+ title.textContent = t.title;
1140
+ title.title = t.title;
1141
+ const sub = document.createElement('span');
1142
+ sub.className = 'session-wd';
1143
+ // 目录已经提成分组标题了,任务行只留时间;子任务多一个「↳」表明它是派生出来的
1144
+ sub.textContent = `${depth > 0 ? '↳ ' : ''}${fmtRelative(t.updatedAt)}`;
1145
+ sub.title = t.workDir || '';
1146
+
1147
+ // 列表只要一个效果:进行中的在呼吸,已完成的常亮。文字放 tooltip 就够了
1148
+ const dot = document.createElement('span');
1149
+ dot.className = running ? 'task-dot running' : t.replies > 0 ? 'task-dot done' : 'task-dot';
1150
+ dot.title = running ? '进行中' : t.replies > 0 ? '已完成' : '未开始';
1151
+
1152
+ main.append(title, sub);
1153
+ main.addEventListener('click', () => switchTask(t.id));
1154
+
1155
+ const acts = document.createElement('div');
1156
+ acts.className = 'session-acts';
1157
+ // 在这条任务下派生一个子任务:工作目录跟它一致,于是在树上长出一支
1158
+ const add = document.createElement('button');
1159
+ add.className = 'icon-btn task-row-add';
1160
+ add.type = 'button';
1161
+ add.title = '在这条任务下新建子任务(同一个工作目录)';
1162
+ add.textContent = '+';
1163
+ add.addEventListener('click', (e) => {
1164
+ e.stopPropagation();
1165
+ startDraft(t.workDir, t.id);
1166
+ });
1167
+ const rename = document.createElement('button');
1168
+ rename.className = 'icon-btn';
1169
+ rename.type = 'button';
1170
+ rename.title = '重命名';
1171
+ rename.textContent = '✎';
1172
+ rename.addEventListener('click', (e) => {
1173
+ e.stopPropagation();
1174
+ beginRename(item, t);
1175
+ });
1176
+ const del = document.createElement('button');
1177
+ del.className = 'icon-btn danger';
1178
+ del.type = 'button';
1179
+ del.title = '删除';
1180
+ del.textContent = '✕';
1181
+ del.addEventListener('click', (e) => {
1182
+ e.stopPropagation();
1183
+ confirmDelete(t);
1184
+ });
1185
+ acts.append(add, rename, del);
1186
+
1187
+ item.append(main, dot, acts);
1188
+ return item;
1189
+ }
1190
+
1191
+ /** 草稿(点了「+」但还没发出第一条指令)也占一行,让用户看见它将落在哪个目录 */
1192
+ function buildDraftRow(depth = 0) {
1193
+ const item = document.createElement('div');
1194
+ item.className = `session-item draft${depth > 0 ? ` child depth-${Math.min(depth, 3)}` : ''}`;
1195
+ const main = document.createElement('div');
1196
+ main.className = 'session-main';
1197
+ const title = document.createElement('span');
1198
+ title.className = 'session-title';
1199
+ title.textContent = '新任务';
1200
+ const sub = document.createElement('span');
1201
+ sub.className = 'session-wd';
1202
+ sub.textContent = '发出第一条指令后保存';
1203
+ main.append(title, sub);
1204
+ const dot = document.createElement('span');
1205
+ dot.className = 'task-dot';
1206
+ item.append(main, dot);
1207
+ return item;
1208
+ }
1209
+
1210
+ function renderTasks() {
1211
+ dom.taskList.innerHTML = '';
1212
+ if (!state.tasks.length) {
1213
+ const hint = document.createElement('div');
1214
+ hint.className = 'sessions-empty';
1215
+ hint.textContent = state.draft ? '新任务:选好目录、发出第一条指令就会保存' : '还没有任务,发出第一条指令就会自动创建';
1216
+ dom.taskList.appendChild(hint);
1217
+ updateRetentionInfo();
1218
+ return;
1219
+ }
1220
+
1221
+ const groups = groupTasksByDir(state.tasks);
1222
+ const draftKey = state.draft ? dirKey(state.draftWorkDir) : null;
1223
+ // 草稿所在的目录可能一条任务都还没有,补一个空组出来,不然「+」点了看不见落点
1224
+ if (state.draft && !groups.some((g) => g.key === draftKey)) {
1225
+ groups.push({ key: draftKey, dir: state.draftWorkDir || null, tasks: [] });
1226
+ }
1227
+ // 组间按「组里最新的一条」排序;草稿所在的组顶到最前面
1228
+ const rank = (g) => (state.draft && g.key === draftKey ? Number.MAX_SAFE_INTEGER : g.tasks[0]?.updatedAt || 0);
1229
+ groups.sort((a, b) => rank(b) - rank(a));
1230
+
1231
+ const sameName = new Map();
1232
+ for (const g of groups) {
1233
+ const label = dirLabel(g.dir);
1234
+ sameName.set(label, (sameName.get(label) || 0) + 1);
1235
+ }
1236
+
1237
+ for (const g of groups) {
1238
+ const box = document.createElement('div');
1239
+ box.className = 'task-group';
1240
+ const isDraftHere = state.draft && g.key === draftKey;
1241
+ const label = g.dir && sameName.get(dirLabel(g.dir)) > 1 ? shortTail(g.dir) : dirLabel(g.dir);
1242
+
1243
+ box.appendChild(buildGroupHead(g, label, g.tasks.length + (isDraftHere ? 1 : 0)));
1244
+
1245
+ const body = document.createElement('div');
1246
+ body.className = 'task-group-body';
1247
+ body.hidden = isDirCollapsed(g.dir);
1248
+
1249
+ // 组内再按派生关系排成树(没有父子关系时就是原来的平铺)
1250
+ const rows = flattenTree(g.tasks);
1251
+ // 草稿行:派生出来的草稿挂在它的父任务下面,顶层草稿仍放在组内最前面
1252
+ const draftParent = isDraftHere && state.draftParentId ? state.draftParentId : null;
1253
+ const parentRendered = draftParent && rows.some((r) => r.task.id === draftParent);
1254
+ if (isDraftHere && !parentRendered) body.appendChild(buildDraftRow());
1255
+ for (const r of rows) {
1256
+ body.appendChild(buildTaskRow(r.task, { depth: r.depth, childCount: r.childCount }));
1257
+ if (isDraftHere && draftParent === r.task.id) body.appendChild(buildDraftRow(r.depth + 1));
1258
+ }
1259
+
1260
+ box.appendChild(body);
1261
+ dom.taskList.appendChild(box);
1262
+ }
1263
+ updateRetentionInfo();
1264
+ }
1265
+
1266
+ function beginRename(item, t) {
1267
+ item.innerHTML = '';
1268
+ const input = document.createElement('input');
1269
+ input.className = 'session-rename';
1270
+ input.value = t.title;
1271
+ item.appendChild(input);
1272
+ input.focus();
1273
+ input.select();
1274
+
1275
+ let done = false;
1276
+ const commit = async (save) => {
1277
+ if (done) return; // Enter 之后还会触发一次 blur,这里去重
1278
+ done = true;
1279
+ if (save) {
1280
+ const v = input.value.replace(/\s+/g, ' ').trim();
1281
+ t.title = v ? v.slice(0, 40) : '未命名任务';
1282
+ t.renamed = true; // 标记为用户命名,后续不再被首条消息自动覆盖
1283
+ // 改名要落到磁盘,否则刷新就丢了
1284
+ try {
1285
+ await api(`/api/tasks/${t.id}`, {
1286
+ method: 'PUT',
1287
+ body: JSON.stringify({ title: t.title, renamed: true, workDir: t.workDir, messages: state.messages }),
1288
+ });
1289
+ } catch (e) {
1290
+ setStatus(`重命名失败:${e.message}`, 'err');
1291
+ }
1292
+ }
1293
+ renderTasks();
1294
+ };
1295
+
1296
+ input.addEventListener('keydown', (e) => {
1297
+ if (e.key === 'Enter') {
1298
+ e.preventDefault();
1299
+ commit(true);
1300
+ } else if (e.key === 'Escape') {
1301
+ e.preventDefault();
1302
+ commit(false);
1303
+ }
1304
+ });
1305
+ input.addEventListener('blur', () => commit(true));
1306
+ input.addEventListener('click', (e) => e.stopPropagation());
1307
+ }
1308
+
1309
+ async function confirmDelete(t) {
1310
+ // 子任务不会被连带删掉(那是别人的工作),只是升到上一层,这里先说清楚
1311
+ const kids = state.tasks.filter((x) => x.parentId === t.id).length;
1312
+ const extra = kids ? `\n它下面的 ${kids} 个子任务不会一起删,会升到上一层。` : '';
1313
+ if (!window.confirm(`删除任务「${t.title}」?\n该任务的记录(含工具调用输出)会从磁盘上立即删除,无法恢复。${extra}`)) return;
1314
+ await deleteTaskById(t.id);
1315
+ }
1316
+
1317
+ async function deleteTaskById(id) {
1318
+ try {
1319
+ await api(`/api/tasks/${id}`, { method: 'DELETE' });
1320
+ } catch (e) {
1321
+ setStatus(`删除失败:${e.message}`, 'err');
1322
+ setDot('err');
1323
+ return;
1324
+ }
1325
+ state.tasks = state.tasks.filter((t) => t.id !== id);
1326
+ // 删掉的任务不该再留在缓存里,正在跑的也一并停掉(任务是它自己删的)
1327
+ state.cache.delete(id);
1328
+ savedBodies().delete(id);
1329
+ const run = state.runs.get(id);
1330
+ if (run) {
1331
+ state.runs.delete(id);
1332
+ try {
1333
+ run.abort();
1334
+ } catch {
1335
+ /* 已经结束了 */
1336
+ }
1337
+ }
1338
+ if (state.activeId === id) {
1339
+ state.activeId = state.tasks[0]?.id ?? null;
1340
+ state.draft = state.activeId === null;
1341
+ state.messages = [];
1342
+ state.workDir = null;
1343
+ if (state.activeId) {
1344
+ try {
1345
+ const task = await fetchTask(state.activeId);
1346
+ state.workDir = task?.workDir || null;
1347
+ state.messages = adoptMessages(task?.messages);
1348
+ } catch {
1349
+ /* 读不到就先空着 */
1350
+ }
1351
+ }
1352
+ state.usageTotal = { prompt: 0, completion: 0 };
1353
+ setInfo(dom.infoUsage, '—');
1354
+ setWorkDir(state.workDir);
1355
+ }
1356
+ saveUiState();
1357
+ syncRunState();
1358
+ renderTasks();
1359
+ renderAll();
1360
+ setStatus('已删除任务', '');
1361
+ }
1362
+
1363
+ /** 停止:只停当前正在看的这条任务,后台别的不受影响 */
1364
+ function abortIfStreaming() {
1365
+ const c = state.runs.get(state.activeId);
1366
+ if (c) c.abort();
1367
+ }
1368
+
1369
+ /**
1370
+ * 切走当前任务:只落盘 + 存进缓存,**绝不中断正在跑的**。
1371
+ * 任务在后台继续跑,切回来时接着看就行。
1372
+ */
1373
+ async function switchTask(id) {
1374
+ if (id === state.activeId) return;
1375
+ await persistActive(); // 先把当前任务存好再切走
1376
+ rememberActive(); // 顺带把内存态存进缓存,切回来直接恢复
1377
+ state.activeId = id;
1378
+ state.draft = false;
1379
+ const meta = state.tasks.find((x) => x.id === id);
1380
+
1381
+ const cached = state.cache.get(id);
1382
+ if (cached) {
1383
+ // 已经在本页加载过:直接用缓存,不重新拉服务端(那正是「切回来像刷新了一样」的原因)
1384
+ state.messages = cached.messages;
1385
+ state.workDir = cached.workDir ?? meta?.workDir ?? null;
1386
+ state.mode = normalizeMode(cached.mode || meta?.mode);
1387
+ state.usageTotal = cached.usageTotal || { prompt: 0, completion: 0 };
1388
+ } else {
1389
+ state.messages = [];
1390
+ state.workDir = meta?.workDir || null;
1391
+ state.mode = normalizeMode(meta?.mode);
1392
+ setWorkDir(state.workDir, false);
1393
+ renderTasks();
1394
+ renderAll();
1395
+ renderMode();
1396
+ try {
1397
+ const task = await fetchTask(id);
1398
+ state.workDir = task?.workDir || state.workDir;
1399
+ state.mode = normalizeMode(task?.mode || state.mode);
1400
+ state.messages = adoptMessages(task?.messages);
1401
+ } catch (e) {
1402
+ setStatus(`读取任务失败:${e.message}`, 'err');
1403
+ setDot('err');
1404
+ return;
1405
+ }
1406
+ state.usageTotal = { prompt: 0, completion: 0 };
1407
+ state.cache.set(id, {
1408
+ messages: state.messages,
1409
+ workDir: state.workDir,
1410
+ mode: state.mode,
1411
+ usageTotal: state.usageTotal,
1412
+ });
1413
+ // 刚从服务端读回来的内容:记下指纹,免得切走时又把它原样 PUT 回去
1414
+ markSaved(id, state.messages);
1415
+ }
1416
+
1417
+ setWorkDir(state.workDir, false);
1418
+ syncRunState();
1419
+ setInfo(
1420
+ dom.infoUsage,
1421
+ state.usageTotal.prompt || state.usageTotal.completion
1422
+ ? `输入 ${state.usageTotal.prompt} / 输出 ${state.usageTotal.completion}`
1423
+ : '—',
1424
+ );
1425
+ expandDir(state.workDir); // 目标那条所在的目录若折叠着,先展开
1426
+ expandTaskAncestors(id); // 它的祖先任务若折叠着,也一并展开,否则切过去看不到它
1427
+ renderTasks();
1428
+ renderAll();
1429
+ renderMode();
1430
+ saveUiState();
1431
+ const t = state.tasks.find((x) => x.id === id);
1432
+ if (state.streaming) setStatus('进行中', '');
1433
+ else setStatus(t ? `已切换到「${t.title}」(${MODE_LABELS[state.mode]}模式)` : '就绪', '');
1434
+ await restorePending();
1435
+ if (!state.workDir) openDirModal();
1436
+ else if (!state.streaming) dom.input.focus();
1437
+ }
1438
+
1439
+ /** 把当前任务的内存态存进缓存(切走、开新任务时用) */
1440
+ function rememberActive() {
1441
+ if (!state.activeId) return;
1442
+ state.cache.set(state.activeId, {
1443
+ messages: state.messages,
1444
+ workDir: state.workDir,
1445
+ mode: state.mode,
1446
+ usageTotal: state.usageTotal,
1447
+ });
1448
+ }
1449
+
1450
+ /**
1451
+ * 把全局的 streaming / runningId 对齐到「当前任务」。
1452
+ * 别的任务在后台跑时,当前任务不该被显示成进行中,也不该被禁用输入。
1453
+ */
1454
+ function syncRunState() {
1455
+ state.streaming = state.runs.has(state.activeId);
1456
+ state.runningId = state.streaming ? state.activeId : null;
1457
+ state.controller = state.runs.get(state.activeId) || null;
1458
+ dom.send.disabled = state.streaming || !state.workDir;
1459
+ dom.stop.hidden = !state.streaming;
1460
+ }
1461
+
1462
+
1463
+ /**
1464
+ * 「新任务」:先置为草稿态,首条消息发出时才落盘,避免留下一堆空任务。
1465
+ *
1466
+ * dir 不传 = 沿用当前工作目录(顶部「+ 新任务」按钮,多数情况是在同一个项目里连着做几件事)
1467
+ * dir 传路径 = 就在这个目录下新建(侧边栏分组行上的「+」,于是同一目录可以有多条任务)
1468
+ * dir 传 null = 明确不要目录(顶部可再选)
1469
+ *
1470
+ * parentId = 派生到某条已有任务之下(侧边栏任务行上的「+」),形成任务树;
1471
+ * 非空时它必须是列表里真实存在的任务,否则就当顶层新建。
1472
+ */
1473
+ async function startDraft(dir, parentId = null) {
1474
+ const target = dir === undefined ? state.workDir : typeof dir === 'string' && dir ? dir : null;
1475
+ const parent = typeof parentId === 'string' && parentId ? state.tasks.find((t) => t.id === parentId) : null;
1476
+ await persistActive(); // 先把当前任务存好
1477
+ rememberActive(); // 后台还在跑的话,它的历史留在缓存里,跑完自己会落盘
1478
+ state.activeId = null;
1479
+ state.draft = true;
1480
+ state.draftWorkDir = target;
1481
+ state.draftParentId = parent ? parent.id : null;
1482
+ state.workDir = target;
1483
+ state.draftMode = state.mode; // 新任务沿用当前模式
1484
+ state.messages = [];
1485
+ state.usageTotal = { prompt: 0, completion: 0 };
1486
+ setInfo(dom.infoUsage, '—');
1487
+ expandDir(target); // 这一组要是折叠着,先展开
1488
+ expandTaskAncestors(state.draftParentId); // 派生到折叠着的父任务下时,先把它展开
1489
+ saveUiState();
1490
+ syncRunState(); // 草稿态不属于任何任务,输入不该被别的任务的运行禁用
1491
+ renderTasks();
1492
+ renderAll();
1493
+ renderMode();
1494
+ setWorkDir(target, false);
1495
+ if (!target) {
1496
+ setStatus('已开始新任务,请选择工作目录');
1497
+ openDirModal();
1498
+ } else if (state.draftParentId) {
1499
+ setStatus(`已在「${parent.title}」下新建子任务(工作目录 ${target})`, '');
1500
+ dom.input.focus();
1501
+ } else {
1502
+ setStatus(`已开始新任务(工作目录 ${target},点顶部可改)`, '');
1503
+ dom.input.focus();
1504
+ }
1505
+ }
1506
+
1507
+ function toggleSidebar() {
1508
+ if (window.matchMedia('(max-width: 820px)').matches) dom.sidebar.classList.toggle('open');
1509
+ else dom.sidebar.classList.toggle('collapsed');
1510
+ }
1511
+
1512
+ /* ---------- 渲染消息 ---------- */
1513
+
1514
+ function buildMsgEl(msg) {
1515
+ const wrap = document.createElement('div');
1516
+ wrap.className = `msg ${msg.role}`;
1517
+ const avatar = document.createElement('div');
1518
+ avatar.className = 'avatar';
1519
+ avatar.textContent = msg.role === 'user' ? '你' : 'AI';
1520
+ const bubble = document.createElement('div');
1521
+ bubble.className = 'bubble';
1522
+ wrap.append(avatar, bubble);
1523
+ msg.el = bubble;
1524
+ paintBubble(msg);
1525
+ return wrap;
1526
+ }
1527
+
1528
+ function renderSteps(container, msg) {
1529
+ container.innerHTML = '';
1530
+ for (const s of msg.steps) {
1531
+ const det = document.createElement('details');
1532
+ det.className = `step ${s.status || 'running'}`;
1533
+
1534
+ const sum = document.createElement('summary');
1535
+ const icon = document.createElement('span');
1536
+ icon.className = 'step-icon';
1537
+ icon.textContent = s.status === 'ok' ? '✓' : s.status === 'fail' ? '✗' : '⋯';
1538
+ const label = document.createElement('span');
1539
+ label.className = 'step-label';
1540
+ label.textContent = s.label || s.name || '工具调用';
1541
+ const brief = document.createElement('span');
1542
+ brief.className = 'step-brief';
1543
+ brief.textContent = s.summary || (s.status === 'running' ? '执行中…' : '');
1544
+ sum.append(icon, label, brief);
1545
+
1546
+ // 写入是被自动放行、还是被计划模式挡下,必须一眼看出来
1547
+ if (s.note) {
1548
+ const note = document.createElement('span');
1549
+ note.className = `step-note${s.status === 'fail' ? ' bad' : ''}`;
1550
+ note.textContent = s.note;
1551
+ sum.appendChild(note);
1552
+ }
1553
+ det.appendChild(sum);
1554
+
1555
+ if (s.content) {
1556
+ const pre = document.createElement('pre');
1557
+ pre.className = 'step-body';
1558
+ pre.textContent = s.content.length > 20000 ? `${s.content.slice(0, 20000)}\n…(已截断显示)` : s.content;
1559
+ det.appendChild(pre);
1560
+ }
1561
+
1562
+ det.open = Boolean(msg.stepOpen?.has(s.id));
1563
+ det.addEventListener('toggle', () => {
1564
+ if (!msg.stepOpen) msg.stepOpen = new Set();
1565
+ if (det.open) msg.stepOpen.add(s.id);
1566
+ else msg.stepOpen.delete(s.id);
1567
+ });
1568
+ container.appendChild(det);
1569
+ }
1570
+
1571
+ if (msg.pending) container.appendChild(buildApprovalCard(msg.pending));
1572
+ }
1573
+
1574
+ function buildApprovalCard(p) {
1575
+ const pv = p.preview || {};
1576
+ const card = document.createElement('div');
1577
+ card.className = 'approval';
1578
+
1579
+ const head = document.createElement('div');
1580
+ head.className = 'approval-head';
1581
+ head.textContent = '需要你批准这次写入';
1582
+
1583
+ const meta = document.createElement('div');
1584
+ meta.className = 'approval-meta';
1585
+ const code = document.createElement('code');
1586
+ code.textContent = pv.path || p.label || '?';
1587
+ const tail = document.createElement('span');
1588
+ tail.textContent = `${pv.exists ? '覆盖已有文件' : '新建文件'} · ${pv.lines || 0} 行 / ${fmtBytes(pv.bytes || 0)}`;
1589
+ meta.append(code, tail);
1590
+
1591
+ const det = document.createElement('details');
1592
+ det.className = 'approval-preview';
1593
+ const dsum = document.createElement('summary');
1594
+ dsum.textContent = '查看将要写入的内容';
1595
+ const pre = document.createElement('pre');
1596
+ pre.textContent = pv.content || '(内容为空)';
1597
+ det.append(dsum, pre);
1598
+
1599
+ const acts = document.createElement('div');
1600
+ acts.className = 'approval-actions';
1601
+ const deny = document.createElement('button');
1602
+ deny.className = 'btn';
1603
+ deny.type = 'button';
1604
+ deny.dataset.act = 'deny';
1605
+ deny.textContent = '拒绝';
1606
+ const ok = document.createElement('button');
1607
+ ok.className = 'btn primary';
1608
+ ok.type = 'button';
1609
+ ok.dataset.act = 'approve';
1610
+ ok.textContent = '批准写入';
1611
+ acts.append(deny, ok);
1612
+
1613
+ card.append(head, meta, det, acts);
1614
+ return card;
1615
+ }
1616
+
1617
+ /**
1618
+ * 气泡重绘。思维链块与工具步骤块各自增量更新:
1619
+ * - 思维链只改文本,保住用户的展开状态;
1620
+ * - 步骤块用签名比对,只在结构变化时重建,正文流式输出时不白重建。
1621
+ */
1622
+ function paintBubble(msg) {
1623
+ if (!msg.el) return;
1624
+ if (msg.error) {
1625
+ msg.el.className = 'bubble error';
1626
+ msg.el.textContent = msg.error;
1627
+ return;
1628
+ }
1629
+ msg.el.className = `bubble${msg.streaming ? ' streaming' : ''}${msg.working ? ' working' : ''}`;
1630
+
1631
+ if (msg.role === 'user') {
1632
+ msg.el.textContent = msg.content;
1633
+ return;
1634
+ }
1635
+
1636
+ const steps = msg.steps || [];
1637
+
1638
+ // 1. 思维链
1639
+ const wantReasoning = msg.reasoning && state.settings.showReasoning !== false;
1640
+ let rbox = msg.el.querySelector(':scope > details.reasoning');
1641
+ if (wantReasoning) {
1642
+ if (!rbox) {
1643
+ rbox = document.createElement('details');
1644
+ rbox.className = 'reasoning';
1645
+ rbox.open = true;
1646
+ msg.reasoningOpen = true;
1647
+ rbox.innerHTML = '<summary></summary><div class="reasoning-body"></div>';
1648
+ msg.el.insertBefore(rbox, msg.el.firstChild);
1649
+ }
1650
+ rbox.querySelector('summary').textContent = `思考过程 · ${msg.reasoning.length} 字`;
1651
+ rbox.querySelector('.reasoning-body').textContent = msg.reasoning;
1652
+ if (msg.reasoningOpen && (msg.content || steps.length)) {
1653
+ rbox.open = false;
1654
+ msg.reasoningOpen = false;
1655
+ }
1656
+ } else if (rbox) {
1657
+ rbox.remove();
1658
+ }
1659
+
1660
+ // 2. 工具步骤 + 待批准卡片
1661
+ let stepBox = msg.el.querySelector(':scope > .steps');
1662
+ if (steps.length || msg.pending) {
1663
+ if (!stepBox) {
1664
+ stepBox = document.createElement('div');
1665
+ stepBox.className = 'steps';
1666
+ const contentEl = msg.el.querySelector(':scope > .content');
1667
+ msg.el.insertBefore(stepBox, contentEl || null);
1668
+ msg.stepsSig = null;
1669
+ }
1670
+ const sig = JSON.stringify([steps.map((s) => [s.id, s.status, s.summary || '']), msg.pending ? msg.pending.callId : null]);
1671
+ if (msg.stepsSig !== sig) {
1672
+ renderSteps(stepBox, msg);
1673
+ msg.stepsSig = sig;
1674
+ }
1675
+ } else if (stepBox) {
1676
+ stepBox.remove();
1677
+ msg.stepsSig = null;
1678
+ }
1679
+
1680
+ // 3. 正文
1681
+ let content = msg.el.querySelector(':scope > .content');
1682
+ if (!content) {
1683
+ content = document.createElement('div');
1684
+ content.className = 'content';
1685
+ msg.el.appendChild(content);
1686
+ }
1687
+ content.innerHTML = renderMarkdown(msg.content || '');
1688
+ enhanceCodeBlocks(content);
1689
+ enhanceTables(content);
1690
+
1691
+ // 4. 提示横幅(上限、空回复、思维链没出正文等)—— 一定要单独显示,
1692
+ // 不能像以前那样「已经有正文就丢掉」,否则关键信息就没了
1693
+ let noticeBox = msg.el.querySelector(':scope > .msg-notices');
1694
+ const notices = msg.notices || [];
1695
+ if (notices.length) {
1696
+ if (!noticeBox) {
1697
+ noticeBox = document.createElement('div');
1698
+ noticeBox.className = 'msg-notices';
1699
+ // 放在正文之前:先解释发生了什么,再给正文
1700
+ const contentEl = msg.el.querySelector(':scope > .content');
1701
+ msg.el.insertBefore(noticeBox, contentEl || null);
1702
+ }
1703
+ noticeBox.textContent = '';
1704
+ for (const text of notices) {
1705
+ const line = document.createElement('div');
1706
+ line.className = 'msg-notice';
1707
+ line.textContent = text;
1708
+ noticeBox.appendChild(line);
1709
+ }
1710
+ } else if (noticeBox) {
1711
+ noticeBox.remove();
1712
+ }
1713
+
1714
+ // 5. 计划模式的「按计划执行」入口
1715
+ let planBar = msg.el.querySelector(':scope > .plan-bar');
1716
+ if (msg.planReady) {
1717
+ if (!planBar) {
1718
+ planBar = document.createElement('div');
1719
+ planBar.className = 'plan-bar';
1720
+ const btnGo = document.createElement('button');
1721
+ btnGo.className = 'btn primary';
1722
+ btnGo.type = 'button';
1723
+ btnGo.textContent = '按计划执行';
1724
+ btnGo.title = '切到自动模式并让模型开始动手;仍限制在当前工作目录内';
1725
+ btnGo.addEventListener('click', () => executePlan(msg));
1726
+ const btnManual = document.createElement('button');
1727
+ btnManual.className = 'btn';
1728
+ btnManual.type = 'button';
1729
+ btnManual.textContent = '手动执行';
1730
+ btnManual.title = '切到手动模式,每次写入仍由你确认';
1731
+ btnManual.addEventListener('click', () => executePlan(msg, 'manual'));
1732
+ const hint = document.createElement('span');
1733
+ hint.className = 'plan-hint';
1734
+ planBar.append(btnGo, btnManual, hint);
1735
+ msg.el.appendChild(planBar);
1736
+ }
1737
+ // 计划已经落盘时,把路径直接写在按钮旁边 —— 用户要能自己打开那份文档看
1738
+ const hint = planBar.querySelector('.plan-hint');
1739
+ if (hint) {
1740
+ hint.textContent = msg.planFile
1741
+ ? `已保存到 ${msg.planFile};模型将按它执行并把结果回填进该文档`
1742
+ : '确认计划无误后再开始,模型将直接改动文件';
1743
+ }
1744
+ } else if (planBar) {
1745
+ planBar.remove();
1746
+ }
1747
+
1748
+ // 6. 撞到轮次上限时的「继续执行」:活干一半不该让用户重新描述一遍
1749
+ let resumeBar = msg.el.querySelector(':scope > .resume-bar');
1750
+ if (msg.canResume) {
1751
+ if (!resumeBar) {
1752
+ resumeBar = document.createElement('div');
1753
+ resumeBar.className = 'resume-bar';
1754
+ const btn = document.createElement('button');
1755
+ btn.className = 'btn primary';
1756
+ btn.type = 'button';
1757
+ btn.textContent = '继续执行';
1758
+ btn.title = '再给一轮额度,接着把没做完的做完';
1759
+ btn.addEventListener('click', () => resumeRun(msg));
1760
+ const hint = document.createElement('span');
1761
+ hint.className = 'plan-hint';
1762
+ hint.textContent = '已经读到的内容都还在,接着往下做';
1763
+ resumeBar.append(btn, hint);
1764
+ msg.el.appendChild(resumeBar);
1765
+ }
1766
+ } else if (resumeBar) {
1767
+ resumeBar.remove();
1768
+ }
1769
+ }
1770
+
1771
+ /** 撞到上限后一键接着干:历史都在,直接再要一轮 */
1772
+ async function resumeRun(msg) {
1773
+ if (state.streaming) return;
1774
+ msg.canResume = false;
1775
+ schedulePaint(msg, true);
1776
+ await send('继续,把没做完的做完。');
1777
+ }
1778
+
1779
+ /** 计划确认后:切模式 → 让模型接着按计划动手 */
1780
+ async function executePlan(msg, mode = 'auto') {
1781
+ if (state.streaming) return;
1782
+ msg.planReady = false;
1783
+ setMode(mode);
1784
+ // 计划已落盘时把它写进这句话里:模型和服务端都知道要按哪一份文档执行
1785
+ const at = msg.planFile ? `(计划已保存到 ${msg.planFile})` : '';
1786
+ setStatus(`已按计划开始执行(${MODE_LABELS[mode]}模式)${at}`, '');
1787
+ await send(msg.planFile ? `上面的计划我已确认,请按 ${msg.planFile} 里的步骤开始执行。` : '上面的计划我已确认,请开始执行。');
1788
+ }
1789
+
1790
+ const paintQueue = new WeakSet();
1791
+ function schedulePaint(msg, immediate = false) {
1792
+ if (immediate) {
1793
+ paintBubble(msg);
1794
+ scrollToBottom();
1795
+ return;
1796
+ }
1797
+ if (paintQueue.has(msg)) return;
1798
+ paintQueue.add(msg);
1799
+ requestAnimationFrame(() => {
1800
+ paintQueue.delete(msg);
1801
+ paintBubble(msg);
1802
+ scrollToBottom();
1803
+ });
1804
+ }
1805
+
1806
+ function appendMessage(msg) {
1807
+ dom.messages.appendChild(buildMsgEl(msg));
1808
+ dom.empty.hidden = true;
1809
+ scrollToBottom(true);
1810
+ }
1811
+
1812
+ function renderAll() {
1813
+ for (const node of dom.messages.querySelectorAll('.msg')) node.remove();
1814
+ for (const msg of state.messages) msg.el = null;
1815
+ const hasMessages = state.messages.length > 0;
1816
+ dom.empty.hidden = hasMessages;
1817
+ for (const msg of state.messages) dom.messages.appendChild(buildMsgEl(msg));
1818
+ if (hasMessages) scrollToBottom(true);
1819
+ }
1820
+
1821
+ function setBusy(busy) {
1822
+ state.streaming = busy;
1823
+ dom.send.disabled = busy || !state.workDir;
1824
+ dom.stop.hidden = !busy;
1825
+ }
1826
+
1827
+ /* ---------- 斜杠指令(E 组:计划与审批 / 模型与请求参数 / 会话与用量 / 工作目录 / 手册) ----------
1828
+ * 指令表与解析在 public/task-slash.js(纯逻辑,可在 node 里测);这里只做 DOM 与状态桥接。
1829
+ * 三条设计决定:
1830
+ * 1. 指令的输出**只进 DOM,不进 state.messages** —— 那是"任务对白",会被落盘、
1831
+ * 也会被下一次推理带上,把页面操作混进去等于伪造对话历史。
1832
+ * 2. 输出用 textContent(不是 innerHTML):/files 的文件名来自文件系统,不该有机会变成标签。
1833
+ * 3. 用 .msg 类,好让 renderAll()/切任务时把它一起清掉,不留残影。
1834
+ */
1835
+
1836
+ const slash = { open: false, hl: 0, items: [] };
1837
+
1838
+ function slashPrint(kind, text) {
1839
+ const el = document.createElement('div');
1840
+ el.className = 'msg slash-msg ' + kind;
1841
+ const box = document.createElement('div');
1842
+ box.className = 'slash-out';
1843
+ box.textContent = String(text);
1844
+ el.appendChild(box);
1845
+ dom.messages.appendChild(el);
1846
+ dom.empty.hidden = true;
1847
+ scrollToBottom(true);
1848
+ }
1849
+
1850
+ const slashOpts = () => ({ surface: 'hub', hasFileSystem: Boolean(state.config && state.config.fsEnabled) });
1851
+
1852
+ /**
1853
+ * 输入框里是否是一个"正在打指令"的前缀(`/pla`)—— 注意 `/plan 描述` 不再算前缀。
1854
+ * 裸 `/`(还没打名字)返回空串:面板据此列出**全部**指令 —— 这就是"指令看起来不全"的那个毛病,
1855
+ * 以前这里要求 `/` 后至少一个字母,于是敲 `/` 什么都不弹(见 docs/20260918-迭代计划-任务页斜杠指令与操作手册页.md)。
1856
+ */
1857
+ function slashPrefix() {
1858
+ const m = String(dom.input.value || '').match(/^\s*\/([A-Za-z][A-Za-z0-9_-]*)?$/);
1859
+ return m ? (m[1] || '') : null;
1860
+ }
1861
+
1862
+ function slashMenuClose() {
1863
+ slash.open = false;
1864
+ slash.items = [];
1865
+ slash.hl = 0;
1866
+ dom.slashMenu.hidden = true;
1867
+ dom.slashMenu.innerHTML = '';
1868
+ }
1869
+
1870
+ function slashMenuRender() {
1871
+ dom.slashMenu.innerHTML = '';
1872
+ let group = null;
1873
+ slash.items.forEach((c, i) => {
1874
+ if (c.group !== group) {
1875
+ group = c.group;
1876
+ const g = document.createElement('div');
1877
+ g.className = 'slash-menu-group';
1878
+ g.textContent = c.group;
1879
+ dom.slashMenu.appendChild(g);
1880
+ }
1881
+ const row = document.createElement('div');
1882
+ row.className = 'slash-menu-item' + (i === slash.hl ? ' hl' : '');
1883
+ row.dataset.idx = String(i);
1884
+ const name = document.createElement('span');
1885
+ name.className = 'slash-name';
1886
+ name.textContent = TaskSlash.usageLine(c);
1887
+ const desc = document.createElement('span');
1888
+ desc.className = 'slash-desc';
1889
+ desc.textContent = c.help;
1890
+ row.append(name, desc);
1891
+ dom.slashMenu.appendChild(row);
1892
+ });
1893
+ const foot = document.createElement('div');
1894
+ foot.className = 'slash-menu-foot';
1895
+ foot.textContent = '↑↓ 选择 · Tab 补全 · Enter 执行 · Esc 关闭';
1896
+ dom.slashMenu.appendChild(foot);
1897
+ dom.slashMenu.hidden = false;
1898
+ }
1899
+
1900
+ function slashMenuSync() {
1901
+ const prefix = slashPrefix();
1902
+ if (prefix == null) return slashMenuClose();
1903
+ const items = TaskSlash.suggest(prefix, slashOpts());
1904
+ if (!items.length) return slashMenuClose();
1905
+ slash.items = items;
1906
+ if (slash.hl >= items.length) slash.hl = 0;
1907
+ slash.open = true;
1908
+ slashMenuRender();
1909
+ }
1910
+
1911
+ function slashMenuMove(delta) {
1912
+ if (!slash.open) return;
1913
+ slash.hl = (slash.hl + delta + slash.items.length) % slash.items.length;
1914
+ slashMenuRender();
1915
+ }
1916
+
1917
+ /** Tab:把高亮项补全到输入框(**只补全不执行** —— 与网关对话页、CLI 的前缀口径一致) */
1918
+ function slashMenuPick() {
1919
+ const c = slash.items[slash.hl];
1920
+ if (!c) return;
1921
+ dom.input.value = '/' + c.name + (c.args && c.args !== '—' ? ' ' : '');
1922
+ autoGrow();
1923
+ slashMenuClose();
1924
+ dom.input.focus();
1925
+ }
1926
+
1927
+ const joinPath = (base, sub) => String(base).replace(/[\\/]+$/, '') + '/' + String(sub).replace(/^[\\/]+/, '');
1928
+
1929
+ function slashCtx() {
1930
+ return {
1931
+ // 页面上的 setMode() 自己会兜非法值;指令层要的是"能不能告诉我切没切成",所以这里显式校验并回话
1932
+ setMode: (m) => {
1933
+ const key = String(m || '').toLowerCase();
1934
+ if (!MODES.includes(key)) throw new Error('模式只能是 manual / auto / plan(收到 ' + m + ')');
1935
+ setMode(key);
1936
+ return MODE_LABELS[key];
1937
+ },
1938
+ mode: () => state.mode,
1939
+ send: (text) => send(text),
1940
+ busy: () => state.streaming,
1941
+ pending: () => (findPendingMessage() || {}).pending || null,
1942
+ decide: (approved) => decideApproval(approved),
1943
+ workDir: () => state.workDir,
1944
+ hasFileSystem: () => Boolean(state.config && state.config.fsEnabled),
1945
+ print: slashPrint,
1946
+ stop: () => abortIfStreaming(),
1947
+
1948
+ model: () => state.settings.model || (state.config && state.config.model) || '',
1949
+ models: () => state.models.slice(),
1950
+ setModel: async (name) => {
1951
+ const next = String(name).trim();
1952
+ state.settings.model = next;
1953
+ // 指令改的也是同一个下拉:选项不在就补一项再选,别让 select 变空
1954
+ ModelSelect.ensure(dom.setModel, next);
1955
+ await pushSettings(); // 设置住在磁盘上:与设置面板里改模型是同一条路
1956
+ },
1957
+
1958
+ // 单价口径只有一份(lib/pricing.js),所以让服务端算;拿不到就只报 token,绝不编数字
1959
+ cost: async () => {
1960
+ const u = state.usageTotal || { prompt: 0, completion: 0 };
1961
+ const model = state.settings.model || (state.config && state.config.model) || '';
1962
+ const local = `本任务累计:输入 ${u.prompt} / 输出 ${u.completion} / 合计 ${u.prompt + u.completion} tokens`;
1963
+ try {
1964
+ const q = `prompt=${u.prompt}&completion=${u.completion}&model=${encodeURIComponent(model)}`;
1965
+ const r = await api('/api/cost?' + q);
1966
+ return local + '\n' + r.text;
1967
+ } catch (e) {
1968
+ return local + '\n(费用粗估取不到:' + e.message + ';token 数是本地累计的,仍然准。)';
1969
+ }
1970
+ },
1971
+
1972
+ tasks: () => state.tasks.map((t) => ({ id: t.id, title: t.title, workDir: t.workDir, updatedAt: t.updatedAt })),
1973
+ switchTask: (id) => switchTask(id),
1974
+
1975
+ manualUrl: () => location.origin + '/manual',
1976
+ // 假 DOM(测试里)没有 window.open:这时候只回 URL,不抛
1977
+ openManual: (url) => {
1978
+ if (typeof window !== 'undefined' && typeof window.open === 'function') {
1979
+ window.open(url, '_blank', 'noopener');
1980
+ }
1981
+ },
1982
+
1983
+ listFiles: async (sub) => {
1984
+ if (!state.workDir) throw new Error('还没有选择工作目录');
1985
+ const target = sub ? joinPath(state.workDir, sub) : state.workDir;
1986
+ const data = await api('/api/fs/list?path=' + encodeURIComponent(target));
1987
+ const dirs = data.entries.filter((e) => e.type === 'dir');
1988
+ const files = data.entries.filter((e) => e.type !== 'dir');
1989
+ const rows = dirs.map((e) => e.name + '/').concat(files.map((e) => e.name + ' ' + fmtBytes(e.size)));
1990
+ const head = data.path + (data.truncated ? '(条目过多,只列前 2000 项)' : '');
1991
+ if (!rows.length) return head + '\n(空目录)';
1992
+ return head + '\n' + rows.join('\n');
1993
+ },
1994
+
1995
+ switchDir: async (p) => {
1996
+ // 先让服务端确认这是个能读的目录(顺带拿到绝对路径),再切 —— 切完发现不存在就尴尬了
1997
+ const data = await api('/api/fs/list?path=' + encodeURIComponent(p));
1998
+ setWorkDir(data.path);
1999
+ return data.path;
2000
+ },
2001
+
2002
+ init: async (force) => {
2003
+ if (!state.workDir) throw new Error('还没有选择工作目录');
2004
+ return api('/api/init', { method: 'POST', body: JSON.stringify({ workDir: state.workDir, force: Boolean(force) }) });
2005
+ },
2006
+
2007
+ instructions: async () => {
2008
+ if (!state.workDir) throw new Error('还没有选择工作目录');
2009
+ const m = await api('/api/memory?path=' + encodeURIComponent(state.workDir));
2010
+ const head = '工作目录:' + m.workDir + '\n注入上限:' + m.limit + ' 字符(超出截断)';
2011
+ if (!m.hasAny) return head + '\n这里还没有 AGENTS.md / CLAUDE.md。用 /init 生成一份骨架。';
2012
+ const parts = [head];
2013
+ for (const f of m.files) {
2014
+ if (!f.exists) {
2015
+ parts.push(f.name + ':不存在');
2016
+ continue;
2017
+ }
2018
+ parts.push(f.name + ':' + f.bytes + ' 字节' + (f.truncated ? '(超上限,注入时截断)' : '') + '\n' + f.content);
2019
+ }
2020
+ if (m.files.filter((f) => f.exists).length > 1) parts.push('(两份都存在时都会注入,AGENTS.md 在前)');
2021
+ return parts.join('\n\n');
2022
+ },
2023
+ };
2024
+ }
2025
+
2026
+ /* ---------- 工作目录 ---------- */
2027
+
2028
+ let rootsCache = [];
2029
+ let currentDir = null;
2030
+ let selectedDir = null;
2031
+
2032
+ /**
2033
+ * 设置当前工作目录。工作目录是任务的元信息,服务端存在索引里,所以切换任务时
2034
+ * 靠列表接口就能立刻显示,不必等整条任务内容读出来。
2035
+ */
2036
+ function setWorkDir(dir, persist = true) {
2037
+ // 选了一模一样的目录不算一次修改:时间戳别动,也别白写一次盘
2038
+ const changed = dir !== state.workDir;
2039
+ state.workDir = dir;
2040
+ if (persist) {
2041
+ if (state.activeId) {
2042
+ if (changed) {
2043
+ const t = state.tasks.find((x) => x.id === state.activeId);
2044
+ if (t) {
2045
+ t.workDir = dir;
2046
+ t.updatedAt = Date.now();
2047
+ }
2048
+ // 落到磁盘(异步,失败会在状态栏报出来)
2049
+ persistActive();
2050
+ }
2051
+ } else {
2052
+ state.draftWorkDir = dir;
2053
+ saveUiState();
2054
+ }
2055
+ renderTasks();
2056
+ }
2057
+ dom.workdirPath.textContent = dir || '未选择';
2058
+ dom.workdirChip.title = dir ? `点击更换工作目录:${dir}` : '点击选择工作目录';
2059
+ const ready = Boolean(dir);
2060
+ dom.input.disabled = !ready;
2061
+ renderInputHint();
2062
+ dom.send.disabled = !ready;
2063
+ dom.emptyTitle.textContent = ready ? '描述你的任务' : '先选择一个工作目录';
2064
+ dom.emptyDesc.innerHTML = ready
2065
+ ? '模型会先看目录结构再动手,写入文件前会先征求你的批准。'
2066
+ : '任务模式下模型会通过工具<b>真正读写</b>你选定的目录:可列目录、读文件、搜索内容;写入文件前会先征求你的批准。所有操作都被限制在该目录之内。';
2067
+ dom.chipPick.hidden = ready; // 已选目录后这个按钮就没意义了(换目录点顶部那根路径条)
2068
+ if (!ready) setStatus('等待选择工作目录');
2069
+ }
2070
+
2071
+ async function loadRoots() {
2072
+ try {
2073
+ const resp = await fetch('/api/fs/roots');
2074
+ const data = await resp.json();
2075
+ if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
2076
+ rootsCache = data.roots || [];
2077
+ dom.dirNote.textContent = '双击目录进入,选中后点「选为工作目录」';
2078
+ } catch (e) {
2079
+ rootsCache = [];
2080
+ dom.dirNote.textContent = `无法读取根目录:${e.message}`;
2081
+ }
2082
+ dom.dirRoots.innerHTML = '';
2083
+ for (const root of rootsCache) {
2084
+ const btn = document.createElement('button');
2085
+ btn.type = 'button';
2086
+ btn.className = 'dir-root';
2087
+ btn.textContent = root.label;
2088
+ btn.title = root.path;
2089
+ btn.addEventListener('click', () => loadDir(root.path));
2090
+ dom.dirRoots.appendChild(btn);
2091
+ }
2092
+ return rootsCache;
2093
+ }
2094
+
2095
+ async function loadDir(p) {
2096
+ dom.dirList.innerHTML = '<div class="dir-empty">读取中…</div>';
2097
+ try {
2098
+ const resp = await fetch(`/api/fs/list?path=${encodeURIComponent(p)}`);
2099
+ const data = await resp.json();
2100
+ if (!resp.ok) {
2101
+ dom.dirList.innerHTML = '';
2102
+ dom.dirNote.textContent = data.error || `读取失败(HTTP ${resp.status})`;
2103
+ return;
2104
+ }
2105
+ currentDir = data.path;
2106
+ selectedDir = null;
2107
+ dom.dirPathInput.value = data.path;
2108
+ dom.dirUp.disabled = !data.parent;
2109
+ dom.dirNote.textContent = data.truncated ? '该目录条目过多,只显示前 2000 项' : '双击目录进入,选中后点「选为工作目录」';
2110
+ renderDirList(data);
2111
+ } catch (e) {
2112
+ dom.dirList.innerHTML = '';
2113
+ dom.dirNote.textContent = `读取失败:${e.message}`;
2114
+ }
2115
+ }
2116
+
2117
+ function renderDirList(data) {
2118
+ dom.dirList.innerHTML = '';
2119
+ const dirs = data.entries.filter((e) => e.type === 'dir');
2120
+ const files = data.entries.filter((e) => e.type !== 'dir');
2121
+
2122
+ if (!dirs.length && !files.length) {
2123
+ dom.dirList.innerHTML = '<div class="dir-empty">(空目录)</div>';
2124
+ return;
2125
+ }
2126
+
2127
+ for (const entry of dirs) {
2128
+ const row = document.createElement('div');
2129
+ row.className = 'dir-row dir';
2130
+ row.tabIndex = 0;
2131
+ const icon = document.createElement('span');
2132
+ icon.className = 'dir-icon';
2133
+ icon.textContent = '📁';
2134
+ const name = document.createElement('span');
2135
+ name.className = 'dir-name';
2136
+ name.textContent = entry.name;
2137
+ row.append(icon, name);
2138
+
2139
+ row.addEventListener('click', () => {
2140
+ for (const el of dom.dirList.querySelectorAll('.dir-row.selected')) el.classList.remove('selected');
2141
+ row.classList.add('selected');
2142
+ selectedDir = `${data.path.replace(/[\\/]+$/, '')}${data.path.includes('\\') ? '\\' : '/'}${entry.name}`;
2143
+ });
2144
+ row.addEventListener('dblclick', () => loadDir(`${data.path.replace(/[\\/]+$/, '')}${data.path.includes('\\') ? '\\' : '/'}${entry.name}`));
2145
+ row.addEventListener('keydown', (e) => {
2146
+ if (e.key === 'Enter') loadDir(`${data.path.replace(/[\\/]+$/, '')}${data.path.includes('\\') ? '\\' : '/'}${entry.name}`);
2147
+ });
2148
+ dom.dirList.appendChild(row);
2149
+ }
2150
+
2151
+ // 文件只作参考展示,不能选为工作目录
2152
+ const shown = files.slice(0, 200);
2153
+ for (const entry of shown) {
2154
+ const row = document.createElement('div');
2155
+ row.className = 'dir-row file';
2156
+ const icon = document.createElement('span');
2157
+ icon.className = 'dir-icon';
2158
+ icon.textContent = '📄';
2159
+ const name = document.createElement('span');
2160
+ name.className = 'dir-name';
2161
+ name.textContent = entry.name;
2162
+ const size = document.createElement('span');
2163
+ size.className = 'dir-size';
2164
+ size.textContent = fmtBytes(entry.size || 0);
2165
+ row.append(icon, name, size);
2166
+ dom.dirList.appendChild(row);
2167
+ }
2168
+ if (files.length > shown.length) {
2169
+ const more = document.createElement('div');
2170
+ more.className = 'dir-empty';
2171
+ more.textContent = `…另有 ${files.length - shown.length} 个文件未显示`;
2172
+ dom.dirList.appendChild(more);
2173
+ }
2174
+ }
2175
+
2176
+ async function openDirModal() {
2177
+ dom.dirModal.hidden = false;
2178
+ const roots = await loadRoots();
2179
+ const initial = state.workDir || roots[0]?.path;
2180
+ if (initial) await loadDir(initial);
2181
+ else dom.dirList.innerHTML = '<div class="dir-empty">没有可用的根目录</div>';
2182
+ }
2183
+
2184
+ function closeDirModal() {
2185
+ dom.dirModal.hidden = true;
2186
+ }
2187
+
2188
+ /* ---------- 任务执行 ---------- */
2189
+
2190
+ function applyUsage(usage) {
2191
+ if (!usage) return;
2192
+ const p = usage.prompt_tokens ?? usage.prompt;
2193
+ const c = usage.completion_tokens ?? usage.completion;
2194
+ if (Number.isFinite(p)) state.usageTotal.prompt += p;
2195
+ if (Number.isFinite(c)) state.usageTotal.completion += c;
2196
+ setInfo(dom.infoUsage, `输入 ${state.usageTotal.prompt} / 输出 ${state.usageTotal.completion}`);
2197
+ }
2198
+
2199
+ function handleEvent(evt, msg) {
2200
+ switch (evt.type) {
2201
+ case 'started': {
2202
+ setStatus(`任务已开始 · ${MODE_LABELS[normalizeMode(evt.mode)]}模式 · 工作目录 ${evt.workDir}`);
2203
+ // 存储目录若在工作目录之内,模型的文件工具就能读到任务历史,必须提醒
2204
+ const sess = evt.session || {};
2205
+ if (evt.storeExposed && !state.storeExposed) {
2206
+ state.storeExposed = true;
2207
+ setStatus('注意:任务存储目录在工作目录之内,模型可以读到任务历史记录', 'err');
2208
+ setDot('warn');
2209
+ } else if (sess.compacted > 0) {
2210
+ // 上下文记忆的状态:更早的工具结果被折叠成一行了。用户该知道「它现在还记得什么」,
2211
+ // 否则会以为模型忘了、白问一遍
2212
+ setStatus(`上下文已压缩:${sess.compacted} 条较早的工具结果只保留摘要(最近几轮仍是全文)`);
2213
+ }
2214
+ // 这条任务已有落盘的计划:明说本次是「按计划执行」,别让用户以为模型在自由发挥
2215
+ if (evt.planFile) {
2216
+ msg.planFile = evt.planFile;
2217
+ msg.notices = msg.notices || [];
2218
+ msg.notices.push(`本次依据已落盘的计划执行:${evt.planFile}`);
2219
+ }
2220
+ break;
2221
+ }
2222
+ case 'reasoning':
2223
+ msg.reasoning += evt.text;
2224
+ if (!msg.content) setStatus(`思考中…(${msg.reasoning.length} 字)`);
2225
+ schedulePaint(msg);
2226
+ break;
2227
+ case 'delta':
2228
+ msg.content += evt.text;
2229
+ schedulePaint(msg);
2230
+ break;
2231
+ case 'assistant_text_end':
2232
+ break;
2233
+ case 'tool_call':
2234
+ // 这一轮生成结束、开始执行工具 —— 交给步骤自己的转圈来表示「在工作」
2235
+ msg.working = false;
2236
+ msg.steps.push({ id: evt.id, name: evt.name, label: evt.label, args: evt.args, status: 'running' });
2237
+ setStatus(`调用工具:${evt.label}`);
2238
+ setDot('warn');
2239
+ schedulePaint(msg, true);
2240
+ break;
2241
+ case 'tool_result': {
2242
+ let step = msg.steps.find((s) => s.id === evt.id);
2243
+ if (!step) {
2244
+ step = { id: evt.id, name: evt.name, label: evt.name, status: 'running' };
2245
+ msg.steps.push(step);
2246
+ } step.status = evt.ok ? 'ok' : 'fail';
2247
+ step.summary = evt.summary;
2248
+ step.content = evt.content;
2249
+ if (evt.blocked === 'plan') step.note = '计划模式:只读,未执行写入';
2250
+ else if (evt.auto) step.note = '自动模式:已直接写入';
2251
+ else if (evt.approved === false) step.summary = '你拒绝了这次写入';
2252
+ setStatus(evt.summary || '');
2253
+ schedulePaint(msg, true);
2254
+ break;
2255
+ }
2256
+ case 'approval_required':
2257
+ msg.pending = {
2258
+ runId: evt.runId,
2259
+ callId: evt.callId,
2260
+ name: evt.name,
2261
+ label: evt.label,
2262
+ preview: evt.preview,
2263
+ note: evt.note,
2264
+ };
2265
+ setStatus('等待你批准写入(挂起态会落盘,刷新页面也不会丢)');
2266
+ schedulePaint(msg, true);
2267
+ break;
2268
+ case 'usage':
2269
+ applyUsage(evt.usage);
2270
+ break;
2271
+ case 'usage_total':
2272
+ if (evt.usage) {
2273
+ state.usageTotal.prompt = evt.usage.prompt ?? state.usageTotal.prompt;
2274
+ state.usageTotal.completion = evt.usage.completion ?? state.usageTotal.completion;
2275
+ setInfo(dom.infoUsage, `输入 ${state.usageTotal.prompt} / 输出 ${state.usageTotal.completion}`);
2276
+ }
2277
+ break;
2278
+ case 'turn_start':
2279
+ // 用户只需要知道「进行中 / 已完成」,不啰嗦第几轮
2280
+ msg.working = true;
2281
+ setStatus(evt.wrapUp ? '正在整理结论…' : '进行中');
2282
+ setDot('warn');
2283
+ schedulePaint(msg, true);
2284
+ break;
2285
+ case 'plan_saved':
2286
+ // 计划模式的产物已经写进工作目录的 docs/ 了:路径是这次运行的硬事实,
2287
+ // 直接显示出来,用户要能自己去打开那份文档
2288
+ msg.planFile = evt.path || msg.planFile;
2289
+ msg.notices = msg.notices || [];
2290
+ msg.notices.push(
2291
+ `${evt.action === 'updated' ? '计划已更新' : '计划已保存'}:${evt.path}${evt.action === 'updated' ? '(同名文件追加为本日更新)' : ''}`,
2292
+ );
2293
+ schedulePaint(msg, true);
2294
+ break;
2295
+ case 'notice':
2296
+ // 以前是「已经有正文就丢掉」,结果「已达上限而停止」这类关键提示在最需要的时候被吞掉,
2297
+ // 页面就真的没有任何下文了。现在一律记下来单独渲染成横幅。
2298
+ msg.notices = msg.notices || [];
2299
+ msg.notices.push(evt.text);
2300
+ if (!msg.content) setStatus(evt.text, 'err');
2301
+ schedulePaint(msg, true);
2302
+ break;
2303
+ case 'error':
2304
+ msg.working = false;
2305
+ // 存在消息上而不是全局:多个任务可能同时在跑,全局的会串台
2306
+ msg.streamError = evt.message || '未知错误';
2307
+ break;
2308
+ case 'done':
2309
+ msg.sawDone = true;
2310
+ msg.working = false;
2311
+ // 计划模式跑完 = 计划已给出来,给一个「按计划执行」的一次性入口
2312
+ if (evt.planReady && msg.content) {
2313
+ msg.planReady = true;
2314
+ if (evt.planFile) msg.planFile = evt.planFile;
2315
+ setStatus(
2316
+ msg.planFile
2317
+ ? `计划已就绪并保存到 ${msg.planFile}:确认后点「按计划执行」开始动手`
2318
+ : '计划已就绪:确认后点「按计划执行」开始动手',
2319
+ '',
2320
+ );
2321
+ schedulePaint(msg, true);
2322
+ }
2323
+ // 到上限而停止必须说出来,否则就是「读了一堆然后没了」
2324
+ if (evt.hitLimit) {
2325
+ const cap = state.config?.limits?.maxSteps ?? '?';
2326
+ msg.notices = msg.notices || [];
2327
+ if (!msg.notices.some((t) => t.includes('上限'))) {
2328
+ msg.notices.push(`已达上限(${cap} 轮),可继续执行。`);
2329
+ }
2330
+ // 记下来,别让 send() 收尾时的「就绪」把它盖掉(同 init() 里踩过的那个坑)
2331
+ msg.endStatus = { text: `已达上限(${cap} 轮),可继续执行`, kind: 'err' };
2332
+ // 活干了一半就该能接着干,不该让用户自己再描述一遍
2333
+ msg.canResume = true;
2334
+ setStatus(msg.endStatus.text, 'err');
2335
+ setDot('warn');
2336
+ schedulePaint(msg, true);
2337
+ } else if (!msg.content && !msg.pending && !(msg.notices || []).length && !msg.streamError) {
2338
+ // 兜底:既没正文、没待批准、也没任何提示 —— 不能让用户面对一个静默的空回复
2339
+ msg.notices = [`模型没有返回内容,可重试或调大 max tokens。`];
2340
+ msg.endStatus = { text: '模型没有返回内容', kind: 'err' };
2341
+ setStatus('模型没有返回内容', 'err');
2342
+ schedulePaint(msg, true);
2343
+ }
2344
+ break;
2345
+ default:
2346
+ break;
2347
+ }
2348
+ }
2349
+
2350
+ async function consume(resp, msg) {
2351
+ const reader = resp.body.getReader();
2352
+ const decoder = new TextDecoder();
2353
+ let buf = '';
2354
+ for (;;) {
2355
+ const { done, value } = await reader.read();
2356
+ if (done) break;
2357
+ buf += decoder.decode(value, { stream: true });
2358
+ const parts = buf.split('\n\n');
2359
+ buf = parts.pop() ?? '';
2360
+ for (const part of parts) {
2361
+ const line = part.split('\n').find((l) => l.startsWith('data:'));
2362
+ if (!line) continue;
2363
+ let evt;
2364
+ try {
2365
+ evt = JSON.parse(line.slice(5).trim());
2366
+ } catch {
2367
+ continue;
2368
+ }
2369
+ handleEvent(evt, msg);
2370
+ }
2371
+ }
2372
+ }
2373
+
2374
+ /**
2375
+ * 跑一次任务流。taskId 在开始时锁定 —— 跑的过程中用户可以切走,
2376
+ * 那时这一轮要继续在后台跑完并写回该任务自己的缓存,而不是跟着 state.activeId 跑偏。
2377
+ */
2378
+ async function runStream(url, payload, msg, taskId = state.activeId) {
2379
+ const controller = new AbortController();
2380
+ if (taskId) state.runs.set(taskId, controller);
2381
+ msg.canResume = false;
2382
+ msg.streamError = null;
2383
+ const isActive = () => taskId === state.activeId;
2384
+ // 先把「谁在跑」同步出去,再重绘:否则侧边栏不会显示进行中
2385
+ syncRunState();
2386
+ if (isActive()) setStatus('进行中');
2387
+ renderTasks();
2388
+ try {
2389
+ const resp = await fetch(url, {
2390
+ method: 'POST',
2391
+ headers: { 'content-type': 'application/json' },
2392
+ body: JSON.stringify(payload),
2393
+ signal: controller.signal,
2394
+ });
2395
+ if (!resp.ok) {
2396
+ const data = await resp.json().catch(() => ({}));
2397
+ // 缺密钥是「还没配」而不是「配错了」:直接把设置面板打开并聚焦到密钥框,
2398
+ // 用户不用自己找入口,也不会以为服务坏了
2399
+ if (data.needsKey) {
2400
+ dom.settings.classList.add('open');
2401
+ const keyInput = state.configControls?.key;
2402
+ if (keyInput) {
2403
+ keyInput.focus();
2404
+ if (typeof keyInput.scrollIntoView === 'function') keyInput.scrollIntoView({ block: 'center' });
2405
+ }
2406
+ }
2407
+ throw new Error(data.error || `HTTP ${resp.status}`);
2408
+ }
2409
+ await consume(resp, msg);
2410
+ // 流在 done 之前就断了(服务重启 / 网络中断):不能既留着一个永远转的「工作中」,
2411
+ // 又显示「就绪」—— 那等于骗用户说一切正常。
2412
+ if (!msg.sawDone && !msg.pending) {
2413
+ msg.working = false;
2414
+ msg.notices = msg.notices || [];
2415
+ msg.notices.push('连接中断,结果可能不完整,建议重试。');
2416
+ msg.endStatus = { text: '连接中断', kind: 'err' };
2417
+ paintIfActive(msg, isActive);
2418
+ }
2419
+ if (msg.streamError) {
2420
+ const m = msg.streamError;
2421
+ msg.streamError = null;
2422
+ throw new Error(m);
2423
+ }
2424
+ if (isActive()) {
2425
+ if (msg.pending) {
2426
+ setStatus('等待你批准写入', '');
2427
+ setDot('warn');
2428
+ } else if (msg.endStatus) {
2429
+ // 这一轮有需要用户知道的结果(触顶 / 空回复),不要用「就绪」盖掉
2430
+ setStatus(msg.endStatus.text, msg.endStatus.kind);
2431
+ setDot(msg.endStatus.kind === 'err' ? 'warn' : 'ok');
2432
+ } else {
2433
+ setStatus('就绪', 'ok');
2434
+ setDot('ok');
2435
+ }
2436
+ }
2437
+ } catch (e) {
2438
+ const stoppedByUser = e?.name === 'AbortError';
2439
+ if (stoppedByUser) {
2440
+ if (!msg.content && !msg.steps.length) msg.error = '(已停止)';
2441
+ else if (!msg.content) msg.content = '_(已停止)_';
2442
+ if (isActive()) setStatus('已停止', '');
2443
+ } else {
2444
+ const message = e?.message || String(e);
2445
+ if (msg.content || msg.steps.length) msg.content += `\n\n> ⚠️ 中断:${message}`;
2446
+ else msg.error = `任务失败:${message}`;
2447
+ if (isActive()) {
2448
+ setStatus(`失败:${message}`, 'err');
2449
+ setDot('err');
2450
+ }
2451
+ }
2452
+ } finally {
2453
+ msg.streaming = false;
2454
+ if (taskId) state.runs.delete(taskId);
2455
+ syncRunState();
2456
+ if (isActive()) {
2457
+ paintBubble(msg);
2458
+ scrollToBottom();
2459
+ }
2460
+ // 跑完把这一条任务存好 —— 存的是它自己的历史,不是「当前任务」的
2461
+ persistTask(taskId, taskId === state.activeId ? state.messages : state.cache.get(taskId)?.messages);
2462
+ renderTasks();
2463
+ if (isActive()) dom.input.focus();
2464
+ }
2465
+ }
2466
+
2467
+ /** 只在该任务正被显示时才重绘(后台任务的 el 是 null,重绘无意义) */
2468
+ function paintIfActive(msg, isActive) {
2469
+ if (isActive()) paintBubble(msg);
2470
+ }
2471
+
2472
+ function findPendingMessage() {
2473
+ for (let i = state.messages.length - 1; i >= 0; i--) if (state.messages[i].pending) return state.messages[i];
2474
+ return null;
2475
+ }
2476
+
2477
+ async function send(text) {
2478
+ const content = text.trim();
2479
+ if (!content || state.streaming) return;
2480
+ if (!state.workDir) {
2481
+ setStatus('请先选择工作目录', 'err');
2482
+ openDirModal();
2483
+ return;
2484
+ }
2485
+ if (findPendingMessage()) {
2486
+ setStatus('请先处理上面待批准的写入', 'err');
2487
+ return;
2488
+ }
2489
+
2490
+ // 草稿态在首条指令时落成一条真实任务(避免留下一堆空任务)
2491
+ const wasDraft = !state.activeId;
2492
+ const created = await ensureTask();
2493
+ if (created === null) return; // 服务端建不出来(存储不可写等),状态栏已经报过原因了
2494
+ if (wasDraft) renderTasks();
2495
+
2496
+ const userMsg = { role: 'user', content };
2497
+ state.messages.push(userMsg);
2498
+ appendMessage(userMsg);
2499
+
2500
+ const assistant = { role: 'assistant', content: '', reasoning: '', steps: [], stepOpen: new Set(), pending: null, notices: [], canResume: false, streaming: true };
2501
+ state.messages.push(assistant);
2502
+ appendMessage(assistant);
2503
+
2504
+ const payload = {
2505
+ workDir: state.workDir,
2506
+ taskId: state.activeId,
2507
+ mode: state.mode,
2508
+ // 只发这一轮的指令 —— 对话历史(含工具调用与工具结果)由服务端持有。
2509
+ // 之前这里回传的是全量「对白」,而工具结果在下面的 map 里就被剥掉了,
2510
+ // 于是模型每轮都得重新 list_dir / grep 才知道文件里写了什么。
2511
+ prompt: content,
2512
+ model: state.settings.model || undefined,
2513
+ system: state.settings.system || undefined,
2514
+ };
2515
+ if (Number.isFinite(state.settings.temperature)) payload.temperature = state.settings.temperature;
2516
+ if (state.settings.maxTokens > 0) payload.maxTokens = state.settings.maxTokens;
2517
+
2518
+ // 锁住这条任务:跑的过程中用户可能切走,那一轮要继续写回这条任务自己的缓存
2519
+ await runStream('/api/task', payload, assistant, state.activeId);
2520
+ }
2521
+
2522
+ async function decideApproval(approved) {
2523
+ const msg = findPendingMessage();
2524
+ if (!msg || state.streaming) return;
2525
+ const pending = msg.pending;
2526
+ msg.pending = null;
2527
+ msg.stepsSig = null;
2528
+ paintBubble(msg);
2529
+ await runStream('/api/task/approve', { runId: pending.runId, callId: pending.callId, approved }, msg, state.activeId);
2530
+ }
2531
+
2532
+ /* ---------- 其他配置:按服务端白名单渲染 ---------- */
2533
+
2534
+ /**
2535
+ * 面板里这四项是手写的,不放进「其他配置」:
2536
+ * 模型要 datalist 联想、附加要求要 textarea、温度/最大 tokens 是每次请求的参数(0 = 不限制)。
2537
+ * 白名单里**其余全部键**都由 `/api/settings` 的 `rows` 驱动渲染 —— 服务端加键,页面自动就有,
2538
+ * 不会再出现「配置里有、设置页面没地方改」这种缺口(lib/settings.js 是唯一白名单)。
2539
+ */
2540
+ const HANDLED_SETTING_KEYS = new Set(['model', 'system', 'temperature', 'maxTokens']);
2541
+
2542
+ const SOURCE_LABELS = { default: '默认', file: '配置文件', env: '环境变量', flag: '启动参数' };
2543
+
2544
+ /** 点号路径取值:'history.maxChars' → values.history.maxChars */
2545
+ function pathGet(obj, key) {
2546
+ return String(key)
2547
+ .split('.')
2548
+ .reduce((cur, part) => (cur === null || typeof cur !== 'object' ? undefined : cur[part]), obj);
2549
+ }
2550
+
2551
+ function cfgValueText(v) {
2552
+ if (v === null || v === undefined) return '';
2553
+ if (typeof v === 'boolean') return v ? 'true' : 'false';
2554
+ return String(v);
2555
+ }
2556
+
2557
+ /** 控件里的值 → 落盘值;返回 undefined 表示「这个键不该发」(空值 + 不可空) */
2558
+ function readExtraValue(row, control) {
2559
+ const type = String(row.type || 'string');
2560
+ const nullable = type.endsWith('?');
2561
+ if (type === 'bool') return Boolean(control.checked);
2562
+ if (type === 'enum') return String(control.value);
2563
+ // 密钥:留空 = 不改动(**不是**清空)。清空是破坏性动作,只在命令行 unset key 里做,
2564
+ // 免得用户点一下别的控件顺手把密钥抹了。
2565
+ if (type === 'secret') {
2566
+ const raw = String(control.value ?? '').trim();
2567
+ return raw ? raw : undefined;
2568
+ }
2569
+ if (type === 'int' || type === 'int?' || type === 'num?') {
2570
+ const raw = String(control.value ?? '').trim();
2571
+ if (!raw) return nullable ? null : undefined; // 非空数字留空 = 不动它(服务端也会拒空)
2572
+ const n = Number(raw);
2573
+ if (!Number.isFinite(n)) return undefined;
2574
+ return type === 'num?' ? n : Math.floor(n);
2575
+ }
2576
+ const raw = String(control.value ?? '').trim();
2577
+ if (!raw) return nullable ? null : undefined; // 可空字符串留空 = 显式清掉(null)
2578
+ return raw;
2579
+ }
2580
+
2581
+ /** 服务端的值 → 控件(改完 / 渲染时回填) */
2582
+ function fillExtraControl(row, value) {
2583
+ const control = state.configControls[row.key];
2584
+ if (!control) return;
2585
+ const type = String(row.type || '');
2586
+ if (type === 'bool') control.checked = value === true;
2587
+ // 密钥:清空输入框,绝不回填明文(服务端也不下发明文)
2588
+ else if (type === 'secret') control.value = '';
2589
+ else control.value = cfgValueText(value);
2590
+ }
2591
+
2592
+ /** 徽标:值从哪来(default 不用标)、要不要重启才生效 */
2593
+ function cfgBadges(row) {
2594
+ const wrap = document.createElement('span');
2595
+ wrap.className = 'cfg-badges';
2596
+ // 密钥的来源与配置文件无关(它住在 credentials.json),所以文案单独一套:
2597
+ // 否则会显示成「配置文件」,用户会去 config.json 里找一个根本不存在的字段
2598
+ if (row.secret) {
2599
+ const b = document.createElement('span');
2600
+ b.className = `cfg-badge source-${row.source || 'default'}`;
2601
+ b.textContent = row.hasKey ? `已设置 · ${row.sourceText || '密钥文件'}` : '未配置';
2602
+ b.title = row.hasKey
2603
+ ? `密钥来自${row.sourceText || '密钥文件'}${row.file ? `:${row.file}` : ''}(密钥不写进 config.json)`
2604
+ : '还没有可用的密钥:填一次就能用,不用手写 .env';
2605
+ wrap.appendChild(b);
2606
+ if (row.file) {
2607
+ const f = document.createElement('span');
2608
+ f.className = 'cfg-badge';
2609
+ f.textContent = 'credentials.json';
2610
+ f.title = row.file;
2611
+ wrap.appendChild(f);
2612
+ }
2613
+ return wrap;
2614
+ }
2615
+ const src = SOURCE_LABELS[row.source];
2616
+ if (src && row.source !== 'default') {
2617
+ const b = document.createElement('span');
2618
+ b.className = `cfg-badge source-${row.source}`;
2619
+ b.textContent = src;
2620
+ b.title = row.source === 'file' ? '值来自 config.json' : `值来自${src},改配置文件不会立刻生效`;
2621
+ wrap.appendChild(b);
2622
+ }
2623
+ if (row.restart) {
2624
+ const b = document.createElement('span');
2625
+ b.className = 'cfg-badge restart';
2626
+ b.textContent = '重启生效';
2627
+ b.title = '这项在服务启动时就被读走了:写进 config.json 了,但要重启服务才生效';
2628
+ wrap.appendChild(b);
2629
+ }
2630
+ return wrap;
2631
+ }
2632
+
2633
+ /**
2634
+ * 渲染「其他配置」。`rows` 为空(服务端连不上、或响应里没有白名单)时整组隐藏 ——
2635
+ * 这些键住在磁盘上,浏览器里没有可兜底的副本,给了输入框也是假的。
2636
+ */
2637
+ function renderExtraConfig(rows, values) {
2638
+ if (Array.isArray(rows)) state.configRows = rows;
2639
+ state.configControls = {};
2640
+ const host = dom.configExtra;
2641
+ if (!host) return;
2642
+ // 只认带 type 的行:类型是服务端契约的一部分(lib/settings.js 的 listSettings)。
2643
+ // 老服务端(进程还没重启、下发的是没有 type 的 rows)宁可不渲染,也不要拿自由文本框去猜枚举和数字
2644
+ const list = (state.configRows || []).filter(
2645
+ (r) => r && r.key && typeof r.type === 'string' && !HANDLED_SETTING_KEYS.has(r.key),
2646
+ );
2647
+ if (dom.configExtraGroup) dom.configExtraGroup.hidden = list.length === 0;
2648
+ host.replaceChildren();
2649
+ if (!list.length) return;
2650
+
2651
+ for (const row of list) {
2652
+ const value = values && typeof values === 'object' ? pathGet(values, row.key) : row.value;
2653
+ const type = String(row.type || 'string');
2654
+ const nullable = type.endsWith('?');
2655
+ let field;
2656
+ let control;
2657
+
2658
+ if (type === 'bool') {
2659
+ field = document.createElement('label');
2660
+ field.className = 'check';
2661
+ control = document.createElement('input');
2662
+ control.type = 'checkbox';
2663
+ const text = document.createElement('span');
2664
+ text.textContent = `${row.desc || row.key}(${row.key})`;
2665
+ field.appendChild(control);
2666
+ field.appendChild(text);
2667
+ } else {
2668
+ field = document.createElement('label');
2669
+ field.className = 'field';
2670
+ const label = document.createElement('span');
2671
+ // 说明在前、键名在后:用户在 README / config.json 里看到的是键名,得对得上
2672
+ label.textContent = row.desc || row.key;
2673
+ const code = document.createElement('code');
2674
+ code.className = 'cfg-key';
2675
+ code.textContent = row.key;
2676
+ label.appendChild(code);
2677
+ label.appendChild(cfgBadges(row));
2678
+ field.appendChild(label);
2679
+
2680
+ if (type === 'enum') {
2681
+ control = document.createElement('select');
2682
+ for (const opt of row.values || []) {
2683
+ const o = document.createElement('option');
2684
+ o.value = String(opt);
2685
+ // mode 用界面上的中文说法,其余枚举(ui.persist)直接用原值
2686
+ o.textContent = row.key === 'mode' ? MODE_LABELS[opt] || String(opt) : String(opt);
2687
+ control.appendChild(o);
2688
+ }
2689
+ } else if (type === 'secret') {
2690
+ // 密钥:password 输入框,**永远不回填明文**(服务端也不会下发)。
2691
+ // 已配置时用 placeholder 说明现状 + 从哪来,留空 = 不改动。
2692
+ control = document.createElement('input');
2693
+ control.type = 'password';
2694
+ control.autocomplete = 'off';
2695
+ control.spellcheck = false;
2696
+ control.placeholder = row.hasKey
2697
+ ? `已设置 ${row.mask || ''}(${row.sourceText || '密钥文件'})· 留空 = 不改动`
2698
+ : '还没设置:粘贴 sk-xxx 后回车(存 credentials.json,仅本用户可读)';
2699
+ } else if (type === 'int' || type === 'int?' || type === 'num?') {
2700
+ control = document.createElement('input');
2701
+ control.type = 'number';
2702
+ if (row.min !== null && row.min !== undefined) control.setAttribute('min', String(row.min));
2703
+ if (row.max !== null && row.max !== undefined) control.setAttribute('max', String(row.max));
2704
+ control.step = type === 'num?' ? 'any' : '1';
2705
+ } else {
2706
+ control = document.createElement('input');
2707
+ control.type = 'text';
2708
+ }
2709
+ if (nullable) control.placeholder = '留空 = 不设(用默认)';
2710
+ field.appendChild(control);
2711
+ }
2712
+
2713
+ control.title = row.desc || row.key;
2714
+ // 密钥控件**不回填值**:服务端下发的 value 本来就是空串,这里也不做任何"回显"
2715
+ if (type === 'bool') control.checked = value === true;
2716
+ else if (type !== 'secret') control.value = cfgValueText(value);
2717
+ control.addEventListener('change', () => saveExtraSetting(row));
2718
+ state.configControls[row.key] = control;
2719
+ host.appendChild(field);
2720
+ }
2721
+ }
2722
+
2723
+ /**
2724
+ * 改一项就 PUT 一项。
2725
+ *
2726
+ * 不攒成「保存」按钮:设置面板其余几项也是改完即存(`readSettingsFromUI` 挂在 change 上),
2727
+ * 两种交互混在一起最容易出现「改了没生效」。失败就把控件退回服务端的值,别让它显示一个假状态。
2728
+ */
2729
+ async function saveExtraSetting(row) {
2730
+ const control = state.configControls[row.key];
2731
+ if (!control) return;
2732
+ const value = readExtraValue(row, control);
2733
+ if (value === undefined) {
2734
+ // 密钥留空是「不改动」(不是清空)——说清楚,别让用户以为自己保存失败了
2735
+ setStatus(row.type === 'secret'
2736
+ ? '密钥留空 = 不改动;想清掉已保存的密钥:gateway-agent config unset key'
2737
+ : `${row.key} 不能为空(留空会退回原值)`, row.type === 'secret' ? '' : 'err');
2738
+ fillExtraControl(row, row.value);
2739
+ return;
2740
+ }
2741
+ control.disabled = true;
2742
+ try {
2743
+ const r = await api('/api/settings', { method: 'PUT', body: JSON.stringify({ values: { [row.key]: value } }) });
2744
+ renderExtraConfig(r?.rows || state.configRows, r?.values);
2745
+ if (r?.values?.ui) state.settingsPersist = r.values.ui.persist === 'browser' ? 'browser' : 'server';
2746
+ // 这里的键会影响运行态(轮次上限 / 历史预算 / 保留策略),信息行跟着刷新
2747
+ await loadConfig();
2748
+ const restart = Array.isArray(r?.restart) ? r.restart : [];
2749
+ setStatus(r?.note || (restart.length ? `已保存 ${row.key}:重启服务后生效` : `已保存 ${row.key}`), restart.length ? '' : 'ok');
2750
+ } catch (e) {
2751
+ fillExtraControl(row, row.value);
2752
+ setStatus(`保存 ${row.key} 失败:${e.message}`, 'err');
2753
+ } finally {
2754
+ // renderExtraConfig 会整块重建控件,所以这里按「现在的那个」恢复可用态
2755
+ const cur = state.configControls[row.key];
2756
+ if (cur) cur.disabled = false;
2757
+ }
2758
+ }
2759
+
2760
+ /* ---------- 设置面板 ---------- */
2761
+
2762
+ /**
2763
+ * 设置面板宽度可拖:抓住左边框往左拉就变宽。
2764
+ *
2765
+ * 面板里最长的是那几行信息(可用工具清单、网关地址)和「其他配置」的说明 ——
2766
+ * 与其让用户盯着省略号,不如让他自己拉宽。宽度存在浏览器本地
2767
+ * (`lgw.settings.width`):它是这台设备上的显示偏好,跟配置本身无关,不占 config.json 的键。
2768
+ */
2769
+ const SETTINGS_WIDTH_KEY = 'lgw.settings.width';
2770
+
2771
+ function wireSettingsResizer() {
2772
+ const panel = dom.settings;
2773
+ if (!panel || panel.dataset.resizerWired) return; // init 可能被重复调用,只接一次
2774
+ panel.dataset.resizerWired = '1';
2775
+ const MIN = 280;
2776
+ const viewport = () => (Number.isFinite(window.innerWidth) && window.innerWidth > 0 ? window.innerWidth : 1280);
2777
+ const clamp = (w) => Math.min(Math.max(Math.round(Number(w) || 0), MIN), Math.round(viewport() * 0.92));
2778
+
2779
+ const saved = Number(readJson(SETTINGS_WIDTH_KEY, 0));
2780
+ if (Number.isFinite(saved) && saved >= MIN) panel.style.width = `${clamp(saved)}px`;
2781
+
2782
+ // 内容整体套一层滚动容器:把手留在外层,滚到「网关地址」那几行时它还在
2783
+ const body = document.createElement('div');
2784
+ body.className = 'settings-body';
2785
+ while (panel.firstChild) body.appendChild(panel.firstChild);
2786
+ panel.appendChild(body);
2787
+
2788
+ const handle = document.createElement('div');
2789
+ handle.className = 'settings-resizer';
2790
+ handle.title = '拖动调整设置面板宽度';
2791
+ panel.appendChild(handle);
2792
+
2793
+ let dragging = false;
2794
+ handle.addEventListener('pointerdown', (e) => {
2795
+ dragging = true;
2796
+ handle.classList.add('dragging');
2797
+ e.preventDefault?.();
2798
+ });
2799
+ window.addEventListener('pointermove', (e) => {
2800
+ if (!dragging) return;
2801
+ panel.style.width = `${clamp(viewport() - e.clientX)}px`;
2802
+ });
2803
+ const stop = () => {
2804
+ if (!dragging) return;
2805
+ dragging = false;
2806
+ handle.classList.remove('dragging');
2807
+ const w = parseInt(panel.style.width, 10);
2808
+ if (Number.isFinite(w)) writeJson(SETTINGS_WIDTH_KEY, clamp(w));
2809
+ };
2810
+ window.addEventListener('pointerup', stop);
2811
+ // 拖到窗口外松手 / 指针被系统取消:也要收尾,否则下次移动会继续改宽度
2812
+ window.addEventListener('pointercancel', stop);
2813
+ }
2814
+
2815
+ function applySettingsToUI() {
2816
+ // 下拉是"只可选"的:值不在选项里时要先补一项再选,否则 select.value 会变成空串
2817
+ ModelSelect.ensure(dom.setModel, state.settings.model || '');
2818
+ dom.setSystem.value = state.settings.system || '';
2819
+ dom.setTemperature.value = state.settings.temperature;
2820
+ dom.setMaxTokens.value = state.settings.maxTokens || '';
2821
+ dom.setShowReasoning.checked = state.settings.showReasoning !== false;
2822
+ }
2823
+
2824
+ function readSettingsFromUI() {
2825
+ const beforeReasoning = state.settings.showReasoning !== false;
2826
+ state.settings.model = dom.setModel.value.trim();
2827
+ state.settings.system = dom.setSystem.value;
2828
+ const temp = Number(dom.setTemperature.value);
2829
+ state.settings.temperature = Number.isFinite(temp) ? temp : DEFAULTS.temperature;
2830
+ const maxTokens = Number(dom.setMaxTokens.value);
2831
+ state.settings.maxTokens = Number.isFinite(maxTokens) && maxTokens > 0 ? Math.floor(maxTokens) : 0;
2832
+ state.settings.showReasoning = dom.setShowReasoning.checked;
2833
+ saveSettings();
2834
+ if (beforeReasoning !== state.settings.showReasoning) {
2835
+ for (const msg of state.messages) paintBubble(msg);
2836
+ }
2837
+ }
2838
+
2839
+ async function loadConfig() {
2840
+ try {
2841
+ const resp = await fetch('/api/config');
2842
+ state.config = await resp.json();
2843
+ setInfo(dom.infoBase, state.config.baseUrl);
2844
+ setInfo(dom.infoKey, state.config.hasKey ? state.config.keyMask : '未配置');
2845
+ const lim = state.config.limits || {};
2846
+ setInfo(dom.infoLimit, `${lim.maxSteps ?? '?'} 轮工具调用 · 挂起保留 ${lim.runs?.ttlMinutes ?? '?'} 分钟`);
2847
+ // 计划模式的工具集是只读的,在设置里点出来,免得用户以为工具坏了
2848
+ const names = (state.config.tools || []).map((t) => t.name);
2849
+ const toolNames = names.join(' / ');
2850
+ const hasBash = names.includes('bash');
2851
+ setInfo(dom.infoTools, toolNames || '—');
2852
+ // 「没有 bash」是任务模式最容易卡死的地方:模型改完代码却没法跑测试,于是只能说
2853
+ // 「已静态复核通过」——用户从现象上看不出是权限没开还是模型偷懒。所以这里直说怎么开。
2854
+ dom.infoTools.title = `${toolNames}|计划模式只提供只读工具`
2855
+ + (hasBash
2856
+ ? '|bash 已开启:自动模式下命令不再逐个确认'
2857
+ : '|没有 bash:模型无法执行命令(跑测试/验收会卡住)。要开启请重启服务时加 --allow-bash');
2858
+ state.defaultMode = normalizeMode(state.config.defaultMode);
2859
+ if (!state.settings.model && state.config.model) {
2860
+ state.settings.model = state.config.model;
2861
+ // 用户可能已在设置面板选过(那时选项已存在),只在还没选过时回填这一项
2862
+ ModelSelect.ensure(dom.setModel, state.config.model, ModelSelect.defaultLabel(state.config.model));
2863
+ }
2864
+ if (state.config.fsEnabled === false) {
2865
+ setStatus('目录浏览接口已禁用(服务未绑定本机)', 'err');
2866
+ setDot('err');
2867
+ } else if (!state.config.hasKey && !statusHasContent()) {
2868
+ // 没有密钥时**服务是能起来的**(不然用户连这一页都打不开),所以这里要把「去哪儿填」说清楚。
2869
+ // 不再要求用户去手写 .env:设置面板里的输入框写的就是 credentials.json。
2870
+ // 只在状态栏还空着(或只是「就绪」)时说这句:重启后恢复任务、裁剪上下文这些更具体的结果
2871
+ // 都比它重要,不能被一句泛泛的提示盖掉。
2872
+ setStatus('还没有配置密钥:打开「设置」填 sk-xxx(存 credentials.json,仅本用户可读),或命令行 gateway-agent config set key sk-xxx', 'err');
2873
+ }
2874
+ } catch (e) {
2875
+ setDot('err');
2876
+ setStatus(`读取配置失败:${e.message}`, 'err');
2877
+ }
2878
+ }
2879
+
2880
+ /**
2881
+ * 模型下拉:从网关 `/v1/models` 拉候选,**只让选、不让填**(与聊天页共用 public/models.js)。
2882
+ */
2883
+ async function loadModels() {
2884
+ dom.btnRefreshModels.disabled = true;
2885
+ dom.modelsHint.textContent = '正在拉取模型列表…';
2886
+ try {
2887
+ const resp = await fetch('/api/models');
2888
+ const data = await resp.json();
2889
+ state.models = data.models || [];
2890
+ const r = ModelSelect.fill(dom.setModel, {
2891
+ models: state.models,
2892
+ current: state.settings.model,
2893
+ defaultModel: data.default || state.config?.model || '',
2894
+ });
2895
+ dom.modelsHint.textContent = data.error ? `拉取失败:${data.error}` : ModelSelect.hintText(r);
2896
+ } catch (e) {
2897
+ // 拉不到就只留「服务端默认 + 当前值」并说清原因:别让用户以为自己没得选
2898
+ const r = ModelSelect.fill(dom.setModel, {
2899
+ models: [],
2900
+ current: state.settings.model,
2901
+ defaultModel: state.config?.model || '',
2902
+ });
2903
+ state.models = [];
2904
+ dom.modelsHint.textContent = `拉取失败:${e.message}(${ModelSelect.hintText(r)})`;
2905
+ } finally {
2906
+ dom.btnRefreshModels.disabled = false;
2907
+ }
2908
+ }
2909
+
2910
+ /* ---------- 事件绑定 ---------- */
2911
+
2912
+ function autoGrow() {
2913
+ dom.input.style.height = 'auto';
2914
+ dom.input.style.height = `${Math.min(dom.input.scrollHeight, 220)}px`;
2915
+ }
2916
+
2917
+ /**
2918
+ * 聊天页和任务页现在是同一个服务,同源相对路径就能跳回去。
2919
+ * 需要指向别处时用 ?chat=http://host:port/ 覆盖。
2920
+ */
2921
+ const CHAT_PATH = '/';
2922
+
2923
+ function wireChatEntry() {
2924
+ if (!dom.btnOpenChat) return;
2925
+ const override = new URLSearchParams(location.search).get('chat');
2926
+ dom.btnOpenChat.href = override || CHAT_PATH;
2927
+ }
2928
+
2929
+ async function init() {
2930
+ loadSettings();
2931
+ applySettingsToUI();
2932
+ // 配置以磁盘为准(服务端 config.json);服务端还空着就把浏览器里这份搬上去。
2933
+ // 放在 loadTasks 之前:界面状态(打开哪条、折叠了哪些)也一并从这里对齐。
2934
+ await syncSettings();
2935
+ applySettingsToUI();
2936
+ renderAll();
2937
+ wireChatEntry();
2938
+ wireModeSwitch();
2939
+ renderMode();
2940
+ setWorkDir(null, false);
2941
+
2942
+ // 任务在服务端,先把列表和当前任务取回来,再决定空态/弹选择器
2943
+ await loadTasks();
2944
+ renderTasks();
2945
+ renderAll();
2946
+ renderMode();
2947
+ setWorkDir(state.workDir, false);
2948
+ loadConfig().then(loadModels);
2949
+
2950
+ if (state.loadError) {
2951
+ setStatus(`任务记录不可用:${state.loadError}`, 'err');
2952
+ setDot('err');
2953
+ return;
2954
+ }
2955
+
2956
+ // 待批准的挂起态已经落盘,刷新过也能捞回来,不用重跑整个任务
2957
+ const restored = await restorePending();
2958
+
2959
+ // 状态栏只有一行,按「越需要用户动手越优先」排:先要目录、再待批准,
2960
+ // 然后才是迁移/清理这类已自动完成的通知 —— 否则通知会被后面的「就绪」吃掉
2961
+ if (!state.workDir) {
2962
+ setStatus('请先选择工作目录');
2963
+ openDirModal();
2964
+ } else if (state.taskLoadError) {
2965
+ setStatus(`读取任务内容失败:${state.taskLoadError}(工作目录和模式已从列表恢复)`, 'err');
2966
+ setDot('err');
2967
+ } else if (restored) {
2968
+ // restorePending 已经写了更具体的文案,这里不覆盖
2969
+ } else if (findPendingMessage()) {
2970
+ setStatus('有一个待批准的写入', '');
2971
+ setDot('warn');
2972
+ } else if (state.migrated) {
2973
+ setStatus(`已把浏览器里保存的 ${state.migrated} 个任务迁移到磁盘`, 'ok');
2974
+ } else if (state.prunedCount) {
2975
+ const rDays = state.storeInfo?.ttlDays ?? 15;
2976
+ const rMax = state.storeInfo?.maxTasks ?? 20;
2977
+ setStatus(`已自动清理 ${state.prunedCount} 个过期任务(保留 ${rDays} 天 / 最多 ${rMax} 个)`, '');
2978
+ } else if (state.settingsMigrated) {
2979
+ setStatus('已把浏览器里的配置迁移到磁盘(清缓存不会再丢)', 'ok');
2980
+ } else {
2981
+ setStatus('就绪', 'ok');
2982
+ }
2983
+
2984
+ dom.input.addEventListener('input', () => {
2985
+ autoGrow();
2986
+ slashMenuSync(); // 输入以 / 开头的片段时弹候选,其它情况关闭
2987
+ });
2988
+ dom.input.addEventListener('keydown', (e) => {
2989
+ if (slash.open) {
2990
+ if (e.key === 'ArrowDown') { e.preventDefault(); return slashMenuMove(1); }
2991
+ if (e.key === 'ArrowUp') { e.preventDefault(); return slashMenuMove(-1); }
2992
+ if (e.key === 'Tab') { e.preventDefault(); return slashMenuPick(); }
2993
+ if (e.key === 'Escape') { e.preventDefault(); return slashMenuClose(); }
2994
+ }
2995
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
2996
+ e.preventDefault();
2997
+ submit();
2998
+ }
2999
+ });
3000
+
3001
+ function submit() {
3002
+ const text = dom.input.value;
3003
+ if (!text.trim() || state.streaming) return;
3004
+ const parsed = TaskSlash.parseSlash(text, slashOpts());
3005
+ dom.input.value = '';
3006
+ autoGrow();
3007
+ if (parsed.isCommand || parsed.unknownSlash) {
3008
+ // 指令在本地执行(不发 /api/task);未知的 /xxx 也在这里被拦下、提示改用 //
3009
+ slashMenuClose();
3010
+ TaskSlash.runSlash(parsed, slashCtx()).catch((e) => slashPrint('error', (e && e.message) || String(e)));
3011
+ return;
3012
+ }
3013
+ send(parsed.escaped ? parsed.text : text);
3014
+ }
3015
+
3016
+ // 面板上用鼠标点选(用 mousedown 而不是 click:click 会先让输入框失焦)
3017
+ dom.slashMenu.addEventListener('mousedown', (e) => {
3018
+ const item = e.target.closest && e.target.closest('.slash-menu-item');
3019
+ if (!item) return;
3020
+ e.preventDefault();
3021
+ slash.hl = Number(item.dataset.idx) || 0;
3022
+ slashMenuPick();
3023
+ });
3024
+
3025
+ dom.send.addEventListener('click', submit);
3026
+ dom.stop.addEventListener('click', abortIfStreaming);
3027
+
3028
+ dom.workdirChip.addEventListener('click', openDirModal);
3029
+ dom.chipPick.addEventListener('click', openDirModal);
3030
+ // 不传参数:沿用当前工作目录(别把 click 事件传进去)
3031
+ dom.newTask.addEventListener('click', () => startDraft());
3032
+ dom.btnToggleSidebar.addEventListener('click', toggleSidebar);
3033
+ dom.btnPrune.addEventListener('click', async () => {
3034
+ try {
3035
+ const data = await api('/api/tasks/prune', { method: 'POST' });
3036
+ state.storeInfo = data;
3037
+ // 清理可能把当前任务也带走了
3038
+ const stillThere = state.tasks.filter((t) => !data.removed?.includes(t.id));
3039
+ state.tasks = stillThere;
3040
+ if (state.activeId && !stillThere.some((t) => t.id === state.activeId)) {
3041
+ state.activeId = null;
3042
+ state.draft = true;
3043
+ state.messages = [];
3044
+ saveUiState();
3045
+ renderAll();
3046
+ }
3047
+ renderTasks();
3048
+ setStatus(data.removed?.length ? `已清理 ${data.removed.length} 个过期任务` : '没有需要清理的任务', '');
3049
+ } catch (e) {
3050
+ setStatus(`清理失败:${e.message}`, 'err');
3051
+ }
3052
+ });
3053
+
3054
+ dom.messages.addEventListener('click', (e) => {
3055
+ const btn = e.target.closest('[data-act]');
3056
+ if (btn) {
3057
+ decideApproval(btn.dataset.act === 'approve');
3058
+ return;
3059
+ }
3060
+ const chip = e.target.closest('.chip');
3061
+ if (chip && chip.id !== 'chip-pick') {
3062
+ dom.input.value = chip.dataset.prompt || chip.textContent;
3063
+ autoGrow();
3064
+ submit();
3065
+ }
3066
+ });
3067
+
3068
+ // 目录选择器
3069
+ dom.dirCancel.addEventListener('click', closeDirModal);
3070
+ dom.dirModal.addEventListener('click', (e) => {
3071
+ if (e.target === dom.dirModal) closeDirModal();
3072
+ });
3073
+ dom.dirUp.addEventListener('click', () => {
3074
+ if (!currentDir) return;
3075
+ const sep = currentDir.includes('\\') ? '\\' : '/';
3076
+ const parent = currentDir.replace(/[\\/]+$/, '').split(sep).slice(0, -1).join(sep) || sep;
3077
+ loadDir(parent);
3078
+ });
3079
+ dom.dirGo.addEventListener('click', () => {
3080
+ const v = dom.dirPathInput.value.trim();
3081
+ if (v) loadDir(v);
3082
+ });
3083
+ dom.dirPathInput.addEventListener('keydown', (e) => {
3084
+ if (e.key === 'Enter') {
3085
+ e.preventDefault();
3086
+ const v = dom.dirPathInput.value.trim();
3087
+ if (v) loadDir(v);
3088
+ }
3089
+ });
3090
+ dom.dirConfirm.addEventListener('click', () => {
3091
+ const target = selectedDir || currentDir;
3092
+ if (!target) return;
3093
+ setWorkDir(target);
3094
+ closeDirModal();
3095
+ setStatus(`工作目录:${target}`, 'ok');
3096
+ setDot('ok');
3097
+ dom.input.focus();
3098
+ });
3099
+ document.addEventListener('keydown', (e) => {
3100
+ if (e.key === 'Escape' && !dom.dirModal.hidden) closeDirModal();
3101
+ });
3102
+
3103
+ dom.btnSettings.addEventListener('click', () => {
3104
+ const open = dom.settings.classList.toggle('open');
3105
+ // 每次打开都重读一次:手改 config.json / 换了环境变量后,刷新面板就能看到;
3106
+ // 顺带把「其他配置」整块按最新白名单重渲染(服务端连不上时 syncSettings 自己会退回来)
3107
+ if (open && state.settingsOnServer) syncSettings().then(applySettingsToUI);
3108
+ });
3109
+ wireSettingsResizer();
3110
+ dom.btnSettingsClose.addEventListener('click', () => dom.settings.classList.remove('open'));
3111
+ for (const node of [dom.setModel, dom.setSystem, dom.setTemperature, dom.setMaxTokens, dom.setShowReasoning]) {
3112
+ node.addEventListener('change', readSettingsFromUI);
3113
+ }
3114
+ dom.setSystem.addEventListener('blur', readSettingsFromUI);
3115
+ dom.btnRefreshModels.addEventListener('click', loadModels);
3116
+ dom.btnReset.addEventListener('click', () => {
3117
+ state.settings = { ...DEFAULTS, model: state.config?.model || '' };
3118
+ saveSettings();
3119
+ applySettingsToUI();
3120
+ setStatus('已恢复默认设置', '');
3121
+ });
3122
+
3123
+ autoGrow();
3124
+ if (state.workDir) dom.input.focus();
3125
+ }
3126
+
3127
+ init();