linco-connect 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.
@@ -0,0 +1,1457 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Linco × Agent</title>
7
+ <style>
8
+ :root {
9
+ --primary: #1677ff;
10
+ --primary-hover: #0958d9;
11
+ --bg: #f0f2f5;
12
+ --card-bg: #ffffff;
13
+ --user-bubble: #1677ff;
14
+ --assistant-bubble: #f5f5f5;
15
+ --system-color: #999;
16
+ --danger: #ff4d4f;
17
+ --text-dark: #333;
18
+ --text-light: #fff;
19
+ --border: #e8e8e8;
20
+ --radius: 12px;
21
+ }
22
+
23
+ * { margin: 0; padding: 0; box-sizing: border-box; }
24
+
25
+ body {
26
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
27
+ display: flex;
28
+ justify-content: center;
29
+ align-items: center;
30
+ height: 100vh;
31
+ background: var(--bg);
32
+ }
33
+
34
+ #chat-container {
35
+ width: 100%;
36
+ max-width: 860px;
37
+ height: 95vh;
38
+ background: var(--card-bg);
39
+ border-radius: var(--radius);
40
+ box-shadow: 0 4px 24px rgba(0,0,0,0.08);
41
+ display: flex;
42
+ flex-direction: column;
43
+ overflow: hidden;
44
+ }
45
+
46
+ #header {
47
+ padding: 14px 20px;
48
+ background: #1a1a2e;
49
+ color: #fff;
50
+ font-size: 16px;
51
+ font-weight: 600;
52
+ display: flex;
53
+ align-items: center;
54
+ gap: 8px;
55
+ flex-shrink: 0;
56
+ }
57
+
58
+ #status-dot {
59
+ width: 8px; height: 8px;
60
+ border-radius: 50%;
61
+ background: #52c41a;
62
+ margin-left: auto;
63
+ transition: background 0.3s;
64
+ }
65
+ #status-dot.disconnected { background: #ff4d4f; }
66
+
67
+ #agent-select {
68
+ background: rgba(255,255,255,0.12);
69
+ color: #fff;
70
+ border: 1px solid rgba(255,255,255,0.28);
71
+ border-radius: 8px;
72
+ padding: 4px 8px;
73
+ font-size: 13px;
74
+ outline: none;
75
+ }
76
+
77
+ #agent-select option { color: #333; }
78
+
79
+ #messages {
80
+ flex: 1;
81
+ overflow-y: auto;
82
+ padding: 16px 20px;
83
+ display: flex;
84
+ flex-direction: column;
85
+ gap: 10px;
86
+ }
87
+
88
+ .message {
89
+ max-width: 88%;
90
+ padding: 10px 14px;
91
+ border-radius: 12px;
92
+ line-height: 1.7;
93
+ font-size: 14px;
94
+ word-break: break-word;
95
+ white-space: pre-wrap;
96
+ animation: fadeIn 0.2s ease;
97
+ }
98
+
99
+ @keyframes fadeIn {
100
+ from { opacity: 0; transform: translateY(8px); }
101
+ to { opacity: 1; transform: translateY(0); }
102
+ }
103
+
104
+ .message.user {
105
+ align-self: flex-end;
106
+ background: var(--user-bubble);
107
+ color: var(--text-light);
108
+ border-bottom-right-radius: 4px;
109
+ }
110
+
111
+ .message.assistant {
112
+ align-self: flex-start;
113
+ background: var(--assistant-bubble);
114
+ color: var(--text-dark);
115
+ border-bottom-left-radius: 4px;
116
+ border: 1px solid var(--border);
117
+ }
118
+
119
+ .message.assistant.markdown {
120
+ white-space: normal;
121
+ line-height: 1.72;
122
+ }
123
+ .message.assistant.markdown > :first-child { margin-top: 0; }
124
+ .message.assistant.markdown > :last-child { margin-bottom: 0; }
125
+ .message.assistant.markdown p { margin: 0 0 0.8em; }
126
+ .message.assistant.markdown h1,
127
+ .message.assistant.markdown h2,
128
+ .message.assistant.markdown h3,
129
+ .message.assistant.markdown h4 {
130
+ margin: 1em 0 0.5em;
131
+ line-height: 1.35;
132
+ color: #1f2f46;
133
+ font-weight: 700;
134
+ }
135
+ .message.assistant.markdown h1 { font-size: 20px; }
136
+ .message.assistant.markdown h2 {
137
+ font-size: 18px;
138
+ padding-bottom: 5px;
139
+ border-bottom: 1px solid #e4edff;
140
+ }
141
+ .message.assistant.markdown h3 {
142
+ font-size: 16px;
143
+ color: #244266;
144
+ }
145
+ .message.assistant.markdown h4 { font-size: 15px; }
146
+ .message.assistant.markdown ul,
147
+ .message.assistant.markdown ol {
148
+ margin: 0 0 0.85em 1.25em;
149
+ padding: 0;
150
+ }
151
+ .message.assistant.markdown li {
152
+ margin: 0.28em 0;
153
+ padding-left: 0.15em;
154
+ }
155
+ .message.assistant.markdown li::marker { color: #1677ff; }
156
+ .message.assistant.markdown blockquote {
157
+ margin: 0.85em 0;
158
+ padding: 0.35em 0 0.35em 0.9em;
159
+ border-left: 3px solid #b7d1ff;
160
+ color: #5b7594;
161
+ background: rgba(22,119,255,0.04);
162
+ border-radius: 0 8px 8px 0;
163
+ }
164
+ .message.assistant.markdown code {
165
+ padding: 0.12em 0.35em;
166
+ border-radius: 4px;
167
+ background: rgba(22,119,255,0.08);
168
+ font-family: 'SF Mono', 'Menlo', 'Monaco', monospace;
169
+ font-size: 0.92em;
170
+ }
171
+ .message.assistant.markdown pre {
172
+ margin: 0.85em 0;
173
+ padding: 11px;
174
+ border-radius: 9px;
175
+ background: #fff;
176
+ border: 1px solid var(--border);
177
+ overflow-x: auto;
178
+ white-space: pre;
179
+ box-shadow: inset 0 0 0 1px rgba(22,119,255,0.03);
180
+ }
181
+ .message.assistant.markdown pre code {
182
+ padding: 0;
183
+ background: transparent;
184
+ border-radius: 0;
185
+ font-size: 12px;
186
+ }
187
+ .message.assistant.markdown table {
188
+ display: block;
189
+ width: 100%;
190
+ margin: 0.85em 0;
191
+ overflow-x: auto;
192
+ border-collapse: collapse;
193
+ font-size: 13px;
194
+ }
195
+ .message.assistant.markdown th,
196
+ .message.assistant.markdown td {
197
+ padding: 7px 9px;
198
+ border: 1px solid var(--border);
199
+ text-align: left;
200
+ white-space: nowrap;
201
+ }
202
+ .message.assistant.markdown th { background: #eef5ff; }
203
+ .message.assistant.markdown strong {
204
+ color: #1f2f46;
205
+ font-weight: 700;
206
+ }
207
+ .message.assistant.markdown hr {
208
+ height: 1px;
209
+ margin: 1em 0;
210
+ border: 0;
211
+ background: linear-gradient(90deg, transparent, #d9e6ff, transparent);
212
+ }
213
+ .message.assistant.markdown p:has(> strong:first-child) {
214
+ padding: 8px 10px;
215
+ border: 1px solid #d9e6ff;
216
+ border-radius: 10px;
217
+ background: #f8fbff;
218
+ }
219
+ .message.assistant.markdown a {
220
+ color: var(--primary);
221
+ text-decoration: none;
222
+ font-weight: 600;
223
+ }
224
+ .message.assistant.markdown a:hover { text-decoration: underline; }
225
+
226
+ .message.system {
227
+ align-self: center;
228
+ font-size: 12px;
229
+ color: var(--system-color);
230
+ background: transparent;
231
+ text-align: center;
232
+ max-width: 95%;
233
+ white-space: pre-wrap;
234
+ }
235
+
236
+ .message.error {
237
+ align-self: center;
238
+ font-size: 12px;
239
+ color: var(--danger);
240
+ background: #fff2f0;
241
+ border: 1px solid #ffccc7;
242
+ border-radius: 8px;
243
+ padding: 8px 14px;
244
+ }
245
+
246
+ .message.tool_call {
247
+ align-self: center;
248
+ font-size: 12px;
249
+ background: #fffbe6;
250
+ border: 1px solid #ffe58f;
251
+ border-radius: 8px;
252
+ padding: 8px 14px;
253
+ color: #ad8b00;
254
+ font-family: 'SF Mono', 'Menlo', 'Monaco', monospace;
255
+ }
256
+
257
+ .tool-card {
258
+ align-self: flex-start;
259
+ width: min(92%, 720px);
260
+ margin: 4px 0 -2px 0;
261
+ border: 1px solid #d9e6ff;
262
+ border-radius: 12px;
263
+ background: linear-gradient(180deg, #f8fbff 0%, #f3f7ff 100%);
264
+ color: #244266;
265
+ font-size: 12px;
266
+ overflow: hidden;
267
+ box-shadow: 0 3px 12px rgba(22, 119, 255, 0.08);
268
+ }
269
+ .tool-card.error {
270
+ border-color: #ffccc7;
271
+ background: linear-gradient(180deg, #fff7f6 0%, #fff2f0 100%);
272
+ color: #a8071a;
273
+ }
274
+ .tool-card-header {
275
+ width: 100%;
276
+ cursor: pointer;
277
+ padding: 9px 12px;
278
+ font-weight: 600;
279
+ user-select: none;
280
+ display: flex;
281
+ align-items: center;
282
+ gap: 8px;
283
+ border: 0;
284
+ background: transparent;
285
+ color: inherit;
286
+ text-align: left;
287
+ font: inherit;
288
+ }
289
+ .tool-card-toggle {
290
+ width: 18px;
291
+ height: 18px;
292
+ border-radius: 50%;
293
+ display: inline-flex;
294
+ align-items: center;
295
+ justify-content: center;
296
+ background: #e6f0ff;
297
+ color: #1677ff;
298
+ font-size: 16px;
299
+ line-height: 1;
300
+ transition: transform 0.18s ease;
301
+ flex-shrink: 0;
302
+ }
303
+ .tool-card.collapsed .tool-card-toggle { transform: rotate(-90deg); }
304
+ .tool-card.collapsed .tool-card-body { display: none; }
305
+ .tool-card-summary-main {
306
+ flex: 1;
307
+ overflow: hidden;
308
+ text-overflow: ellipsis;
309
+ white-space: nowrap;
310
+ }
311
+ .tool-card-summary-badge {
312
+ padding: 2px 7px;
313
+ border-radius: 999px;
314
+ background: #e6f0ff;
315
+ color: #1677ff;
316
+ font-size: 11px;
317
+ font-weight: 500;
318
+ flex-shrink: 0;
319
+ }
320
+ .tool-card.error .tool-card-summary-badge,
321
+ .tool-card.error .tool-card-toggle {
322
+ background: #fff1f0;
323
+ color: #cf1322;
324
+ }
325
+ .tool-card-body {
326
+ display: block;
327
+ }
328
+ .tool-section {
329
+ border-top: 1px solid rgba(22,119,255,0.12);
330
+ padding: 9px 12px 11px;
331
+ }
332
+ .tool-section-title {
333
+ font-weight: 600;
334
+ margin-bottom: 6px;
335
+ color: #5b7594;
336
+ }
337
+ .tool-card.error .tool-section-title { color: #a8071a; }
338
+ .tool-section pre {
339
+ margin: 0;
340
+ padding: 10px;
341
+ background: rgba(255,255,255,0.85);
342
+ border: 1px solid rgba(22,119,255,0.08);
343
+ border-radius: 8px;
344
+ white-space: pre-wrap;
345
+ word-break: break-word;
346
+ max-height: 260px;
347
+ overflow: auto;
348
+ font-family: 'SF Mono', 'Menlo', 'Monaco', monospace;
349
+ color: #26384d;
350
+ }
351
+
352
+ .outgoing-card {
353
+ align-self: flex-start;
354
+ width: min(88%, 520px);
355
+ padding: 12px;
356
+ border: 1px solid #d9e6ff;
357
+ border-radius: 12px;
358
+ background: #f8fbff;
359
+ box-shadow: 0 3px 12px rgba(22, 119, 255, 0.08);
360
+ display: flex;
361
+ gap: 12px;
362
+ align-items: center;
363
+ animation: fadeIn 0.2s ease;
364
+ }
365
+ .outgoing-card.error {
366
+ border-color: #ffccc7;
367
+ background: #fff2f0;
368
+ color: #cf1322;
369
+ }
370
+ .outgoing-preview {
371
+ width: 72px;
372
+ height: 72px;
373
+ border-radius: 10px;
374
+ border: 1px solid var(--border);
375
+ object-fit: cover;
376
+ background: #fff;
377
+ flex-shrink: 0;
378
+ }
379
+ .outgoing-icon {
380
+ width: 46px;
381
+ height: 46px;
382
+ border-radius: 12px;
383
+ background: #e6f0ff;
384
+ color: #1677ff;
385
+ display: flex;
386
+ align-items: center;
387
+ justify-content: center;
388
+ font-size: 22px;
389
+ flex-shrink: 0;
390
+ }
391
+ .outgoing-info {
392
+ min-width: 0;
393
+ flex: 1;
394
+ display: flex;
395
+ flex-direction: column;
396
+ gap: 4px;
397
+ font-size: 12px;
398
+ color: #5b7594;
399
+ }
400
+ .outgoing-name {
401
+ font-size: 14px;
402
+ color: #244266;
403
+ font-weight: 600;
404
+ overflow: hidden;
405
+ text-overflow: ellipsis;
406
+ white-space: nowrap;
407
+ }
408
+ .outgoing-actions {
409
+ display: flex;
410
+ gap: 8px;
411
+ margin-top: 4px;
412
+ }
413
+ .outgoing-link {
414
+ color: #1677ff;
415
+ text-decoration: none;
416
+ font-weight: 600;
417
+ }
418
+ .outgoing-link:hover { text-decoration: underline; }
419
+
420
+ .message.user img {
421
+ max-width: 180px;
422
+ border-radius: 8px;
423
+ margin-top: 6px;
424
+ display: block;
425
+ border: 1px solid rgba(255,255,255,0.35);
426
+ }
427
+
428
+ .file-list {
429
+ margin-top: 6px;
430
+ display: flex;
431
+ flex-direction: column;
432
+ gap: 4px;
433
+ font-size: 12px;
434
+ opacity: 0.95;
435
+ }
436
+
437
+ .danger-confirm {
438
+ align-self: center;
439
+ background: #fff2f0;
440
+ border: 2px solid #ff4d4f;
441
+ border-radius: 10px;
442
+ padding: 14px 18px;
443
+ max-width: 90%;
444
+ }
445
+ .danger-confirm .danger-text {
446
+ color: #cf1322;
447
+ font-size: 13px;
448
+ margin-bottom: 10px;
449
+ white-space: pre-wrap;
450
+ }
451
+ .danger-confirm .danger-buttons { display: flex; gap: 10px; justify-content: flex-end; }
452
+ .danger-confirm button {
453
+ padding: 6px 16px; border-radius: 6px; border: none;
454
+ cursor: pointer; font-size: 13px; font-weight: 500;
455
+ }
456
+ .btn-approve { background: #ff4d4f; color: #fff; }
457
+ .btn-approve:hover { background: #cf1322; }
458
+ .btn-deny { background: #f5f5f5; color: #666; border: 1px solid #d9d9d9; }
459
+ .btn-deny:hover { background: #e8e8e8; }
460
+ .permission-confirm {
461
+ background: #eef6ff;
462
+ border-color: #1677ff;
463
+ }
464
+ .permission-confirm .danger-text { color: #0958d9; }
465
+ .permission-confirm .btn-approve { background: #1677ff; }
466
+ .permission-confirm .btn-approve:hover { background: #0958d9; }
467
+
468
+ #attachment-bar {
469
+ display: none;
470
+ padding: 8px 16px;
471
+ border-top: 1px solid var(--border);
472
+ background: #fafafa;
473
+ gap: 8px;
474
+ align-items: center;
475
+ flex-wrap: wrap;
476
+ flex-shrink: 0;
477
+ }
478
+ #attachment-bar.active { display: flex; }
479
+ .attachment-chip {
480
+ display: flex;
481
+ align-items: center;
482
+ gap: 8px;
483
+ padding: 6px 8px;
484
+ background: #fff;
485
+ border: 1px solid var(--border);
486
+ border-radius: 8px;
487
+ max-width: 260px;
488
+ font-size: 12px;
489
+ color: #555;
490
+ }
491
+ .attachment-chip img {
492
+ width: 36px;
493
+ height: 36px;
494
+ border-radius: 6px;
495
+ object-fit: cover;
496
+ border: 1px solid var(--border);
497
+ flex-shrink: 0;
498
+ }
499
+ .attachment-name {
500
+ overflow: hidden;
501
+ text-overflow: ellipsis;
502
+ white-space: nowrap;
503
+ }
504
+ .remove-btn {
505
+ background: none;
506
+ border: none;
507
+ color: var(--danger);
508
+ cursor: pointer;
509
+ font-size: 14px;
510
+ padding: 4px;
511
+ flex-shrink: 0;
512
+ }
513
+
514
+ #input-area {
515
+ display: flex;
516
+ padding: 12px 16px;
517
+ border-top: 1px solid var(--border);
518
+ gap: 8px;
519
+ flex-shrink: 0;
520
+ background: #fafafa;
521
+ align-items: flex-end;
522
+ }
523
+
524
+ #input {
525
+ flex: 1;
526
+ padding: 10px 14px;
527
+ border: 1px solid #d9d9d9;
528
+ border-radius: 8px;
529
+ outline: none;
530
+ font-size: 14px;
531
+ resize: none;
532
+ font-family: inherit;
533
+ transition: border-color 0.2s;
534
+ max-height: 120px;
535
+ }
536
+ #input:focus { border-color: var(--primary); box-shadow: 0 0 0 2px rgba(22,119,255,0.1); }
537
+
538
+ .icon-btn {
539
+ width: 38px;
540
+ height: 38px;
541
+ border: none;
542
+ border-radius: 8px;
543
+ cursor: pointer;
544
+ font-size: 18px;
545
+ display: flex;
546
+ align-items: center;
547
+ justify-content: center;
548
+ transition: background 0.2s;
549
+ flex-shrink: 0;
550
+ }
551
+
552
+ #upload-btn { background: #f5f5f5; color: #666; }
553
+ #upload-btn:hover { background: #e8e8e8; }
554
+
555
+ #send-btn {
556
+ padding: 10px 20px;
557
+ background: var(--primary);
558
+ color: #fff;
559
+ border: none;
560
+ border-radius: 8px;
561
+ cursor: pointer;
562
+ font-size: 14px;
563
+ font-weight: 500;
564
+ white-space: nowrap;
565
+ transition: background 0.2s;
566
+ }
567
+ #send-btn:hover { background: var(--primary-hover); }
568
+ #send-btn:disabled { opacity: 0.5; cursor: not-allowed; background: #bbb; }
569
+ </style>
570
+ </head>
571
+ <body>
572
+ <div id="chat-container">
573
+ <div id="header">
574
+ <span id="header-title">🤖 Linco · Agent</span>
575
+ <span id="status-dot" title="连接状态"></span>
576
+ <select id="agent-select" title="选择本地测试 Agent">
577
+ <option value="claude">Claude Code</option>
578
+ <option value="codex">Codex</option>
579
+ </select>
580
+ </div>
581
+ <div id="messages"></div>
582
+ <div id="attachment-bar"></div>
583
+ <div id="input-area">
584
+ <button id="upload-btn" class="icon-btn" title="上传附件">📎</button>
585
+ <input type="file" id="file-input" multiple hidden>
586
+ <textarea id="input" rows="2" placeholder="输入消息,Enter 发送,Shift+Enter 换行...&#10;支持 csv、xlsx、sql、图片、PDF、Word 等普通文件"></textarea>
587
+ <button id="send-btn" disabled>发送</button>
588
+ </div>
589
+ </div>
590
+
591
+ <script src="https://cdn.jsdelivr.net/npm/dompurify@3.2.6/dist/purify.min.js"></script>
592
+ <script src="https://cdn.jsdelivr.net/npm/marked@15.0.12/marked.min.js"></script>
593
+ <script>
594
+ let ws = null;
595
+ const messagesEl = document.getElementById('messages');
596
+ const inputEl = document.getElementById('input');
597
+ const sendBtn = document.getElementById('send-btn');
598
+ const statusDot = document.getElementById('status-dot');
599
+ const agentSelect = document.getElementById('agent-select');
600
+ const uploadBtn = document.getElementById('upload-btn');
601
+ const fileInput = document.getElementById('file-input');
602
+ const attachmentBar = document.getElementById('attachment-bar');
603
+
604
+ const uploadLimits = {
605
+ maxCount: 50,
606
+ maxFileBytes: 50 * 1024 * 1024,
607
+ maxTotalBytes: 250 * 1024 * 1024,
608
+ blockedExtensions: ['.exe', '.msi', '.dll', '.com', '.scr', '.bat', '.cmd', '.ps1', '.vbs', '.hta', '.lnk', '.url', '.reg', '.cpl'],
609
+ };
610
+
611
+ let connected = false;
612
+ let currentSessionId = '';
613
+ let currentSessionIdSource = '';
614
+ let currentSessionKey = '';
615
+ let currentAgentType = loadAgentTypeFromUrlOrStorage();
616
+ let reconnectTimer = null;
617
+ let localToken = loadLocalToken();
618
+ let currentAssistantMsg = null;
619
+ let thinkingMsg = null;
620
+ let pendingAttachments = [];
621
+ let assistantBuffer = '';
622
+ let currentAssistantMarkdown = '';
623
+ let assistantFrame = null;
624
+
625
+ function scrollToBottom() {
626
+ messagesEl.scrollTop = messagesEl.scrollHeight;
627
+ }
628
+
629
+ function addMessage(type, text, extra = {}) {
630
+ const div = document.createElement('div');
631
+ div.className = `message ${type}`;
632
+
633
+ if (type === 'assistant') {
634
+ div.classList.add('markdown');
635
+ renderAssistantMarkdown(div, text);
636
+ } else {
637
+ const textNode = document.createElement('span');
638
+ textNode.textContent = text;
639
+ div.appendChild(textNode);
640
+ }
641
+
642
+ if (extra.attachments?.length) {
643
+ const list = document.createElement('div');
644
+ list.className = 'file-list';
645
+ for (const file of extra.attachments) {
646
+ if (file.mimeType?.startsWith('image/')) {
647
+ const img = document.createElement('img');
648
+ img.src = `data:${file.mimeType};base64,${file.base64}`;
649
+ img.alt = file.name;
650
+ list.appendChild(img);
651
+ } else {
652
+ const item = document.createElement('div');
653
+ item.textContent = `📄 ${file.name} (${formatSize(file.size)})`;
654
+ list.appendChild(item);
655
+ }
656
+ }
657
+ div.appendChild(list);
658
+ }
659
+
660
+ messagesEl.appendChild(div);
661
+ scrollToBottom();
662
+ return div;
663
+ }
664
+
665
+ function renderAssistantMarkdown(el, rawText) {
666
+ if (!el) return;
667
+
668
+ if (!window.marked || !window.DOMPurify) {
669
+ el.textContent = rawText || '';
670
+ return;
671
+ }
672
+
673
+ try {
674
+ const html = marked.parse(rawText || '', {
675
+ breaks: true,
676
+ gfm: true,
677
+ });
678
+ el.innerHTML = DOMPurify.sanitize(html, {
679
+ ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i,
680
+ });
681
+ for (const link of el.querySelectorAll('a[href]')) {
682
+ link.target = '_blank';
683
+ link.rel = 'noopener noreferrer';
684
+ }
685
+ } catch {
686
+ el.textContent = rawText || '';
687
+ }
688
+ }
689
+
690
+ function removeMessage(el) {
691
+ if (el) {
692
+ el.remove();
693
+ scrollToBottom();
694
+ }
695
+ }
696
+
697
+ function addToolCard(tool) {
698
+ const toolId = tool.id || '';
699
+ const selector = toolId ? `.tool-card[data-tool-id="${cssEscape(toolId)}"]` : '';
700
+ const existing = selector ? messagesEl.querySelector(selector) : null;
701
+ const card = existing || createToolCard(toolId, tool.name || '工具调用', '输入');
702
+
703
+ updateToolCardHeader(card, tool.name || '工具调用', card.dataset.hasResult ? null : '输入');
704
+ upsertToolSection(card, 'input', '输入', tool.input || '(无)');
705
+
706
+ if (!existing) insertToolCard(card);
707
+ scrollToBottom();
708
+ return card;
709
+ }
710
+
711
+ function createToolCard(toolId, title, badgeText) {
712
+ const card = document.createElement('div');
713
+ card.className = 'tool-card';
714
+ card.dataset.toolId = toolId || '';
715
+
716
+ const header = document.createElement('button');
717
+ header.type = 'button';
718
+ header.className = 'tool-card-header';
719
+ header.addEventListener('click', () => {
720
+ card.classList.toggle('collapsed');
721
+ });
722
+
723
+ const toggle = document.createElement('span');
724
+ toggle.className = 'tool-card-toggle';
725
+ toggle.textContent = '⌄';
726
+
727
+ const main = document.createElement('span');
728
+ main.className = 'tool-card-summary-main';
729
+ main.textContent = title;
730
+
731
+ const badge = document.createElement('span');
732
+ badge.className = 'tool-card-summary-badge';
733
+ badge.textContent = badgeText;
734
+
735
+ header.appendChild(toggle);
736
+ header.appendChild(main);
737
+ header.appendChild(badge);
738
+ card.appendChild(header);
739
+
740
+ const body = document.createElement('div');
741
+ body.className = 'tool-card-body';
742
+ card.appendChild(body);
743
+
744
+ return card;
745
+ }
746
+
747
+ function updateToolCardHeader(card, title, badgeText) {
748
+ const main = card.querySelector('.tool-card-summary-main');
749
+ if (main && title) main.textContent = title;
750
+ const badge = card.querySelector('.tool-card-summary-badge');
751
+ if (badge && badgeText) badge.textContent = badgeText;
752
+ }
753
+
754
+ function upsertToolSection(card, sectionKey, title, output) {
755
+ const body = card.querySelector('.tool-card-body') || card;
756
+ let section = body.querySelector(`.tool-section[data-section-key="${cssEscape(sectionKey)}"]`);
757
+ if (!section) {
758
+ section = createToolSection(title, output);
759
+ section.dataset.sectionKey = sectionKey;
760
+ body.appendChild(section);
761
+ return section;
762
+ }
763
+
764
+ const titleEl = section.querySelector('.tool-section-title');
765
+ if (titleEl) titleEl.textContent = title;
766
+ const pre = section.querySelector('pre');
767
+ if (pre) pre.textContent = output;
768
+ return section;
769
+ }
770
+
771
+ function createToolSection(title, output) {
772
+ const section = document.createElement('div');
773
+ section.className = 'tool-section';
774
+ const titleEl = document.createElement('div');
775
+ titleEl.className = 'tool-section-title';
776
+ titleEl.textContent = title;
777
+ const pre = document.createElement('pre');
778
+ pre.textContent = output;
779
+ section.appendChild(titleEl);
780
+ section.appendChild(pre);
781
+ return section;
782
+ }
783
+
784
+ function insertToolCard(card) {
785
+ if (currentAssistantMsg?.parentNode === messagesEl) {
786
+ messagesEl.insertBefore(card, currentAssistantMsg);
787
+ return;
788
+ }
789
+ messagesEl.appendChild(card);
790
+ }
791
+
792
+ function addToolResult(result) {
793
+ const selector = result.toolUseId ? `.tool-card[data-tool-id="${cssEscape(result.toolUseId)}"]` : '';
794
+ const card = selector ? messagesEl.querySelector(selector) : null;
795
+ const target = card || createOrphanToolResultCard(result);
796
+ target.dataset.hasResult = 'true';
797
+ if (result.isError) target.classList.add('error');
798
+ updateToolCardHeader(target, card ? null : '工具结果', result.isError ? '错误' : '完成');
799
+
800
+ upsertToolSection(target, 'result', result.isError ? '错误输出' : '输出', result.output || '(无输出)');
801
+ scrollToBottom();
802
+ }
803
+
804
+ function createOrphanToolResultCard(result) {
805
+ const card = createToolCard(result.toolUseId || '', '工具结果', result.isError ? '错误' : '输出');
806
+ insertToolCard(card);
807
+ return card;
808
+ }
809
+
810
+ function addOutgoingAttachment(file) {
811
+ flushAssistantBuffer();
812
+
813
+ const card = document.createElement('div');
814
+ card.className = 'outgoing-card';
815
+
816
+ if (file.error) {
817
+ card.classList.add('error');
818
+ const icon = document.createElement('div');
819
+ icon.className = 'outgoing-icon';
820
+ icon.textContent = '!';
821
+ const info = document.createElement('div');
822
+ info.className = 'outgoing-info';
823
+ const name = document.createElement('div');
824
+ name.className = 'outgoing-name';
825
+ name.textContent = file.name || '文件发送失败';
826
+ const meta = document.createElement('div');
827
+ meta.textContent = file.error;
828
+ info.appendChild(name);
829
+ info.appendChild(meta);
830
+ card.appendChild(icon);
831
+ card.appendChild(info);
832
+ messagesEl.appendChild(card);
833
+ scrollToBottom();
834
+ return;
835
+ }
836
+
837
+ const fileUrl = withLocalToken(file.url || '/');
838
+
839
+ if (file.kind === 'image') {
840
+ const link = document.createElement('a');
841
+ link.href = fileUrl;
842
+ link.target = '_blank';
843
+ link.rel = 'noopener noreferrer';
844
+ const img = document.createElement('img');
845
+ img.className = 'outgoing-preview';
846
+ img.src = fileUrl;
847
+ img.alt = file.name || 'image';
848
+ link.appendChild(img);
849
+ card.appendChild(link);
850
+ } else {
851
+ const icon = document.createElement('div');
852
+ icon.className = 'outgoing-icon';
853
+ icon.textContent = '📄';
854
+ card.appendChild(icon);
855
+ }
856
+
857
+ const info = document.createElement('div');
858
+ info.className = 'outgoing-info';
859
+ const name = document.createElement('div');
860
+ name.className = 'outgoing-name';
861
+ name.textContent = file.name || '未命名文件';
862
+ const meta = document.createElement('div');
863
+ meta.textContent = `${file.mimeType || 'application/octet-stream'} · ${formatSize(file.size || 0)}`;
864
+ const actions = document.createElement('div');
865
+ actions.className = 'outgoing-actions';
866
+
867
+ const openLink = document.createElement('a');
868
+ openLink.className = 'outgoing-link';
869
+ openLink.href = fileUrl;
870
+ openLink.target = '_blank';
871
+ openLink.rel = 'noopener noreferrer';
872
+ openLink.textContent = file.kind === 'image' ? '打开预览' : '打开';
873
+
874
+ const downloadLink = document.createElement('a');
875
+ downloadLink.className = 'outgoing-link';
876
+ downloadLink.href = fileUrl;
877
+ downloadLink.download = file.name || '';
878
+ downloadLink.textContent = '下载';
879
+
880
+ actions.appendChild(openLink);
881
+ actions.appendChild(downloadLink);
882
+ info.appendChild(name);
883
+ info.appendChild(meta);
884
+ info.appendChild(actions);
885
+ card.appendChild(info);
886
+ messagesEl.appendChild(card);
887
+ scrollToBottom();
888
+ }
889
+
890
+ function cssEscape(value) {
891
+ if (window.CSS?.escape) return CSS.escape(value);
892
+ return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&');
893
+ }
894
+
895
+ function addDangerConfirm(text) {
896
+ const container = document.createElement('div');
897
+ container.className = 'danger-confirm';
898
+
899
+ const textEl = document.createElement('div');
900
+ textEl.className = 'danger-text';
901
+ textEl.textContent = text;
902
+ container.appendChild(textEl);
903
+
904
+ const buttons = document.createElement('div');
905
+ buttons.className = 'danger-buttons';
906
+
907
+ const denyBtn = document.createElement('button');
908
+ denyBtn.className = 'btn-deny';
909
+ denyBtn.textContent = '取消';
910
+ denyBtn.addEventListener('click', () => {
911
+ container.remove();
912
+ scrollToBottom();
913
+ safeSend({ type: 'danger_confirm', to: currentAgentType, sessionKey: currentSessionKey, approved: false });
914
+ });
915
+
916
+ const approveBtn = document.createElement('button');
917
+ approveBtn.className = 'btn-approve';
918
+ approveBtn.textContent = '确认执行';
919
+ approveBtn.addEventListener('click', () => {
920
+ container.remove();
921
+ scrollToBottom();
922
+ safeSend({ type: 'danger_confirm', to: currentAgentType, sessionKey: currentSessionKey, approved: true });
923
+ });
924
+
925
+ buttons.appendChild(denyBtn);
926
+ buttons.appendChild(approveBtn);
927
+ container.appendChild(buttons);
928
+ messagesEl.appendChild(container);
929
+ scrollToBottom();
930
+ }
931
+
932
+ function addPermissionConfirm(data) {
933
+ const container = document.createElement('div');
934
+ container.className = 'danger-confirm permission-confirm';
935
+
936
+ const title = document.createElement('div');
937
+ title.className = 'danger-text';
938
+ title.textContent = `Claude 请求使用工具:${data.toolName || 'tool'}`;
939
+ container.appendChild(title);
940
+
941
+ if (data.input) {
942
+ const inputEl = document.createElement('pre');
943
+ inputEl.className = 'tool-input';
944
+ inputEl.textContent = data.input;
945
+ container.appendChild(inputEl);
946
+ }
947
+
948
+ const buttons = document.createElement('div');
949
+ buttons.className = 'danger-buttons';
950
+
951
+ const denyBtn = document.createElement('button');
952
+ denyBtn.className = 'btn-deny';
953
+ denyBtn.textContent = '拒绝';
954
+ denyBtn.addEventListener('click', () => {
955
+ container.remove();
956
+ scrollToBottom();
957
+ safeSend({ type: 'permission_response', to: currentAgentType, sessionKey: currentSessionKey, requestId: data.requestId, approved: false });
958
+ });
959
+
960
+ const approveBtn = document.createElement('button');
961
+ approveBtn.className = 'btn-approve';
962
+ approveBtn.textContent = '允许';
963
+ approveBtn.addEventListener('click', () => {
964
+ container.remove();
965
+ scrollToBottom();
966
+ safeSend({ type: 'permission_response', to: currentAgentType, sessionKey: currentSessionKey, requestId: data.requestId, approved: true });
967
+ });
968
+
969
+ buttons.appendChild(denyBtn);
970
+ buttons.appendChild(approveBtn);
971
+ container.appendChild(buttons);
972
+ messagesEl.appendChild(container);
973
+ scrollToBottom();
974
+ }
975
+
976
+ function setConnected(value) {
977
+ connected = value;
978
+ statusDot.className = connected ? '' : 'disconnected';
979
+ updateSendState();
980
+ }
981
+
982
+ function updateSendState() {
983
+ sendBtn.disabled = !connected;
984
+ }
985
+
986
+ function updateSessionInfo(data) {
987
+ currentSessionId = data.sessionId || currentSessionId;
988
+ currentSessionIdSource = data.sessionIdSource || currentSessionIdSource;
989
+ currentSessionKey = data.sessionKey || data.sessionId || currentSessionKey;
990
+ if (data.agentType) setAgentSelection(data.agentType, { persist: true });
991
+
992
+ if (!data.upload) return;
993
+ uploadLimits.maxCount = data.upload.maxCount || uploadLimits.maxCount;
994
+ uploadLimits.maxFileBytes = data.upload.maxFileBytes || uploadLimits.maxFileBytes;
995
+ uploadLimits.maxTotalBytes = data.upload.maxTotalBytes || uploadLimits.maxTotalBytes;
996
+ uploadLimits.blockedExtensions = Array.isArray(data.upload.blockedExtensions)
997
+ ? data.upload.blockedExtensions.map(ext => ext.toLowerCase())
998
+ : uploadLimits.blockedExtensions;
999
+ }
1000
+
1001
+ function fileToBase64(file) {
1002
+ return new Promise((resolve, reject) => {
1003
+ const reader = new FileReader();
1004
+ reader.onload = () => {
1005
+ const dataUrl = reader.result;
1006
+ const [, base64] = dataUrl.split(',');
1007
+ resolve({
1008
+ name: file.name,
1009
+ mimeType: file.type || mimeFromName(file.name),
1010
+ size: file.size,
1011
+ base64,
1012
+ });
1013
+ };
1014
+ reader.onerror = reject;
1015
+ reader.readAsDataURL(file);
1016
+ });
1017
+ }
1018
+
1019
+ async function addFiles(files) {
1020
+ const incoming = Array.from(files || []);
1021
+ if (pendingAttachments.length + incoming.length > uploadLimits.maxCount) {
1022
+ addMessage('error', `❌ 单次最多上传 ${uploadLimits.maxCount} 个附件`);
1023
+ return;
1024
+ }
1025
+
1026
+ for (const file of incoming) {
1027
+ const error = validateFile(file);
1028
+ if (error) {
1029
+ addMessage('error', error);
1030
+ continue;
1031
+ }
1032
+
1033
+ try {
1034
+ const attachment = await fileToBase64(file);
1035
+ pendingAttachments.push(attachment);
1036
+ addMessage('system', `✅ 附件已就绪: ${file.name}`);
1037
+ } catch (err) {
1038
+ addMessage('error', `❌ 读取附件失败: ${err.message}`);
1039
+ }
1040
+ }
1041
+
1042
+ renderAttachments();
1043
+ }
1044
+
1045
+ function validateFile(file) {
1046
+ const ext = extensionOf(file.name);
1047
+ if (ext && uploadLimits.blockedExtensions.includes(ext)) {
1048
+ return `❌ 出于安全原因,默认不允许上传 ${ext} 文件: ${file.name}`;
1049
+ }
1050
+ if (file.size > uploadLimits.maxFileBytes) {
1051
+ return `❌ 文件超过 ${formatSize(uploadLimits.maxFileBytes)}: ${file.name}`;
1052
+ }
1053
+ const totalSize = pendingAttachments.reduce((sum, item) => sum + (item.size || 0), 0) + file.size;
1054
+ if (totalSize > uploadLimits.maxTotalBytes) {
1055
+ return `❌ 附件总大小超过 ${formatSize(uploadLimits.maxTotalBytes)}`;
1056
+ }
1057
+ return null;
1058
+ }
1059
+
1060
+ function renderAttachments() {
1061
+ attachmentBar.textContent = '';
1062
+ attachmentBar.classList.toggle('active', pendingAttachments.length > 0);
1063
+
1064
+ for (const [index, file] of pendingAttachments.entries()) {
1065
+ const chip = document.createElement('div');
1066
+ chip.className = 'attachment-chip';
1067
+
1068
+ if (file.mimeType?.startsWith('image/')) {
1069
+ const img = document.createElement('img');
1070
+ img.src = `data:${file.mimeType};base64,${file.base64}`;
1071
+ img.alt = file.name;
1072
+ chip.appendChild(img);
1073
+ }
1074
+
1075
+ const name = document.createElement('span');
1076
+ name.className = 'attachment-name';
1077
+ name.textContent = `${file.name} (${formatSize(file.size)})`;
1078
+ chip.appendChild(name);
1079
+
1080
+ const remove = document.createElement('button');
1081
+ remove.className = 'remove-btn';
1082
+ remove.textContent = '✕';
1083
+ remove.addEventListener('click', () => {
1084
+ pendingAttachments.splice(index, 1);
1085
+ renderAttachments();
1086
+ });
1087
+ chip.appendChild(remove);
1088
+
1089
+ attachmentBar.appendChild(chip);
1090
+ }
1091
+ }
1092
+
1093
+ async function sendMessage() {
1094
+ const text = inputEl.value.trim();
1095
+ if (!text && pendingAttachments.length === 0) return;
1096
+ if (!connected) {
1097
+ addMessage('error', '本地模拟 IM 未连接,请使用 linco start --local-im 启动后再打开测试页。');
1098
+ return;
1099
+ }
1100
+
1101
+ const files = pendingAttachments.map(a => ({
1102
+ name: a.name,
1103
+ type: a.mimeType,
1104
+ base64: a.base64,
1105
+ }));
1106
+
1107
+ const lincoMsg = {
1108
+ type: 'inbound_message',
1109
+ to: currentAgentType,
1110
+ accountId: 'main',
1111
+ agentId: 'main',
1112
+ chatType: 'direct',
1113
+ userId: 'local-test-user',
1114
+ messageId: `local-msg-${Date.now()}`,
1115
+ sessionKey: currentSessionKey || currentSessionId,
1116
+ text,
1117
+ files: files.length > 0 ? files : undefined,
1118
+ };
1119
+
1120
+ addMessage('user', text || '(附件)', { attachments: pendingAttachments });
1121
+ safeSend(lincoMsg);
1122
+
1123
+ pendingAttachments = [];
1124
+ renderAttachments();
1125
+ inputEl.value = '';
1126
+ inputEl.style.height = 'auto';
1127
+ }
1128
+
1129
+ function safeSend(payload) {
1130
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
1131
+ addMessage('error', '本地模拟 IM 未连接,请使用 linco start --local-im 启动后再打开测试页。');
1132
+ return;
1133
+ }
1134
+ ws.send(JSON.stringify(payload));
1135
+ }
1136
+
1137
+ function appendAssistantChunk(text) {
1138
+ if (!currentAssistantMsg) {
1139
+ currentAssistantMarkdown = '';
1140
+ currentAssistantMsg = addMessage('assistant', '');
1141
+ }
1142
+ assistantBuffer += text || '';
1143
+ if (assistantFrame) return;
1144
+ assistantFrame = requestAnimationFrame(() => {
1145
+ currentAssistantMarkdown += assistantBuffer;
1146
+ assistantBuffer = '';
1147
+ renderAssistantMarkdown(currentAssistantMsg, currentAssistantMarkdown);
1148
+ assistantFrame = null;
1149
+ scrollToBottom();
1150
+ });
1151
+ }
1152
+
1153
+ function flushAssistantBuffer() {
1154
+ if (assistantFrame) {
1155
+ cancelAnimationFrame(assistantFrame);
1156
+ assistantFrame = null;
1157
+ }
1158
+ if (currentAssistantMsg && assistantBuffer) {
1159
+ currentAssistantMarkdown += assistantBuffer;
1160
+ assistantBuffer = '';
1161
+ renderAssistantMarkdown(currentAssistantMsg, currentAssistantMarkdown);
1162
+ scrollToBottom();
1163
+ }
1164
+ }
1165
+
1166
+ function loadAgentTypeFromUrlOrStorage() {
1167
+ const params = new URLSearchParams(location.search);
1168
+ return normalizeAgentType(params.get('agentType') || localStorage.getItem('linco.agentType') || 'claude');
1169
+ }
1170
+
1171
+ function normalizeAgentType(value) {
1172
+ const type = String(value || 'claude').trim().toLowerCase();
1173
+ return ['claude', 'codex'].includes(type) ? type : 'claude';
1174
+ }
1175
+
1176
+ function agentLabel(type) {
1177
+ return type === 'codex' ? 'Codex' : 'Claude Code';
1178
+ }
1179
+
1180
+ function setAgentSelection(agentType, options = {}) {
1181
+ currentAgentType = normalizeAgentType(agentType);
1182
+ if (agentSelect.value !== currentAgentType) agentSelect.value = currentAgentType;
1183
+ if (options.persist !== false) localStorage.setItem('linco.agentType', currentAgentType);
1184
+ }
1185
+
1186
+ function initializeAgentSelector(config = {}) {
1187
+ if (Array.isArray(config.agents) && config.agents.length > 0) {
1188
+ agentSelect.innerHTML = '';
1189
+ for (const agent of config.agents) {
1190
+ const option = document.createElement('option');
1191
+ option.value = normalizeAgentType(agent.type);
1192
+ option.textContent = agent.label || agentLabel(option.value);
1193
+ agentSelect.appendChild(option);
1194
+ }
1195
+ }
1196
+
1197
+ const params = new URLSearchParams(location.search);
1198
+ const selected = params.get('agentType') || localStorage.getItem('linco.agentType') || config.defaultLocalAgent || currentAgentType;
1199
+ setAgentSelection(selected);
1200
+ }
1201
+
1202
+ function handleAgentChange() {
1203
+ const nextAgent = normalizeAgentType(agentSelect.value);
1204
+ if (nextAgent === currentAgentType && ws?.readyState === WebSocket.OPEN) return;
1205
+ setAgentSelection(nextAgent);
1206
+ currentSessionId = '';
1207
+ currentSessionIdSource = '';
1208
+ addMessage('system', `已切换到 ${agentLabel(nextAgent)},本地测试会话正在重新连接...`);
1209
+ reconnectWebSocketNow();
1210
+ }
1211
+
1212
+ function reconnectWebSocketNow() {
1213
+ clearTimeout(reconnectTimer);
1214
+ reconnectTimer = null;
1215
+ const currentWs = ws;
1216
+ ws = null;
1217
+ if (currentWs && currentWs.readyState !== WebSocket.CLOSED) {
1218
+ currentWs.onclose = null;
1219
+ currentWs.close();
1220
+ }
1221
+ setConnected(false);
1222
+ connectWebSocket();
1223
+ }
1224
+
1225
+ function loadLocalToken() {
1226
+ const params = new URLSearchParams(location.search);
1227
+ const tokenFromUrl = params.get('localToken') || params.get('token') || '';
1228
+ const token = tokenFromUrl || localStorage.getItem('linco.localToken') || '';
1229
+ if (token) localStorage.setItem('linco.localToken', token);
1230
+ if (tokenFromUrl) hideLocalTokenFromAddressBar(params);
1231
+ return token;
1232
+ }
1233
+
1234
+ function hideLocalTokenFromAddressBar(params) {
1235
+ params.delete('localToken');
1236
+ params.delete('token');
1237
+ const nextUrl = `${location.pathname}${params.toString() ? `?${params}` : ''}${location.hash}`;
1238
+ history.replaceState(null, '', nextUrl);
1239
+ }
1240
+
1241
+ function ensureLocalTokenForUi() {
1242
+ if (localToken) return true;
1243
+ addMessage('error', '❌ 缺少本地访问 token,请使用 linco start 输出的本地测试页地址打开。');
1244
+ return false;
1245
+ }
1246
+
1247
+ function withLocalToken(urlString) {
1248
+ const url = new URL(urlString, location.href);
1249
+ if (localToken) url.searchParams.set('localToken', localToken);
1250
+ return url.toString();
1251
+ }
1252
+
1253
+ async function loadClientConfig() {
1254
+ if (!ensureLocalTokenForUi()) return {};
1255
+ try {
1256
+ const res = await fetch('/api/client-config', {
1257
+ cache: 'no-store',
1258
+ headers: {
1259
+ Authorization: `Bearer ${localToken}`,
1260
+ },
1261
+ });
1262
+ if (!res.ok) return {};
1263
+ return await res.json();
1264
+ } catch {
1265
+ return {};
1266
+ }
1267
+ }
1268
+
1269
+ function defaultWebSocketUrl() {
1270
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
1271
+ return `${protocol}//${location.host}`;
1272
+ }
1273
+
1274
+ function pageSessionId() {
1275
+ const params = new URLSearchParams(location.search);
1276
+ return params.get('session_id') || params.get('sessionId') || currentSessionId || '';
1277
+ }
1278
+
1279
+ function websocketUrlWithSessionId(wsUrl) {
1280
+ const url = new URL(wsUrl, location.href);
1281
+ const sessionId = pageSessionId();
1282
+ if (sessionId) url.searchParams.set('session_id', sessionId);
1283
+ url.searchParams.set('agentType', currentAgentType);
1284
+ url.searchParams.set('linco', '1');
1285
+ if (localToken && url.host === location.host) url.searchParams.set('localToken', localToken);
1286
+ return url.toString();
1287
+ }
1288
+
1289
+ async function initLocalTestPage() {
1290
+ const config = await loadClientConfig();
1291
+ initializeAgentSelector(config);
1292
+ if (!config.localImEnabled) {
1293
+ setConnected(false);
1294
+ addMessage('system', '本地模拟 IM 默认关闭。需要测试本地前端 IM 时,请使用 linco start --local-im 启动。');
1295
+ return;
1296
+ }
1297
+ connectWebSocket(config);
1298
+ }
1299
+
1300
+ async function connectWebSocket(config) {
1301
+ if (!ensureLocalTokenForUi()) return;
1302
+ const clientConfig = config || await loadClientConfig();
1303
+ initializeAgentSelector(clientConfig);
1304
+ if (!clientConfig.localImEnabled) {
1305
+ setConnected(false);
1306
+ return;
1307
+ }
1308
+ ws = new WebSocket(websocketUrlWithSessionId(clientConfig.wsUrl || defaultWebSocketUrl()));
1309
+ ws.onmessage = handleSocketMessage;
1310
+ ws.onopen = () => setConnected(true);
1311
+ ws.onclose = () => {
1312
+ setConnected(false);
1313
+ addMessage('system', '⚠️ 连接已断开,正在尝试重连...');
1314
+ reconnectTimer = setTimeout(connectWebSocket, 3000);
1315
+ };
1316
+ ws.onerror = () => setConnected(false);
1317
+ }
1318
+
1319
+ function handleSocketMessage(event) {
1320
+ let data;
1321
+ try {
1322
+ data = JSON.parse(event.data);
1323
+ } catch {
1324
+ addMessage('error', '❌ 收到无法解析的服务端消息');
1325
+ return;
1326
+ }
1327
+
1328
+ switch (data.type) {
1329
+ case 'session_info':
1330
+ updateSessionInfo(data);
1331
+ break;
1332
+ case 'outbound_message':
1333
+ if (data.mediaName) {
1334
+ addOutgoingAttachment({
1335
+ name: data.mediaName,
1336
+ mimeType: data.mediaType,
1337
+ url: data.mediaUrl || '#',
1338
+ size: 0,
1339
+ kind: data.mediaType?.startsWith('image/') ? 'image' : 'file',
1340
+ });
1341
+ } else {
1342
+ const msgType = data.messageId?.includes('error') ? 'error' : 'system';
1343
+ addMessage(msgType, data.text || '');
1344
+ }
1345
+ break;
1346
+ case 'stream_chunk':
1347
+ handleStreamChunk(data);
1348
+ break;
1349
+ case 'tool_call':
1350
+ addToolCard(data);
1351
+ break;
1352
+ case 'tool_result':
1353
+ addToolResult(data);
1354
+ break;
1355
+ case 'outgoing_attachment':
1356
+ addOutgoingAttachment(data);
1357
+ break;
1358
+ case 'permission_request':
1359
+ addPermissionConfirm(data);
1360
+ break;
1361
+ case 'danger_warning':
1362
+ addDangerConfirm(data.text || '需要确认操作');
1363
+ break;
1364
+ case 'presence_event':
1365
+ case 'pong':
1366
+ break;
1367
+ default:
1368
+ console.log('[WS] 未处理消息类型:', data.type, data);
1369
+ }
1370
+ }
1371
+
1372
+ function handleStreamChunk(data) {
1373
+ if (data.done) {
1374
+ flushAssistantBuffer();
1375
+ currentAssistantMsg = null;
1376
+ thinkingMsg = null;
1377
+ } else {
1378
+ if (thinkingMsg) { removeMessage(thinkingMsg); thinkingMsg = null; }
1379
+ appendAssistantChunk(data.delta || '');
1380
+ }
1381
+ }
1382
+
1383
+ uploadBtn.addEventListener('click', () => fileInput.click());
1384
+ fileInput.addEventListener('change', (e) => {
1385
+ addFiles(e.target.files);
1386
+ fileInput.value = '';
1387
+ });
1388
+
1389
+ document.addEventListener('paste', (e) => {
1390
+ const items = e.clipboardData?.items;
1391
+ if (!items) return;
1392
+ const images = [];
1393
+ for (const item of items) {
1394
+ if (item.type.startsWith('image/')) {
1395
+ const file = item.getAsFile();
1396
+ if (file) images.push(file);
1397
+ }
1398
+ }
1399
+ if (images.length > 0) {
1400
+ e.preventDefault();
1401
+ addFiles(images);
1402
+ }
1403
+ });
1404
+
1405
+ sendBtn.addEventListener('click', sendMessage);
1406
+ inputEl.addEventListener('keydown', (e) => {
1407
+ if (e.key === 'Enter' && !e.shiftKey) {
1408
+ e.preventDefault();
1409
+ sendMessage();
1410
+ }
1411
+ });
1412
+
1413
+ inputEl.addEventListener('input', () => {
1414
+ inputEl.style.height = 'auto';
1415
+ inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
1416
+ });
1417
+
1418
+ agentSelect.addEventListener('change', handleAgentChange);
1419
+ setAgentSelection(currentAgentType, { persist: false });
1420
+ initLocalTestPage();
1421
+
1422
+ function extensionOf(name) {
1423
+ const idx = name.lastIndexOf('.');
1424
+ return idx >= 0 ? name.slice(idx).toLowerCase() : '';
1425
+ }
1426
+
1427
+ function mimeFromName(name) {
1428
+ switch (extensionOf(name)) {
1429
+ case '.png': return 'image/png';
1430
+ case '.jpg':
1431
+ case '.jpeg': return 'image/jpeg';
1432
+ case '.gif': return 'image/gif';
1433
+ case '.webp': return 'image/webp';
1434
+ case '.txt': return 'text/plain';
1435
+ case '.md': return 'text/markdown';
1436
+ case '.csv': return 'text/csv';
1437
+ case '.sql': return 'application/sql';
1438
+ case '.pdf': return 'application/pdf';
1439
+ case '.doc': return 'application/msword';
1440
+ case '.docx': return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
1441
+ case '.xls': return 'application/vnd.ms-excel';
1442
+ case '.xlsx': return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
1443
+ case '.json': return 'application/json';
1444
+ case '.xml': return 'application/xml';
1445
+ case '.zip': return 'application/zip';
1446
+ default: return 'application/octet-stream';
1447
+ }
1448
+ }
1449
+
1450
+ function formatSize(size) {
1451
+ if (!Number.isFinite(size) || size <= 0) return '0 KB';
1452
+ if (size >= 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`;
1453
+ return `${(size / 1024).toFixed(1)} KB`;
1454
+ }
1455
+ </script>
1456
+ </body>
1457
+ </html>