a3216 commited on
Commit
bc29ff1
·
verified ·
1 Parent(s): 8fc24d8

sync from GitHub 1383c6c: feat: 增强Web管理日志功能与界面美化

Browse files

Auto-synced from GitHub commit 1383c6c3e047c5be1ad940609d127f8ec7f88a88

app/admin_html.py CHANGED
@@ -113,6 +113,64 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
113
  .msg-bubble.system { background: rgba(139,143,163,0.15); }
114
  .msg-bubble .role { font-size: 11px; color: var(--muted); margin-bottom: 4px; }
115
  .msg-bubble .thought { font-size: 12px; color: var(--muted); font-style: italic; margin-top: 6px; padding-top: 6px; border-top: 1px dashed var(--border); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  </style>
117
  </head>
118
  <body data-disabled="__DISABLED_ATTR__">
@@ -151,11 +209,11 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
151
  <div class="tab-content active" data-tab="overview">
152
  <div class="card">
153
  <h2>系统概览</h2>
154
- <div class="stats-grid" id="overviewStats"><div class="muted">加载中...</div></div>
155
  </div>
156
  <div class="card">
157
- <h2>最近 24 小时请求趋势</h2>
158
- <div id="overviewTimeline"><div class="muted">加载中...</div></div>
159
  </div>
160
  </div>
161
 
@@ -301,29 +359,35 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
301
  <div class="tab-content" data-tab="requests">
302
  <div class="card">
303
  <h2>请求历史 <span id="reqLogLimit" class="muted" style="font-size:12px;font-weight:normal"></span></h2>
304
- <div class="row" style="margin-bottom:12px;gap:6px">
305
- <input id="reqPath" placeholder="路径模糊匹配" style="flex:1">
306
- <select id="reqMethod">
 
 
 
 
 
307
  <option value="">所有方法</option>
308
  <option>GET</option><option>POST</option><option>PUT</option><option>DELETE</option><option>OPTIONS</option>
309
  </select>
310
- <select id="reqStatus">
311
  <option value="">所有状态</option>
312
  <option value="2xx">2xx 成功</option>
313
  <option value="3xx">3xx 重定向</option>
314
  <option value="4xx">4xx 客户端错误</option>
315
  <option value="5xx">5xx 服务端错误</option>
316
  </select>
317
- <select id="reqOk">
318
  <option value="">全部</option>
319
  <option value="1">仅成功</option>
320
  <option value="0">仅失败</option>
321
  </select>
322
- <input id="reqKeyword" placeholder="关键词(路径/错误)" style="flex:1">
323
- <button onclick="loadRequests(1)">查询</button>
324
- <button class="ghost" onclick="loadRequests(1)">刷新</button>
325
- <button class="ghost" onclick="clearRequests()">清空</button>
326
  </div>
 
327
  <div id="reqList">点查询开始</div>
328
  <div class="row" style="margin-top:12px;justify-content:space-between">
329
  <div class="muted" id="reqPageInfo"></div>
@@ -361,7 +425,7 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
361
  </div>
362
 
363
  <div class="card">
364
- <h2>统计(最近 24 小时)</h2>
365
  <div id="reqStats"><div class="muted">加载中...</div></div>
366
  </div>
367
  </div>
@@ -478,19 +542,57 @@ async function loadOverview() {
478
  api('/admin/api/providers'),
479
  ]);
480
  const u = usage;
 
 
481
  $('overviewStats').innerHTML = [
482
- ['24h 请求', u.total_requests],
483
- ['成功', u.success],
484
- ['失败', u.failed],
485
- ['Token 总量', u.total_tokens],
486
- ['文件数', files.count],
487
- ['会话数', sessions.count],
488
- ['厂商数', providers.count],
489
- ['默认厂商', providers.defaultProviderId || '-'],
490
- ].map(([k,v]) => `<div class="stat-box"><div class="label">${k}</div><div class="value">${v}</div></div>`).join('');
491
- loadTimeline('overviewTimeline', 24, 'hour');
492
  } catch (e) { $('overviewStats').innerHTML = '<div class="muted">' + esc(e.message) + '</div>'; }
493
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
494
  async function loadTimeline(elId, hours, gran) {
495
  try {
496
  const data = await api(`/admin/api/usage/timeline?hours=${hours}&granularity=${gran}`);
@@ -671,6 +773,18 @@ async function delWebhook(id) {
671
 
672
  // ===== 请求历史 =====
673
  let _reqPage = 1, _reqPageSize = 50, _reqTotal = 0;
 
 
 
 
 
 
 
 
 
 
 
 
674
 
675
  async function loadReqLimit() {
676
  try {
@@ -690,7 +804,7 @@ async function saveReqLimit() {
690
  async function loadRequests(page) {
691
  _reqPage = page || 1;
692
  const offset = (_reqPage - 1) * _reqPageSize;
693
- const params = new URLSearchParams({ limit: _reqPageSize, offset });
694
  const path = $('reqPath').value.trim();
695
  const method = $('reqMethod').value;
696
  const status = $('reqStatus').value;
@@ -710,12 +824,12 @@ async function loadRequests(page) {
710
  $('reqList').innerHTML = '<table><tr><th>ID</th><th>时间</th><th>方法</th><th>路径</th><th>状态</th><th>耗时</th><th>Provider/Model</th><th>Key</th><th>错误</th><th>操作</th></tr>' +
711
  items.map(r => {
712
  const sc = r.status_code || 0;
713
- const scClass = sc >= 500 ? 'err' : sc >= 400 ? 'warn' : sc >= 300 ? 'off' : 'ok';
714
  const elapsed = r.elapsed_ms == null ? '-' : (r.elapsed_ms + 'ms');
715
- const errHtml = r.ok ? '<span class="tag ok">成功</span>' : (r.error_code ? '<span class="tag err" title="' + esc(r.error_message||'') + '">' + esc(r.error_code) + '</span>' : '<span class="tag err">失败</span>');
716
  const pm = (r.provider || r.model) ? esc(r.provider||'') + '/' + esc(r.model||'') : '<span class="muted">-</span>';
717
- const stream = r.stream ? ' <span class="tag off">stream</span>' : '';
718
- return '<tr><td>' + r.id + '</td><td>' + fmtTime(r.ts) + '</td><td>' + esc(r.method) + '</td><td class="mono">' + esc(r.path) + (r.query ? '?' + esc(r.query).slice(0,30) : '') + stream + '</td><td><span class="tag ' + scClass + '">' + sc + '</span></td><td>' + elapsed + '</td><td class="mono">' + pm + '</td><td class="mono">' + esc(r.access_key||'-').slice(0,12) + '</td><td>' + errHtml + '</td><td><button class="ghost small" onclick="viewReq(' + r.id + ')">详情</button></td></tr>';
719
  }).join('') + '</table>';
720
  }
721
  const totalPages = Math.max(1, Math.ceil(_reqTotal / _reqPageSize));
@@ -731,43 +845,34 @@ async function viewReq(id) {
731
  const r = d.record;
732
  $('reqDetailId').textContent = id;
733
  $('reqDetailCard').classList.remove('hidden');
734
- const scClass = (r.status_code||0) >= 500 ? 'err' : (r.status_code||0) >= 400 ? 'warn' : 'ok';
735
- let html = '<div class="stats-grid" style="margin-bottom:12px">';
736
- html += '<div class="stat-box"><div class="label">方法</div><div class="value" style="font-size:14px">' + esc(r.method) + '</div></div>';
737
- html += '<div class="stat-box"><div class="label">状态码</div><div class="value" style="font-size:14px"><span class="tag ' + scClass + '">' + r.status_code + '</span></div></div>';
738
- html += '<div class="stat-box"><div class="label">耗时</div><div class="value" style="font-size:14px">' + (r.elapsed_ms == null ? '-' : r.elapsed_ms + 'ms') + '</div></div>';
739
- html += '<div class="stat-box"><div class="label">Provider/Model</div><div class="value" style="font-size:14px">' + esc(r.provider||'-') + ' / ' + esc(r.model||'-') + '</div></div>';
740
- html += '<div class="stat-box"><div class="label">客户端 IP</div><div class="value" style="font-size:14px" class="mono">' + esc(r.client_ip||'-') + '</div></div>';
741
- html += '<div class="stat-box"><div class="label">Access Key</div><div class="value" style="font-size:14px" class="mono">' + esc(r.access_key||'-') + '</div></div>';
742
  html += '</div>';
743
 
744
  if (r.error_code || r.error_message) {
745
- html += '<div class="card" style="background:rgba(248,113,113,0.1);border-color:var(--err);margin-bottom:12px"><strong>错误:</strong> <span class="tag err">' + esc(r.error_code||'') + '</span> ' + esc(r.error_message||'') + '</div>';
746
  }
747
 
748
- html += '<h3 style="margin:12px 0 6px;font-size:14px">完整路径</h3>';
749
- html += '<pre>' + esc(r.method) + ' ' + esc(r.path) + (r.query ? '?' + esc(r.query) : '') + '</pre>';
750
-
751
- html += '<h3 style="margin:12px 0 6px;font-size:14px">请求标头(已脱敏)</h3>';
752
- html += '<pre>' + esc(JSON.stringify(r.request_headers, null, 2)) + '</pre>';
753
 
754
- html += '<h3 style="margin:12px 0 6px;font-size:14px">请求体(已脱敏)</h3>';
755
- html += '<pre>' + esc(typeof r.request_body === 'string' ? r.request_body : JSON.stringify(r.request_body, null, 2)) + '</pre>';
756
-
757
- html += '<h3 style="margin:12px 0 6px;font-size:14px">响应标头</h3>';
758
- html += '<pre>' + esc(JSON.stringify(r.response_headers, null, 2)) + '</pre>';
759
-
760
- html += '<h3 style="margin:12px 0 6px;font-size:14px">响应体</h3>';
761
  const rb = r.response_body;
 
762
  if (rb && typeof rb === 'object' && rb.type === 'streaming') {
763
- html += '<div class="card" style="margin-bottom:8px"><strong>流式响应</strong>:共 ' + rb.total_chunks + ' 个 chunk,' + rb.total_bytes + ' 字节</div>';
764
  html += '<pre>' + esc((rb.sampled_chunks || []).join('\\n--- chunk ---\\n')) + '</pre>';
765
  } else {
766
  html += '<pre>' + esc(typeof rb === 'string' ? rb : JSON.stringify(rb, null, 2)) + '</pre>';
767
  }
768
-
769
- html += '<h3 style="margin:12px 0 6px;font-size:14px">User-Agent</h3>';
770
- html += '<pre>' + esc(r.user_agent || '-') + '</pre>';
771
 
772
  $('reqDetailBody').innerHTML = html;
773
  $('reqDetailCard').scrollIntoView({behavior:'smooth'});
@@ -795,23 +900,29 @@ async function clearRequests() {
795
  }
796
  async function loadReqStats() {
797
  try {
798
- const d = await api('/admin/api/request-log/stats?hours=24');
799
- let html = '<div class="stats-grid" style="margin-bottom:12px">';
800
- html += '<div class="stat-box"><div class="label">24h 总请求</div><div class="value">' + d.total + '</div></div>';
 
 
 
 
801
  for (const [k, v] of Object.entries(d.by_status || {})) {
802
- html += '<div class="stat-box"><div class="label">' + k + '</div><div class="value">' + v + '</div></div>';
 
803
  }
 
804
  html += '</div>';
805
  const bp = d.by_path || [];
806
  if (bp.length) {
807
- html += '<h3 style="margin:12px 0 6px;font-size:14px">Top 路径</h3><table><tr><th>路径</th><th>次数</th><th>平均耗时</th><th>成功</th></tr>';
808
- html += bp.map(p => '<tr><td class="mono">' + esc(p.path) + '</td><td>' + p.count + '</td><td>' + p.avg_ms + 'ms</td><td>' + p.ok + '</td></tr>').join('');
809
  html += '</table>';
810
  }
811
  const be = d.by_error || [];
812
  if (be.length) {
813
  html += '<h3 style="margin:12px 0 6px;font-size:14px">错误码 Top</h3><table><tr><th>错误码</th><th>次数</th></tr>';
814
- html += be.map(e => '<tr><td class="mono"><span class="tag err">' + esc(e.code) + '</span></td><td>' + e.count + '</td></tr>').join('');
815
  html += '</table>';
816
  }
817
  const slow = d.slowest || [];
 
113
  .msg-bubble.system { background: rgba(139,143,163,0.15); }
114
  .msg-bubble .role { font-size: 11px; color: var(--muted); margin-bottom: 4px; }
115
  .msg-bubble .thought { font-size: 12px; color: var(--muted); font-style: italic; margin-top: 6px; padding-top: 6px; border-top: 1px dashed var(--border); }
116
+
117
+ /* ===== 增强样式 ===== */
118
+ tr:hover td { background: rgba(79,140,255,0.06); }
119
+ th { white-space: nowrap; }
120
+ td.mono, .mono { font-family: ui-monospace, "SF Mono", Consolas, monospace; font-size: 12px; word-break: break-all; }
121
+
122
+ /* 分段选择器(请求类别) */
123
+ .seg { display: inline-flex; background: var(--panel2); border: 1px solid var(--border); border-radius: 6px; padding: 2px; gap: 2px; }
124
+ .seg button { background: transparent; border: none; color: var(--muted); padding: 5px 12px; font-size: 12px; border-radius: 4px; }
125
+ .seg button:hover { color: var(--text); }
126
+ .seg button.active { background: var(--accent); color: #fff; }
127
+
128
+ /* KPI 卡片:左侧强调色条 */
129
+ .kpi-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
130
+ .kpi { position: relative; background: var(--panel2); padding: 12px 16px 12px 18px; border-radius: 8px; overflow: hidden; }
131
+ .kpi::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--accent); }
132
+ .kpi.ok::before { background: var(--ok); }
133
+ .kpi.err::before { background: var(--err); }
134
+ .kpi.warn::before { background: var(--warn); }
135
+ .kpi .label { font-size: 12px; color: var(--muted); }
136
+ .kpi .value { font-size: 22px; font-weight: 600; margin-top: 4px; }
137
+ .kpi .sub { font-size: 11px; color: var(--muted); margin-top: 2px; }
138
+
139
+ /* 流量趋势卡片 */
140
+ .traffic-card { background: var(--panel2); border-radius: 8px; padding: 14px 16px; }
141
+ .traffic-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 10px; }
142
+ .traffic-head .big { font-size: 20px; font-weight: 600; }
143
+ .traffic-head .delta { font-size: 12px; }
144
+ .bar-chart { gap: 3px; height: 90px; }
145
+ .bar { background: linear-gradient(180deg, var(--accent2), var(--accent)); min-height: 2px; border-radius: 2px 2px 0 0; transition: height .2s; }
146
+ .bar.fail { background: linear-gradient(180deg, #f87171, #b91c1c); }
147
+ .bar-axis { display: flex; justify-content: space-between; color: var(--muted); font-size: 10px; margin-top: 4px; }
148
+
149
+ /* 成功率环 */
150
+ .ring-wrap { display: flex; align-items: center; gap: 14px; }
151
+ .ring { position: relative; width: 84px; height: 84px; }
152
+ .ring svg { transform: rotate(-90deg); }
153
+ .ring .pct { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 600; }
154
+
155
+ /* 请求列表行:状态药丸、流式标记 */
156
+ .pill { display: inline-block; padding: 1px 7px; border-radius: 10px; font-size: 11px; font-weight: 500; }
157
+ .pill.s2 { background: rgba(74,222,128,0.16); color: var(--ok); }
158
+ .pill.s3 { background: rgba(139,143,163,0.16); color: var(--muted); }
159
+ .pill.s4 { background: rgba(251,191,36,0.16); color: var(--warn); }
160
+ .pill.s5 { background: rgba(248,113,113,0.16); color: var(--err); }
161
+ .pill.stream { background: rgba(106,161,255,0.16); color: var(--accent2); margin-left: 4px; }
162
+
163
+ .filter-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
164
+ .filter-row input, .filter-row select { flex: 1; min-width: 110px; }
165
+ .filter-row .grow { flex: 1; }
166
+ .filter-row .shrink { flex: 0 0 auto; }
167
+ .hint { font-size: 11px; color: var(--muted); margin-top: 6px; }
168
+
169
+ /* 详情区折叠块 */
170
+ details.req-block { background: var(--panel2); border: 1px solid var(--border); border-radius: 6px; margin: 8px 0; }
171
+ details.req-block summary { padding: 8px 12px; cursor: pointer; font-size: 13px; font-weight: 500; }
172
+ details.req-block[open] summary { border-bottom: 1px solid var(--border); }
173
+ details.req-block pre { margin: 0; border: none; border-radius: 0; max-height: 320px; }
174
  </style>
175
  </head>
176
  <body data-disabled="__DISABLED_ATTR__">
 
209
  <div class="tab-content active" data-tab="overview">
210
  <div class="card">
211
  <h2>系统概览</h2>
212
+ <div class="kpi-grid" id="overviewStats"><div class="muted">加载中...</div></div>
213
  </div>
214
  <div class="card">
215
+ <h2>最近 24 小时流量请求分析</h2>
216
+ <div id="overviewTraffic"><div class="muted">加载中...</div></div>
217
  </div>
218
  </div>
219
 
 
359
  <div class="tab-content" data-tab="requests">
360
  <div class="card">
361
  <h2>请求历史 <span id="reqLogLimit" class="muted" style="font-size:12px;font-weight:normal"></span></h2>
362
+ <div class="filter-row">
363
+ <div class="seg shrink" id="reqCategorySeg" role="group" aria-label="请求类别">
364
+ <button class="active" data-cat="business" onclick="setReqCategory('business')">业务请求 /v1/*</button>
365
+ <button data-cat="admin" onclick="setReqCategory('admin')">管理面板 /admin/api/*</button>
366
+ <button data-cat="all" onclick="setReqCategory('all')">全部</button>
367
+ </div>
368
+ <input id="reqPath" class="grow" placeholder="路径模糊匹配">
369
+ <select id="reqMethod" class="shrink">
370
  <option value="">所有方法</option>
371
  <option>GET</option><option>POST</option><option>PUT</option><option>DELETE</option><option>OPTIONS</option>
372
  </select>
373
+ <select id="reqStatus" class="shrink">
374
  <option value="">所有状态</option>
375
  <option value="2xx">2xx 成功</option>
376
  <option value="3xx">3xx 重定向</option>
377
  <option value="4xx">4xx 客户端错误</option>
378
  <option value="5xx">5xx 服务端错误</option>
379
  </select>
380
+ <select id="reqOk" class="shrink">
381
  <option value="">全部</option>
382
  <option value="1">仅成功</option>
383
  <option value="0">仅失败</option>
384
  </select>
385
+ <input id="reqKeyword" class="grow" placeholder="关键词(路径/错误)">
386
+ <button class="shrink" onclick="loadRequests(1)">查询</button>
387
+ <button class="ghost shrink" onclick="loadRequests(1)">刷新</button>
388
+ <button class="ghost shrink" onclick="clearRequests()">清空</button>
389
  </div>
390
+ <div class="hint" id="reqCategoryHint">默认只看业务请求(对话 / 伪流式 / OpenAI 兼容等 <code>/v1/*</code> 接口),管理面板自身的访问不在其中。切换上方分段可查看全部。</div>
391
  <div id="reqList">点查询开始</div>
392
  <div class="row" style="margin-top:12px;justify-content:space-between">
393
  <div class="muted" id="reqPageInfo"></div>
 
425
  </div>
426
 
427
  <div class="card">
428
+ <h2>统计(最近 24 小时)<span id="reqStatsCatLabel" class="muted" style="font-size:12px;font-weight:normal"></span></h2>
429
  <div id="reqStats"><div class="muted">加载中...</div></div>
430
  </div>
431
  </div>
 
542
  api('/admin/api/providers'),
543
  ]);
544
  const u = usage;
545
+ const successRate = u.total_requests ? Math.round(u.success / u.total_requests * 100) : 0;
546
+ const kpi = (cls, label, value, sub) => `<div class="kpi ${cls||''}"><div class="label">${label}</div><div class="value">${value}</div>${sub?`<div class="sub">${sub}</div>`:''}</div>`;
547
  $('overviewStats').innerHTML = [
548
+ kpi('', '24h 请求', u.total_requests, '成功 ' + u.success + ' / 失败 ' + u.failed),
549
+ kpi(successRate>=95?'ok':successRate>=80?'warn':'err', '成功', successRate + '%'),
550
+ kpi('ok', '成功', u.success),
551
+ kpi(u.failed>0?'err':'', '失败', u.failed),
552
+ kpi('', 'Token 总量', u.total_tokens),
553
+ kpi('', '文件数', files.count),
554
+ kpi('', '会话数', sessions.count),
555
+ kpi('', '厂商', providers.count),
556
+ ].join('');
557
+ loadOverviewTraffic();
558
  } catch (e) { $('overviewStats').innerHTML = '<div class="muted">' + esc(e.message) + '</div>'; }
559
  }
560
+ async function loadOverviewTraffic() {
561
+ const el = $('overviewTraffic');
562
+ try {
563
+ const [timeline, usage] = await Promise.all([
564
+ api('/admin/api/usage/timeline?hours=24&granularity=hour'),
565
+ api('/admin/api/usage?hours=24'),
566
+ ]);
567
+ const items = timeline.items || [];
568
+ if (!items.length) { el.innerHTML = '<div class="muted">最近 24 小时无请求</div>'; return; }
569
+ const max = Math.max(...items.map(i => i.requests), 1);
570
+ const total = items.reduce((s,i) => s + (i.requests||0), 0);
571
+ const tokens = items.reduce((s,i) => s + (i.tokens||0), 0);
572
+ const okTotal = usage.success || 0;
573
+ const failTotal = usage.failed || 0;
574
+ const rate = total ? Math.round(okTotal / total * 100) : 0;
575
+
576
+ // 成功率环
577
+ const R = 36, C = 2 * Math.PI * R, off = C * (1 - rate/100);
578
+ const ringCls = rate >= 95 ? 'var(--ok)' : rate >= 80 ? 'var(--warn)' : 'var(--err)';
579
+ let html = '<div class="ring-wrap" style="margin-bottom:14px">';
580
+ html += '<div class="ring"><svg width="84" height="84"><circle cx="42" cy="42" r="' + R + '" fill="none" stroke="var(--border)" stroke-width="8"/><circle cx="42" cy="42" r="' + R + '" fill="none" stroke="' + ringCls + '" stroke-width="8" stroke-linecap="round" stroke-dasharray="' + C + '" stroke-dashoffset="' + off + '"/></svg><div class="pct">' + rate + '%</div></div>';
581
+ html += '<div><div class="muted">24h 请求成功率</div><div style="font-size:13px;margin-top:4px">成功 <span style="color:var(--ok)">' + okTotal + '</span> · 失败 <span style="color:var(--err)">' + failTotal + '</span> · 共 ' + total + ' 次</div><div class="muted" style="margin-top:4px">Token ' + tokens + '</div></div>';
582
+ html += '</div>';
583
+
584
+ // 柱状趋势(timeline 未区分成功/失败,统一着色)
585
+ const bars = items.map(i => {
586
+ const h = Math.max(2, Math.round(i.requests / max * 80));
587
+ const t = new Date(i.bucket_start*1000).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'});
588
+ return `<div class="bar" style="height:${h}px" title="${t} | ${i.requests} 次 | ${i.tokens} tokens"></div>`;
589
+ }).join('');
590
+ const first = new Date(items[0].bucket_start*1000).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'});
591
+ html += `<div class="traffic-card"><div class="traffic-head"><span class="big">${total}</span><span class="delta">次请求 / 峰值 ${max} 每时段</span></div><div class="bar-chart">${bars}</div><div class="bar-axis"><span>${first}</span><span>现在</span></div></div>`;
592
+
593
+ el.innerHTML = html;
594
+ } catch (e) { el.innerHTML = '<div class="muted">' + esc(e.message) + '</div>'; }
595
+ }
596
  async function loadTimeline(elId, hours, gran) {
597
  try {
598
  const data = await api(`/admin/api/usage/timeline?hours=${hours}&granularity=${gran}`);
 
773
 
774
  // ===== 请求历史 =====
775
  let _reqPage = 1, _reqPageSize = 50, _reqTotal = 0;
776
+ let _reqCategory = 'business'; // 默认只看业务请求 /v1/*
777
+
778
+ function setReqCategory(cat) {
779
+ _reqCategory = cat;
780
+ document.querySelectorAll('#reqCategorySeg button').forEach(b =>
781
+ b.classList.toggle('active', b.dataset.cat === cat));
782
+ loadRequests(1);
783
+ loadReqStats();
784
+ }
785
+ function _catLabel(cat) {
786
+ return cat === 'admin' ? '(管理面板 /admin/api/*)' : cat === 'all' ? '(全部)' : '(业务请求 /v1/*)';
787
+ }
788
 
789
  async function loadReqLimit() {
790
  try {
 
804
  async function loadRequests(page) {
805
  _reqPage = page || 1;
806
  const offset = (_reqPage - 1) * _reqPageSize;
807
+ const params = new URLSearchParams({ limit: _reqPageSize, offset, category: _reqCategory });
808
  const path = $('reqPath').value.trim();
809
  const method = $('reqMethod').value;
810
  const status = $('reqStatus').value;
 
824
  $('reqList').innerHTML = '<table><tr><th>ID</th><th>时间</th><th>方法</th><th>路径</th><th>状态</th><th>耗时</th><th>Provider/Model</th><th>Key</th><th>错误</th><th>操作</th></tr>' +
825
  items.map(r => {
826
  const sc = r.status_code || 0;
827
+ const scCls = sc >= 500 ? 's5' : sc >= 400 ? 's4' : sc >= 300 ? 's3' : 's2';
828
  const elapsed = r.elapsed_ms == null ? '-' : (r.elapsed_ms + 'ms');
829
+ const errHtml = r.ok ? '<span class="pill s2">成功</span>' : (r.error_code ? '<span class="pill s5" title="' + esc(r.error_message||'') + '">' + esc(r.error_code) + '</span>' : '<span class="pill s5">失败</span>');
830
  const pm = (r.provider || r.model) ? esc(r.provider||'') + '/' + esc(r.model||'') : '<span class="muted">-</span>';
831
+ const stream = r.stream ? '<span class="pill stream">stream</span>' : '';
832
+ return '<tr><td>' + r.id + '</td><td class="muted">' + fmtTime(r.ts) + '</td><td>' + esc(r.method) + '</td><td class="mono">' + esc(r.path) + (r.query ? '?' + esc(r.query).slice(0,30) : '') + stream + '</td><td><span class="pill ' + scCls + '">' + sc + '</span></td><td>' + elapsed + '</td><td class="mono">' + pm + '</td><td class="mono">' + esc(r.access_key||'-').slice(0,12) + '</td><td>' + errHtml + '</td><td><button class="ghost small" onclick="viewReq(' + r.id + ')">详情</button></td></tr>';
833
  }).join('') + '</table>';
834
  }
835
  const totalPages = Math.max(1, Math.ceil(_reqTotal / _reqPageSize));
 
845
  const r = d.record;
846
  $('reqDetailId').textContent = id;
847
  $('reqDetailCard').classList.remove('hidden');
848
+ const scCls = (r.status_code||0) >= 500 ? 's5' : (r.status_code||0) >= 400 ? 's4' : (r.status_code||0) >= 300 ? 's3' : 's2';
849
+ let html = '<div class="kpi-grid" style="margin-bottom:12px">';
850
+ html += '<div class="kpi"><div class="label">方法</div><div class="value" style="font-size:14px">' + esc(r.method) + '</div></div>';
851
+ html += '<div class="kpi ' + ((r.status_code||0)>=500?'err':(r.status_code||0)>=400?'warn':'ok') + '"><div class="label">状态码</div><div class="value" style="font-size:14px"><span class="pill ' + scCls + '">' + r.status_code + '</span></div></div>';
852
+ html += '<div class="kpi"><div class="label">耗时</div><div class="value" style="font-size:14px">' + (r.elapsed_ms == null ? '-' : r.elapsed_ms + 'ms') + '</div></div>';
853
+ html += '<div class="kpi"><div class="label">Provider/Model</div><div class="value" style="font-size:14px">' + esc(r.provider||'-') + ' / ' + esc(r.model||'-') + '</div></div>';
854
+ html += '<div class="kpi"><div class="label">客户端 IP</div><div class="value mono" style="font-size:14px">' + esc(r.client_ip||'-') + '</div></div>';
855
+ html += '<div class="kpi"><div class="label">Access Key</div><div class="value mono" style="font-size:14px">' + esc(r.access_key||'-') + '</div></div>';
856
  html += '</div>';
857
 
858
  if (r.error_code || r.error_message) {
859
+ html += '<div class="card" style="background:rgba(248,113,113,0.1);border-color:var(--err);margin-bottom:12px"><strong>错误:</strong> <span class="pill s5">' + esc(r.error_code||'') + '</span> ' + esc(r.error_message||'') + '</div>';
860
  }
861
 
862
+ html += '<details class="req-block" open><summary>完整路径</summary><pre>' + esc(r.method) + ' ' + esc(r.path) + (r.query ? '?' + esc(r.query) : '') + '</pre></details>';
863
+ html += '<details class="req-block"><summary>请求标头(已脱敏)</summary><pre>' + esc(JSON.stringify(r.request_headers, null, 2)) + '</pre></details>';
864
+ html += '<details class="req-block"><summary>请求体(已脱敏)</summary><pre>' + esc(typeof r.request_body === 'string' ? r.request_body : JSON.stringify(r.request_body, null, 2)) + '</pre></details>';
865
+ html += '<details class="req-block"><summary>响应标头</summary><pre>' + esc(JSON.stringify(r.response_headers, null, 2)) + '</pre></details>';
 
866
 
 
 
 
 
 
 
 
867
  const rb = r.response_body;
868
+ html += '<details class="req-block"><summary>响应体' + (rb && typeof rb === 'object' && rb.type === 'streaming' ? '(流式,' + rb.total_chunks + ' chunks / ' + rb.total_bytes + ' B)' : '') + '</summary>';
869
  if (rb && typeof rb === 'object' && rb.type === 'streaming') {
 
870
  html += '<pre>' + esc((rb.sampled_chunks || []).join('\\n--- chunk ---\\n')) + '</pre>';
871
  } else {
872
  html += '<pre>' + esc(typeof rb === 'string' ? rb : JSON.stringify(rb, null, 2)) + '</pre>';
873
  }
874
+ html += '</details>';
875
+ html += '<details class="req-block"><summary>User-Agent</summary><pre>' + esc(r.user_agent || '-') + '</pre></details>';
 
876
 
877
  $('reqDetailBody').innerHTML = html;
878
  $('reqDetailCard').scrollIntoView({behavior:'smooth'});
 
900
  }
901
  async function loadReqStats() {
902
  try {
903
+ $('reqStatsCatLabel').textContent = _catLabel(_reqCategory);
904
+ const d = await api('/admin/api/request-log/stats?hours=24&category=' + _reqCategory);
905
+ // 成功率
906
+ const okC = (d.by_status['2xx']||0) + (d.by_status['3xx']||0);
907
+ const rate = d.total ? Math.round(okC / d.total * 100) : 0;
908
+ let html = '<div class="kpi-grid" style="margin-bottom:12px">';
909
+ html += '<div class="kpi"><div class="label">24h 总请求</div><div class="value">' + d.total + '</div><div class="sub">成功 ' + okC + ' / 失败 ' + (d.total - okC) + '</div></div>';
910
  for (const [k, v] of Object.entries(d.by_status || {})) {
911
+ const cls = k.startsWith('5') ? 'err' : k.startsWith('4') ? 'warn' : 'ok';
912
+ html += '<div class="kpi ' + cls + '"><div class="label">' + k + '</div><div class="value">' + v + '</div></div>';
913
  }
914
+ html += '<div class="kpi ' + (rate>=95?'ok':rate>=80?'warn':'err') + '"><div class="label">成功率</div><div class="value">' + rate + '%</div></div>';
915
  html += '</div>';
916
  const bp = d.by_path || [];
917
  if (bp.length) {
918
+ html += '<h3 style="margin:12px 0 6px;font-size:14px">Top 路径</h3><table><tr><th>路径</th><th>次数</th><th>平均耗时</th><th>成功</th><th>成功率</th></tr>';
919
+ html += bp.map(p => '<tr><td class="mono">' + esc(p.path) + '</td><td>' + p.count + '</td><td>' + p.avg_ms + 'ms</td><td>' + p.ok + '</td><td>' + (p.count ? Math.round(p.ok/p.count*100) : 0) + '%</td></tr>').join('');
920
  html += '</table>';
921
  }
922
  const be = d.by_error || [];
923
  if (be.length) {
924
  html += '<h3 style="margin:12px 0 6px;font-size:14px">错误码 Top</h3><table><tr><th>错误码</th><th>次数</th></tr>';
925
+ html += be.map(e => '<tr><td class="mono"><span class="pill s5">' + esc(e.code) + '</span></td><td>' + e.count + '</td></tr>').join('');
926
  html += '</table>';
927
  }
928
  const slow = d.slowest || [];
app/api/pseudo_stream.py CHANGED
@@ -5,7 +5,6 @@
5
  from __future__ import annotations
6
 
7
  import asyncio
8
- import json
9
  from typing import Any, Optional
10
 
11
  from fastapi import APIRouter, Depends, Header, Request
@@ -216,24 +215,22 @@ async def _run_pseudo_session(
216
 
217
 
218
  async def _openai_passthrough_chunks(base_url: str, api_key: str, body: dict):
219
- """把 OpenAI 厂商的原始 SSE 字节流解析为 OpenAI chunk dict。"""
 
 
 
 
 
 
220
  from ..utils.sse import parse_sse_stream
221
 
222
  chunk_iter = await openai_adapter.chat_completions(
223
  base_url=base_url, api_key=api_key, body=body, stream=True
224
  )
225
- async for chunk in chunk_iter:
226
- if isinstance(chunk, bytes):
227
- text = chunk.decode("utf-8", errors="replace")
228
- else:
229
- text = str(chunk)
230
- for line in text.split("\n"):
231
- if not line.startswith("data:"):
232
- continue
233
- payload = line[5:].strip()
234
- if payload == "[DONE]":
235
- return
236
- try:
237
- yield json.loads(payload)
238
- except Exception:
239
- continue
 
5
  from __future__ import annotations
6
 
7
  import asyncio
 
8
  from typing import Any, Optional
9
 
10
  from fastapi import APIRouter, Depends, Header, Request
 
215
 
216
 
217
  async def _openai_passthrough_chunks(base_url: str, api_key: str, body: dict):
218
+ """把 OpenAI 厂商的原始 SSE 字节流解析为 OpenAI chunk dict。
219
+
220
+ 必须用 parse_sse_stream 按 ``\\n\\n`` 帧分隔符跨 chunk 缓冲解析。
221
+ httpx 的 aiter_bytes() 字节块边界与 SSE 帧边界无关,若按每个 chunk
222
+ 独立 split('\\n') 解析,跨块切断的 ``data: {...}`` 行会 json.loads
223
+ 失败被静默丢弃,导致回复少字、换行被破坏。
224
+ """
225
  from ..utils.sse import parse_sse_stream
226
 
227
  chunk_iter = await openai_adapter.chat_completions(
228
  base_url=base_url, api_key=api_key, body=body, stream=True
229
  )
230
+ async for sse in parse_sse_stream(chunk_iter):
231
+ if sse.get("__done__"):
232
+ return
233
+ if sse.get("__raw__") is not None:
234
+ # JSON 帧,消费方只关心 OpenAI chunk 结构,跳过
235
+ continue
236
+ yield sse
 
 
 
 
 
 
 
 
app/api/request_logs.py CHANGED
@@ -34,13 +34,14 @@ async def list_records(
34
  ok: Optional[int] = Query(default=None, ge=0, le=1),
35
  hours: Optional[int] = Query(default=None, ge=1, le=720),
36
  keyword: Optional[str] = Query(default=None),
 
37
  ):
38
  result = request_log_store.list_records(
39
  limit=limit, offset=offset,
40
  path=path, method=method, status=status,
41
  access_key=access_key,
42
  ok=bool(ok) if ok is not None else None,
43
- hours=hours, keyword=keyword,
44
  )
45
  return ok_with_cors(result)
46
 
@@ -49,8 +50,9 @@ async def list_records(
49
  async def stats(
50
  _admin: str = Depends(_require_admin),
51
  hours: int = Query(default=24, ge=1, le=720),
 
52
  ):
53
- return ok_with_cors(request_log_store.stats(hours=hours))
54
 
55
 
56
  @router.get("/settings")
 
34
  ok: Optional[int] = Query(default=None, ge=0, le=1),
35
  hours: Optional[int] = Query(default=None, ge=1, le=720),
36
  keyword: Optional[str] = Query(default=None),
37
+ category: Optional[str] = Query(default="business"),
38
  ):
39
  result = request_log_store.list_records(
40
  limit=limit, offset=offset,
41
  path=path, method=method, status=status,
42
  access_key=access_key,
43
  ok=bool(ok) if ok is not None else None,
44
+ hours=hours, keyword=keyword, category=category,
45
  )
46
  return ok_with_cors(result)
47
 
 
50
  async def stats(
51
  _admin: str = Depends(_require_admin),
52
  hours: int = Query(default=24, ge=1, le=720),
53
+ category: Optional[str] = Query(default="business"),
54
  ):
55
+ return ok_with_cors(request_log_store.stats(hours=hours, category=category))
56
 
57
 
58
  @router.get("/settings")
app/api/xtc.py CHANGED
@@ -408,29 +408,21 @@ async def _simple_stream_gen(
408
  yield sse_data(event)
409
  else:
410
  # OpenAI 厂商:直接读原始 SSE
 
 
 
411
  raw_iter = await openai_adapter.chat_completions(
412
  base_url=provider.base_url, api_key=api_key, body=upstream_body, stream=True
413
  )
414
- async for raw in raw_iter:
415
- if isinstance(raw, bytes):
416
- text = raw.decode("utf-8", errors="replace")
417
- else:
418
- text = str(raw)
419
- # 解析 data: 行
420
- for line in text.split("\n"):
421
- if not line.startswith("data:"):
422
- continue
423
- payload = line[5:].strip()
424
- if payload == "[DONE]":
425
- yield sse_data({"type": "done", "delta": "", "finish_reason": "stop"})
426
- return
427
- try:
428
- ch = json.loads(payload)
429
- except Exception:
430
- continue
431
- event = _simple_from_openai_chunk(ch)
432
- if event:
433
- yield sse_data(event)
434
  yield sse_data({"type": "done", "delta": "", "finish_reason": "stop"})
435
  except HttpError as e:
436
  yield sse_data(
 
408
  yield sse_data(event)
409
  else:
410
  # OpenAI 厂商:直接读原始 SSE
411
+ # 必须用 parse_sse_stream 跨 chunk 缓冲解析,否则跨块切断的
412
+ # data: 行会 json.loads 失败被丢弃,导致回复少字、换行被破坏。
413
+ from ..utils.sse import parse_sse_stream
414
  raw_iter = await openai_adapter.chat_completions(
415
  base_url=provider.base_url, api_key=api_key, body=upstream_body, stream=True
416
  )
417
+ async for sse in parse_sse_stream(raw_iter):
418
+ if sse.get("__done__"):
419
+ yield sse_data({"type": "done", "delta": "", "finish_reason": "stop"})
420
+ return
421
+ if sse.get("__raw__") is not None:
422
+ continue
423
+ event = _simple_from_openai_chunk(sse)
424
+ if event:
425
+ yield sse_data(event)
 
 
 
 
 
 
 
 
 
 
 
426
  yield sse_data({"type": "done", "delta": "", "finish_reason": "stop"})
427
  except HttpError as e:
428
  yield sse_data(
app/services/request_log_store.py CHANGED
@@ -169,6 +169,22 @@ def insert(record: dict) -> Optional[int]:
169
 
170
  # ===== 查询 =====
171
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  def list_records(
173
  *,
174
  limit: int = 50,
@@ -180,10 +196,15 @@ def list_records(
180
  ok: Optional[bool] = None,
181
  hours: Optional[int] = None,
182
  keyword: Optional[str] = None,
 
183
  ) -> dict:
184
  """列表查询:返回摘要(不含 request_body / response_body / headers)。"""
185
  sql = "SELECT id, ts, method, path, query, status_code, elapsed_ms, access_key, client_ip, user_agent, provider, model, stream, ok, error_code, error_message FROM request_log WHERE 1=1"
186
  args: list = []
 
 
 
 
187
  if path:
188
  sql += " AND path LIKE ?"
189
  args.append(f"%{path}%")
@@ -293,35 +314,39 @@ def _row_to_detail(row) -> dict:
293
 
294
  # ===== 统计 =====
295
 
296
- def stats(hours: int = 24) -> dict:
297
- """请求日志统计。"""
298
  since = int(time.time()) - hours * 3600
 
 
 
299
  with get_conn() as conn:
300
  total = conn.execute(
301
- "SELECT COUNT(*) AS c FROM request_log WHERE ts >= ?", (since,)
 
302
  ).fetchone()
303
  by_status = conn.execute(
304
  "SELECT CAST(status_code/100 AS INTEGER) AS bucket, COUNT(*) AS c "
305
- "FROM request_log WHERE ts >= ? GROUP BY bucket ORDER BY bucket",
306
- (since,),
307
  ).fetchall()
308
  by_path = conn.execute(
309
  "SELECT path, COUNT(*) AS c, AVG(elapsed_ms) AS avg_ms, "
310
  "SUM(CASE WHEN ok=1 THEN 1 ELSE 0 END) AS ok_c "
311
- "FROM request_log WHERE ts >= ? GROUP BY path ORDER BY c DESC LIMIT 20",
312
- (since,),
313
  ).fetchall()
314
  by_error = conn.execute(
315
  "SELECT error_code, COUNT(*) AS c FROM request_log "
316
- "WHERE ts >= ? AND ok = 0 AND error_code IS NOT NULL "
317
  "GROUP BY error_code ORDER BY c DESC LIMIT 20",
318
- (since,),
319
  ).fetchall()
320
  slow = conn.execute(
321
  "SELECT id, ts, method, path, elapsed_ms, status_code, ok "
322
- "FROM request_log WHERE ts >= ? AND elapsed_ms IS NOT NULL "
323
  "ORDER BY elapsed_ms DESC LIMIT 10",
324
- (since,),
325
  ).fetchall()
326
  return {
327
  "hours": hours,
 
169
 
170
  # ===== 查询 =====
171
 
172
+ def _category_filter(category: Optional[str]) -> tuple[str, list]:
173
+ """返回 (SQL 片段, 参数) 用于按请求类别过滤。
174
+
175
+ - ``business``(默认):只看业务请求 ``/v1/*``(对话/伪流式/OpenAI 兼容等)
176
+ - ``admin``:只看管理面板访问 ``/admin/api/*``
177
+ - ``all`` 或其它:不过滤
178
+ """
179
+ cat = str(category or "business").lower()
180
+ if cat == "admin":
181
+ return " AND path LIKE '/admin/api/%'", []
182
+ if cat == "all":
183
+ return "", []
184
+ # 默认 business:排除管理面板访问,只保留 /v1/*
185
+ return " AND path LIKE '/v1/%'", []
186
+
187
+
188
  def list_records(
189
  *,
190
  limit: int = 50,
 
196
  ok: Optional[bool] = None,
197
  hours: Optional[int] = None,
198
  keyword: Optional[str] = None,
199
+ category: Optional[str] = None,
200
  ) -> dict:
201
  """列表查询:返回摘要(不含 request_body / response_body / headers)。"""
202
  sql = "SELECT id, ts, method, path, query, status_code, elapsed_ms, access_key, client_ip, user_agent, provider, model, stream, ok, error_code, error_message FROM request_log WHERE 1=1"
203
  args: list = []
204
+ cat_sql, cat_args = _category_filter(category)
205
+ if cat_sql:
206
+ sql += cat_sql
207
+ args += cat_args
208
  if path:
209
  sql += " AND path LIKE ?"
210
  args.append(f"%{path}%")
 
314
 
315
  # ===== 统计 =====
316
 
317
+ def stats(hours: int = 24, category: Optional[str] = None) -> dict:
318
+ """请求日志统计。``category`` 语义同 :func:`list_records`。"""
319
  since = int(time.time()) - hours * 3600
320
+ cat_sql, cat_args = _category_filter(category)
321
+ base_cond = " WHERE ts >= ?"
322
+ base_cond += cat_sql
323
  with get_conn() as conn:
324
  total = conn.execute(
325
+ "SELECT COUNT(*) AS c FROM request_log" + base_cond,
326
+ [since] + cat_args,
327
  ).fetchone()
328
  by_status = conn.execute(
329
  "SELECT CAST(status_code/100 AS INTEGER) AS bucket, COUNT(*) AS c "
330
+ "FROM request_log" + base_cond + " GROUP BY bucket ORDER BY bucket",
331
+ [since] + cat_args,
332
  ).fetchall()
333
  by_path = conn.execute(
334
  "SELECT path, COUNT(*) AS c, AVG(elapsed_ms) AS avg_ms, "
335
  "SUM(CASE WHEN ok=1 THEN 1 ELSE 0 END) AS ok_c "
336
+ "FROM request_log" + base_cond + " GROUP BY path ORDER BY c DESC LIMIT 20",
337
+ [since] + cat_args,
338
  ).fetchall()
339
  by_error = conn.execute(
340
  "SELECT error_code, COUNT(*) AS c FROM request_log "
341
+ + base_cond + " AND ok = 0 AND error_code IS NOT NULL "
342
  "GROUP BY error_code ORDER BY c DESC LIMIT 20",
343
+ [since] + cat_args,
344
  ).fetchall()
345
  slow = conn.execute(
346
  "SELECT id, ts, method, path, elapsed_ms, status_code, ok "
347
+ "FROM request_log" + base_cond + " AND elapsed_ms IS NOT NULL "
348
  "ORDER BY elapsed_ms DESC LIMIT 10",
349
+ [since] + cat_args,
350
  ).fetchall()
351
  return {
352
  "hours": hours,
app/utils/sse.py CHANGED
@@ -1,6 +1,7 @@
1
  """SSE 流式工具。"""
2
  from __future__ import annotations
3
 
 
4
  import json
5
  from typing import Any, AsyncIterator, Iterable, Optional
6
 
@@ -18,11 +19,22 @@ def sse_event(event: str, obj: Any) -> str:
18
 
19
 
20
  async def parse_sse_stream(byte_stream: AsyncIterator[bytes]) -> AsyncIterator[dict]:
21
- """从字节流解析 SSE,yield 每个 data JSON。"""
 
 
 
 
 
 
 
 
 
 
 
22
  buffer = ""
23
  async for chunk in byte_stream:
24
  if isinstance(chunk, (bytes, bytearray)):
25
- text = chunk.decode("utf-8", errors="replace")
26
  else:
27
  text = str(chunk)
28
  buffer += text
@@ -43,3 +55,21 @@ async def parse_sse_stream(byte_stream: AsyncIterator[bytes]) -> AsyncIterator[d
43
  yield json.loads(data_str)
44
  except json.JSONDecodeError:
45
  yield {"__raw__": data_str}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """SSE 流式工具。"""
2
  from __future__ import annotations
3
 
4
+ import codecs
5
  import json
6
  from typing import Any, AsyncIterator, Iterable, Optional
7
 
 
19
 
20
 
21
  async def parse_sse_stream(byte_stream: AsyncIterator[bytes]) -> AsyncIterator[dict]:
22
+ """从字节流解析 SSE,yield 每个 data JSON。
23
+
24
+ 两个关键点(否则中文/长回复会丢字、换行被破坏):
25
+
26
+ 1. 用增量 UTF-8 解码器(codecs.getincrementaldecoder)跨 chunk 组装多字节
27
+ 字符。若每个 chunk 单独 decode(errors="replace"),被 TCP 分段切断的中文
28
+ 字符的前半字节会被替换成 ``?``,造成乱码/丢字。
29
+ 2. 按完整 SSE 帧(``\\n\\n`` 分隔)缓冲后再解析。httpx 的 aiter_bytes()
30
+ 字节块边界与 SSE 帧边界无关,若按每个 chunk 独立 split('\\n') 解析,
31
+ 跨块切断的 ``data: {...}`` 行会 json.loads 失败被静默丢弃。
32
+ """
33
+ decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
34
  buffer = ""
35
  async for chunk in byte_stream:
36
  if isinstance(chunk, (bytes, bytearray)):
37
+ text = decoder.decode(bytes(chunk))
38
  else:
39
  text = str(chunk)
40
  buffer += text
 
55
  yield json.loads(data_str)
56
  except json.JSONDecodeError:
57
  yield {"__raw__": data_str}
58
+ # 收尾:flush 增量解码器残余字节,并尝试解析缓冲区里最后一帧
59
+ tail = decoder.decode(b"", final=True)
60
+ if tail:
61
+ buffer += tail
62
+ if buffer.strip():
63
+ data_lines = []
64
+ for line in buffer.split("\n"):
65
+ if line.startswith("data:"):
66
+ data_lines.append(line[5:].strip())
67
+ if data_lines:
68
+ data_str = "\n".join(data_lines)
69
+ if data_str == "[DONE]":
70
+ yield {"__done__": True}
71
+ return
72
+ try:
73
+ yield json.loads(data_str)
74
+ except json.JSONDecodeError:
75
+ yield {"__raw__": data_str}