Selinore commited on
Commit
77d2e53
·
verified ·
1 Parent(s): 42b0380

Update public/index.html

Browse files
Files changed (1) hide show
  1. public/index.html +568 -754
public/index.html CHANGED
@@ -10,34 +10,27 @@
10
  :root{
11
  --bg:#edf3fb; --panel:#fff; --line:#dce5f3; --text:#1f2937; --sub:#64748b;
12
  --brand:#3498ff; --self:#dff6c8; --other:#fff; --code:#f6f8ff;
13
- --tool:#f8fafc; --important:#a855f7;
14
- --tip:#22c55e; --note:#3b82f6; --warn:#f59e0b; --err:#ef4444;
15
  }
16
  *{box-sizing:border-box}
17
  html,body{height:100%;margin:0;font-family:Inter,"Noto Sans SC",system-ui}
18
  body{background:var(--bg);color:var(--text);overflow:hidden}
19
- input[type="checkbox"], input[type="radio"] { accent-color: #22c55e; }
20
-
21
  .app{height:100%;width:100vw;display:grid;grid-template-columns:300px minmax(0,1fr);transition:.2s}
22
  .app.debug-open{grid-template-columns:300px minmax(0,1fr) 350px}
23
  .panel{min-height:0;display:flex;flex-direction:column;min-width:0}
24
- .left{background:var(--panel);border-right:1px solid var(--line);z-index:42}
25
  .main{background:var(--bg);min-width:0}
26
  .right{background:#0f172a;color:#d9e9ff;border-left:1px solid #233754;display:none;min-width:0}
27
  .app.debug-open .right{display:flex}
28
- .head{height:62px;flex-shrink:0;display:flex;align-items:center;justify-content:space-between;padding:0 12px;border-bottom:1px solid var(--line);background:#fff}
29
  .scroll{overflow:auto;min-height:0}
30
- .muted{font-size:12px;color:var(--sub)}
31
  .avatar{width:38px;height:38px;border-radius:12px;background:linear-gradient(135deg,#4fa8ff,#7bc7ff);color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700}
32
  .btn{border:1px solid #cfdbef;background:#f7fbff;border-radius:11px;padding:8px 10px;cursor:pointer}
33
- .btn:hover{background:#ecf4ff}
34
  .search{padding:10px;border-bottom:1px solid var(--line)}
35
  .search input{width:100%;padding:10px 12px;border:1px solid #c9d8ef;border-radius:12px;outline:none}
36
- .search input:focus{border-color:#6fb1ff}
37
  .channel{display:flex;gap:10px;align-items:center;padding:10px;border-radius:12px;cursor:pointer;margin:4px 8px}
38
  .channel:hover{background:#f1f6ff}
39
  .channel.active{background:#e7f1ff}
40
-
41
  .messages{
42
  padding:14px 16px;
43
  background-image:radial-gradient(circle at 1px 1px,rgba(70,110,170,.08) 1px,transparent 0);
@@ -55,109 +48,82 @@
55
  border-radius:8px; padding:6px 8px; margin-bottom:10px;
56
  display:none;
57
  }
 
 
 
 
 
 
58
  #msgList{
59
- margin-top:auto; /* 消息少时贴底 */
60
  display:flex;
61
  flex-direction:column;
 
62
  }
63
 
64
  .row{display:flex;margin-bottom:12px;width:100%}
65
  .row.self{justify-content:flex-end}
66
- .row.tool-center{justify-content:center}
67
  .bubble{
68
- position:relative;
69
  max-width:min(680px,86%);
70
  border:1px solid #dce6f4;border-radius:16px;padding:10px 12px;
71
  box-shadow:0 1px 4px rgba(20,30,50,.08);
 
72
  overflow:hidden;
73
- background:var(--other);
74
- }
75
- .bubble.self{background:var(--self)}
76
- .bubble.other{background:var(--other)}
77
- .bubble.tool{
78
- background:var(--tool);
79
- border-style:dashed;
80
- border-color:#94a3b8;
81
- box-shadow:none;
82
- max-width:min(760px,92%);
83
- }
84
- .bubble-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px;}
85
  .sender{font-size:12px;color:#2563eb;font-weight:600}
86
- .sender.tool{color:#475569}
87
- .bubble-copy{font-size:11px; padding:3px 8px; border-radius:8px;border:1px solid #d6e2f2;background:#fff;cursor:pointer;flex-shrink:0;}
88
  .meta{font-size:11px;color:#6b7280;text-align:right;margin-top:6px}
89
 
90
- .composer{background:#fff;border-top:1px solid var(--line);padding:10px;flex-shrink:0;position:relative}
91
  .mode-row{display:flex;gap:8px;margin-bottom:8px}
92
  .mode-btn{border:1px solid #cfdbef;background:#f7fbff;padding:6px 10px;border-radius:10px;cursor:pointer}
93
  .mode-btn.active{background:#e6f1ff;border-color:#8cb5f4}
94
  .input-row{display:flex;align-items:flex-end;gap:8px;position:relative}
95
- .send{width:44px;height:44px;border:none;border-radius:12px;background:var(--brand);color:#fff;cursor:pointer}
96
  textarea{width:100%;min-height:42px;max-height:240px;resize:vertical;border:1px solid #c8d8ef;border-radius:14px;padding:10px 12px;outline:none}
97
- textarea:focus{border-color:#66aafc;box-shadow:0 0 0 3px rgba(102,170,252,.2)}
98
  .preview{font-size:12px;margin-bottom:8px}
99
  .chip{display:inline-flex;align-items:center;gap:6px;padding:4px 9px;border-radius:999px;border:1px solid #bfd6ff;background:#ecf5ff;color:#1e3a8a;margin:2px}
100
  .hold{width:100%;padding:12px;border-radius:12px;border:1px dashed #7aa9f7;background:#f0f7ff;cursor:pointer}
101
 
102
- #cmdSuggest{
103
- position:absolute;left:54px;right:54px;bottom:58px;z-index:25;
104
- background:#fff;border:1px solid #d5e2f3;border-radius:12px;
105
- box-shadow:0 8px 24px rgba(15,23,42,.12);display:none;max-height:200px;overflow:auto;
106
- }
107
- .cmd-item{padding:8px 10px;cursor:pointer;border-bottom:1px solid #edf2fb}
108
- .cmd-item:last-child{border-bottom:none}
109
- .cmd-item:hover,.cmd-item.active{background:#eef5ff}
110
- .cmd-name{font-family:ui-monospace,Consolas,monospace;color:#1d4ed8;font-size:12px}
111
- .cmd-desc{font-size:11px;color:#64748b;margin-top:2px}
112
-
113
  .md{font-size:14px;line-height:1.65;word-break:break-word}
114
  .md p{margin:.35em 0}
115
- .md img{max-width:min(420px,100%);height:auto;border-radius:12px;border:1px solid #d6e2f2;cursor:zoom-in;display:block;margin:8px 0;box-shadow:0 2px 8px rgba(15,23,42,.06)}
116
  .md pre{margin:.5em 0}
117
  .md a{color:#2563eb;text-decoration:underline}
118
- .md ul,.md ol{padding-left:20px;margin:.35em 0}
119
- .md li{margin:.22em 0}
120
- .md code:not(.katex *){
121
- background:#eaf0ff;border:1px solid #d2ddff;padding:1px 6px;border-radius:6px;
122
- }
123
 
124
- .render-card{
125
- border:1px solid #d8e3f5;border-radius:12px;background:#f8fbff;
126
- margin:.45em 0;overflow:hidden;
127
- }
128
- .render-head{
129
- display:flex;align-items:center;justify-content:space-between;
130
- gap:8px;padding:8px 10px;background:#edf4ff;border-bottom:1px solid #d8e4fb;font-size:12px;
131
- }
132
- .render-actions button{
133
- border:1px solid #c7d8f7;background:#fff;border-radius:7px;padding:3px 7px;cursor:pointer;margin-left:6px;
134
- font-size:12px;
135
- }
136
- .render-body{padding:10px;min-height:48px;overflow:auto}
137
  .render-placeholder{font-size:12px;color:#64748b}
138
  .render-error{font-size:12px;color:#b91c1c;white-space:pre-wrap}
139
- .render-svg-wrap svg{max-width:100%;height:auto}
140
  .wx-audio{display:inline-flex;align-items:center;gap:8px;background:#e7f1ff;border:1px solid #b8d2ff;border-radius:999px;padding:7px 12px;cursor:pointer}
141
  .wx-audio.playing{background:#d7eaff}
142
- .tool-chip{margin-top:8px;border:1px solid #cbd5e1;border-radius:10px;background:#fff;padding:9px 10px;font-size:12px;color:#334155;display:flex;align-items:center;justify-content:space-between;gap:8px;}
143
 
144
- #offlineBar{
145
- position:absolute;top:10px;left:50%;transform:translateX(-50%);
146
- z-index:35;background:#fffbeb;color:#92400e;border:1px solid #fcd34d;
147
- border-radius:999px;padding:6px 12px;font-size:12px;display:none;
148
  }
 
 
 
 
 
149
 
150
  .mobile-only{display:none}
151
- #mobileMask{
152
- position:fixed;inset:0;background:rgba(0,0,0,.28);z-index:41;display:none;
153
- }
154
  #mobileMask.show{display:block}
155
 
156
- @media (max-width: 960px){
157
  .app,.app.debug-open{grid-template-columns:1fr;width:100vw}
158
  .left{
159
  position:fixed;left:0;top:0;bottom:0;width:86vw;max-width:340px;
160
- z-index:42;transform:translateX(-100%);transition:.2s;border-right:1px solid #d8e4f3;
161
  }
162
  .left.show{transform:translateX(0)}
163
  .right{
@@ -168,7 +134,7 @@
168
  .mobile-only{display:inline-block}
169
  .bubble{max-width:96%}
170
  .head{padding:0 8px}
171
- .messages{padding:10px 10px 8px}
172
  #cmdSuggest{left:48px;right:48px;bottom:56px}
173
  }
174
 
@@ -177,7 +143,7 @@
177
  .modal-card{width:min(92vw,860px);max-height:88vh;background:#fff;border-radius:14px;padding:14px;overflow:auto}
178
  .field{margin-bottom:10px}
179
  .field label{display:block;font-size:12px;color:#64748b;margin-bottom:5px}
180
- .field input,.field textarea,.field select{width:100%;padding:9px 10px;border:1px solid #cbd8ed;border-radius:10px}
181
  .field textarea{min-height:92px;font-family:ui-monospace,Consolas,monospace;font-size:12px}
182
  .grid-2{display:grid;grid-template-columns:1fr 1fr;gap:10px}
183
  .modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:8px}
@@ -185,27 +151,18 @@
185
  #mediaTop{position:absolute;top:16px;right:24px;display:flex;gap:10px}
186
  #mediaTop button{border:none;border-radius:10px;padding:9px 11px;cursor:pointer}
187
  #mediaContent img,#mediaContent video{max-width:90vw;max-height:84vh;border-radius:12px}
188
-
189
  #toolDetail{
190
- position:fixed; top:0; right:-560px; width:min(560px,95vw); height:100vh;
191
- background:#fff; z-index:70; box-shadow:-8px 0 24px rgba(15,23,42,.15);
192
- transition:.22s; border-left:1px solid #dbe5f3; display:flex; flex-direction:column;
193
  }
194
  #toolDetail.show{right:0}
195
- #toolDetailHead{
196
- height:56px; border-bottom:1px solid #e2e8f0; display:flex; align-items:center; justify-content:space-between;
197
- padding:0 12px; flex-shrink:0; font-weight:600;
198
- }
199
- #toolDetailBody{flex:1; overflow:auto; padding:10px;}
200
- #toolDetailBody pre{
201
- margin:0; white-space:pre-wrap; word-break:break-word;
202
- background:#f8fafc; border:1px solid #e2e8f0; border-radius:10px; padding:10px; font-size:12px;
203
- }
204
-
205
  #toast{
206
  position:fixed;left:50%;bottom:24px;transform:translateX(-50%);
207
- background:rgba(15,23,42,.92);color:#fff;padding:8px 12px;border-radius:10px;
208
- font-size:12px;z-index:999;opacity:0;pointer-events:none;transition:.18s;
209
  }
210
  #toast.show{opacity:1}
211
  </style>
@@ -232,7 +189,10 @@
232
  <div style="display:flex;align-items:center;gap:10px;min-width:0">
233
  <button id="btnOpenLeft" class="btn mobile-only">☰</button>
234
  <div id="headAvatar" class="avatar">C</div>
235
- <div style="min-width:0"><div id="headName" style="font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">请选择频道</div><div class="muted">OpenClaw Socket 历史流</div></div>
 
 
 
236
  </div>
237
  <div style="display:flex;gap:8px;flex-shrink:0">
238
  <button id="btnSettings" class="btn">设置</button>
@@ -255,13 +215,13 @@
255
  <div id="textMode">
256
  <div class="input-row">
257
  <label class="btn">📎<input id="fileInput" type="file" multiple accept="image/*,video/*,audio/*" style="display:none"></label>
258
- <textarea id="input" rows="1" placeholder="输入消息(支持Markdown/公式/mermaid/echarts/vega/graphviz/plantuml)"></textarea>
259
  <button id="sendBtn" class="send">➤</button>
260
  <div id="cmdSuggest"></div>
261
  </div>
262
  </div>
263
  <div id="voiceMode" style="display:none">
264
- <button id="holdBtn" class="hold">按住说话,松开发送(仅语音,不自动转写)</button>
265
  </div>
266
  </div>
267
  </main>
@@ -279,11 +239,11 @@
279
  <div class="modal-card">
280
  <h3 style="margin-top:0">设置</h3>
281
  <div class="grid-2">
282
- <div class="field"><label>网关地址(可连其他服务)</label><input id="setApiBase" placeholder="例如:https://xxx.com" /></div>
283
- <div class="field"><label>连接密码(CONNECT_PASSWORD)</label><input id="setPassword" /></div>
284
  </div>
285
  <div class="grid-2">
286
- <div class="field"><label>机器人用户名(botUsername)</label><input id="setBotUsername" /></div>
287
  <div class="field"><label>SSE拉流间隔(ms)</label><input id="setStreamInterval" type="number" min="800" step="100" /></div>
288
  </div>
289
  <div class="field"><label>默认消息条数(limit)</label><input id="setMessageLimit" type="number" min="20" step="10" /></div>
@@ -325,19 +285,74 @@
325
  <div id="toast"></div>
326
 
327
  <script>
328
- const appEl = document.getElementById('app');
329
-
330
- const DEFAULT_COMMANDS = [
331
- { cmd:'/help', desc:'查看帮助' },
332
  { cmd:'/new', desc:'新会话' },
333
  { cmd:'/reset', desc:'重置上下文' },
 
 
 
 
 
334
  { cmd:'/clear', desc:'清空会话' },
335
- { cmd:'/history', desc:'查看历史信息' },
336
- { cmd:'/status', desc:'查看状态' },
337
- { cmd:'/model', desc:'切换模型(如果服务端支持)' },
338
- { cmd:'/tools', desc:'查看可用工具' }
339
  ];
340
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
  const S = {
342
  cfg: { runtimeConfig: { botUsername: 'openman', streamInterval: 1500, defaultMessageLimit: 120, uiProfile: {} } },
343
  settings: {
@@ -353,79 +368,52 @@
353
  current: null,
354
  files: [],
355
  es: null,
356
- msgStore: new Map(), // cid -> { ids:Set,lastId,loadedLimit,loadingMore,hasMore,scrollTop,reconnectTry }
357
  rec: null,
358
  recording: false,
359
  chunks: [],
360
  activeAudio: null,
361
  seenServerLogs: new Set(),
362
- commandList: DEFAULT_COMMANDS,
363
  cmdActive: -1,
 
364
  pluginObserver: null,
365
- chartInstances: new Map(), // domId -> instance
 
366
  offline: !navigator.onLine
367
  };
368
 
369
- const E = {
370
- left: leftPanel, right: rightPanel, messages, topLoader: document.getElementById('topLoader'),
371
- msgList: document.getElementById('msgList'),
372
- channelList, channelCount, searchInput,
373
- headName, headAvatar, input, fileInput, preview, logs, toast: document.getElementById('toast'),
374
- modeText, modeVoice, textMode, voiceMode, holdBtn,
375
- settingsModal, mediaModal, mediaContent, mediaDownload,
376
- toolDetail: document.getElementById('toolDetail'),
377
- toolDetailTitle: document.getElementById('toolDetailTitle'),
378
- toolDetailBody: document.getElementById('toolDetailBody'),
379
- cmdSuggest: document.getElementById('cmdSuggest'),
380
- mobileMask: document.getElementById('mobileMask'),
381
- offlineBar: document.getElementById('offlineBar')
382
- };
383
-
384
- function apiBaseTrim() {
385
- const b = (S.settings.apiBase || '').trim();
386
- return b ? b.replace(/\/+$/, '') : '';
387
- }
388
- function apiUrl(path) {
389
- const base = apiBaseTrim();
390
- return base ? `${base}${path}` : path;
391
- }
392
  function scrollKey(cid){ return `scroll_${cid}`; }
393
  function draftKey(cid){ return `draft_${cid}`; }
394
  function cacheKey(cid){ return `cache_msgs_${cid}`; }
395
 
396
  function toast(msg){
397
- E.toast.textContent = msg;
398
- E.toast.classList.add('show');
399
- clearTimeout(E.toast._t);
400
- E.toast._t = setTimeout(()=>E.toast.classList.remove('show'), 1400);
401
- }
402
-
403
- function log(type, msg){
404
- if(type === 'info' && S.settings.debugLogs !== 'true') return;
405
- const t = new Date().toLocaleTimeString('zh-CN',{hour12:false});
406
- const p = type === 'error' ? '❌' : type === 'warn' ? '⚠️' : '📌';
407
- const line = `[${t}] ${p} ${msg}`;
408
- (type === 'error' ? console.error : console.log)(line);
409
- E.logs.textContent += line + '\n';
410
- E.logs.scrollTop = E.logs.scrollHeight;
411
- }
412
-
413
- async function apiFetch(path, opt={}){
414
  const headers = new Headers(opt.headers || {});
415
  if(S.settings.connectPassword) headers.set('x-connect-password', S.settings.connectPassword);
416
-
417
- const controller = new AbortController();
418
- const t = setTimeout(()=>controller.abort(), opt.timeout || 20000);
419
- try{
420
- const rsp = await fetch(apiUrl(path), { ...opt, headers, signal: controller.signal });
421
- if(rsp.status === 401) throw new Error('连接密码错误或未提供');
422
- return rsp;
423
- } finally {
424
- clearTimeout(t);
425
- }
426
  }
427
 
428
- function escapeHtml(s=''){return String(s).replace(/[&<>"']/g,m=>({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[m]))}
429
  function extType(url=''){
430
  const u = String(url).toLowerCase();
431
  if(u.endsWith('#audio') || /[?&]type=audio/.test(u)) return 'audio';
@@ -437,50 +425,48 @@
437
 
438
  function ensureStore(cid){
439
  if(!S.msgStore.has(cid)) S.msgStore.set(cid,{
440
- ids:new Set(), lastId:null,
441
- loadedLimit:50, loadingMore:false, hasMore:true, scrollTop:0, reconnectTry:0
442
  });
443
  return S.msgStore.get(cid);
444
  }
445
  function nearBottom(el,th=120){ return el.scrollHeight - el.scrollTop - el.clientHeight < th; }
 
446
  function saveCurrentScroll(){
447
  if(!S.current) return;
448
  const cid = S.current.id;
449
  const st = ensureStore(cid);
450
- st.scrollTop = E.messages.scrollTop;
451
  localStorage.setItem(scrollKey(cid), String(st.scrollTop));
452
- localStorage.setItem(draftKey(cid), E.input.value || '');
453
  }
454
  function restoreChannelScroll(cid){
455
  const st = ensureStore(cid);
456
  const v = localStorage.getItem(scrollKey(cid));
457
  let t = Number(v || st.scrollTop || 0);
458
  if(!Number.isFinite(t) || t < 0) t = 0;
459
- E.messages.scrollTop = t;
460
  }
461
  function restoreDraft(cid){
462
- E.input.value = localStorage.getItem(draftKey(cid)) || '';
463
  autoGrow();
 
464
  }
465
 
466
  function formatTimeFromMessage(m){
467
- const candidates = [m.timestamp, m.created_at, m.updated_at];
468
- let d = null;
469
  for(const c of candidates){
470
- if(c == null) continue;
471
- if(typeof c === 'number' || /^\d+$/.test(String(c))){
472
- let n = Number(c);
473
- if(n < 1e11) n = n * 1000;
474
- const t = new Date(n);
475
- if(!isNaN(t.getTime())) { d = t; break; }
476
  }else{
477
- const t = new Date(c);
478
- if(!isNaN(t.getTime())) { d = t; break; }
479
  }
480
  }
481
- if(!d) d = new Date();
482
- const pad = x => String(x).padStart(2,'0');
483
- return `${d.getFullYear()}.${pad(d.getMonth()+1)}.${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
484
  }
485
 
486
  function loadScriptAny(urls){
@@ -491,7 +477,7 @@
491
  const s=document.createElement('script');
492
  s.src=urls[i++]; s.async=true;
493
  s.onload=()=>resolve();
494
- s.onerror=()=>{ s.remove(); next(); };
495
  document.head.appendChild(s);
496
  };
497
  next();
@@ -505,7 +491,7 @@
505
  const l=document.createElement('link');
506
  l.rel='stylesheet'; l.href=urls[i++];
507
  l.onload=()=>resolve();
508
- l.onerror=()=>{ l.remove(); next(); };
509
  document.head.appendChild(l);
510
  };
511
  next();
@@ -522,10 +508,6 @@
522
  'https://cdn.jsdelivr.net/npm/dompurify@3.1.6/dist/purify.min.js',
523
  'https://unpkg.com/dompurify@3.1.6/dist/purify.min.js'
524
  ]),
525
- window.mermaid ? Promise.resolve() : loadScriptAny([
526
- 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js',
527
- 'https://unpkg.com/mermaid@10/dist/mermaid.min.js'
528
- ]),
529
  loadCssAny([
530
  'https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.css',
531
  'https://unpkg.com/katex@0.16.10/dist/katex.min.css'
@@ -534,24 +516,18 @@
534
  'https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.js',
535
  'https://unpkg.com/katex@0.16.10/dist/katex.min.js'
536
  ]),
537
- window.renderMathInElement ? Promise.resolve() : loadScriptAny([
538
- 'https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/contrib/auto-render.min.js',
539
- 'https://unpkg.com/katex@0.16.10/dist/contrib/auto-render.min.js'
540
  ]),
541
  window.echarts ? Promise.resolve() : loadScriptAny([
542
- 'https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js',
543
- 'https://unpkg.com/echarts@5/dist/echarts.min.js'
544
  ]),
545
  (window.vega && window.vegaLite && window.vegaEmbed) ? Promise.resolve() : loadScriptAny([
546
  'https://cdn.jsdelivr.net/npm/vega@5/build/vega.min.js'
547
- ]).then(()=>loadScriptAny([
548
- 'https://cdn.jsdelivr.net/npm/vega-lite@5/build/vega-lite.min.js'
549
- ])).then(()=>loadScriptAny([
550
- 'https://cdn.jsdelivr.net/npm/vega-embed@6/build/vega-embed.min.js'
551
- ])),
552
  window.Viz ? Promise.resolve() : loadScriptAny([
553
- 'https://cdn.jsdelivr.net/npm/viz.js@2.1.2/viz.js',
554
- 'https://unpkg.com/viz.js@2.1.2/viz.js'
555
  ]),
556
  window.flowchart ? Promise.resolve() : loadScriptAny([
557
  'https://cdn.jsdelivr.net/npm/flowchart.js@1.18.0/release/flowchart.min.js'
@@ -560,48 +536,62 @@
560
  if(window.mermaid) mermaid.initialize({startOnLoad:false,securityLevel:'strict',theme:'default'});
561
  }
562
 
563
- function protectMath(md=''){
 
564
  const tokens = [];
565
- let i = 0;
566
- let txt = String(md);
567
-
568
- // block $$...$$
569
- txt = txt.replace(/\$\$([\s\S]+?)\$\$/g, (m)=>{
570
- const k = `@@MATH_BLOCK_${i++}@@`;
571
- tokens.push({k,v:m});
 
572
  return k;
573
  });
574
 
575
- // inline $...$
576
- txt = txt.replace(/(^|[^\\])\$([^\n$]+?)\$/g, (m,p1,p2)=>{
577
- const k = `@@MATH_INLINE_${i++}@@`;
578
- tokens.push({k,v:`$${p2}$`});
579
- return `${p1}${k}`;
 
 
 
 
580
  });
581
 
582
- return { txt, tokens };
583
- }
584
- function restoreMath(html, tokens){
585
- let out = html;
586
- for(const t of tokens){
587
- out = out.split(t.k).join(t.v);
588
- }
589
- return out;
 
 
 
 
 
 
 
590
  }
591
 
592
  function markdownToHtml(md=''){
593
  if(window.marked){
594
- marked.setOptions({ gfm:true, breaks:true, mangle:false, headerIds:false });
595
  return marked.parse(md);
596
  }
597
  return escapeHtml(md).replace(/\n/g,'<br>');
598
  }
599
 
600
  function renderMd(raw=''){
601
- const p = protectMath(raw);
602
- let html = markdownToHtml(p.txt);
603
- html = restoreMath(html, p.tokens);
604
-
 
605
  if(window.DOMPurify){
606
  html = DOMPurify.sanitize(html,{
607
  USE_PROFILES:{html:true},
@@ -612,41 +602,6 @@
612
  return html;
613
  }
614
 
615
- async function renderMathIn(root){
616
- // 优先 KaTeX 自动渲染
617
- if(window.renderMathInElement){
618
- try{
619
- renderMathInElement(root, {
620
- delimiters: [
621
- {left: "$$", right: "$$", display: true},
622
- {left: "$", right: "$", display: false},
623
- {left: "\\(", right: "\\)", display: false},
624
- {left: "\\[", right: "\\]", display: true}
625
- ],
626
- throwOnError: false,
627
- ignoredTags: ["script", "noscript", "style", "textarea", "pre", "code"]
628
- });
629
- return;
630
- }catch(e){
631
- log('warn','KaTeX渲染失败: '+e.message);
632
- }
633
- }
634
- }
635
-
636
- function openToolDetail(title, obj){
637
- E.toolDetailTitle.textContent = title;
638
- let pretty = '';
639
- try { pretty = JSON.stringify(obj, null, 2); } catch { pretty = String(obj); }
640
- E.toolDetailBody.innerHTML = `<pre>${escapeHtml(pretty)}</pre>`;
641
- E.toolDetail.classList.add('show');
642
- }
643
-
644
- function buildCopyText(m){
645
- const t = msgText(m);
646
- const files = (m?.data?.files || []).map(x=>x.url || x?.file?.url).filter(Boolean);
647
- return [t, files.length?('\n'+files.join('\n')):''].join('\n').trim();
648
- }
649
-
650
  function msgText(m){
651
  if(typeof m.content==='string') return m.content;
652
  if(Array.isArray(m.content)) return m.content.map(x=>x?.text||'').join('\n');
@@ -655,27 +610,25 @@
655
  }
656
 
657
  function isSelf(m){
658
- if (m.role === 'toolResult') return false;
659
- const botName = (S.settings.botUsername || S.cfg?.runtimeConfig?.botUsername || 'openman').toLowerCase();
660
- const n = (m?.user?.name || '').toLowerCase();
661
- if(m.role === 'user') return true;
662
- if(m.role === 'assistant') return false;
663
- if(n && n !== botName) return true;
664
- return false;
665
  }
666
 
667
  function openMedia(url,type){
668
- E.mediaContent.innerHTML = type==='image'
669
  ? `<img src="${escapeHtml(url)}">`
670
  : `<video src="${escapeHtml(url)}" controls autoplay></video>`;
671
- E.mediaDownload.href = url;
672
- E.mediaModal.classList.add('show');
673
  }
674
 
675
  function toggleWxAudio(wx,audio){
676
  if(S.activeAudio && S.activeAudio!==audio) S.activeAudio.pause();
677
- if(audio.paused){ audio.play(); wx.classList.add('playing'); S.activeAudio=audio; }
678
- else { audio.pause(); wx.classList.remove('playing'); }
679
  audio.onended=()=>wx.classList.remove('playing');
680
  audio.onpause=()=>wx.classList.remove('playing');
681
  audio.onplay=()=>wx.classList.add('playing');
@@ -690,139 +643,89 @@
690
  const blob = new Blob([svgData], {type:'image/svg+xml;charset=utf-8'});
691
  const url = URL.createObjectURL(blob);
692
  const img = new Image();
693
- await new Promise((resolve,reject)=>{
694
- img.onload=resolve; img.onerror=reject; img.src=url;
695
- });
696
  const c = document.createElement('canvas');
697
- c.width = img.width || 1000;
698
- c.height = img.height || 600;
699
  const ctx = c.getContext('2d');
700
- ctx.fillStyle = '#fff';
701
- ctx.fillRect(0,0,c.width,c.height);
702
- ctx.drawImage(img,0,0);
703
  URL.revokeObjectURL(url);
704
  c.toBlob((b)=>{
705
- const a = document.createElement('a');
706
- a.href = URL.createObjectURL(b);
707
- a.download = filename;
708
- a.click();
709
  setTimeout(()=>URL.revokeObjectURL(a.href),1200);
710
  }, 'image/png');
711
  }
712
 
713
  const Renderers = {
714
- mermaid: {
715
- match: (lang)=>['mermaid','mindmap','sequence','flowchart-mermaid'].includes(lang),
716
  render: async (code, body)=>{
717
  if(!window.mermaid) throw new Error('mermaid 未加载');
718
  const id='mmd_'+Math.random().toString(36).slice(2);
719
- const source = (code || '').trim();
720
- const { svg } = await mermaid.render(id, source);
721
  body.innerHTML = `<div class="render-svg-wrap">${svg}</div>`;
722
  }
723
  },
724
- echarts: {
725
- match: (lang)=>lang==='echarts',
726
  render: async (code, body, card)=>{
727
  if(!window.echarts) throw new Error('echarts 未加载');
728
- let opt;
729
- try{ opt = JSON.parse(code); }catch{
730
- throw new Error('echarts 需要合法 JSON option');
731
- }
732
- const d = document.createElement('div');
733
- d.style.height = '360px';
734
- d.style.minWidth = '280px';
735
- body.innerHTML = '';
736
- body.appendChild(d);
737
- const chart = echarts.init(d, null, {renderer:'canvas'});
738
- chart.setOption(opt, true);
739
- const id = card.dataset.pid;
740
- S.chartInstances.set(id, chart);
741
-
742
  const ro = new ResizeObserver(()=>chart.resize());
743
- ro.observe(d);
744
- card._ro = ro;
745
  },
746
- export: async (card)=>{
747
  const chart = S.chartInstances.get(card.dataset.pid);
748
  if(!chart) throw new Error('图表未就绪');
749
  const url = chart.getDataURL({type:'png',pixelRatio:2,backgroundColor:'#fff'});
750
- const a = document.createElement('a');
751
- a.href = url; a.download = `echarts_${Date.now()}.png`; a.click();
752
  }
753
  },
754
- vega: {
755
- match: (lang)=>['vega','vegalite','vega-lite'].includes(lang),
756
- render: async (code, body)=>{
757
  if(!window.vegaEmbed) throw new Error('vega-embed 未加载');
758
- let spec;
759
- try{ spec = JSON.parse(code); }catch{
760
- throw new Error('vega/vega-lite 需要合法 JSON spec');
761
- }
762
- body.innerHTML = '<div></div>';
763
- await vegaEmbed(body.firstElementChild, spec, {actions:false, renderer:'svg'});
764
  }
765
  },
766
- graphviz: {
767
- match: (lang)=>['graphviz','dot'].includes(lang),
768
- render: async (code, body)=>{
769
  if(!window.Viz) throw new Error('Viz.js 未加载');
770
  const viz = new Viz();
771
  const svg = await viz.renderString(code);
772
  body.innerHTML = `<div class="render-svg-wrap">${sanitizeSvgText(svg)}</div>`;
773
  }
774
  },
775
- flowchart: {
776
- match: (lang)=>lang==='flowchart',
777
- render: async (code, body)=>{
778
  if(!window.flowchart) throw new Error('flowchart.js 未加载');
779
  const id='fc_'+Math.random().toString(36).slice(2);
780
- body.innerHTML = `<div id="${id}" class="render-svg-wrap"></div>`;
781
- const chart = flowchart.parse(code);
782
- chart.drawSVG(id, {lineWidth:2,fontSize:14});
783
  }
784
  },
785
- plantuml: {
786
- match: (lang)=>lang==='plantuml',
787
- render: async (code, body)=>{
788
- // 使用 kroki 公共接口(纯前端)
789
- const rsp = await fetch('https://kroki.io/plantuml/svg', {
790
- method: 'POST',
791
- headers: {'Content-Type':'text/plain'},
792
- body: code
793
- });
794
  if(!rsp.ok) throw new Error('plantuml 渲染失败');
795
  const svg = await rsp.text();
796
  body.innerHTML = `<div class="render-svg-wrap">${sanitizeSvgText(svg)}</div>`;
797
  }
798
- },
799
- htmlpreview: {
800
- match: (lang)=>['html-preview','htmlpreview','html'].includes(lang),
801
- render: async (code, body)=>{
802
- const iframe = document.createElement('iframe');
803
- iframe.style.width = '100%';
804
- iframe.style.minHeight = '260px';
805
- iframe.style.border = '1px solid #dbe5f3';
806
- iframe.style.borderRadius = '10px';
807
- iframe.setAttribute('sandbox', 'allow-same-origin');
808
- iframe.srcdoc = String(code);
809
- body.innerHTML = '';
810
- body.appendChild(iframe);
811
- }
812
- },
813
- svg: {
814
- match: (lang)=>lang==='svg',
815
- render: async (code, body)=>{
816
- body.innerHTML = `<div class="render-svg-wrap">${sanitizeSvgText(code)}</div>`;
817
- }
818
  }
819
  };
820
 
821
  function getRenderer(lang=''){
822
- const l = (lang || '').toLowerCase().trim();
823
- for(const r of Object.values(Renderers)){
824
- if(r.match(l)) return r;
825
- }
826
  return null;
827
  }
828
 
@@ -833,116 +736,69 @@
833
  if(!en.isIntersecting) return;
834
  const card = en.target;
835
  S.pluginObserver.unobserve(card);
836
- if(card.dataset.rendered === '1') return;
837
- card.dataset.rendered = '1';
838
-
839
- const lang = card.dataset.lang || '';
840
- const code = card.dataset.code || '';
841
- const body = card.querySelector('.render-body');
842
- const renderer = getRenderer(lang);
843
- if(!renderer){
 
844
  body.innerHTML = `<pre style="margin:0;white-space:pre-wrap">${escapeHtml(code)}</pre>`;
845
  return;
846
  }
847
- try{
848
- await renderer.render(code, body, card);
849
- }catch(e){
850
- body.innerHTML = `<div class="render-error">渲染失败:${escapeHtml(e.message || String(e))}</div>`;
851
- }
852
  });
853
- }, {root: E.messages, threshold: 0.1});
854
  return S.pluginObserver;
855
  }
856
 
857
  function registerPluginBlocks(root){
858
- const preCodes = root.querySelectorAll('pre > code');
859
  const io = ensurePluginObserver();
860
-
861
- preCodes.forEach((codeEl)=>{
862
  const pre = codeEl.parentElement;
863
- const className = codeEl.className || '';
864
- const lang = (className.match(/language-([a-zA-Z0-9_-]+)/)?.[1] || '').toLowerCase();
865
  const raw = codeEl.textContent || '';
866
  const renderer = getRenderer(lang);
867
 
868
- // 非插件语言:保留普通代码,但增强复制/下载
869
- if(!renderer){
870
- const wrap=document.createElement('div');
871
- wrap.className='render-card';
872
- wrap.innerHTML=`
873
- <div class="render-head">
874
- <span>${escapeHtml(lang || 'code')}</span>
875
- <span class="render-actions">
876
- <button class="copy">复制</button>
877
- <button class="save">保存</button>
878
- </span>
879
- </div>
880
- <div class="render-body"><pre style="margin:0;white-space:pre-wrap"><code>${escapeHtml(raw)}</code></pre></div>
881
- `;
882
- pre.replaceWith(wrap);
883
- wrap.querySelector('.copy').onclick=async()=>{await navigator.clipboard.writeText(raw);toast('代码已复制')};
884
- wrap.querySelector('.save').onclick=()=>{
885
- const a=document.createElement('a');
886
- a.href=URL.createObjectURL(new Blob([raw],{type:'text/plain;charset=utf-8'}));
887
- a.download=`code_${Date.now()}.${lang||'txt'}`;
888
- a.click();
889
- setTimeout(()=>URL.revokeObjectURL(a.href),1200);
890
- toast('代码已保存');
891
- };
892
- return;
893
- }
894
-
895
- const pid = 'p_'+Math.random().toString(36).slice(2);
896
- const card = document.createElement('div');
897
- card.className = 'render-card';
898
- card.dataset.pid = pid;
899
- card.dataset.lang = lang;
900
- card.dataset.code = raw;
901
- card.dataset.rendered = '0';
902
 
903
  card.innerHTML = `
904
  <div class="render-head">
905
- <span>🔧 ${escapeHtml(lang)} 渲染</span>
906
  <span class="render-actions">
907
  <button class="copy">复制源码</button>
908
- <button class="svg">导出PNG</button>
909
  <button class="raw">源码</button>
910
  </span>
911
  </div>
912
- <div class="render-body"><div class="render-placeholder">滚动到可视区域后自动渲染...</div></div>
913
  `;
914
  pre.replaceWith(card);
915
 
916
- card.querySelector('.copy').onclick = async()=> {
917
- await navigator.clipboard.writeText(raw);
918
- toast('源码已复制');
919
- };
920
- card.querySelector('.raw').onclick = ()=> {
921
- openToolDetail(`${lang} source`, raw);
922
- };
923
- card.querySelector('.svg').onclick = async()=> {
924
  try{
925
  const r = getRenderer(lang);
926
- if(r?.export){
927
- await r.export(card);
928
- toast('导出成功');
929
- return;
930
- }
931
  const svg = card.querySelector('svg');
932
  if(!svg) throw new Error('当前渲染器不支持导出PNG');
933
- await exportSvgAsPng(svg, `${lang}_${Date.now()}.png`);
934
  toast('导出成功');
935
- }catch(e){
936
- toast(e.message || '导出失败');
937
- }
938
  };
939
 
940
- io.observe(card);
941
  });
942
  }
943
 
944
  async function enhance(root){
945
- // 链接自动转多媒体
946
  root.querySelectorAll('a[href]').forEach(a=>{
947
  const href=a.getAttribute('href')||'';
948
  const t=extType(href);
@@ -960,108 +816,88 @@
960
  });
961
 
962
  root.querySelectorAll('img').forEach(img=>img.onclick=()=>openMedia(img.src,'image'));
963
-
964
  registerPluginBlocks(root);
965
- await renderMathIn(root);
 
 
 
 
 
 
 
 
 
 
 
 
 
966
  }
967
 
968
  async function buildMsg(m){
969
  const self = isSelf(m);
970
- const isToolResultRole = (m.role === 'toolResult');
971
- const toolCalls = Array.isArray(m?.data?.toolCalls) ? m.data.toolCalls : [];
972
- const toolResults = Array.isArray(m?.data?.toolResults) ? m.data.toolResults : [];
973
- const hasToolPayload = toolCalls.length > 0 || toolResults.length > 0;
974
- const rawText = msgText(m).trim();
975
- const files = (m && typeof m.data === 'object' && Array.isArray(m.data.files)) ? m.data.files : [];
976
- if (m.role === 'assistant' && !rawText && files.length === 0 && !hasToolPayload) return null;
977
-
978
- const isToolOnlyBubble = isToolResultRole || (m.role === 'assistant' && hasToolPayload && !rawText && files.length===0);
979
- const row=document.createElement('div');
980
- row.className='row'+(self?' self':'') + (isToolOnlyBubble ? ' tool-center' : '');
981
- row.dataset.id=m.id||'';
982
-
983
- const bubble=document.createElement('div');
984
- bubble.className='bubble '+(isToolOnlyBubble ? 'tool' : (self?'self':'other'));
985
-
986
- const sender = self ? 'User' : (isToolResultRole ? 'Tool' : (m?.user?.name || 'Assistant'));
987
- const html = (!isToolResultRole && rawText) ? renderMd(rawText) : '';
988
-
989
- let filesHtml='';
990
- if(files.length && !isToolResultRole){
991
- filesHtml+='<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px">';
992
  for(const it of files){
993
- const f=it.file||it; const url=it.url||f.url||''; if(!url) continue;
994
- const t=extType(url);
995
- if(t==='image') filesHtml+=`<img src="${escapeHtml(url)}">`;
996
- else if(t==='video') filesHtml+=`<video src="${escapeHtml(url)}" controls style="max-width:340px;border-radius:10px"></video>`;
997
- else if(t==='audio') filesHtml+=`<span class="wx-audio" data-audio="${escapeHtml(url)}">🔊 语音</span>`;
998
- else filesHtml+=`<a href="${escapeHtml(url)}" target="_blank" rel="noopener">${escapeHtml(f.filename||'文件')}</a>`;
 
 
999
  }
1000
- filesHtml+='</div>';
1001
- }
1002
-
1003
- let toolHtml = '';
1004
- if (m.role === 'assistant' && toolCalls.length > 0) {
1005
- toolHtml += `<div class="tool-chip">🛠️ 已发起 ${toolCalls.length} 个工具调用 <button class="btn btn-tool-view" data-kind="call">查看详情</button></div>`;
1006
- }
1007
- if (isToolResultRole) {
1008
- const payload = toolResults.length ? toolResults : (rawText ? { text: rawText } : m);
1009
- toolHtml += `<div class="tool-chip">📦 ToolResult(默认折叠)<button class="btn btn-tool-view" data-kind="role-result">查看详情</button></div>`;
1010
- m.__toolResultPayload = payload;
1011
- } else if (toolResults.length > 0) {
1012
- toolHtml += `<div class="tool-chip">📦 收到 ${toolResults.length} 个工具结果 <button class="btn btn-tool-view" data-kind="result">查看详情</button></div>`;
1013
  }
1014
 
1015
- bubble.innerHTML=`
1016
  <div class="bubble-toolbar">
1017
- <div class="sender ${isToolResultRole?'tool':''}">${escapeHtml(sender)}</div>
1018
  <button class="bubble-copy">复制</button>
1019
  </div>
1020
- ${html ? `<div class="md">${html}</div>` : ''}
1021
  ${filesHtml}
1022
- ${toolHtml}
1023
  <div class="meta">${formatTimeFromMessage(m)}</div>
1024
  `;
1025
  row.appendChild(bubble);
1026
 
1027
  bubble.querySelector('.bubble-copy').onclick = async()=>{
1028
  await navigator.clipboard.writeText(buildCopyText(m));
1029
- toast('对话已复制');
1030
  };
1031
- bubble.querySelectorAll('.btn-tool-view').forEach(btn=>{
1032
- btn.onclick = ()=>{
1033
- const kind = btn.getAttribute('data-kind');
1034
- if (kind === 'call') openToolDetail('Tool Calls', toolCalls);
1035
- else if (kind === 'role-result') openToolDetail('Tool Result', m.__toolResultPayload || m);
1036
- else openToolDetail('Tool Results', toolResults);
1037
- };
1038
- });
1039
 
1040
  await enhance(bubble);
1041
-
1042
  bubble.querySelectorAll('.wx-audio[data-audio]').forEach(n=>{
1043
- const a=new Audio(n.getAttribute('data-audio').replace('#audio',''));
1044
- n.onclick=()=>toggleWxAudio(n,a);
1045
  });
1046
- bubble.querySelectorAll('img').forEach(img=>img.onclick=()=>openMedia(img.src,'image'));
1047
 
1048
  return row;
1049
  }
1050
 
1051
  function pruneDomIfNeeded(){
1052
- // 轻量虚拟化:DOM上限保护,防止超长会话卡顿
1053
- const maxNodes = 280;
1054
- const rows = E.msgList.querySelectorAll('.row');
1055
  if(rows.length <= maxNodes) return;
1056
- const removeCount = rows.length - maxNodes;
1057
- for(let i=0;i<removeCount;i++){
1058
- rows[i].remove();
1059
- }
1060
  }
1061
 
1062
- function saveChannelCache(cid, orderedMessages){
1063
  try{
1064
- const slim = orderedMessages.slice(-140);
1065
  localStorage.setItem(cacheKey(cid), JSON.stringify(slim));
1066
  }catch{}
1067
  }
@@ -1074,42 +910,37 @@
1074
  }catch{return []}
1075
  }
1076
 
1077
- async function appendMessages(cid,list){
1078
  const st=ensureStore(cid);
1079
- const keep=nearBottom(E.messages);
1080
- let appended = 0;
1081
  for(const m of list){
1082
- if(!m||!m.id) continue;
1083
- if(st.ids.has(m.id)) continue;
1084
  const node = await buildMsg(m);
1085
- if (!node) continue;
1086
- st.ids.add(m.id); st.lastId=m.id;
1087
- E.msgList.appendChild(node);
1088
- appended++;
1089
  }
1090
  pruneDomIfNeeded();
1091
- if(keep || appended>0) E.messages.scrollTop=E.messages.scrollHeight;
1092
  }
1093
 
1094
  async function prependOlderMessages(cid, list){
1095
- const st = ensureStore(cid);
1096
- const prevHeight = E.messages.scrollHeight;
1097
- const frag = document.createDocumentFragment();
1098
- let added = 0;
1099
- for (const m of list) {
1100
- if(!m || !m.id) continue;
1101
- if(st.ids.has(m.id)) continue;
1102
  const node = await buildMsg(m);
1103
- if (!node) continue;
1104
  st.ids.add(m.id);
1105
  frag.appendChild(node);
1106
  added++;
1107
  }
1108
- const firstMsg = E.msgList.firstChild;
1109
- if (firstMsg) E.msgList.insertBefore(frag, firstMsg);
1110
- else E.msgList.appendChild(frag);
1111
- const newHeight = E.messages.scrollHeight;
1112
- E.messages.scrollTop += (newHeight - prevHeight);
1113
  return added;
1114
  }
1115
 
@@ -1121,64 +952,59 @@
1121
  saveChannelCache(cid, list);
1122
  return list;
1123
  }catch(e){
1124
- log('warn', `在线拉取失败,使用本地缓存: ${e.message}`);
1125
- const cached = readChannelCache(cid);
1126
- return cached;
1127
  }
1128
  }
1129
 
1130
  async function loadOlderAtTop(){
1131
- if (!S.current) return;
1132
- const cid = S.current.id;
1133
- const st = ensureStore(cid);
1134
- if (st.loadingMore || !st.hasMore) return;
1135
- st.loadingMore = true;
1136
- E.topLoader.style.display = 'block';
1137
- try {
1138
- const oldCount = st.ids.size;
1139
  st.loadedLimit += 50;
1140
  const list = await fetchChannelMessages(cid, st.loadedLimit);
1141
  const added = await prependOlderMessages(cid, list);
1142
- if (added === 0 || st.ids.size === oldCount) {
1143
- st.hasMore = false;
1144
- E.topLoader.textContent = '没有更多历史消息了';
1145
- setTimeout(() => { E.topLoader.style.display = 'none'; E.topLoader.textContent = '正在加载更早消息...'; }, 900);
1146
- } else {
1147
- E.topLoader.style.display = 'none';
1148
- }
1149
- } catch (e) {
1150
- log('warn', '加载更早消息失败: ' + e.message);
1151
- E.topLoader.style.display = 'none';
1152
- } finally {
1153
- st.loadingMore = false;
1154
  }
1155
  }
1156
 
1157
  async function loadChannels(){
1158
  const r=await apiFetch('/api/channels');
1159
  S.channels=await r.json();
1160
- E.channelCount.textContent=String(S.channels.length||0);
1161
  renderChannels();
1162
  const saved = localStorage.getItem('lastChannelId');
1163
- if (saved && S.channels.some(c=>c.id===saved)) {
1164
- await switchChannel(saved);
1165
- } else if(!S.current && S.channels.length) {
1166
- await switchChannel(S.channels[0].id);
1167
- }
1168
  }
1169
 
1170
  function renderChannels(){
1171
- const kw=E.searchInput.value.trim().toLowerCase();
1172
- const arr=(S.channels||[]).filter(c=>!kw||c.name.toLowerCase().includes(kw)||c.id.toLowerCase().includes(kw));
1173
- E.channelList.innerHTML=arr.map(c=>`
1174
  <div class="channel ${S.current?.id===c.id?'active':''}" data-id="${c.id}">
1175
  <div class="avatar">${escapeHtml((c.name||'C')[0].toUpperCase())}</div>
1176
  <div style="min-width:0">
1177
  <div style="font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${escapeHtml(c.name)}</div>
1178
  <div class="muted" style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${escapeHtml(c.id)}</div>
1179
  </div>
1180
- </div>`).join('');
1181
- E.channelList.querySelectorAll('.channel').forEach(n=>n.onclick=()=>switchChannel(n.dataset.id));
 
1182
  }
1183
 
1184
  async function switchChannel(cid){
@@ -1186,41 +1012,47 @@
1186
  const c=S.channels.find(x=>x.id===cid); if(!c) return;
1187
  S.current=c;
1188
  localStorage.setItem('lastChannelId', cid);
1189
- renderChannels();
1190
- E.headName.textContent=c.name;
1191
- E.headAvatar.textContent=(c.name||'C')[0].toUpperCase();
1192
 
1193
- E.msgList.innerHTML='';
 
 
1194
 
 
1195
  const st=ensureStore(cid);
1196
- st.ids.clear();
1197
- st.lastId=null;
1198
- st.hasMore=true;
1199
- st.loadedLimit = 50;
1200
 
1201
  const list = await fetchChannelMessages(cid, st.loadedLimit);
1202
  await appendMessages(cid, list);
1203
 
1204
  connectStream(cid);
1205
 
1206
- E.left.classList.remove('show');
1207
- E.mobileMask.classList.remove('show');
1208
 
1209
  restoreDraft(cid);
1210
 
1211
- const savedTop = localStorage.getItem(scrollKey(cid));
1212
- if (savedTop !== null) {
1213
- requestAnimationFrame(() => { restoreChannelScroll(cid); });
1214
- } else {
1215
- E.messages.scrollTop = E.messages.scrollHeight;
1216
- }
 
 
 
 
 
1217
  }
1218
 
1219
- function closeStream(){ if(S.es){S.es.close();S.es=null;} }
 
 
1220
 
1221
  function connectStream(cid){
1222
  closeStream();
1223
  const st=ensureStore(cid);
 
 
1224
 
1225
  const q=new URLSearchParams();
1226
  if(st.lastId) q.set('last_id',st.lastId);
@@ -1228,33 +1060,31 @@
1228
  q.set('limit', String(Math.max(st.loadedLimit, Number(S.settings.messageLimit || S.cfg?.runtimeConfig?.defaultMessageLimit || 120))));
1229
  q.set('interval', String(Math.max(800, Number(S.settings.streamInterval || S.cfg?.runtimeConfig?.streamInterval || 1500))));
1230
 
1231
- S.es=new EventSource(apiUrl(`/api/messages/${cid}/stream?${q.toString()}`));
 
 
 
1232
 
1233
- S.es.addEventListener('messages',async ev=>{
 
1234
  try{
1235
- const p=JSON.parse(ev.data||'{}');
1236
- const items = p.items || [];
1237
- await appendMessages(cid, items);
1238
-
1239
- // 更新本地缓存:简单追加
1240
- const cached = readChannelCache(cid);
1241
- const ids = new Set(cached.map(x=>x.id));
1242
- for(const it of items){
1243
- if(it?.id && !ids.has(it.id)) cached.push(it);
1244
- }
1245
- saveChannelCache(cid, cached);
1246
- st.reconnectTry = 0;
1247
- } catch(e){
1248
  log('warn','SSE解析失败:'+e.message);
1249
  }
1250
  });
1251
 
1252
- S.es.onerror=()=>{
 
1253
  closeStream();
1254
  st.reconnectTry = Math.min((st.reconnectTry || 0) + 1, 8);
1255
- const delay = Math.min(1000 * Math.pow(1.7, st.reconnectTry), 20000);
1256
- log('warn',`SSE断开,${Math.round(delay/1000)}秒后重连`);
1257
- setTimeout(()=>{ if(S.current?.id===cid) connectStream(cid); }, delay);
 
 
 
1258
  };
1259
  }
1260
 
@@ -1268,9 +1098,12 @@
1268
  }
1269
 
1270
  function renderPreview(){
1271
- if(!S.files.length){E.preview.innerHTML='';return;}
1272
- E.preview.innerHTML=S.files.map((f,i)=>`<span class="chip">${f.type.startsWith('image/')?'🖼️':f.type.startsWith('video/')?'🎥':'🎤'} ${escapeHtml(f.name)} <button data-i="${i}" style="border:none;background:transparent;color:#ef4444;cursor:pointer">×</button></span>`).join('');
1273
- E.preview.querySelectorAll('[data-i]').forEach(b=>b.onclick=()=>{S.files.splice(+b.dataset.i,1);renderPreview();});
 
 
 
1274
  }
1275
 
1276
  async function uploadFile(file){
@@ -1278,25 +1111,22 @@
1278
  const r=await apiFetch('/api/upload',{
1279
  method:'POST',
1280
  headers:{'Content-Type':'application/json'},
1281
- body:JSON.stringify({ filename:file.name,base64:String(b64).split(',')[1],mimeType:file.type })
1282
  });
1283
- const d=await r.json(); if(!d.url) throw new Error('上传失败');
 
1284
  return d.url;
1285
  }
1286
 
1287
  async function sendNow(){
1288
  if(!S.current) return alert('请先选择频道');
1289
-
1290
- const text=E.input.value.trim();
1291
  if(!text && !S.files.length) return;
1292
 
1293
  const links=[];
1294
  for(const f of S.files){
1295
- try{
1296
- links.push({url:await uploadFile(f),type:f.type});
1297
- }catch(e){
1298
- log('error','附件上传失败:'+e.message);
1299
- }
1300
  }
1301
 
1302
  let full=text;
@@ -1316,10 +1146,8 @@
1316
  body:JSON.stringify({text:full})
1317
  });
1318
 
1319
- E.input.value='';
1320
- autoGrow();
1321
- S.files=[];
1322
- renderPreview();
1323
  localStorage.setItem(draftKey(S.current.id), '');
1324
  hideCmdSuggest();
1325
  }
@@ -1330,7 +1158,7 @@
1330
  return '';
1331
  }
1332
  function extByMime(m=''){
1333
- const s = m.toLowerCase();
1334
  if(s.includes('ogg')) return 'ogg';
1335
  if(s.includes('webm')) return 'webm';
1336
  if(s.includes('wav')) return 'wav';
@@ -1338,97 +1166,91 @@
1338
  }
1339
 
1340
  async function startRecord(){
1341
- if(!S.current||S.recording) return;
1342
  try{
1343
  const stream=await navigator.mediaDevices.getUserMedia({audio:true});
1344
  const mime=pickMime();
1345
- const rec=mime?new MediaRecorder(stream,{mimeType:mime}):new MediaRecorder(stream);
1346
  S.rec=rec; S.chunks=[];
1347
-
1348
  rec.ondataavailable=e=>{ if(e.data && e.data.size) S.chunks.push(e.data); };
1349
  rec.onstop=async()=>{
1350
  try{
1351
  const finalMime = rec.mimeType || mime || 'audio/ogg';
1352
  const blob=new Blob(S.chunks,{type:finalMime});
1353
- const ext = extByMime(finalMime);
1354
- const file=new File([blob],`voice_${Date.now()}.${ext}`,{type:finalMime});
1355
  const url=await uploadFile(file);
1356
  await apiFetch(`/api/send/${S.current.id}`,{
1357
  method:'POST',
1358
  headers:{'Content-Type':'application/json'},
1359
  body:JSON.stringify({text:`[语音](${url}#audio)`})
1360
  });
1361
- }catch(e){
1362
- log('error','语音发送失败:'+e.message);
1363
- }finally{
1364
- stream.getTracks().forEach(t=>t.stop());
1365
- }
1366
  };
1367
-
1368
  rec.start();
1369
  S.recording=true;
1370
- E.holdBtn.textContent='录音中... 松开发送';
1371
- }catch(e){
1372
- log('error','录音失败:'+e.message);
1373
- }
1374
  }
1375
  function stopRecord(){
1376
  if(!S.recording) return;
1377
  S.recording=false;
1378
- E.holdBtn.textContent='按住说话,松开发送(仅语音,不自动转写)';
1379
- try{S.rec&&S.rec.stop();}catch{}
1380
  }
1381
 
1382
  async function loadConfig(){
1383
  try{
1384
  const r=await fetch(apiUrl('/api/config'));
1385
  S.cfg=await r.json();
1386
- if(Array.isArray(S.cfg?.defaultCommands) && S.cfg.defaultCommands.length){
1387
- S.commandList = S.cfg.defaultCommands;
1388
- }
 
 
 
 
 
1389
  }catch(e){ log('warn','读取配置失败:'+e.message); }
1390
  }
1391
 
1392
  async function pullServerLogs(){
1393
  try{
1394
- const r = await apiFetch('/api/logs');
1395
- const arr = await r.json();
1396
  if(!Array.isArray(arr)) return;
1397
  for(let i=arr.length-1;i>=0;i--){
1398
- const line = arr[i];
1399
  if(S.seenServerLogs.has(line)) continue;
1400
  S.seenServerLogs.add(line);
1401
- if(S.seenServerLogs.size>500){
1402
- S.seenServerLogs = new Set([...S.seenServerLogs].slice(-350));
1403
- }
1404
- E.logs.textContent += `[server] ${line}\n`;
1405
  }
1406
- E.logs.scrollTop = E.logs.scrollHeight;
1407
  }catch{}
1408
  }
1409
 
1410
  function openSettings(){
1411
- setApiBase.value = S.settings.apiBase || '';
1412
- setPassword.value = S.settings.connectPassword || '';
1413
- setBotUsername.value = S.settings.botUsername || S.cfg?.runtimeConfig?.botUsername || 'openman';
1414
- setStreamInterval.value = S.settings.streamInterval || S.cfg?.runtimeConfig?.streamInterval || 1500;
1415
- setMessageLimit.value = S.settings.messageLimit || S.cfg?.runtimeConfig?.defaultMessageLimit || 120;
1416
- setDebug.checked = (S.settings.debugLogs === 'true');
1417
- setUnsafe.checked = (S.settings.allowUnsafe === 'true');
1418
- const ui = S.cfg?.runtimeConfig?.uiProfile || {};
1419
- setUiProfile.value = JSON.stringify(ui, null, 2);
1420
- E.settingsModal.classList.add('show');
1421
- }
1422
- function closeSettings(){ E.settingsModal.classList.remove('show'); }
1423
 
1424
  async function saveSettings(){
1425
- S.settings.apiBase = setApiBase.value.trim();
1426
- S.settings.connectPassword = setPassword.value.trim();
1427
- S.settings.botUsername = setBotUsername.value.trim();
1428
- S.settings.streamInterval = String(Math.max(800, Number(setStreamInterval.value || 1500)));
1429
- S.settings.messageLimit = String(Math.max(20, Number(setMessageLimit.value || 120)));
1430
- S.settings.debugLogs = setDebug.checked ? 'true' : 'false';
1431
- S.settings.allowUnsafe = setUnsafe.checked ? 'true' : 'false';
1432
 
1433
  localStorage.setItem('apiBase', S.settings.apiBase);
1434
  localStorage.setItem('connectPassword', S.settings.connectPassword);
@@ -1438,114 +1260,109 @@
1438
  localStorage.setItem('debugLogs', S.settings.debugLogs);
1439
  localStorage.setItem('allowUnsafe', S.settings.allowUnsafe);
1440
 
1441
- let uiProfile = {};
1442
- try{
1443
- uiProfile = JSON.parse(setUiProfile.value || '{}');
1444
- }catch{
1445
- toast('环境映射JSON格式错误');
1446
- return;
1447
- }
1448
 
1449
  try{
1450
  await apiFetch('/api/runtime-settings',{
1451
- method:'POST',
1452
- headers:{'Content-Type':'application/json'},
1453
  body:JSON.stringify({
1454
- botUsername: S.settings.botUsername || undefined,
1455
- streamInterval: Number(S.settings.streamInterval),
1456
- defaultMessageLimit: Number(S.settings.messageLimit),
1457
- debugLogs: S.settings.debugLogs === 'true',
1458
  uiProfile
1459
  })
1460
  });
1461
- }catch(e){
1462
- log('warn','后端运行设置更新失败:'+e.message);
1463
- }
1464
 
1465
  closeSettings();
1466
- if(S.current) await switchChannel(S.current.id); else await loadChannels();
1467
  toast('设置已保存');
1468
  }
1469
 
1470
  function autoGrow(){
1471
- E.input.style.height='auto';
1472
- E.input.style.height=Math.min(E.input.scrollHeight,240)+'px';
1473
  }
1474
 
1475
  function showCmdSuggest(list){
1476
  if(!list.length){ hideCmdSuggest(); return; }
1477
- E.cmdSuggest.innerHTML = list.map((x,idx)=>`
1478
  <div class="cmd-item ${idx===S.cmdActive?'active':''}" data-idx="${idx}">
1479
  <div class="cmd-name">${escapeHtml(x.cmd)}</div>
1480
  <div class="cmd-desc">${escapeHtml(x.desc || '')}</div>
1481
  </div>
1482
  `).join('');
1483
- E.cmdSuggest.style.display = 'block';
1484
- E.cmdSuggest.querySelectorAll('.cmd-item').forEach(el=>{
1485
  el.onclick=()=>{
1486
- const i = Number(el.dataset.idx);
1487
  applyCommand(list[i]?.cmd || '');
1488
  };
1489
  });
1490
  }
1491
  function hideCmdSuggest(){
1492
- E.cmdSuggest.style.display = 'none';
1493
- E.cmdSuggest.innerHTML = '';
1494
- S.cmdActive = -1;
 
1495
  }
1496
  function applyCommand(cmd){
1497
  if(!cmd) return;
1498
- E.input.value = cmd + ' ';
1499
  autoGrow();
1500
  hideCmdSuggest();
1501
- E.input.focus();
1502
- localStorage.setItem(draftKey(S.current?.id || 'default'), E.input.value);
1503
  }
1504
  function refreshCmdSuggest(){
1505
- const t = E.input.value || '';
1506
- const trim = t.trim();
1507
- if(!trim.startsWith('/')) return hideCmdSuggest();
1508
-
1509
- const q = trim.toLowerCase();
1510
- const list = S.commandList.filter(x=>x.cmd.toLowerCase().includes(q)).slice(0,8);
1511
- if(!list.length) return hideCmdSuggest();
1512
  S._cmdCurrentList = list;
 
 
1513
  if(S.cmdActive >= list.length) S.cmdActive = 0;
1514
  showCmdSuggest(list);
1515
  }
1516
 
1517
  function bind(){
1518
- btnOpenLeft.onclick=()=>{E.left.classList.add('show');E.mobileMask.classList.add('show');};
1519
- btnCloseLeft.onclick=()=>{E.left.classList.remove('show');E.mobileMask.classList.remove('show');};
1520
- E.mobileMask.onclick=()=>{E.left.classList.remove('show');E.mobileMask.classList.remove('show');};
 
 
 
1521
 
1522
- btnLogs.onclick=()=>appEl.classList.toggle('debug-open');
1523
- btnCloseLogs.onclick=()=>appEl.classList.remove('debug-open');
 
1524
 
1525
- btnSettings.onclick=openSettings;
1526
- setCancel.onclick=closeSettings;
1527
- setSave.onclick=saveSettings;
1528
 
1529
- mediaClose.onclick=()=>E.mediaModal.classList.remove('show');
1530
- E.mediaModal.addEventListener('click',e=>{ if(e.target===E.mediaModal) E.mediaModal.classList.remove('show'); });
1531
 
1532
- toolDetailClose.onclick = ()=>E.toolDetail.classList.remove('show');
1533
- E.searchInput.addEventListener('input',renderChannels);
1534
 
1535
- E.fileInput.addEventListener('change',e=>{
1536
  S.files.push(...Array.from(e.target.files||[]));
1537
  renderPreview();
1538
  e.target.value='';
1539
  });
1540
 
1541
- // 输入框粘贴图片
1542
- E.input.addEventListener('paste',(e)=>{
1543
- const items = Array.from(e.clipboardData?.items || []);
1544
- const imgs = [];
1545
  for(const it of items){
1546
- if(it.kind === 'file' && it.type.startsWith('image/')){
1547
- const f = it.getAsFile();
1548
- if(f) imgs.push(new File([f], `paste_${Date.now()}_${Math.random().toString(36).slice(2)}.${(f.type.split('/')[1]||'png').replace('+xml','')}`, {type:f.type}));
 
 
1549
  }
1550
  }
1551
  if(imgs.length){
@@ -1556,32 +1373,34 @@
1556
  }
1557
  });
1558
 
1559
- // 拖拽上传
1560
  ['dragenter','dragover'].forEach(evt=>{
1561
- E.input.addEventListener(evt, (e)=>{e.preventDefault(); e.stopPropagation();});
1562
  });
1563
- E.input.addEventListener('drop', (e)=>{
1564
  e.preventDefault();
1565
- const files = Array.from(e.dataTransfer?.files || []);
1566
- if(files.length){
1567
- S.files.push(...files);
1568
  renderPreview();
1569
- toast(`已添加 ${files.length} 个附件`);
1570
  }
1571
  });
1572
 
1573
- sendBtn.onclick=sendNow;
1574
- E.input.addEventListener('input',()=>{
 
1575
  autoGrow();
1576
- if(S.current) localStorage.setItem(draftKey(S.current.id), E.input.value || '');
1577
  refreshCmdSuggest();
1578
  });
1579
 
1580
- E.input.addEventListener('keydown',e=>{
1581
- if(E.cmdSuggest.style.display==='block' && S._cmdCurrentList?.length){
 
 
1582
  if(e.key==='ArrowDown'){
1583
  e.preventDefault();
1584
- S.cmdActive = (S.cmdActive + 1 + S._cmdCurrentList.length) % S._cmdCurrentList.length;
1585
  showCmdSuggest(S._cmdCurrentList);
1586
  return;
1587
  }
@@ -1597,46 +1416,46 @@
1597
  return;
1598
  }
1599
  }
1600
- if(e.key==='Enter'&&!e.shiftKey){
1601
  e.preventDefault();
1602
  sendNow();
1603
  }
1604
  });
1605
- E.input.addEventListener('blur',()=>setTimeout(hideCmdSuggest,120));
1606
 
1607
- E.modeText.onclick=()=>{E.modeText.classList.add('active');E.modeVoice.classList.remove('active');E.textMode.style.display='';E.voiceMode.style.display='none';};
1608
- E.modeVoice.onclick=()=>{E.modeVoice.classList.add('active');E.modeText.classList.remove('active');E.textMode.style.display='none';E.voiceMode.style.display='';};
1609
 
1610
  const s=()=>startRecord(), t=()=>stopRecord();
1611
- E.holdBtn.addEventListener('mousedown',s);
1612
- E.holdBtn.addEventListener('mouseup',t);
1613
- E.holdBtn.addEventListener('mouseleave',t);
1614
- E.holdBtn.addEventListener('touchstart',e=>{e.preventDefault();s();},{passive:false});
1615
- E.holdBtn.addEventListener('touchend',e=>{e.preventDefault();t();},{passive:false});
1616
- E.holdBtn.addEventListener('touchcancel',t);
1617
-
1618
- let lock = false;
1619
- E.messages.addEventListener('scroll', async () => {
1620
  saveCurrentScroll();
1621
- if (lock) return;
1622
- if (E.messages.scrollTop <= 24) {
1623
- lock = true;
1624
  await loadOlderAtTop();
1625
- lock = false;
1626
  }
1627
  });
1628
 
1629
  window.addEventListener('beforeunload', saveCurrentScroll);
1630
 
1631
  window.addEventListener('online', ()=>{
1632
- S.offline = false;
1633
- E.offlineBar.style.display='none';
1634
  toast('网络已恢复');
1635
  if(S.current) connectStream(S.current.id);
1636
  });
1637
  window.addEventListener('offline', ()=>{
1638
- S.offline = true;
1639
- E.offlineBar.style.display='block';
1640
  closeStream();
1641
  toast('网络已断开,使用本地缓存');
1642
  });
@@ -1644,12 +1463,7 @@
1644
 
1645
  async function initPWA(){
1646
  if('serviceWorker' in navigator){
1647
- try{
1648
- await navigator.serviceWorker.register('/sw.js');
1649
- log('info','Service Worker 已注册');
1650
- }catch(e){
1651
- log('warn','Service Worker 注册失败: '+e.message);
1652
- }
1653
  }
1654
  }
1655
 
@@ -1660,11 +1474,11 @@
1660
  await loadConfig();
1661
  await loadEnhancers();
1662
 
1663
- if(S.offline) E.offlineBar.style.display='block';
1664
 
1665
  try{
1666
  await loadChannels();
1667
- setInterval(pullServerLogs, 3000);
1668
  }catch(e){
1669
  log('error','初始化失败:'+e.message);
1670
  alert('初始化失败:'+e.message);
 
10
  :root{
11
  --bg:#edf3fb; --panel:#fff; --line:#dce5f3; --text:#1f2937; --sub:#64748b;
12
  --brand:#3498ff; --self:#dff6c8; --other:#fff; --code:#f6f8ff;
 
 
13
  }
14
  *{box-sizing:border-box}
15
  html,body{height:100%;margin:0;font-family:Inter,"Noto Sans SC",system-ui}
16
  body{background:var(--bg);color:var(--text);overflow:hidden}
 
 
17
  .app{height:100%;width:100vw;display:grid;grid-template-columns:300px minmax(0,1fr);transition:.2s}
18
  .app.debug-open{grid-template-columns:300px minmax(0,1fr) 350px}
19
  .panel{min-height:0;display:flex;flex-direction:column;min-width:0}
20
+ .left{background:#fff;border-right:1px solid var(--line);z-index:42}
21
  .main{background:var(--bg);min-width:0}
22
  .right{background:#0f172a;color:#d9e9ff;border-left:1px solid #233754;display:none;min-width:0}
23
  .app.debug-open .right{display:flex}
24
+ .head{height:62px;display:flex;align-items:center;justify-content:space-between;padding:0 12px;border-bottom:1px solid var(--line);background:#fff;flex-shrink:0}
25
  .scroll{overflow:auto;min-height:0}
26
+ .muted{font-size:12px;color:#64748b}
27
  .avatar{width:38px;height:38px;border-radius:12px;background:linear-gradient(135deg,#4fa8ff,#7bc7ff);color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700}
28
  .btn{border:1px solid #cfdbef;background:#f7fbff;border-radius:11px;padding:8px 10px;cursor:pointer}
 
29
  .search{padding:10px;border-bottom:1px solid var(--line)}
30
  .search input{width:100%;padding:10px 12px;border:1px solid #c9d8ef;border-radius:12px;outline:none}
 
31
  .channel{display:flex;gap:10px;align-items:center;padding:10px;border-radius:12px;cursor:pointer;margin:4px 8px}
32
  .channel:hover{background:#f1f6ff}
33
  .channel.active{background:#e7f1ff}
 
34
  .messages{
35
  padding:14px 16px;
36
  background-image:radial-gradient(circle at 1px 1px,rgba(70,110,170,.08) 1px,transparent 0);
 
48
  border-radius:8px; padding:6px 8px; margin-bottom:10px;
49
  display:none;
50
  }
51
+ #offlineBar{
52
+ position:absolute;top:10px;left:50%;transform:translateX(-50%);
53
+ z-index:30;background:#fffbeb;color:#92400e;border:1px solid #fcd34d;
54
+ border-radius:999px;padding:6px 12px;font-size:12px;display:none;
55
+ }
56
+ /* 关键:消息少时仍贴底 */
57
  #msgList{
58
+ min-height:100%;
59
  display:flex;
60
  flex-direction:column;
61
+ justify-content:flex-end;
62
  }
63
 
64
  .row{display:flex;margin-bottom:12px;width:100%}
65
  .row.self{justify-content:flex-end}
 
66
  .bubble{
 
67
  max-width:min(680px,86%);
68
  border:1px solid #dce6f4;border-radius:16px;padding:10px 12px;
69
  box-shadow:0 1px 4px rgba(20,30,50,.08);
70
+ background:#fff;
71
  overflow:hidden;
72
+ }
73
+ .bubble.self{background:#dff6c8}
74
+ .bubble-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px}
 
 
 
 
 
 
 
 
 
75
  .sender{font-size:12px;color:#2563eb;font-weight:600}
76
+ .bubble-copy{font-size:11px;padding:3px 8px;border-radius:8px;border:1px solid #d6e2f2;background:#fff;cursor:pointer}
 
77
  .meta{font-size:11px;color:#6b7280;text-align:right;margin-top:6px}
78
 
79
+ .composer{background:#fff;border-top:1px solid var(--line);padding:10px;flex-shrink:0}
80
  .mode-row{display:flex;gap:8px;margin-bottom:8px}
81
  .mode-btn{border:1px solid #cfdbef;background:#f7fbff;padding:6px 10px;border-radius:10px;cursor:pointer}
82
  .mode-btn.active{background:#e6f1ff;border-color:#8cb5f4}
83
  .input-row{display:flex;align-items:flex-end;gap:8px;position:relative}
84
+ .send{width:44px;height:44px;border:none;border-radius:12px;background:#3498ff;color:#fff;cursor:pointer}
85
  textarea{width:100%;min-height:42px;max-height:240px;resize:vertical;border:1px solid #c8d8ef;border-radius:14px;padding:10px 12px;outline:none}
 
86
  .preview{font-size:12px;margin-bottom:8px}
87
  .chip{display:inline-flex;align-items:center;gap:6px;padding:4px 9px;border-radius:999px;border:1px solid #bfd6ff;background:#ecf5ff;color:#1e3a8a;margin:2px}
88
  .hold{width:100%;padding:12px;border-radius:12px;border:1px dashed #7aa9f7;background:#f0f7ff;cursor:pointer}
89
 
 
 
 
 
 
 
 
 
 
 
 
90
  .md{font-size:14px;line-height:1.65;word-break:break-word}
91
  .md p{margin:.35em 0}
92
+ .md img{max-width:min(420px,100%);height:auto;border-radius:12px;border:1px solid #d6e2f2;cursor:zoom-in;display:block;margin:8px 0}
93
  .md pre{margin:.5em 0}
94
  .md a{color:#2563eb;text-decoration:underline}
 
 
 
 
 
95
 
96
+ .render-card{border:1px solid #d8e3f5;border-radius:12px;background:#f8fbff;margin:.45em 0;overflow:hidden}
97
+ .render-head{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;background:#edf4ff;border-bottom:1px solid #d8e4fb;font-size:12px}
98
+ .render-actions button{border:1px solid #c7d8f7;background:#fff;border-radius:7px;padding:3px 7px;cursor:pointer;margin-left:6px}
99
+ .render-body{padding:10px;overflow:auto;min-height:42px}
100
+ .render-svg-wrap svg{max-width:100%;height:auto}
 
 
 
 
 
 
 
 
101
  .render-placeholder{font-size:12px;color:#64748b}
102
  .render-error{font-size:12px;color:#b91c1c;white-space:pre-wrap}
103
+
104
  .wx-audio{display:inline-flex;align-items:center;gap:8px;background:#e7f1ff;border:1px solid #b8d2ff;border-radius:999px;padding:7px 12px;cursor:pointer}
105
  .wx-audio.playing{background:#d7eaff}
 
106
 
107
+ #cmdSuggest{
108
+ position:absolute;left:54px;right:54px;bottom:58px;z-index:25;
109
+ background:#fff;border:1px solid #d5e2f3;border-radius:12px;
110
+ box-shadow:0 8px 24px rgba(15,23,42,.12);display:none;max-height:220px;overflow:auto;
111
  }
112
+ .cmd-item{padding:8px 10px;cursor:pointer;border-bottom:1px solid #edf2fb}
113
+ .cmd-item:last-child{border-bottom:none}
114
+ .cmd-item:hover,.cmd-item.active{background:#eef5ff}
115
+ .cmd-name{font-family:ui-monospace,Consolas,monospace;color:#1d4ed8;font-size:12px}
116
+ .cmd-desc{font-size:11px;color:#64748b;margin-top:2px}
117
 
118
  .mobile-only{display:none}
119
+ #mobileMask{position:fixed;inset:0;background:rgba(0,0,0,.28);z-index:41;display:none}
 
 
120
  #mobileMask.show{display:block}
121
 
122
+ @media (max-width:960px){
123
  .app,.app.debug-open{grid-template-columns:1fr;width:100vw}
124
  .left{
125
  position:fixed;left:0;top:0;bottom:0;width:86vw;max-width:340px;
126
+ transform:translateX(-100%);transition:.2s;z-index:42;border-right:1px solid #d8e4f3;
127
  }
128
  .left.show{transform:translateX(0)}
129
  .right{
 
134
  .mobile-only{display:inline-block}
135
  .bubble{max-width:96%}
136
  .head{padding:0 8px}
137
+ .messages{padding:10px}
138
  #cmdSuggest{left:48px;right:48px;bottom:56px}
139
  }
140
 
 
143
  .modal-card{width:min(92vw,860px);max-height:88vh;background:#fff;border-radius:14px;padding:14px;overflow:auto}
144
  .field{margin-bottom:10px}
145
  .field label{display:block;font-size:12px;color:#64748b;margin-bottom:5px}
146
+ .field input,.field textarea{width:100%;padding:9px 10px;border:1px solid #cbd8ed;border-radius:10px}
147
  .field textarea{min-height:92px;font-family:ui-monospace,Consolas,monospace;font-size:12px}
148
  .grid-2{display:grid;grid-template-columns:1fr 1fr;gap:10px}
149
  .modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:8px}
 
151
  #mediaTop{position:absolute;top:16px;right:24px;display:flex;gap:10px}
152
  #mediaTop button{border:none;border-radius:10px;padding:9px 11px;cursor:pointer}
153
  #mediaContent img,#mediaContent video{max-width:90vw;max-height:84vh;border-radius:12px}
 
154
  #toolDetail{
155
+ position:fixed;top:0;right:-560px;width:min(560px,95vw);height:100vh;background:#fff;z-index:70;
156
+ box-shadow:-8px 0 24px rgba(15,23,42,.15);transition:.22s;border-left:1px solid #dbe5f3;display:flex;flex-direction:column;
 
157
  }
158
  #toolDetail.show{right:0}
159
+ #toolDetailHead{height:56px;border-bottom:1px solid #e2e8f0;display:flex;align-items:center;justify-content:space-between;padding:0 12px;font-weight:600}
160
+ #toolDetailBody{flex:1;overflow:auto;padding:10px}
161
+ #toolDetailBody pre{margin:0;white-space:pre-wrap;word-break:break-word;background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px;padding:10px;font-size:12px}
 
 
 
 
 
 
 
162
  #toast{
163
  position:fixed;left:50%;bottom:24px;transform:translateX(-50%);
164
+ background:rgba(15,23,42,.92);color:#fff;padding:8px 12px;border-radius:10px;font-size:12px;
165
+ z-index:999;opacity:0;pointer-events:none;transition:.18s;
166
  }
167
  #toast.show{opacity:1}
168
  </style>
 
189
  <div style="display:flex;align-items:center;gap:10px;min-width:0">
190
  <button id="btnOpenLeft" class="btn mobile-only">☰</button>
191
  <div id="headAvatar" class="avatar">C</div>
192
+ <div style="min-width:0">
193
+ <div id="headName" style="font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">请选择频道</div>
194
+ <div class="muted">OpenClaw Socket 历史流</div>
195
+ </div>
196
  </div>
197
  <div style="display:flex;gap:8px;flex-shrink:0">
198
  <button id="btnSettings" class="btn">设置</button>
 
215
  <div id="textMode">
216
  <div class="input-row">
217
  <label class="btn">📎<input id="fileInput" type="file" multiple accept="image/*,video/*,audio/*" style="display:none"></label>
218
+ <textarea id="input" rows="1" placeholder="输入消息(支持 Markdown/KaTeX/mermaid/echarts/vega/graphviz/plantuml)"></textarea>
219
  <button id="sendBtn" class="send">➤</button>
220
  <div id="cmdSuggest"></div>
221
  </div>
222
  </div>
223
  <div id="voiceMode" style="display:none">
224
+ <button id="holdBtn" class="hold">按住说话,松开发送(纯语音)</button>
225
  </div>
226
  </div>
227
  </main>
 
239
  <div class="modal-card">
240
  <h3 style="margin-top:0">设置</h3>
241
  <div class="grid-2">
242
+ <div class="field"><label>网关地址</label><input id="setApiBase" /></div>
243
+ <div class="field"><label>连接密码</label><input id="setPassword" /></div>
244
  </div>
245
  <div class="grid-2">
246
+ <div class="field"><label>机器人用户名</label><input id="setBotUsername" /></div>
247
  <div class="field"><label>SSE拉流间隔(ms)</label><input id="setStreamInterval" type="number" min="800" step="100" /></div>
248
  </div>
249
  <div class="field"><label>默认消息条数(limit)</label><input id="setMessageLimit" type="number" min="20" step="10" /></div>
 
285
  <div id="toast"></div>
286
 
287
  <script>
288
+ const MANDATORY_CMDS = [
 
 
 
289
  { cmd:'/new', desc:'新会话' },
290
  { cmd:'/reset', desc:'重置上下文' },
291
+ { cmd:'/status', desc:'查看状态' }
292
+ ];
293
+ const BASE_CMDS = [
294
+ { cmd:'/help', desc:'查看帮助' },
295
+ ...MANDATORY_CMDS,
296
  { cmd:'/clear', desc:'清空会话' },
297
+ { cmd:'/history', desc:'查看历史' },
298
+ { cmd:'/model', desc:'切换模型(服务端支持时)' },
299
+ { cmd:'/tools', desc:'查看工具' }
 
300
  ];
301
 
302
+ const D = {
303
+ app: document.getElementById('app'),
304
+ leftPanel: document.getElementById('leftPanel'),
305
+ rightPanel: document.getElementById('rightPanel'),
306
+ messages: document.getElementById('messages'),
307
+ msgList: document.getElementById('msgList'),
308
+ topLoader: document.getElementById('topLoader'),
309
+ offlineBar: document.getElementById('offlineBar'),
310
+ channelList: document.getElementById('channelList'),
311
+ channelCount: document.getElementById('channelCount'),
312
+ searchInput: document.getElementById('searchInput'),
313
+ headName: document.getElementById('headName'),
314
+ headAvatar: document.getElementById('headAvatar'),
315
+ input: document.getElementById('input'),
316
+ fileInput: document.getElementById('fileInput'),
317
+ preview: document.getElementById('preview'),
318
+ logs: document.getElementById('logs'),
319
+ toast: document.getElementById('toast'),
320
+ modeText: document.getElementById('modeText'),
321
+ modeVoice: document.getElementById('modeVoice'),
322
+ textMode: document.getElementById('textMode'),
323
+ voiceMode: document.getElementById('voiceMode'),
324
+ holdBtn: document.getElementById('holdBtn'),
325
+ settingsModal: document.getElementById('settingsModal'),
326
+ mediaModal: document.getElementById('mediaModal'),
327
+ mediaContent: document.getElementById('mediaContent'),
328
+ mediaDownload: document.getElementById('mediaDownload'),
329
+ toolDetail: document.getElementById('toolDetail'),
330
+ toolDetailTitle: document.getElementById('toolDetailTitle'),
331
+ toolDetailBody: document.getElementById('toolDetailBody'),
332
+ cmdSuggest: document.getElementById('cmdSuggest'),
333
+ mobileMask: document.getElementById('mobileMask'),
334
+
335
+ btnOpenLeft: document.getElementById('btnOpenLeft'),
336
+ btnCloseLeft: document.getElementById('btnCloseLeft'),
337
+ btnSettings: document.getElementById('btnSettings'),
338
+ btnLogs: document.getElementById('btnLogs'),
339
+ btnCloseLogs: document.getElementById('btnCloseLogs'),
340
+ sendBtn: document.getElementById('sendBtn'),
341
+ setCancel: document.getElementById('setCancel'),
342
+ setSave: document.getElementById('setSave'),
343
+ mediaClose: document.getElementById('mediaClose'),
344
+ toolDetailClose: document.getElementById('toolDetailClose'),
345
+
346
+ setApiBase: document.getElementById('setApiBase'),
347
+ setPassword: document.getElementById('setPassword'),
348
+ setBotUsername: document.getElementById('setBotUsername'),
349
+ setStreamInterval: document.getElementById('setStreamInterval'),
350
+ setMessageLimit: document.getElementById('setMessageLimit'),
351
+ setDebug: document.getElementById('setDebug'),
352
+ setUnsafe: document.getElementById('setUnsafe'),
353
+ setUiProfile: document.getElementById('setUiProfile')
354
+ };
355
+
356
  const S = {
357
  cfg: { runtimeConfig: { botUsername: 'openman', streamInterval: 1500, defaultMessageLimit: 120, uiProfile: {} } },
358
  settings: {
 
368
  current: null,
369
  files: [],
370
  es: null,
371
+ msgStore: new Map(),
372
  rec: null,
373
  recording: false,
374
  chunks: [],
375
  activeAudio: null,
376
  seenServerLogs: new Set(),
377
+ cmdList: [...BASE_CMDS],
378
  cmdActive: -1,
379
+ _cmdCurrentList: [],
380
  pluginObserver: null,
381
+ chartInstances: new Map(),
382
+ streamToken: 0,
383
  offline: !navigator.onLine
384
  };
385
 
386
+ function apiBaseTrim(){ const b=(S.settings.apiBase||'').trim(); return b ? b.replace(/\/+$/,'') : ''; }
387
+ function apiUrl(path){ const base = apiBaseTrim(); return base ? `${base}${path}` : path; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  function scrollKey(cid){ return `scroll_${cid}`; }
389
  function draftKey(cid){ return `draft_${cid}`; }
390
  function cacheKey(cid){ return `cache_msgs_${cid}`; }
391
 
392
  function toast(msg){
393
+ D.toast.textContent = msg;
394
+ D.toast.classList.add('show');
395
+ clearTimeout(D.toast._t);
396
+ D.toast._t = setTimeout(()=>D.toast.classList.remove('show'), 1300);
397
+ }
398
+ function log(type,msg){
399
+ if(type==='info' && S.settings.debugLogs!=='true') return;
400
+ const t=new Date().toLocaleTimeString('zh-CN',{hour12:false});
401
+ const p=type==='error'?'❌':type==='warn'?'⚠️':'📌';
402
+ const line=`[${t}] ${p} ${msg}`;
403
+ (type==='error'?console.error:console.log)(line);
404
+ D.logs.textContent += line + '\n';
405
+ D.logs.scrollTop = D.logs.scrollHeight;
406
+ }
407
+
408
+ async function apiFetch(path,opt={}){
 
409
  const headers = new Headers(opt.headers || {});
410
  if(S.settings.connectPassword) headers.set('x-connect-password', S.settings.connectPassword);
411
+ const rsp = await fetch(apiUrl(path), { ...opt, headers });
412
+ if(rsp.status===401) throw new Error('连接密码错误或未提供');
413
+ return rsp;
 
 
 
 
 
 
 
414
  }
415
 
416
+ function escapeHtml(s=''){ return String(s).replace(/[&<>"']/g,m=>({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[m])); }
417
  function extType(url=''){
418
  const u = String(url).toLowerCase();
419
  if(u.endsWith('#audio') || /[?&]type=audio/.test(u)) return 'audio';
 
425
 
426
  function ensureStore(cid){
427
  if(!S.msgStore.has(cid)) S.msgStore.set(cid,{
428
+ ids:new Set(), lastId:null, loadedLimit:50, loadingMore:false, hasMore:true, scrollTop:0, reconnectTry:0
 
429
  });
430
  return S.msgStore.get(cid);
431
  }
432
  function nearBottom(el,th=120){ return el.scrollHeight - el.scrollTop - el.clientHeight < th; }
433
+
434
  function saveCurrentScroll(){
435
  if(!S.current) return;
436
  const cid = S.current.id;
437
  const st = ensureStore(cid);
438
+ st.scrollTop = D.messages.scrollTop;
439
  localStorage.setItem(scrollKey(cid), String(st.scrollTop));
440
+ localStorage.setItem(draftKey(cid), D.input.value || '');
441
  }
442
  function restoreChannelScroll(cid){
443
  const st = ensureStore(cid);
444
  const v = localStorage.getItem(scrollKey(cid));
445
  let t = Number(v || st.scrollTop || 0);
446
  if(!Number.isFinite(t) || t < 0) t = 0;
447
+ D.messages.scrollTop = t;
448
  }
449
  function restoreDraft(cid){
450
+ D.input.value = localStorage.getItem(draftKey(cid)) || '';
451
  autoGrow();
452
+ refreshCmdSuggest();
453
  }
454
 
455
  function formatTimeFromMessage(m){
456
+ const candidates=[m.timestamp,m.created_at,m.updated_at];
457
+ let d=null;
458
  for(const c of candidates){
459
+ if(c==null) continue;
460
+ if(typeof c==='number' || /^\d+$/.test(String(c))){
461
+ let n=Number(c); if(n<1e11) n*=1000;
462
+ const t=new Date(n); if(!isNaN(t.getTime())){d=t;break;}
 
 
463
  }else{
464
+ const t=new Date(c); if(!isNaN(t.getTime())){d=t;break;}
 
465
  }
466
  }
467
+ if(!d) d=new Date();
468
+ const p=x=>String(x).padStart(2,'0');
469
+ return `${d.getFullYear()}.${p(d.getMonth()+1)}.${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
470
  }
471
 
472
  function loadScriptAny(urls){
 
477
  const s=document.createElement('script');
478
  s.src=urls[i++]; s.async=true;
479
  s.onload=()=>resolve();
480
+ s.onerror=()=>{s.remove();next();};
481
  document.head.appendChild(s);
482
  };
483
  next();
 
491
  const l=document.createElement('link');
492
  l.rel='stylesheet'; l.href=urls[i++];
493
  l.onload=()=>resolve();
494
+ l.onerror=()=>{l.remove();next();};
495
  document.head.appendChild(l);
496
  };
497
  next();
 
508
  'https://cdn.jsdelivr.net/npm/dompurify@3.1.6/dist/purify.min.js',
509
  'https://unpkg.com/dompurify@3.1.6/dist/purify.min.js'
510
  ]),
 
 
 
 
511
  loadCssAny([
512
  'https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.css',
513
  'https://unpkg.com/katex@0.16.10/dist/katex.min.css'
 
516
  'https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.js',
517
  'https://unpkg.com/katex@0.16.10/dist/katex.min.js'
518
  ]),
519
+ window.mermaid ? Promise.resolve() : loadScriptAny([
520
+ 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js'
 
521
  ]),
522
  window.echarts ? Promise.resolve() : loadScriptAny([
523
+ 'https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js'
 
524
  ]),
525
  (window.vega && window.vegaLite && window.vegaEmbed) ? Promise.resolve() : loadScriptAny([
526
  'https://cdn.jsdelivr.net/npm/vega@5/build/vega.min.js'
527
+ ]).then(()=>loadScriptAny(['https://cdn.jsdelivr.net/npm/vega-lite@5/build/vega-lite.min.js']))
528
+ .then(()=>loadScriptAny(['https://cdn.jsdelivr.net/npm/vega-embed@6/build/vega-embed.min.js'])),
 
 
 
529
  window.Viz ? Promise.resolve() : loadScriptAny([
530
+ 'https://cdn.jsdelivr.net/npm/viz.js@2.1.2/viz.js'
 
531
  ]),
532
  window.flowchart ? Promise.resolve() : loadScriptAny([
533
  'https://cdn.jsdelivr.net/npm/flowchart.js@1.18.0/release/flowchart.min.js'
 
536
  if(window.mermaid) mermaid.initialize({startOnLoad:false,securityLevel:'strict',theme:'default'});
537
  }
538
 
539
+ // ---- KaTeX 强制渲染(修复 $...$ / $$...$$)----
540
+ function renderMathTokens(md=''){
541
  const tokens = [];
542
+ let idx = 0;
543
+ let text = String(md);
544
+
545
+ // code block 先保护
546
+ const codeBlocks = [];
547
+ text = text.replace(/```[\s\S]*?```/g, (m)=>{
548
+ const k = `@@CODEBLOCK_${codeBlocks.length}@@`;
549
+ codeBlocks.push(m);
550
  return k;
551
  });
552
 
553
+ // block math
554
+ text = text.replace(/\$\$([\s\S]+?)\$\$/g, (_,expr)=>{
555
+ const key = `@@MATH_${idx++}@@`;
556
+ let html = `$$${expr}$$`;
557
+ if(window.katex){
558
+ try{ html = katex.renderToString(expr, {displayMode:true, throwOnError:false}); }catch{}
559
+ }
560
+ tokens.push({key, html});
561
+ return key;
562
  });
563
 
564
+ // inline math
565
+ text = text.replace(/(^|[^\\])\$([^\n$]+?)\$/g, (m,pfx,expr)=>{
566
+ const key = `@@MATH_${idx++}@@`;
567
+ let html = `$${expr}$`;
568
+ if(window.katex){
569
+ try{ html = katex.renderToString(expr, {displayMode:false, throwOnError:false}); }catch{}
570
+ }
571
+ tokens.push({key, html});
572
+ return `${pfx}${key}`;
573
+ });
574
+
575
+ // 还原 code block
576
+ text = text.replace(/@@CODEBLOCK_(\d+)@@/g, (_,i)=>codeBlocks[Number(i)] || '');
577
+
578
+ return { text, tokens };
579
  }
580
 
581
  function markdownToHtml(md=''){
582
  if(window.marked){
583
+ marked.setOptions({gfm:true,breaks:true,mangle:false,headerIds:false});
584
  return marked.parse(md);
585
  }
586
  return escapeHtml(md).replace(/\n/g,'<br>');
587
  }
588
 
589
  function renderMd(raw=''){
590
+ const p = renderMathTokens(raw);
591
+ let html = markdownToHtml(p.text);
592
+ for(const t of p.tokens){
593
+ html = html.split(t.key).join(t.html);
594
+ }
595
  if(window.DOMPurify){
596
  html = DOMPurify.sanitize(html,{
597
  USE_PROFILES:{html:true},
 
602
  return html;
603
  }
604
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
605
  function msgText(m){
606
  if(typeof m.content==='string') return m.content;
607
  if(Array.isArray(m.content)) return m.content.map(x=>x?.text||'').join('\n');
 
610
  }
611
 
612
  function isSelf(m){
613
+ const bot=(S.settings.botUsername||S.cfg?.runtimeConfig?.botUsername||'openman').toLowerCase();
614
+ const n=(m?.user?.name||'').toLowerCase();
615
+ if(m.role==='user') return true;
616
+ if(m.role==='assistant') return false;
617
+ return !!(n && n!==bot);
 
 
618
  }
619
 
620
  function openMedia(url,type){
621
+ D.mediaContent.innerHTML = type==='image'
622
  ? `<img src="${escapeHtml(url)}">`
623
  : `<video src="${escapeHtml(url)}" controls autoplay></video>`;
624
+ D.mediaDownload.href = url;
625
+ D.mediaModal.classList.add('show');
626
  }
627
 
628
  function toggleWxAudio(wx,audio){
629
  if(S.activeAudio && S.activeAudio!==audio) S.activeAudio.pause();
630
+ if(audio.paused){audio.play();wx.classList.add('playing');S.activeAudio=audio;}
631
+ else{audio.pause();wx.classList.remove('playing');}
632
  audio.onended=()=>wx.classList.remove('playing');
633
  audio.onpause=()=>wx.classList.remove('playing');
634
  audio.onplay=()=>wx.classList.add('playing');
 
643
  const blob = new Blob([svgData], {type:'image/svg+xml;charset=utf-8'});
644
  const url = URL.createObjectURL(blob);
645
  const img = new Image();
646
+ await new Promise((resolve,reject)=>{ img.onload=resolve; img.onerror=reject; img.src=url; });
 
 
647
  const c = document.createElement('canvas');
648
+ c.width = img.width || 1000; c.height = img.height || 600;
 
649
  const ctx = c.getContext('2d');
650
+ ctx.fillStyle = '#fff'; ctx.fillRect(0,0,c.width,c.height); ctx.drawImage(img,0,0);
 
 
651
  URL.revokeObjectURL(url);
652
  c.toBlob((b)=>{
653
+ const a=document.createElement('a');
654
+ a.href=URL.createObjectURL(b); a.download=filename; a.click();
 
 
655
  setTimeout(()=>URL.revokeObjectURL(a.href),1200);
656
  }, 'image/png');
657
  }
658
 
659
  const Renderers = {
660
+ mermaid:{
661
+ match:l=>['mermaid','mindmap','sequence'].includes(l),
662
  render: async (code, body)=>{
663
  if(!window.mermaid) throw new Error('mermaid 未加载');
664
  const id='mmd_'+Math.random().toString(36).slice(2);
665
+ const { svg } = await mermaid.render(id, code.trim());
 
666
  body.innerHTML = `<div class="render-svg-wrap">${svg}</div>`;
667
  }
668
  },
669
+ echarts:{
670
+ match:l=>l==='echarts',
671
  render: async (code, body, card)=>{
672
  if(!window.echarts) throw new Error('echarts 未加载');
673
+ let opt; try{ opt=JSON.parse(code);}catch{ throw new Error('echarts 需要 JSON option'); }
674
+ const d=document.createElement('div');
675
+ d.style.height='360px'; body.innerHTML=''; body.appendChild(d);
676
+ const chart=echarts.init(d); chart.setOption(opt,true);
677
+ S.chartInstances.set(card.dataset.pid, chart);
 
 
 
 
 
 
 
 
 
678
  const ro = new ResizeObserver(()=>chart.resize());
679
+ ro.observe(d); card._ro = ro;
 
680
  },
681
+ export: async(card)=>{
682
  const chart = S.chartInstances.get(card.dataset.pid);
683
  if(!chart) throw new Error('图表未就绪');
684
  const url = chart.getDataURL({type:'png',pixelRatio:2,backgroundColor:'#fff'});
685
+ const a=document.createElement('a'); a.href=url; a.download=`echarts_${Date.now()}.png`; a.click();
 
686
  }
687
  },
688
+ vega:{
689
+ match:l=>['vega','vega-lite','vegalite'].includes(l),
690
+ render: async(code, body)=>{
691
  if(!window.vegaEmbed) throw new Error('vega-embed 未加载');
692
+ let spec; try{ spec=JSON.parse(code);}catch{ throw new Error('vega spec 必须是 JSON'); }
693
+ body.innerHTML='<div></div>';
694
+ await vegaEmbed(body.firstElementChild, spec, {actions:false,renderer:'svg'});
 
 
 
695
  }
696
  },
697
+ graphviz:{
698
+ match:l=>['graphviz','dot'].includes(l),
699
+ render: async(code, body)=>{
700
  if(!window.Viz) throw new Error('Viz.js 未加载');
701
  const viz = new Viz();
702
  const svg = await viz.renderString(code);
703
  body.innerHTML = `<div class="render-svg-wrap">${sanitizeSvgText(svg)}</div>`;
704
  }
705
  },
706
+ flowchart:{
707
+ match:l=>l==='flowchart',
708
+ render: async(code, body)=>{
709
  if(!window.flowchart) throw new Error('flowchart.js 未加载');
710
  const id='fc_'+Math.random().toString(36).slice(2);
711
+ body.innerHTML=`<div id="${id}" class="render-svg-wrap"></div>`;
712
+ flowchart.parse(code).drawSVG(id, {lineWidth:2,fontSize:14});
 
713
  }
714
  },
715
+ plantuml:{
716
+ match:l=>l==='plantuml',
717
+ render: async(code, body)=>{
718
+ const rsp = await fetch('https://kroki.io/plantuml/svg',{method:'POST',headers:{'Content-Type':'text/plain'},body:code});
 
 
 
 
 
719
  if(!rsp.ok) throw new Error('plantuml 渲染失败');
720
  const svg = await rsp.text();
721
  body.innerHTML = `<div class="render-svg-wrap">${sanitizeSvgText(svg)}</div>`;
722
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
723
  }
724
  };
725
 
726
  function getRenderer(lang=''){
727
+ const l = String(lang).toLowerCase().trim();
728
+ for(const r of Object.values(Renderers)){ if(r.match(l)) return r; }
 
 
729
  return null;
730
  }
731
 
 
736
  if(!en.isIntersecting) return;
737
  const card = en.target;
738
  S.pluginObserver.unobserve(card);
739
+ if(card.dataset.rendered==='1') return;
740
+ card.dataset.rendered='1';
741
+
742
+ const lang=card.dataset.lang||'';
743
+ const code=card.dataset.code||'';
744
+ const body=card.querySelector('.render-body');
745
+ const r=getRenderer(lang);
746
+
747
+ if(!r){
748
  body.innerHTML = `<pre style="margin:0;white-space:pre-wrap">${escapeHtml(code)}</pre>`;
749
  return;
750
  }
751
+ try{ await r.render(code, body, card); }
752
+ catch(e){ body.innerHTML = `<div class="render-error">渲染失败:${escapeHtml(e.message||String(e))}</div>`; }
 
 
 
753
  });
754
+ }, {root:D.messages, threshold:0.1});
755
  return S.pluginObserver;
756
  }
757
 
758
  function registerPluginBlocks(root){
 
759
  const io = ensurePluginObserver();
760
+ root.querySelectorAll('pre > code').forEach(codeEl=>{
 
761
  const pre = codeEl.parentElement;
762
+ const lang = (codeEl.className.match(/language-([a-zA-Z0-9_-]+)/)?.[1] || '').toLowerCase();
 
763
  const raw = codeEl.textContent || '';
764
  const renderer = getRenderer(lang);
765
 
766
+ const pid='p_'+Math.random().toString(36).slice(2);
767
+ const card=document.createElement('div');
768
+ card.className='render-card';
769
+ card.dataset.pid=pid; card.dataset.lang=lang; card.dataset.code=raw; card.dataset.rendered='0';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
770
 
771
  card.innerHTML = `
772
  <div class="render-head">
773
+ <span>${renderer ? `🔧 ${escapeHtml(lang)} 渲染` : escapeHtml(lang || 'code')}</span>
774
  <span class="render-actions">
775
  <button class="copy">复制源码</button>
776
+ <button class="png">导出PNG</button>
777
  <button class="raw">源码</button>
778
  </span>
779
  </div>
780
+ <div class="render-body"><div class="render-placeholder">${renderer ? '滚动到可视区域后自动渲染...' : ''}</div><pre style="margin:0;white-space:pre-wrap;display:${renderer?'none':'block'}">${escapeHtml(raw)}</pre></div>
781
  `;
782
  pre.replaceWith(card);
783
 
784
+ card.querySelector('.copy').onclick=async()=>{ await navigator.clipboard.writeText(raw); toast('源码已复制'); };
785
+ card.querySelector('.raw').onclick=()=>openToolDetail(`${lang || 'code'} source`, raw);
786
+ card.querySelector('.png').onclick=async()=>{
 
 
 
 
 
787
  try{
788
  const r = getRenderer(lang);
789
+ if(r?.export){ await r.export(card); toast('导出成功'); return; }
 
 
 
 
790
  const svg = card.querySelector('svg');
791
  if(!svg) throw new Error('当前渲染器不支持导出PNG');
792
+ await exportSvgAsPng(svg, `${lang||'render'}_${Date.now()}.png`);
793
  toast('导出成功');
794
+ }catch(e){ toast(e.message || '导出失败'); }
 
 
795
  };
796
 
797
+ if(renderer) io.observe(card);
798
  });
799
  }
800
 
801
  async function enhance(root){
 
802
  root.querySelectorAll('a[href]').forEach(a=>{
803
  const href=a.getAttribute('href')||'';
804
  const t=extType(href);
 
816
  });
817
 
818
  root.querySelectorAll('img').forEach(img=>img.onclick=()=>openMedia(img.src,'image'));
 
819
  registerPluginBlocks(root);
820
+ }
821
+
822
+ function buildCopyText(m){
823
+ const t = msgText(m);
824
+ const files = (m?.data?.files || []).map(x=>x.url || x?.file?.url).filter(Boolean);
825
+ return [t, files.length?('\n'+files.join('\n')):''].join('\n').trim();
826
+ }
827
+
828
+ function openToolDetail(title,obj){
829
+ D.toolDetailTitle.textContent = title;
830
+ let pretty='';
831
+ try{ pretty=JSON.stringify(obj,null,2);}catch{ pretty=String(obj); }
832
+ D.toolDetailBody.innerHTML = `<pre>${escapeHtml(pretty)}</pre>`;
833
+ D.toolDetail.classList.add('show');
834
  }
835
 
836
  async function buildMsg(m){
837
  const self = isSelf(m);
838
+ const raw = msgText(m).trim();
839
+ const files = Array.isArray(m?.data?.files) ? m.data.files : [];
840
+ if(!raw && !files.length) return null;
841
+
842
+ const row = document.createElement('div');
843
+ row.className = 'row' + (self ? ' self' : '');
844
+ row.dataset.id = m.id || '';
845
+
846
+ const bubble = document.createElement('div');
847
+ bubble.className = 'bubble' + (self ? ' self' : '');
848
+
849
+ let filesHtml = '';
850
+ if(files.length){
851
+ filesHtml += '<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px">';
 
 
 
 
 
 
 
 
852
  for(const it of files){
853
+ const f = it.file || it;
854
+ const url = it.url || f.url || '';
855
+ if(!url) continue;
856
+ const t = extType(url);
857
+ if(t==='image') filesHtml += `<img src="${escapeHtml(url)}">`;
858
+ else if(t==='video') filesHtml += `<video src="${escapeHtml(url)}" controls style="max-width:340px;border-radius:10px"></video>`;
859
+ else if(t==='audio') filesHtml += `<span class="wx-audio" data-audio="${escapeHtml(url)}">🔊 语音</span>`;
860
+ else filesHtml += `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">${escapeHtml(f.filename||'文件')}</a>`;
861
  }
862
+ filesHtml += '</div>';
 
 
 
 
 
 
 
 
 
 
 
 
863
  }
864
 
865
+ bubble.innerHTML = `
866
  <div class="bubble-toolbar">
867
+ <div class="sender">${escapeHtml(self ? 'User' : (m?.user?.name || 'Assistant'))}</div>
868
  <button class="bubble-copy">复制</button>
869
  </div>
870
+ <div class="md">${renderMd(raw)}</div>
871
  ${filesHtml}
 
872
  <div class="meta">${formatTimeFromMessage(m)}</div>
873
  `;
874
  row.appendChild(bubble);
875
 
876
  bubble.querySelector('.bubble-copy').onclick = async()=>{
877
  await navigator.clipboard.writeText(buildCopyText(m));
878
+ toast('已复制');
879
  };
 
 
 
 
 
 
 
 
880
 
881
  await enhance(bubble);
 
882
  bubble.querySelectorAll('.wx-audio[data-audio]').forEach(n=>{
883
+ const a = new Audio(n.getAttribute('data-audio').replace('#audio',''));
884
+ n.onclick = ()=>toggleWxAudio(n,a);
885
  });
 
886
 
887
  return row;
888
  }
889
 
890
  function pruneDomIfNeeded(){
891
+ const maxNodes = 300;
892
+ const rows = D.msgList.querySelectorAll('.row');
 
893
  if(rows.length <= maxNodes) return;
894
+ const need = rows.length - maxNodes;
895
+ for(let i=0;i<need;i++) rows[i].remove();
 
 
896
  }
897
 
898
+ function saveChannelCache(cid, list){
899
  try{
900
+ const slim = list.slice(-120);
901
  localStorage.setItem(cacheKey(cid), JSON.stringify(slim));
902
  }catch{}
903
  }
 
910
  }catch{return []}
911
  }
912
 
913
+ async function appendMessages(cid, list){
914
  const st=ensureStore(cid);
915
+ const keep=nearBottom(D.messages);
 
916
  for(const m of list){
917
+ if(!m || !m.id || st.ids.has(m.id)) continue;
 
918
  const node = await buildMsg(m);
919
+ if(!node) continue;
920
+ st.ids.add(m.id);
921
+ st.lastId = m.id;
922
+ D.msgList.appendChild(node);
923
  }
924
  pruneDomIfNeeded();
925
+ if(keep) D.messages.scrollTop = D.messages.scrollHeight;
926
  }
927
 
928
  async function prependOlderMessages(cid, list){
929
+ const st=ensureStore(cid);
930
+ const prevHeight = D.messages.scrollHeight;
931
+ const frag=document.createDocumentFragment();
932
+ let added=0;
933
+ for(const m of list){
934
+ if(!m || !m.id || st.ids.has(m.id)) continue;
 
935
  const node = await buildMsg(m);
936
+ if(!node) continue;
937
  st.ids.add(m.id);
938
  frag.appendChild(node);
939
  added++;
940
  }
941
+ if(D.msgList.firstChild) D.msgList.insertBefore(frag, D.msgList.firstChild);
942
+ else D.msgList.appendChild(frag);
943
+ D.messages.scrollTop += (D.messages.scrollHeight - prevHeight);
 
 
944
  return added;
945
  }
946
 
 
952
  saveChannelCache(cid, list);
953
  return list;
954
  }catch(e){
955
+ log('warn', '在线读取失败,使用缓存: '+e.message);
956
+ return readChannelCache(cid);
 
957
  }
958
  }
959
 
960
  async function loadOlderAtTop(){
961
+ if(!S.current) return;
962
+ const cid=S.current.id;
963
+ const st=ensureStore(cid);
964
+ if(st.loadingMore || !st.hasMore) return;
965
+ st.loadingMore=true;
966
+ D.topLoader.style.display='block';
967
+ try{
968
+ const oldCount=st.ids.size;
969
  st.loadedLimit += 50;
970
  const list = await fetchChannelMessages(cid, st.loadedLimit);
971
  const added = await prependOlderMessages(cid, list);
972
+ if(added===0 || st.ids.size===oldCount){
973
+ st.hasMore=false;
974
+ D.topLoader.textContent='没有更多历史消息了';
975
+ setTimeout(()=>{D.topLoader.style.display='none';D.topLoader.textContent='正在加载更早消息...';},900);
976
+ }else D.topLoader.style.display='none';
977
+ }catch(e){
978
+ log('warn','加载更早失败:'+e.message);
979
+ D.topLoader.style.display='none';
980
+ }finally{
981
+ st.loadingMore=false;
 
 
982
  }
983
  }
984
 
985
  async function loadChannels(){
986
  const r=await apiFetch('/api/channels');
987
  S.channels=await r.json();
988
+ D.channelCount.textContent=String(S.channels.length||0);
989
  renderChannels();
990
  const saved = localStorage.getItem('lastChannelId');
991
+ if(saved && S.channels.some(c=>c.id===saved)) await switchChannel(saved);
992
+ else if(S.channels.length) await switchChannel(S.channels[0].id);
 
 
 
993
  }
994
 
995
  function renderChannels(){
996
+ const kw=D.searchInput.value.trim().toLowerCase();
997
+ const arr=(S.channels||[]).filter(c=>!kw || c.name.toLowerCase().includes(kw) || c.id.toLowerCase().includes(kw));
998
+ D.channelList.innerHTML = arr.map(c=>`
999
  <div class="channel ${S.current?.id===c.id?'active':''}" data-id="${c.id}">
1000
  <div class="avatar">${escapeHtml((c.name||'C')[0].toUpperCase())}</div>
1001
  <div style="min-width:0">
1002
  <div style="font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${escapeHtml(c.name)}</div>
1003
  <div class="muted" style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${escapeHtml(c.id)}</div>
1004
  </div>
1005
+ </div>
1006
+ `).join('');
1007
+ D.channelList.querySelectorAll('.channel').forEach(n=>n.onclick=()=>switchChannel(n.dataset.id));
1008
  }
1009
 
1010
  async function switchChannel(cid){
 
1012
  const c=S.channels.find(x=>x.id===cid); if(!c) return;
1013
  S.current=c;
1014
  localStorage.setItem('lastChannelId', cid);
 
 
 
1015
 
1016
+ renderChannels();
1017
+ D.headName.textContent=c.name;
1018
+ D.headAvatar.textContent=(c.name||'C')[0].toUpperCase();
1019
 
1020
+ D.msgList.innerHTML='';
1021
  const st=ensureStore(cid);
1022
+ st.ids.clear(); st.lastId=null; st.hasMore=true; st.loadedLimit=50;
 
 
 
1023
 
1024
  const list = await fetchChannelMessages(cid, st.loadedLimit);
1025
  await appendMessages(cid, list);
1026
 
1027
  connectStream(cid);
1028
 
1029
+ D.leftPanel.classList.remove('show');
1030
+ D.mobileMask.classList.remove('show');
1031
 
1032
  restoreDraft(cid);
1033
 
1034
+ // 少消息或首次进入:强制贴底
1035
+ requestAnimationFrame(()=>{
1036
+ const rows = D.msgList.querySelectorAll('.row').length;
1037
+ if(rows < 20){
1038
+ D.messages.scrollTop = D.messages.scrollHeight;
1039
+ }else{
1040
+ const savedTop = localStorage.getItem(scrollKey(cid));
1041
+ if(savedTop !== null) restoreChannelScroll(cid);
1042
+ else D.messages.scrollTop = D.messages.scrollHeight;
1043
+ }
1044
+ });
1045
  }
1046
 
1047
+ function closeStream(){
1048
+ if(S.es){ try{S.es.close();}catch{} S.es=null; }
1049
+ }
1050
 
1051
  function connectStream(cid){
1052
  closeStream();
1053
  const st=ensureStore(cid);
1054
+ S.streamToken += 1;
1055
+ const token = S.streamToken;
1056
 
1057
  const q=new URLSearchParams();
1058
  if(st.lastId) q.set('last_id',st.lastId);
 
1060
  q.set('limit', String(Math.max(st.loadedLimit, Number(S.settings.messageLimit || S.cfg?.runtimeConfig?.defaultMessageLimit || 120))));
1061
  q.set('interval', String(Math.max(800, Number(S.settings.streamInterval || S.cfg?.runtimeConfig?.streamInterval || 1500))));
1062
 
1063
+ const es = new EventSource(apiUrl(`/api/messages/${cid}/stream?${q.toString()}`));
1064
+ S.es = es;
1065
+
1066
+ es.onopen = ()=>{ st.reconnectTry = 0; log('info', 'SSE已连接'); };
1067
 
1068
+ es.addEventListener('messages', async(ev)=>{
1069
+ if(token !== S.streamToken) return;
1070
  try{
1071
+ const p = JSON.parse(ev.data || '{}');
1072
+ await appendMessages(cid, p.items || []);
1073
+ }catch(e){
 
 
 
 
 
 
 
 
 
 
1074
  log('warn','SSE解析失败:'+e.message);
1075
  }
1076
  });
1077
 
1078
+ es.onerror = ()=>{
1079
+ if(token !== S.streamToken) return;
1080
  closeStream();
1081
  st.reconnectTry = Math.min((st.reconnectTry || 0) + 1, 8);
1082
+ const delay = Math.min(1200 * Math.pow(1.7, st.reconnectTry), 25000);
1083
+ log('warn', `SSE断开,${Math.round(delay/1000)}秒后重连`);
1084
+ setTimeout(()=>{
1085
+ if(token !== S.streamToken) return;
1086
+ if(S.current?.id===cid) connectStream(cid);
1087
+ }, delay);
1088
  };
1089
  }
1090
 
 
1098
  }
1099
 
1100
  function renderPreview(){
1101
+ if(!S.files.length){ D.preview.innerHTML=''; return; }
1102
+ D.preview.innerHTML = S.files.map((f,i)=>`
1103
+ <span class="chip">${f.type.startsWith('image/')?'🖼️':f.type.startsWith('video/')?'🎥':'🎤'} ${escapeHtml(f.name)}
1104
+ <button data-i="${i}" style="border:none;background:transparent;color:#ef4444;cursor:pointer">×</button>
1105
+ </span>`).join('');
1106
+ D.preview.querySelectorAll('[data-i]').forEach(b=>b.onclick=()=>{S.files.splice(+b.dataset.i,1);renderPreview();});
1107
  }
1108
 
1109
  async function uploadFile(file){
 
1111
  const r=await apiFetch('/api/upload',{
1112
  method:'POST',
1113
  headers:{'Content-Type':'application/json'},
1114
+ body:JSON.stringify({filename:file.name,base64:String(b64).split(',')[1],mimeType:file.type})
1115
  });
1116
+ const d=await r.json();
1117
+ if(!d.url) throw new Error('上传失败');
1118
  return d.url;
1119
  }
1120
 
1121
  async function sendNow(){
1122
  if(!S.current) return alert('请先选择频道');
1123
+ const text = D.input.value.trim();
 
1124
  if(!text && !S.files.length) return;
1125
 
1126
  const links=[];
1127
  for(const f of S.files){
1128
+ try{ links.push({url:await uploadFile(f),type:f.type}); }
1129
+ catch(e){ log('error','附件上传失败:'+e.message); }
 
 
 
1130
  }
1131
 
1132
  let full=text;
 
1146
  body:JSON.stringify({text:full})
1147
  });
1148
 
1149
+ D.input.value=''; autoGrow();
1150
+ S.files=[]; renderPreview();
 
 
1151
  localStorage.setItem(draftKey(S.current.id), '');
1152
  hideCmdSuggest();
1153
  }
 
1158
  return '';
1159
  }
1160
  function extByMime(m=''){
1161
+ const s=m.toLowerCase();
1162
  if(s.includes('ogg')) return 'ogg';
1163
  if(s.includes('webm')) return 'webm';
1164
  if(s.includes('wav')) return 'wav';
 
1166
  }
1167
 
1168
  async function startRecord(){
1169
+ if(!S.current || S.recording) return;
1170
  try{
1171
  const stream=await navigator.mediaDevices.getUserMedia({audio:true});
1172
  const mime=pickMime();
1173
+ const rec=mime ? new MediaRecorder(stream,{mimeType:mime}) : new MediaRecorder(stream);
1174
  S.rec=rec; S.chunks=[];
 
1175
  rec.ondataavailable=e=>{ if(e.data && e.data.size) S.chunks.push(e.data); };
1176
  rec.onstop=async()=>{
1177
  try{
1178
  const finalMime = rec.mimeType || mime || 'audio/ogg';
1179
  const blob=new Blob(S.chunks,{type:finalMime});
1180
+ const file=new File([blob], `voice_${Date.now()}.${extByMime(finalMime)}`, {type:finalMime});
 
1181
  const url=await uploadFile(file);
1182
  await apiFetch(`/api/send/${S.current.id}`,{
1183
  method:'POST',
1184
  headers:{'Content-Type':'application/json'},
1185
  body:JSON.stringify({text:`[语音](${url}#audio)`})
1186
  });
1187
+ }catch(e){ log('error','语音发送失败:'+e.message); }
1188
+ finally{ stream.getTracks().forEach(t=>t.stop()); }
 
 
 
1189
  };
 
1190
  rec.start();
1191
  S.recording=true;
1192
+ D.holdBtn.textContent='录音中... 松开发送';
1193
+ }catch(e){ log('error','录音失败:'+e.message); }
 
 
1194
  }
1195
  function stopRecord(){
1196
  if(!S.recording) return;
1197
  S.recording=false;
1198
+ D.holdBtn.textContent='按住说话,松开发送(纯语音)';
1199
+ try{ S.rec && S.rec.stop(); }catch{}
1200
  }
1201
 
1202
  async function loadConfig(){
1203
  try{
1204
  const r=await fetch(apiUrl('/api/config'));
1205
  S.cfg=await r.json();
1206
+ // 合并命令,确保 /new /reset /status 必有
1207
+ const fromServer = Array.isArray(S.cfg?.defaultCommands) ? S.cfg.defaultCommands : [];
1208
+ const map = new Map();
1209
+ [...BASE_CMDS, ...fromServer, ...MANDATORY_CMDS].forEach(x=>{
1210
+ if(!x?.cmd) return;
1211
+ map.set(x.cmd, {cmd:x.cmd, desc:x.desc || ''});
1212
+ });
1213
+ S.cmdList = [...map.values()];
1214
  }catch(e){ log('warn','读取配置失败:'+e.message); }
1215
  }
1216
 
1217
  async function pullServerLogs(){
1218
  try{
1219
+ const r=await apiFetch('/api/logs');
1220
+ const arr=await r.json();
1221
  if(!Array.isArray(arr)) return;
1222
  for(let i=arr.length-1;i>=0;i--){
1223
+ const line=arr[i];
1224
  if(S.seenServerLogs.has(line)) continue;
1225
  S.seenServerLogs.add(line);
1226
+ if(S.seenServerLogs.size>500) S.seenServerLogs = new Set([...S.seenServerLogs].slice(-350));
1227
+ D.logs.textContent += `[server] ${line}\n`;
 
 
1228
  }
1229
+ D.logs.scrollTop = D.logs.scrollHeight;
1230
  }catch{}
1231
  }
1232
 
1233
  function openSettings(){
1234
+ D.setApiBase.value = S.settings.apiBase || '';
1235
+ D.setPassword.value = S.settings.connectPassword || '';
1236
+ D.setBotUsername.value = S.settings.botUsername || S.cfg?.runtimeConfig?.botUsername || 'openman';
1237
+ D.setStreamInterval.value = S.settings.streamInterval || S.cfg?.runtimeConfig?.streamInterval || 1500;
1238
+ D.setMessageLimit.value = S.settings.messageLimit || S.cfg?.runtimeConfig?.defaultMessageLimit || 120;
1239
+ D.setDebug.checked = S.settings.debugLogs === 'true';
1240
+ D.setUnsafe.checked = S.settings.allowUnsafe === 'true';
1241
+ D.setUiProfile.value = JSON.stringify(S.cfg?.runtimeConfig?.uiProfile || {}, null, 2);
1242
+ D.settingsModal.classList.add('show');
1243
+ }
1244
+ function closeSettings(){ D.settingsModal.classList.remove('show'); }
 
1245
 
1246
  async function saveSettings(){
1247
+ S.settings.apiBase = D.setApiBase.value.trim();
1248
+ S.settings.connectPassword = D.setPassword.value.trim();
1249
+ S.settings.botUsername = D.setBotUsername.value.trim();
1250
+ S.settings.streamInterval = String(Math.max(800, Number(D.setStreamInterval.value || 1500)));
1251
+ S.settings.messageLimit = String(Math.max(20, Number(D.setMessageLimit.value || 120)));
1252
+ S.settings.debugLogs = D.setDebug.checked ? 'true' : 'false';
1253
+ S.settings.allowUnsafe = D.setUnsafe.checked ? 'true' : 'false';
1254
 
1255
  localStorage.setItem('apiBase', S.settings.apiBase);
1256
  localStorage.setItem('connectPassword', S.settings.connectPassword);
 
1260
  localStorage.setItem('debugLogs', S.settings.debugLogs);
1261
  localStorage.setItem('allowUnsafe', S.settings.allowUnsafe);
1262
 
1263
+ let uiProfile={};
1264
+ try{ uiProfile = JSON.parse(D.setUiProfile.value || '{}'); }
1265
+ catch{ toast('环境映射JSON格式错误'); return; }
 
 
 
 
1266
 
1267
  try{
1268
  await apiFetch('/api/runtime-settings',{
1269
+ method:'POST', headers:{'Content-Type':'application/json'},
 
1270
  body:JSON.stringify({
1271
+ botUsername:S.settings.botUsername || undefined,
1272
+ streamInterval:Number(S.settings.streamInterval),
1273
+ defaultMessageLimit:Number(S.settings.messageLimit),
1274
+ debugLogs:S.settings.debugLogs==='true',
1275
  uiProfile
1276
  })
1277
  });
1278
+ }catch(e){ log('warn','后端设置更新失败:'+e.message); }
 
 
1279
 
1280
  closeSettings();
1281
+ if(S.current) await switchChannel(S.current.id);
1282
  toast('设置已保存');
1283
  }
1284
 
1285
  function autoGrow(){
1286
+ D.input.style.height='auto';
1287
+ D.input.style.height=Math.min(D.input.scrollHeight,240)+'px';
1288
  }
1289
 
1290
  function showCmdSuggest(list){
1291
  if(!list.length){ hideCmdSuggest(); return; }
1292
+ D.cmdSuggest.innerHTML = list.map((x,idx)=>`
1293
  <div class="cmd-item ${idx===S.cmdActive?'active':''}" data-idx="${idx}">
1294
  <div class="cmd-name">${escapeHtml(x.cmd)}</div>
1295
  <div class="cmd-desc">${escapeHtml(x.desc || '')}</div>
1296
  </div>
1297
  `).join('');
1298
+ D.cmdSuggest.style.display='block';
1299
+ D.cmdSuggest.querySelectorAll('.cmd-item').forEach(el=>{
1300
  el.onclick=()=>{
1301
+ const i=Number(el.dataset.idx);
1302
  applyCommand(list[i]?.cmd || '');
1303
  };
1304
  });
1305
  }
1306
  function hideCmdSuggest(){
1307
+ D.cmdSuggest.style.display='none';
1308
+ D.cmdSuggest.innerHTML='';
1309
+ S.cmdActive=-1;
1310
+ S._cmdCurrentList=[];
1311
  }
1312
  function applyCommand(cmd){
1313
  if(!cmd) return;
1314
+ D.input.value = cmd + ' ';
1315
  autoGrow();
1316
  hideCmdSuggest();
1317
+ D.input.focus();
1318
+ if(S.current) localStorage.setItem(draftKey(S.current.id), D.input.value);
1319
  }
1320
  function refreshCmdSuggest(){
1321
+ const t=(D.input.value || '').trim();
1322
+ if(!t.startsWith('/')) return hideCmdSuggest();
1323
+ const q=t.toLowerCase();
1324
+ const list = S.cmdList.filter(x=>x.cmd.toLowerCase().includes(q)).slice(0,10);
 
 
 
1325
  S._cmdCurrentList = list;
1326
+ if(!list.length) return hideCmdSuggest();
1327
+ if(S.cmdActive < 0) S.cmdActive = 0;
1328
  if(S.cmdActive >= list.length) S.cmdActive = 0;
1329
  showCmdSuggest(list);
1330
  }
1331
 
1332
  function bind(){
1333
+ D.btnOpenLeft.onclick=()=>{ D.leftPanel.classList.add('show'); D.mobileMask.classList.add('show'); };
1334
+ D.btnCloseLeft.onclick=()=>{ D.leftPanel.classList.remove('show'); D.mobileMask.classList.remove('show'); };
1335
+ D.mobileMask.onclick=()=>{ D.leftPanel.classList.remove('show'); D.mobileMask.classList.remove('show'); };
1336
+
1337
+ D.btnLogs.onclick=()=>D.app.classList.toggle('debug-open');
1338
+ D.btnCloseLogs.onclick=()=>D.app.classList.remove('debug-open');
1339
 
1340
+ D.btnSettings.onclick=openSettings;
1341
+ D.setCancel.onclick=closeSettings;
1342
+ D.setSave.onclick=saveSettings;
1343
 
1344
+ D.mediaClose.onclick=()=>D.mediaModal.classList.remove('show');
1345
+ D.mediaModal.addEventListener('click',e=>{ if(e.target===D.mediaModal) D.mediaModal.classList.remove('show'); });
 
1346
 
1347
+ D.toolDetailClose.onclick=()=>D.toolDetail.classList.remove('show');
 
1348
 
1349
+ D.searchInput.addEventListener('input',renderChannels);
 
1350
 
1351
+ D.fileInput.addEventListener('change',e=>{
1352
  S.files.push(...Array.from(e.target.files||[]));
1353
  renderPreview();
1354
  e.target.value='';
1355
  });
1356
 
1357
+ D.input.addEventListener('paste',(e)=>{
1358
+ const items=Array.from(e.clipboardData?.items || []);
1359
+ const imgs=[];
 
1360
  for(const it of items){
1361
+ if(it.kind==='file' && it.type.startsWith('image/')){
1362
+ const f=it.getAsFile();
1363
+ if(f){
1364
+ imgs.push(new File([f], `paste_${Date.now()}_${Math.random().toString(36).slice(2)}.${(f.type.split('/')[1]||'png').replace('+xml','')}`, {type:f.type}));
1365
+ }
1366
  }
1367
  }
1368
  if(imgs.length){
 
1373
  }
1374
  });
1375
 
 
1376
  ['dragenter','dragover'].forEach(evt=>{
1377
+ D.input.addEventListener(evt,e=>{ e.preventDefault(); e.stopPropagation(); });
1378
  });
1379
+ D.input.addEventListener('drop',e=>{
1380
  e.preventDefault();
1381
+ const fs=Array.from(e.dataTransfer?.files || []);
1382
+ if(fs.length){
1383
+ S.files.push(...fs);
1384
  renderPreview();
1385
+ toast(`已添加 ${fs.length} 个附件`);
1386
  }
1387
  });
1388
 
1389
+ D.sendBtn.onclick=sendNow;
1390
+
1391
+ D.input.addEventListener('input',()=>{
1392
  autoGrow();
1393
+ if(S.current) localStorage.setItem(draftKey(S.current.id), D.input.value || '');
1394
  refreshCmdSuggest();
1395
  });
1396
 
1397
+ D.input.addEventListener('focus', refreshCmdSuggest);
1398
+
1399
+ D.input.addEventListener('keydown',e=>{
1400
+ if(D.cmdSuggest.style.display==='block' && S._cmdCurrentList.length){
1401
  if(e.key==='ArrowDown'){
1402
  e.preventDefault();
1403
+ S.cmdActive = (S.cmdActive + 1) % S._cmdCurrentList.length;
1404
  showCmdSuggest(S._cmdCurrentList);
1405
  return;
1406
  }
 
1416
  return;
1417
  }
1418
  }
1419
+ if(e.key==='Enter' && !e.shiftKey){
1420
  e.preventDefault();
1421
  sendNow();
1422
  }
1423
  });
1424
+ D.input.addEventListener('blur',()=>setTimeout(hideCmdSuggest,120));
1425
 
1426
+ D.modeText.onclick=()=>{ D.modeText.classList.add('active'); D.modeVoice.classList.remove('active'); D.textMode.style.display=''; D.voiceMode.style.display='none'; };
1427
+ D.modeVoice.onclick=()=>{ D.modeVoice.classList.add('active'); D.modeText.classList.remove('active'); D.textMode.style.display='none'; D.voiceMode.style.display=''; };
1428
 
1429
  const s=()=>startRecord(), t=()=>stopRecord();
1430
+ D.holdBtn.addEventListener('mousedown',s);
1431
+ D.holdBtn.addEventListener('mouseup',t);
1432
+ D.holdBtn.addEventListener('mouseleave',t);
1433
+ D.holdBtn.addEventListener('touchstart',e=>{e.preventDefault();s();},{passive:false});
1434
+ D.holdBtn.addEventListener('touchend',e=>{e.preventDefault();t();},{passive:false});
1435
+ D.holdBtn.addEventListener('touchcancel',t);
1436
+
1437
+ let lock=false;
1438
+ D.messages.addEventListener('scroll', async()=>{
1439
  saveCurrentScroll();
1440
+ if(lock) return;
1441
+ if(D.messages.scrollTop<=24){
1442
+ lock=true;
1443
  await loadOlderAtTop();
1444
+ lock=false;
1445
  }
1446
  });
1447
 
1448
  window.addEventListener('beforeunload', saveCurrentScroll);
1449
 
1450
  window.addEventListener('online', ()=>{
1451
+ S.offline=false;
1452
+ D.offlineBar.style.display='none';
1453
  toast('网络已恢复');
1454
  if(S.current) connectStream(S.current.id);
1455
  });
1456
  window.addEventListener('offline', ()=>{
1457
+ S.offline=true;
1458
+ D.offlineBar.style.display='block';
1459
  closeStream();
1460
  toast('网络已断开,使用本地缓存');
1461
  });
 
1463
 
1464
  async function initPWA(){
1465
  if('serviceWorker' in navigator){
1466
+ try{ await navigator.serviceWorker.register('/sw.js'); }catch(e){ log('warn','SW注册失败:'+e.message); }
 
 
 
 
 
1467
  }
1468
  }
1469
 
 
1474
  await loadConfig();
1475
  await loadEnhancers();
1476
 
1477
+ if(S.offline) D.offlineBar.style.display='block';
1478
 
1479
  try{
1480
  await loadChannels();
1481
+ setInterval(pullServerLogs,3000);
1482
  }catch(e){
1483
  log('error','初始化失败:'+e.message);
1484
  alert('初始化失败:'+e.message);