diff --git a/src/App.tsx b/src/App.tsx index 0660b00..184eaf1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> } /> - } /> } /> } /> diff --git a/src/api.ts b/src/api.ts index 493d518..d88558b 100644 --- a/src/api.ts +++ b/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('/stats/overview').then((r) => r.data), - agent: (id: string) => api.get(`/stats/agents/${id}`).then((r) => r.data) + agent: (id: string) => api.get(`/stats/agents/${id}`).then((r) => r.data), + agentTokens: (id: string, opts: { days?: number; limit?: number } = {}) => + api + .get(`/stats/agents/${id}/tokens`, { + params: { days: opts.days ?? 30, limit: opts.limit ?? 10 } + }) + .then((r) => r.data) }; // ============== 流式(手写 SSE,不用 EventSource,因为它不能 POST) ============== diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 25bddc5..64027fb 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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: , label: '调用统计' }, - { to: '/llm-providers', icon: , label: 'LLM 提供商' }, { to: '/teams', icon: , label: '团队' } ] } diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index e42fbb9..4e38f61 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -74,6 +74,10 @@ export default function ChatPage() { const abortRef = useRef(null); const autoScrollRef = useRef(true); const initialScrollDoneRef = useRef(false); + const scrollRafRef = useRef(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 占位) diff --git a/src/pages/PromptLibraryPage.tsx b/src/pages/PromptLibraryPage.tsx index 0d50b45..82d3541 100644 --- a/src/pages/PromptLibraryPage.tsx +++ b/src/pages/PromptLibraryPage.tsx @@ -125,7 +125,8 @@ export default function PromptLibraryPage({ }); return ( -
+
+
+
+
+ +
); } diff --git a/src/pages/StatsPage.tsx b/src/pages/StatsPage.tsx index d0598b6..728b0f1 100644 --- a/src/pages/StatsPage.tsx +++ b/src/pages/StatsPage.tsx @@ -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(null); const [loading, setLoading] = useState(false); + const [tokenLoading, setTokenLoading] = useState(false); + const [tokenData, setTokenData] = useState(null); + const [tokenAgentId, setTokenAgentId] = useState(''); + const [tokenDays, setTokenDays] = useState(30); + const [tokenLimit, setTokenLimit] = useState(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 ; if (!data) return ; @@ -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 (
@@ -250,6 +272,176 @@ export default function StatsPage() { )}
+ +
+ + +