fix: optimize leaderboard and agent handoff
This commit is contained in:
1 parent
24a5ca0c06
commit
f4ccef8c70
3 files changed
+135
-59
No files matched your search
@@ -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<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
|
||||
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({
|
||||
)}
|
||||
>
|
||||
<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 className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1041,6 +1045,7 @@ export default function AdminUserLeaderboardPage() {
|
||||
const [groupRules, setGroupRules] = useState("zero,two");
|
||||
const [showGroupStats, setShowGroupStats] = useState(false);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(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() {
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-5 p-6">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center gap-2 py-20 text-sm text-[#5F6368]">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
加载统计数据
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center text-sm text-[#5F6368]">
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -1492,11 +1515,23 @@ export default function AdminUserLeaderboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
<UserTable
|
||||
users={rankedUsers}
|
||||
users={visibleUsers}
|
||||
tab={tab}
|
||||
selectedUserId={selectedUserId}
|
||||
rankOffset={(userPage - 1) * LEADERBOARD_PAGE_SIZE}
|
||||
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 className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user