chainlesschain 0.44.0 → 0.45.1

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.
@@ -0,0 +1,1136 @@
1
+ /**
2
+ * web-ui-server.js
3
+ * Creates a lightweight HTTP server that serves the ChainlessChain Web UI.
4
+ * The UI is a self-contained single-page app with an embedded WebSocket client.
5
+ *
6
+ * Usage:
7
+ * const server = createWebUIServer({ wsPort, wsToken, wsHost, projectRoot, projectName, mode });
8
+ * server.listen(18810, '127.0.0.1');
9
+ */
10
+
11
+ import http from "http";
12
+
13
+ /**
14
+ * Build the full HTML page with runtime config injected.
15
+ *
16
+ * @param {object} cfg
17
+ * @param {number} cfg.wsPort - WebSocket server port
18
+ * @param {string|null} cfg.wsToken - Optional auth token
19
+ * @param {string} cfg.wsHost - WebSocket server host (for browser)
20
+ * @param {string|null} cfg.projectRoot - Absolute project root path (null = global mode)
21
+ * @param {string|null} cfg.projectName - Human-readable project name
22
+ * @param {"project"|"global"} cfg.mode - UI mode
23
+ * @returns {string} Full HTML document
24
+ */
25
+ function buildHtml({
26
+ wsPort,
27
+ wsToken,
28
+ wsHost,
29
+ projectRoot,
30
+ projectName,
31
+ mode,
32
+ }) {
33
+ // Escape <, > and & so the JSON is safe to embed directly inside a <script> tag.
34
+ const cfg = JSON.stringify({
35
+ wsPort,
36
+ wsToken,
37
+ wsHost,
38
+ projectRoot,
39
+ projectName,
40
+ mode,
41
+ })
42
+ .replace(/</g, "\\u003c")
43
+ .replace(/>/g, "\\u003e")
44
+ .replace(/&/g, "\\u0026");
45
+ const title =
46
+ mode === "project"
47
+ ? `${projectName || "Project"} — ChainlessChain`
48
+ : "ChainlessChain — Global";
49
+
50
+ return `<!DOCTYPE html>
51
+ <html lang="zh-CN">
52
+ <head>
53
+ <meta charset="UTF-8">
54
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
55
+ <title>${escapeHtml(title)}</title>
56
+ <script>window.__CC_CONFIG__ = ${cfg};</script>
57
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
58
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.0/marked.min.js"></script>
59
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
60
+ <style>
61
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
62
+
63
+ :root {
64
+ --bg-base: #0f1117;
65
+ --bg-sidebar: #161b22;
66
+ --bg-panel: #1c2128;
67
+ --bg-input: #21262d;
68
+ --bg-bubble-user: #1f4e79;
69
+ --bg-bubble-ai: #1c2128;
70
+ --border: #30363d;
71
+ --text: #e6edf3;
72
+ --text-dim: #8b949e;
73
+ --text-muted: #484f58;
74
+ --accent: #58a6ff;
75
+ --accent-dim: #1f3a5f;
76
+ --green: #3fb950;
77
+ --red: #f85149;
78
+ --yellow: #d29922;
79
+ --radius: 8px;
80
+ --sidebar-w: 260px;
81
+ }
82
+
83
+ html, body { height: 100%; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; background: var(--bg-base); color: var(--text); font-size: 14px; line-height: 1.5; }
84
+
85
+ /* ── Layout ─────────────────────────────────────────────────────── */
86
+ #app { display: flex; height: 100vh; overflow: hidden; }
87
+
88
+ /* ── Sidebar ────────────────────────────────────────────────────── */
89
+ #sidebar {
90
+ width: var(--sidebar-w);
91
+ min-width: var(--sidebar-w);
92
+ background: var(--bg-sidebar);
93
+ border-right: 1px solid var(--border);
94
+ display: flex;
95
+ flex-direction: column;
96
+ overflow: hidden;
97
+ }
98
+
99
+ #sidebar-header {
100
+ padding: 16px;
101
+ border-bottom: 1px solid var(--border);
102
+ }
103
+
104
+ #sidebar-logo {
105
+ display: flex;
106
+ align-items: center;
107
+ gap: 8px;
108
+ margin-bottom: 12px;
109
+ }
110
+
111
+ #sidebar-logo .icon {
112
+ width: 28px; height: 28px;
113
+ background: var(--accent);
114
+ border-radius: 6px;
115
+ display: flex; align-items: center; justify-content: center;
116
+ font-size: 16px; font-weight: 700; color: #fff;
117
+ flex-shrink: 0;
118
+ }
119
+
120
+ #sidebar-logo .brand { font-weight: 600; font-size: 15px; }
121
+
122
+ #project-badge {
123
+ background: var(--accent-dim);
124
+ border: 1px solid var(--accent);
125
+ border-radius: 5px;
126
+ padding: 5px 10px;
127
+ font-size: 12px;
128
+ color: var(--accent);
129
+ word-break: break-all;
130
+ }
131
+
132
+ #project-badge .proj-name { font-weight: 600; font-size: 13px; display: block; }
133
+ #project-badge .proj-path { color: var(--text-dim); font-size: 11px; display: block; margin-top: 2px; word-break: break-all; }
134
+
135
+ #global-badge {
136
+ background: var(--bg-panel);
137
+ border: 1px solid var(--border);
138
+ border-radius: 5px;
139
+ padding: 5px 10px;
140
+ font-size: 12px;
141
+ color: var(--text-dim);
142
+ }
143
+
144
+ #btn-new-session {
145
+ margin: 12px 16px 8px;
146
+ width: calc(100% - 32px);
147
+ padding: 8px 12px;
148
+ background: var(--accent);
149
+ color: #fff;
150
+ border: none;
151
+ border-radius: var(--radius);
152
+ font-size: 13px;
153
+ font-weight: 500;
154
+ cursor: pointer;
155
+ display: flex; align-items: center; gap: 6px; justify-content: center;
156
+ transition: opacity 0.15s;
157
+ }
158
+ #btn-new-session:hover { opacity: 0.85; }
159
+
160
+ #session-list {
161
+ flex: 1;
162
+ overflow-y: auto;
163
+ padding: 4px 8px 8px;
164
+ }
165
+
166
+ #session-list::-webkit-scrollbar { width: 4px; }
167
+ #session-list::-webkit-scrollbar-track { background: transparent; }
168
+ #session-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
169
+
170
+ .session-item {
171
+ padding: 8px 10px;
172
+ border-radius: var(--radius);
173
+ cursor: pointer;
174
+ display: flex;
175
+ align-items: flex-start;
176
+ gap: 8px;
177
+ margin-bottom: 2px;
178
+ transition: background 0.1s;
179
+ }
180
+ .session-item:hover { background: var(--bg-panel); }
181
+ .session-item.active { background: var(--accent-dim); }
182
+
183
+ .session-item .s-icon { font-size: 14px; flex-shrink: 0; margin-top: 1px; }
184
+ .session-item .s-info { min-width: 0; }
185
+ .session-item .s-title { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
186
+ .session-item .s-meta { font-size: 11px; color: var(--text-dim); margin-top: 2px; }
187
+
188
+ #sidebar-footer {
189
+ padding: 12px 16px;
190
+ border-top: 1px solid var(--border);
191
+ font-size: 11px;
192
+ color: var(--text-muted);
193
+ display: flex;
194
+ align-items: center;
195
+ justify-content: space-between;
196
+ }
197
+
198
+ #conn-status { display: flex; align-items: center; gap: 5px; }
199
+ #conn-dot {
200
+ width: 7px; height: 7px;
201
+ border-radius: 50%;
202
+ background: var(--text-muted);
203
+ }
204
+ #conn-dot.connected { background: var(--green); }
205
+ #conn-dot.error { background: var(--red); }
206
+
207
+ /* ── Main area ──────────────────────────────────────────────────── */
208
+ #main {
209
+ flex: 1;
210
+ display: flex;
211
+ flex-direction: column;
212
+ overflow: hidden;
213
+ background: var(--bg-base);
214
+ }
215
+
216
+ #chat-header {
217
+ padding: 14px 20px;
218
+ border-bottom: 1px solid var(--border);
219
+ background: var(--bg-panel);
220
+ display: flex;
221
+ align-items: center;
222
+ gap: 12px;
223
+ }
224
+
225
+ #chat-title { font-size: 15px; font-weight: 600; }
226
+ #chat-subtitle { font-size: 12px; color: var(--text-dim); margin-left: auto; }
227
+
228
+ #session-type-tabs {
229
+ display: flex;
230
+ gap: 4px;
231
+ }
232
+
233
+ .tab-btn {
234
+ padding: 4px 12px;
235
+ border-radius: 5px;
236
+ border: 1px solid var(--border);
237
+ background: transparent;
238
+ color: var(--text-dim);
239
+ font-size: 12px;
240
+ cursor: pointer;
241
+ transition: all 0.1s;
242
+ }
243
+ .tab-btn:hover { background: var(--bg-input); color: var(--text); }
244
+ .tab-btn.active { background: var(--accent-dim); border-color: var(--accent); color: var(--accent); }
245
+
246
+ /* ── Messages ───────────────────────────────────────────────────── */
247
+ #messages {
248
+ flex: 1;
249
+ overflow-y: auto;
250
+ padding: 20px;
251
+ display: flex;
252
+ flex-direction: column;
253
+ gap: 16px;
254
+ }
255
+
256
+ #messages::-webkit-scrollbar { width: 6px; }
257
+ #messages::-webkit-scrollbar-track { background: transparent; }
258
+ #messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
259
+
260
+ .message { display: flex; gap: 10px; align-items: flex-start; max-width: 100%; }
261
+ .message.user { flex-direction: row-reverse; }
262
+
263
+ .msg-avatar {
264
+ width: 30px; height: 30px;
265
+ border-radius: 50%;
266
+ flex-shrink: 0;
267
+ display: flex; align-items: center; justify-content: center;
268
+ font-size: 14px;
269
+ }
270
+ .message.user .msg-avatar { background: var(--accent); color: #fff; }
271
+ .message.ai .msg-avatar { background: var(--bg-panel); border: 1px solid var(--border); }
272
+ .message.system .msg-avatar { background: var(--bg-panel); border: 1px solid var(--border); font-size: 12px; }
273
+
274
+ .msg-bubble {
275
+ max-width: calc(100% - 80px);
276
+ padding: 10px 14px;
277
+ border-radius: 12px;
278
+ font-size: 14px;
279
+ line-height: 1.6;
280
+ word-break: break-word;
281
+ }
282
+
283
+ .message.user .msg-bubble {
284
+ background: var(--bg-bubble-user);
285
+ border-radius: 12px 4px 12px 12px;
286
+ }
287
+ .message.ai .msg-bubble {
288
+ background: var(--bg-bubble-ai);
289
+ border: 1px solid var(--border);
290
+ border-radius: 4px 12px 12px 12px;
291
+ }
292
+ .message.system .msg-bubble {
293
+ background: transparent;
294
+ border: 1px dashed var(--border);
295
+ color: var(--text-dim);
296
+ font-size: 12px;
297
+ border-radius: var(--radius);
298
+ }
299
+
300
+ /* Markdown inside AI bubbles */
301
+ .msg-bubble h1, .msg-bubble h2, .msg-bubble h3 { margin: 12px 0 6px; font-size: 1em; }
302
+ .msg-bubble p { margin-bottom: 8px; }
303
+ .msg-bubble p:last-child { margin-bottom: 0; }
304
+ .msg-bubble ul, .msg-bubble ol { padding-left: 20px; margin-bottom: 8px; }
305
+ .msg-bubble li { margin-bottom: 3px; }
306
+ .msg-bubble pre { margin: 8px 0; border-radius: 6px; overflow: auto; }
307
+ .msg-bubble pre code { font-size: 12px; }
308
+ .msg-bubble code:not(pre code) { background: var(--bg-input); padding: 1px 5px; border-radius: 4px; font-size: 12px; }
309
+ .msg-bubble blockquote { border-left: 3px solid var(--border); padding-left: 10px; color: var(--text-dim); margin: 8px 0; }
310
+ .msg-bubble table { border-collapse: collapse; margin: 8px 0; width: 100%; }
311
+ .msg-bubble th, .msg-bubble td { border: 1px solid var(--border); padding: 5px 10px; }
312
+ .msg-bubble th { background: var(--bg-input); }
313
+ .msg-bubble a { color: var(--accent); text-decoration: none; }
314
+ .msg-bubble a:hover { text-decoration: underline; }
315
+
316
+ .typing-indicator { display: flex; gap: 4px; align-items: center; padding: 4px 0; }
317
+ .typing-indicator span {
318
+ width: 6px; height: 6px; border-radius: 50%; background: var(--text-dim);
319
+ animation: blink 1.2s infinite;
320
+ }
321
+ .typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
322
+ .typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
323
+ @keyframes blink { 0%,80%,100% { opacity: 0.2; } 40% { opacity: 1; } }
324
+
325
+ /* ── Input area ─────────────────────────────────────────────────── */
326
+ #input-area {
327
+ padding: 16px 20px;
328
+ border-top: 1px solid var(--border);
329
+ background: var(--bg-panel);
330
+ }
331
+
332
+ #input-row {
333
+ display: flex;
334
+ gap: 10px;
335
+ align-items: flex-end;
336
+ }
337
+
338
+ #msg-input {
339
+ flex: 1;
340
+ background: var(--bg-input);
341
+ border: 1px solid var(--border);
342
+ border-radius: var(--radius);
343
+ padding: 10px 14px;
344
+ color: var(--text);
345
+ font-size: 14px;
346
+ font-family: inherit;
347
+ resize: none;
348
+ max-height: 150px;
349
+ min-height: 42px;
350
+ outline: none;
351
+ transition: border-color 0.15s;
352
+ line-height: 1.5;
353
+ }
354
+ #msg-input:focus { border-color: var(--accent); }
355
+ #msg-input::placeholder { color: var(--text-muted); }
356
+
357
+ #btn-send {
358
+ padding: 10px 18px;
359
+ background: var(--accent);
360
+ color: #fff;
361
+ border: none;
362
+ border-radius: var(--radius);
363
+ font-size: 14px;
364
+ cursor: pointer;
365
+ flex-shrink: 0;
366
+ transition: opacity 0.15s;
367
+ height: 42px;
368
+ }
369
+ #btn-send:hover:not(:disabled) { opacity: 0.85; }
370
+ #btn-send:disabled { opacity: 0.4; cursor: not-allowed; }
371
+
372
+ #input-hint { font-size: 11px; color: var(--text-muted); margin-top: 6px; }
373
+
374
+ /* ── Empty state ────────────────────────────────────────────────── */
375
+ #empty-state {
376
+ flex: 1;
377
+ display: flex;
378
+ flex-direction: column;
379
+ align-items: center;
380
+ justify-content: center;
381
+ gap: 14px;
382
+ color: var(--text-dim);
383
+ padding: 40px;
384
+ text-align: center;
385
+ }
386
+ #empty-state .es-icon { font-size: 48px; opacity: 0.5; }
387
+ #empty-state h2 { font-size: 20px; color: var(--text); }
388
+ #empty-state p { font-size: 14px; max-width: 320px; }
389
+
390
+ /* ── Question dialog (slot-filling) ─────────────────────────────── */
391
+ #question-overlay {
392
+ display: none;
393
+ position: fixed; inset: 0;
394
+ background: rgba(0,0,0,0.6);
395
+ z-index: 100;
396
+ align-items: center;
397
+ justify-content: center;
398
+ }
399
+ #question-overlay.active { display: flex; }
400
+
401
+ #question-box {
402
+ background: var(--bg-panel);
403
+ border: 1px solid var(--border);
404
+ border-radius: 12px;
405
+ padding: 24px;
406
+ width: 400px;
407
+ max-width: 90vw;
408
+ }
409
+ #question-box h3 { font-size: 15px; margin-bottom: 12px; }
410
+ #question-text { font-size: 14px; color: var(--text-dim); margin-bottom: 16px; }
411
+ #question-options { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; }
412
+ #question-input-wrap { margin-bottom: 16px; }
413
+ #question-input-wrap input {
414
+ width: 100%; padding: 8px 12px;
415
+ background: var(--bg-input); border: 1px solid var(--border);
416
+ border-radius: var(--radius); color: var(--text); font-size: 14px; outline: none;
417
+ }
418
+ #question-input-wrap input:focus { border-color: var(--accent); }
419
+ .q-option {
420
+ padding: 8px 12px;
421
+ background: var(--bg-input);
422
+ border: 1px solid var(--border);
423
+ border-radius: var(--radius);
424
+ cursor: pointer;
425
+ font-size: 13px;
426
+ color: var(--text);
427
+ text-align: left;
428
+ transition: border-color 0.1s;
429
+ }
430
+ .q-option:hover { border-color: var(--accent); }
431
+ #btn-question-submit {
432
+ width: 100%; padding: 9px;
433
+ background: var(--accent); color: #fff;
434
+ border: none; border-radius: var(--radius);
435
+ font-size: 14px; cursor: pointer;
436
+ }
437
+ #btn-question-submit:hover { opacity: 0.85; }
438
+ </style>
439
+ </head>
440
+ <body>
441
+ <div id="app">
442
+ <!-- Sidebar -->
443
+ <nav id="sidebar">
444
+ <div id="sidebar-header">
445
+ <div id="sidebar-logo">
446
+ <div class="icon">C</div>
447
+ <span class="brand">ChainlessChain</span>
448
+ </div>
449
+ <div id="mode-badge"></div>
450
+ </div>
451
+
452
+ <button id="btn-new-session">
453
+ <span>+</span> 新建会话
454
+ </button>
455
+
456
+ <div id="session-list"></div>
457
+
458
+ <div id="sidebar-footer">
459
+ <div id="conn-status">
460
+ <div id="conn-dot"></div>
461
+ <span id="conn-label">未连接</span>
462
+ </div>
463
+ <span id="version-label">v5.0.2.3</span>
464
+ </div>
465
+ </nav>
466
+
467
+ <!-- Main chat area -->
468
+ <main id="main">
469
+ <div id="chat-header">
470
+ <span id="chat-title">欢迎</span>
471
+ <div id="session-type-tabs">
472
+ <button class="tab-btn active" data-type="agent">Agent</button>
473
+ <button class="tab-btn" data-type="chat">Chat</button>
474
+ </div>
475
+ <span id="chat-subtitle"></span>
476
+ </div>
477
+
478
+ <div id="empty-state">
479
+ <div class="es-icon">🤖</div>
480
+ <h2>开始对话</h2>
481
+ <p id="empty-desc"></p>
482
+ </div>
483
+
484
+ <div id="messages" style="display:none"></div>
485
+
486
+ <div id="input-area">
487
+ <div id="input-row">
488
+ <textarea id="msg-input" rows="1" placeholder="输入消息... (Enter 发送,Shift+Enter 换行)"></textarea>
489
+ <button id="btn-send" disabled>发送</button>
490
+ </div>
491
+ <div id="input-hint">使用 /help 查看可用命令</div>
492
+ </div>
493
+ </main>
494
+ </div>
495
+
496
+ <!-- Question dialog -->
497
+ <div id="question-overlay">
498
+ <div id="question-box">
499
+ <h3>⚙️ 需要您输入</h3>
500
+ <div id="question-text"></div>
501
+ <div id="question-options"></div>
502
+ <div id="question-input-wrap" style="display:none">
503
+ <input id="question-input" type="text" placeholder="请输入...">
504
+ </div>
505
+ <button id="btn-question-submit">确认</button>
506
+ </div>
507
+ </div>
508
+
509
+ <script>
510
+ (function () {
511
+ 'use strict';
512
+
513
+ // ── Config ──────────────────────────────────────────────────────────────
514
+ const CFG = window.__CC_CONFIG__;
515
+ const WS_URL = 'ws://' + CFG.wsHost + ':' + CFG.wsPort;
516
+
517
+ // ── State ────────────────────────────────────────────────────────────────
518
+ let ws = null;
519
+ let wsReady = false;
520
+ let currentSessionId = null;
521
+ let streamingMsgId = null;
522
+ let streamBuffer = '';
523
+ let selectedSessionType = 'agent';
524
+ let pendingQuestionResolve = null;
525
+ let _msgId = 0;
526
+ const sessions = new Map(); // id → { id, title, type, createdAt }
527
+
528
+ // ── DOM refs ─────────────────────────────────────────────────────────────
529
+ const $ = id => document.getElementById(id);
530
+ const modeBadge = $('mode-badge');
531
+ const sessionList = $('session-list');
532
+ const btnNewSession = $('btn-new-session');
533
+ const messages = $('messages');
534
+ const emptyState = $('empty-state');
535
+ const emptyDesc = $('empty-desc');
536
+ const msgInput = $('msg-input');
537
+ const btnSend = $('btn-send');
538
+ const chatTitle = $('chat-title');
539
+ const chatSubtitle = $('chat-subtitle');
540
+ const connDot = $('conn-dot');
541
+ const connLabel = $('conn-label');
542
+ const questionOverlay = $('question-overlay');
543
+ const questionText = $('question-text');
544
+ const questionOptions = $('question-options');
545
+ const questionInputWrap = $('question-input-wrap');
546
+ const questionInputEl = $('question-input');
547
+ const btnQuestionSubmit = $('btn-question-submit');
548
+ const tabBtns = document.querySelectorAll('.tab-btn');
549
+
550
+ // ── Init mode badge ─────────────────────────────────────────────────��────
551
+ if (CFG.mode === 'project') {
552
+ modeBadge.innerHTML =
553
+ '<div id="project-badge">' +
554
+ '<span class="proj-name">' + esc(CFG.projectName || 'Project') + '</span>' +
555
+ '<span class="proj-path">' + esc(CFG.projectRoot || '') + '</span>' +
556
+ '</div>';
557
+ emptyDesc.textContent = '当前已绑定项目 ' + (CFG.projectName || '') + ',AI 将结合项目上下文回答。';
558
+ } else {
559
+ modeBadge.innerHTML = '<div id="global-badge">🌐 全局模式</div>';
560
+ emptyDesc.textContent = '全局模式:未绑定项目,可直接对话或管理项目。';
561
+ }
562
+
563
+ // ── marked.js config ────────────────────────────────────────────────────
564
+ if (window.marked) {
565
+ marked.setOptions({
566
+ highlight: (code, lang) => {
567
+ if (window.hljs && lang && hljs.getLanguage(lang)) {
568
+ return hljs.highlight(code, { language: lang }).value;
569
+ }
570
+ return window.hljs ? hljs.highlightAuto(code).value : code;
571
+ },
572
+ breaks: true,
573
+ gfm: true,
574
+ });
575
+ }
576
+
577
+ // ── WebSocket ────────────────────────────────────────────────────────────
578
+ function connect() {
579
+ setConnStatus('connecting');
580
+ try {
581
+ ws = new WebSocket(WS_URL);
582
+ } catch (e) {
583
+ setConnStatus('error');
584
+ return;
585
+ }
586
+
587
+ ws.onopen = () => {
588
+ if (CFG.wsToken) {
589
+ send({ type: 'auth', token: CFG.wsToken });
590
+ } else {
591
+ onAuthenticated();
592
+ }
593
+ };
594
+
595
+ ws.onmessage = ev => {
596
+ let msg;
597
+ try { msg = JSON.parse(ev.data); } catch { return; }
598
+ handleMessage(msg);
599
+ };
600
+
601
+ ws.onclose = () => {
602
+ wsReady = false;
603
+ setConnStatus('disconnected');
604
+ btnSend.disabled = true;
605
+ setTimeout(connect, 3000);
606
+ };
607
+
608
+ ws.onerror = () => {
609
+ setConnStatus('error');
610
+ };
611
+ }
612
+
613
+ function send(obj) {
614
+ if (ws && ws.readyState === WebSocket.OPEN) {
615
+ if (!obj.id) { obj = Object.assign({ id: 'ui-' + (++_msgId) }, obj); }
616
+ ws.send(JSON.stringify(obj));
617
+ }
618
+ }
619
+
620
+ function onAuthenticated() {
621
+ wsReady = true;
622
+ setConnStatus('connected');
623
+ btnSend.disabled = false;
624
+ // Load session list
625
+ send({ type: 'session-list' });
626
+ }
627
+
628
+ function handleMessage(msg) {
629
+ switch (msg.type) {
630
+ case 'auth-result':
631
+ if (msg.success) {
632
+ onAuthenticated();
633
+ } else {
634
+ setConnStatus('error');
635
+ addSystemMsg('认证失败:' + (msg.message || '无效的 token'));
636
+ }
637
+ break;
638
+
639
+ case 'pong':
640
+ break;
641
+
642
+ case 'session-created':
643
+ // Server sends { sessionId, sessionType }
644
+ if (msg.sessionId) {
645
+ sessions.set(msg.sessionId, {
646
+ id: msg.sessionId,
647
+ title: (msg.sessionType || 'agent') === 'agent' ? 'Agent 会话' : 'Chat 会话',
648
+ type: msg.sessionType || 'agent',
649
+ createdAt: Date.now(),
650
+ });
651
+ renderSessionList();
652
+ if (currentSessionId === msg.sessionId) {
653
+ updateChatHeader();
654
+ }
655
+ }
656
+ break;
657
+
658
+ case 'session-list-result':
659
+ if (Array.isArray(msg.sessions)) {
660
+ msg.sessions.forEach(s => {
661
+ sessions.set(s.id, {
662
+ id: s.id,
663
+ title: s.title || '会话',
664
+ type: s.type || 'agent',
665
+ createdAt: s.createdAt || 0,
666
+ });
667
+ });
668
+ renderSessionList();
669
+ }
670
+ break;
671
+
672
+ // Streaming: chat handler emits response-token per token
673
+ case 'response-token':
674
+ if (msg.sessionId === currentSessionId) {
675
+ if (!streamingMsgId) {
676
+ hideTyping();
677
+ streamBuffer = '';
678
+ streamingMsgId = 'stream-' + Date.now();
679
+ appendAiMsgStreaming(streamingMsgId);
680
+ }
681
+ streamBuffer += (msg.token || '');
682
+ updateStreamingMsg(streamingMsgId, streamBuffer);
683
+ }
684
+ break;
685
+
686
+ // Final response: both agent and chat handlers emit response-complete
687
+ case 'response-complete':
688
+ if (msg.sessionId === currentSessionId) {
689
+ if (streamingMsgId) {
690
+ finalizeStreamingMsg(streamingMsgId, streamBuffer || msg.content || '');
691
+ streamingMsgId = null;
692
+ streamBuffer = '';
693
+ } else {
694
+ // Agent mode: no token stream, show full response at once
695
+ hideTyping();
696
+ if (msg.content) appendAiMsg(msg.content);
697
+ }
698
+ maybeUpdateSessionTitle(currentSessionId);
699
+ }
700
+ break;
701
+
702
+ // Agent tool events — show as system info
703
+ case 'tool-executing':
704
+ if (msg.sessionId === currentSessionId) {
705
+ addSystemMsg('🔧 ' + (msg.display || msg.tool || '工具调用中...'));
706
+ }
707
+ break;
708
+
709
+ case 'tool-result':
710
+ // Silently consumed — agent will emit response-complete after
711
+ break;
712
+
713
+ case 'model-switch':
714
+ if (msg.sessionId === currentSessionId) {
715
+ addSystemMsg('🔄 模型切换: ' + msg.from + ' → ' + msg.to + ' (' + msg.reason + ')');
716
+ }
717
+ break;
718
+
719
+ case 'command-response':
720
+ if (msg.sessionId === currentSessionId) {
721
+ addSystemMsg('✅ ' + JSON.stringify(msg.result || {}));
722
+ }
723
+ break;
724
+
725
+ case 'error':
726
+ hideTyping();
727
+ if (msg.sessionId === currentSessionId || !msg.sessionId) {
728
+ addSystemMsg('错误:' + (msg.message || msg.error || '未知错误'));
729
+ }
730
+ break;
731
+
732
+ case 'question':
733
+ // Interactive question from slot-filler or agent
734
+ if (msg.sessionId === currentSessionId) {
735
+ showQuestion(msg);
736
+ }
737
+ break;
738
+
739
+ default:
740
+ break;
741
+ }
742
+ }
743
+
744
+ // ── Session management ───────────────────────────────────────────────────
745
+ function createSession() {
746
+ const tempId = 'pending-' + Date.now();
747
+ currentSessionId = tempId;
748
+
749
+ // Optimistically add to sidebar
750
+ sessions.set(tempId, {
751
+ id: tempId,
752
+ title: selectedSessionType === 'agent' ? 'Agent 会话' : 'Chat 会话',
753
+ type: selectedSessionType,
754
+ createdAt: Date.now(),
755
+ pending: true,
756
+ });
757
+ renderSessionList();
758
+ showMessagesArea();
759
+ updateChatHeader();
760
+
761
+ send({
762
+ type: 'session-create',
763
+ sessionType: selectedSessionType,
764
+ projectRoot: CFG.projectRoot || undefined,
765
+ });
766
+
767
+ // Listen for real session ID
768
+ const origHandler = ws.onmessage;
769
+ ws.onmessage = ev => {
770
+ let msg;
771
+ try { msg = JSON.parse(ev.data); } catch { return; }
772
+ if (msg.type === 'session-created' && msg.sessionId) {
773
+ // Replace temp id
774
+ sessions.delete(tempId);
775
+ const realId = msg.sessionId;
776
+ sessions.set(realId, {
777
+ id: realId,
778
+ title: selectedSessionType === 'agent' ? 'Agent 会话' : 'Chat 会话',
779
+ type: msg.sessionType || selectedSessionType,
780
+ createdAt: Date.now(),
781
+ });
782
+ if (currentSessionId === tempId) {
783
+ currentSessionId = realId;
784
+ updateChatHeader();
785
+ }
786
+ renderSessionList();
787
+ ws.onmessage = origHandler;
788
+ return;
789
+ }
790
+ // Pass through all other messages
791
+ handleMessage(msg);
792
+ };
793
+
794
+ addSystemMsg(
795
+ selectedSessionType === 'agent'
796
+ ? '已创建 Agent 会话,可以开始对话。输入 /help 查看可用命令。'
797
+ : '已创建 Chat 会话。'
798
+ );
799
+ }
800
+
801
+ function switchSession(id) {
802
+ if (currentSessionId === id) return;
803
+ currentSessionId = id;
804
+ renderSessionList();
805
+ updateChatHeader();
806
+ clearMessages();
807
+ showMessagesArea();
808
+ addSystemMsg('已切换到会话 ' + (sessions.get(id)?.title || id));
809
+ // Reload session history
810
+ send({ type: 'session-resume', sessionId: id });
811
+ }
812
+
813
+ function maybeUpdateSessionTitle(sessionId) {
814
+ // Use first 30 chars of first user message as title (client-side only)
815
+ const userMsgs = document.querySelectorAll('.message.user .msg-bubble');
816
+ if (userMsgs.length > 0) {
817
+ const raw = userMsgs[0].textContent.slice(0, 30).trim();
818
+ const s = sessions.get(sessionId);
819
+ if (s && (s.title === 'Agent 会话' || s.title === 'Chat 会话' || s.title === '新会话')) {
820
+ s.title = raw + (raw.length >= 30 ? '…' : '');
821
+ renderSessionList();
822
+ }
823
+ }
824
+ }
825
+
826
+ // ── UI helpers ───────────────────────────────────────────────────────────
827
+ function renderSessionList() {
828
+ const sorted = [...sessions.values()].sort((a, b) => b.createdAt - a.createdAt);
829
+ sessionList.innerHTML = sorted.map(s => {
830
+ const icon = s.type === 'agent' ? '🤖' : '💬';
831
+ const active = s.id === currentSessionId ? ' active' : '';
832
+ return '<div class="session-item' + active + '" data-id="' + esc(s.id) + '">' +
833
+ '<span class="s-icon">' + icon + '</span>' +
834
+ '<div class="s-info">' +
835
+ '<div class="s-title">' + esc(s.title) + '</div>' +
836
+ '<div class="s-meta">' + s.type + '</div>' +
837
+ '</div>' +
838
+ '</div>';
839
+ }).join('');
840
+
841
+ sessionList.querySelectorAll('.session-item').forEach(el => {
842
+ el.addEventListener('click', () => switchSession(el.dataset.id));
843
+ });
844
+ }
845
+
846
+ function updateChatHeader() {
847
+ const s = sessions.get(currentSessionId);
848
+ if (s) {
849
+ chatTitle.textContent = s.title;
850
+ chatSubtitle.textContent = s.type === 'agent' ? 'Agent 模式' : 'Chat 模式';
851
+ } else {
852
+ chatTitle.textContent = '新会话';
853
+ chatSubtitle.textContent = '';
854
+ }
855
+ }
856
+
857
+ function setConnStatus(state) {
858
+ connDot.className = '';
859
+ if (state === 'connected') {
860
+ connDot.classList.add('connected');
861
+ connLabel.textContent = '已连接';
862
+ } else if (state === 'error') {
863
+ connDot.classList.add('error');
864
+ connLabel.textContent = '连接错误';
865
+ } else if (state === 'connecting') {
866
+ connLabel.textContent = '连接中...';
867
+ } else {
868
+ connLabel.textContent = '未连接';
869
+ }
870
+ }
871
+
872
+ function showMessagesArea() {
873
+ emptyState.style.display = 'none';
874
+ messages.style.display = 'flex';
875
+ }
876
+
877
+ function clearMessages() {
878
+ messages.innerHTML = '';
879
+ }
880
+
881
+ function scrollToBottom() {
882
+ messages.scrollTop = messages.scrollHeight;
883
+ }
884
+
885
+ // ── Message rendering ────────────────────────────────────────────────────
886
+ function appendUserMsg(text) {
887
+ const el = document.createElement('div');
888
+ el.className = 'message user';
889
+ el.innerHTML =
890
+ '<div class="msg-avatar">U</div>' +
891
+ '<div class="msg-bubble">' + esc(text).replace(/\n/g, '<br>') + '</div>';
892
+ messages.appendChild(el);
893
+ scrollToBottom();
894
+ }
895
+
896
+ function appendAiMsg(text) {
897
+ const el = document.createElement('div');
898
+ el.className = 'message ai';
899
+ el.innerHTML =
900
+ '<div class="msg-avatar">🤖</div>' +
901
+ '<div class="msg-bubble">' + renderMd(text) + '</div>';
902
+ messages.appendChild(el);
903
+ if (window.hljs) {
904
+ el.querySelectorAll('pre code').forEach(block => hljs.highlightElement(block));
905
+ }
906
+ scrollToBottom();
907
+ }
908
+
909
+ function appendAiMsgStreaming(id) {
910
+ const el = document.createElement('div');
911
+ el.className = 'message ai';
912
+ el.id = id;
913
+ el.innerHTML =
914
+ '<div class="msg-avatar">🤖</div>' +
915
+ '<div class="msg-bubble"><div class="typing-indicator"><span></span><span></span><span></span></div></div>';
916
+ messages.appendChild(el);
917
+ scrollToBottom();
918
+ }
919
+
920
+ function updateStreamingMsg(id, text) {
921
+ const el = document.getElementById(id);
922
+ if (el) {
923
+ el.querySelector('.msg-bubble').innerHTML = renderMd(text);
924
+ scrollToBottom();
925
+ }
926
+ }
927
+
928
+ function finalizeStreamingMsg(id, text) {
929
+ const el = document.getElementById(id);
930
+ if (el) {
931
+ const bubble = el.querySelector('.msg-bubble');
932
+ bubble.innerHTML = renderMd(text);
933
+ if (window.hljs) {
934
+ bubble.querySelectorAll('pre code').forEach(block => hljs.highlightElement(block));
935
+ }
936
+ scrollToBottom();
937
+ }
938
+ }
939
+
940
+ function addSystemMsg(text) {
941
+ const el = document.createElement('div');
942
+ el.className = 'message system';
943
+ el.innerHTML =
944
+ '<div class="msg-avatar">ℹ</div>' +
945
+ '<div class="msg-bubble">' + esc(text) + '</div>';
946
+ messages.appendChild(el);
947
+ scrollToBottom();
948
+ }
949
+
950
+ function showTyping() {
951
+ if (document.getElementById('typing-bubble')) return;
952
+ const el = document.createElement('div');
953
+ el.className = 'message ai';
954
+ el.id = 'typing-bubble';
955
+ el.innerHTML =
956
+ '<div class="msg-avatar">🤖</div>' +
957
+ '<div class="msg-bubble"><div class="typing-indicator"><span></span><span></span><span></span></div></div>';
958
+ messages.appendChild(el);
959
+ scrollToBottom();
960
+ }
961
+
962
+ function hideTyping() {
963
+ const el = document.getElementById('typing-bubble');
964
+ if (el) el.remove();
965
+ }
966
+
967
+ // ── Sending messages ─────────────────────────────────────────────────────
968
+ function sendMessage() {
969
+ const text = msgInput.value.trim();
970
+ if (!text || !wsReady) return;
971
+ if (!currentSessionId) { createSession(); }
972
+
973
+ msgInput.value = '';
974
+ msgInput.style.height = 'auto';
975
+ btnSend.disabled = true;
976
+
977
+ appendUserMsg(text);
978
+ showMessagesArea();
979
+ showTyping();
980
+
981
+ send({
982
+ type: 'session-message',
983
+ sessionId: currentSessionId,
984
+ content: text,
985
+ });
986
+
987
+ // Re-enable after brief delay (server will stream back)
988
+ setTimeout(() => { btnSend.disabled = false; }, 500);
989
+ }
990
+
991
+ // ── Question dialog ──────────────────────────────────────────────────────
992
+ function showQuestion(msg) {
993
+ questionText.textContent = msg.message || msg.question || '请输入:';
994
+ questionOptions.innerHTML = '';
995
+ questionInputEl.value = '';
996
+
997
+ if (Array.isArray(msg.choices) && msg.choices.length > 0) {
998
+ questionInputWrap.style.display = 'none';
999
+ msg.choices.forEach(c => {
1000
+ const btn = document.createElement('button');
1001
+ btn.className = 'q-option';
1002
+ btn.textContent = c;
1003
+ btn.addEventListener('click', () => submitQuestion(c, msg.requestId));
1004
+ questionOptions.appendChild(btn);
1005
+ });
1006
+ } else {
1007
+ questionInputWrap.style.display = 'block';
1008
+ }
1009
+
1010
+ pendingQuestionResolve = { requestId: msg.requestId };
1011
+ questionOverlay.classList.add('active');
1012
+ questionInputEl.focus();
1013
+ }
1014
+
1015
+ function submitQuestion(value, requestId) {
1016
+ questionOverlay.classList.remove('active');
1017
+ pendingQuestionResolve = null;
1018
+ send({
1019
+ type: 'session-answer',
1020
+ sessionId: currentSessionId,
1021
+ requestId: requestId,
1022
+ answer: value,
1023
+ });
1024
+ }
1025
+
1026
+ // ── Event listeners ──────────────────────────────────────────────────────
1027
+ btnNewSession.addEventListener('click', () => {
1028
+ clearMessages();
1029
+ currentSessionId = null;
1030
+ createSession();
1031
+ });
1032
+
1033
+ btnSend.addEventListener('click', sendMessage);
1034
+
1035
+ msgInput.addEventListener('keydown', ev => {
1036
+ if (ev.key === 'Enter' && !ev.shiftKey) {
1037
+ ev.preventDefault();
1038
+ sendMessage();
1039
+ }
1040
+ });
1041
+
1042
+ msgInput.addEventListener('input', () => {
1043
+ msgInput.style.height = 'auto';
1044
+ msgInput.style.height = Math.min(msgInput.scrollHeight, 150) + 'px';
1045
+ });
1046
+
1047
+ tabBtns.forEach(btn => {
1048
+ btn.addEventListener('click', () => {
1049
+ tabBtns.forEach(b => b.classList.remove('active'));
1050
+ btn.classList.add('active');
1051
+ selectedSessionType = btn.dataset.type;
1052
+ });
1053
+ });
1054
+
1055
+ btnQuestionSubmit.addEventListener('click', () => {
1056
+ if (pendingQuestionResolve) {
1057
+ submitQuestion(questionInputEl.value, pendingQuestionResolve.requestId);
1058
+ }
1059
+ });
1060
+
1061
+ questionInputEl.addEventListener('keydown', ev => {
1062
+ if (ev.key === 'Enter' && pendingQuestionResolve) {
1063
+ submitQuestion(questionInputEl.value, pendingQuestionResolve.requestId);
1064
+ }
1065
+ });
1066
+
1067
+ // ── Utility ──────────────────────────────────────────────────────────────
1068
+ function esc(s) {
1069
+ return String(s || '')
1070
+ .replace(/&/g, '&amp;')
1071
+ .replace(/</g, '&lt;')
1072
+ .replace(/>/g, '&gt;')
1073
+ .replace(/"/g, '&quot;');
1074
+ }
1075
+
1076
+ function renderMd(text) {
1077
+ if (window.marked) {
1078
+ try { return marked.parse(text); } catch (_) { /* fall through */ }
1079
+ }
1080
+ return esc(text).replace(/\n/g, '<br>');
1081
+ }
1082
+
1083
+ // ── Start ────────────────────────────────────────────────────────────────
1084
+ connect();
1085
+ })();
1086
+ </script>
1087
+ </body>
1088
+ </html>`;
1089
+ }
1090
+
1091
+ /**
1092
+ * Escape HTML special characters (server-side, for injecting into attributes/text).
1093
+ */
1094
+ function escapeHtml(str) {
1095
+ return String(str)
1096
+ .replace(/&/g, "&amp;")
1097
+ .replace(/</g, "&lt;")
1098
+ .replace(/>/g, "&gt;")
1099
+ .replace(/"/g, "&quot;");
1100
+ }
1101
+
1102
+ /**
1103
+ * Create and return a Node.js HTTP server that serves the Web UI.
1104
+ *
1105
+ * @param {object} opts
1106
+ * @param {number} opts.wsPort
1107
+ * @param {string|null} opts.wsToken
1108
+ * @param {string} opts.wsHost
1109
+ * @param {string|null} opts.projectRoot
1110
+ * @param {string|null} opts.projectName
1111
+ * @param {"project"|"global"} opts.mode
1112
+ * @returns {import("http").Server}
1113
+ */
1114
+ export function createWebUIServer(opts) {
1115
+ const html = buildHtml(opts);
1116
+
1117
+ return http.createServer((req, res) => {
1118
+ // Only serve GET / and GET /index.html (strip query string for comparison)
1119
+ const urlPath = req.url.split("?")[0];
1120
+ if (
1121
+ req.method !== "GET" ||
1122
+ (urlPath !== "/" && urlPath !== "/index.html")
1123
+ ) {
1124
+ res.writeHead(404, { "Content-Type": "text/plain" });
1125
+ res.end("Not Found");
1126
+ return;
1127
+ }
1128
+
1129
+ res.writeHead(200, {
1130
+ "Content-Type": "text/html; charset=utf-8",
1131
+ "Cache-Control": "no-store",
1132
+ "X-Content-Type-Options": "nosniff",
1133
+ });
1134
+ res.end(html, "utf-8");
1135
+ });
1136
+ }