Update public/index.html
Browse files- 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,
|
| 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>连接密码(
|
| 167 |
<input id="setPassword" />
|
| 168 |
</div>
|
|
|
|
| 169 |
<div class="field">
|
| 170 |
-
<label>
|
| 171 |
-
<
|
| 172 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
<div class="field">
|
| 174 |
-
<label>
|
| 175 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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',
|
| 197 |
-
settings:{
|
| 198 |
connectPassword: localStorage.getItem('connectPassword') || '',
|
| 199 |
debugLogs: localStorage.getItem('debugLogs') || 'true',
|
| 200 |
-
allowUnsafe: localStorage.getItem('allowUnsafe') || 'true'
|
|
|
|
|
|
|
|
|
|
| 201 |
},
|
| 202 |
-
channels:
|
| 203 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 224 |
-
if(S.settings.connectPassword)
|
| 225 |
-
const
|
| 226 |
-
if(
|
| 227 |
-
return
|
| 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=>({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[m]))}
|
| 235 |
-
function
|
|
|
|
| 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 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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',
|
| 280 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
|
| 282 |
-
|
| 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 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
s=s.replace(/
|
| 298 |
-
s=s.replace(/
|
| 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'}`;
|
| 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');
|
|
|
|
|
|
|
| 382 |
}else if(t==='audio'){
|
| 383 |
const audio=new Audio(href);
|
| 384 |
-
const wx=document.createElement('span');
|
| 385 |
-
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'
|
| 403 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 420 |
-
|
| 421 |
-
if(
|
|
|
|
| 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">${
|
| 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;
|
| 506 |
-
|
|
|
|
|
|
|
|
|
|
| 507 |
E.messages.innerHTML='';
|
| 508 |
const st=ensureStore(cid); st.ids.clear(); st.lastId=null;
|
| 509 |
|
| 510 |
-
const
|
|
|
|
| 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||[]);}
|
|
|
|
| 529 |
});
|
| 530 |
S.es.onerror=()=>{
|
| 531 |
log('warn','SSE断开,3秒重连');
|
| 532 |
-
closeStream();
|
|
|
|
| 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}); }
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 611 |
function openSettings(){
|
| 612 |
-
setPassword.value=S.settings.connectPassword||'';
|
| 613 |
-
|
| 614 |
-
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 621 |
-
S.settings.
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=>({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[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','初始化完成');
|