From d388fefa21797769bf55965190413633bb00bda1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=B5=B7?= <7836246@qq.com> Date: Tue, 17 Mar 2026 09:33:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(log-viewer):=20=E6=97=A5=E5=A4=9C=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E5=88=87=E6=8D=A2=20+=20=E6=A0=87=E9=A2=98=E6=8F=90?= =?UTF-8?q?=E5=8F=96=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增日/夜主题切换按钮(☀️/🌙),支持 localStorage 持久化,首次自动检测系统偏好 - 完整暗色主题 CSS 变量覆盖,所有 UI 元素适配 - 修复标题提取:过滤 注入内容和 Claude Code 引导语 - 日志页面前端重构为独立 HTML/CSS/JS 静态文件 - 登录页面样式同步更新为现代玻璃态主题 - Express 静态文件服务配置修复(兼容 v5 path-to-regexp) --- config.yaml | 11 + public/login.html | 48 ++++ public/logs.css | 495 ++++++++++++++++++++++++++++++++++++++ public/logs.html | 80 +++++++ public/logs.js | 370 ++++++++++++++++++++++++++++ src/config.ts | 17 ++ src/index.ts | 12 +- src/log-viewer.ts | 599 ++++------------------------------------------ src/logger.ts | 160 ++++++++++++- src/types.ts | 5 + 10 files changed, 1237 insertions(+), 560 deletions(-) create mode 100644 public/login.html create mode 100644 public/logs.css create mode 100644 public/logs.html create mode 100644 public/logs.js 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 @@ + + + + + +Cursor2API - 登录 + + + + +
+ +
+ + +
+ +
Token 无效,请检查后重试
+
+ + + diff --git a/public/logs.css b/public/logs.css new file mode 100644 index 0000000..5c04c25 --- /dev/null +++ b/public/logs.css @@ -0,0 +1,495 @@ +/* Cursor2API Log Viewer v4 - Modern Light Theme */ +@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&display=swap'); + +:root { + --bg0: #f0f4f8; + --bg1: #ffffff; + --bg2: #f7f9fc; + --bg3: #edf2f7; + --bg-card: #ffffff; + --bdr: #e2e8f0; + --bdr2: #cbd5e1; + --t1: #1e293b; + --t2: #475569; + --t3: #94a3b8; + --blue: #3b82f6; + --cyan: #0891b2; + --green: #059669; + --yellow: #d97706; + --red: #dc2626; + --purple: #7c3aed; + --pink: #db2777; + --orange: #ea580c; + --mono: 'JetBrains Mono', monospace; + --sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + --shadow-sm: 0 1px 2px rgba(0,0,0,.05); + --shadow: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.04); + --shadow-md: 0 4px 6px rgba(0,0,0,.06), 0 2px 4px rgba(0,0,0,.04); + --shadow-lg: 0 10px 15px rgba(0,0,0,.06), 0 4px 6px rgba(0,0,0,.04); + --radius: 10px; + --radius-sm: 6px; +} + +* { box-sizing: border-box; margin: 0; padding: 0 } +body { + font-family: var(--sans); + background: linear-gradient(135deg, #e0e7ff 0%, #f0f4f8 30%, #fdf2f8 70%, #f0f4f8 100%); + color: var(--t1); + height: 100vh; + overflow: hidden; +} + +/* ===== App Shell ===== */ +.app { display: flex; flex-direction: column; height: 100vh } + +/* ===== Header ===== */ +.hdr { + display: flex; align-items: center; justify-content: space-between; + padding: 10px 20px; + background: rgba(255,255,255,.82); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + border-bottom: 1px solid rgba(226,232,240,.8); + box-shadow: var(--shadow-sm); + position: relative; z-index: 10; +} +.hdr h1 { + font-size: 16px; font-weight: 700; + background: linear-gradient(135deg, #6366f1, #3b82f6, #0891b2); + -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; + display: flex; align-items: center; gap: 6px; +} +.hdr h1 .ic { font-size: 17px; -webkit-text-fill-color: initial } +.hdr-stats { display: flex; gap: 8px } +.sc { + padding: 4px 12px; + background: var(--bg2); + border: 1px solid var(--bdr); + border-radius: 20px; + font-size: 11px; color: var(--t2); + display: flex; align-items: center; gap: 4px; + box-shadow: var(--shadow-sm); +} +.sc b { font-family: var(--mono); color: var(--t1); font-weight: 600 } +.hdr-r { display: flex; gap: 8px; align-items: center } +.hdr-btn { + padding: 5px 12px; font-size: 11px; font-weight: 500; + background: var(--bg1); border: 1px solid var(--bdr); + border-radius: var(--radius-sm); color: var(--t2); + cursor: pointer; transition: all .2s; + box-shadow: var(--shadow-sm); +} +.hdr-btn:hover { border-color: var(--red); color: var(--red); background: #fef2f2 } +.conn { + display: flex; align-items: center; gap: 5px; + font-size: 10px; font-weight: 500; + padding: 4px 10px; border-radius: 20px; + border: 1px solid var(--bdr); background: var(--bg1); + box-shadow: var(--shadow-sm); +} +.conn.on { color: var(--green); border-color: #bbf7d0 } +.conn.off { color: var(--red); border-color: #fecaca } +.conn .d { width: 6px; height: 6px; border-radius: 50% } +.conn.on .d { background: var(--green); animation: p 2s infinite } +.conn.off .d { background: var(--red) } +@keyframes p { 0%,100%{opacity:1} 50%{opacity:.3} } + +/* ===== Main Layout ===== */ +.main { display: flex; flex: 1; overflow: hidden } + +/* ===== Sidebar ===== */ +.side { + width: 370px; border-right: 1px solid var(--bdr); + display: flex; flex-direction: column; + background: rgba(255,255,255,.65); + backdrop-filter: blur(12px); + flex-shrink: 0; +} +.search { padding: 8px 12px; border-bottom: 1px solid var(--bdr) } +.sw { position: relative } +.sw::before { content: '🔍'; position: absolute; left: 10px; top: 50%; transform: translateY(-50%); font-size: 12px; pointer-events: none } +.si { + width: 100%; padding: 8px 12px 8px 32px; font-size: 12px; + background: var(--bg1); border: 1px solid var(--bdr); + border-radius: var(--radius); color: var(--t1); + outline: none; font-family: var(--mono); + box-shadow: var(--shadow-sm) inset; + transition: border-color .2s, box-shadow .2s; +} +.si:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(59,130,246,.12) } +.si::placeholder { color: var(--t3) } + +/* Time filter bar */ +.tbar { padding: 6px 10px; border-bottom: 1px solid var(--bdr); display: flex; gap: 4px } +.tb { + padding: 3px 10px; font-size: 10px; font-weight: 500; + border: 1px solid var(--bdr); border-radius: 20px; + background: var(--bg1); color: var(--t3); + cursor: pointer; transition: all .2s; +} +.tb:hover { border-color: var(--cyan); color: var(--cyan); background: #ecfeff } +.tb.a { background: linear-gradient(135deg, #0891b2, #06b6d4); border-color: transparent; color: #fff; box-shadow: 0 2px 6px rgba(8,145,178,.25) } + +/* Status filter bar */ +.fbar { padding: 6px 10px; border-bottom: 1px solid var(--bdr); display: flex; gap: 4px; flex-wrap: wrap } +.fb { + padding: 3px 10px; font-size: 10px; font-weight: 500; + border: 1px solid var(--bdr); border-radius: 20px; + background: var(--bg1); color: var(--t2); + cursor: pointer; transition: all .2s; + display: flex; align-items: center; gap: 4px; +} +.fb:hover { border-color: var(--blue); color: var(--blue); background: #eff6ff } +.fb.a { background: linear-gradient(135deg, #3b82f6, #6366f1); border-color: transparent; color: #fff; box-shadow: 0 2px 6px rgba(59,130,246,.25) } +.fc { + font-size: 9px; font-weight: 600; + padding: 0 5px; border-radius: 10px; + background: rgba(0,0,0,.06); min-width: 16px; text-align: center; +} +.fb.a .fc { background: rgba(255,255,255,.25) } + +/* Request list */ +.rlist { flex: 1; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--bdr) transparent } + +.ri { + padding: 10px 14px; + border-bottom: 1px solid var(--bdr); + cursor: pointer; transition: all .15s; position: relative; + margin: 0 6px; + border-radius: var(--radius-sm); +} +.ri:hover { background: var(--bg3) } +.ri.a { + background: linear-gradient(135deg, rgba(59,130,246,.08), rgba(99,102,241,.06)); + border-left: 3px solid var(--blue); + box-shadow: var(--shadow-sm); +} +.ri .si-dot { position: absolute; right: 10px; top: 10px; width: 8px; height: 8px; border-radius: 50%; box-shadow: 0 0 0 2px rgba(255,255,255,.8) } +.si-dot.processing { background: var(--yellow); animation: p 1s infinite } +.si-dot.success { background: var(--green) } +.si-dot.error { background: var(--red) } +.si-dot.intercepted { background: var(--pink) } +.ri-title { + font-size: 12px; color: var(--t1); font-weight: 600; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + margin-bottom: 4px; padding-right: 18px; + line-height: 1.3; +} +.ri-time { font-size: 10px; color: var(--t3); font-family: var(--mono); margin-bottom: 4px } +.r1 { display: flex; align-items: center; justify-content: space-between; margin-bottom: 3px } +.rid { font-family: var(--mono); font-size: 9px; color: var(--t3); display: flex; align-items: center; gap: 5px } +.rfmt { font-size: 8px; font-weight: 700; padding: 2px 6px; border-radius: 4px; text-transform: uppercase; letter-spacing: .3px } +.rfmt.anthropic { background: #f3e8ff; color: var(--purple) } +.rfmt.openai { background: #dcfce7; color: var(--green) } +.rfmt.responses { background: #ffedd5; color: var(--orange) } +.rtm { font-size: 9px; color: var(--t3); font-family: var(--mono) } +.r2 { display: flex; align-items: center; gap: 5px; margin-bottom: 3px } +.rmod { font-size: 10px; color: var(--t2); max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap } +.rch { font-size: 9px; color: var(--t3); font-family: var(--mono) } +.rbd { display: flex; gap: 3px; flex-wrap: wrap } +.bg { font-size: 8px; font-weight: 600; padding: 2px 6px; border-radius: 10px; letter-spacing: .2px } +.bg.str { background: #ecfeff; color: var(--cyan) } +.bg.tls { background: #f3e8ff; color: var(--purple) } +.bg.rtr { background: #fef3c7; color: var(--yellow) } +.bg.cnt { background: #ffedd5; color: var(--orange) } +.bg.err { background: #fef2f2; color: var(--red) } +.bg.icp { background: #fdf2f8; color: var(--pink) } + +.rdbar { height: 3px; border-radius: 2px; margin-top: 5px; background: var(--bg3); overflow: hidden } +.rdfill { height: 100%; border-radius: 2px; transition: width .3s } +.rdfill.f { background: linear-gradient(90deg, #34d399, #059669) } +.rdfill.m { background: linear-gradient(90deg, #fbbf24, #d97706) } +.rdfill.s { background: linear-gradient(90deg, #fb923c, #ea580c) } +.rdfill.vs { background: linear-gradient(90deg, #f87171, #dc2626) } +.rdfill.pr { background: linear-gradient(90deg, #60a5fa, #3b82f6); animation: pp 1.5s infinite } +@keyframes pp { 0%{opacity:1} 50%{opacity:.4} 100%{opacity:1} } + +/* ===== Detail Panel ===== */ +.dp { flex: 1; display: flex; flex-direction: column; overflow: hidden; background: var(--bg0) } +.dh { + padding: 10px 16px; + border-bottom: 1px solid var(--bdr); + display: flex; align-items: center; justify-content: space-between; + background: rgba(255,255,255,.75); + backdrop-filter: blur(8px); + flex-shrink: 0; +} +.dh h2 { font-size: 13px; font-weight: 600; display: flex; align-items: center; gap: 6px; color: var(--t1) } +.dh-acts { display: flex; gap: 10px; align-items: center } +.auto-expand { display: flex; align-items: center; gap: 5px; font-size: 11px; color: var(--t2); cursor: pointer; user-select: none } +.auto-expand input { accent-color: var(--blue); width: 14px; height: 14px } + +/* Tabs */ +.tabs { + display: flex; border-bottom: 1px solid var(--bdr); + background: rgba(255,255,255,.65); backdrop-filter: blur(8px); + flex-shrink: 0; gap: 2px; padding: 0 8px; +} +.tab { + padding: 9px 18px; font-size: 12px; font-weight: 500; color: var(--t2); + cursor: pointer; border-bottom: 2px solid transparent; + transition: all .2s; position: relative; border-radius: 6px 6px 0 0; +} +.tab:hover { color: var(--t1); background: rgba(59,130,246,.04) } +.tab.a { color: var(--blue); border-bottom-color: var(--blue); font-weight: 600 } + +.tab-content { flex: 1; overflow-y: auto; padding: 0; scrollbar-width: thin; scrollbar-color: var(--bdr) transparent } + +/* Summary Card */ +.scard { padding: 12px 16px; background: var(--bg-card); border-bottom: 1px solid var(--bdr); flex-shrink: 0; display: none; box-shadow: var(--shadow-sm) } +.sgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 10px } +.si2 { display: flex; flex-direction: column; gap: 2px; padding: 6px 8px; background: var(--bg2); border-radius: var(--radius-sm); border: 1px solid var(--bdr) } +.si2 .l { font-size: 9px; text-transform: uppercase; color: var(--t3); letter-spacing: .5px; font-weight: 500 } +.si2 .v { font-size: 12px; font-weight: 600; color: var(--t1); font-family: var(--mono) } + +/* Phase Timeline */ +.ptl { padding: 10px 16px; border-bottom: 1px solid var(--bdr); background: var(--bg-card); flex-shrink: 0; display: none } +.ptl-lbl { font-size: 10px; text-transform: uppercase; color: var(--t3); margin-bottom: 6px; letter-spacing: .5px; font-weight: 500 } +.ptl-bar { display: flex; height: 24px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg3); gap: 1px; box-shadow: var(--shadow-sm) inset } +.pseg { + display: flex; align-items: center; justify-content: center; + font-size: 9px; font-weight: 500; color: rgba(255,255,255,.9); + min-width: 3px; position: relative; cursor: default; +} +.pseg:hover { opacity: .85 } +.pseg .tip { + position: absolute; bottom: calc(100% + 4px); left: 50%; transform: translateX(-50%); + background: var(--t1); color: #fff; + padding: 4px 8px; border-radius: var(--radius-sm); + font-size: 10px; white-space: nowrap; + pointer-events: none; opacity: 0; transition: opacity .15s; z-index: 10; + box-shadow: var(--shadow-md); +} +.pseg:hover .tip { opacity: 1 } + +/* ===== Log Entries ===== */ +.llist { padding: 6px } +.le { + display: grid; + grid-template-columns: 68px 50px 40px 62px 76px 1fr; + gap: 6px; padding: 6px 10px; border-radius: var(--radius-sm); + margin-bottom: 2px; font-size: 11px; position: relative; align-items: start; + transition: background .1s; +} +.le:hover { background: rgba(59,130,246,.04) } +.le.ani { animation: fi .25s ease } +@keyframes fi { from{opacity:0;transform:translateY(-3px)} to{opacity:1;transform:translateY(0)} } +.le-sep { border-top: 2px solid var(--bdr2); margin: 10px 6px 4px } +.le-sep-label { + font-size: 10px; color: var(--blue); font-family: var(--mono); + font-weight: 600; padding: 2px 10px 6px; + display: flex; align-items: center; gap: 6px; +} +.le-sep-label::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: var(--blue); opacity: .4 } +.lt { font-family: var(--mono); font-size: 10px; color: var(--t3); white-space: nowrap; padding-top: 2px } +.ld { font-family: var(--mono); font-size: 10px; color: var(--t3); text-align: right; padding-top: 2px } +.ll { + font-size: 9px; font-weight: 600; padding: 2px 0; border-radius: 3px; + text-transform: uppercase; text-align: center; +} +.ll.debug { background: #f1f5f9; color: var(--t3) } +.ll.info { background: #eff6ff; color: var(--blue) } +.ll.warn { background: #fffbeb; color: var(--yellow) } +.ll.error { background: #fef2f2; color: var(--red) } +.ls { font-size: 10px; font-weight: 500; color: var(--purple); padding-top: 2px } +.lp { font-size: 9px; padding: 2px 4px; border-radius: 3px; background: #ecfeff; color: var(--cyan); text-align: center; font-weight: 500 } +.lm { color: var(--t1); word-break: break-word; line-height: 1.4 } +.ldt { color: var(--blue); font-size: 10px; cursor: pointer; margin-top: 3px; display: inline-block; user-select: none; font-weight: 500 } +.ldt:hover { text-decoration: underline } +.ldd { + margin-top: 4px; padding: 8px 10px; + background: var(--bg2); border-radius: var(--radius-sm); + font-family: var(--mono); font-size: 10px; color: var(--t2); + white-space: pre-wrap; word-break: break-all; + max-height: 220px; overflow-y: auto; + border: 1px solid var(--bdr); line-height: 1.5; position: relative; +} +.copy-btn { + position: absolute; top: 6px; right: 6px; + padding: 3px 10px; font-size: 10px; font-weight: 500; + background: var(--bg1); border: 1px solid var(--bdr); + border-radius: var(--radius-sm); color: var(--t2); + cursor: pointer; opacity: 0; transition: all .2s; z-index: 2; + box-shadow: var(--shadow-sm); +} +.ldd:hover .copy-btn, .resp-box:hover .copy-btn { opacity: 1 } +.copy-btn:hover { color: var(--blue); border-color: var(--blue); background: #eff6ff } +.tli { position: absolute; left: 0; top: 0; bottom: 0; width: 3px; border-radius: 0 3px 3px 0 } + +/* ===== Content Sections (Request/Prompts/Response tabs) ===== */ +.content-section { padding: 14px 18px; border-bottom: 1px solid var(--bdr) } +.content-section:last-child { border-bottom: none } +.cs-title { + font-size: 12px; font-weight: 700; color: var(--blue); + text-transform: uppercase; letter-spacing: .5px; + margin-bottom: 10px; display: flex; align-items: center; gap: 8px; +} +.cs-title .cnt { font-size: 10px; font-weight: 400; color: var(--t3); font-family: var(--mono) } +.msg-item { margin-bottom: 8px; border: 1px solid var(--bdr); border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow-sm) } +.msg-header { + padding: 8px 12px; background: var(--bg2); + display: flex; align-items: center; justify-content: space-between; + cursor: pointer; transition: background .15s; +} +.msg-header:hover { background: var(--bg3) } +.msg-role { font-size: 11px; font-weight: 700; text-transform: uppercase; display: flex; align-items: center; gap: 6px } +.msg-role.system { color: var(--pink) } +.msg-role.user { color: var(--blue) } +.msg-role.assistant { color: var(--green) } +.msg-role.tool { color: var(--orange) } +.msg-meta { font-size: 10px; color: var(--t3); font-family: var(--mono) } +.msg-body { + padding: 10px 12px; font-family: var(--mono); font-size: 11px; + color: var(--t2); white-space: pre-wrap; word-break: break-word; + line-height: 1.5; max-height: 400px; overflow-y: auto; background: var(--bg2); +} +.tool-item { + padding: 8px 12px; border: 1px solid var(--bdr); + border-radius: var(--radius-sm); margin-bottom: 5px; + background: var(--bg2); +} +.tool-name { font-family: var(--mono); font-size: 12px; font-weight: 600; color: var(--purple) } +.tool-desc { font-size: 11px; color: var(--t3); margin-top: 3px } +.resp-box { + padding: 12px 14px; background: var(--bg2); + border: 1px solid var(--bdr); border-radius: var(--radius); + font-family: var(--mono); font-size: 11px; color: var(--t2); + white-space: pre-wrap; word-break: break-word; line-height: 1.5; + max-height: 600px; overflow-y: auto; position: relative; + box-shadow: var(--shadow-sm) inset; +} +.resp-box.diff { border-color: var(--yellow); background: #fffbeb } +.retry-item { margin-bottom: 8px; border: 1px solid #fde68a; border-radius: var(--radius); overflow: hidden } +.retry-header { padding: 6px 12px; background: #fffbeb; font-size: 11px; font-weight: 600; color: var(--yellow) } +.retry-body { + padding: 10px 12px; font-family: var(--mono); font-size: 11px; + color: var(--t2); white-space: pre-wrap; max-height: 200px; + overflow-y: auto; background: var(--bg2); +} + +/* JSON syntax highlighting */ +.jk { color: #6366f1 } .js { color: var(--green) } +.jn { color: var(--yellow) } .jb { color: var(--purple) } .jnl { color: var(--t3) } + +/* Empty state */ +.empty { + display: flex; flex-direction: column; align-items: center; + justify-content: center; height: 100%; color: var(--t3); gap: 10px; + padding: 40px; +} +.empty .ic { font-size: 36px; opacity: .25 } +.empty p { font-size: 13px; font-weight: 500 } +.empty .sub { font-size: 11px; opacity: .6 } + +/* Level filter buttons */ +.lvf { display: flex; gap: 3px } +.lvb { + padding: 3px 10px; font-size: 10px; font-weight: 500; + border: 1px solid var(--bdr); border-radius: var(--radius-sm); + background: var(--bg1); color: var(--t2); + cursor: pointer; transition: all .2s; +} +.lvb:hover { border-color: var(--blue); color: var(--blue); background: #eff6ff } +.lvb.a { background: var(--blue); border-color: var(--blue); color: #fff; box-shadow: 0 2px 4px rgba(59,130,246,.2) } + +/* Scrollbar */ +::-webkit-scrollbar { width: 5px } +::-webkit-scrollbar-track { background: transparent } +::-webkit-scrollbar-thumb { background: var(--bdr2); border-radius: 3px } +::-webkit-scrollbar-thumb:hover { background: var(--t3) } + +/* ===== Theme Toggle ===== */ +.theme-toggle { + width: 36px; height: 36px; + background: var(--bg1); border: 1px solid var(--bdr); + border-radius: 50%; cursor: pointer; + display: flex; align-items: center; justify-content: center; + font-size: 16px; transition: all .3s; + box-shadow: var(--shadow-sm); + line-height: 1; +} +.theme-toggle:hover { + border-color: var(--blue); + box-shadow: 0 0 0 3px rgba(59,130,246,.15); + transform: rotate(20deg); +} + +/* ===== Dark Theme ===== */ +[data-theme="dark"] { + --bg0: #0f172a; + --bg1: #1e293b; + --bg2: #1e293b; + --bg3: #334155; + --bg-card: #1e293b; + --bdr: #334155; + --bdr2: #475569; + --t1: #f1f5f9; + --t2: #cbd5e1; + --t3: #64748b; + --blue: #60a5fa; + --cyan: #22d3ee; + --green: #34d399; + --yellow: #fbbf24; + --red: #f87171; + --purple: #a78bfa; + --pink: #f472b6; + --orange: #fb923c; + --shadow-sm: 0 1px 2px rgba(0,0,0,.3); + --shadow: 0 1px 3px rgba(0,0,0,.4), 0 1px 2px rgba(0,0,0,.3); + --shadow-md: 0 4px 6px rgba(0,0,0,.35), 0 2px 4px rgba(0,0,0,.25); + --shadow-lg: 0 10px 15px rgba(0,0,0,.35), 0 4px 6px rgba(0,0,0,.25); +} +[data-theme="dark"] body { + background: linear-gradient(135deg, #0c1222 0%, #0f172a 30%, #1a1333 70%, #0f172a 100%); +} +[data-theme="dark"] .hdr { + background: rgba(15,23,42,.85); + border-bottom-color: rgba(51,65,85,.8); +} +[data-theme="dark"] .side { + background: rgba(15,23,42,.7); +} +[data-theme="dark"] .si { + background: var(--bg1); + box-shadow: none; +} +[data-theme="dark"] .dh { + background: rgba(15,23,42,.8); +} +[data-theme="dark"] .tabs { + background: rgba(15,23,42,.7); +} +[data-theme="dark"] .ri:hover { + background: rgba(51,65,85,.5); +} +[data-theme="dark"] .ri.a { + background: linear-gradient(135deg, rgba(96,165,250,.12), rgba(99,102,241,.08)); +} +[data-theme="dark"] .le:hover { + background: rgba(96,165,250,.06); +} +[data-theme="dark"] .ll.debug { background: #1e293b; color: var(--t3) } +[data-theme="dark"] .ll.info { background: #1e3a5f; color: var(--blue) } +[data-theme="dark"] .ll.warn { background: #422006; color: var(--yellow) } +[data-theme="dark"] .ll.error { background: #450a0a; color: var(--red) } +[data-theme="dark"] .lp { background: #164e63; color: var(--cyan) } +[data-theme="dark"] .rfmt.anthropic { background: #2e1065; color: var(--purple) } +[data-theme="dark"] .rfmt.openai { background: #052e16; color: var(--green) } +[data-theme="dark"] .rfmt.responses { background: #431407; color: var(--orange) } +[data-theme="dark"] .bg.str { background: #164e63; color: var(--cyan) } +[data-theme="dark"] .bg.tls { background: #2e1065; color: var(--purple) } +[data-theme="dark"] .bg.rtr { background: #422006; color: var(--yellow) } +[data-theme="dark"] .bg.cnt { background: #431407; color: var(--orange) } +[data-theme="dark"] .bg.err { background: #450a0a; color: var(--red) } +[data-theme="dark"] .bg.icp { background: #500724; color: var(--pink) } +[data-theme="dark"] .tb:hover { background: #164e63 } +[data-theme="dark"] .fb:hover { background: #1e3a5f } +[data-theme="dark"] .resp-box.diff { border-color: var(--yellow); background: #422006 } +[data-theme="dark"] .retry-header { background: #422006 } +[data-theme="dark"] .msg-header:hover { background: var(--bg3) } +[data-theme="dark"] .copy-btn { background: var(--bg3) } +[data-theme="dark"] .copy-btn:hover { background: #1e3a5f } +[data-theme="dark"] .hdr-btn:hover { background: #450a0a } +[data-theme="dark"] .conn.on { border-color: #065f46 } +[data-theme="dark"] .conn.off { border-color: #7f1d1d } +[data-theme="dark"] .lvb:hover { background: #1e3a5f } diff --git a/public/logs.html b/public/logs.html new file mode 100644 index 0000000..dc707a2 --- /dev/null +++ b/public/logs.html @@ -0,0 +1,80 @@ + + + + + + +Cursor2API - 全链路日志 + + + + +
+
+

Cursor2API 日志

+
+
0请求
+
0
+
0
+
-ms 均耗
+
-ms TTFT
+
+
+ + +
已连接
+
+
+
+
+ +
+ + + + + +
+
+ + + + + +
+
+
📡

等待请求...

+
+
+
+
+

🔍 实时日志流

+
+ +
+ + + + +
+
+
+
+
阶段耗时
+ +
+
+
📋

实时日志将在此显示

发起请求后即可看到全链路日志

+
+
+
+
+
+ + + diff --git a/public/logs.js b/public/logs.js new file mode 100644 index 0000000..465f54d --- /dev/null +++ b/public/logs.js @@ -0,0 +1,370 @@ +// 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.startTimeb.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='
📡

'+(q?'无匹配':'暂无请求')+'

';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+='
'+escH(t.name)+'
'+(t.description?'
'+escH(t.description)+'
':'')+'
'}); + 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+='
'+m.role+' #'+(i+1)+''+fmtN(m.contentLength)+' chars '+(collapsed?'▶ 展开':'▼ 收起')+'
'+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+='
'+m.role+imgs+' #'+(i+1)+''+fmtN(m.contentLength)+' chars '+(collapsed?'▶ 展开':'▼ 收起')+'
'+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+='
第 '+r.attempt+' 次重试 — '+escH(r.reason)+'
'+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+='
续写 #'+r.index+' (去重后 '+fmtN(r.dedupedLength)+' chars)
'+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 - 登录 - - - - -
- -
- - -
- -
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; };