Merge branch 'ji' of http://192.168.0.200/knowledge-base-bj/deerflow-server into ji
This commit is contained in:
commit
7657c45aae
11 files changed
+1102
-51
No files matched your search
@@ -136,21 +136,21 @@ export function AgentManageDialog({
|
||||
{agents.map((agent, index) => (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="flex items-center gap-2 px-3 py-2 hover:bg-accent/30"
|
||||
className="flex min-w-0 items-start gap-2 px-3 py-2 hover:bg-accent/30"
|
||||
>
|
||||
<span className="text-muted-foreground w-6 shrink-0 text-center text-xs tabular-nums">
|
||||
{index + 1}
|
||||
</span>
|
||||
<BotIcon className="text-muted-foreground size-4 shrink-0" />
|
||||
<BotIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm">{agent.name}</span>
|
||||
{agent.description && (
|
||||
<span className="truncate text-[11px] text-muted-foreground">
|
||||
<span className="line-clamp-2 whitespace-normal break-words text-[11px] leading-snug text-muted-foreground">
|
||||
{agent.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<Tooltip content="上移">
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -222,13 +222,13 @@ export function AgentManageDialog({
|
||||
key={agent.id}
|
||||
value={`${agent.name} ${agent.description ?? ""}`}
|
||||
onSelect={() => void add(agent)}
|
||||
className="flex min-w-0 items-center overflow-hidden"
|
||||
className="flex w-full min-w-0 items-start overflow-hidden"
|
||||
>
|
||||
<BotIcon className="text-muted-foreground mr-2 size-4 shrink-0" />
|
||||
<BotIcon className="text-muted-foreground mr-2 mt-0.5 size-4 shrink-0" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm">{agent.name}</span>
|
||||
{agent.description && (
|
||||
<span className="truncate text-[10px] text-muted-foreground">
|
||||
<span className="line-clamp-2 whitespace-normal break-words text-[10px] leading-snug text-muted-foreground">
|
||||
{agent.description}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -120,15 +120,15 @@ export function AgentSelector({
|
||||
value={`${agent.name} ${agent.description ?? ""}`}
|
||||
onSelect={() => handleSelect(agent)}
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-2 overflow-hidden",
|
||||
"flex w-full min-w-0 items-start gap-2 overflow-hidden",
|
||||
agent.id === selectedAgentId && "bg-accent",
|
||||
)}
|
||||
>
|
||||
<BotIcon className="text-muted-foreground size-4 shrink-0" />
|
||||
<BotIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm">{agent.name}</span>
|
||||
{agent.description && (
|
||||
<span className="truncate text-[10px] text-muted-foreground">
|
||||
<span className="line-clamp-2 whitespace-normal break-words text-[10px] leading-snug text-muted-foreground">
|
||||
{agent.description}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -86,7 +86,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
|
||||
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
||||
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
||||
import { useUserPreferences } from "@/core/user-preferences";
|
||||
import {
|
||||
isThreadRagModeLocked,
|
||||
lockThreadRagMode,
|
||||
@@ -204,7 +205,11 @@ export function InputBox({
|
||||
null,
|
||||
);
|
||||
|
||||
const { questions: recommendedQuestions } = useRecommendedQuestions();
|
||||
const { questions: recommendedQuestions } = useRecommendedQuestions();
|
||||
const userPreferences = useUserPreferences();
|
||||
const showAdminRecommendedQuestions =
|
||||
!userPreferences.hide_admin_prompt_prefix &&
|
||||
recommendedQuestions.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (models.length === 0) {
|
||||
@@ -511,7 +516,7 @@ export function InputBox({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && recommendedQuestions.length > 0 && (
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
|
||||
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
|
||||
{recommendedQuestions.map((q) => (
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
import {
|
||||
AlertCircle,
|
||||
ArchiveIcon,
|
||||
ArrowDownUpIcon,
|
||||
@@ -22,9 +22,9 @@ import {
|
||||
Trash2Icon,
|
||||
TrendingUpIcon,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -45,9 +45,8 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
@@ -145,11 +144,11 @@ const rescanErrorToastDarkOptions = {
|
||||
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
|
||||
} as const;
|
||||
|
||||
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const { theme } = useTheme();
|
||||
const router = useRouter();
|
||||
const [filter, setFilter] = useState<string>("mine");
|
||||
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const { theme } = useTheme();
|
||||
const router = useRouter();
|
||||
const [filter, setFilter] = useState<string>("mine");
|
||||
const [installOpen, setInstallOpen] = useState(false);
|
||||
const [staleSheetOpen, setStaleSheetOpen] = useState(false);
|
||||
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
|
||||
@@ -222,12 +221,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
[filteredSkills, page],
|
||||
);
|
||||
|
||||
const handleCreateSkill = () => {
|
||||
onClose?.();
|
||||
router.push("/?mode=skill");
|
||||
};
|
||||
|
||||
const handleRescan = async () => {
|
||||
const handleRescan = async () => {
|
||||
try {
|
||||
const result = await reconcileMutation.mutateAsync();
|
||||
if (result.adopted.length > 0) {
|
||||
@@ -326,12 +320,8 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
<Upload className="size-4" />
|
||||
{t.settings.skills.installSkill}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleCreateSkill}>
|
||||
<SparklesIcon className="size-4" />
|
||||
{t.settings.skills.createSkill}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
</header>
|
||||
<TagSearchBar
|
||||
targetType="skill"
|
||||
onSearchChange={setSearch}
|
||||
@@ -363,9 +353,9 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
|
||||
)}
|
||||
{error && <div className="text-destructive text-sm">Error: {error.message}</div>}
|
||||
{!isLoading && !error && filteredSkills.length === 0 && (
|
||||
<EmptySkill onCreateSkill={handleCreateSkill} />
|
||||
)}
|
||||
{!isLoading && !error && filteredSkills.length === 0 && (
|
||||
<EmptySkill />
|
||||
)}
|
||||
|
||||
{/* —— 原纵向列表布局(保留备查)——
|
||||
{!isLoading && !error &&
|
||||
@@ -986,10 +976,10 @@ export function SkillRow({
|
||||
);
|
||||
}
|
||||
|
||||
function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Empty>
|
||||
function EmptySkill() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<SparklesIcon />
|
||||
@@ -999,12 +989,9 @@ function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
|
||||
{t.settings.skills.emptyDescription}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button onClick={onCreateSkill}>{t.settings.skills.emptyButton}</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
|
||||
function InstallSkillDialog({
|
||||
open,
|
||||
|
||||
@@ -35,6 +35,88 @@ export interface AdminMemoryResponse {
|
||||
buckets: AdminMemoryBucket[];
|
||||
}
|
||||
|
||||
export interface AdminLeaderboardUser {
|
||||
user_id: string;
|
||||
email: string;
|
||||
system_role: string;
|
||||
thread_count: number;
|
||||
run_count: number;
|
||||
qa_count: number;
|
||||
llm_call_count: number;
|
||||
tool_call_count: number;
|
||||
skill_call_count: number;
|
||||
total_tokens: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
last_active_at: string;
|
||||
}
|
||||
|
||||
export interface AdminLeaderboardSkill {
|
||||
name: string;
|
||||
call_count: number;
|
||||
user_count: number;
|
||||
success_count: number;
|
||||
error_count: number;
|
||||
avg_duration_ms: number;
|
||||
last_used_at: string;
|
||||
}
|
||||
|
||||
export interface AdminLeaderboardQuestion {
|
||||
text: string;
|
||||
user_id: string;
|
||||
email: string;
|
||||
thread_id: string;
|
||||
run_id: string;
|
||||
asked_at: string;
|
||||
}
|
||||
|
||||
export interface AdminLeaderboardTrendPoint {
|
||||
date: string;
|
||||
runs: number;
|
||||
questions: number;
|
||||
tokens: number;
|
||||
users: number;
|
||||
}
|
||||
|
||||
export interface AdminLeaderboardOverview {
|
||||
total_users: number;
|
||||
active_users: number;
|
||||
total_threads: number;
|
||||
total_runs: number;
|
||||
total_questions: number;
|
||||
total_tokens: number;
|
||||
total_skill_calls: number;
|
||||
total_tool_calls: number;
|
||||
error_runs: number;
|
||||
}
|
||||
|
||||
export interface AdminLeaderboardResponse {
|
||||
range_days: number;
|
||||
since: string;
|
||||
until: string;
|
||||
overview: AdminLeaderboardOverview;
|
||||
users_by_activity: AdminLeaderboardUser[];
|
||||
users_by_questions: AdminLeaderboardUser[];
|
||||
users_by_tokens: AdminLeaderboardUser[];
|
||||
skills: AdminLeaderboardSkill[];
|
||||
tools: AdminLeaderboardSkill[];
|
||||
recent_questions: AdminLeaderboardQuestion[];
|
||||
frequent_questions: AdminLeaderboardQuestion[];
|
||||
trends: AdminLeaderboardTrendPoint[];
|
||||
}
|
||||
|
||||
export interface AdminUserAnalyticsResponse {
|
||||
range_days: number;
|
||||
since: string;
|
||||
until: string;
|
||||
user: AdminLeaderboardUser;
|
||||
overview: AdminLeaderboardOverview;
|
||||
skills: AdminLeaderboardSkill[];
|
||||
tools: AdminLeaderboardSkill[];
|
||||
recent_questions: AdminLeaderboardQuestion[];
|
||||
trends: AdminLeaderboardTrendPoint[];
|
||||
}
|
||||
|
||||
const base = () => `${getBackendBaseURL()}/api/admin/users`;
|
||||
|
||||
async function parse<T>(res: Response, what: string): Promise<T> {
|
||||
@@ -73,6 +155,35 @@ export async function getUserMemory(
|
||||
);
|
||||
}
|
||||
|
||||
export async function getUserLeaderboard(
|
||||
days: number,
|
||||
limit = 20,
|
||||
): Promise<AdminLeaderboardResponse> {
|
||||
const params = new URLSearchParams({
|
||||
days: String(days),
|
||||
limit: String(limit),
|
||||
});
|
||||
return parse(
|
||||
await apiFetch(`${base()}/leaderboard?${params}`),
|
||||
"Failed to load leaderboard",
|
||||
);
|
||||
}
|
||||
|
||||
export async function getUserAnalytics(
|
||||
userId: string,
|
||||
days: number,
|
||||
limit = 20,
|
||||
): Promise<AdminUserAnalyticsResponse> {
|
||||
const params = new URLSearchParams({
|
||||
days: String(days),
|
||||
limit: String(limit),
|
||||
});
|
||||
return parse(
|
||||
await apiFetch(`${base()}/${encodeURIComponent(userId)}/analytics?${params}`),
|
||||
"Failed to load user analytics",
|
||||
);
|
||||
}
|
||||
|
||||
export async function addAdminMemoryEntry(
|
||||
userId: string,
|
||||
content: string,
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
deleteAdminMemoryEntry,
|
||||
deleteAdminV1Fact,
|
||||
getUserMemory,
|
||||
getUserAnalytics,
|
||||
getUserLeaderboard,
|
||||
listUserThreadMessages,
|
||||
listUserThreads,
|
||||
replaceAdminMemoryEntry,
|
||||
@@ -13,6 +15,21 @@ import {
|
||||
updateAdminV1Section,
|
||||
} from "./api";
|
||||
|
||||
export function useUserLeaderboard(days: number, limit = 20) {
|
||||
return useQuery({
|
||||
queryKey: ["admin-activity", "leaderboard", days, limit],
|
||||
queryFn: () => getUserLeaderboard(days, limit),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUserAnalytics(userId: string | null, days: number, limit = 20) {
|
||||
return useQuery({
|
||||
queryKey: ["admin-activity", "analytics", userId, days, limit],
|
||||
queryFn: () => getUserAnalytics(userId!, days, limit),
|
||||
enabled: !!userId,
|
||||
});
|
||||
}
|
||||
|
||||
/** Threads owned by a given user. Disabled until a user is picked. */
|
||||
export function useUserThreads(userId: string | null) {
|
||||
return useQuery({
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Target,
|
||||
Trophy,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -259,6 +260,13 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
path: "/page/strategy/admin/scheduled-tasks",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-user-leaderboard",
|
||||
label: "用户排行榜",
|
||||
icon: Trophy,
|
||||
path: "/page/strategy/admin/user-leaderboard",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-user-activity",
|
||||
label: "用户活动审计",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, MessageSquareIcon, SearchIcon, UserIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -109,11 +110,13 @@ function MemoryBucketView({ bucket }: { bucket: AdminMemoryBucket }) {
|
||||
}
|
||||
|
||||
export default function AdminUserActivityPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { data: users, isLoading: usersLoading } = useAllUsers(true);
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const [threadId, setThreadId] = useState<string | null>(null);
|
||||
const [userQuery, setUserQuery] = useState("");
|
||||
const [tab, setTab] = useState<"qa" | "memory">("qa");
|
||||
const targetUserId = searchParams.get("user");
|
||||
|
||||
const { data: threads, isLoading: threadsLoading } = useUserThreads(userId);
|
||||
const { data: messages, isLoading: messagesLoading } = useUserThreadMessages(
|
||||
@@ -122,6 +125,14 @@ export default function AdminUserActivityPage() {
|
||||
);
|
||||
const { data: memory, isLoading: memoryLoading } = useUserMemory(userId);
|
||||
|
||||
useEffect(() => {
|
||||
if (targetUserId) {
|
||||
setUserId(targetUserId);
|
||||
setThreadId(null);
|
||||
setTab("qa");
|
||||
}
|
||||
}, [targetUserId]);
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
const needle = userQuery.trim().toLowerCase();
|
||||
const list = users ?? [];
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
Clock3,
|
||||
Loader2,
|
||||
Medal,
|
||||
MessageSquareText,
|
||||
MousePointerClick,
|
||||
Search,
|
||||
Sparkles,
|
||||
TrendingUp,
|
||||
UserRound,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import type {
|
||||
AdminLeaderboardQuestion,
|
||||
AdminLeaderboardSkill,
|
||||
AdminLeaderboardTrendPoint,
|
||||
AdminLeaderboardUser,
|
||||
} from "@/core/admin/api";
|
||||
import { useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type UserRankTab = "activity" | "questions" | "tokens";
|
||||
|
||||
function fmtNumber(value?: number | null): string {
|
||||
return Number(value ?? 0).toLocaleString();
|
||||
}
|
||||
|
||||
function fmtTime(value?: string | null): string {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||
}
|
||||
|
||||
function scoreOf(user: AdminLeaderboardUser): number {
|
||||
return user.run_count + user.tool_call_count;
|
||||
}
|
||||
|
||||
function clampText(value: string, max = 120): string {
|
||||
const text = value.trim();
|
||||
return text.length > max ? `${text.slice(0, max)}...` : text;
|
||||
}
|
||||
|
||||
function OverviewCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: string;
|
||||
hint: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground text-sm">{label}</span>
|
||||
<Icon className="text-muted-foreground size-4" />
|
||||
</div>
|
||||
<div className="mt-3 text-2xl font-semibold tabular-nums">{value}</div>
|
||||
<div className="text-muted-foreground mt-1 text-xs">{hint}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
|
||||
const max = Math.max(1, ...points.map((p) => p.runs));
|
||||
return (
|
||||
<div className="flex h-24 items-end gap-1">
|
||||
{points.length === 0 ? (
|
||||
<div className="text-muted-foreground flex w-full items-center justify-center text-sm">
|
||||
暂无趋势数据
|
||||
</div>
|
||||
) : (
|
||||
points.slice(-30).map((p) => (
|
||||
<div
|
||||
key={p.date}
|
||||
title={`${p.date}: ${p.runs} 次使用,${p.questions} 次问答`}
|
||||
className="min-w-2 flex-1 rounded-t bg-primary/70"
|
||||
style={{ height: `${Math.max(8, (p.runs / max) * 96)}px` }}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserRankList({
|
||||
users,
|
||||
activeUserId,
|
||||
onSelect,
|
||||
tab,
|
||||
}: {
|
||||
users: AdminLeaderboardUser[];
|
||||
activeUserId: string | null;
|
||||
onSelect: (user: AdminLeaderboardUser) => void;
|
||||
tab: UserRankTab;
|
||||
}) {
|
||||
if (users.length === 0) {
|
||||
return <div className="text-muted-foreground py-10 text-center text-sm">暂无用户统计</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{users.map((user, index) => {
|
||||
const main =
|
||||
tab === "questions"
|
||||
? `${fmtNumber(user.qa_count)} 次问答`
|
||||
: tab === "tokens"
|
||||
? `${fmtNumber(user.total_tokens)} tokens`
|
||||
: `${fmtNumber(scoreOf(user))} 次使用`;
|
||||
return (
|
||||
<button
|
||||
key={user.user_id}
|
||||
type="button"
|
||||
onClick={() => onSelect(user)}
|
||||
className={cn(
|
||||
"grid grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b px-4 py-3 text-left hover:bg-muted/70",
|
||||
activeUserId === user.user_id && "bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-muted text-sm font-semibold">
|
||||
{index < 3 ? <Medal className="size-4 text-primary" /> : index + 1}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">{user.email || user.user_id}</span>
|
||||
{user.system_role === "admin" && (
|
||||
<Badge variant="outline" className="shrink-0 text-xs">
|
||||
管理员
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
||||
<span>会话 {fmtNumber(user.thread_count)}</span>
|
||||
<span>运行 {fmtNumber(user.run_count)}</span>
|
||||
<span>技能 {fmtNumber(user.skill_call_count)}</span>
|
||||
<span>最近 {fmtTime(user.last_active_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-semibold tabular-nums">{main}</div>
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
LLM {fmtNumber(user.llm_call_count)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RankingTable({
|
||||
title,
|
||||
icon: Icon,
|
||||
items,
|
||||
empty,
|
||||
}: {
|
||||
title: string;
|
||||
icon: LucideIcon;
|
||||
items: AdminLeaderboardSkill[];
|
||||
empty: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="flex items-center gap-2 border-b px-4 py-3">
|
||||
<Icon className="text-muted-foreground size-4" />
|
||||
<h2 className="font-semibold">{title}</h2>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{items.length === 0 && (
|
||||
<div className="text-muted-foreground py-10 text-center text-sm">{empty}</div>
|
||||
)}
|
||||
{items.map((item, index) => (
|
||||
<div key={item.name} className="grid grid-cols-[32px_minmax(0,1fr)_auto] gap-3 px-4 py-3">
|
||||
<span className="text-muted-foreground text-sm tabular-nums">{index + 1}</span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{item.name}</div>
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
{fmtNumber(item.user_count)} 位用户 · 平均 {fmtNumber(item.avg_duration_ms)} ms · 最近 {fmtTime(item.last_used_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm">
|
||||
<div className="font-semibold tabular-nums">{fmtNumber(item.call_count)}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
成功 {fmtNumber(item.success_count)} / 失败 {fmtNumber(item.error_count)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionList({
|
||||
title,
|
||||
items,
|
||||
showUser = true,
|
||||
}: {
|
||||
title: string;
|
||||
items: AdminLeaderboardQuestion[];
|
||||
showUser?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="flex items-center gap-2 border-b px-4 py-3">
|
||||
<MessageSquareText className="text-muted-foreground size-4" />
|
||||
<h2 className="font-semibold">{title}</h2>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{items.length === 0 && (
|
||||
<div className="text-muted-foreground py-10 text-center text-sm">暂无问题记录</div>
|
||||
)}
|
||||
{items.map((item, index) => (
|
||||
<div key={`${item.run_id || item.text}-${index}`} className="px-4 py-3">
|
||||
<div className="text-sm leading-6">{clampText(item.text, 180)}</div>
|
||||
<div className="text-muted-foreground mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
||||
{showUser && <span>{item.email || item.user_id || "未知用户"}</span>}
|
||||
<span>{fmtTime(item.asked_at)}</span>
|
||||
{item.thread_id && <span>会话 {item.thread_id.slice(0, 8)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserDetailPanel({
|
||||
userId,
|
||||
days,
|
||||
onOpenAudit,
|
||||
}: {
|
||||
userId: string | null;
|
||||
days: number;
|
||||
onOpenAudit: (userId: string) => void;
|
||||
}) {
|
||||
const { data, isLoading } = useUserAnalytics(userId, days, 12);
|
||||
|
||||
if (!userId) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
|
||||
点击排行榜中的用户查看个人统计
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
加载用户详情
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-lg font-semibold">{data.user.email || data.user.user_id}</div>
|
||||
<div className="text-muted-foreground mt-1 text-xs">最近活跃 {fmtTime(data.user.last_active_at)}</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => onOpenAudit(data.user.user_id)}>
|
||||
审计详情
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||
<Metric label="问答次数" value={data.user.qa_count} />
|
||||
<Metric label="运行次数" value={data.user.run_count} />
|
||||
<Metric label="技能调用" value={data.user.skill_call_count} />
|
||||
<Metric label="总 Tokens" value={data.user.total_tokens} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="mb-3 flex items-center gap-2 font-semibold">
|
||||
<TrendingUp className="text-muted-foreground size-4" />
|
||||
个人使用趋势
|
||||
</div>
|
||||
<MiniBars points={data.trends} />
|
||||
</div>
|
||||
|
||||
<RankingTable title="个人常用技能" icon={Sparkles} items={data.skills} empty="暂无技能调用" />
|
||||
<QuestionList title="最近提问" items={data.recent_questions} showUser={false} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded-md bg-muted/60 px-3 py-2">
|
||||
<div className="text-muted-foreground text-xs">{label}</div>
|
||||
<div className="mt-1 font-semibold tabular-nums">{fmtNumber(value)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminUserLeaderboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const [days, setDays] = useState(30);
|
||||
const [tab, setTab] = useState<UserRankTab>("activity");
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
const { data, isLoading, error } = useUserLeaderboard(days, 30);
|
||||
|
||||
const users = useMemo(() => {
|
||||
const source =
|
||||
tab === "questions"
|
||||
? data?.users_by_questions
|
||||
: tab === "tokens"
|
||||
? data?.users_by_tokens
|
||||
: 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;
|
||||
|
||||
return (
|
||||
<div className="flex size-full min-h-0 bg-muted/20">
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="border-b bg-background px-6 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">用户排行榜</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
汇总用户活跃、问答、Token、技能、工具和近期问题,按时间范围统计。
|
||||
</p>
|
||||
</div>
|
||||
<Select value={String(days)} onValueChange={(value) => setDays(Number(value))}>
|
||||
<SelectTrigger className="w-36">
|
||||
<Clock3 className="mr-2 size-4" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7">近 7 天</SelectItem>
|
||||
<SelectItem value="30">近 30 天</SelectItem>
|
||||
<SelectItem value="90">近 90 天</SelectItem>
|
||||
<SelectItem value="365">近 1 年</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
{isLoading && (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-16 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
加载统计数据
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && error && (
|
||||
<div className="text-destructive rounded-lg border bg-card p-6 text-sm">
|
||||
加载失败:{error.message}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && data && overview && (
|
||||
<>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<OverviewCard icon={Users} label="总用户" value={fmtNumber(overview.total_users)} hint={`${fmtNumber(overview.active_users)} 位在当前周期活跃`} />
|
||||
<OverviewCard icon={MousePointerClick} label="使用次数" value={fmtNumber(overview.total_runs)} hint={`${fmtNumber(overview.total_threads)} 个会话`} />
|
||||
<OverviewCard icon={MessageSquareText} label="问答次数" value={fmtNumber(overview.total_questions)} hint={`失败运行 ${fmtNumber(overview.error_runs)} 次`} />
|
||||
<OverviewCard icon={Sparkles} label="技能/工具调用" value={fmtNumber(overview.total_skill_calls)} hint={`工具总调用 ${fmtNumber(overview.total_tool_calls)} 次`} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.4fr)_minmax(360px,0.8fr)]">
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="text-muted-foreground size-4" />
|
||||
<h2 className="font-semibold">用户排行</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="搜索用户"
|
||||
className="h-9 w-52 pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Tabs value={tab} onValueChange={(value) => setTab(value as UserRankTab)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="activity">使用</TabsTrigger>
|
||||
<TabsTrigger value="questions">问答</TabsTrigger>
|
||||
<TabsTrigger value="tokens">消耗</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
<UserRankList
|
||||
users={users}
|
||||
activeUserId={selectedUserId}
|
||||
tab={tab}
|
||||
onSelect={(user) => setSelectedUserId(user.user_id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="flex items-center gap-2 border-b px-4 py-3">
|
||||
<UserRound className="text-muted-foreground size-4" />
|
||||
<h2 className="font-semibold">用户详情</h2>
|
||||
</div>
|
||||
<div className="h-[560px]">
|
||||
<UserDetailPanel
|
||||
userId={selectedUserId}
|
||||
days={days}
|
||||
onOpenAudit={(userId) => {
|
||||
navigate(`/page/strategy/admin/user-activity?user=${encodeURIComponent(userId)}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="mb-3 flex items-center gap-2 font-semibold">
|
||||
<TrendingUp className="text-muted-foreground size-4" />
|
||||
时间趋势
|
||||
</div>
|
||||
<MiniBars points={data.trends} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<RankingTable title="最常用技能" icon={Sparkles} items={data.skills} empty="当前周期暂无技能调用" />
|
||||
<RankingTable title="最常用工具" icon={Activity} items={data.tools} empty="当前周期暂无工具调用" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<QuestionList title="最近经常问的问题" items={data.frequent_questions} />
|
||||
<QuestionList title="最新问题" items={data.recent_questions} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import LlmMetricsPage from "./LlmMetricsPage";
|
||||
import UsersPage from "./UsersPage";
|
||||
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
||||
import AdminUserActivityPage from "./AdminUserActivityPage";
|
||||
import AdminUserLeaderboardPage from "./AdminUserLeaderboardPage";
|
||||
import TagsPage from "./TagsPage";
|
||||
import LightAppNewNotePage from "./LightAppNewNotePage";
|
||||
import LightAppNoteListPage from "./LightAppNoteListPage";
|
||||
@@ -244,6 +245,16 @@ export default function StrategyRoutes() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/user-leaderboard"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<AdminUserLeaderboardPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/user-activity"
|
||||
element={
|
||||
|
||||
@@ -15,8 +15,11 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import and_, case, desc, func, select
|
||||
|
||||
from app.gateway.deps import (
|
||||
get_agent_store,
|
||||
@@ -28,6 +31,11 @@ from deerflow.agents.memory import BuiltinFileProvider
|
||||
from deerflow.config.paths import get_paths
|
||||
from deerflow.runtime import serialize_channel_values
|
||||
from app.gateway.routers.memory import _build_v1_provider, _is_v1
|
||||
from deerflow.persistence.engine import get_session_factory
|
||||
from deerflow.persistence.run.model import RunRow
|
||||
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
||||
from deerflow.persistence.tool_metrics.model import ToolCallMetricRow
|
||||
from deerflow.persistence.user.model import UserRow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/admin/users", tags=["admin-users"])
|
||||
@@ -45,6 +53,429 @@ async def _require_admin(request: Request) -> None:
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
|
||||
|
||||
# ── Leaderboard / analytics ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AdminLeaderboardUser(BaseModel):
|
||||
user_id: str
|
||||
email: str = ""
|
||||
system_role: str = "user"
|
||||
thread_count: int = 0
|
||||
run_count: int = 0
|
||||
qa_count: int = 0
|
||||
llm_call_count: int = 0
|
||||
tool_call_count: int = 0
|
||||
skill_call_count: int = 0
|
||||
total_tokens: int = 0
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
last_active_at: str = ""
|
||||
|
||||
|
||||
class AdminLeaderboardSkill(BaseModel):
|
||||
name: str
|
||||
call_count: int = 0
|
||||
user_count: int = 0
|
||||
success_count: int = 0
|
||||
error_count: int = 0
|
||||
avg_duration_ms: int = 0
|
||||
last_used_at: str = ""
|
||||
|
||||
|
||||
class AdminLeaderboardQuestion(BaseModel):
|
||||
text: str
|
||||
user_id: str = ""
|
||||
email: str = ""
|
||||
thread_id: str = ""
|
||||
run_id: str = ""
|
||||
asked_at: str = ""
|
||||
|
||||
|
||||
class AdminLeaderboardTrendPoint(BaseModel):
|
||||
date: str
|
||||
runs: int = 0
|
||||
questions: int = 0
|
||||
tokens: int = 0
|
||||
users: int = 0
|
||||
|
||||
|
||||
class AdminLeaderboardOverview(BaseModel):
|
||||
total_users: int = 0
|
||||
active_users: int = 0
|
||||
total_threads: int = 0
|
||||
total_runs: int = 0
|
||||
total_questions: int = 0
|
||||
total_tokens: int = 0
|
||||
total_skill_calls: int = 0
|
||||
total_tool_calls: int = 0
|
||||
error_runs: int = 0
|
||||
|
||||
|
||||
class AdminLeaderboardResponse(BaseModel):
|
||||
range_days: int
|
||||
since: str
|
||||
until: str
|
||||
overview: AdminLeaderboardOverview
|
||||
users_by_activity: list[AdminLeaderboardUser] = Field(default_factory=list)
|
||||
users_by_questions: list[AdminLeaderboardUser] = Field(default_factory=list)
|
||||
users_by_tokens: list[AdminLeaderboardUser] = Field(default_factory=list)
|
||||
skills: list[AdminLeaderboardSkill] = Field(default_factory=list)
|
||||
tools: list[AdminLeaderboardSkill] = Field(default_factory=list)
|
||||
recent_questions: list[AdminLeaderboardQuestion] = Field(default_factory=list)
|
||||
frequent_questions: list[AdminLeaderboardQuestion] = Field(default_factory=list)
|
||||
trends: list[AdminLeaderboardTrendPoint] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AdminUserAnalyticsResponse(BaseModel):
|
||||
range_days: int
|
||||
since: str
|
||||
until: str
|
||||
user: AdminLeaderboardUser
|
||||
overview: AdminLeaderboardOverview
|
||||
skills: list[AdminLeaderboardSkill] = Field(default_factory=list)
|
||||
tools: list[AdminLeaderboardSkill] = Field(default_factory=list)
|
||||
recent_questions: list[AdminLeaderboardQuestion] = Field(default_factory=list)
|
||||
trends: list[AdminLeaderboardTrendPoint] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now()
|
||||
|
||||
|
||||
def _iso(value) -> str:
|
||||
return value.isoformat() if isinstance(value, datetime) else str(value or "")
|
||||
|
||||
|
||||
def _range_start(days: int) -> datetime:
|
||||
return _now() - timedelta(days=max(1, min(days, 365)))
|
||||
|
||||
|
||||
def _question_count_expr():
|
||||
return func.sum(case((RunRow.first_human_message.is_not(None), 1), else_=0))
|
||||
|
||||
|
||||
def _run_time_filter(since: datetime, until: datetime):
|
||||
return and_(RunRow.created_at >= since, RunRow.created_at <= until)
|
||||
|
||||
|
||||
def _tool_time_filter(since: datetime, until: datetime):
|
||||
return and_(ToolCallMetricRow.created_at >= since, ToolCallMetricRow.created_at <= until)
|
||||
|
||||
|
||||
async def _query_user_leaderboard(session, since: datetime, until: datetime, user_id: str | None = None) -> list[AdminLeaderboardUser]:
|
||||
run_agg = (
|
||||
select(
|
||||
RunRow.user_id.label("user_id"),
|
||||
func.count(RunRow.run_id).label("run_count"),
|
||||
_question_count_expr().label("qa_count"),
|
||||
func.coalesce(func.sum(RunRow.llm_call_count), 0).label("llm_call_count"),
|
||||
func.coalesce(func.sum(RunRow.total_tokens), 0).label("total_tokens"),
|
||||
func.coalesce(func.sum(RunRow.total_input_tokens), 0).label("input_tokens"),
|
||||
func.coalesce(func.sum(RunRow.total_output_tokens), 0).label("output_tokens"),
|
||||
func.max(RunRow.updated_at).label("last_active_at"),
|
||||
)
|
||||
.where(_run_time_filter(since, until))
|
||||
.group_by(RunRow.user_id)
|
||||
.subquery()
|
||||
)
|
||||
thread_agg = (
|
||||
select(ThreadMetaRow.user_id.label("user_id"), func.count(ThreadMetaRow.thread_id).label("thread_count"))
|
||||
.where(and_(ThreadMetaRow.created_at >= since, ThreadMetaRow.created_at <= until))
|
||||
.group_by(ThreadMetaRow.user_id)
|
||||
.subquery()
|
||||
)
|
||||
tool_agg = (
|
||||
select(
|
||||
ToolCallMetricRow.user_id.label("user_id"),
|
||||
func.count(ToolCallMetricRow.id).label("tool_call_count"),
|
||||
func.sum(case((ToolCallMetricRow.skill_name.is_not(None), 1), else_=0)).label("skill_call_count"),
|
||||
)
|
||||
.where(_tool_time_filter(since, until))
|
||||
.group_by(ToolCallMetricRow.user_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
UserRow.id,
|
||||
UserRow.email,
|
||||
UserRow.system_role,
|
||||
func.coalesce(thread_agg.c.thread_count, 0),
|
||||
func.coalesce(run_agg.c.run_count, 0),
|
||||
func.coalesce(run_agg.c.qa_count, 0),
|
||||
func.coalesce(run_agg.c.llm_call_count, 0),
|
||||
func.coalesce(tool_agg.c.tool_call_count, 0),
|
||||
func.coalesce(tool_agg.c.skill_call_count, 0),
|
||||
func.coalesce(run_agg.c.total_tokens, 0),
|
||||
func.coalesce(run_agg.c.input_tokens, 0),
|
||||
func.coalesce(run_agg.c.output_tokens, 0),
|
||||
run_agg.c.last_active_at,
|
||||
)
|
||||
.outerjoin(run_agg, run_agg.c.user_id == UserRow.id)
|
||||
.outerjoin(thread_agg, thread_agg.c.user_id == UserRow.id)
|
||||
.outerjoin(tool_agg, tool_agg.c.user_id == UserRow.id)
|
||||
)
|
||||
if user_id:
|
||||
stmt = stmt.where(UserRow.id == user_id)
|
||||
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)),
|
||||
)
|
||||
rows = (await session.execute(stmt)).all()
|
||||
return [
|
||||
AdminLeaderboardUser(
|
||||
user_id=r[0],
|
||||
email=r[1] or "",
|
||||
system_role=r[2] or "user",
|
||||
thread_count=int(r[3] or 0),
|
||||
run_count=int(r[4] or 0),
|
||||
qa_count=int(r[5] or 0),
|
||||
llm_call_count=int(r[6] or 0),
|
||||
tool_call_count=int(r[7] or 0),
|
||||
skill_call_count=int(r[8] or 0),
|
||||
total_tokens=int(r[9] or 0),
|
||||
input_tokens=int(r[10] or 0),
|
||||
output_tokens=int(r[11] or 0),
|
||||
last_active_at=_iso(r[12]),
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
async def _query_call_rankings(session, since: datetime, until: datetime, *, user_id: str | None, field: str, limit: int) -> list[AdminLeaderboardSkill]:
|
||||
name_col = ToolCallMetricRow.skill_name if field == "skill" else ToolCallMetricRow.tool_name
|
||||
call_count = func.count(ToolCallMetricRow.id).label("call_count")
|
||||
last_used_at = func.max(ToolCallMetricRow.created_at).label("last_used_at")
|
||||
stmt = (
|
||||
select(
|
||||
name_col.label("name"),
|
||||
call_count,
|
||||
func.count(func.distinct(ToolCallMetricRow.user_id)).label("user_count"),
|
||||
func.sum(case((ToolCallMetricRow.status == "success", 1), else_=0)).label("success_count"),
|
||||
func.sum(case((ToolCallMetricRow.status != "success", 1), else_=0)).label("error_count"),
|
||||
func.coalesce(func.avg(ToolCallMetricRow.duration_ms), 0).label("avg_duration_ms"),
|
||||
last_used_at,
|
||||
)
|
||||
.where(_tool_time_filter(since, until), name_col.is_not(None), name_col != "")
|
||||
.group_by(name_col)
|
||||
.order_by(call_count.desc(), last_used_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
if user_id:
|
||||
stmt = stmt.where(ToolCallMetricRow.user_id == user_id)
|
||||
rows = (await session.execute(stmt)).all()
|
||||
return [
|
||||
AdminLeaderboardSkill(
|
||||
name=r[0] or "",
|
||||
call_count=int(r[1] or 0),
|
||||
user_count=int(r[2] or 0),
|
||||
success_count=int(r[3] or 0),
|
||||
error_count=int(r[4] or 0),
|
||||
avg_duration_ms=int(r[5] or 0),
|
||||
last_used_at=_iso(r[6]),
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
async def _query_recent_questions(session, since: datetime, until: datetime, *, user_id: str | None, limit: int) -> list[AdminLeaderboardQuestion]:
|
||||
stmt = (
|
||||
select(RunRow.first_human_message, RunRow.user_id, UserRow.email, RunRow.thread_id, RunRow.run_id, RunRow.created_at)
|
||||
.outerjoin(UserRow, UserRow.id == RunRow.user_id)
|
||||
.where(_run_time_filter(since, until), RunRow.first_human_message.is_not(None), RunRow.first_human_message != "")
|
||||
.order_by(RunRow.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
if user_id:
|
||||
stmt = stmt.where(RunRow.user_id == user_id)
|
||||
rows = (await session.execute(stmt)).all()
|
||||
return [
|
||||
AdminLeaderboardQuestion(
|
||||
text=(r[0] or "")[:500],
|
||||
user_id=r[1] or "",
|
||||
email=r[2] or "",
|
||||
thread_id=r[3] or "",
|
||||
run_id=r[4] or "",
|
||||
asked_at=_iso(r[5]),
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
async def _query_frequent_questions(session, since: datetime, until: datetime, limit: int) -> list[AdminLeaderboardQuestion]:
|
||||
stmt = (
|
||||
select(
|
||||
RunRow.first_human_message,
|
||||
func.count(RunRow.run_id).label("count"),
|
||||
func.max(RunRow.user_id).label("user_id"),
|
||||
func.max(UserRow.email).label("email"),
|
||||
func.max(RunRow.created_at).label("asked_at"),
|
||||
)
|
||||
.outerjoin(UserRow, UserRow.id == RunRow.user_id)
|
||||
.where(_run_time_filter(since, until), RunRow.first_human_message.is_not(None), RunRow.first_human_message != "")
|
||||
.group_by(RunRow.first_human_message)
|
||||
.having(func.count(RunRow.run_id) > 1)
|
||||
.order_by(desc("count"), desc("asked_at"))
|
||||
.limit(limit)
|
||||
)
|
||||
rows = (await session.execute(stmt)).all()
|
||||
return [
|
||||
AdminLeaderboardQuestion(
|
||||
text=(r[0] or "")[:500],
|
||||
user_id=r[2] or "",
|
||||
email=r[3] or "",
|
||||
asked_at=_iso(r[4]),
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
async def _query_trends(session, since: datetime, until: datetime, *, user_id: str | None = None) -> list[AdminLeaderboardTrendPoint]:
|
||||
bucket = func.date(RunRow.created_at)
|
||||
stmt = (
|
||||
select(
|
||||
bucket.label("date"),
|
||||
func.count(RunRow.run_id).label("runs"),
|
||||
_question_count_expr().label("questions"),
|
||||
func.coalesce(func.sum(RunRow.total_tokens), 0).label("tokens"),
|
||||
func.count(func.distinct(RunRow.user_id)).label("users"),
|
||||
)
|
||||
.where(_run_time_filter(since, until))
|
||||
.group_by(bucket)
|
||||
.order_by(bucket.asc())
|
||||
)
|
||||
if user_id:
|
||||
stmt = stmt.where(RunRow.user_id == user_id)
|
||||
rows = (await session.execute(stmt)).all()
|
||||
return [
|
||||
AdminLeaderboardTrendPoint(
|
||||
date=str(r[0]),
|
||||
runs=int(r[1] or 0),
|
||||
questions=int(r[2] or 0),
|
||||
tokens=int(r[3] or 0),
|
||||
users=int(r[4] or 0),
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
async def _query_overview(session, since: datetime, until: datetime, *, user_id: str | None = None) -> AdminLeaderboardOverview:
|
||||
run_where = [_run_time_filter(since, until)]
|
||||
thread_where = [and_(ThreadMetaRow.created_at >= since, ThreadMetaRow.created_at <= until)]
|
||||
tool_where = [_tool_time_filter(since, until)]
|
||||
if user_id:
|
||||
run_where.append(RunRow.user_id == user_id)
|
||||
thread_where.append(ThreadMetaRow.user_id == user_id)
|
||||
tool_where.append(ToolCallMetricRow.user_id == user_id)
|
||||
|
||||
total_users = 1 if user_id else (await session.execute(select(func.count(UserRow.id)))).scalar_one()
|
||||
run_row = (await session.execute(
|
||||
select(
|
||||
func.count(RunRow.run_id),
|
||||
_question_count_expr(),
|
||||
func.coalesce(func.sum(RunRow.total_tokens), 0),
|
||||
func.count(func.distinct(RunRow.user_id)),
|
||||
func.sum(case((RunRow.status == "error", 1), else_=0)),
|
||||
).where(*run_where)
|
||||
)).one()
|
||||
thread_count = (await session.execute(select(func.count(ThreadMetaRow.thread_id)).where(*thread_where))).scalar_one()
|
||||
tool_row = (await session.execute(
|
||||
select(
|
||||
func.count(ToolCallMetricRow.id),
|
||||
func.sum(case((ToolCallMetricRow.skill_name.is_not(None), 1), else_=0)),
|
||||
).where(*tool_where)
|
||||
)).one()
|
||||
|
||||
return AdminLeaderboardOverview(
|
||||
total_users=int(total_users or 0),
|
||||
active_users=int(run_row[3] or 0),
|
||||
total_threads=int(thread_count or 0),
|
||||
total_runs=int(run_row[0] or 0),
|
||||
total_questions=int(run_row[1] or 0),
|
||||
total_tokens=int(run_row[2] or 0),
|
||||
total_tool_calls=int(tool_row[0] or 0),
|
||||
total_skill_calls=int(tool_row[1] or 0),
|
||||
error_runs=int(run_row[4] or 0),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/leaderboard", response_model=AdminLeaderboardResponse)
|
||||
async def get_user_leaderboard(
|
||||
request: Request,
|
||||
days: int = Query(default=30, ge=1, le=365),
|
||||
limit: int = Query(default=20, ge=5, le=100),
|
||||
) -> AdminLeaderboardResponse:
|
||||
"""Return admin analytics for user activity, Q&A, skills, questions, and trends."""
|
||||
await _require_admin(request)
|
||||
session_factory = get_session_factory()
|
||||
if session_factory is None:
|
||||
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
|
||||
|
||||
until = _now()
|
||||
since = _range_start(days)
|
||||
async with session_factory() as session:
|
||||
overview = await _query_overview(session, since, until)
|
||||
users = await _query_user_leaderboard(session, since, until)
|
||||
skills = await _query_call_rankings(session, since, until, user_id=None, field="skill", limit=limit)
|
||||
tools = await _query_call_rankings(session, since, until, user_id=None, field="tool", limit=limit)
|
||||
recent_questions = await _query_recent_questions(session, since, until, user_id=None, limit=limit)
|
||||
frequent_questions = await _query_frequent_questions(session, since, until, limit=limit)
|
||||
trends = await _query_trends(session, since, until)
|
||||
|
||||
return AdminLeaderboardResponse(
|
||||
range_days=days,
|
||||
since=_iso(since),
|
||||
until=_iso(until),
|
||||
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,
|
||||
recent_questions=recent_questions,
|
||||
frequent_questions=frequent_questions,
|
||||
trends=trends,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{user_id}/analytics", response_model=AdminUserAnalyticsResponse)
|
||||
async def get_user_analytics(
|
||||
user_id: str,
|
||||
request: Request,
|
||||
days: int = Query(default=30, ge=1, le=365),
|
||||
limit: int = Query(default=20, ge=5, le=100),
|
||||
) -> AdminUserAnalyticsResponse:
|
||||
"""Return one user's admin analytics profile."""
|
||||
await _require_admin(request)
|
||||
session_factory = get_session_factory()
|
||||
if session_factory is None:
|
||||
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
|
||||
|
||||
until = _now()
|
||||
since = _range_start(days)
|
||||
async with session_factory() as session:
|
||||
users = await _query_user_leaderboard(session, since, until, user_id=user_id)
|
||||
if not users:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
overview = await _query_overview(session, since, until, user_id=user_id)
|
||||
skills = await _query_call_rankings(session, since, until, user_id=user_id, field="skill", limit=limit)
|
||||
tools = await _query_call_rankings(session, since, until, user_id=user_id, field="tool", limit=limit)
|
||||
recent_questions = await _query_recent_questions(session, since, until, user_id=user_id, limit=limit)
|
||||
trends = await _query_trends(session, since, until, user_id=user_id)
|
||||
|
||||
return AdminUserAnalyticsResponse(
|
||||
range_days=days,
|
||||
since=_iso(since),
|
||||
until=_iso(until),
|
||||
user=users[0],
|
||||
overview=overview,
|
||||
skills=skills,
|
||||
tools=tools,
|
||||
recent_questions=recent_questions,
|
||||
trends=trends,
|
||||
)
|
||||
|
||||
|
||||
# ── Threads ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user