Selinore commited on
Commit
031d70f
·
verified ·
1 Parent(s): ef4f67d

Update public/index.html

Browse files
Files changed (1) hide show
  1. public/index.html +234 -118
public/index.html CHANGED
@@ -12,6 +12,7 @@
12
  *{box-sizing:border-box}
13
  html,body{height:100%;margin:0;overflow:hidden;font-family:Inter,"Noto Sans SC",system-ui}
14
  body{background:var(--bg);color:var(--text)}
 
15
 
16
  .app{height:100%;display:grid;grid-template-columns:320px 1fr;transition:.2s}
17
  .app.debug-open{grid-template-columns:320px 1fr 360px}
@@ -61,17 +62,23 @@
61
  .md p{margin:.35em 0}
62
  .md img{max-width:320px;height:auto;border-radius:10px;border:1px solid #d9e3f3;cursor:zoom-in}
63
  .md pre{margin:.5em 0}
 
 
64
  .admon{border-left:4px solid #3b82f6;background:#eff6ff;border-radius:8px;padding:8px 10px;margin:.5em 0}
65
  .admon.tip{border-color:#22c55e;background:#f0fdf4}
 
66
  .admon.warn{border-color:#f59e0b;background:#fffbeb}
67
  .admon.err{border-color:#ef4444;background:#fef2f2}
68
  .admon.important{border-color:#8b5cf6;background:#f5f3ff}
 
 
69
  .code-wrap{border:1px solid #d8e3f5;border-radius:10px;background:var(--code);margin:.45em 0;overflow:hidden}
70
  .code-head{display:flex;justify-content:space-between;align-items:center;padding:6px 8px;background:#ebf2ff;border-bottom:1px solid #d8e4fb;font-size:12px}
71
  .code-actions button{border:1px solid #c7d8f7;background:#fff;border-radius:7px;padding:3px 7px;cursor:pointer;margin-left:6px}
72
  .code-body{padding:8px 10px;overflow:auto;max-height:300px}
73
  .code-collapsed .code-body{display:none}
74
  .inline-code{background:#eaf0ff;border:1px solid #d2ddff;padding:1px 6px;border-radius:6px;cursor:pointer}
 
75
  .wx-audio{display:inline-flex;align-items:center;gap:8px;background:#e7f1ff;border:1px solid #b8d2ff;border-radius:999px;padding:7px 12px;cursor:pointer}
76
  .wx-audio.playing{background:#d7eaff}
77
 
@@ -91,11 +98,12 @@
91
 
92
  .modal{position:fixed;inset:0;background:rgba(0,0,0,.65);display:none;align-items:center;justify-content:center;z-index:60}
93
  .modal.show{display:flex}
94
- .modal-card{width:min(92vw,700px);max-height:88vh;background:#fff;border-radius:14px;padding:14px;overflow:auto}
95
  .field{margin-bottom:10px}
96
  .field label{display:block;font-size:12px;color:#64748b;margin-bottom:5px}
97
  .field input,.field select{width:100%;padding:9px 10px;border:1px solid #cbd8ed;border-radius:10px}
98
  .modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:8px}
 
99
  #mediaTop{position:absolute;top:16px;right:24px;display:flex;gap:10px}
100
  #mediaTop button{border:none;border-radius:10px;padding:9px 11px;cursor:pointer}
101
  #mediaContent img,#mediaContent video{max-width:90vw;max-height:84vh;border-radius:12px}
@@ -133,10 +141,12 @@
133
 
134
  <div class="composer">
135
  <div id="preview" class="preview"></div>
 
136
  <div class="mode-row">
137
  <button id="modeText" class="mode-btn active">文字模式</button>
138
  <button id="modeVoice" class="mode-btn">说话模式</button>
139
  </div>
 
140
  <div id="textMode">
141
  <div class="input-row">
142
  <label class="btn">📎<input id="fileInput" type="file" multiple accept="image/*,video/*,audio/*" style="display:none"></label>
@@ -144,6 +154,7 @@
144
  <button id="sendBtn" class="send">➤</button>
145
  </div>
146
  </div>
 
147
  <div id="voiceMode" style="display:none">
148
  <button id="holdBtn" class="hold">按住说话,松开发送</button>
149
  </div>
@@ -159,21 +170,41 @@
159
  </aside>
160
  </div>
161
 
 
162
  <div id="settingsModal" class="modal">
163
  <div class="modal-card">
164
- <h3 style="margin-top:0">设置</h3>
 
165
  <div class="field">
166
- <label>连接密码(对应 CONNECT_PASSWORD)</label>
167
  <input id="setPassword" />
168
  </div>
 
169
  <div class="field">
170
- <label>前端调试日志</label>
171
- <select id="setDebug"><option value="true">开启</option><option value="false">关闭</option></select>
172
  </div>
 
 
 
 
 
 
173
  <div class="field">
174
- <label>允许高级HTML嵌入</label>
175
- <select id="setUnsafe"><option value="true">开启</option><option value="false">关闭</option></select>
 
 
 
 
 
 
 
 
 
 
176
  </div>
 
177
  <div class="modal-actions">
178
  <button id="setCancel" class="btn">取消</button>
179
  <button id="setSave" class="btn" style="background:#3498ff;color:#fff;border:none">保存</button>
@@ -181,6 +212,7 @@
181
  </div>
182
  </div>
183
 
 
184
  <div id="mediaModal" class="modal">
185
  <div id="mediaTop">
186
  <a id="mediaDownload" href="#" download target="_blank"><button>下载</button></a>
@@ -190,58 +222,86 @@
190
  </div>
191
 
192
  <script>
193
- // ---------- basic ----------
194
  const appEl = document.getElementById('app');
195
  const S = {
196
- cfg:{ botUsername:'openman', needPassword:false },
197
- settings:{
198
  connectPassword: localStorage.getItem('connectPassword') || '',
199
  debugLogs: localStorage.getItem('debugLogs') || 'true',
200
- allowUnsafe: localStorage.getItem('allowUnsafe') || 'true'
 
 
 
201
  },
202
- channels:[], current:null, files:[], es:null,
203
- msgStore:new Map(), rec:null, recording:false, chunks:[], activeAudio:null
 
 
 
 
 
 
 
204
  };
 
205
  const E = {
206
- left: leftPanel, right:rightPanel, messages, channelList, channelCount, searchInput,
207
  headName, headAvatar, input, fileInput, preview, logs,
208
  modeText, modeVoice, textMode, voiceMode, holdBtn,
209
  settingsModal, mediaModal, mediaContent, mediaDownload
210
  };
211
 
212
- function log(type,msg){
213
- if(type==='info' && S.settings.debugLogs!=='true') return;
214
- const t=new Date().toLocaleTimeString('zh-CN',{hour12:false});
215
- const p=type==='error'?'❌':type==='warn'?'⚠️':'📌';
216
- const line=`[${t}] ${p} ${msg}`;
217
- (type==='error'?console.error:console.log)(line);
218
  E.logs.textContent += line + '\n';
219
  E.logs.scrollTop = E.logs.scrollHeight;
220
  }
221
 
222
- async function apiFetch(url,opt={}){
223
- const h = new Headers(opt.headers||{});
224
- if(S.settings.connectPassword) h.set('x-connect-password', S.settings.connectPassword);
225
- const r = await fetch(url,{...opt,headers:h});
226
- if(r.status===401) throw new Error('连接密码错误或未提供');
227
- return r;
228
  }
229
 
230
- function ensureStore(cid){
231
- if(!S.msgStore.has(cid)) S.msgStore.set(cid,{ids:new Set(),lastId:null});
232
- return S.msgStore.get(cid);
233
- }
234
  function escapeHtml(s=''){return String(s).replace(/[&<>"']/g,m=>({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[m]))}
235
- function nearBottom(el,th=120){return el.scrollHeight-el.scrollTop-el.clientHeight<th}
 
236
  function extType(url=''){
237
- const u=url.toLowerCase();
238
  if(/\.(png|jpe?g|gif|webp|svg)(\?|#|$)/.test(u)) return 'image';
239
  if(/\.(mp4|webm|mov|m4v)(\?|#|$)/.test(u)) return 'video';
240
  if(/\.(mp3|wav|ogg|m4a|aac|flac|webm)(\?|#|$)/.test(u)) return 'audio';
241
  return 'file';
242
  }
243
 
244
- // ---------- CDN loader (multi mirror, fail-safe) ----------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  function loadScriptAny(urls){
246
  return new Promise((resolve,reject)=>{
247
  let i=0;
@@ -258,7 +318,6 @@
258
  }
259
 
260
  async function loadEnhancers(){
261
- // 不阻塞主功能
262
  await Promise.allSettled([
263
  window.marked ? Promise.resolve() : loadScriptAny([
264
  'https://cdn.jsdelivr.net/npm/marked/marked.min.js',
@@ -276,28 +335,47 @@
276
  ])
277
  ]);
278
  if(window.mermaid) mermaid.initialize({startOnLoad:false,theme:'default'});
279
- log('info','渲染增强库状态: marked=' + !!window.marked + ', purify=' + !!window.DOMPurify + ', mermaid=' + !!window.mermaid);
280
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
 
282
- // ---------- render ----------
283
- function preprocessAdmon(t=''){
284
- return String(t).replace(/^\s*\[!(TIP|NOTE|WARNING|CAUTION|IMPORTANT|ERROR)\]\s*(.*)$/gmi,(_,k,b)=>{
285
- const K=k.toUpperCase();
286
- const cls = K==='TIP'?'tip':K==='NOTE'?'note':(K==='WARNING'||K==='CAUTION')?'warn':K==='IMPORTANT'?'important':'err';
287
- return `<div class="admon ${cls}"><b>${K}</b> ${b||''}</div>`;
288
- });
289
  }
290
 
291
  function simpleMd(t=''){
292
- // 无 marked 时的最小可用渲染
293
- let s=escapeHtml(preprocessAdmon(t));
294
- s=s.replace(/```(\w+)?\n([\s\S]*?)```/g,(_,lang,code)=>{
295
- return `<pre><code class="language-${escapeHtml(lang||'text')}">${escapeHtml(code)}</code></pre>`;
296
- });
297
- s=s.replace(/`([^`\n]+)`/g,'<code>$1</code>');
298
- s=s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g,'<img alt="$1" src="$2">');
299
- s=s.replace(/\[([^\]]+)\]\(([^)]+)\)/g,'<a href="$2" target="_blank" rel="noopener">$1</a>');
300
- s=s.replace(/\n/g,'<br>');
301
  return s;
302
  }
303
 
@@ -305,7 +383,7 @@
305
  const src = preprocessAdmon(raw);
306
  let html = '';
307
  if(window.marked){
308
- marked.setOptions({gfm:true,breaks:true,mangle:false,headerIds:false});
309
  html = marked.parse(src);
310
  }else{
311
  html = simpleMd(src);
@@ -321,20 +399,18 @@
321
  }
322
 
323
  async function enhance(root){
324
- // inline code copy
325
  root.querySelectorAll('code').forEach(code=>{
326
- const inPre=code.parentElement && code.parentElement.tagName==='PRE';
327
  if(!inPre){
328
  code.classList.add('inline-code');
329
- code.onclick=async()=>{await navigator.clipboard.writeText(code.textContent||'');log('info','行内代码已复制')};
330
  }
331
  });
332
 
333
- // code block toolbar
334
  root.querySelectorAll('pre > code').forEach(code=>{
335
- const pre=code.parentElement;
336
- const lang=(code.className||'').replace('language-','')||'text';
337
- const raw=code.textContent||'';
338
 
339
  if(lang.toLowerCase()==='mermaid' && window.mermaid){
340
  const id='mmd_'+Math.random().toString(36).slice(2);
@@ -364,7 +440,7 @@
364
  wrap.querySelector('.save').onclick=()=>{
365
  const a=document.createElement('a');
366
  a.href=URL.createObjectURL(new Blob([raw],{type:'text/plain;charset=utf-8'}));
367
- a.download=`code_${Date.now()}.${lang||'txt'}`; a.click();
368
  setTimeout(()=>URL.revokeObjectURL(a.href),1200);
369
  };
370
  wrap.querySelector('.toggle').onclick=(e)=>{
@@ -373,16 +449,19 @@
373
  };
374
  });
375
 
376
- // links -> media
377
  root.querySelectorAll('a[href]').forEach(a=>{
378
  const href=a.getAttribute('href')||'';
379
  const t=extType(href);
380
  if(t==='video'){
381
- const v=document.createElement('video'); v.src=href; v.controls=true; v.style.maxWidth='340px'; v.style.borderRadius='10px'; a.replaceWith(v);
 
 
382
  }else if(t==='audio'){
383
  const audio=new Audio(href);
384
- const wx=document.createElement('span'); wx.className='wx-audio'; wx.innerHTML='🔊 语音';
385
- wx.onclick=()=>toggleWxAudio(wx,audio); a.replaceWith(wx);
 
 
386
  }
387
  });
388
 
@@ -391,16 +470,18 @@
391
 
392
  function toggleWxAudio(wx,audio){
393
  if(S.activeAudio && S.activeAudio!==audio) S.activeAudio.pause();
394
- if(audio.paused){audio.play();wx.classList.add('playing');S.activeAudio=audio;}
395
- else {audio.pause();wx.classList.remove('playing');}
396
  audio.onended=()=>wx.classList.remove('playing');
397
  audio.onpause=()=>wx.classList.remove('playing');
398
  audio.onplay=()=>wx.classList.add('playing');
399
  }
400
 
401
  function openMedia(url,type){
402
- E.mediaContent.innerHTML = type==='image' ? `<img src="${escapeHtml(url)}">` : `<video src="${escapeHtml(url)}" controls autoplay></video>`;
403
- E.mediaDownload.href=url;
 
 
404
  E.mediaModal.classList.add('show');
405
  }
406
 
@@ -410,30 +491,29 @@
410
  if(typeof m?.data?.content==='string') return m.data.content;
411
  return '';
412
  }
413
- function msgTime(m){
414
- const t=m.timestamp||m.created_at||m.updated_at||Date.now();
415
- const d=new Date(Number(t));
416
- return isNaN(d.getTime())?String(t):d.toLocaleString('zh-CN',{hour12:false});
417
- }
418
  function isSelf(m){
419
- const n=(m?.user?.name||'').toLowerCase();
420
- if(m.role==='user') return true;
421
- if(n && n!==S.cfg.botUsername.toLowerCase()) return true;
 
422
  return false;
423
  }
424
 
425
  async function buildMsg(m){
426
- const row=document.createElement('div');
427
  const self=isSelf(m);
 
428
  row.className='row'+(self?' self':'');
429
  row.dataset.id=m.id||'';
 
430
  const bubble=document.createElement('div');
431
  bubble.className='bubble '+(self?'self':'other');
432
 
433
  const sender=m?.user?.name||(self?'User':'Assistant');
434
  const html=renderMd(msgText(m));
435
- let filesHtml='';
436
  const files=(m&&typeof m.data==='object'&&Array.isArray(m.data.files))?m.data.files:[];
 
 
437
  if(files.length){
438
  filesHtml+='<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px">';
439
  for(const it of files){
@@ -451,17 +531,16 @@
451
  ${self?'':`<div class="sender">${escapeHtml(sender)}</div>`}
452
  <div class="md">${html}</div>
453
  ${filesHtml}
454
- <div class="meta">${msgTime(m)}</div>
455
  `;
456
  row.appendChild(bubble);
457
- await enhance(bubble);
458
 
 
459
  bubble.querySelectorAll('.wx-audio[data-audio]').forEach(n=>{
460
  const a=new Audio(n.getAttribute('data-audio'));
461
  n.onclick=()=>toggleWxAudio(n,a);
462
  });
463
  bubble.querySelectorAll('img').forEach(img=>img.onclick=()=>openMedia(img.src,'image'));
464
-
465
  return row;
466
  }
467
 
@@ -477,13 +556,12 @@
477
  if(keep) E.messages.scrollTop=E.messages.scrollHeight;
478
  }
479
 
480
- // ---------- channels ----------
481
  async function loadChannels(){
482
  const r=await apiFetch('/api/channels');
483
  S.channels=await r.json();
484
  E.channelCount.textContent=String(S.channels.length||0);
485
  renderChannels();
486
- if(!S.current&&S.channels.length) await switchChannel(S.channels[0].id);
487
  }
488
 
489
  function renderChannels(){
@@ -502,19 +580,22 @@
502
 
503
  async function switchChannel(cid){
504
  const c=S.channels.find(x=>x.id===cid); if(!c) return;
505
- S.current=c; renderChannels();
506
- E.headName.textContent=c.name; E.headAvatar.textContent=(c.name||'C')[0].toUpperCase();
 
 
 
507
  E.messages.innerHTML='';
508
  const st=ensureStore(cid); st.ids.clear(); st.lastId=null;
509
 
510
- const r=await apiFetch(`/api/messages/${cid}?limit=120`);
 
511
  const arr=(await r.json()||[]).reverse();
512
  await appendMessages(cid,arr);
513
  connectStream(cid);
514
  E.left.classList.remove('show');
515
  }
516
 
517
- // ---------- stream ----------
518
  function closeStream(){ if(S.es){S.es.close();S.es=null;} }
519
  function connectStream(cid){
520
  closeStream();
@@ -522,18 +603,21 @@
522
  const q=new URLSearchParams();
523
  if(st.lastId) q.set('last_id',st.lastId);
524
  if(S.settings.connectPassword) q.set('p',S.settings.connectPassword);
 
 
525
  S.es=new EventSource(`/api/messages/${cid}/stream?${q.toString()}`);
526
 
527
  S.es.addEventListener('messages',async ev=>{
528
- try{ const p=JSON.parse(ev.data||'{}'); await appendMessages(cid,p.items||[]);}catch(e){log('warn','SSE解析失败:'+e.message);}
 
529
  });
530
  S.es.onerror=()=>{
531
  log('warn','SSE断开,3秒重连');
532
- closeStream(); setTimeout(()=>{ if(S.current?.id===cid) connectStream(cid); },3000);
 
533
  };
534
  }
535
 
536
- // ---------- send ----------
537
  function fileToBase64(file){
538
  return new Promise((resolve,reject)=>{
539
  const fr=new FileReader();
@@ -542,11 +626,13 @@
542
  fr.readAsDataURL(file);
543
  });
544
  }
 
545
  function renderPreview(){
546
  if(!S.files.length){E.preview.innerHTML='';return;}
547
  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('');
548
  E.preview.querySelectorAll('[data-i]').forEach(b=>b.onclick=()=>{S.files.splice(+b.dataset.i,1);renderPreview();});
549
  }
 
550
  async function uploadFile(file){
551
  const b64=await fileToBase64(file);
552
  const r=await apiFetch('/api/upload',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
@@ -555,6 +641,7 @@
555
  const d=await r.json(); if(!d.url) throw new Error('上传失败');
556
  return d.url;
557
  }
 
558
  async function sendNow(){
559
  if(!S.current) return alert('请先选择频道');
560
  const text=E.input.value.trim();
@@ -562,7 +649,8 @@
562
 
563
  const links=[];
564
  for(const f of S.files){
565
- try{ links.push({url:await uploadFile(f),type:f.type}); }catch(e){log('error','附件上传失败:'+e.message);}
 
566
  }
567
 
568
  let full=text;
@@ -575,12 +663,12 @@
575
  E.input.value=''; autoGrow(); S.files=[]; renderPreview();
576
  }
577
 
578
- // ---------- voice ----------
579
  function pickMime(){
580
  const c=['audio/webm;codecs=opus','audio/webm','audio/ogg;codecs=opus'];
581
  for(const m of c){ if(window.MediaRecorder && MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported(m)) return m; }
582
  return '';
583
  }
 
584
  async function startRecord(){
585
  if(!S.current||S.recording) return;
586
  try{
@@ -596,32 +684,68 @@
596
  const url=await uploadFile(file);
597
  await apiFetch(`/api/send/${S.current.id}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:`[语音](${url})`})});
598
  }catch(e){log('error','语音发送失败:'+e.message);}
599
- finally{ stream.getTracks().forEach(t=>t.stop());}
600
  };
601
  rec.start(); S.recording=true; E.holdBtn.textContent='录音中... 松开发送';
602
- }catch(e){log('error','录音失败:'+e.message);}
603
  }
 
604
  function stopRecord(){
605
  if(!S.recording) return;
606
  S.recording=false; E.holdBtn.textContent='按住说话,松开发送';
607
  try{S.rec&&S.rec.stop();}catch{}
608
  }
609
 
610
- // ---------- settings ----------
 
 
 
 
 
 
 
611
  function openSettings(){
612
- setPassword.value=S.settings.connectPassword||'';
613
- setDebug.value=S.settings.debugLogs||'true';
614
- setUnsafe.value=S.settings.allowUnsafe||'true';
 
 
 
615
  E.settingsModal.classList.add('show');
616
  }
617
- function closeSettings(){E.settingsModal.classList.remove('show')}
 
618
  async function saveSettings(){
619
- S.settings.connectPassword=setPassword.value.trim();
620
- S.settings.debugLogs=setDebug.value;
621
- S.settings.allowUnsafe=setUnsafe.value;
622
- localStorage.setItem('connectPassword',S.settings.connectPassword);
623
- localStorage.setItem('debugLogs',S.settings.debugLogs);
624
- localStorage.setItem('allowUnsafe',S.settings.allowUnsafe);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
625
  closeSettings();
626
  if(S.current) await switchChannel(S.current.id); else await loadChannels();
627
  }
@@ -631,14 +755,6 @@
631
  E.input.style.height=Math.min(E.input.scrollHeight,240)+'px';
632
  }
633
 
634
- async function loadConfig(){
635
- try{
636
- const r=await fetch('/api/config');
637
- S.cfg=await r.json();
638
- }catch(e){ log('warn','读取配置失败:'+e.message); }
639
- }
640
-
641
- // ---------- bind first (保证按钮永远可点) ----------
642
  function bind(){
643
  btnOpenLeft.onclick=()=>E.left.classList.add('show');
644
  btnCloseLeft.onclick=()=>E.left.classList.remove('show');
@@ -651,7 +767,7 @@
651
  setSave.onclick=saveSettings;
652
 
653
  mediaClose.onclick=()=>E.mediaModal.classList.remove('show');
654
- E.mediaModal.addEventListener('click',e=>{if(e.target===E.mediaModal)E.mediaModal.classList.remove('show');});
655
 
656
  E.searchInput.addEventListener('input',renderChannels);
657
 
@@ -663,7 +779,7 @@
663
 
664
  sendBtn.onclick=sendNow;
665
  E.input.addEventListener('input',autoGrow);
666
- E.input.addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendNow();}});
667
 
668
  E.modeText.onclick=()=>{E.modeText.classList.add('active');E.modeVoice.classList.remove('active');E.textMode.style.display='';E.voiceMode.style.display='none';};
669
  E.modeVoice.onclick=()=>{E.modeVoice.classList.add('active');E.modeText.classList.remove('active');E.textMode.style.display='none';E.voiceMode.style.display='';};
@@ -678,10 +794,10 @@
678
  }
679
 
680
  (async function init(){
681
- bind(); // 先绑定,避免“啥也点不了”
682
  autoGrow();
683
  await loadConfig();
684
- await loadEnhancers(); // 异步加载增强,不阻塞主功能
685
  try{
686
  await loadChannels();
687
  log('info','初始化完成');
 
12
  *{box-sizing:border-box}
13
  html,body{height:100%;margin:0;overflow:hidden;font-family:Inter,"Noto Sans SC",system-ui}
14
  body{background:var(--bg);color:var(--text)}
15
+ input[type="checkbox"], input[type="radio"] { accent-color: #22c55e; } /* 选中绿色 */
16
 
17
  .app{height:100%;display:grid;grid-template-columns:320px 1fr;transition:.2s}
18
  .app.debug-open{grid-template-columns:320px 1fr 360px}
 
62
  .md p{margin:.35em 0}
63
  .md img{max-width:320px;height:auto;border-radius:10px;border:1px solid #d9e3f3;cursor:zoom-in}
64
  .md pre{margin:.5em 0}
65
+ .md a{color:#2563eb}
66
+
67
  .admon{border-left:4px solid #3b82f6;background:#eff6ff;border-radius:8px;padding:8px 10px;margin:.5em 0}
68
  .admon.tip{border-color:#22c55e;background:#f0fdf4}
69
+ .admon.note{border-color:#3b82f6;background:#eff6ff}
70
  .admon.warn{border-color:#f59e0b;background:#fffbeb}
71
  .admon.err{border-color:#ef4444;background:#fef2f2}
72
  .admon.important{border-color:#8b5cf6;background:#f5f3ff}
73
+ .admon-title{font-weight:700;margin-bottom:4px}
74
+
75
  .code-wrap{border:1px solid #d8e3f5;border-radius:10px;background:var(--code);margin:.45em 0;overflow:hidden}
76
  .code-head{display:flex;justify-content:space-between;align-items:center;padding:6px 8px;background:#ebf2ff;border-bottom:1px solid #d8e4fb;font-size:12px}
77
  .code-actions button{border:1px solid #c7d8f7;background:#fff;border-radius:7px;padding:3px 7px;cursor:pointer;margin-left:6px}
78
  .code-body{padding:8px 10px;overflow:auto;max-height:300px}
79
  .code-collapsed .code-body{display:none}
80
  .inline-code{background:#eaf0ff;border:1px solid #d2ddff;padding:1px 6px;border-radius:6px;cursor:pointer}
81
+
82
  .wx-audio{display:inline-flex;align-items:center;gap:8px;background:#e7f1ff;border:1px solid #b8d2ff;border-radius:999px;padding:7px 12px;cursor:pointer}
83
  .wx-audio.playing{background:#d7eaff}
84
 
 
98
 
99
  .modal{position:fixed;inset:0;background:rgba(0,0,0,.65);display:none;align-items:center;justify-content:center;z-index:60}
100
  .modal.show{display:flex}
101
+ .modal-card{width:min(92vw,760px);max-height:88vh;background:#fff;border-radius:14px;padding:14px;overflow:auto}
102
  .field{margin-bottom:10px}
103
  .field label{display:block;font-size:12px;color:#64748b;margin-bottom:5px}
104
  .field input,.field select{width:100%;padding:9px 10px;border:1px solid #cbd8ed;border-radius:10px}
105
  .modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:8px}
106
+
107
  #mediaTop{position:absolute;top:16px;right:24px;display:flex;gap:10px}
108
  #mediaTop button{border:none;border-radius:10px;padding:9px 11px;cursor:pointer}
109
  #mediaContent img,#mediaContent video{max-width:90vw;max-height:84vh;border-radius:12px}
 
141
 
142
  <div class="composer">
143
  <div id="preview" class="preview"></div>
144
+
145
  <div class="mode-row">
146
  <button id="modeText" class="mode-btn active">文字模式</button>
147
  <button id="modeVoice" class="mode-btn">说话模式</button>
148
  </div>
149
+
150
  <div id="textMode">
151
  <div class="input-row">
152
  <label class="btn">📎<input id="fileInput" type="file" multiple accept="image/*,video/*,audio/*" style="display:none"></label>
 
154
  <button id="sendBtn" class="send">➤</button>
155
  </div>
156
  </div>
157
+
158
  <div id="voiceMode" style="display:none">
159
  <button id="holdBtn" class="hold">按住说话,松开发送</button>
160
  </div>
 
170
  </aside>
171
  </div>
172
 
173
+ <!-- 设置 -->
174
  <div id="settingsModal" class="modal">
175
  <div class="modal-card">
176
+ <h3 style="margin-top:0">设置(本地缓存 + 运行期配置)</h3>
177
+
178
  <div class="field">
179
+ <label>连接密码(CONNECT_PASSWORD)</label>
180
  <input id="setPassword" />
181
  </div>
182
+
183
  <div class="field">
184
+ <label>机器人用户名(botUsername)</label>
185
+ <input id="setBotUsername" />
186
  </div>
187
+
188
+ <div class="field">
189
+ <label>SSE拉流间隔(ms)</label>
190
+ <input id="setStreamInterval" type="number" min="800" step="100" />
191
+ </div>
192
+
193
  <div class="field">
194
+ <label>默认消息条数(limit)</label>
195
+ <input id="setMessageLimit" type="number" min="20" step="10" />
196
+ </div>
197
+
198
+ <div class="field" style="display:flex;align-items:center;gap:8px">
199
+ <input id="setDebug" type="checkbox" />
200
+ <label for="setDebug" style="margin:0">前端调试日志</label>
201
+ </div>
202
+
203
+ <div class="field" style="display:flex;align-items:center;gap:8px">
204
+ <input id="setUnsafe" type="checkbox" />
205
+ <label for="setUnsafe" style="margin:0">允许高级HTML嵌入(iframe/svg等)</label>
206
  </div>
207
+
208
  <div class="modal-actions">
209
  <button id="setCancel" class="btn">取消</button>
210
  <button id="setSave" class="btn" style="background:#3498ff;color:#fff;border:none">保存</button>
 
212
  </div>
213
  </div>
214
 
215
+ <!-- 媒体 -->
216
  <div id="mediaModal" class="modal">
217
  <div id="mediaTop">
218
  <a id="mediaDownload" href="#" download target="_blank"><button>下载</button></a>
 
222
  </div>
223
 
224
  <script>
 
225
  const appEl = document.getElementById('app');
226
  const S = {
227
+ cfg: { runtimeConfig: { botUsername: 'openman', streamInterval: 1500, defaultMessageLimit: 120 } },
228
+ settings: {
229
  connectPassword: localStorage.getItem('connectPassword') || '',
230
  debugLogs: localStorage.getItem('debugLogs') || 'true',
231
+ allowUnsafe: localStorage.getItem('allowUnsafe') || 'true',
232
+ botUsername: localStorage.getItem('botUsername') || '',
233
+ streamInterval: localStorage.getItem('streamInterval') || '',
234
+ messageLimit: localStorage.getItem('messageLimit') || ''
235
  },
236
+ channels: [],
237
+ current: null,
238
+ files: [],
239
+ es: null,
240
+ msgStore: new Map(),
241
+ rec: null,
242
+ recording: false,
243
+ chunks: [],
244
+ activeAudio: null
245
  };
246
+
247
  const E = {
248
+ left: leftPanel, right: rightPanel, messages, channelList, channelCount, searchInput,
249
  headName, headAvatar, input, fileInput, preview, logs,
250
  modeText, modeVoice, textMode, voiceMode, holdBtn,
251
  settingsModal, mediaModal, mediaContent, mediaDownload
252
  };
253
 
254
+ function log(type, msg){
255
+ if(type === 'info' && S.settings.debugLogs !== 'true') return;
256
+ const t = new Date().toLocaleTimeString('zh-CN',{hour12:false});
257
+ const p = type === 'error' ? '❌' : type === 'warn' ? '⚠️' : '📌';
258
+ const line = `[${t}] ${p} ${msg}`;
259
+ (type === 'error' ? console.error : console.log)(line);
260
  E.logs.textContent += line + '\n';
261
  E.logs.scrollTop = E.logs.scrollHeight;
262
  }
263
 
264
+ async function apiFetch(url, opt={}){
265
+ const headers = new Headers(opt.headers || {});
266
+ if(S.settings.connectPassword) headers.set('x-connect-password', S.settings.connectPassword);
267
+ const rsp = await fetch(url, { ...opt, headers });
268
+ if(rsp.status === 401) throw new Error('连接密码错误或未提供');
269
+ return rsp;
270
  }
271
 
 
 
 
 
272
  function escapeHtml(s=''){return String(s).replace(/[&<>"']/g,m=>({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[m]))}
273
+ function ensureStore(cid){ if(!S.msgStore.has(cid)) S.msgStore.set(cid,{ids:new Set(),lastId:null}); return S.msgStore.get(cid); }
274
+ function nearBottom(el,th=120){ return el.scrollHeight - el.scrollTop - el.clientHeight < th; }
275
  function extType(url=''){
276
+ const u = url.toLowerCase();
277
  if(/\.(png|jpe?g|gif|webp|svg)(\?|#|$)/.test(u)) return 'image';
278
  if(/\.(mp4|webm|mov|m4v)(\?|#|$)/.test(u)) return 'video';
279
  if(/\.(mp3|wav|ogg|m4a|aac|flac|webm)(\?|#|$)/.test(u)) return 'audio';
280
  return 'file';
281
  }
282
 
283
+ // 时间格式:yyyy.MM.dd HH:mm
284
+ function formatTimeFromMessage(m){
285
+ const candidates = [m.timestamp, m.created_at, m.updated_at];
286
+ let d = null;
287
+ for(const c of candidates){
288
+ if(c == null) continue;
289
+ if(typeof c === 'number' || /^\d+$/.test(String(c))){
290
+ let n = Number(c);
291
+ if(n < 1e11) n = n * 1000; // 秒转毫秒
292
+ const t = new Date(n);
293
+ if(!isNaN(t.getTime())) { d = t; break; }
294
+ }else{
295
+ const t = new Date(c);
296
+ if(!isNaN(t.getTime())) { d = t; break; }
297
+ }
298
+ }
299
+ if(!d) d = new Date();
300
+ const pad = x => String(x).padStart(2,'0');
301
+ return `${d.getFullYear()}.${pad(d.getMonth()+1)}.${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
302
+ }
303
+
304
+ // --- CDN 多镜像加载 ---
305
  function loadScriptAny(urls){
306
  return new Promise((resolve,reject)=>{
307
  let i=0;
 
318
  }
319
 
320
  async function loadEnhancers(){
 
321
  await Promise.allSettled([
322
  window.marked ? Promise.resolve() : loadScriptAny([
323
  'https://cdn.jsdelivr.net/npm/marked/marked.min.js',
 
335
  ])
336
  ]);
337
  if(window.mermaid) mermaid.initialize({startOnLoad:false,theme:'default'});
338
+ log('info', `渲染增强加载: marked=${!!window.marked}, purify=${!!window.DOMPurify}, mermaid=${!!window.mermaid}`);
339
+ }
340
+
341
+ // --- Markdown 渲染 ---
342
+ function preprocessAdmon(md=''){
343
+ let s = String(md);
344
+
345
+ // blockquote版:
346
+ // > [!IMPORTANT]
347
+ // > **任务...**
348
+ s = s.replace(
349
+ /(^|\n)>\s*\[!(TIP|NOTE|IMPORTANT|WARNING|CAUTION|ERROR)\]\s*\n((?:>\s?.*(?:\n|$))*)/gim,
350
+ (_, pfx, type, body) => {
351
+ const t = type.toUpperCase();
352
+ const cls = t==='TIP'?'tip':t==='NOTE'?'note':(t==='WARNING'||t==='CAUTION')?'warn':t==='IMPORTANT'?'important':'err';
353
+ const content = body.replace(/^>\s?/gm,'').trim();
354
+ return `${pfx}<div class="admon ${cls}"><div class="admon-title">${t}</div>\n${content}\n</div>\n`;
355
+ }
356
+ );
357
+
358
+ // 单行版 [!TIP] text
359
+ s = s.replace(
360
+ /^\s*\[!(TIP|NOTE|IMPORTANT|WARNING|CAUTION|ERROR)\]\s*(.*)$/gim,
361
+ (_, type, rest) => {
362
+ const t = type.toUpperCase();
363
+ const cls = t==='TIP'?'tip':t==='NOTE'?'note':(t==='WARNING'||t==='CAUTION')?'warn':t==='IMPORTANT'?'important':'err';
364
+ return `<div class="admon ${cls}"><div class="admon-title">${t}</div>${rest || ''}</div>`;
365
+ }
366
+ );
367
 
368
+ return s;
 
 
 
 
 
 
369
  }
370
 
371
  function simpleMd(t=''){
372
+ let s = escapeHtml(preprocessAdmon(t));
373
+ s = s.replace(/```(\w+)?\n([\s\S]*?)```/g, (_,lang,code)=>
374
+ `<pre><code class="language-${escapeHtml(lang||'text')}">${escapeHtml(code)}</code></pre>`);
375
+ s = s.replace(/`([^`\n]+)`/g,'<code>$1</code>');
376
+ s = s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g,'<img alt="$1" src="$2">');
377
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g,'<a href="$2" target="_blank" rel="noopener">$1</a>');
378
+ s = s.replace(/\n/g,'<br>');
 
 
379
  return s;
380
  }
381
 
 
383
  const src = preprocessAdmon(raw);
384
  let html = '';
385
  if(window.marked){
386
+ marked.setOptions({ gfm:true, breaks:true, mangle:false, headerIds:false });
387
  html = marked.parse(src);
388
  }else{
389
  html = simpleMd(src);
 
399
  }
400
 
401
  async function enhance(root){
 
402
  root.querySelectorAll('code').forEach(code=>{
403
+ const inPre = code.parentElement && code.parentElement.tagName === 'PRE';
404
  if(!inPre){
405
  code.classList.add('inline-code');
406
+ code.onclick = async()=>{ await navigator.clipboard.writeText(code.textContent||''); log('info','行内代码已复制'); };
407
  }
408
  });
409
 
 
410
  root.querySelectorAll('pre > code').forEach(code=>{
411
+ const pre = code.parentElement;
412
+ const lang = (code.className||'').replace('language-','')||'text';
413
+ const raw = code.textContent||'';
414
 
415
  if(lang.toLowerCase()==='mermaid' && window.mermaid){
416
  const id='mmd_'+Math.random().toString(36).slice(2);
 
440
  wrap.querySelector('.save').onclick=()=>{
441
  const a=document.createElement('a');
442
  a.href=URL.createObjectURL(new Blob([raw],{type:'text/plain;charset=utf-8'}));
443
+ a.download=`code_${Date.now()}.${lang||'txt'}`;a.click();
444
  setTimeout(()=>URL.revokeObjectURL(a.href),1200);
445
  };
446
  wrap.querySelector('.toggle').onclick=(e)=>{
 
449
  };
450
  });
451
 
 
452
  root.querySelectorAll('a[href]').forEach(a=>{
453
  const href=a.getAttribute('href')||'';
454
  const t=extType(href);
455
  if(t==='video'){
456
+ const v=document.createElement('video');
457
+ v.src=href; v.controls=true; v.style.maxWidth='340px'; v.style.borderRadius='10px';
458
+ a.replaceWith(v);
459
  }else if(t==='audio'){
460
  const audio=new Audio(href);
461
+ const wx=document.createElement('span');
462
+ wx.className='wx-audio'; wx.innerHTML='🔊 语音';
463
+ wx.onclick=()=>toggleWxAudio(wx,audio);
464
+ a.replaceWith(wx);
465
  }
466
  });
467
 
 
470
 
471
  function toggleWxAudio(wx,audio){
472
  if(S.activeAudio && S.activeAudio!==audio) S.activeAudio.pause();
473
+ if(audio.paused){ audio.play(); wx.classList.add('playing'); S.activeAudio=audio; }
474
+ else { audio.pause(); wx.classList.remove('playing'); }
475
  audio.onended=()=>wx.classList.remove('playing');
476
  audio.onpause=()=>wx.classList.remove('playing');
477
  audio.onplay=()=>wx.classList.add('playing');
478
  }
479
 
480
  function openMedia(url,type){
481
+ E.mediaContent.innerHTML = type==='image'
482
+ ? `<img src="${escapeHtml(url)}">`
483
+ : `<video src="${escapeHtml(url)}" controls autoplay></video>`;
484
+ E.mediaDownload.href = url;
485
  E.mediaModal.classList.add('show');
486
  }
487
 
 
491
  if(typeof m?.data?.content==='string') return m.data.content;
492
  return '';
493
  }
494
+
 
 
 
 
495
  function isSelf(m){
496
+ const botName = (S.settings.botUsername || S.cfg?.runtimeConfig?.botUsername || 'openman').toLowerCase();
497
+ const n = (m?.user?.name || '').toLowerCase();
498
+ if(m.role === 'user') return true;
499
+ if(n && n !== botName) return true;
500
  return false;
501
  }
502
 
503
  async function buildMsg(m){
 
504
  const self=isSelf(m);
505
+ const row=document.createElement('div');
506
  row.className='row'+(self?' self':'');
507
  row.dataset.id=m.id||'';
508
+
509
  const bubble=document.createElement('div');
510
  bubble.className='bubble '+(self?'self':'other');
511
 
512
  const sender=m?.user?.name||(self?'User':'Assistant');
513
  const html=renderMd(msgText(m));
 
514
  const files=(m&&typeof m.data==='object'&&Array.isArray(m.data.files))?m.data.files:[];
515
+ let filesHtml='';
516
+
517
  if(files.length){
518
  filesHtml+='<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px">';
519
  for(const it of files){
 
531
  ${self?'':`<div class="sender">${escapeHtml(sender)}</div>`}
532
  <div class="md">${html}</div>
533
  ${filesHtml}
534
+ <div class="meta">${formatTimeFromMessage(m)}</div>
535
  `;
536
  row.appendChild(bubble);
 
537
 
538
+ await enhance(bubble);
539
  bubble.querySelectorAll('.wx-audio[data-audio]').forEach(n=>{
540
  const a=new Audio(n.getAttribute('data-audio'));
541
  n.onclick=()=>toggleWxAudio(n,a);
542
  });
543
  bubble.querySelectorAll('img').forEach(img=>img.onclick=()=>openMedia(img.src,'image'));
 
544
  return row;
545
  }
546
 
 
556
  if(keep) E.messages.scrollTop=E.messages.scrollHeight;
557
  }
558
 
 
559
  async function loadChannels(){
560
  const r=await apiFetch('/api/channels');
561
  S.channels=await r.json();
562
  E.channelCount.textContent=String(S.channels.length||0);
563
  renderChannels();
564
+ if(!S.current && S.channels.length) await switchChannel(S.channels[0].id);
565
  }
566
 
567
  function renderChannels(){
 
580
 
581
  async function switchChannel(cid){
582
  const c=S.channels.find(x=>x.id===cid); if(!c) return;
583
+ S.current=c;
584
+ renderChannels();
585
+ E.headName.textContent=c.name;
586
+ E.headAvatar.textContent=(c.name||'C')[0].toUpperCase();
587
+
588
  E.messages.innerHTML='';
589
  const st=ensureStore(cid); st.ids.clear(); st.lastId=null;
590
 
591
+ const lim = Number(S.settings.messageLimit || S.cfg?.runtimeConfig?.defaultMessageLimit || 120);
592
+ const r=await apiFetch(`/api/messages/${cid}?limit=${Math.max(20,lim)}`);
593
  const arr=(await r.json()||[]).reverse();
594
  await appendMessages(cid,arr);
595
  connectStream(cid);
596
  E.left.classList.remove('show');
597
  }
598
 
 
599
  function closeStream(){ if(S.es){S.es.close();S.es=null;} }
600
  function connectStream(cid){
601
  closeStream();
 
603
  const q=new URLSearchParams();
604
  if(st.lastId) q.set('last_id',st.lastId);
605
  if(S.settings.connectPassword) q.set('p',S.settings.connectPassword);
606
+ q.set('limit', String(Math.max(20, Number(S.settings.messageLimit || S.cfg?.runtimeConfig?.defaultMessageLimit || 120))));
607
+ q.set('interval', String(Math.max(800, Number(S.settings.streamInterval || S.cfg?.runtimeConfig?.streamInterval || 1500))));
608
  S.es=new EventSource(`/api/messages/${cid}/stream?${q.toString()}`);
609
 
610
  S.es.addEventListener('messages',async ev=>{
611
+ try{ const p=JSON.parse(ev.data||'{}'); await appendMessages(cid,p.items||[]);}
612
+ catch(e){log('warn','SSE解析失败:'+e.message);}
613
  });
614
  S.es.onerror=()=>{
615
  log('warn','SSE断开,3秒重连');
616
+ closeStream();
617
+ setTimeout(()=>{ if(S.current?.id===cid) connectStream(cid); },3000);
618
  };
619
  }
620
 
 
621
  function fileToBase64(file){
622
  return new Promise((resolve,reject)=>{
623
  const fr=new FileReader();
 
626
  fr.readAsDataURL(file);
627
  });
628
  }
629
+
630
  function renderPreview(){
631
  if(!S.files.length){E.preview.innerHTML='';return;}
632
  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('');
633
  E.preview.querySelectorAll('[data-i]').forEach(b=>b.onclick=()=>{S.files.splice(+b.dataset.i,1);renderPreview();});
634
  }
635
+
636
  async function uploadFile(file){
637
  const b64=await fileToBase64(file);
638
  const r=await apiFetch('/api/upload',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
 
641
  const d=await r.json(); if(!d.url) throw new Error('上传失败');
642
  return d.url;
643
  }
644
+
645
  async function sendNow(){
646
  if(!S.current) return alert('请先选择频道');
647
  const text=E.input.value.trim();
 
649
 
650
  const links=[];
651
  for(const f of S.files){
652
+ try{ links.push({url:await uploadFile(f),type:f.type}); }
653
+ catch(e){ log('error','附件上传失败:'+e.message); }
654
  }
655
 
656
  let full=text;
 
663
  E.input.value=''; autoGrow(); S.files=[]; renderPreview();
664
  }
665
 
 
666
  function pickMime(){
667
  const c=['audio/webm;codecs=opus','audio/webm','audio/ogg;codecs=opus'];
668
  for(const m of c){ if(window.MediaRecorder && MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported(m)) return m; }
669
  return '';
670
  }
671
+
672
  async function startRecord(){
673
  if(!S.current||S.recording) return;
674
  try{
 
684
  const url=await uploadFile(file);
685
  await apiFetch(`/api/send/${S.current.id}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:`[语音](${url})`})});
686
  }catch(e){log('error','语音发送失败:'+e.message);}
687
+ finally{ stream.getTracks().forEach(t=>t.stop()); }
688
  };
689
  rec.start(); S.recording=true; E.holdBtn.textContent='录音中... 松开发送';
690
+ }catch(e){ log('error','录音失败:'+e.message); }
691
  }
692
+
693
  function stopRecord(){
694
  if(!S.recording) return;
695
  S.recording=false; E.holdBtn.textContent='按住说话,松开发送';
696
  try{S.rec&&S.rec.stop();}catch{}
697
  }
698
 
699
+ async function loadConfig(){
700
+ try{
701
+ const r=await fetch('/api/config');
702
+ S.cfg=await r.json();
703
+ }catch(e){ log('warn','读取配置失败:'+e.message); }
704
+ }
705
+
706
+ // 设置弹窗
707
  function openSettings(){
708
+ setPassword.value = S.settings.connectPassword || '';
709
+ setBotUsername.value = S.settings.botUsername || S.cfg?.runtimeConfig?.botUsername || 'openman';
710
+ setStreamInterval.value = S.settings.streamInterval || S.cfg?.runtimeConfig?.streamInterval || 1500;
711
+ setMessageLimit.value = S.settings.messageLimit || S.cfg?.runtimeConfig?.defaultMessageLimit || 120;
712
+ setDebug.checked = (S.settings.debugLogs === 'true');
713
+ setUnsafe.checked = (S.settings.allowUnsafe === 'true');
714
  E.settingsModal.classList.add('show');
715
  }
716
+ function closeSettings(){ E.settingsModal.classList.remove('show'); }
717
+
718
  async function saveSettings(){
719
+ S.settings.connectPassword = setPassword.value.trim();
720
+ S.settings.botUsername = setBotUsername.value.trim();
721
+ S.settings.streamInterval = String(Math.max(800, Number(setStreamInterval.value || 1500)));
722
+ S.settings.messageLimit = String(Math.max(20, Number(setMessageLimit.value || 120)));
723
+ S.settings.debugLogs = setDebug.checked ? 'true' : 'false';
724
+ S.settings.allowUnsafe = setUnsafe.checked ? 'true' : 'false';
725
+
726
+ localStorage.setItem('connectPassword', S.settings.connectPassword);
727
+ localStorage.setItem('botUsername', S.settings.botUsername);
728
+ localStorage.setItem('streamInterval', S.settings.streamInterval);
729
+ localStorage.setItem('messageLimit', S.settings.messageLimit);
730
+ localStorage.setItem('debugLogs', S.settings.debugLogs);
731
+ localStorage.setItem('allowUnsafe', S.settings.allowUnsafe);
732
+
733
+ // 同步到后端运行配置(不是改真正.env)
734
+ try{
735
+ await apiFetch('/api/runtime-settings',{
736
+ method:'POST',
737
+ headers:{'Content-Type':'application/json'},
738
+ body:JSON.stringify({
739
+ botUsername: S.settings.botUsername || undefined,
740
+ streamInterval: Number(S.settings.streamInterval),
741
+ defaultMessageLimit: Number(S.settings.messageLimit),
742
+ debugLogs: S.settings.debugLogs === 'true'
743
+ })
744
+ });
745
+ }catch(e){
746
+ log('warn','后端运行设置更新失败:'+e.message);
747
+ }
748
+
749
  closeSettings();
750
  if(S.current) await switchChannel(S.current.id); else await loadChannels();
751
  }
 
755
  E.input.style.height=Math.min(E.input.scrollHeight,240)+'px';
756
  }
757
 
 
 
 
 
 
 
 
 
758
  function bind(){
759
  btnOpenLeft.onclick=()=>E.left.classList.add('show');
760
  btnCloseLeft.onclick=()=>E.left.classList.remove('show');
 
767
  setSave.onclick=saveSettings;
768
 
769
  mediaClose.onclick=()=>E.mediaModal.classList.remove('show');
770
+ E.mediaModal.addEventListener('click',e=>{ if(e.target===E.mediaModal) E.mediaModal.classList.remove('show'); });
771
 
772
  E.searchInput.addEventListener('input',renderChannels);
773
 
 
779
 
780
  sendBtn.onclick=sendNow;
781
  E.input.addEventListener('input',autoGrow);
782
+ E.input.addEventListener('keydown',e=>{ if(e.key==='Enter'&&!e.shiftKey){ e.preventDefault(); sendNow(); } });
783
 
784
  E.modeText.onclick=()=>{E.modeText.classList.add('active');E.modeVoice.classList.remove('active');E.textMode.style.display='';E.voiceMode.style.display='none';};
785
  E.modeVoice.onclick=()=>{E.modeVoice.classList.add('active');E.modeText.classList.remove('active');E.textMode.style.display='none';E.voiceMode.style.display='';};
 
794
  }
795
 
796
  (async function init(){
797
+ bind(); // 先绑定,避免“按钮没反应”
798
  autoGrow();
799
  await loadConfig();
800
+ await loadEnhancers(); // 异步增强,CDN失败也不阻塞基本功能
801
  try{
802
  await loadChannels();
803
  log('info','初始化完成');