diff --git a/frontend-web/src/pages/AdminUserLeaderboardPage.tsx b/frontend-web/src/pages/AdminUserLeaderboardPage.tsx index cfd7a5d7..412f0b3c 100644 --- a/frontend-web/src/pages/AdminUserLeaderboardPage.tsx +++ b/frontend-web/src/pages/AdminUserLeaderboardPage.tsx @@ -39,6 +39,7 @@ import { DialogTrigger, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; +import { Pagination } from "@/components/ui/pagination"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -63,6 +64,7 @@ type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency" type RangeState = { since: string; until: string; days?: number }; type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal"; const DEFAULT_REPORT_MODEL = "__default__"; +const LEADERBOARD_PAGE_SIZE = 20; const accentClasses: Record = { crimson: { @@ -722,11 +724,13 @@ function UserTable({ users, tab, selectedUserId, + rankOffset = 0, onSelect, }: { users: AdminLeaderboardUser[]; tab: UserRankTab; selectedUserId: string | null; + rankOffset?: number; onSelect: (user: AdminLeaderboardUser) => void; }) { const max = Math.max(1, ...users.map((user) => userScore(user, tab))); @@ -749,7 +753,7 @@ function UserTable({ )} >
- {index + 1} + {rankOffset + index + 1}
@@ -1041,6 +1045,7 @@ export default function AdminUserLeaderboardPage() { const [groupRules, setGroupRules] = useState("zero,two"); const [showGroupStats, setShowGroupStats] = useState(false); const [selectedUserId, setSelectedUserId] = useState(null); + const [userPage, setUserPage] = useState(1); const apiRange = useMemo( () => rangeForApi(range), [range], @@ -1083,6 +1088,19 @@ export default function AdminUserLeaderboardPage() { const overview = data?.overview; const topUsers = rankedUsers.slice(0, 3); + const userPageCount = Math.max(1, Math.ceil(rankedUsers.length / LEADERBOARD_PAGE_SIZE)); + const visibleUsers = useMemo(() => { + const start = (userPage - 1) * LEADERBOARD_PAGE_SIZE; + return rankedUsers.slice(start, start + LEADERBOARD_PAGE_SIZE); + }, [rankedUsers, userPage]); + + useEffect(() => { + setUserPage(1); + }, [apiRange.since, apiRange.until, query, tab]); + + useEffect(() => { + setUserPage((page) => Math.min(page, userPageCount)); + }, [userPageCount]); useEffect(() => { if (rankedUsers.length === 0) { @@ -1411,9 +1429,14 @@ export default function AdminUserLeaderboardPage() {
{isLoading && ( -
- - 加载统计数据 +
+
+ + 正在统计排行榜数据 +
+
+ 线上用户较多或时间范围较大时,统计时间可能较长,请耐心等待。为避免影响其他接口,系统会限制排行榜统计的并发数量。 +
)} @@ -1492,11 +1515,23 @@ export default function AdminUserLeaderboardPage() {
setSelectedUserId(user.user_id)} /> +
+ + 共 {fmtNumber(rankedUsers.length)} 位用户,当前第 {userPage} / {userPageCount} 页 + + +
diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index 2dca0d8f..061c2ef1 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -131,7 +131,7 @@ export default function AgentChatPage() { const pendingMessageHandled = useRef(false); useEffect(() => { if (pendingMessageHandled.current) return; - if (!agent_id || !isNewThread || hasStartedConversation) return; + if (!agent_id || !isNewThread) return; let pending: { agentId?: string; text?: string } | null = null; try { const raw = sessionStorage.getItem("pendingAgentMessage"); @@ -143,7 +143,7 @@ export default function AgentChatPage() { pendingMessageHandled.current = true; sessionStorage.removeItem("pendingAgentMessage"); handleSubmit({ text: pending.text, files: [] }); - }, [agent_id, isNewThread, hasStartedConversation, handleSubmit]); + }, [agent_id, isNewThread, handleSubmit]); const handleClarificationSubmit = useCallback( (answer: string) => { diff --git a/offline-backend-20260512/backend/app/gateway/routers/admin_users.py b/offline-backend-20260512/backend/app/gateway/routers/admin_users.py index c7ef2b53..bdb020d2 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/admin_users.py +++ b/offline-backend-20260512/backend/app/gateway/routers/admin_users.py @@ -13,8 +13,10 @@ methods' ``user_id`` parameter is designed for. from __future__ import annotations +import asyncio import json import logging +import os import re from collections.abc import AsyncIterator from difflib import SequenceMatcher @@ -53,6 +55,20 @@ from deerflow.persistence.user.model import UserRow logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/admin/users", tags=["admin-users"]) +_LEADERBOARD_MAX_CONCURRENT = max( + 1, + int(os.getenv("ADMIN_LEADERBOARD_MAX_CONCURRENT", "1")), +) +_LEADERBOARD_TIMEOUT_SECONDS = max( + 5.0, + float(os.getenv("ADMIN_LEADERBOARD_TIMEOUT_SECONDS", "45")), +) +_LEADERBOARD_ACQUIRE_TIMEOUT_SECONDS = max( + 0.1, + float(os.getenv("ADMIN_LEADERBOARD_ACQUIRE_TIMEOUT_SECONDS", "0.5")), +) +_leaderboard_semaphore = asyncio.Semaphore(_LEADERBOARD_MAX_CONCURRENT) + async def _require_admin(request: Request) -> None: """Reject non-admin callers. In no-auth mode there is no user → allow.""" @@ -983,59 +999,84 @@ async def get_user_leaderboard( if session_factory is None: raise HTTPException(status_code=503, detail="Analytics requires database persistence") - settings = load_system_settings().leaderboard - since_dt, until_dt, range_days = _resolve_range(days, since, until) - excluded = {uid for uid in settings.exclude_user_ids if uid} - excluded.update({uid for uid in (exclude_user_ids or []) if uid}) - include_scheduled = settings.include_scheduled if include_scheduled is None else include_scheduled - include_admins = settings.include_admins if include_admins is None else include_admins - include_failed = settings.include_failed if include_failed is None else include_failed - async with session_factory() as session: - common = { - "exclude_user_ids": excluded, - "include_scheduled": include_scheduled, - "include_admins": include_admins, - "include_failed": include_failed, - } - tool_common = { - "exclude_user_ids": excluded, - "include_scheduled": include_scheduled, - "include_admins": include_admins, - } - question_common = { - **common, - "cleanup_system_questions": settings.cleanup_system_questions, - } - overview = await _query_overview(session, since_dt, until_dt, **common) - users = await _query_user_leaderboard(session, since_dt, until_dt, **common) - skills = await _query_call_rankings(session, since_dt, until_dt, user_id=None, field="skill", limit=limit, **tool_common) - tools = await _query_call_rankings(session, since_dt, until_dt, user_id=None, field="tool", limit=limit, **tool_common) - models = await _query_model_rankings(session, since_dt, until_dt, user_id=None, limit=limit, **common) - if not models: - models = await _query_run_dimensions(session, since_dt, until_dt, user_id=None, field="model", limit=limit, **common) - agents = await _query_run_dimensions(session, since_dt, until_dt, user_id=None, field="agent", limit=limit, **common) - run_statuses = await _query_run_dimensions(session, since_dt, until_dt, user_id=None, field="status", limit=limit, **common) - recent_questions = await _query_recent_questions(session, since_dt, until_dt, user_id=None, limit=limit, **question_common) - frequent_questions = await _query_frequent_questions(session, since_dt, until_dt, limit=limit, **question_common) - trends = await _query_trends(session, since_dt, until_dt, **common) + try: + await asyncio.wait_for( + _leaderboard_semaphore.acquire(), + timeout=_LEADERBOARD_ACQUIRE_TIMEOUT_SECONDS, + ) + except TimeoutError as exc: + raise HTTPException( + status_code=429, + detail="排行榜正在统计中,请稍后再试。线上用户较多时统计时间可能较长,请耐心等待。", + ) from exc - return AdminLeaderboardResponse( - range_days=range_days, - since=_iso(since_dt), - until=_iso(until_dt), - overview=overview, - users_by_activity=sorted(users, key=lambda u: (u.run_count + u.tool_call_count, u.total_tokens), reverse=True)[:limit], - users_by_questions=sorted(users, key=lambda u: (u.qa_count, u.run_count), reverse=True)[:limit], - users_by_tokens=sorted(users, key=lambda u: u.total_tokens, reverse=True)[:limit], - skills=skills, - tools=tools, - models=models, - agents=agents, - run_statuses=run_statuses, - recent_questions=recent_questions, - frequent_questions=frequent_questions, - trends=trends, - ) + async def build_response() -> AdminLeaderboardResponse: + settings = load_system_settings().leaderboard + since_dt, until_dt, range_days = _resolve_range(days, since, until) + excluded = {uid for uid in settings.exclude_user_ids if uid} + excluded.update({uid for uid in (exclude_user_ids or []) if uid}) + resolved_include_scheduled = settings.include_scheduled if include_scheduled is None else include_scheduled + resolved_include_admins = settings.include_admins if include_admins is None else include_admins + resolved_include_failed = settings.include_failed if include_failed is None else include_failed + async with session_factory() as session: + common = { + "exclude_user_ids": excluded, + "include_scheduled": resolved_include_scheduled, + "include_admins": resolved_include_admins, + "include_failed": resolved_include_failed, + } + tool_common = { + "exclude_user_ids": excluded, + "include_scheduled": resolved_include_scheduled, + "include_admins": resolved_include_admins, + } + question_common = { + **common, + "cleanup_system_questions": settings.cleanup_system_questions, + } + overview = await _query_overview(session, since_dt, until_dt, **common) + users = await _query_user_leaderboard(session, since_dt, until_dt, **common) + skills = await _query_call_rankings(session, since_dt, until_dt, user_id=None, field="skill", limit=limit, **tool_common) + tools = await _query_call_rankings(session, since_dt, until_dt, user_id=None, field="tool", limit=limit, **tool_common) + models = await _query_model_rankings(session, since_dt, until_dt, user_id=None, limit=limit, **common) + if not models: + models = await _query_run_dimensions(session, since_dt, until_dt, user_id=None, field="model", limit=limit, **common) + agents = await _query_run_dimensions(session, since_dt, until_dt, user_id=None, field="agent", limit=limit, **common) + run_statuses = await _query_run_dimensions(session, since_dt, until_dt, user_id=None, field="status", limit=limit, **common) + recent_questions = await _query_recent_questions(session, since_dt, until_dt, user_id=None, limit=limit, **question_common) + frequent_questions = await _query_frequent_questions(session, since_dt, until_dt, limit=limit, **question_common) + trends = await _query_trends(session, since_dt, until_dt, **common) + + return AdminLeaderboardResponse( + range_days=range_days, + since=_iso(since_dt), + until=_iso(until_dt), + overview=overview, + users_by_activity=sorted(users, key=lambda u: (u.run_count + u.tool_call_count, u.total_tokens), reverse=True)[:limit], + users_by_questions=sorted(users, key=lambda u: (u.qa_count, u.run_count), reverse=True)[:limit], + users_by_tokens=sorted(users, key=lambda u: u.total_tokens, reverse=True)[:limit], + skills=skills, + tools=tools, + models=models, + agents=agents, + run_statuses=run_statuses, + recent_questions=recent_questions, + frequent_questions=frequent_questions, + trends=trends, + ) + + try: + return await asyncio.wait_for( + build_response(), + timeout=_LEADERBOARD_TIMEOUT_SECONDS, + ) + except TimeoutError as exc: + raise HTTPException( + status_code=504, + detail="排行榜统计时间过长,请缩短时间范围后重试,或稍后再查看。", + ) from exc + finally: + _leaderboard_semaphore.release() async def _build_leaderboard_report_prompt(