From 92a46003f8b7e5f7ed8b6b6708663f4a50fa564f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Mon, 8 Jun 2026 19:59:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=9F=A5=E8=AF=86=E5=BA=93=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E5=90=8E=E5=8F=B0=E4=BF=9D=E5=AD=98=20+=20=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E7=BD=AE=E9=A1=B6=E4=BB=85=E9=A6=96=E9=A1=B5=20+=20?= =?UTF-8?q?=E6=8E=92=E8=A1=8C=E6=A6=9C=E9=A1=B5=E9=9D=A2=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 知识库「保存」改为一键触发,无需填表;后端经 BackgroundTasks 异步沉淀到 wiki,点击立即返回 {queued:true} - 对话列表置顶分区仅在第一页展示,翻页后不再重复顶置 - 公开/管理员排行榜页面重构与维度增强;排行榜页改为整页统一滚动 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workspace/knowledge-save-trigger.tsx | 143 +-- frontend-web/src/core/knowledge/api.ts | 6 +- frontend-web/src/core/knowledge/types.ts | 2 + .../src/pages/AdminUserLeaderboardPage.tsx | 3 +- frontend-web/src/pages/ChatsPage.tsx | 7 +- .../src/pages/PublicLeaderboardPage.tsx | 935 ++++++++++++++---- .../app/gateway/routers/admin_users.py | 4 + .../backend/app/gateway/routers/knowledge.py | 58 +- 8 files changed, 854 insertions(+), 304 deletions(-) diff --git a/frontend-web/src/components/workspace/knowledge-save-trigger.tsx b/frontend-web/src/components/workspace/knowledge-save-trigger.tsx index b3ea6f48..e6bbcfec 100644 --- a/frontend-web/src/components/workspace/knowledge-save-trigger.tsx +++ b/frontend-web/src/components/workspace/knowledge-save-trigger.tsx @@ -1,27 +1,9 @@ "use client"; import { BookMarked } from "lucide-react"; -import { useState } from "react"; -import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { useKnowledgeActions } from "@/core/knowledge/hooks"; import { useThread } from "./messages/context"; @@ -29,130 +11,37 @@ import { Tooltip } from "./tooltip"; export function KnowledgeSaveTrigger({ threadId }: { threadId: string }) { const { thread } = useThread(); - const navigate = useNavigate(); const { ingestThread } = useKnowledgeActions(); - const [open, setOpen] = useState(false); - const [title, setTitle] = useState(""); - const [tags, setTags] = useState(""); - const [status, setStatus] = useState<"approved" | "draft">("approved"); - const [mode, setMode] = useState<"summary" | "full">("summary"); - const [includeSources, setIncludeSources] = useState(true); if (thread.messages.length === 0) { return null; } + // One click → fire-and-forget. The backend sediments the thread into the + // wiki asynchronously (title/tags auto-generated), so the click never blocks. const handleSave = async () => { try { - const result = await ingestThread.mutateAsync({ + await ingestThread.mutateAsync({ threadId, - payload: { - title: title.trim() || null, - tags: tags - .split(/[,,\s]+/) - .map((t) => t.trim()) - .filter(Boolean), - status, - mode, - include_sources: includeSources, - }, + payload: { background: true }, }); - toast.success("已保存到知识库", { - action: { - label: "查看", - onClick: () => - navigate(`/page/workspace/knowledge/${result.note.id}`), - }, - }); - setOpen(false); - setTitle(""); - setTags(""); + toast.success("已加入知识库保存队列,正在后台整理…"); } catch (error) { toast.error(error instanceof Error ? error.message : "保存失败"); } }; return ( - <> - - - - - - - - 保存到知识库 - - 将本次对话沉淀为 obsidian-wiki 兼容的知识笔记。 - - -
- setTitle(e.target.value)} - /> - setTags(e.target.value)} - /> -
- - -
- -
- - - - -
-
- + + + ); } diff --git a/frontend-web/src/core/knowledge/api.ts b/frontend-web/src/core/knowledge/api.ts index 6f9c9307..57824a41 100644 --- a/frontend-web/src/core/knowledge/api.ts +++ b/frontend-web/src/core/knowledge/api.ts @@ -102,7 +102,11 @@ export async function searchKnowledge(query: string, limit = 8) { } export async function ingestThread(threadId: string, payload: IngestThreadPayload = {}) { - return parseResponse<{ note: KnowledgeNote; created: boolean }>( + return parseResponse<{ + note?: KnowledgeNote; + created?: boolean; + queued?: boolean; + }>( await apiFetch(`${baseUrl()}/ingest/thread/${encodeURIComponent(threadId)}`, { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/frontend-web/src/core/knowledge/types.ts b/frontend-web/src/core/knowledge/types.ts index d8cf9ed8..3bdf322c 100644 --- a/frontend-web/src/core/knowledge/types.ts +++ b/frontend-web/src/core/knowledge/types.ts @@ -70,6 +70,8 @@ export interface IngestThreadPayload { status?: KnowledgeStatus; tags?: string[]; include_sources?: boolean; + /** When true the backend sediments asynchronously and returns immediately. */ + background?: boolean; } export interface BatchIngestPayload { diff --git a/frontend-web/src/pages/AdminUserLeaderboardPage.tsx b/frontend-web/src/pages/AdminUserLeaderboardPage.tsx index 0da4cdcc..62652e7f 100644 --- a/frontend-web/src/pages/AdminUserLeaderboardPage.tsx +++ b/frontend-web/src/pages/AdminUserLeaderboardPage.tsx @@ -1229,6 +1229,8 @@ export default function AdminUserLeaderboardPage() { return (
+ {/* 整页统一滚动:标题与操作工具条不再固定置顶,随内容一起滚动,避免可视区域被压缩 */} +
{/* 标题维度:仅承载页面标题与说明 */} @@ -1448,7 +1450,6 @@ export default function AdminUserLeaderboardPage() {
-
{isLoading && (
diff --git a/frontend-web/src/pages/ChatsPage.tsx b/frontend-web/src/pages/ChatsPage.tsx index d10b3c52..ed2a3f9c 100644 --- a/frontend-web/src/pages/ChatsPage.tsx +++ b/frontend-web/src/pages/ChatsPage.tsx @@ -76,8 +76,9 @@ export default function ChatsPage() { }); }, [threads, search, onlyFavorites]); - // Pinned conversations are surfaced in their own section and excluded from - // the paginated list so they stay visible regardless of the current page. + // Pinned conversations are surfaced in their own section on the first page + // only, and excluded from the paginated list (so they don't repeat on every + // page as the user pages through history). const pinnedThreads = useMemo( () => filteredThreads.filter(isThreadPinned), [filteredThreads], @@ -200,7 +201,7 @@ export default function ChatsPage() {
- {pinnedThreads.length > 0 && ( + {currentPage === 1 && pinnedThreads.length > 0 && ( <>
{t.chats.pinnedSection} diff --git a/frontend-web/src/pages/PublicLeaderboardPage.tsx b/frontend-web/src/pages/PublicLeaderboardPage.tsx index 495afb98..df783113 100644 --- a/frontend-web/src/pages/PublicLeaderboardPage.tsx +++ b/frontend-web/src/pages/PublicLeaderboardPage.tsx @@ -1,51 +1,144 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; import { Activity, Award, + BarChart3, + Bot, + CalendarDays, Crown, Gauge, + Layers3, Loader2, + Maximize2, Medal, MessageSquareText, + MousePointerClick, + Search, Sparkles, TrendingUp, Users, type LucideIcon, } from "lucide-react"; +import { useEffect, useId, useMemo, useState, type ReactNode } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + 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 { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import type { + AdminLeaderboardDimension, + AdminLeaderboardOverview, + AdminLeaderboardQuestion, AdminLeaderboardResponse, AdminLeaderboardSkill, + AdminLeaderboardTrendPoint, AdminLeaderboardUser, } from "@/core/admin/api"; import { getBackendBaseURL } from "@/core/config"; +import { cn } from "@/lib/utils"; + +type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency"; +type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal"; const RANK_COLORS = ["#C1121F", "#E11D48", "#F59E0B", "#991B1B", "#B45309"] as const; +const LEADERBOARD_PAGE_SIZE = 20; +const RANGE_DAYS = 30; -function fmtNumber(value: number | undefined | null): string { - const n = Number(value ?? 0); - if (!Number.isFinite(n)) return "0"; - return n.toLocaleString("zh-CN"); +const accentClasses: Record = { + crimson: { + soft: "bg-[#FFF1F1] dark:bg-[#2563EB]/15", + text: "text-[#C1121F] dark:text-[#7FACF2]", + bar: "bg-[#C1121F] dark:bg-[#2563EB]", + border: "border-[#F1D6D6] dark:border-[#213D5E]", + }, + rose: { + soft: "bg-[#FFF1F2] dark:bg-rose-400/15", + text: "text-[#E11D48] dark:text-rose-200", + bar: "bg-[#E11D48]", + border: "border-[#FECDD3] dark:border-rose-300/25", + }, + amber: { + soft: "bg-[#FFFBEB] dark:bg-amber-300/15", + text: "text-[#B45309] dark:text-amber-100", + bar: "bg-[#F59E0B]", + border: "border-[#FDE68A] dark:border-amber-200/25", + }, + teal: { + soft: "bg-[#ECFDF5] dark:bg-teal-300/15", + text: "text-[#0F766E] dark:text-teal-100", + bar: "bg-[#0F766E]", + border: "border-[#A7F3D0] dark:border-teal-200/25", + }, +}; + +function fmtNumber(value?: number | null): string { + return Number(value ?? 0).toLocaleString(); } -function usernameOf(email: string | undefined | null): string { - if (!email) return "匿名用户"; - const at = email.indexOf("@"); - return at > 0 ? email.slice(0, at) : email; +function fmtTime(value?: string | null): string { + if (!value) return "-"; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); } -function fmtDate(value: string | undefined | null): string { +function fmtDate(value?: string | null): string { if (!value) return ""; - const d = new Date(value); - if (Number.isNaN(d.getTime())) return ""; - return d.toLocaleString("zh-CN", { hour12: false }); + const date = new Date(value); + return Number.isNaN(date.getTime()) ? "" : date.toLocaleString("zh-CN", { hour12: false }); +} + +function clampText(value: string, max = 120): string { + const text = value.trim(); + return text.length > max ? `${text.slice(0, max)}...` : text; +} + +function userScore(user: AdminLeaderboardUser, tab: UserRankTab): number { + if (tab === "questions") return user.qa_count; + if (tab === "tokens") return user.total_tokens; + if (tab === "skills") return user.skill_call_count; + if (tab === "efficiency") { + return user.run_count > 0 ? Math.round((user.qa_count / user.run_count) * 100) : 0; + } + return user.run_count + user.tool_call_count; +} + +function scoreLabel(user: AdminLeaderboardUser, tab: UserRankTab): string { + if (tab === "questions") return `${fmtNumber(user.qa_count)} 次问答`; + if (tab === "tokens") return `${fmtNumber(user.total_tokens)} tokens`; + if (tab === "skills") return `${fmtNumber(user.skill_call_count)} 次技能`; + if (tab === "efficiency") { + return `${(user.run_count > 0 ? user.qa_count / user.run_count : 0).toFixed(1)} 问答/运行`; + } + return `${fmtNumber(userScore(user, tab))} 次使用`; +} + +function rankTabDescription(tab: UserRankTab): string { + if (tab === "questions") return "问答排行按用户提出有效问题的运行次数排序。"; + if (tab === "tokens") return "消耗排行按模型输入与输出 Token 总量排序。"; + if (tab === "skills") return "技能排行按带有技能名称的工具调用次数排序。"; + if (tab === "efficiency") return "效率排行 = 问答次数 / 运行次数,用来衡量每次运行承载的问答密度。"; + return "使用排行按运行次数与工具调用次数合计排序。"; +} + +function initials(user: AdminLeaderboardUser): string { + const source = user.email || user.user_id || "?"; + return source.slice(0, 1).toUpperCase(); } async function fetchPublicLeaderboard(days: number): Promise { const res = await fetch( - `${getBackendBaseURL()}/api/public/leaderboard?days=${days}&limit=20`, + `${getBackendBaseURL()}/api/public/leaderboard?days=${days}&limit=100`, { headers: { Accept: "application/json" } }, ); if (!res.ok) { @@ -59,7 +152,10 @@ export default function PublicLeaderboardPage() { const [data, setData] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); - const days = 30; + const [tab, setTab] = useState("activity"); + const [query, setQuery] = useState(""); + const [userPage, setUserPage] = useState(1); + const days = RANGE_DAYS; useEffect(() => { let cancelled = false; @@ -95,6 +191,41 @@ export default function PublicLeaderboardPage() { return () => clearInterval(timer); }, [pending, days]); + const rankedUsers = useMemo(() => { + const base = data?.users_by_activity ?? []; + const source = + tab === "questions" + ? data?.users_by_questions + : tab === "tokens" + ? data?.users_by_tokens + : tab === "skills" + ? [...base].sort((a, b) => b.skill_call_count - a.skill_call_count) + : tab === "efficiency" + ? [...base].sort((a, b) => userScore(b, "efficiency") - userScore(a, "efficiency")) + : data?.users_by_activity; + const needle = query.trim().toLowerCase(); + return (source ?? []).filter((user) => + needle ? `${user.email} ${user.user_id}`.toLowerCase().includes(needle) : true, + ); + }, [data, query, tab]); + + const overview = data?.overview; + const hasRows = rankedUsers.length > 0 || (overview?.total_runs ?? 0) > 0 || (overview?.total_users ?? 0) > 0; + 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); + }, [query, tab]); + + useEffect(() => { + setUserPage((page) => Math.min(page, userPageCount)); + }, [userPageCount]); + return (
@@ -106,7 +237,7 @@ export default function PublicLeaderboardPage() {

用户排行榜

- 展示近 {data?.range_days ?? days} 天用户活跃、问答、Token 消耗与技能热度。 + 展示近 {data?.range_days ?? days} 天用户活跃、问答、Token 消耗、技能与工具热度、模型与 Agent 使用及近期问题。 {data?.since && data?.until && ( ({fmtDate(data.since).slice(0, 10)} ~ {fmtDate(data.until).slice(0, 10)}) @@ -128,9 +259,10 @@ export default function PublicLeaderboardPage() {

)} - {data && ( + {data && overview && (
- {pending && ( + {/* 仅在首次统计、尚无数据时提示;已有数据时后台静默刷新,不再常驻横幅 */} + {pending && !hasRows && (
排行榜正在后台统计,数据会自动刷新。 @@ -138,47 +270,109 @@ export default function PublicLeaderboardPage() { )} {/* 概览指标 */} -
- - - - +
+ + + +
- {/* 三类用户排名 */} -
- u.run_count} - unit="次运行" - /> - u.qa_count} - unit="次问答" - /> - u.total_tokens} - unit="Token" - /> + {/* 衍生洞察 */} + + + {/* 榜单前三 */} +
+
+
+

榜单前三

+

{rankTabDescription(tab)}

+
+ setTab(value as UserRankTab)}> + + 使用排行 + 问答排行 + 消耗排行 + 技能排行 + 效率排行 + + +
+
+ + + +
- {/* 技能热度 + 高频问题 */} -
- - q.text)} - /> + {/* 完整排行榜 */} +
+
+
+ +
+

完整排行榜

+

{rankTabDescription(tab)}

+
+
+
+ + setQuery(event.target.value)} + placeholder="搜索用户" + className="h-9 w-56 bg-white pl-8 dark:bg-[#011226]" + /> +
+
+ +
+ + 共 {fmtNumber(rankedUsers.length)} 位用户,当前第 {userPage} / {userPageCount} 页 + + +
+ + {/* 时间趋势 */} +
+
+
+ +

时间趋势

+
+ {data.since && data.until && ( +
+ + {fmtDate(data.since).slice(0, 10)} 至 {fmtDate(data.until).slice(0, 10)} +
+ )} +
+ +
+ + {/* 技能与工具热度 */} +
+ + +
+ + {/* 模型 / Agent / 运行状态 */} +
+ + + +
+ + {/* 问题 */} +
+ + +
)} @@ -194,157 +388,560 @@ function StatCard({ icon: Icon, label, value, - sub, + note, + accent, }: { icon: LucideIcon; label: string; value: string; - sub?: string; + note: string; + accent: LeaderboardAccent; }) { + const color = accentClasses[accent]; return ( -
-
- - {label} +
+
+ {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) => ( -
  1. - - - {usernameOf(user.email)} - - - {fmtNumber(metric(user))} - - {unit} -
  2. - ))} -
- )} -
+
+ {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 ( - - - +
+
第 {rank} 名
+
暂无数据
+
); } - 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 ? ( -
暂无数据
- ) : ( -
    - {top.map((skill) => ( -
  • -
    - - {skill.name} - - - {fmtNumber(skill.call_count)} 次 - -
    -
    -
    -
    -
  • - ))} -
- )}
); } -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) => ( -
  1. - {index + 1}. - {text} -
  2. +
    + {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 } = {}) => ( +
+ + {series.map((item) => ( + + ))} +
+ ); + + 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: