diff --git a/config.yaml b/config.yaml
index a25b601..bc1d3ee 100644
--- a/config.yaml
+++ b/config.yaml
@@ -81,3 +81,14 @@ vision:
# 配置此项后只有图片 API 走代理,不影响主请求的响应速度
# 如果不配,会回退到上面的全局 proxy(如果有的话)
# proxy: "http://127.0.0.1:7890"
+
+# ==================== 日志持久化配置(可选) ====================
+# 开启后日志会写入文件,重启后自动加载历史记录
+# 环境变量: LOG_FILE_ENABLED=true|false, LOG_DIR=./logs
+logging:
+ # 是否启用日志文件持久化(默认关闭)
+ file_enabled: true
+ # 日志文件存储目录
+ dir: "./logs"
+ # 日志保留天数(超过天数的日志文件会自动清理)
+ max_days: 7
diff --git a/public/login.html b/public/login.html
new file mode 100644
index 0000000..2002469
--- /dev/null
+++ b/public/login.html
@@ -0,0 +1,48 @@
+
+
+
+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(/"([^"]+)"\s*:/g,'"$1":')
+ .replace(/:\s*"([^"]*?)"/g,': "$1"')
+ .replace(/:\s*(\d+\.?\d*)/g,': $1')
+ .replace(/:\s*(true|false)/g,': $1')
+ .replace(/:\s*(null)/g,': null')
+ }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='';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+='Stream';if(r.hasTools)bd+='T:'+r.toolCount+'';
+ if(r.retryCount>0)bd+='R:'+r.retryCount+'';if(r.continuationCount>0)bd+='C:'+r.continuationCount+'';
+ if(r.status==='error')bd+='ERR';if(r.status==='intercepted')bd+='INTERCEPT';
+ const fm=r.apiFormat||'anthropic';
+ return ''
+ +'
'
+ +'
'+escH(title)+'
'
+ +'
'+dateStr+' · '+dur+(tt?' · ⚡'+tt:'')+'
'
+ +'
'+r.requestId+' '+fm+''
+ +(ch?'→ '+ch+'':'')+'
'
+ +'
'+bd+'
'
+ +'
';
+ }).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=[['状态',''+s.status.toUpperCase()+''],['耗时',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(['错误',''+escH(s.error)+'']);
+ document.getElementById('sgrid').innerHTML=items.map(([l,v])=>''+l+''+v+'
').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 ''+escH(pt.label)+' '+d+'ms'+(pct>10?''+pt.phase+'':'')+'
'}).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='';
+ 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='';return}
+ let h='';
+ const s=selId?rmap[selId]:null;
+ if(s){
+ h+='📋 请求概要
';
+ h+='
'+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})+'
';
+ }
+ if(curPayload.tools&&curPayload.tools.length){
+ h+='🔧 工具定义 '+curPayload.tools.length+' 个
';
+ curPayload.tools.forEach(t=>{h+='
'});
+ h+='
';
+ }
+ if(curPayload.cursorRequest){
+ h+='🔄 Cursor 请求(转换后)
';
+ h+='
'+syntaxHL(curPayload.cursorRequest)+'
';
+ }
+ if(curPayload.cursorMessages&&curPayload.cursorMessages.length){
+ h+='📨 Cursor 消息列表 '+curPayload.cursorMessages.length+' 条
';
+ curPayload.cursorMessages.forEach((m,i)=>{
+ const collapsed=m.contentPreview.length>500;
+ h+='
'+escH(m.contentPreview)+'
';
+ });
+ h+='
';
+ }
+ tc.innerHTML=h||'';
+}
+
+function renderPromptsTab(tc){
+ if(!curPayload){tc.innerHTML='';return}
+ let h='';
+ if(curPayload.systemPrompt){
+ h+='🔒 System Prompt '+fmtN(curPayload.systemPrompt.length)+' chars
';
+ h+='
'+escH(curPayload.systemPrompt)+'
';
+ }
+ if(curPayload.messages&&curPayload.messages.length){
+ h+='💬 消息列表 '+curPayload.messages.length+' 条
';
+ curPayload.messages.forEach((m,i)=>{
+ const imgs=m.hasImages?' 🖼️':'';
+ const collapsed=m.contentPreview.length>500;
+ h+='
'+escH(m.contentPreview)+'
';
+ });
+ h+='
';
+ }
+ tc.innerHTML=h||'';
+}
+
+function renderResponseTab(tc){
+ if(!curPayload){tc.innerHTML='';return}
+ let h='';
+ if(curPayload.thinkingContent){
+ h+='🧠 Thinking 内容 '+fmtN(curPayload.thinkingContent.length)+' chars
';
+ h+='
'+escH(curPayload.thinkingContent)+'
';
+ }
+ if(curPayload.rawResponse){
+ h+='📝 模型原始返回 '+fmtN(curPayload.rawResponse.length)+' chars
';
+ h+='
'+escH(curPayload.rawResponse)+'
';
+ }
+ if(curPayload.finalResponse&&curPayload.finalResponse!==curPayload.rawResponse){
+ h+='✅ 最终响应(处理后)'+fmtN(curPayload.finalResponse.length)+' chars
';
+ h+='
'+escH(curPayload.finalResponse)+'
';
+ }
+ if(curPayload.toolCalls&&curPayload.toolCalls.length){
+ h+='🔧 工具调用结果 '+curPayload.toolCalls.length+' 个
';
+ h+='
'+syntaxHL(curPayload.toolCalls)+'
';
+ }
+ if(curPayload.retryResponses&&curPayload.retryResponses.length){
+ h+='🔄 重试历史 '+curPayload.retryResponses.length+' 次
';
+ curPayload.retryResponses.forEach(r=>{h+='
'+escH(r.response.substring(0,1000))+(r.response.length>1000?'\n... ('+fmtN(r.response.length)+' chars)':'')+'
'});
+ h+='
';
+ }
+ if(curPayload.continuationResponses&&curPayload.continuationResponses.length){
+ h+='📎 续写历史 '+curPayload.continuationResponses.length+' 次
';
+ curPayload.continuationResponses.forEach(r=>{h+='
'+escH(r.response.substring(0,1000))+(r.response.length>1000?'\n...':'')+'
'});
+ h+='
';
+ }
+ tc.innerHTML=h||'';
+}
+
+// ===== 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='';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=''+escH(title)+' ('+l.requestId+')
';
+ }
+ 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=''+(show?'▼ 收起':'▶ 详情')+'
'+syntaxHL(l.details)+'
';
+ }
+ return ''+t+''+d+''+l.level+''+l.source+''+l.phase+''+escH(l.message)+det+'
';
+}
+
+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=''+escH(title)+' ('+en.requestId+')
';
+ 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=ci0?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();
diff --git a/src/config.ts b/src/config.ts
index d7264c4..03341de 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -61,6 +61,14 @@ export function getConfig(): AppConfig {
enabled: yaml.thinking.enabled !== false, // 默认启用
};
}
+ // ★ 日志文件持久化
+ if (yaml.logging !== undefined) {
+ config.logging = {
+ file_enabled: yaml.logging.file_enabled === true, // 默认关闭
+ dir: yaml.logging.dir || './logs',
+ max_days: typeof yaml.logging.max_days === 'number' ? yaml.logging.max_days : 7,
+ };
+ }
} catch (e) {
console.warn('[Config] 读取 config.yaml 失败:', e);
}
@@ -90,6 +98,15 @@ export function getConfig(): AppConfig {
enabled: process.env.THINKING_ENABLED !== 'false' && process.env.THINKING_ENABLED !== '0',
};
}
+ // Logging 环境变量覆盖
+ if (process.env.LOG_FILE_ENABLED !== undefined) {
+ if (!config.logging) config.logging = { file_enabled: false, dir: './logs', max_days: 7 };
+ config.logging.file_enabled = process.env.LOG_FILE_ENABLED === 'true' || process.env.LOG_FILE_ENABLED === '1';
+ }
+ if (process.env.LOG_DIR) {
+ if (!config.logging) config.logging = { file_enabled: false, dir: './logs', max_days: 7 };
+ config.logging.dir = process.env.LOG_DIR;
+ }
// 从 base64 FP 环境变量解析指纹
if (process.env.FP) {
diff --git a/src/index.ts b/src/index.ts
index fbc4d68..72790cf 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -11,7 +11,8 @@ import express from 'express';
import { getConfig } from './config.js';
import { handleMessages, listModels, countTokens } from './handler.js';
import { handleOpenAIChatCompletions, handleOpenAIResponses } from './openai-handler.js';
-import { serveLogViewer, apiGetLogs, apiGetRequests, apiGetStats, apiGetPayload, apiLogsStream, serveLogViewerLogin } from './log-viewer.js';
+import { serveLogViewer, apiGetLogs, apiGetRequests, apiGetStats, apiGetPayload, apiLogsStream, serveLogViewerLogin, apiClearLogs } from './log-viewer.js';
+import { loadLogsFromFiles } from './logger.js';
// 从 package.json 读取版本号,统一来源,避免多处硬编码
const require = createRequire(import.meta.url);
@@ -36,6 +37,9 @@ app.use((_req, res, next) => {
next();
});
+// ★ 静态文件路由(无需鉴权,CSS/JS 等)
+app.use('/public', express.static('public'));
+
// ★ 日志查看器鉴权中间件:配置了 authTokens 时需要验证
const logViewerAuth = (req: express.Request, res: express.Response, next: express.NextFunction) => {
const tokens = config.authTokens;
@@ -65,6 +69,7 @@ app.get('/api/requests', logViewerAuth, apiGetRequests);
app.get('/api/stats', logViewerAuth, apiGetStats);
app.get('/api/payload/:requestId', logViewerAuth, apiGetPayload);
app.get('/api/logs/stream', logViewerAuth, apiLogsStream);
+app.post('/api/logs/clear', logViewerAuth, apiClearLogs);
// ★ API 鉴权中间件:配置了 authTokens 则需要 Bearer token
app.use((req, res, next) => {
@@ -140,13 +145,18 @@ app.get('/', (_req, res) => {
// ==================== 启动 ====================
+// ★ 从日志文件加载历史(必须在 listen 之前)
+loadLogsFromFiles();
+
app.listen(config.port, () => {
const auth = config.authTokens?.length ? `${config.authTokens.length} token(s)` : 'open';
+ const logPersist = config.logging?.file_enabled ? `file → ${config.logging.dir}` : 'memory only';
console.log('');
console.log(` \x1b[36m⚡ Cursor2API v${VERSION}\x1b[0m`);
console.log(` ├─ Server: \x1b[32mhttp://localhost:${config.port}\x1b[0m`);
console.log(` ├─ Model: ${config.cursorModel}`);
console.log(` ├─ Auth: ${auth}`);
+ console.log(` ├─ Logging: ${logPersist}`);
console.log(` └─ Logs: \x1b[35mhttp://localhost:${config.port}/logs\x1b[0m`);
console.log('');
});
diff --git a/src/log-viewer.ts b/src/log-viewer.ts
index 51e08ec..8ffe241 100644
--- a/src/log-viewer.ts
+++ b/src/log-viewer.ts
@@ -1,17 +1,24 @@
/**
- * log-viewer.ts - 全链路日志 Web UI v3
+ * log-viewer.ts - 全链路日志 Web UI v4
*
- * 核心特性:
- * - 完整请求参数查看(原始请求 body, messages, tools)
- * - 提示词查看(system prompt, 用户消息)
- * - 模型返回内容查看(原始响应, 最终响应, thinking, tool calls)
- * - 阶段耗时时间线
- * - 重试/续写历史
- * - 实时 SSE + 搜索 + 过滤
+ * 静态文件分离版:HTML/CSS/JS 放在 public/ 目录,此文件只包含 API 路由和文件服务
*/
import type { Request, Response } from 'express';
-import { getAllLogs, getRequestSummaries, getStats, getRequestPayload, subscribeToLogs, subscribeToSummaries } from './logger.js';
+import { readFileSync } from 'fs';
+import { join, dirname } from 'path';
+import { fileURLToPath } from 'url';
+import { getAllLogs, getRequestSummaries, getStats, getRequestPayload, subscribeToLogs, subscribeToSummaries, clearAllLogs } from './logger.js';
+
+// ==================== 静态文件路径 ====================
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+const publicDir = join(__dirname, '..', 'public');
+
+function readPublicFile(filename: string): string {
+ return readFileSync(join(publicDir, filename), 'utf-8');
+}
// ==================== API 路由 ====================
@@ -39,6 +46,12 @@ export function apiGetPayload(req: Request, res: Response): void {
res.json(payload);
}
+/** POST /api/logs/clear - 清空所有日志 */
+export function apiClearLogs(_req: Request, res: Response): void {
+ const result = clearAllLogs();
+ res.json({ success: true, ...result });
+}
+
export function apiLogsStream(req: Request, res: Response): void {
res.writeHead(200, {
'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache',
@@ -54,562 +67,32 @@ export function apiLogsStream(req: Request, res: Response): void {
req.on('close', () => { unsubLog(); unsubSummary(); clearInterval(hb); });
}
+// ==================== 页面服务 ====================
+
export function serveLogViewer(_req: Request, res: Response): void {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
- res.send(LOG_VIEWER_HTML);
+ res.send(readPublicFile('logs.html'));
}
export function serveLogViewerLogin(_req: Request, res: Response): void {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
- res.send(LOGIN_HTML);
+ res.send(readPublicFile('login.html'));
}
-// ==================== Login Page HTML ====================
-
-const LOGIN_HTML = `
-
-
-
-
-Cursor2API - 登录
-
-
-
-
-
-
-
⚡ Cursor2API
-
日志查看器需要验证身份
-
-
-
-
-
-
-
Token 无效,请检查后重试
-
-
-
-`;
-
-// ==================== HTML ====================
-
-const LOG_VIEWER_HTML = `
-
-
-
-
-Cursor2API - 全链路日志
-
-
-
-
-
-
-
⚡ Cursor2API 日志
-
-
0请求
-
✓0
-
✗0
-
-ms 均耗
-
⚡-ms TTFT
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
🔍 实时日志流
-
-
-
-
-
-
-
-
-
-
-
-
-
📋 日志
-
📥 请求参数
-
💬 提示词
-
📤 响应内容
-
-
-
-
-
-
-
-
-`;
diff --git a/src/logger.ts b/src/logger.ts
index 75d57d2..8a1d672 100644
--- a/src/logger.ts
+++ b/src/logger.ts
@@ -1,5 +1,5 @@
/**
- * logger.ts - 全链路日志系统 v3
+ * logger.ts - 全链路日志系统 v4
*
* 核心升级:
* - 存储完整的请求参数(messages, system prompt, tools)
@@ -7,10 +7,16 @@
* - 存储转换后的 Cursor 请求
* - 阶段耗时追踪 (Phase Timing)
* - TTFT (Time To First Token)
+ * - 用户问题标题提取
+ * - 日志文件持久化(JSONL 格式,可配置开关)
+ * - 日志清空操作
* - 全部通过 Web UI 可视化
*/
import { EventEmitter } from 'events';
+import { existsSync, mkdirSync, appendFileSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'fs';
+import { join, basename } from 'path';
+import { getConfig } from './config.js';
// ==================== 类型定义 ====================
@@ -100,6 +106,8 @@ export interface RequestSummary {
phaseTimings: PhaseTiming[];
thinkingChars: number;
systemPromptLength: number;
+ /** 用户提问标题(截取最后一个 user 消息的前 80 字符) */
+ title?: string;
}
// ==================== 存储 ====================
@@ -123,6 +131,129 @@ function shortId(): string {
return id;
}
+// ==================== 日志文件持久化 ====================
+
+function getLogDir(): string | null {
+ const cfg = getConfig();
+ if (!cfg.logging?.file_enabled) return null;
+ return cfg.logging.dir || './logs';
+}
+
+function getLogFilePath(): string | null {
+ const dir = getLogDir();
+ if (!dir) return null;
+ const date = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
+ return join(dir, `cursor2api-${date}.jsonl`);
+}
+
+function ensureLogDir(): void {
+ const dir = getLogDir();
+ if (dir && !existsSync(dir)) {
+ mkdirSync(dir, { recursive: true });
+ }
+}
+
+/** 将已完成的请求写入日志文件 */
+function persistRequest(summary: RequestSummary, payload: RequestPayload): void {
+ const filepath = getLogFilePath();
+ if (!filepath) return;
+ try {
+ ensureLogDir();
+ const record = { timestamp: Date.now(), summary, payload };
+ appendFileSync(filepath, JSON.stringify(record) + '\n', 'utf-8');
+ } catch (e) {
+ console.warn('[Logger] 写入日志文件失败:', e);
+ }
+}
+
+/** 启动时从日志文件加载历史记录 */
+export function loadLogsFromFiles(): void {
+ const dir = getLogDir();
+ if (!dir || !existsSync(dir)) return;
+ try {
+ const cfg = getConfig();
+ const maxDays = cfg.logging?.max_days || 7;
+ const cutoff = Date.now() - maxDays * 86400000;
+
+ const files = readdirSync(dir)
+ .filter(f => f.startsWith('cursor2api-') && f.endsWith('.jsonl'))
+ .sort(); // 按日期排序
+
+ // 清理过期文件
+ for (const f of files) {
+ const dateStr = f.replace('cursor2api-', '').replace('.jsonl', '');
+ const fileDate = new Date(dateStr).getTime();
+ if (fileDate < cutoff) {
+ try { unlinkSync(join(dir, f)); } catch { /* ignore */ }
+ continue;
+ }
+ }
+
+ // 加载有效文件(最多最近2个文件)
+ const validFiles = readdirSync(dir)
+ .filter(f => f.startsWith('cursor2api-') && f.endsWith('.jsonl'))
+ .sort()
+ .slice(-2);
+
+ let loaded = 0;
+ for (const f of validFiles) {
+ const content = readFileSync(join(dir, f), 'utf-8');
+ const lines = content.split('\n').filter(Boolean);
+ for (const line of lines) {
+ try {
+ const record = JSON.parse(line);
+ if (record.summary && record.summary.requestId) {
+ const s = record.summary as RequestSummary;
+ const p = record.payload as RequestPayload || {};
+ if (!requestSummaries.has(s.requestId)) {
+ requestSummaries.set(s.requestId, s);
+ requestPayloads.set(s.requestId, p);
+ requestOrder.push(s.requestId);
+ loaded++;
+ }
+ }
+ } catch { /* skip malformed lines */ }
+ }
+ }
+
+ // 裁剪到 MAX_REQUESTS
+ while (requestOrder.length > MAX_REQUESTS) {
+ const oldId = requestOrder.shift()!;
+ requestSummaries.delete(oldId);
+ requestPayloads.delete(oldId);
+ }
+
+ if (loaded > 0) {
+ console.log(`[Logger] 从日志文件加载了 ${loaded} 条历史记录`);
+ }
+ } catch (e) {
+ console.warn('[Logger] 加载日志文件失败:', e);
+ }
+}
+
+/** 清空所有日志(内存 + 文件) */
+export function clearAllLogs(): { cleared: number } {
+ const count = requestSummaries.size;
+ logEntries.length = 0;
+ requestSummaries.clear();
+ requestPayloads.clear();
+ requestOrder.length = 0;
+ logCounter = 0;
+
+ // 清空日志文件
+ const dir = getLogDir();
+ if (dir && existsSync(dir)) {
+ try {
+ const files = readdirSync(dir).filter(f => f.startsWith('cursor2api-') && f.endsWith('.jsonl'));
+ for (const f of files) {
+ try { unlinkSync(join(dir, f)); } catch { /* ignore */ }
+ }
+ } catch { /* ignore */ }
+ }
+
+ return { cleared: count };
+}
+
// ==================== 统计 ====================
export function getStats() {
@@ -321,6 +452,28 @@ export class RequestLogger {
}
return { role: m.role, contentPreview: fullContent, contentLength, hasImages };
});
+
+ // ★ 提取用户问题标题:取最后一个 user 消息的真实提问
+ const userMsgs = body.messages.filter((m: any) => m.role === 'user');
+ if (userMsgs.length > 0) {
+ const lastUser = userMsgs[userMsgs.length - 1];
+ let text = '';
+ if (typeof lastUser.content === 'string') {
+ text = lastUser.content;
+ } else if (Array.isArray(lastUser.content)) {
+ text = lastUser.content
+ .filter((c: any) => c.type === 'text')
+ .map((c: any) => c.text || '')
+ .join(' ');
+ }
+ // 去掉 ... 注入内容
+ text = text.replace(/[\s\S]*?<\/system-reminder>/gi, '');
+ // 去掉 Claude Code 尾部的 "First, think step by step..." 引导语
+ text = text.replace(/First,\s*think\s+step\s+by\s+step[\s\S]*$/i, '');
+ // 清理换行、多余空格
+ text = text.replace(/\s+/g, ' ').trim();
+ this.summary.title = text.length > 80 ? text.substring(0, 77) + '...' : text;
+ }
}
// tools
@@ -439,6 +592,9 @@ export class RequestLogger {
this.log('info', 'System', 'complete', `完成 (${duration}ms, ${responseChars} chars, stop=${stopReason})`);
logEmitter.emit('summary', this.summary);
+ // ★ 持久化到文件
+ persistRequest(this.summary, this.payload);
+
const retryInfo = this.summary.retryCount > 0 ? ` retry=${this.summary.retryCount}` : '';
const contInfo = this.summary.continuationCount > 0 ? ` cont=${this.summary.continuationCount}` : '';
const toolInfo = this.summary.toolCallsDetected > 0 ? ` tools_called=${this.summary.toolCallsDetected}` : '';
@@ -451,6 +607,7 @@ export class RequestLogger {
this.summary.endTime = Date.now();
this.log('info', 'System', 'intercept', reason);
logEmitter.emit('summary', this.summary);
+ persistRequest(this.summary, this.payload);
console.log(`\x1b[35m⊘\x1b[0m [${this.requestId}] 拦截: ${reason}`);
}
@@ -461,5 +618,6 @@ export class RequestLogger {
this.summary.error = error;
this.log('error', 'System', 'error', error);
logEmitter.emit('summary', this.summary);
+ persistRequest(this.summary, this.payload);
}
}
diff --git a/src/types.ts b/src/types.ts
index 6646f89..c797ac7 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -123,6 +123,11 @@ export interface AppConfig {
thinking?: {
enabled: boolean; // 是否启用 thinking(最高优先级,覆盖客户端请求)
};
+ logging?: {
+ file_enabled: boolean; // 是否启用日志文件持久化
+ dir: string; // 日志文件存储目录
+ max_days: number; // 日志保留天数
+ };
fingerprint: {
userAgent: string;
};