feat: token stats and feature gating
parent
e3997c661e
commit
b2df36647c
|
|
@ -11,7 +11,6 @@ import MarketplacePage from './pages/MarketplacePage';
|
|||
import TeamsPage from './pages/TeamsPage';
|
||||
import PromptLibraryPage from './pages/PromptLibraryPage';
|
||||
import StatsPage from './pages/StatsPage';
|
||||
import LLMProvidersPage from './pages/LLMProvidersPage';
|
||||
import SharedSessionPage from './pages/SharedSessionPage';
|
||||
import WorkflowsPage from './pages/WorkflowsPage';
|
||||
import { useAuth } from './store/auth';
|
||||
|
|
@ -52,7 +51,6 @@ export default function App() {
|
|||
<Route path="/teams" element={<TeamsPage />} />
|
||||
<Route path="/prompts" element={<PromptLibraryPage />} />
|
||||
<Route path="/stats" element={<StatsPage />} />
|
||||
<Route path="/llm-providers" element={<LLMProvidersPage />} />
|
||||
<Route path="/workflows" element={<WorkflowsPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
|
|
|||
38
src/api.ts
38
src/api.ts
|
|
@ -486,9 +486,45 @@ export interface AgentStats {
|
|||
daily: { day: string; count: number }[];
|
||||
}
|
||||
|
||||
export interface AgentTokenStatsByModel {
|
||||
providerKind: string;
|
||||
model: string;
|
||||
calls: number;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
costUSD: number;
|
||||
}
|
||||
|
||||
export interface AgentTokenStatsDaily {
|
||||
day: string;
|
||||
calls: number;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
costUSD: number;
|
||||
}
|
||||
|
||||
export interface AgentTokenStats {
|
||||
agentId: string;
|
||||
days: number;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
costUSD: number;
|
||||
byModel: AgentTokenStatsByModel[];
|
||||
daily: AgentTokenStatsDaily[];
|
||||
}
|
||||
|
||||
export const StatsAPI = {
|
||||
overview: () => api.get<StatsOverview>('/stats/overview').then((r) => r.data),
|
||||
agent: (id: string) => api.get<AgentStats>(`/stats/agents/${id}`).then((r) => r.data)
|
||||
agent: (id: string) => api.get<AgentStats>(`/stats/agents/${id}`).then((r) => r.data),
|
||||
agentTokens: (id: string, opts: { days?: number; limit?: number } = {}) =>
|
||||
api
|
||||
.get<AgentTokenStats>(`/stats/agents/${id}/tokens`, {
|
||||
params: { days: opts.days ?? 30, limit: opts.limit ?? 10 }
|
||||
})
|
||||
.then((r) => r.data)
|
||||
};
|
||||
|
||||
// ============== 流式(手写 SSE,不用 EventSource,因为它不能 POST) ==============
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
BookOutlined,
|
||||
ApartmentOutlined,
|
||||
BarChartOutlined,
|
||||
ApiOutlined,
|
||||
TeamOutlined,
|
||||
SunOutlined,
|
||||
MoonOutlined,
|
||||
|
|
@ -44,7 +43,6 @@ const NAV_GROUPS: Array<{
|
|||
label: '管理',
|
||||
items: [
|
||||
{ to: '/stats', icon: <BarChartOutlined />, label: '调用统计' },
|
||||
{ to: '/llm-providers', icon: <ApiOutlined />, label: 'LLM 提供商' },
|
||||
{ to: '/teams', icon: <TeamOutlined />, label: '团队' }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,10 @@ export default function ChatPage() {
|
|||
const abortRef = useRef<AbortController | null>(null);
|
||||
const autoScrollRef = useRef(true);
|
||||
const initialScrollDoneRef = useRef(false);
|
||||
const scrollRafRef = useRef<number | null>(null);
|
||||
const scrollProgrammaticRef = useRef(false);
|
||||
const lastScrollTopRef = useRef(0);
|
||||
const userScrollLockRef = useRef(false);
|
||||
|
||||
// URL 参数 ?session=xxx&msg=yyy
|
||||
useEffect(() => {
|
||||
|
|
@ -91,9 +95,21 @@ export default function ChatPage() {
|
|||
}, [searchParams]);
|
||||
|
||||
const scrollBottom = (force = false) => {
|
||||
if (!force && !autoScrollRef.current) return;
|
||||
requestAnimationFrame(() => {
|
||||
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight, behavior: 'smooth' });
|
||||
if (force) {
|
||||
userScrollLockRef.current = false;
|
||||
autoScrollRef.current = true;
|
||||
}
|
||||
if (!force && (!autoScrollRef.current || userScrollLockRef.current)) return;
|
||||
if (scrollRafRef.current) {
|
||||
cancelAnimationFrame(scrollRafRef.current);
|
||||
scrollRafRef.current = null;
|
||||
}
|
||||
scrollRafRef.current = requestAnimationFrame(() => {
|
||||
scrollRafRef.current = null;
|
||||
const el = bodyRef.current;
|
||||
if (!el) return;
|
||||
scrollProgrammaticRef.current = true;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -101,8 +117,24 @@ export default function ChatPage() {
|
|||
const el = bodyRef.current;
|
||||
if (!el) return;
|
||||
const onScroll = () => {
|
||||
if (scrollProgrammaticRef.current) {
|
||||
scrollProgrammaticRef.current = false;
|
||||
lastScrollTopRef.current = el.scrollTop;
|
||||
} else {
|
||||
const nextTop = el.scrollTop;
|
||||
const lastTop = lastScrollTopRef.current;
|
||||
if (nextTop < lastTop - 2) {
|
||||
userScrollLockRef.current = true;
|
||||
}
|
||||
lastScrollTopRef.current = nextTop;
|
||||
}
|
||||
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
autoScrollRef.current = distance < 32;
|
||||
if (distance < 8) userScrollLockRef.current = false;
|
||||
autoScrollRef.current = distance < 32 && !userScrollLockRef.current;
|
||||
if ((!autoScrollRef.current || userScrollLockRef.current) && scrollRafRef.current) {
|
||||
cancelAnimationFrame(scrollRafRef.current);
|
||||
scrollRafRef.current = null;
|
||||
}
|
||||
};
|
||||
el.addEventListener('scroll', onScroll, { passive: true });
|
||||
onScroll();
|
||||
|
|
@ -294,7 +326,7 @@ export default function ChatPage() {
|
|||
setSessionRefresh((t) => t + 1);
|
||||
setAttachments([]); // 用完即清
|
||||
setImageUrls([]);
|
||||
scrollBottom(true);
|
||||
scrollBottom();
|
||||
},
|
||||
onAborted: (data) => {
|
||||
// 已停止:保留已生成内容;user 消息也留下(用 tempUser 占位)
|
||||
|
|
|
|||
|
|
@ -125,7 +125,8 @@ export default function PromptLibraryPage({
|
|||
});
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="feature-cover-container">
|
||||
<div className="page-container">
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 24,
|
||||
|
|
@ -489,6 +490,10 @@ export default function PromptLibraryPage({
|
|||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
<div className="feature-cover">
|
||||
<Empty description="功能规划中,本期不支持" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,43 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { BarChartOutlined, LineChartOutlined, MessageOutlined, RobotOutlined } from '@ant-design/icons';
|
||||
import { Card, Empty, App as AntApp, Spin, Tag } from 'antd';
|
||||
import { Card, Empty, App as AntApp, Spin, Tag, Select, Space, Table } from 'antd';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { StatsAPI, StatsOverview } from '../api';
|
||||
import { AgentTokenStats, StatsAPI, StatsOverview } from '../api';
|
||||
|
||||
export default function StatsPage() {
|
||||
const { message } = AntApp.useApp();
|
||||
const [data, setData] = useState<StatsOverview | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tokenLoading, setTokenLoading] = useState(false);
|
||||
const [tokenData, setTokenData] = useState<AgentTokenStats | null>(null);
|
||||
const [tokenAgentId, setTokenAgentId] = useState<string>('');
|
||||
const [tokenDays, setTokenDays] = useState<number>(30);
|
||||
const [tokenLimit, setTokenLimit] = useState<number>(10);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
StatsAPI.overview()
|
||||
.then(setData)
|
||||
.then((res) => {
|
||||
setData(res);
|
||||
if (!tokenAgentId && res.topAgents?.[0]?.id) {
|
||||
setTokenAgentId(res.topAgents[0].id);
|
||||
}
|
||||
})
|
||||
.catch((e) => message.error('加载失败:' + (e?.message ?? e)))
|
||||
.finally(() => setLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tokenAgentId) return;
|
||||
setTokenLoading(true);
|
||||
StatsAPI.agentTokens(tokenAgentId, { days: tokenDays, limit: tokenLimit })
|
||||
.then(setTokenData)
|
||||
.catch((e) => message.error('Token 统计加载失败:' + (e?.message ?? e)))
|
||||
.finally(() => setTokenLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tokenAgentId, tokenDays, tokenLimit]);
|
||||
|
||||
if (loading) return <Spin style={{ marginTop: 80, display: 'block' }} />;
|
||||
if (!data) return <Empty description="暂无数据" style={{ marginTop: 80 }} />;
|
||||
|
||||
|
|
@ -25,6 +45,8 @@ export default function StatsPage() {
|
|||
const maxAgent = Math.max(1, ...data.topAgents.map((a) => a.messageCount));
|
||||
const last7Days = data.daily.slice(-7).reduce((sum, item) => sum + item.total, 0);
|
||||
const avgSessionMessages = data.sessionCount > 0 ? (data.messageCount / data.sessionCount).toFixed(1) : '0.0';
|
||||
const formatUSD = (v?: number | null) =>
|
||||
`$${Number(v || 0).toLocaleString(undefined, { minimumFractionDigits: 4, maximumFractionDigits: 4 })}`;
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
|
|
@ -250,6 +272,176 @@ export default function StatsPage() {
|
|||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<Card
|
||||
title="Token 使用量"
|
||||
extra={
|
||||
<Space size={10} wrap>
|
||||
<Select
|
||||
size="small"
|
||||
value={tokenAgentId || undefined}
|
||||
style={{ minWidth: 220 }}
|
||||
placeholder="选择智能体"
|
||||
onChange={setTokenAgentId}
|
||||
options={data.topAgents.map((a) => ({ value: a.id, label: a.name }))}
|
||||
/>
|
||||
<Select
|
||||
size="small"
|
||||
value={tokenDays}
|
||||
style={{ width: 120 }}
|
||||
onChange={setTokenDays}
|
||||
options={[
|
||||
{ value: 7, label: '近 7 天' },
|
||||
{ value: 30, label: '近 30 天' },
|
||||
{ value: 90, label: '近 90 天' }
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
size="small"
|
||||
value={tokenLimit}
|
||||
style={{ width: 120 }}
|
||||
onChange={setTokenLimit}
|
||||
options={[
|
||||
{ value: 5, label: 'Top 5' },
|
||||
{ value: 10, label: 'Top 10' },
|
||||
{ value: 20, label: 'Top 20' }
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
style={{ borderRadius: 22, boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)' }}
|
||||
>
|
||||
{tokenLoading ? (
|
||||
<Spin style={{ marginTop: 20, display: 'block' }} />
|
||||
) : !tokenAgentId ? (
|
||||
<Empty description="暂无可统计的智能体" style={{ marginTop: 20 }} />
|
||||
) : !tokenData ? (
|
||||
<Empty description="暂无 Token 统计数据" style={{ marginTop: 20 }} />
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 14, marginBottom: 16 }}>
|
||||
{[
|
||||
{ label: '总 Token', value: tokenData.totalTokens, tone: 'rgba(8, 145, 178, 0.10)', color: 'var(--color-brand)' },
|
||||
{ label: '输入 Token', value: tokenData.promptTokens, tone: 'rgba(14, 165, 233, 0.10)', color: 'var(--color-info)' },
|
||||
{ label: '输出 Token', value: tokenData.completionTokens, tone: 'rgba(34, 197, 94, 0.10)', color: 'var(--color-success)' },
|
||||
{ label: '费用 (USD)', value: formatUSD(tokenData.costUSD), tone: 'rgba(249, 115, 22, 0.10)', color: 'var(--color-warning)' }
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
style={{
|
||||
borderRadius: 18,
|
||||
padding: '16px 18px',
|
||||
background: 'rgba(255,255,255,0.72)',
|
||||
border: '1px solid rgba(255,255,255,0.7)'
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 999,
|
||||
background: item.color,
|
||||
marginRight: 6,
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
/>
|
||||
{item.label}
|
||||
</div>
|
||||
<div style={{ fontSize: 26, fontWeight: 700, color: 'var(--color-text)' }}>
|
||||
{typeof item.value === 'number' ? item.value.toLocaleString() : item.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: 18 }}>
|
||||
<Card
|
||||
size="small"
|
||||
title="近 N 天趋势(总 Token)"
|
||||
extra={
|
||||
<Tag bordered={false} style={{ borderRadius: 999, background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)' }}>
|
||||
days={tokenData.days}
|
||||
</Tag>
|
||||
}
|
||||
style={{ borderRadius: 18, boxShadow: 'none' }}
|
||||
>
|
||||
{tokenData.daily.length === 0 ? (
|
||||
<Empty description="暂无" />
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6, height: 220, padding: '18px 12px 10px' }}>
|
||||
{(() => {
|
||||
const maxTokenDaily = Math.max(1, ...tokenData.daily.map((d) => d.totalTokens));
|
||||
return tokenData.daily.map((d) => {
|
||||
const h = (d.totalTokens / maxTokenDaily) * 170;
|
||||
return (
|
||||
<div
|
||||
key={d.day}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
minWidth: 0
|
||||
}}
|
||||
title={`${d.day}\nCalls ${d.calls}\nTotalTokens ${d.totalTokens}\nCost ${formatUSD(d.costUSD)}`}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 12,
|
||||
height: h,
|
||||
background: 'var(--gradient-brand)',
|
||||
borderRadius: '6px 6px 0 0'
|
||||
}}
|
||||
/>
|
||||
<div style={{ fontSize: 10, color: 'var(--color-text-tertiary)', marginTop: 6, transform: 'rotate(-45deg)' }}>
|
||||
{d.day.slice(5)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
size="small"
|
||||
title="模型消耗排行"
|
||||
style={{ borderRadius: 18, boxShadow: 'none' }}
|
||||
>
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey={(r) => `${r.providerKind}:${r.model}`}
|
||||
dataSource={tokenData.byModel || []}
|
||||
columns={[
|
||||
{ title: 'Provider', dataIndex: 'providerKind', width: 110 },
|
||||
{ title: 'Model', dataIndex: 'model' },
|
||||
{ title: 'Calls', dataIndex: 'calls', width: 80 },
|
||||
{
|
||||
title: 'Tokens',
|
||||
dataIndex: 'totalTokens',
|
||||
width: 110,
|
||||
render: (v) => Number(v || 0).toLocaleString()
|
||||
},
|
||||
{
|
||||
title: 'Cost',
|
||||
dataIndex: 'costUSD',
|
||||
width: 110,
|
||||
render: (v) => formatUSD(v)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,8 @@ export default function TeamsPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="page-container" style={{ maxWidth: 1080 }}>
|
||||
<div className="feature-cover-container">
|
||||
<div className="page-container" style={{ maxWidth: 1080 }}>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 24,
|
||||
|
|
@ -390,6 +391,10 @@ export default function TeamsPage() {
|
|||
</Form>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
<div className="feature-cover">
|
||||
<Empty description="功能规划中,本期不支持" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,7 +135,8 @@ export default function WorkflowsPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="page-container" style={{ maxWidth: 1400 }}>
|
||||
<div className="feature-cover-container">
|
||||
<div className="page-container" style={{ maxWidth: 1400 }}>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 24,
|
||||
|
|
@ -358,6 +359,10 @@ export default function WorkflowsPage() {
|
|||
onClose={() => setRunsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="feature-cover">
|
||||
<Empty description="功能规划中,本期不支持" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1004,6 +1004,29 @@ body {
|
|||
padding: 40px 32px 60px;
|
||||
}
|
||||
|
||||
.feature-cover-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.feature-cover {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.74);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.feature-cover .ant-empty {
|
||||
padding: 26px 30px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue