Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,18 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`.

### English

- Keep Trae pasted-callback errors as JSON instead of Cloudflare HTML, and keep Trae quota on the account card after refresh
- Treat Trae Solo `1005` / `4008` as quota, fail stream requests before OpenAI chunks when the first Solo event is an error, and send Trae's native catalog spelling (for example `DeepSeek-V4-Flash`)
- Show provider on Logs, Overview account pool, and account-page totals, and add Overview request share by provider family
- Replace the Overview traffic bars with a GSAP-drawn area/line chart, including success/error fill, axes, and hover readout

### 中文

- Trae 粘贴回调失败保持 JSON,不再被 Cloudflare HTML 盖住;刷新后 Trae 额度仍留在账号卡片上
- Trae Solo `1005` / `4008` 按套餐配额处理;流式若开头就是 Solo 错误则先失败再写 chunk;请求使用目录里的原始模型名(如 `DeepSeek-V4-Flash`)
- Logs、概览账号池和账号页统计显示供应商,概览增加按供应商家族的请求占比
- 概览流量图改为 GSAP 绘制的面积折线,带成功/失败分层、坐标轴和悬停读数

## 0.2.17 - 2026-08-29

### English
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ export async function api<T = any>(path: string, opts: RequestInit = {}, keyOver
data = text
}
if (!res.ok) {
const msg = data?.error?.message || data?.message || text || res.statusText
const raw = typeof data === 'string' ? data : text
const looksHTML = /<!doctype html|<html[\s>]/i.test(raw)
const msg = data?.error?.message || data?.message || (looksHTML ? `${res.status} ${res.statusText}` : (raw || res.statusText))
throw new ApiError(msg, res.status, data?.error?.code)
}
return data as T
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/api/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type RequestLog = {
requested_model: string
mapped_model?: string
account_id?: string
provider?: string
prompt_tokens?: number | null
completion_tokens?: number | null
cache_read_tokens?: number | null
Expand Down Expand Up @@ -123,6 +124,7 @@ export type RequestStats = {
errors: RequestStatsBucket[]
models: RequestStatsNamed[]
accounts: RequestStatsNamed[]
providers: RequestStatsNamed[]
series: RequestStatsPoint[]
}

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/AddAccountModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -293,8 +293,8 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
try {
setPhase('busy')
const id = await ensureAccount()
await completeLoginCallback(id, pasted)
stopPolling()
await completeLoginCallback(id, pasted)
setPhase('done')
setMessage(t('wizardAccountReady'))
onAdded()
Expand Down
189 changes: 156 additions & 33 deletions frontend/src/components/overview/TrafficChart.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { useLayoutEffect, useRef } from 'react'
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import gsap from 'gsap'
import type { RequestStatsPoint } from '@/api/logs'
import { formatCompact } from '@/lib/format'

const WIDTH = 640
const HEIGHT = 168
const PAD = { top: 12, right: 8, bottom: 24, left: 36 }

function hourLabel(value: string, daily: boolean, lang: 'en' | 'zh') {
const date = new Date(value)
Expand All @@ -10,77 +15,195 @@ function hourLabel(value: string, daily: boolean, lang: 'en' | 'zh') {
: { hour: '2-digit', minute: '2-digit', hour12: false }).format(date)
}

function niceMax(peak: number) {
if (peak <= 1) return 1
const exp = Math.floor(Math.log10(peak))
const base = 10 ** exp
const n = peak / base
const nice = n <= 1 ? 1 : n <= 2 ? 2 : n <= 5 ? 5 : 10
return nice * base
}

function linePath(xs: number[], ys: number[]) {
if (!xs.length) return ''
return xs.map((x, i) => `${i ? 'L' : 'M'}${x.toFixed(2)} ${ys[i].toFixed(2)}`).join(' ')
}

function areaPath(xs: number[], ys: number[], baseline: number) {
if (!xs.length) return ''
return `${linePath(xs, ys)} L${xs[xs.length - 1].toFixed(2)} ${baseline.toFixed(2)} L${xs[0].toFixed(2)} ${baseline.toFixed(2)} Z`
}

export function TrafficChart({
series,
lang,
emptyLabel,
okLabel,
errorLabel,
}: {
series: RequestStatsPoint[]
lang: 'en' | 'zh'
emptyLabel: string
okLabel: string
errorLabel: string
}) {
const rootRef = useRef<HTMLDivElement>(null)
const peak = Math.max(1, ...series.map((point) => point.requests))
const [active, setActive] = useState<number | null>(null)
const first = series[0]
const last = series[series.length - 1]
const span = first && last ? Date.parse(last.at) - Date.parse(first.at) : 0
const daily = span > 48 * 60 * 60 * 1000
const innerW = WIDTH - PAD.left - PAD.right
const innerH = HEIGHT - PAD.top - PAD.bottom
const peak = Math.max(0, ...series.map((point) => point.requests))
const yMax = niceMax(peak)
const ticks = [0, 0.5, 1].map((part) => Math.round(yMax * part))
const hasTraffic = series.some((point) => point.requests > 0)

const geometry = useMemo(() => {
const count = Math.max(1, series.length)
const step = count === 1 ? 0 : innerW / (count - 1)
const xs = series.map((_, index) => PAD.left + index * step)
const yFor = (value: number) => PAD.top + innerH - (value / Math.max(1, yMax)) * innerH
const totalYs = series.map((point) => yFor(point.requests))
const okYs = series.map((point) => yFor(Math.max(0, point.requests - point.error)))
const baseline = yFor(0)
return {
xs,
totalYs,
okYs,
baseline,
totalLine: linePath(xs, totalYs),
okArea: areaPath(xs, okYs, baseline),
errorArea: areaPath(xs, totalYs, baseline),
}
}, [innerH, innerW, series, yMax])

useLayoutEffect(() => {
const root = rootRef.current
if (!root) return
if (!root || !hasTraffic) return
const context = gsap.context(() => {
const media = gsap.matchMedia()
media.add('(prefers-reduced-motion: reduce)', () => {
gsap.set('[data-bar]', { clearProps: 'transform' })
gsap.set('[data-traffic-draw]', { clearProps: 'strokeDashoffset,strokeDasharray' })
gsap.set('[data-traffic-fill]', { clearProps: 'opacity,transform' })
gsap.set('[data-traffic-dot]', { clearProps: 'transform,opacity' })
})
media.add('(prefers-reduced-motion: no-preference)', () => {
const bars = gsap.utils.toArray<HTMLElement>('[data-bar]')
if (!bars.length) return
gsap.fromTo(bars, { scaleY: 0 }, {
scaleY: 1,
duration: 0.42,
const line = root.querySelector<SVGPathElement>('[data-traffic-draw]')
const fills = gsap.utils.toArray<SVGPathElement>('[data-traffic-fill]')
const dots = gsap.utils.toArray<SVGCircleElement>('[data-traffic-dot]')
if (line) {
const length = line.getTotalLength()
gsap.fromTo(line, { strokeDasharray: length, strokeDashoffset: length }, {
strokeDashoffset: 0,
duration: 0.7,
ease: 'power2.out',
})
}
gsap.fromTo(fills, { autoAlpha: 0 }, {
autoAlpha: 1,
duration: 0.45,
ease: 'power2.out',
stagger: { amount: Math.min(0.45, bars.length * 0.012) },
transformOrigin: '50% 100%',
stagger: 0.06,
})
if (dots.length) {
gsap.fromTo(dots, { scale: 0, transformOrigin: '50% 50%' }, {
scale: 1,
duration: 0.28,
ease: 'back.out(1.6)',
stagger: { amount: Math.min(0.28, dots.length * 0.02) },
delay: 0.28,
})
}
})
}, root)
return () => context.revert()
}, [series])
}, [geometry.totalLine, hasTraffic])

if (!series.length || series.every((point) => point.requests === 0)) {
if (!series.length || !hasTraffic) {
return (
<div className="grid min-h-36 place-items-center rounded-lg border border-dashed border-[var(--app-line-strong)] px-4 text-sm text-[var(--app-faint)]">
<div className="grid min-h-40 place-items-center rounded-lg border border-dashed border-[var(--app-line-strong)] px-4 text-sm text-[var(--app-faint)]">
{emptyLabel}
</div>
)
}

const hover = active == null ? null : series[active]
const hoverX = active == null ? 0 : geometry.xs[active]
const hoverY = active == null ? 0 : geometry.totalYs[active]

return (
<div ref={rootRef} className="min-w-0">
<div className="flex h-36 items-end gap-px">
{series.map((point) => {
const height = Math.max(point.requests ? 8 : 2, Math.round((point.requests / peak) * 100))
const failed = point.requests > 0 && point.error / point.requests >= 0.5
<svg
viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
className="h-40 w-full overflow-visible"
role="img"
aria-label={emptyLabel}
onMouseLeave={() => setActive(null)}
>
{ticks.map((tick) => {
const y = PAD.top + innerH - (tick / Math.max(1, yMax)) * innerH
return (
<div
key={point.at}
className="group relative flex min-w-0 flex-1 items-end"
title={`${hourLabel(point.at, daily, lang)} · ${point.requests}`}
>
<span
data-bar
className={`block w-full origin-bottom rounded-[2px] ${failed ? 'bg-[var(--app-danger)]' : 'bg-[var(--app-ok)]'} ${point.requests ? 'opacity-90' : 'bg-[var(--app-line-strong)] opacity-50'}`}
style={{ height: `${height}%` }}
/>
</div>
<g key={tick}>
<line x1={PAD.left} x2={WIDTH - PAD.right} y1={y} y2={y} stroke="var(--app-line)" strokeWidth="1" />
<text x={PAD.left - 8} y={y + 3} textAnchor="end" className="fill-[var(--app-faint)]" fontSize="10" fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace">
{formatCompact(tick)}
</text>
</g>
)
})}
</div>
<div className="mt-2 flex items-center justify-between text-[10px] text-[var(--app-faint)]">
<span className="mono">{first ? hourLabel(first.at, daily, lang) : ''}</span>
<span className="mono">{last ? hourLabel(last.at, daily, lang) : ''}</span>
<path data-traffic-fill d={geometry.errorArea} fill="color-mix(in srgb, var(--app-danger) 22%, transparent)" />
<path data-traffic-fill d={geometry.okArea} fill="color-mix(in srgb, var(--app-ok) 18%, transparent)" />
<path data-traffic-draw d={geometry.totalLine} fill="none" stroke="var(--app-ok)" strokeWidth="1.75" strokeLinejoin="round" strokeLinecap="round" />
{series.map((point, index) => (
<circle
key={point.at}
data-traffic-dot
cx={geometry.xs[index]}
cy={geometry.totalYs[index]}
r={series.length > 36 ? 1.4 : 2.1}
fill={point.error && point.requests && point.error / point.requests >= 0.5 ? 'var(--app-danger)' : 'var(--app-ok)'}
/>
))}
{series.map((point, index) => {
const prev = index === 0 ? geometry.xs[0] : (geometry.xs[index - 1] + geometry.xs[index]) / 2
const next = index === series.length - 1 ? geometry.xs[index] : (geometry.xs[index] + geometry.xs[index + 1]) / 2
return (
<rect
key={`${point.at}-hit`}
x={prev}
y={PAD.top}
width={Math.max(4, next - prev)}
height={innerH}
fill="transparent"
onMouseEnter={() => setActive(index)}
/>
)
})}
{hover ? (
<g>
<line x1={hoverX} x2={hoverX} y1={PAD.top} y2={PAD.top + innerH} stroke="var(--app-line-strong)" strokeWidth="1" strokeDasharray="3 3" />
<circle cx={hoverX} cy={hoverY} r="4" fill="var(--app-surface)" stroke="var(--app-ok)" strokeWidth="1.5" />
</g>
) : null}
<text x={PAD.left} y={HEIGHT - 6} className="fill-[var(--app-faint)]" fontSize="10" fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace">
{first ? hourLabel(first.at, daily, lang) : ''}
</text>
<text x={WIDTH - PAD.right} y={HEIGHT - 6} textAnchor="end" className="fill-[var(--app-faint)]" fontSize="10" fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace">
{last ? hourLabel(last.at, daily, lang) : ''}
</text>
</svg>
<div className="mt-2 flex min-h-5 items-center justify-between gap-3 text-[10px] text-[var(--app-faint)]">
<div className="flex items-center gap-3">
<span className="inline-flex items-center gap-1.5"><span className="size-1.5 rounded-full bg-[var(--app-ok)]" />{okLabel}</span>
<span className="inline-flex items-center gap-1.5"><span className="size-1.5 rounded-full bg-[var(--app-danger)]" />{errorLabel}</span>
</div>
<span className="mono">
{hover
? `${hourLabel(hover.at, daily, lang)} · ${hover.requests} · ${okLabel} ${hover.ok} · ${errorLabel} ${hover.error}`
: `${formatCompact(peak)} peak`}
</span>
</div>
</div>
)
Expand Down
9 changes: 5 additions & 4 deletions frontend/src/components/ui/PageSkeletons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function OverviewPageSkeleton() {
<section className="flex items-end justify-between gap-4 border-b border-[var(--app-line)] pb-4"><div className="space-y-3"><SkeletonBlock className="h-8 w-40" /><SkeletonBlock className="h-4 w-80 max-w-[70vw]" /></div><div className="hidden space-y-2 sm:block"><SkeletonBlock className="ml-auto h-8 w-40" /><SkeletonBlock className="ml-auto h-4 w-20" /></div></section>
<section className="grid overflow-hidden rounded-lg border border-[var(--app-line)] bg-[var(--app-surface)] sm:grid-cols-2 xl:grid-cols-4">{Array.from({ length: 4 }, (_, index) => <div key={index} className="min-h-32 space-y-5 border-[var(--app-line)] p-5 first:border-0 sm:border-l sm:first:border-0"><SkeletonBlock className="h-4 w-24" /><SkeletonBlock className="h-8 w-28" /><SkeletonBlock className="h-3 w-36" /></div>)}</section>
<section className="grid gap-5 xl:grid-cols-[minmax(0,1.35fr)_minmax(320px,.75fr)]"><div className="app-panel-flat overflow-hidden rounded-lg p-5"><SkeletonBlock className="h-5 w-24" /><SkeletonBlock className="mt-2 h-3 w-48" /><SkeletonBlock className="mt-6 h-36 w-full" /><div className="mt-5 grid grid-cols-4 gap-3">{Array.from({ length: 4 }, (_, index) => <SkeletonBlock key={index} className="h-10 w-full" />)}</div></div><div className="app-panel-flat overflow-hidden rounded-lg p-5"><SkeletonBlock className="h-5 w-28" /><SkeletonBlock className="mt-2 h-3 w-40" /><div className="mt-6 space-y-4">{Array.from({ length: 5 }, (_, index) => <SkeletonBlock key={index} className="h-8 w-full" />)}</div></div></section>
<section className="grid gap-5 xl:grid-cols-3">{Array.from({ length: 3 }, (_, index) => <div key={index} className="app-panel-flat overflow-hidden rounded-lg p-5"><SkeletonBlock className="h-5 w-28" /><div className="mt-6 space-y-4">{Array.from({ length: 4 }, (_, item) => <SkeletonBlock key={item} className="h-8 w-full" />)}</div></div>)}</section>
<section className="grid gap-5 xl:grid-cols-2 2xl:grid-cols-4">{Array.from({ length: 4 }, (_, index) => <div key={index} className="app-panel-flat overflow-hidden rounded-lg p-5"><SkeletonBlock className="h-5 w-28" /><div className="mt-6 space-y-4">{Array.from({ length: 4 }, (_, item) => <SkeletonBlock key={item} className="h-8 w-full" />)}</div></div>)}</section>
</div>
)
}
Expand Down Expand Up @@ -84,15 +84,16 @@ export function AccessPageSkeleton() {
export function LogsRequestListSkeleton() {
return (
<div aria-label="Loading logs">
<div className="hidden grid-cols-8 gap-4 border-b border-[var(--app-line)] px-5 py-3 md:grid">
{Array.from({ length: 8 }, (_, index) => <SkeletonBlock key={index} className="h-3 w-16" />)}
<div className="hidden grid-cols-9 gap-4 border-b border-[var(--app-line)] px-5 py-3 md:grid">
{Array.from({ length: 9 }, (_, index) => <SkeletonBlock key={index} className="h-3 w-16" />)}
</div>
{Array.from({ length: 8 }, (_, index) => (
<div key={index} className="grid grid-cols-2 items-center gap-4 border-b border-[var(--app-line)] px-5 py-3.5 last:border-0 md:grid-cols-8">
<div key={index} className="grid grid-cols-2 items-center gap-4 border-b border-[var(--app-line)] px-5 py-3.5 last:border-0 md:grid-cols-9">
<SkeletonBlock className="h-4 w-28" />
<SkeletonBlock className="h-4 w-24" />
<SkeletonBlock className="hidden h-4 w-20 md:block" />
<SkeletonBlock className="hidden h-4 w-16 md:block" />
<SkeletonBlock className="hidden h-4 w-16 md:block" />
<SkeletonBlock className="hidden h-4 w-14 md:block" />
<SkeletonBlock className="hidden h-4 w-12 md:block" />
<SkeletonBlock className="hidden h-4 w-12 md:block" />
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export const messages: Record<Lang, Dict> = {
logsClearConfirm: 'Delete all request history on this host?',
logsColTime: 'Time',
logsColAccount: 'Account',
logsColProvider: 'Provider',
logsColModel: 'Model',
logsColStatus: 'Status',
logsColLatency: 'Latency',
Expand Down Expand Up @@ -189,6 +190,9 @@ export const messages: Record<Lang, Dict> = {
statsCooling: 'cooling',
statsTopModels: 'Top models',
statsTopAccounts: 'Top accounts',
statsTopProviders: 'Providers',
statsTopProvidersHint: 'Request share by account family',
statsNoProviders: 'No provider traffic yet.',
statsErrorMix: 'Error mix',
statsErrorMixHint: 'Classified failures in this window',
statsNoModels: 'No model traffic yet.',
Expand Down Expand Up @@ -548,6 +552,7 @@ export const messages: Record<Lang, Dict> = {
logsClearConfirm: '删除本机全部请求历史?',
logsColTime: '时间',
logsColAccount: '账号',
logsColProvider: '供应商',
logsColModel: '模型',
logsColStatus: '状态',
logsColLatency: '延迟',
Expand Down Expand Up @@ -620,6 +625,9 @@ export const messages: Record<Lang, Dict> = {
statsCooling: '冷却中',
statsTopModels: '热门模型',
statsTopAccounts: '热门账号',
statsTopProviders: '供应商',
statsTopProvidersHint: '按账号类型统计请求量',
statsNoProviders: '还没有供应商流量。',
statsErrorMix: '错误构成',
statsErrorMixHint: '该窗口内已分类的失败',
statsNoModels: '还没有模型流量。',
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/lib/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ export function isTraeProvider(provider?: string) {
return String(provider || '').toLowerCase() === 'trae'
}

export function accountProviderFamilyLabel(
provider: string | undefined,
t: (key: string) => string,
) {
const providerID = String(provider || '').toLowerCase()
if (isWorkBuddyProvider(providerID)) return 'WorkBuddy'
if (isTraeProvider(providerID)) return 'Trae'
if (isQoderProvider(providerID)) return 'Qoder'
return provider || t('account')
}

export function accountProviderLabel(
provider: string | undefined,
region: string | undefined,
Expand Down
Loading
Loading