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/app.js ADDED
@@ -0,0 +1,1055 @@
1
+ 'use strict';
2
+
3
+ /* ---------- 常量与状态 ---------- */
4
+
5
+ const STORE_SETTINGS = 'lgw.settings.v1';
6
+ const STORE_INDEX = 'lgw.sessions.v2'; // 会话索引(轻量,只存元信息)
7
+ const STORE_SESSION = 'lgw.session.'; // 每个会话的消息单独一个 key:lgw.session.<id>
8
+ const LEGACY_MESSAGES = 'lgw.messages.v1'; // 旧版单会话数据,仅用于一次性迁移
9
+
10
+ // 保留策略:按「最后一次活动」滑动保留 15 天,且最多留 20 个会话(超出删最旧)
11
+ const TTL_DAYS = 15;
12
+ const TTL_MS = TTL_DAYS * 24 * 60 * 60 * 1000;
13
+ const MAX_SESSIONS = 20;
14
+
15
+ const DEFAULTS = {
16
+ model: '',
17
+ system: '',
18
+ temperature: 0.7,
19
+ maxTokens: 0,
20
+ stream: true,
21
+ showReasoning: true,
22
+ historyChars: 32000, // 发送给上游的历史字符上限,0 = 不限制
23
+ };
24
+
25
+ const state = {
26
+ messages: [], // 当前会话的消息 { role, content, reasoning, streaming?, error?, el? }
27
+ sessions: [], // [{ id, title, createdAt, updatedAt, renamed? }]
28
+ activeId: null, // null = 尚未落盘的草稿态(首条消息发出时才建会话)
29
+ draft: false, // true = 用户主动点了「新对话」,刷新后仍保持空白态
30
+ settings: { ...DEFAULTS },
31
+ models: [], // 最近一次从 /api/models 拉到的可选模型(设置里只让选这些)
32
+ streaming: false,
33
+ controller: null,
34
+ usageTotal: { prompt: 0, completion: 0 },
35
+ config: null,
36
+ storageOk: true,
37
+ prunedCount: 0,
38
+ };
39
+
40
+ const dom = {
41
+ messages: document.getElementById('messages'),
42
+ empty: document.getElementById('empty-hint'),
43
+ input: document.getElementById('input'),
44
+ send: document.getElementById('btn-send'),
45
+ stop: document.getElementById('btn-stop'),
46
+ clear: document.getElementById('btn-clear'),
47
+ btnOpenTask: document.getElementById('btn-open-task'),
48
+ status: document.getElementById('status'),
49
+ dot: document.getElementById('status-dot'),
50
+ meta: document.getElementById('gateway-meta'),
51
+ settings: document.getElementById('settings'),
52
+ btnSettings: document.getElementById('btn-settings'),
53
+ btnSettingsClose: document.getElementById('btn-settings-close'),
54
+ setModel: document.getElementById('set-model'),
55
+ modelsHint: document.getElementById('models-hint'),
56
+ setSystem: document.getElementById('set-system'),
57
+ setTemperature: document.getElementById('set-temperature'),
58
+ setMaxTokens: document.getElementById('set-max-tokens'),
59
+ setStream: document.getElementById('set-stream'),
60
+ setShowReasoning: document.getElementById('set-show-reasoning'),
61
+ setHistoryChars: document.getElementById('set-history-chars'),
62
+ setKey: document.getElementById('set-key'),
63
+ keyHint: document.getElementById('key-hint'),
64
+ btnSaveKey: document.getElementById('btn-save-key'),
65
+ btnRefreshModels: document.getElementById('btn-refresh-models'),
66
+ btnReset: document.getElementById('btn-reset'),
67
+ infoBase: document.getElementById('info-base'),
68
+ infoKey: document.getElementById('info-key'),
69
+ infoUsage: document.getElementById('info-usage'),
70
+ infoSessions: document.getElementById('info-sessions'),
71
+ infoRetention: document.getElementById('info-retention'),
72
+ sidebar: document.getElementById('sidebar'),
73
+ sessionList: document.getElementById('session-list'),
74
+ sessionsCount: document.getElementById('sessions-count'),
75
+ sessionsRetention: document.getElementById('sessions-retention'),
76
+ btnNewSession: document.getElementById('btn-new-session'),
77
+ btnPrune: document.getElementById('btn-prune'),
78
+ btnToggleSidebar: document.getElementById('btn-toggle-sidebar'),
79
+ };
80
+
81
+ /* ---------- 工具函数 ----------
82
+ * escapeHtml / renderMarkdown / enhanceCodeBlocks 已抽到 public/render.js,
83
+ * 由页面在本脚本之前加载,聊天页与任务页共用同一份实现。
84
+ */
85
+
86
+ function setStatus(text, kind = '') {
87
+ dom.status.textContent = text;
88
+ dom.status.className = `hint${kind ? ' ' + kind : ''}`;
89
+ }
90
+
91
+ function setDot(kind) {
92
+ dom.dot.className = `dot${kind ? ' ' + kind : ''}`;
93
+ }
94
+
95
+ /**
96
+ * 信息行的值:面板窄,长值(网关地址、模型名…)按 CSS 截成省略号,
97
+ * 所以顺手把完整值挂到 title 上 —— 悬停就能看全(与任务页同一套写法)。
98
+ */
99
+ function setInfo(el, text) {
100
+ if (!el) return;
101
+ el.textContent = text;
102
+ el.title = text;
103
+ }
104
+
105
+ function scrollToBottom(force = false) {
106
+ const nearBottom = dom.messages.scrollHeight - dom.messages.scrollTop - dom.messages.clientHeight < 120;
107
+ if (force || nearBottom) dom.messages.scrollTop = dom.messages.scrollHeight;
108
+ }
109
+
110
+ /* ---------- 持久化 ---------- */
111
+
112
+ function saveSettings() {
113
+ try {
114
+ localStorage.setItem(STORE_SETTINGS, JSON.stringify(state.settings));
115
+ } catch { /* 忽略隐私模式等写入失败 */ }
116
+ }
117
+
118
+ function loadSettings() {
119
+ try {
120
+ const raw = localStorage.getItem(STORE_SETTINGS);
121
+ if (raw) Object.assign(state.settings, JSON.parse(raw));
122
+ } catch { /* 忽略损坏的本地数据 */ }
123
+ }
124
+
125
+ /* ---------- 会话存储 ----------
126
+ * 存储布局(全部在 localStorage):
127
+ * lgw.sessions.v2 -> { version, activeId, sessions: [{ id, title, createdAt, updatedAt, renamed }] }
128
+ * lgw.session.<id> -> [{ role, content, reasoning }]
129
+ * 索引与消息分开存:发消息只重写当前会话那一个 key,清理时按 id 精确删除。
130
+ * 自动管理:跨过 15 天没活动的会话在启动/保存时删除;总数超过 20 个删最旧的。
131
+ */
132
+
133
+ function newId() {
134
+ try {
135
+ return crypto.randomUUID();
136
+ } catch {
137
+ return `s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
138
+ }
139
+ }
140
+
141
+ function readJson(key, fallback) {
142
+ try {
143
+ const raw = localStorage.getItem(key);
144
+ if (!raw) return fallback;
145
+ const value = JSON.parse(raw);
146
+ return value === null || value === undefined ? fallback : value;
147
+ } catch {
148
+ return fallback; // 数据损坏时按「不存在」处理,不能让整个页面打不开
149
+ }
150
+ }
151
+
152
+ function writeJson(key, value) {
153
+ try {
154
+ localStorage.setItem(key, JSON.stringify(value));
155
+ return true;
156
+ } catch (e) {
157
+ state.storageOk = false;
158
+ setStatus(`浏览器存储写入失败(可能已满):${e?.name || e}`, 'err');
159
+ return false;
160
+ }
161
+ }
162
+
163
+ function removeKey(key) {
164
+ try {
165
+ localStorage.removeItem(key);
166
+ } catch { /* 忽略 */ }
167
+ }
168
+
169
+ function normalizeSession(s) {
170
+ if (!s || typeof s.id !== 'string' || !s.id) return null;
171
+ const created = Number(s.createdAt);
172
+ const updated = Number(s.updatedAt);
173
+ const base = Number.isFinite(created) ? created : Date.now();
174
+ return {
175
+ id: s.id,
176
+ title: typeof s.title === 'string' && s.title.trim() ? s.title.trim() : '未命名会话',
177
+ createdAt: base,
178
+ updatedAt: Number.isFinite(updated) ? updated : base,
179
+ renamed: Boolean(s.renamed),
180
+ };
181
+ }
182
+
183
+ function readSessionMessages(id) {
184
+ const arr = readJson(STORE_SESSION + id, []);
185
+ if (!Array.isArray(arr)) return [];
186
+ return arr
187
+ .filter((m) => m && (m.content || m.reasoning))
188
+ .map((m) => ({
189
+ role: m.role === 'assistant' ? 'assistant' : 'user',
190
+ content: String(m.content || ''),
191
+ reasoning: String(m.reasoning || ''),
192
+ }));
193
+ }
194
+
195
+ function writeSessionMessages(id, messages) {
196
+ return writeJson(
197
+ STORE_SESSION + id,
198
+ messages
199
+ .filter((m) => m.content || m.reasoning)
200
+ .map(({ role, content, reasoning }) => ({ role, content, reasoning })),
201
+ );
202
+ }
203
+
204
+ function writeIndex() {
205
+ return writeJson(STORE_INDEX, { version: 2, activeId: state.activeId, draft: state.draft, sessions: state.sessions });
206
+ }
207
+
208
+ /** 从首条用户消息生成标题(用户手动改过名的会话不覆盖) */
209
+ function titleFromMessages(messages) {
210
+ const first = messages.find((m) => m.role === 'user' && m.content);
211
+ if (!first) return '新对话';
212
+ const t = first.content.replace(/\s+/g, ' ').trim();
213
+ if (!t) return '新对话';
214
+ return t.length > 24 ? `${t.slice(0, 24)}…` : t;
215
+ }
216
+
217
+ /** 过期清理:滑动 15 天 + 最多 20 个。返回被清掉的会话数组 */
218
+ function pruneSessions() {
219
+ const now = Date.now();
220
+ const removed = [];
221
+ const kept = [];
222
+ for (const s of state.sessions) {
223
+ if (now - s.updatedAt > TTL_MS) removed.push(s);
224
+ else kept.push(s);
225
+ }
226
+ kept.sort((a, b) => b.updatedAt - a.updatedAt);
227
+ removed.push(...kept.splice(MAX_SESSIONS));
228
+ for (const s of removed) removeKey(STORE_SESSION + s.id);
229
+ state.sessions = kept;
230
+ if (state.activeId && !kept.some((s) => s.id === state.activeId)) state.activeId = null;
231
+ return removed;
232
+ }
233
+
234
+ /** 把当前会话写盘,并顺带自动命名 / 清理。返回本次清掉的会话 */
235
+ function persistActive() {
236
+ if (state.activeId) {
237
+ const s = state.sessions.find((x) => x.id === state.activeId);
238
+ if (s) {
239
+ writeSessionMessages(s.id, state.messages);
240
+ if (!s.renamed && state.messages.length) s.title = titleFromMessages(state.messages);
241
+ s.updatedAt = Date.now();
242
+ }
243
+ }
244
+ const removed = pruneSessions();
245
+ state.prunedCount += removed.length;
246
+ writeIndex();
247
+ return removed;
248
+ }
249
+
250
+ /** 草稿态(activeId 为 null)在首条消息发出时落盘为一个新会话 */
251
+ function ensureSession() {
252
+ if (state.activeId && state.sessions.some((s) => s.id === state.activeId)) {
253
+ state.draft = false;
254
+ return state.activeId;
255
+ }
256
+ const now = Date.now();
257
+ const s = { id: newId(), title: '新对话', createdAt: now, updatedAt: now, renamed: false };
258
+ state.sessions.unshift(s);
259
+ state.activeId = s.id;
260
+ state.messages = [];
261
+ state.draft = false;
262
+ return s.id;
263
+ }
264
+
265
+ function loadSessions() {
266
+ const raw = readJson(STORE_INDEX, null);
267
+ const sessions = Array.isArray(raw?.sessions) ? raw.sessions.map(normalizeSession).filter(Boolean) : [];
268
+ state.sessions = sessions;
269
+ state.activeId = typeof raw?.activeId === 'string' ? raw.activeId : null;
270
+ state.draft = Boolean(raw?.draft);
271
+
272
+ // 旧版单会话数据迁移:只在还没有任何会话时做一次,做完即删旧 key
273
+ if (!state.sessions.length) {
274
+ const legacy = readJson(LEGACY_MESSAGES, null);
275
+ if (Array.isArray(legacy) && legacy.some((m) => m && (m.content || m.reasoning))) {
276
+ const now = Date.now();
277
+ const id = newId();
278
+ const messages = legacy
279
+ .filter((m) => m && (m.content || m.reasoning))
280
+ .map((m) => ({
281
+ role: m.role === 'assistant' ? 'assistant' : 'user',
282
+ content: String(m.content || ''),
283
+ reasoning: String(m.reasoning || ''),
284
+ }));
285
+ state.sessions = [{ id, title: titleFromMessages(messages), createdAt: now, updatedAt: now, renamed: false }];
286
+ state.activeId = id;
287
+ writeSessionMessages(id, messages);
288
+ removeKey(LEGACY_MESSAGES);
289
+ state.migrated = true;
290
+ }
291
+ }
292
+
293
+ const removed = pruneSessions();
294
+ state.prunedCount += removed.length;
295
+
296
+ // 用户主动开了新对话(draft)就保持空白;否则恢复最近使用的会话
297
+ if (!state.draft && !state.activeId && state.sessions.length) state.activeId = state.sessions[0].id;
298
+ if (state.draft) state.activeId = null;
299
+ state.messages = state.activeId ? readSessionMessages(state.activeId) : [];
300
+ writeIndex();
301
+ }
302
+
303
+ /* ---------- 会话侧边栏 ---------- */
304
+
305
+ function fmtRelative(ts) {
306
+ const min = Math.floor((Date.now() - ts) / 60000);
307
+ if (min < 1) return '刚刚';
308
+ if (min < 60) return `${min} 分钟前`;
309
+ const h = Math.floor(min / 60);
310
+ if (h < 24) return `${h} 小时前`;
311
+ return `${Math.floor(h / 24)} 天前`;
312
+ }
313
+
314
+ function fmtDateTime(ts) {
315
+ const d = new Date(ts);
316
+ const p = (n) => String(n).padStart(2, '0');
317
+ return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
318
+ }
319
+
320
+ function updateRetentionInfo() {
321
+ dom.sessionsCount.textContent = `会话 ${state.sessions.length} / ${MAX_SESSIONS}`;
322
+ setInfo(dom.infoSessions, `${state.sessions.length} / ${MAX_SESSIONS}(滑动 ${TTL_DAYS} 天)`);
323
+ if (!state.sessions.length) {
324
+ dom.sessionsRetention.textContent = `滑动保留 ${TTL_DAYS} 天`;
325
+ setInfo(dom.infoRetention, '暂无会话');
326
+ return;
327
+ }
328
+ const oldest = state.sessions.reduce((a, b) => (a.updatedAt < b.updatedAt ? a : b));
329
+ const text = `最早 ${fmtDateTime(oldest.updatedAt + TTL_MS)} 过期`;
330
+ dom.sessionsRetention.textContent = text;
331
+ setInfo(dom.infoRetention, text);
332
+ }
333
+
334
+ function renderSessions() {
335
+ dom.sessionList.innerHTML = '';
336
+ if (!state.sessions.length) {
337
+ const hint = document.createElement('div');
338
+ hint.className = 'sessions-empty';
339
+ hint.textContent = '还没有会话,发一条消息就会自动创建';
340
+ dom.sessionList.appendChild(hint);
341
+ updateRetentionInfo();
342
+ return;
343
+ }
344
+
345
+ const sorted = [...state.sessions].sort((a, b) => b.updatedAt - a.updatedAt);
346
+ for (const s of sorted) {
347
+ const item = document.createElement('div');
348
+ item.className = `session-item${s.id === state.activeId ? ' active' : ''}`;
349
+
350
+ const main = document.createElement('button');
351
+ main.className = 'session-main';
352
+ main.type = 'button';
353
+ const title = document.createElement('span');
354
+ title.className = 'session-title';
355
+ title.textContent = s.title;
356
+ title.title = s.title;
357
+ const time = document.createElement('span');
358
+ time.className = 'session-time';
359
+ time.textContent = fmtRelative(s.updatedAt);
360
+ main.append(title, time);
361
+ main.addEventListener('click', () => switchSession(s.id));
362
+
363
+ const acts = document.createElement('div');
364
+ acts.className = 'session-acts';
365
+ const rename = document.createElement('button');
366
+ rename.className = 'icon-btn';
367
+ rename.type = 'button';
368
+ rename.title = '重命名';
369
+ rename.textContent = '✎';
370
+ rename.addEventListener('click', (e) => {
371
+ e.stopPropagation();
372
+ beginRename(item, s);
373
+ });
374
+ const del = document.createElement('button');
375
+ del.className = 'icon-btn danger';
376
+ del.type = 'button';
377
+ del.title = '删除';
378
+ del.textContent = '✕';
379
+ del.addEventListener('click', (e) => {
380
+ e.stopPropagation();
381
+ confirmDelete(s);
382
+ });
383
+ acts.append(rename, del);
384
+
385
+ item.append(main, acts);
386
+ dom.sessionList.appendChild(item);
387
+ }
388
+ updateRetentionInfo();
389
+ }
390
+
391
+ function beginRename(item, s) {
392
+ item.innerHTML = '';
393
+ const input = document.createElement('input');
394
+ input.className = 'session-rename';
395
+ input.value = s.title;
396
+ item.appendChild(input);
397
+ input.focus();
398
+ input.select();
399
+
400
+ let done = false;
401
+ const commit = (save) => {
402
+ if (done) return; // Enter 之后还会触发一次 blur,这里去重
403
+ done = true;
404
+ if (save) {
405
+ const t = input.value.replace(/\s+/g, ' ').trim();
406
+ s.title = t ? t.slice(0, 40) : '未命名会话';
407
+ s.renamed = true; // 标记为用户命名,后续不再被首条消息自动覆盖
408
+ writeIndex();
409
+ }
410
+ renderSessions();
411
+ };
412
+
413
+ input.addEventListener('keydown', (e) => {
414
+ if (e.key === 'Enter') {
415
+ e.preventDefault();
416
+ commit(true);
417
+ } else if (e.key === 'Escape') {
418
+ e.preventDefault();
419
+ commit(false);
420
+ }
421
+ });
422
+ input.addEventListener('blur', () => commit(true));
423
+ input.addEventListener('click', (e) => e.stopPropagation());
424
+ }
425
+
426
+ function confirmDelete(s) {
427
+ const count = readSessionMessages(s.id).length;
428
+ if (!window.confirm(`删除会话「${s.title}」?\n该会话的 ${count} 条本地记录将立即清除,无法恢复。`)) return;
429
+ deleteSessionById(s.id);
430
+ setStatus('已删除会话', '');
431
+ }
432
+
433
+ function deleteSessionById(id) {
434
+ removeKey(STORE_SESSION + id);
435
+ state.sessions = state.sessions.filter((s) => s.id !== id);
436
+ if (state.activeId === id) {
437
+ state.activeId = state.sessions[0]?.id ?? null;
438
+ state.draft = state.activeId === null;
439
+ state.messages = state.activeId ? readSessionMessages(state.activeId) : [];
440
+ state.usageTotal = { prompt: 0, completion: 0 };
441
+ setInfo(dom.infoUsage, '—');
442
+ }
443
+ writeIndex();
444
+ renderSessions();
445
+ renderAll();
446
+ }
447
+
448
+ /** 切走当前会话前先落盘,保证正在编辑的上下文不丢 */
449
+ function abortIfStreaming() {
450
+ if (state.streaming && state.controller) state.controller.abort();
451
+ }
452
+
453
+ function switchSession(id) {
454
+ if (id === state.activeId) return;
455
+ abortIfStreaming();
456
+ persistActive();
457
+ state.activeId = id;
458
+ state.draft = false;
459
+ state.messages = readSessionMessages(id);
460
+ state.usageTotal = { prompt: 0, completion: 0 };
461
+ setInfo(dom.infoUsage, '—');
462
+ renderSessions();
463
+ renderAll();
464
+ const s = state.sessions.find((x) => x.id === id);
465
+ setStatus(s ? `已切换到「${s.title}」` : '就绪', '');
466
+ dom.input.focus();
467
+ }
468
+
469
+ /** 「新对话」:不立刻建会话,先把 activeId 置空;首条消息发出时才落盘,避免留下一堆空会话 */
470
+ function startDraft() {
471
+ abortIfStreaming();
472
+ persistActive();
473
+ state.activeId = null;
474
+ state.draft = true;
475
+ state.messages = [];
476
+ state.usageTotal = { prompt: 0, completion: 0 };
477
+ setInfo(dom.infoUsage, '—');
478
+ writeIndex();
479
+ renderSessions();
480
+ renderAll();
481
+ setStatus('已开始新对话', '');
482
+ dom.input.focus();
483
+ }
484
+
485
+ function toggleSidebar() {
486
+ if (window.matchMedia('(max-width: 820px)').matches) dom.sidebar.classList.toggle('open');
487
+ else dom.sidebar.classList.toggle('collapsed');
488
+ }
489
+
490
+ /* ---------- 渲染 ---------- */
491
+
492
+ function buildMsgEl(msg) {
493
+ const wrap = document.createElement('div');
494
+ wrap.className = `msg ${msg.role}`;
495
+
496
+ const avatar = document.createElement('div');
497
+ avatar.className = 'avatar';
498
+ avatar.textContent = msg.role === 'user' ? '你' : 'AI';
499
+
500
+ const bubble = document.createElement('div');
501
+ bubble.className = 'bubble';
502
+
503
+ wrap.append(avatar, bubble);
504
+ msg.el = bubble;
505
+ paintBubble(msg);
506
+ return wrap;
507
+ }
508
+
509
+ function paintBubble(msg) {
510
+ if (!msg.el) return;
511
+ if (msg.error) {
512
+ msg.el.className = 'bubble error';
513
+ msg.el.textContent = msg.error;
514
+ return;
515
+ }
516
+ msg.el.className = `bubble${msg.streaming ? ' streaming' : ''}`;
517
+
518
+ // 用户消息原样展示(不解析 Markdown,保留换行)
519
+ if (msg.role === 'user') {
520
+ msg.el.textContent = msg.content;
521
+ return;
522
+ }
523
+
524
+ // 思维链:单独放在可折叠块里。思考阶段自动展开,正文一开始就自动收起
525
+ // (用增量更新的方式而不是整块重绘,避免每帧重置用户的展开/收起操作)
526
+ const wantReasoning = msg.reasoning && state.settings.showReasoning;
527
+ let box = msg.el.querySelector(':scope > details.reasoning');
528
+ if (wantReasoning) {
529
+ if (!box) {
530
+ box = document.createElement('details');
531
+ box.className = 'reasoning';
532
+ box.open = true;
533
+ msg.reasoningOpen = true;
534
+ box.innerHTML = '<summary></summary><div class="reasoning-body"></div>';
535
+ msg.el.appendChild(box);
536
+ }
537
+ box.querySelector('summary').textContent = `思考过程 · ${msg.reasoning.length} 字`;
538
+ const body = box.querySelector('.reasoning-body');
539
+ body.textContent = msg.reasoning;
540
+ if (msg.reasoningOpen && msg.content) {
541
+ box.open = false;
542
+ msg.reasoningOpen = false;
543
+ }
544
+ } else if (box) {
545
+ box.remove();
546
+ }
547
+
548
+ let content = msg.el.querySelector(':scope > .content');
549
+ if (!content) {
550
+ content = document.createElement('div');
551
+ content.className = 'content';
552
+ msg.el.appendChild(content);
553
+ }
554
+ content.innerHTML = renderMarkdown(msg.content || '');
555
+ enhanceCodeBlocks(content);
556
+ enhanceTables(content);
557
+ }
558
+
559
+ const paintQueue = new WeakSet();
560
+ function schedulePaint(msg) {
561
+ if (paintQueue.has(msg)) return;
562
+ paintQueue.add(msg);
563
+ requestAnimationFrame(() => {
564
+ paintQueue.delete(msg);
565
+ paintBubble(msg);
566
+ scrollToBottom();
567
+ });
568
+ }
569
+
570
+ function appendMessage(msg) {
571
+ dom.messages.appendChild(buildMsgEl(msg));
572
+ dom.empty.hidden = true;
573
+ scrollToBottom(true);
574
+ }
575
+
576
+ function renderAll() {
577
+ for (const node of dom.messages.querySelectorAll('.msg')) node.remove();
578
+ for (const msg of state.messages) msg.el = null;
579
+ dom.empty.hidden = state.messages.length > 0;
580
+ for (const msg of state.messages) dom.messages.appendChild(buildMsgEl(msg));
581
+ if (state.messages.length) scrollToBottom(true);
582
+ }
583
+
584
+ function setBusy(busy) {
585
+ state.streaming = busy;
586
+ dom.send.disabled = busy;
587
+ dom.stop.hidden = !busy;
588
+ }
589
+
590
+ /* ---------- 发送消息 ---------- */
591
+
592
+ /**
593
+ * 组装发给上游的历史:从最新往回取,直到超过 historyChars 字符预算为止。
594
+ * 0 表示不限制。这样长对话不会把请求体撑到上游拒绝,且始终保留最近上下文。
595
+ */
596
+ function buildHistory() {
597
+ const all = state.messages
598
+ .filter((m) => m.content && m.content.trim()) // 纯空白不算有效上下文
599
+ .map(({ role, content }) => ({ role, content }));
600
+ const budget = Number(state.settings.historyChars);
601
+ if (!Number.isFinite(budget) || budget <= 0) return { messages: all, trimmed: 0 };
602
+
603
+ const kept = [];
604
+ let total = 0;
605
+ for (let i = all.length - 1; i >= 0; i--) {
606
+ const len = all[i].content.length;
607
+ if (kept.length && total + len > budget) break; // 至少保留最后一条,哪怕它自己就超预算
608
+ total += len;
609
+ kept.unshift(all[i]);
610
+ }
611
+ return { messages: kept, trimmed: all.length - kept.length };
612
+ }
613
+
614
+ async function send(text) {
615
+ const content = text.trim();
616
+ if (!content || state.streaming) return;
617
+
618
+ ensureSession(); // 草稿态在首条消息发出时落盘成会话
619
+
620
+ const userMsg = { role: 'user', content };
621
+ state.messages.push(userMsg);
622
+ appendMessage(userMsg);
623
+
624
+ const assistant = { role: 'assistant', content: '', reasoning: '', streaming: true };
625
+ state.messages.push(assistant);
626
+ appendMessage(assistant);
627
+
628
+ // 只把有内容的历史发给上游(空的 assistant 占位不算),并按字符预算裁剪
629
+ const history = buildHistory();
630
+ const payload = {
631
+ messages: history.messages,
632
+ model: state.settings.model || undefined,
633
+ system: state.settings.system || undefined,
634
+ stream: state.settings.stream,
635
+ // 这次对话的 id:服务端会以 W3C baggage 转发给网关,用于任务树归组。
636
+ // 上面 ensureSession() 刚把草稿态落盘,activeId 此时一定有了。
637
+ sessionId: state.activeId || undefined,
638
+ };
639
+ if (Number.isFinite(state.settings.temperature)) payload.temperature = state.settings.temperature;
640
+ if (state.settings.maxTokens > 0) payload.maxTokens = state.settings.maxTokens;
641
+
642
+ setBusy(true);
643
+ setStatus('生成中…');
644
+ state.controller = new AbortController();
645
+
646
+ try {
647
+ const resp = await fetch('/api/chat', {
648
+ method: 'POST',
649
+ headers: { 'content-type': 'application/json' },
650
+ body: JSON.stringify(payload),
651
+ signal: state.controller.signal,
652
+ });
653
+
654
+ if (!resp.ok) {
655
+ const data = await resp.json().catch(() => ({}));
656
+ // 缺密钥是「还没配」,不是「配错了」:顺手把设置面板打开,用户不用自己找入口
657
+ if (data.needsKey) {
658
+ dom.settings.classList.add('open');
659
+ if (dom.setKey) dom.setKey.focus();
660
+ }
661
+ throw new Error(data.error || `HTTP ${resp.status}`);
662
+ }
663
+
664
+ if (!state.settings.stream) {
665
+ const data = await resp.json();
666
+ assistant.content = data.text || '';
667
+ assistant.reasoning = data.reasoning || '';
668
+ assistant.streaming = false;
669
+ paintBubble(assistant);
670
+ applyUsage(data.usage);
671
+ } else {
672
+ await consumeStream(resp, assistant);
673
+ }
674
+ setStatus(history.trimmed ? `就绪 · 上下文已裁剪 ${history.trimmed} 条较早消息` : '就绪', 'ok');
675
+ setDot('ok');
676
+ } catch (e) {
677
+ if (e?.name === 'AbortError') {
678
+ setStatus('已停止', '');
679
+ if (assistant.reasoning && !assistant.content) {
680
+ assistant.content = '_(已停止:思考阶段被中断,尚未产出正文)_';
681
+ } else if (!assistant.content) {
682
+ assistant.error = '(已停止,未收到内容)';
683
+ }
684
+ } else {
685
+ const message = e?.message || String(e);
686
+ if (assistant.content) {
687
+ assistant.content += `\n\n> ⚠️ 中断:${message}`;
688
+ } else {
689
+ assistant.error = `请求失败:${message}`;
690
+ }
691
+ setStatus(`失败:${message}`, 'err');
692
+ setDot('err');
693
+ }
694
+ } finally {
695
+ assistant.streaming = false;
696
+ paintBubble(assistant);
697
+ setBusy(false);
698
+ state.controller = null;
699
+ const removed = persistActive();
700
+ renderSessions();
701
+ if (removed.length) setStatus(`已自动清理 ${removed.length} 个过期会话`, '');
702
+ scrollToBottom();
703
+ dom.input.focus();
704
+ }
705
+ }
706
+
707
+ /** 消费服务端归一化的 SSE:{type:reasoning|delta|usage|notice|error|done} */
708
+ async function consumeStream(resp, assistant) {
709
+ const reader = resp.body.getReader();
710
+ const decoder = new TextDecoder();
711
+ let buf = '';
712
+ for (;;) {
713
+ const { done, value } = await reader.read();
714
+ if (done) break;
715
+ buf += decoder.decode(value, { stream: true });
716
+ const parts = buf.split('\n\n');
717
+ buf = parts.pop() ?? '';
718
+ for (const part of parts) {
719
+ const line = part.split('\n').find((l) => l.startsWith('data:'));
720
+ if (!line) continue;
721
+ let evt;
722
+ try {
723
+ evt = JSON.parse(line.slice(5).trim());
724
+ } catch {
725
+ continue;
726
+ }
727
+ if (evt.type === 'reasoning') {
728
+ assistant.reasoning += evt.text;
729
+ if (!assistant.content) setStatus(`思考中…(${assistant.reasoning.length} 字)`);
730
+ schedulePaint(assistant);
731
+ } else if (evt.type === 'delta') {
732
+ assistant.content += evt.text;
733
+ schedulePaint(assistant);
734
+ } else if (evt.type === 'usage') {
735
+ applyUsage(evt.usage);
736
+ } else if (evt.type === 'notice') {
737
+ if (!assistant.content) assistant.content = `_${evt.text}_`;
738
+ } else if (evt.type === 'error') {
739
+ throw new Error(evt.message);
740
+ }
741
+ }
742
+ }
743
+ }
744
+
745
+ function applyUsage(usage) {
746
+ if (!usage) return;
747
+ const p = usage.prompt_tokens ?? usage.prompt;
748
+ const c = usage.completion_tokens ?? usage.completion;
749
+ if (Number.isFinite(p)) state.usageTotal.prompt += p;
750
+ if (Number.isFinite(c)) state.usageTotal.completion += c;
751
+ const last = [p, c].some(Number.isFinite) ? `本次 输入 ${p ?? '?'} / 输出 ${c ?? '?'}` : '';
752
+ const total = `累计 输入 ${state.usageTotal.prompt} / 输出 ${state.usageTotal.completion}`;
753
+ setInfo(dom.infoUsage, last ? `${last} · ${total}` : total);
754
+ if (last) setStatus(`${last} tokens`, 'ok');
755
+ }
756
+
757
+ /* ---------- 设置面板 ---------- */
758
+
759
+ /**
760
+ * 设置面板宽度可拖:抓住左边框往左拉就变宽(与任务页同一套行为与同一个 key)。
761
+ * 面板里最长的内容(网关地址等几行信息)与其被省略号截断,不如让用户自己拉宽;
762
+ * 宽度存浏览器本地,不占 config.json 的键。
763
+ */
764
+ const SETTINGS_WIDTH_KEY = 'lgw.settings.width';
765
+
766
+ function wireSettingsResizer() {
767
+ const panel = dom.settings;
768
+ if (!panel || panel.dataset.resizerWired) return; // init 可能被重复调用,只接一次
769
+ panel.dataset.resizerWired = '1';
770
+ const MIN = 280;
771
+ const viewport = () => (Number.isFinite(window.innerWidth) && window.innerWidth > 0 ? window.innerWidth : 1280);
772
+ const clamp = (w) => Math.min(Math.max(Math.round(Number(w) || 0), MIN), Math.round(viewport() * 0.92));
773
+
774
+ const saved = Number(readJson(SETTINGS_WIDTH_KEY, 0));
775
+ if (Number.isFinite(saved) && saved >= MIN) panel.style.width = `${clamp(saved)}px`;
776
+
777
+ // 内容整体套一层滚动容器:把手留在外层,滚到下面那几行信息时它还在
778
+ const body = document.createElement('div');
779
+ body.className = 'settings-body';
780
+ while (panel.firstChild) body.appendChild(panel.firstChild);
781
+ panel.appendChild(body);
782
+
783
+ const handle = document.createElement('div');
784
+ handle.className = 'settings-resizer';
785
+ handle.title = '拖动调整设置面板宽度';
786
+ panel.appendChild(handle);
787
+
788
+ let dragging = false;
789
+ handle.addEventListener('pointerdown', (e) => {
790
+ dragging = true;
791
+ handle.classList.add('dragging');
792
+ e.preventDefault?.();
793
+ });
794
+ window.addEventListener('pointermove', (e) => {
795
+ if (!dragging) return;
796
+ panel.style.width = `${clamp(viewport() - e.clientX)}px`;
797
+ });
798
+ const stop = () => {
799
+ if (!dragging) return;
800
+ dragging = false;
801
+ handle.classList.remove('dragging');
802
+ const w = parseInt(panel.style.width, 10);
803
+ if (Number.isFinite(w)) writeJson(SETTINGS_WIDTH_KEY, clamp(w));
804
+ };
805
+ window.addEventListener('pointerup', stop);
806
+ // 拖到窗口外松手 / 指针被系统取消:也要收尾,否则下次移动会继续改宽度
807
+ window.addEventListener('pointercancel', stop);
808
+ }
809
+
810
+ function applySettingsToUI() {
811
+ // 下拉是"只可选"的:值不在选项里时要先补一项再选,否则 select.value 会变成空串
812
+ ModelSelect.ensure(dom.setModel, state.settings.model || '');
813
+ dom.setSystem.value = state.settings.system || '';
814
+ dom.setTemperature.value = state.settings.temperature;
815
+ dom.setMaxTokens.value = state.settings.maxTokens || '';
816
+ dom.setStream.checked = Boolean(state.settings.stream);
817
+ dom.setShowReasoning.checked = state.settings.showReasoning !== false;
818
+ dom.setHistoryChars.value = state.settings.historyChars || '';
819
+ }
820
+
821
+ function readSettingsFromUI() {
822
+ const beforeReasoning = state.settings.showReasoning !== false;
823
+ state.settings.model = dom.setModel.value.trim();
824
+ state.settings.system = dom.setSystem.value;
825
+ const temp = Number(dom.setTemperature.value);
826
+ state.settings.temperature = Number.isFinite(temp) ? temp : DEFAULTS.temperature;
827
+ const maxTokens = Number(dom.setMaxTokens.value);
828
+ state.settings.maxTokens = Number.isFinite(maxTokens) && maxTokens > 0 ? Math.floor(maxTokens) : 0;
829
+ const historyChars = Number(dom.setHistoryChars.value);
830
+ state.settings.historyChars = Number.isFinite(historyChars) && historyChars > 0 ? Math.floor(historyChars) : 0;
831
+ state.settings.stream = dom.setStream.checked;
832
+ state.settings.showReasoning = dom.setShowReasoning.checked;
833
+ saveSettings();
834
+ updateMeta();
835
+ // 该开关直接决定气泡里渲不渲染思维链块,切换后需要重绘已有消息
836
+ if (beforeReasoning !== state.settings.showReasoning) {
837
+ for (const msg of state.messages) paintBubble(msg);
838
+ }
839
+ }
840
+
841
+ function updateMeta() {
842
+ const model = state.settings.model || state.config?.model || '(未设置)';
843
+ const base = state.config?.baseUrl || '—';
844
+ dom.meta.textContent = `${model} · ${base}`;
845
+ }
846
+
847
+ async function loadConfig() {
848
+ try {
849
+ const resp = await fetch('/api/config');
850
+ state.config = await resp.json();
851
+ setInfo(dom.infoBase, state.config.baseUrl);
852
+ setInfo(dom.infoKey, state.config.hasKey
853
+ ? `${state.config.keyMask}${state.config.keySourceText ? ` · ${state.config.keySourceText}` : ''}`
854
+ : '未配置');
855
+ if (dom.keyHint) {
856
+ dom.keyHint.textContent = state.config.hasKey
857
+ ? `已设置 ${state.config.keyMask}(来自${state.config.keySourceText || '密钥文件'})。密钥存在 credentials.json,不写进 config.json;留空 = 不改动。`
858
+ : '还没有密钥:粘贴 sk-xxx 点「保存密钥」即可(存 credentials.json,仅本用户可读)—— 不用手写 .env。';
859
+ }
860
+ if (!state.config.hasKey) setStatus('还没有配置密钥:在「设置」里填 sk-xxx(或 gateway-agent config set key sk-xxx)', 'err');
861
+ if (!state.settings.model && state.config.model) {
862
+ state.settings.model = state.config.model;
863
+ // 用户可能已在设置面板选过(那时选项已存在),只在还没选过时回填这一项
864
+ ModelSelect.ensure(dom.setModel, state.config.model, ModelSelect.defaultLabel(state.config.model));
865
+ }
866
+ updateMeta();
867
+ setDot('ok');
868
+ // 没密钥的提示要压过「就绪」:这一页现在能打开,但一发消息就会被服务端挡回来
869
+ setStatus(
870
+ state.config.hasKey ? '就绪' : '还没有配置密钥:在「设置」里填 sk-xxx(或 gateway-agent config set key sk-xxx)',
871
+ state.config.hasKey ? 'ok' : 'err',
872
+ );
873
+ } catch (e) {
874
+ setDot('err');
875
+ setStatus(`读取配置失败:${e.message}`, 'err');
876
+ }
877
+ }
878
+
879
+ /**
880
+ * 保存密钥。
881
+ *
882
+ * 与这一页其它设置不同:那些存在浏览器里,密钥必须交给服务端(写 credentials.json,不写 config.json)。
883
+ * 走的是同一个 `PUT /api/settings` —— 服务端把 `key` 单独分流,其余键照旧写 config.json。
884
+ */
885
+ async function saveKey() {
886
+ const value = String(dom.setKey.value || '').trim();
887
+ if (!value) {
888
+ setStatus('密钥留空 = 不改动(想清掉已保存的密钥:gateway-agent config unset key)', '');
889
+ return;
890
+ }
891
+ dom.btnSaveKey.disabled = true;
892
+ try {
893
+ const resp = await fetch('/api/settings', {
894
+ method: 'PUT',
895
+ headers: { 'content-type': 'application/json' },
896
+ body: JSON.stringify({ values: { key: value } }),
897
+ });
898
+ const data = await resp.json().catch(() => ({}));
899
+ if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
900
+ // 明文不留在输入框里,也不写进浏览器本地存储的任何一处
901
+ dom.setKey.value = '';
902
+ await loadConfig();
903
+ await loadModels();
904
+ setStatus(data.note || '密钥已保存,立即生效', 'ok');
905
+ } catch (e) {
906
+ setStatus(`保存密钥失败:${e.message}`, 'err');
907
+ } finally {
908
+ dom.btnSaveKey.disabled = false;
909
+ }
910
+ }
911
+
912
+ /**
913
+ * 模型下拉:从网关 `/v1/models` 拉候选,**只让选、不让填**。
914
+ * 建选项与"当前值不在列表里怎么办"的规则都在 public/models.js(两页共用同一份)。
915
+ */
916
+ async function loadModels() {
917
+ dom.btnRefreshModels.disabled = true;
918
+ dom.modelsHint.textContent = '正在拉取模型列表…';
919
+ try {
920
+ const resp = await fetch('/api/models');
921
+ const data = await resp.json();
922
+ state.models = data.models || [];
923
+ const r = ModelSelect.fill(dom.setModel, {
924
+ models: state.models,
925
+ current: state.settings.model,
926
+ defaultModel: data.default || state.config?.model || '',
927
+ });
928
+ dom.modelsHint.textContent = data.error ? `拉取失败:${data.error}` : ModelSelect.hintText(r);
929
+ } catch (e) {
930
+ // 拉不到就只留「服务端默认 + 当前值」,并说清原因:别让用户以为自己没得选
931
+ const r = ModelSelect.fill(dom.setModel, {
932
+ models: [],
933
+ current: state.settings.model,
934
+ defaultModel: state.config?.model || '',
935
+ });
936
+ state.models = [];
937
+ dom.modelsHint.textContent = `拉取失败:${e.message}(${ModelSelect.hintText(r)})`;
938
+ } finally {
939
+ dom.btnRefreshModels.disabled = false;
940
+ }
941
+ }
942
+
943
+ /* ---------- 事件绑定 ---------- */
944
+
945
+ /** 任务页和聊天页现在是同一个服务,同源相对路径就能跳过去。
946
+ * 需要指向别处时用 ?task=http://host:port/task 覆盖。 */
947
+ const TASK_PATH = '/task';
948
+
949
+ function wireTaskEntry() {
950
+ if (!dom.btnOpenTask) return;
951
+ const override = new URLSearchParams(location.search).get('task');
952
+ dom.btnOpenTask.href = override || TASK_PATH;
953
+ }
954
+
955
+ function autoGrow() {
956
+ dom.input.style.height = 'auto';
957
+ dom.input.style.height = `${Math.min(dom.input.scrollHeight, 220)}px`;
958
+ }
959
+
960
+ function init() {
961
+ loadSettings();
962
+ loadSessions();
963
+ applySettingsToUI();
964
+ renderSessions();
965
+ renderAll();
966
+ wireTaskEntry();
967
+ loadConfig().then(loadModels);
968
+
969
+ if (state.prunedCount) setStatus(`已自动清理 ${state.prunedCount} 个过期会话(保留 ${TTL_DAYS} 天 / 最多 ${MAX_SESSIONS} 个)`, '');
970
+ else if (state.migrated) setStatus('已把旧的单会话记录迁移为新版会话', 'ok');
971
+
972
+ dom.input.addEventListener('input', autoGrow);
973
+ dom.input.addEventListener('keydown', (e) => {
974
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
975
+ e.preventDefault();
976
+ submit();
977
+ }
978
+ });
979
+
980
+ function submit() {
981
+ const text = dom.input.value;
982
+ if (!text.trim() || state.streaming) return;
983
+ dom.input.value = '';
984
+ autoGrow();
985
+ send(text);
986
+ }
987
+
988
+ dom.send.addEventListener('click', submit);
989
+
990
+ dom.stop.addEventListener('click', () => {
991
+ if (state.controller) state.controller.abort();
992
+ });
993
+
994
+ dom.clear.addEventListener('click', startDraft); // 顶栏「新对话」
995
+ dom.btnNewSession.addEventListener('click', startDraft); // 侧边栏「+ 新对话」
996
+ dom.btnToggleSidebar.addEventListener('click', toggleSidebar);
997
+
998
+ dom.btnPrune.addEventListener('click', () => {
999
+ abortIfStreaming();
1000
+ const removed = persistActive();
1001
+ renderSessions();
1002
+ setStatus(
1003
+ removed.length ? `已清理 ${removed.length} 个会话` : `没有需要清理的会话(${TTL_DAYS} 天内、且未超过 ${MAX_SESSIONS} 个)`,
1004
+ removed.length ? 'ok' : '',
1005
+ );
1006
+ });
1007
+
1008
+ dom.btnSettings.addEventListener('click', () => dom.settings.classList.toggle('open'));
1009
+ wireSettingsResizer();
1010
+ dom.btnSettingsClose.addEventListener('click', () => dom.settings.classList.remove('open'));
1011
+
1012
+ const settingNodes = [
1013
+ dom.setModel,
1014
+ dom.setSystem,
1015
+ dom.setTemperature,
1016
+ dom.setMaxTokens,
1017
+ dom.setStream,
1018
+ dom.setShowReasoning,
1019
+ dom.setHistoryChars,
1020
+ ];
1021
+ for (const node of settingNodes) node.addEventListener('change', readSettingsFromUI);
1022
+ dom.setSystem.addEventListener('blur', readSettingsFromUI);
1023
+
1024
+ dom.btnRefreshModels.addEventListener('click', loadModels);
1025
+ // 密钥:唯一的写入路径是服务端的 PUT /api/settings(它写 credentials.json,不写 config.json)。
1026
+ // 这页的设置项大多存在浏览器里,密钥是例外 —— 它是服务端的事,所以单独一个「保存密钥」按钮。
1027
+ dom.btnSaveKey.addEventListener('click', saveKey);
1028
+ dom.setKey.addEventListener('keydown', (e) => {
1029
+ if (e.key === 'Enter') {
1030
+ e.preventDefault();
1031
+ saveKey();
1032
+ }
1033
+ });
1034
+ dom.btnReset.addEventListener('click', () => {
1035
+ state.settings = { ...DEFAULTS, model: state.config?.model || '' };
1036
+ saveSettings();
1037
+ applySettingsToUI();
1038
+ updateMeta();
1039
+ setStatus('已恢复默认设置', '');
1040
+ });
1041
+
1042
+ dom.messages.addEventListener('click', (e) => {
1043
+ const chip = e.target.closest('.chip');
1044
+ if (!chip) return;
1045
+ const prompt = chip.dataset.prompt || chip.textContent;
1046
+ dom.input.value = prompt;
1047
+ autoGrow();
1048
+ submit();
1049
+ });
1050
+
1051
+ autoGrow();
1052
+ dom.input.focus();
1053
+ }
1054
+
1055
+ init();