+
+ {label}
+
+
+
-
+
{value}
- {sub &&
{sub}
}
+
{note}
);
}
-function RankCard({
- title,
- icon: Icon,
- users,
- metric,
- unit,
-}: {
- title: string;
- icon: LucideIcon;
- users: AdminLeaderboardUser[];
- metric: (u: AdminLeaderboardUser) => number;
- unit: string;
-}) {
- const top = users.slice(0, 10);
+function InsightStrip({ overview }: { overview: AdminLeaderboardOverview }) {
+ const activeUsers = Math.max(1, overview.active_users);
+ const totalRuns = Math.max(1, overview.total_runs);
+ const items = [
+ {
+ label: "人均使用",
+ value: overview.total_runs / activeUsers,
+ unit: "次/活跃用户",
+ description: "当前时间段内,平均每位活跃用户发起了多少次运行。",
+ },
+ {
+ label: "问答密度",
+ value: overview.total_questions / totalRuns,
+ unit: "问答/运行",
+ description: "问答次数 ÷ 运行次数,用来判断每次使用承载了多少有效提问。",
+ },
+ {
+ label: "平均 Token",
+ value: overview.total_tokens / totalRuns,
+ unit: "Token/运行",
+ description: "总 Token ÷ 运行次数,反映单次使用的模型消耗强度。",
+ },
+ {
+ label: "技能覆盖",
+ value: overview.total_tool_calls ? (overview.total_skill_calls / overview.total_tool_calls) * 100 : 0,
+ unit: "%",
+ description: "技能调用 ÷ 工具总调用,越高说明用户越多依赖已配置技能能力。",
+ },
+ {
+ label: "失败率",
+ value: (overview.error_runs / totalRuns) * 100,
+ unit: "%",
+ description: "失败运行 ÷ 总运行,用来快速发现稳定性或配置问题。",
+ },
+ ];
return (
-
-
-
- {title}
-
- {top.length === 0 ? (
-
暂无数据
- ) : (
-
- {top.map((user, index) => (
-
-
-
- {usernameOf(user.email)}
-
-
- {fmtNumber(metric(user))}
-
- {unit}
-
- ))}
-
- )}
-
+
+ {items.map((item) => (
+
+
{item.label}
+
+ {Number(item.value).toFixed(item.unit === "%" || String(item.value).includes(".") ? 1 : 0)}
+ {item.unit}
+
+
{item.description}
+
+ ))}
+
);
}
-function RankBadge({ rank }: { rank: number }) {
- const color = RANK_COLORS[Math.min(rank - 1, RANK_COLORS.length - 1)];
- if (rank <= 3) {
- const Icon = rank === 1 ? Crown : Medal;
+function PodiumCard({
+ user,
+ rank,
+ tab,
+}: {
+ user?: AdminLeaderboardUser;
+ rank: 1 | 2 | 3;
+ tab: UserRankTab;
+}) {
+ const styles = {
+ 1: "border-[#FEEFC3] bg-[#FEF7E0] text-[#B06000] dark:border-[#F4B400]/40 dark:bg-[#F4B400]/10 dark:text-[#FDD663]",
+ 2: "border-[#E5E7EB] bg-[#F8FAFC] text-[#4B5563] dark:border-[#6B7280]/40 dark:bg-white/5 dark:text-[#E5E7EB]",
+ 3: "border-[#FAD2CF] bg-[#FCE8E6] text-[#DB4437] dark:border-[#DB4437]/40 dark:bg-[#DB4437]/10 dark:text-[#F28B82]",
+ }[rank];
+
+ if (!user) {
return (
-
-
-
+
);
}
- return (
-
- {rank}
-
- );
-}
-function SkillCard({ skills }: { skills: AdminLeaderboardSkill[] }) {
- const top = skills.slice(0, 10);
- const max = Math.max(1, ...top.map((s) => s.call_count));
return (
-
-
-
- 技能热度
+
+
+
+ {rank === 1 ? : }
+ 第 {rank} 名
+
+ {user.system_role === "admin" && 管理员 }
+
+
+
+ {initials(user)}
+
+
{user.email || user.user_id}
+
{scoreLabel(user, tab)}
+
+ 运行 {fmtNumber(user.run_count)}
+ 问答 {fmtNumber(user.qa_count)}
+ 技能 {fmtNumber(user.skill_call_count)}
+
- {top.length === 0 ? (
-
暂无数据
- ) : (
-
- )}
);
}
-function QuestionsCard({
+function UserTable({
+ users,
+ tab,
+ rankOffset = 0,
+}: {
+ users: AdminLeaderboardUser[];
+ tab: UserRankTab;
+ rankOffset?: number;
+}) {
+ const max = Math.max(1, ...users.map((user) => userScore(user, tab)));
+ if (users.length === 0) {
+ return
暂无用户统计
;
+ }
+
+ return (
+
+ {users.map((user, index) => {
+ const score = userScore(user, tab);
+ return (
+
+
+ {rankOffset + index + 1}
+
+
+
+ {user.email || user.user_id}
+ {user.system_role === "admin" && 管理员 }
+
+
+ 最近活跃 {fmtTime(user.last_active_at)}
+
+
+
+
+
+ 会话 {fmtNumber(user.thread_count)} · LLM {fmtNumber(user.llm_call_count)}
+
+
+
+
{scoreLabel(user, tab)}
+
+ {fmtNumber(user.total_tokens)} tokens
+
+
+
+ );
+ })}
+
+ );
+}
+
+function SkillRanking({
title,
icon: Icon,
- questions,
+ items,
+ empty,
}: {
title: string;
icon: LucideIcon;
- questions: string[];
+ items: AdminLeaderboardSkill[];
+ empty: string;
}) {
- const top = questions.slice(0, 10);
+ const max = Math.max(1, ...items.map((item) => item.call_count));
return (
-
-
-
- {title}
+
+
+
+
+
{title}
+
+
{items.length} 项
- {top.length === 0 ? (
- 暂无数据
- ) : (
-
- {top.map((text, index) => (
-
- {index + 1}.
- {text}
-
+
+ {items.length === 0 &&
{empty}
}
+ {items.slice(0, 10).map((item, index) => (
+
+
{index + 1}
+
+
+ {item.name}
+
+ {fmtNumber(item.user_count)} 人
+
+
+
+
+
+
+ {fmtNumber(item.call_count)}
+
+
+ 失败 {fmtNumber(item.error_count)}
+
+
+
+ ))}
+
+
+ );
+}
+
+function DimensionRanking({
+ title,
+ icon: Icon,
+ items,
+ empty,
+}: {
+ title: string;
+ icon: LucideIcon;
+ items: AdminLeaderboardDimension[];
+ empty: string;
+}) {
+ const max = Math.max(1, ...items.map((item) => item.count));
+ return (
+
+
+
+
+
{title}
+
+
{items.length} 项
+
+
+ {items.length === 0 &&
{empty}
}
+ {items.slice(0, 8).map((item, index) => (
+
+
{index + 1}
+
+
+ {item.name}
+
+ {fmtNumber(item.user_count)} 人
+
+
+
+
+ 成功 {fmtNumber(item.success_count)} · 失败 {fmtNumber(item.error_count)} · {fmtNumber(item.total_tokens)} tokens
+
+
+
+
{fmtNumber(item.count)}
+
次
+
+
+ ))}
+
+
+ );
+}
+
+function QuestionPanel({
+ title,
+ items,
+}: {
+ title: string;
+ items: AdminLeaderboardQuestion[];
+}) {
+ return (
+
+
+
+
+
{title}
+
+
{items.length}
+
+
+
+ {items.length === 0 &&
暂无问题记录
}
+ {items.map((item, index) => (
+
+
{clampText(item.text, 180)}
+
+ {item.email || item.user_id || "未知用户"}
+ {fmtTime(item.asked_at)}
+
+
))}
-
- )}
+
+
+
+ );
+}
+
+function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
+ type TrendMetric = keyof Pick
;
+ const [metric, setMetric] = useState<"all" | TrendMetric>("all");
+ const chartId = useId().replace(/:/g, "");
+ if (points.length === 0) {
+ return 暂无趋势数据
;
+ }
+
+ const visible = points.slice(-32);
+ const series = [
+ { key: "runs", label: "运行", color: "#C1121F", soft: "bg-[#FFF1F1] text-[#C1121F] dark:text-[#7FACF2] dark:bg-[#2563EB]/15 dark:text-[#7FACF2]", description: "每天发起的运行次数" },
+ { key: "questions", label: "问答", color: "#0F766E", soft: "bg-[#ECFDF5] text-[#0F766E] dark:bg-teal-300/15 dark:text-teal-100", description: "每天产生的有效提问次数" },
+ { key: "users", label: "活跃", color: "#B45309", soft: "bg-[#FFFBEB] text-[#B45309] dark:bg-amber-300/15 dark:text-amber-100", description: "每天有行为的用户数量" },
+ { key: "tokens", label: "Token", color: "#7C3AED", soft: "bg-[#F5F3FF] text-[#7C3AED] dark:bg-violet-300/15 dark:text-violet-100", description: "每天模型输入输出 Token 总量" },
+ ] as const;
+ const activeMetric: TrendMetric = metric === "all" ? "runs" : metric;
+ const activeSeries = series.find((item) => item.key === activeMetric) ?? series[0];
+ const metricMax = Math.max(1, ...visible.map((point) => Number(point[activeMetric] ?? 0)));
+ const latest = visible[visible.length - 1];
+ const previous = visible[visible.length - 2];
+ const latestValue = Number(latest?.[activeMetric] ?? 0);
+ const previousValue = Number(previous?.[activeMetric] ?? 0);
+ const delta = latestValue - previousValue;
+ const width = 920;
+ const height = 280;
+ const padding = { top: 22, right: 28, bottom: 36, left: 48 };
+ const chartWidth = width - padding.left - padding.right;
+ const chartHeight = height - padding.top - padding.bottom;
+ const xFor = (index: number) =>
+ padding.left + (visible.length <= 1 ? chartWidth / 2 : (index / (visible.length - 1)) * chartWidth);
+ const yFor = (value: number, max: number) => padding.top + chartHeight - (value / max) * chartHeight;
+ const gridLines = [0, 0.25, 0.5, 0.75, 1];
+ const selectedPath = visible
+ .map((point, index) => {
+ const value = Number(point[activeMetric] ?? 0);
+ return `${index === 0 ? "M" : "L"} ${xFor(index).toFixed(2)} ${yFor(value, metricMax).toFixed(2)}`;
+ })
+ .join(" ");
+ const selectedArea = `${selectedPath} L ${xFor(visible.length - 1).toFixed(2)} ${padding.top + chartHeight} L ${xFor(0).toFixed(2)} ${padding.top + chartHeight} Z`;
+ const peak = visible.reduce(
+ (best, point) => (Number(point[activeMetric] ?? 0) > Number(best[activeMetric] ?? 0) ? point : best),
+ visible[0],
+ );
+ const displayedSeries = metric === "all" ? series : [activeSeries];
+ const totalFor = (key: TrendMetric) => visible.reduce((sum, point) => sum + Number(point[key] ?? 0), 0);
+ const metricCards = ({ large = false }: { large?: boolean } = {}) => (
+
+ setMetric("all")}
+ className={cn(
+ "min-w-0 rounded-lg border px-3 py-2 text-left transition hover:-translate-y-0.5 hover:shadow-sm",
+ large && "px-4 py-3",
+ metric === "all"
+ ? "border-[#C1121F] dark:border-[#2563EB] bg-[#FFF1F1] shadow-sm dark:border-[#7FACF2]/40 dark:bg-[#0D2D54]"
+ : "border-[#F1D6D6] bg-white dark:border-[#213D5E] dark:bg-[#02244C]",
+ )}
+ >
+ 全部趋势
+
+ {fmtNumber(visible.reduce((sum, point) => sum + point.runs + point.questions + point.users, 0))}
+
+ {large && 同时显示运行、问答、活跃与 Token }
+
+ {series.map((item) => (
+ setMetric(item.key)}
+ className={cn(
+ "min-w-0 rounded-lg border px-3 py-2 text-left transition hover:-translate-y-0.5 hover:shadow-sm",
+ large && "px-4 py-3",
+ metric === item.key
+ ? "border-[#C1121F] dark:border-[#2563EB] bg-[#FFF1F1] shadow-sm dark:border-[#7FACF2]/40 dark:bg-[#0D2D54]"
+ : "border-[#F1D6D6] bg-white dark:border-[#213D5E] dark:bg-[#02244C]",
+ )}
+ title={item.description}
+ >
+ {item.label}
+
+ {fmtNumber(totalFor(item.key))}
+
+ {large && {item.description} }
+
+ ))}
+
+ );
+
+ const analysisDialog = (title: string, description: ReactNode) => (
+
+
+
+
+ 放大分析
+
+
+
+
+ {title}
+ {description}
+
+ {metricCards({ large: true })}
+
+
+
当前查看
+
{metric === "all" ? "全部指标" : activeSeries.label}
+
最新 {metric === "all" ? "运行" : activeSeries.label}
+
{fmtNumber(latestValue)}
+
= 0 ? "text-[#C1121F] dark:text-[#7FACF2]" : "text-[#7F1D1D] dark:text-[#FCA5A5]")}>
+ 较上一日 {delta >= 0 ? "+" : ""}{fmtNumber(delta)}
+
+
+ 峰值 {peak.date} / {fmtNumber(Number(peak[activeMetric] ?? 0))}
+
+
+ {chart(true)}
+
+
+
+ );
+
+ const chart = (large = false) => (
+
+
+
+
+
+
+
+
+ {gridLines.map((ratio) => {
+ const y = padding.top + chartHeight * ratio;
+ return (
+
+
+
+ {fmtNumber(Math.round(metricMax * (1 - ratio)))}
+
+
+ );
+ })}
+ {metric !== "all" && }
+ {displayedSeries.map((item) => {
+ const max = Math.max(1, ...visible.map((point) => Number(point[item.key] ?? 0)));
+ const path = visible
+ .map((point, index) => {
+ const value = Number(point[item.key] ?? 0);
+ return `${index === 0 ? "M" : "L"} ${xFor(index).toFixed(2)} ${yFor(value, max).toFixed(2)}`;
+ })
+ .join(" ");
+ return (
+
+
+ {metric !== "all" &&
+ visible.map((point, index) => {
+ const value = Number(point[item.key] ?? 0);
+ const isPeak = point.date === peak.date;
+ const isLast = index === visible.length - 1;
+ return (
+
+ {`${point.date} ${item.label}: ${fmtNumber(value)}`}
+
+ );
+ })}
+
+ );
+ })}
+ {visible.map((point, index) => {
+ if (index !== 0 && index !== visible.length - 1 && index % Math.ceil(visible.length / 6) !== 0) return null;
+ return (
+
+ {point.date.slice(5)}
+
+ );
+ })}
+
+
+ );
+
+ return (
+
+
+ 每个折线点代表一天;点击指标卡可单独查看运行、问答、活跃或 Token,全部趋势会同时展示四条多色折线。
+
+
+
{metricCards()}
+ {analysisDialog(
+ "时间趋势分析",
+ <>当前查看 {metric === "all" ? "全部指标" : activeSeries.label},峰值 {peak.date} / {fmtNumber(Number(peak[activeMetric] ?? 0))}。>,
+ )}
+
+
+
+
最新 {metric === "all" ? "运行" : activeSeries.label}
+
{fmtNumber(latestValue)}
+
= 0 ? "text-[#C1121F] dark:text-[#7FACF2]" : "text-[#7F1D1D] dark:text-[#FCA5A5]")}>
+ 较上一日 {delta >= 0 ? "+" : ""}{fmtNumber(delta)}
+
+
+ 峰值 {peak.date.slice(5)} / {fmtNumber(Number(peak[activeMetric] ?? 0))}
+
+
+ {chart(false)}
+
);
}
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 5b1e0e7c..8825bb2a 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/admin_users.py
+++ b/offline-backend-20260512/backend/app/gateway/routers/admin_users.py
@@ -808,6 +808,10 @@ async def _query_user_leaderboard(
if user_id:
stmt = stmt.where(UserRow.id == user_id)
stmt = _apply_user_scope(stmt, UserRow.id, exclude_user_ids=exclude_user_ids, include_admins=include_admins)
+ # 排行榜只统计真正产生过问答的用户;从未问答的账号(仅注册、零活跃)不计入榜单与人数。
+ # 个人分析(指定 user_id)不受此限制,仍可查看任意单个用户。
+ if user_id is None:
+ stmt = stmt.where(func.coalesce(run_agg.c.qa_count, 0) > 0)
stmt = stmt.order_by(
desc(func.coalesce(run_agg.c.run_count, 0) + func.coalesce(tool_agg.c.tool_call_count, 0)),
desc(func.coalesce(run_agg.c.total_tokens, 0)),
diff --git a/offline-backend-20260512/backend/app/gateway/routers/knowledge.py b/offline-backend-20260512/backend/app/gateway/routers/knowledge.py
index febe9531..52bc016d 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/knowledge.py
+++ b/offline-backend-20260512/backend/app/gateway/routers/knowledge.py
@@ -15,7 +15,7 @@ import logging
import mimetypes
from typing import Any
-from fastapi import APIRouter, HTTPException, Request
+from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
@@ -40,6 +40,10 @@ class IngestThreadRequest(BaseModel):
status: str = Field(default="approved", description="draft | approved")
tags: list[str] = Field(default_factory=list)
include_sources: bool = True
+ background: bool = Field(
+ default=False,
+ description="When true, sediment the thread asynchronously in a background task and return immediately.",
+ )
class BatchIngestRequest(BaseModel):
@@ -117,16 +121,64 @@ def _messages_and_meta(values: dict[str, Any]) -> tuple[list[dict[str, Any]], st
return messages, title, batches
+async def _ingest_thread_background(service, checkpointer, thread_id: str, body: IngestThreadRequest, created_by: str | None) -> None:
+ """Sediment a thread into a note without an HTTP context.
+
+ Runs after the response is sent (FastAPI ``BackgroundTasks``); ``created_by``
+ must be captured by the request handler since the user contextvar is gone
+ by the time this executes. All failures are swallowed (logged) — the user
+ has already been told the save was queued.
+ """
+ try:
+ values = await _read_thread_values(checkpointer, thread_id)
+ messages, title, batches = _messages_and_meta(values)
+ if not messages:
+ logger.warning("Background knowledge ingest skipped: thread %s has no messages", sanitize_log_param(thread_id))
+ return
+ await service.capture_thread(
+ thread_id=thread_id,
+ messages=messages,
+ thread_title=title,
+ mode=body.mode,
+ title=body.title,
+ status=body.status,
+ tags=body.tags,
+ include_sources=body.include_sources,
+ reference_batches=batches,
+ created_by=created_by,
+ )
+ except EmptyThreadError:
+ logger.warning("Background knowledge ingest skipped: thread %s has no sediment-able content", sanitize_log_param(thread_id))
+ except Exception:
+ logger.exception("Background knowledge ingest failed for thread %s", sanitize_log_param(thread_id))
+
+
# --------------------------------------------------------------------------- #
# Ingest
# --------------------------------------------------------------------------- #
@router.post("/ingest/thread/{thread_id}")
@require_auth
@require_permission("threads", "read", owner_check=True)
-async def ingest_thread(thread_id: str, body: IngestThreadRequest, request: Request) -> dict[str, Any]:
- """Sediment a single thread into a knowledge note."""
+async def ingest_thread(thread_id: str, body: IngestThreadRequest, request: Request, background_tasks: BackgroundTasks) -> dict[str, Any]:
+ """Sediment a single thread into a knowledge note.
+
+ With ``background=true`` the heavy capture (read checkpoint → extract →
+ write vault + DB) is deferred to a background task and the endpoint returns
+ ``{"queued": true}`` immediately, so the user's "保存" click never blocks.
+ """
service = get_knowledge_service(request)
checkpointer = get_checkpointer(request)
+ if body.background:
+ # Capture the user id now — the contextvar is gone once the task runs.
+ background_tasks.add_task(
+ _ingest_thread_background,
+ service,
+ checkpointer,
+ thread_id,
+ body,
+ get_effective_user_id(),
+ )
+ return {"queued": True}
values = await _read_thread_values(checkpointer, thread_id)
messages, title, batches = _messages_and_meta(values)
if not messages: