Spaces:
Running
Running
github-actions[bot]
sync: upstream b70f787 Merge pull request #84 from huangzt/feature/vue-logs-ui
c6dedd5 | // Cursor2API Log Viewer v4 - Client JS | |
| // ===== Theme Toggle ===== | |
| function getTheme(){return document.documentElement.getAttribute('data-theme')||'light'} | |
| function applyThemeIcon(){const btn=document.getElementById('themeToggle');if(btn)btn.textContent=getTheme()==='dark'?'☀️':'🌙'} | |
| function toggleTheme(){const t=getTheme()==='dark'?'light':'dark';document.documentElement.setAttribute('data-theme',t);localStorage.setItem('cursor2api_theme',t);applyThemeIcon()} | |
| applyThemeIcon(); | |
| let reqs=[],rmap={},logs=[],selId=null,cFil='all',cLv='all',sq='',curTab='logs',curPayload=null,timeFil='all'; | |
| const PC={receive:'var(--blue)',convert:'var(--cyan)',send:'var(--purple)',response:'var(--purple)',thinking:'#a855f7',refusal:'var(--yellow)',retry:'var(--yellow)',truncation:'var(--yellow)',continuation:'var(--yellow)',toolparse:'var(--orange)',sanitize:'var(--orange)',stream:'var(--green)',complete:'var(--green)',error:'var(--red)',intercept:'var(--pink)',auth:'var(--t3)'}; | |
| // ===== Token Auth ===== | |
| const urlToken = new URLSearchParams(window.location.search).get('token'); | |
| if (urlToken) localStorage.setItem('cursor2api_token', urlToken); | |
| const authToken = localStorage.getItem('cursor2api_token') || ''; | |
| function authQ(base) { return authToken ? (base.includes('?') ? base + '&token=' : base + '?token=') + encodeURIComponent(authToken) : base; } | |
| function logoutBtn() { | |
| if (authToken) { | |
| const b = document.createElement('button'); | |
| b.textContent = '退出'; | |
| b.className = 'hdr-btn'; | |
| b.onclick = () => { localStorage.removeItem('cursor2api_token'); window.location.href = '/logs'; }; | |
| document.querySelector('.hdr-r').prepend(b); | |
| } | |
| } | |
| // ===== Init ===== | |
| async function init(){ | |
| try{ | |
| const[a,b]=await Promise.all([fetch(authQ('/api/requests?limit=100')),fetch(authQ('/api/logs?limit=500'))]); | |
| if (a.status === 401) { localStorage.removeItem('cursor2api_token'); window.location.href = '/logs'; return; } | |
| reqs=await a.json();logs=await b.json();rmap={};reqs.forEach(r=>rmap[r.requestId]=r); | |
| renderRL();updCnt();updStats(); | |
| // 默认显示实时日志流 | |
| renderLogs(logs.slice(-200)); | |
| }catch(e){console.error(e)} | |
| connectSSE(); | |
| logoutBtn(); | |
| } | |
| // ===== SSE ===== | |
| let es; | |
| function connectSSE(){ | |
| if(es)try{es.close()}catch{} | |
| es=new EventSource(authQ('/api/logs/stream')); | |
| es.addEventListener('log',e=>{ | |
| const en=JSON.parse(e.data);logs.push(en); | |
| if(logs.length>5000)logs=logs.slice(-3000); | |
| if(!selId||selId===en.requestId){if(curTab==='logs')appendLog(en)} | |
| }); | |
| es.addEventListener('summary',e=>{ | |
| const s=JSON.parse(e.data);rmap[s.requestId]=s; | |
| const i=reqs.findIndex(r=>r.requestId===s.requestId); | |
| if(i>=0)reqs[i]=s;else reqs.unshift(s); | |
| renderRL();updCnt(); | |
| if(selId===s.requestId)renderSCard(s); | |
| }); | |
| es.addEventListener('stats',e=>{applyStats(JSON.parse(e.data))}); | |
| es.onopen=()=>{const c=document.getElementById('conn');c.className='conn on';c.querySelector('span').textContent='已连接'}; | |
| es.onerror=()=>{const c=document.getElementById('conn');c.className='conn off';c.querySelector('span').textContent='重连中...';setTimeout(connectSSE,3000)}; | |
| } | |
| // ===== Stats ===== | |
| function updStats(){fetch(authQ('/api/stats')).then(r=>r.json()).then(applyStats).catch(()=>{})} | |
| function applyStats(s){document.getElementById('sT').textContent=s.totalRequests;document.getElementById('sS').textContent=s.successCount;document.getElementById('sE').textContent=s.errorCount;document.getElementById('sA').textContent=s.avgResponseTime||'-';document.getElementById('sF').textContent=s.avgTTFT||'-'} | |
| // ===== Time Filter ===== | |
| function getTimeCutoff(){ | |
| if(timeFil==='all')return 0; | |
| const now=Date.now(); | |
| const map={today:now-now%(86400000)+new Date().getTimezoneOffset()*-60000,'2d':now-2*86400000,'7d':now-7*86400000,'30d':now-30*86400000}; | |
| if(timeFil==='today'){const d=new Date();d.setHours(0,0,0,0);return d.getTime()} | |
| return map[timeFil]||0; | |
| } | |
| function setTF(f,btn){timeFil=f;document.querySelectorAll('#tbar .tb').forEach(b=>b.classList.remove('a'));btn.classList.add('a');renderRL();updCnt()} | |
| // ===== Search & Filter ===== | |
| function mS(r,q){ | |
| const s=q.toLowerCase(); | |
| return r.requestId.includes(s)||r.model.toLowerCase().includes(s)||r.path.toLowerCase().includes(s)||(r.title||'').toLowerCase().includes(s); | |
| } | |
| function updCnt(){ | |
| const q=sq.toLowerCase();const cut=getTimeCutoff(); | |
| let a=0,s=0,e=0,p=0,i=0; | |
| reqs.forEach(r=>{ | |
| if(cut&&r.startTime<cut)return; | |
| if(q&&!mS(r,q))return; | |
| a++;if(r.status==='success')s++;else if(r.status==='error')e++;else if(r.status==='processing')p++;else if(r.status==='intercepted')i++; | |
| }); | |
| document.getElementById('cA').textContent=a;document.getElementById('cS').textContent=s;document.getElementById('cE').textContent=e;document.getElementById('cP').textContent=p;document.getElementById('cI').textContent=i; | |
| } | |
| function fR(f,btn){cFil=f;document.querySelectorAll('#fbar .fb').forEach(b=>b.classList.remove('a'));btn.classList.add('a');renderRL()} | |
| // ===== Format helpers ===== | |
| function fmtDate(ts){const d=new Date(ts);return (d.getMonth()+1)+'/'+d.getDate()+' '+d.toLocaleTimeString('zh-CN',{hour12:false})} | |
| function timeAgo(ts){const s=Math.floor((Date.now()-ts)/1000);if(s<5)return'刚刚';if(s<60)return s+'s前';if(s<3600)return Math.floor(s/60)+'m前';return Math.floor(s/3600)+'h前'} | |
| function fmtN(n){if(n>=1e6)return(n/1e6).toFixed(1)+'M';if(n>=1e3)return(n/1e3).toFixed(1)+'K';return String(n)} | |
| function escH(s){if(!s)return'';const d=document.createElement('div');d.textContent=String(s);return d.innerHTML} | |
| function syntaxHL(data){ | |
| try{const s=typeof data==='string'?data:JSON.stringify(data,null,2); | |
| return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') | |
| .replace(/"([^"]+)"\s*:/g,'<span class="jk">"$1"</span>:') | |
| .replace(/:\s*"([^"]*?)"/g,': <span class="js">"$1"</span>') | |
| .replace(/:\s*(\d+\.?\d*)/g,': <span class="jn">$1</span>') | |
| .replace(/:\s*(true|false)/g,': <span class="jb">$1</span>') | |
| .replace(/:\s*(null)/g,': <span class="jnl">null</span>') | |
| }catch{return escH(String(data))} | |
| } | |
| function copyText(text){navigator.clipboard.writeText(text).then(()=>{}).catch(()=>{})} | |
| // ===== Request List ===== | |
| function renderRL(){ | |
| const el=document.getElementById('rlist');const q=sq.toLowerCase();const cut=getTimeCutoff(); | |
| let f=reqs; | |
| if(cut)f=f.filter(r=>r.startTime>=cut); | |
| if(q)f=f.filter(r=>mS(r,q)); | |
| if(cFil!=='all')f=f.filter(r=>r.status===cFil); | |
| if(!f.length){el.innerHTML='<div class="empty"><div class="ic">📡</div><p>'+(q?'无匹配':'暂无请求')+'</p></div>';return} | |
| el.innerHTML=f.map(r=>{ | |
| const ac=r.requestId===selId; | |
| const dur=r.endTime?((r.endTime-r.startTime)/1000).toFixed(1)+'s':'...'; | |
| const durMs=r.endTime?r.endTime-r.startTime:Date.now()-r.startTime; | |
| const pct=Math.min(100,durMs/30000*100),dc=!r.endTime?'pr':durMs<3000?'f':durMs<10000?'m':durMs<20000?'s':'vs'; | |
| const ch=r.responseChars>0?fmtN(r.responseChars)+' chars':''; | |
| const tt=r.ttft?r.ttft+'ms':''; | |
| const title=r.title||r.model; | |
| const dateStr=fmtDate(r.startTime); | |
| let bd='';if(r.stream)bd+='<span class="bg str">Stream</span>';if(r.hasTools)bd+='<span class="bg tls">T:'+r.toolCount+'</span>'; | |
| if(r.retryCount>0)bd+='<span class="bg rtr">R:'+r.retryCount+'</span>';if(r.continuationCount>0)bd+='<span class="bg cnt">C:'+r.continuationCount+'</span>'; | |
| if(r.status==='error')bd+='<span class="bg err">ERR</span>';if(r.status==='intercepted')bd+='<span class="bg icp">INTERCEPT</span>'; | |
| const fm=r.apiFormat||'anthropic'; | |
| return '<div class="ri'+(ac?' a':'')+'" data-r="'+r.requestId+'">' | |
| +'<div class="si-dot '+r.status+'"></div>' | |
| +'<div class="ri-title">'+escH(title)+'</div>' | |
| +'<div class="ri-time">'+dateStr+' · '+dur+(tt?' · ⚡'+tt:'')+'</div>' | |
| +'<div class="r1"><span class="rid">'+r.requestId+' <span class="rfmt '+fm+'">'+fm+'</span></span>' | |
| +(ch?'<span class="rch">→ '+ch+'</span>':'')+'</div>' | |
| +'<div class="rbd">'+bd+'</div>' | |
| +'<div class="rdbar"><div class="rdfill '+dc+'" style="width:'+pct+'%"></div></div></div>'; | |
| }).join(''); | |
| } | |
| // ===== Select Request ===== | |
| async function selReq(id){ | |
| if(selId===id){desel();return} | |
| selId=id;renderRL(); | |
| const s=rmap[id]; | |
| if(s){document.getElementById('dTitle').textContent=s.title||'请求 '+id;renderSCard(s)} | |
| document.getElementById('tabs').style.display='flex'; | |
| // ★ 保持当前 tab(不重置为 logs) | |
| const tabEl=document.querySelector('.tab[data-tab="'+curTab+'"]'); | |
| if(tabEl){setTab(curTab,tabEl)}else{setTab('logs',document.querySelector('.tab'))} | |
| // Load payload | |
| try{const r=await fetch(authQ('/api/payload/'+id));if(r.ok)curPayload=await r.json();else curPayload=null}catch{curPayload=null} | |
| // Re-render current tab with new data | |
| const tabEl2=document.querySelector('.tab[data-tab="'+curTab+'"]'); | |
| if(tabEl2)setTab(curTab,tabEl2); | |
| } | |
| function desel(){ | |
| selId=null;curPayload=null;renderRL(); | |
| document.getElementById('dTitle').textContent='实时日志流'; | |
| document.getElementById('scard').style.display='none'; | |
| document.getElementById('ptl').style.display='none'; | |
| document.getElementById('tabs').style.display='none'; | |
| curTab='logs'; | |
| renderLogs(logs.slice(-200)); | |
| } | |
| function renderSCard(s){ | |
| const c=document.getElementById('scard');c.style.display='block'; | |
| const dur=s.endTime?((s.endTime-s.startTime)/1000).toFixed(2)+'s':'进行中...'; | |
| const sc={processing:'var(--yellow)',success:'var(--green)',error:'var(--red)',intercepted:'var(--pink)'}[s.status]||'var(--t3)'; | |
| const items=[['状态','<span style="color:'+sc+'">'+s.status.toUpperCase()+'</span>'],['耗时',dur],['模型',escH(s.model)],['格式',(s.apiFormat||'anthropic').toUpperCase()],['消息数',s.messageCount],['响应字数',fmtN(s.responseChars)],['TTFT',s.ttft?s.ttft+'ms':'-'],['API耗时',s.cursorApiTime?s.cursorApiTime+'ms':'-'],['停止原因',s.stopReason||'-'],['重试',s.retryCount],['续写',s.continuationCount],['工具调用',s.toolCallsDetected]]; | |
| if(s.thinkingChars>0)items.push(['Thinking',fmtN(s.thinkingChars)+' chars']); | |
| if(s.error)items.push(['错误','<span style="color:var(--red)">'+escH(s.error)+'</span>']); | |
| document.getElementById('sgrid').innerHTML=items.map(([l,v])=>'<div class="si2"><span class="l">'+l+'</span><span class="v">'+v+'</span></div>').join(''); | |
| renderPTL(s); | |
| } | |
| function renderPTL(s){ | |
| const el=document.getElementById('ptl'),bar=document.getElementById('pbar'); | |
| if(!s.phaseTimings||!s.phaseTimings.length){el.style.display='none';return} | |
| el.style.display='block';const tot=(s.endTime||Date.now())-s.startTime;if(tot<=0){el.style.display='none';return} | |
| bar.innerHTML=s.phaseTimings.map(pt=>{const d=pt.duration||((pt.endTime||Date.now())-pt.startTime);const pct=Math.max(1,d/tot*100);const bg=PC[pt.phase]||'var(--t3)';return '<div class="pseg" style="width:'+pct+'%;background:'+bg+'" title="'+pt.label+': '+d+'ms"><span class="tip">'+escH(pt.label)+' '+d+'ms</span>'+(pct>10?'<span style="font-size:7px">'+pt.phase+'</span>':'')+'</div>'}).join(''); | |
| } | |
| // ===== Tabs ===== | |
| function setTab(tab,el){ | |
| curTab=tab; | |
| document.querySelectorAll('.tab').forEach(t=>t.classList.remove('a')); | |
| el.classList.add('a'); | |
| const tc=document.getElementById('tabContent'); | |
| if(tab==='logs'){ | |
| tc.innerHTML='<div class="llist" id="logList"></div>'; | |
| if(selId){renderLogs(logs.filter(l=>l.requestId===selId))}else{renderLogs(logs.slice(-200))} | |
| } else if(tab==='request'){ | |
| renderRequestTab(tc); | |
| } else if(tab==='prompts'){ | |
| renderPromptsTab(tc); | |
| } else if(tab==='response'){ | |
| renderResponseTab(tc); | |
| } | |
| } | |
| function renderRequestTab(tc){ | |
| if(!curPayload){tc.innerHTML='<div class="empty"><div class="ic">📥</div><p>暂无请求数据</p></div>';return} | |
| let h=''; | |
| const s=selId?rmap[selId]:null; | |
| if(s){ | |
| h+='<div class="content-section"><div class="cs-title">📋 请求概要</div>'; | |
| h+='<div class="resp-box">'+syntaxHL({method:s.method,path:s.path,model:s.model,stream:s.stream,apiFormat:s.apiFormat,messageCount:s.messageCount,toolCount:s.toolCount,hasTools:s.hasTools})+'</div></div>'; | |
| } | |
| if(curPayload.tools&&curPayload.tools.length){ | |
| h+='<div class="content-section"><div class="cs-title">🔧 工具定义 <span class="cnt">'+curPayload.tools.length+' 个</span></div>'; | |
| curPayload.tools.forEach(t=>{h+='<div class="tool-item"><div class="tool-name">'+escH(t.name)+'</div>'+(t.description?'<div class="tool-desc">'+escH(t.description)+'</div>':'')+'</div>'}); | |
| h+='</div>'; | |
| } | |
| if(curPayload.cursorRequest){ | |
| h+='<div class="content-section"><div class="cs-title">🔄 Cursor 请求(转换后)</div>'; | |
| h+='<div class="resp-box">'+syntaxHL(curPayload.cursorRequest)+'<button class="copy-btn" onclick="copyText(JSON.stringify(curPayload.cursorRequest,null,2))">复制</button></div></div>'; | |
| } | |
| if(curPayload.cursorMessages&&curPayload.cursorMessages.length){ | |
| h+='<div class="content-section"><div class="cs-title">📨 Cursor 消息列表 <span class="cnt">'+curPayload.cursorMessages.length+' 条</span></div>'; | |
| curPayload.cursorMessages.forEach((m,i)=>{ | |
| const collapsed=m.contentPreview.length>500; | |
| h+='<div class="msg-item"><div class="msg-header" onclick="togMsg(this)"><span class="msg-role '+m.role+'">'+m.role+' #'+(i+1)+'</span><span class="msg-meta">'+fmtN(m.contentLength)+' chars '+(collapsed?'▶ 展开':'▼ 收起')+'</span></div><div class="msg-body" style="display:'+(collapsed?'none':'block')+';max-height:800px;overflow-y:auto">'+escH(m.contentPreview)+'</div></div>'; | |
| }); | |
| h+='</div>'; | |
| } | |
| tc.innerHTML=h||'<div class="empty"><div class="ic">📥</div><p>暂无请求数据</p></div>'; | |
| } | |
| function renderPromptsTab(tc){ | |
| if(!curPayload){tc.innerHTML='<div class="empty"><div class="ic">💬</div><p>暂无提示词数据</p></div>';return} | |
| let h=''; | |
| const s=selId?rmap[selId]:null; | |
| // ===== 转换摘要 ===== | |
| if(s){ | |
| const origMsgCount=curPayload.messages?curPayload.messages.length:0; | |
| const cursorMsgCount=curPayload.cursorMessages?curPayload.cursorMessages.length:0; | |
| const origToolCount=s.toolCount||0; | |
| const sysPLen=curPayload.systemPrompt?curPayload.systemPrompt.length:0; | |
| const cursorTotalChars=curPayload.cursorRequest?.totalChars||0; | |
| // 计算工具指令占用的字符数(第一条 cursor 消息 减去 原始第一条用户消息) | |
| const firstCursorMsg=curPayload.cursorMessages?.[0]; | |
| const firstOrigUser=curPayload.messages?.find(m=>m.role==='user'); | |
| const toolInstructionChars=firstCursorMsg&&firstOrigUser?Math.max(0,firstCursorMsg.contentLength-(firstOrigUser?.contentLength||0)):0; | |
| h+='<div class="content-section"><div class="cs-title">🔄 转换摘要</div>'; | |
| h+='<div class="sgrid" style="grid-template-columns:repeat(3,1fr);gap:8px;margin:8px 0">'; | |
| h+='<div class="si2"><span class="l">原始工具数</span><span class="v">'+origToolCount+'</span></div>'; | |
| h+='<div class="si2"><span class="l">Cursor 工具数</span><span class="v" style="color:var(--green)">0 <span style="font-size:10px;color:var(--t2)">(嵌入消息)</span></span></div>'; | |
| h+='<div class="si2"><span class="l">工具指令占用</span><span class="v">'+(toolInstructionChars>0?fmtN(toolInstructionChars)+' chars':origToolCount>0?'嵌入第1条消息':'N/A')+'</span></div>'; | |
| h+='<div class="si2"><span class="l">原始消息数</span><span class="v">'+origMsgCount+'</span></div>'; | |
| h+='<div class="si2"><span class="l">Cursor 消息数</span><span class="v" style="color:var(--green)">'+cursorMsgCount+'</span></div>'; | |
| h+='<div class="si2"><span class="l">总上下文大小</span><span class="v">'+(cursorTotalChars>0?fmtN(cursorTotalChars)+' chars':'—')+'</span></div>'; | |
| h+='</div>'; | |
| if(origToolCount>0){ | |
| h+='<div style="color:var(--yellow);font-size:12px;padding:6px 10px;background:rgba(234,179,8,0.1);border-radius:6px;margin-top:4px">⚠️ Cursor API 不支持原生 tools 参数。'+origToolCount+' 个工具定义已转换为文本指令,嵌入在 user #1 消息中'+(toolInstructionChars>0?'(约 '+fmtN(toolInstructionChars)+' chars)':'')+'</div>'; | |
| } | |
| h+='</div>'; | |
| } | |
| // ===== 原始请求 ===== | |
| h+='<div class="content-section"><div class="cs-title">📥 客户端原始请求</div></div>'; | |
| if(curPayload.question){ | |
| h+='<div class="content-section"><div class="cs-title">❓ 用户问题摘要 <span class="cnt">'+fmtN(curPayload.question.length)+' chars</span></div>'; | |
| h+='<div class="resp-box" style="max-height:300px;overflow-y:auto;border-color:var(--orange)">'+escH(curPayload.question)+'<button class="copy-btn" onclick="copyText(curPayload.question)">复制</button></div></div>'; | |
| } | |
| if(curPayload.systemPrompt){ | |
| h+='<div class="content-section"><div class="cs-title">🔒 原始 System Prompt <span class="cnt">'+fmtN(curPayload.systemPrompt.length)+' chars</span></div>'; | |
| h+='<div class="resp-box" style="max-height:400px;overflow-y:auto;border-color:var(--orange)">'+escH(curPayload.systemPrompt)+'<button class="copy-btn" onclick="copyText(curPayload.systemPrompt)">复制</button></div></div>'; | |
| } | |
| if(curPayload.messages&&curPayload.messages.length){ | |
| h+='<div class="content-section"><div class="cs-title">💬 原始消息列表 <span class="cnt">'+curPayload.messages.length+' 条</span></div>'; | |
| curPayload.messages.forEach((m,i)=>{ | |
| const imgs=m.hasImages?' 🖼️':''; | |
| const collapsed=m.contentPreview.length>500; | |
| h+='<div class="msg-item"><div class="msg-header" onclick="togMsg(this)"><span class="msg-role '+m.role+'">'+m.role+imgs+' #'+(i+1)+'</span><span class="msg-meta">'+fmtN(m.contentLength)+' chars '+(collapsed?'▶ 展开':'▼ 收起')+'</span></div><div class="msg-body" style="display:'+(collapsed?'none':'block')+';max-height:800px;overflow-y:auto">'+escH(m.contentPreview)+'</div></div>'; | |
| }); | |
| h+='</div>'; | |
| } | |
| // ===== 转换后 Cursor 请求 ===== | |
| if(curPayload.cursorMessages&&curPayload.cursorMessages.length){ | |
| h+='<div class="content-section" style="margin-top:24px;border-top:2px solid var(--green);padding-top:16px"><div class="cs-title">📤 Cursor 最终消息(转换后) <span class="cnt" style="background:var(--green);color:#fff">'+curPayload.cursorMessages.length+' 条</span></div>'; | |
| h+='<div style="color:var(--t2);font-size:12px;margin-bottom:8px">⬇️ 以下是清洗后实际发给 Cursor 模型的消息(已清除身份声明、注入工具指令、添加认知重构)</div>'; | |
| curPayload.cursorMessages.forEach((m,i)=>{ | |
| const collapsed=m.contentPreview.length>500; | |
| h+='<div class="msg-item" style="border-left:3px solid var(--green)"><div class="msg-header" onclick="togMsg(this)"><span class="msg-role '+m.role+'">'+m.role+' #'+(i+1)+'</span><span class="msg-meta">'+fmtN(m.contentLength)+' chars '+(collapsed?'▶ 展开':'▼ 收起')+'</span></div><div class="msg-body" style="display:'+(collapsed?'none':'block')+';max-height:800px;overflow-y:auto">'+escH(m.contentPreview)+'</div></div>'; | |
| }); | |
| h+='</div>'; | |
| } else if(curPayload.cursorRequest) { | |
| h+='<div class="content-section" style="margin-top:24px;border-top:2px solid var(--green);padding-top:16px"><div class="cs-title">📤 Cursor 最终请求(转换后)</div>'; | |
| h+='<div class="resp-box" style="border-color:var(--green)">'+syntaxHL(curPayload.cursorRequest)+'</div></div>'; | |
| } | |
| tc.innerHTML=h||'<div class="empty"><div class="ic">💬</div><p>暂无提示词数据</p></div>'; | |
| } | |
| function renderResponseTab(tc){ | |
| if(!curPayload){tc.innerHTML='<div class="empty"><div class="ic">📤</div><p>暂无响应数据</p></div>';return} | |
| let h=''; | |
| if(curPayload.answer){ | |
| const title=curPayload.answerType==='tool_calls'?'✅ 最终结果(工具调用摘要)':'✅ 最终回答摘要'; | |
| h+='<div class="content-section"><div class="cs-title">'+title+' <span class="cnt">'+fmtN(curPayload.answer.length)+' chars</span></div>'; | |
| h+='<div class="resp-box diff" style="max-height:320px">'+escH(curPayload.answer)+'<button class="copy-btn" onclick="copyText(curPayload.answer)">复制</button></div></div>'; | |
| } | |
| if(curPayload.toolCallNames&&curPayload.toolCallNames.length&&!curPayload.toolCalls){ | |
| h+='<div class="content-section"><div class="cs-title">🔧 工具调用名称 <span class="cnt">'+curPayload.toolCallNames.length+' 个</span></div>'; | |
| h+='<div class="resp-box">'+escH(curPayload.toolCallNames.join(', '))+'<button class="copy-btn" onclick="copyText(curPayload.toolCallNames.join(\', \'))">复制</button></div></div>'; | |
| } | |
| if(curPayload.thinkingContent){ | |
| h+='<div class="content-section"><div class="cs-title">🧠 Thinking 内容 <span class="cnt">'+fmtN(curPayload.thinkingContent.length)+' chars</span></div>'; | |
| h+='<div class="resp-box" style="border-color:var(--purple);max-height:300px">'+escH(curPayload.thinkingContent)+'<button class="copy-btn" onclick="copyText(curPayload.thinkingContent)">复制</button></div></div>'; | |
| } | |
| if(curPayload.rawResponse){ | |
| h+='<div class="content-section"><div class="cs-title">📝 模型原始返回 <span class="cnt">'+fmtN(curPayload.rawResponse.length)+' chars</span></div>'; | |
| h+='<div class="resp-box" style="max-height:400px">'+escH(curPayload.rawResponse)+'<button class="copy-btn" onclick="copyText(curPayload.rawResponse)">复制</button></div></div>'; | |
| } | |
| if(curPayload.finalResponse&&curPayload.finalResponse!==curPayload.rawResponse){ | |
| h+='<div class="content-section"><div class="cs-title">✅ 最终响应(处理后)<span class="cnt">'+fmtN(curPayload.finalResponse.length)+' chars</span></div>'; | |
| h+='<div class="resp-box diff" style="max-height:400px">'+escH(curPayload.finalResponse)+'<button class="copy-btn" onclick="copyText(curPayload.finalResponse)">复制</button></div></div>'; | |
| } | |
| if(curPayload.toolCalls&&curPayload.toolCalls.length){ | |
| h+='<div class="content-section"><div class="cs-title">🔧 工具调用结果 <span class="cnt">'+curPayload.toolCalls.length+' 个</span></div>'; | |
| h+='<div class="resp-box">'+syntaxHL(curPayload.toolCalls)+'<button class="copy-btn" onclick="copyText(JSON.stringify(curPayload.toolCalls,null,2))">复制</button></div></div>'; | |
| } | |
| if(curPayload.retryResponses&&curPayload.retryResponses.length){ | |
| h+='<div class="content-section"><div class="cs-title">🔄 重试历史 <span class="cnt">'+curPayload.retryResponses.length+' 次</span></div>'; | |
| curPayload.retryResponses.forEach(r=>{h+='<div class="retry-item"><div class="retry-header">第 '+r.attempt+' 次重试 — '+escH(r.reason)+'</div><div class="retry-body">'+escH(r.response.substring(0,1000))+(r.response.length>1000?'\n... ('+fmtN(r.response.length)+' chars)':'')+'</div></div>'}); | |
| h+='</div>'; | |
| } | |
| if(curPayload.continuationResponses&&curPayload.continuationResponses.length){ | |
| h+='<div class="content-section"><div class="cs-title">📎 续写历史 <span class="cnt">'+curPayload.continuationResponses.length+' 次</span></div>'; | |
| curPayload.continuationResponses.forEach(r=>{h+='<div class="retry-item"><div class="retry-header" style="color:var(--orange)">续写 #'+r.index+' (去重后 '+fmtN(r.dedupedLength)+' chars)</div><div class="retry-body">'+escH(r.response.substring(0,1000))+(r.response.length>1000?'\n...':'')+'</div></div>'}); | |
| h+='</div>'; | |
| } | |
| tc.innerHTML=h||'<div class="empty"><div class="ic">📤</div><p>暂无响应数据</p></div>'; | |
| } | |
| // ===== Log rendering ===== | |
| function renderLogs(ll){ | |
| const el=document.getElementById('logList');if(!el)return; | |
| const fil=cLv==='all'?ll:ll.filter(l=>l.level===cLv); | |
| if(!fil.length){el.innerHTML='<div class="empty"><div class="ic">📋</div><p>暂无日志</p></div>';return} | |
| const autoExp=document.getElementById('autoExpand').checked; | |
| // 如果是全局视图(未选中请求),在不同 requestId 之间加分隔线 | |
| let lastRid=''; | |
| el.innerHTML=fil.map(l=>{ | |
| let sep=''; | |
| if(!selId&&l.requestId!==lastRid&&lastRid){ | |
| const title=rmap[l.requestId]?.title||l.requestId; | |
| sep='<div class="le-sep"></div><div class="le-sep-label">'+escH(title)+' ('+l.requestId+')</div>'; | |
| } | |
| lastRid=l.requestId; | |
| return sep+logH(l,autoExp); | |
| }).join(''); | |
| el.scrollTop=el.scrollHeight; | |
| } | |
| function logH(l,autoExp){ | |
| const t=new Date(l.timestamp).toLocaleTimeString('zh-CN',{hour12:false,hour:'2-digit',minute:'2-digit',second:'2-digit'}); | |
| const d=l.duration!=null?'+'+l.duration+'ms':''; | |
| let det=''; | |
| if(l.details){ | |
| const raw=typeof l.details==='string'?l.details:JSON.stringify(l.details,null,2); | |
| const show=autoExp; | |
| det='<div class="ldt" onclick="togDet(this)">'+(show?'▼ 收起':'▶ 详情')+'</div><div class="ldd" style="display:'+(show?'block':'none')+'">'+syntaxHL(l.details)+'<button class="copy-btn" onclick="event.stopPropagation();copyText(\''+escAttr(raw)+'\')">复制</button></div>'; | |
| } | |
| return '<div class="le"><div class="tli" style="background:'+(PC[l.phase]||'var(--t3)')+'"></div><span class="lt">'+t+'</span><span class="ld">'+d+'</span><span class="ll '+l.level+'">'+l.level+'</span><span class="ls">'+l.source+'</span><span class="lp">'+l.phase+'</span><div class="lm">'+escH(l.message)+det+'</div></div>'; | |
| } | |
| function escAttr(s){return s.replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/\n/g,'\\n').replace(/\r/g,'')} | |
| function appendLog(en){ | |
| const el=document.getElementById('logList');if(!el)return; | |
| if(el.querySelector('.empty'))el.innerHTML=''; | |
| if(cLv!=='all'&&en.level!==cLv)return; | |
| const autoExp=document.getElementById('autoExpand').checked; | |
| // 分隔线(实时模式) | |
| if(!selId){ | |
| const children=el.children; | |
| if(children.length>0){ | |
| const lastEl=children[children.length-1]; | |
| const lastRid=lastEl.getAttribute('data-rid')||''; | |
| if(lastRid&&lastRid!==en.requestId){ | |
| const title=rmap[en.requestId]?.title||en.requestId; | |
| const sep=document.createElement('div'); | |
| sep.innerHTML='<div class="le-sep"></div><div class="le-sep-label">'+escH(title)+' ('+en.requestId+')</div>'; | |
| while(sep.firstChild)el.appendChild(sep.firstChild); | |
| } | |
| } | |
| } | |
| const d=document.createElement('div');d.innerHTML=logH(en,autoExp); | |
| const n=d.firstElementChild;n.classList.add('ani');n.setAttribute('data-rid',en.requestId); | |
| el.appendChild(n); | |
| while(el.children.length>500)el.removeChild(el.firstChild); | |
| el.scrollTop=el.scrollHeight; | |
| } | |
| // ===== Utils ===== | |
| function togDet(el){const d=el.nextElementSibling;if(d.style.display==='none'){d.style.display='block';el.textContent='▼ 收起'}else{d.style.display='none';el.textContent='▶ 详情'}} | |
| function togMsg(el){const b=el.nextElementSibling;const isHidden=b.style.display==='none';b.style.display=isHidden?'block':'none';const m=el.querySelector('.msg-meta');if(m){const t=m.textContent;m.textContent=isHidden?t.replace('▶ 展开','▼ 收起'):t.replace('▼ 收起','▶ 展开')}} | |
| function sL(lv,btn){cLv=lv;document.querySelectorAll('#lvF .lvb').forEach(b=>b.classList.remove('a'));btn.classList.add('a');if(curTab==='logs'){if(selId)renderLogs(logs.filter(l=>l.requestId===selId));else renderLogs(logs.slice(-200))}} | |
| // ===== Clear logs ===== | |
| async function clearLogs(){ | |
| if(!confirm('确定清空所有日志?此操作不可恢复。'))return; | |
| try{ | |
| await fetch(authQ('/api/logs/clear'),{method:'POST'}); | |
| reqs=[];rmap={};logs=[];selId=null;curPayload=null; | |
| renderRL();updCnt();updStats();desel(); | |
| }catch(e){console.error(e)} | |
| } | |
| // ===== Keyboard ===== | |
| document.addEventListener('keydown',e=>{ | |
| if((e.ctrlKey||e.metaKey)&&e.key==='k'){e.preventDefault();document.getElementById('searchIn').focus();return} | |
| if(e.key==='Escape'){if(document.activeElement===document.getElementById('searchIn')){document.getElementById('searchIn').blur();document.getElementById('searchIn').value='';sq='';renderRL();updCnt()}else{desel()}return} | |
| if(e.key==='ArrowDown'||e.key==='ArrowUp'){e.preventDefault();const q=sq.toLowerCase();const cut=getTimeCutoff();let f=reqs;if(cut)f=f.filter(r=>r.startTime>=cut);if(q)f=f.filter(r=>mS(r,q));if(cFil!=='all')f=f.filter(r=>r.status===cFil);if(!f.length)return;const ci=selId?f.findIndex(r=>r.requestId===selId):-1;let ni;if(e.key==='ArrowDown')ni=ci<f.length-1?ci+1:0;else ni=ci>0?ci-1:f.length-1;selReq(f[ni].requestId);const it=document.querySelector('[data-r="'+f[ni].requestId+'"]');if(it)it.scrollIntoView({block:'nearest'})} | |
| }); | |
| document.getElementById('searchIn').addEventListener('input',e=>{sq=e.target.value;renderRL();updCnt()}); | |
| document.getElementById('rlist').addEventListener('click',e=>{const el=e.target.closest('[data-r]');if(el)selReq(el.getAttribute('data-r'))}); | |
| setInterval(renderRL,30000); | |
| init(); | |