fix: optimize leaderboard and agent handoff

This commit is contained in:
吉鑫升 committed 2026-06-05 21:06:23 +08:00
1 parent 24a5ca0c06
commit f4ccef8c70
3 files changed
+135 -59

No files matched your search

@@ -39,6 +39,7 @@ import {
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Pagination } from "@/components/ui/pagination";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 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 RangeState = { since: string; until: string; days?: number };
type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal"; type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal";
const DEFAULT_REPORT_MODEL = "__default__"; const DEFAULT_REPORT_MODEL = "__default__";
const LEADERBOARD_PAGE_SIZE = 20;
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = { const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
crimson: { crimson: {
@@ -722,11 +724,13 @@ function UserTable({
users, users,
tab, tab,
selectedUserId, selectedUserId,
rankOffset = 0,
onSelect, onSelect,
}: { }: {
users: AdminLeaderboardUser[]; users: AdminLeaderboardUser[];
tab: UserRankTab; tab: UserRankTab;
selectedUserId: string | null; selectedUserId: string | null;
rankOffset?: number;
onSelect: (user: AdminLeaderboardUser) => void; onSelect: (user: AdminLeaderboardUser) => void;
}) { }) {
const max = Math.max(1, ...users.map((user) => userScore(user, tab))); const max = Math.max(1, ...users.map((user) => userScore(user, tab)));
@@ -749,7 +753,7 @@ function UserTable({
)} )}
> >
<div className="flex size-8 items-center justify-center rounded-md bg-[#FFF1F1] text-sm font-semibold text-[#C1121F] dark:bg-[#3A1215] dark:text-[#F2B8BB]"> <div className="flex size-8 items-center justify-center rounded-md bg-[#FFF1F1] text-sm font-semibold text-[#C1121F] dark:bg-[#3A1215] dark:text-[#F2B8BB]">
{index + 1} {rankOffset + index + 1}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -1041,6 +1045,7 @@ export default function AdminUserLeaderboardPage() {
const [groupRules, setGroupRules] = useState("zero,two"); const [groupRules, setGroupRules] = useState("zero,two");
const [showGroupStats, setShowGroupStats] = useState(false); const [showGroupStats, setShowGroupStats] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null); const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [userPage, setUserPage] = useState(1);
const apiRange = useMemo( const apiRange = useMemo(
() => rangeForApi(range), () => rangeForApi(range),
[range], [range],
@@ -1083,6 +1088,19 @@ export default function AdminUserLeaderboardPage() {
const overview = data?.overview; const overview = data?.overview;
const topUsers = rankedUsers.slice(0, 3); 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(() => { useEffect(() => {
if (rankedUsers.length === 0) { if (rankedUsers.length === 0) {
@@ -1411,9 +1429,14 @@ export default function AdminUserLeaderboardPage() {
<ScrollArea className="min-h-0 flex-1"> <ScrollArea className="min-h-0 flex-1">
<div className="space-y-5 p-6"> <div className="space-y-5 p-6">
{isLoading && ( {isLoading && (
<div className="flex items-center justify-center gap-2 py-20 text-sm text-[#5F6368]"> <div className="flex flex-col items-center justify-center gap-3 py-20 text-center text-sm text-[#5F6368]">
<Loader2 className="size-4 animate-spin" /> <div className="flex items-center gap-2">
加载统计数据 <Loader2 className="size-4 animate-spin" />
<span>正在统计排行榜数据</span>
</div>
<div className="max-w-md text-xs leading-5 text-[#7A5A5A] dark:text-[#D8B8B8]">
线上用户较多或时间范围较大时,统计时间可能较长,请耐心等待。为避免影响其他接口,系统会限制排行榜统计的并发数量。
</div>
</div> </div>
)} )}
@@ -1492,11 +1515,23 @@ export default function AdminUserLeaderboardPage() {
</div> </div>
</div> </div>
<UserTable <UserTable
users={rankedUsers} users={visibleUsers}
tab={tab} tab={tab}
selectedUserId={selectedUserId} selectedUserId={selectedUserId}
rankOffset={(userPage - 1) * LEADERBOARD_PAGE_SIZE}
onSelect={(user) => setSelectedUserId(user.user_id)} onSelect={(user) => setSelectedUserId(user.user_id)}
/> />
<div className="flex flex-wrap items-center justify-between gap-3 text-xs text-[#6B7280] dark:text-slate-400">
<span>
共 {fmtNumber(rankedUsers.length)} 位用户,当前第 {userPage} / {userPageCount} 页
</span>
<Pagination
page={userPage}
pageSize={LEADERBOARD_PAGE_SIZE}
total={rankedUsers.length}
onPageChange={setUserPage}
/>
</div>
</section> </section>
<section className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#462123] dark:bg-[#230D0F]"> <section className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
+2 -2
View File
@@ -131,7 +131,7 @@ export default function AgentChatPage() {
const pendingMessageHandled = useRef(false); const pendingMessageHandled = useRef(false);
useEffect(() => { useEffect(() => {
if (pendingMessageHandled.current) return; if (pendingMessageHandled.current) return;
if (!agent_id || !isNewThread || hasStartedConversation) return; if (!agent_id || !isNewThread) return;
let pending: { agentId?: string; text?: string } | null = null; let pending: { agentId?: string; text?: string } | null = null;
try { try {
const raw = sessionStorage.getItem("pendingAgentMessage"); const raw = sessionStorage.getItem("pendingAgentMessage");
@@ -143,7 +143,7 @@ export default function AgentChatPage() {
pendingMessageHandled.current = true; pendingMessageHandled.current = true;
sessionStorage.removeItem("pendingAgentMessage"); sessionStorage.removeItem("pendingAgentMessage");
handleSubmit({ text: pending.text, files: [] }); handleSubmit({ text: pending.text, files: [] });
}, [agent_id, isNewThread, hasStartedConversation, handleSubmit]); }, [agent_id, isNewThread, handleSubmit]);
const handleClarificationSubmit = useCallback( const handleClarificationSubmit = useCallback(
(answer: string) => { (answer: string) => {
@@ -13,8 +13,10 @@ methods' ``user_id`` parameter is designed for.
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import logging import logging
import os
import re import re
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from difflib import SequenceMatcher from difflib import SequenceMatcher
@@ -53,6 +55,20 @@ from deerflow.persistence.user.model import UserRow
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/admin/users", tags=["admin-users"]) 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: async def _require_admin(request: Request) -> None:
"""Reject non-admin callers. In no-auth mode there is no user → allow.""" """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: if session_factory is None:
raise HTTPException(status_code=503, detail="Analytics requires database persistence") raise HTTPException(status_code=503, detail="Analytics requires database persistence")
settings = load_system_settings().leaderboard try:
since_dt, until_dt, range_days = _resolve_range(days, since, until) await asyncio.wait_for(
excluded = {uid for uid in settings.exclude_user_ids if uid} _leaderboard_semaphore.acquire(),
excluded.update({uid for uid in (exclude_user_ids or []) if uid}) timeout=_LEADERBOARD_ACQUIRE_TIMEOUT_SECONDS,
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 except TimeoutError as exc:
include_failed = settings.include_failed if include_failed is None else include_failed raise HTTPException(
async with session_factory() as session: status_code=429,
common = { detail="排行榜正在统计中,请稍后再试。线上用户较多时统计时间可能较长,请耐心等待。",
"exclude_user_ids": excluded, ) from exc
"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)
return AdminLeaderboardResponse( async def build_response() -> AdminLeaderboardResponse:
range_days=range_days, settings = load_system_settings().leaderboard
since=_iso(since_dt), since_dt, until_dt, range_days = _resolve_range(days, since, until)
until=_iso(until_dt), excluded = {uid for uid in settings.exclude_user_ids if uid}
overview=overview, excluded.update({uid for uid in (exclude_user_ids or []) if uid})
users_by_activity=sorted(users, key=lambda u: (u.run_count + u.tool_call_count, u.total_tokens), reverse=True)[:limit], resolved_include_scheduled = settings.include_scheduled if include_scheduled is None else include_scheduled
users_by_questions=sorted(users, key=lambda u: (u.qa_count, u.run_count), reverse=True)[:limit], resolved_include_admins = settings.include_admins if include_admins is None else include_admins
users_by_tokens=sorted(users, key=lambda u: u.total_tokens, reverse=True)[:limit], resolved_include_failed = settings.include_failed if include_failed is None else include_failed
skills=skills, async with session_factory() as session:
tools=tools, common = {
models=models, "exclude_user_ids": excluded,
agents=agents, "include_scheduled": resolved_include_scheduled,
run_statuses=run_statuses, "include_admins": resolved_include_admins,
recent_questions=recent_questions, "include_failed": resolved_include_failed,
frequent_questions=frequent_questions, }
trends=trends, 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( async def _build_leaderboard_report_prompt(