feat: 公开用户排行榜页面 + 修复 checkpointer 连接与前端依赖

- 新增公开排行榜页面与后端 public_leaderboard 路由
- 完善 skills/agents 路由
- checkpointer Postgres 改用连接池(自愈), 解决连接关闭后定时任务连环失败
- 修复前端 mermaid 依赖未声明导致 Vite 解析失败 (新增 pnpm-lock)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-08 17:09:28 +08:00
1 parent 8113b822c5
commit 80d2db6315
26 files changed
+1877 -967

No files matched your search

+4 -1
View File
@@ -1,7 +1,7 @@
# Workspace/editor # Workspace/editor
.vscode/ .vscode/
frontend-web/.vscode/ frontend-web/.vscode/
node_modules/
# Claude Code local-only settings (per-user permissions, not shared) # Claude Code local-only settings (per-user permissions, not shared)
.claude/settings.local.json .claude/settings.local.json
@@ -84,6 +84,9 @@ offline-backend-20260512/backend/tests/*
deploy deploy
.history .history
# Cloned third-party repos (local only)
git-clone/
# Repo-root offline dependency pack (local only) # Repo-root offline dependency pack (local only)
/dist/ /dist/
+1
View File
@@ -74,6 +74,7 @@
"hast": "^1.0.0", "hast": "^1.0.0",
"katex": "^0.16.28", "katex": "^0.16.28",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"mermaid": "11.14.0",
"motion": "^12.26.2", "motion": "^12.26.2",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
+3
View File
@@ -19,6 +19,7 @@ import PublicScheduledTaskPage, {
PublicScheduledTaskFullscreenPage, PublicScheduledTaskFullscreenPage,
} from "./pages/PublicScheduledTaskPage"; } from "./pages/PublicScheduledTaskPage";
import PublicSharePage from "./pages/PublicSharePage"; import PublicSharePage from "./pages/PublicSharePage";
import PublicLeaderboardPage from "./pages/PublicLeaderboardPage";
import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage"; import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
import EmbedChatPage from "./pages/EmbedChatPage"; import EmbedChatPage from "./pages/EmbedChatPage";
import { ChatRuntime } from "./pages/WorkspaceRoutes"; import { ChatRuntime } from "./pages/WorkspaceRoutes";
@@ -66,6 +67,8 @@ export function App() {
<Route path="/landing" element={<LandingPage />} /> <Route path="/landing" element={<LandingPage />} />
{/* Public read-only conversation viewer — no authentication. */} {/* Public read-only conversation viewer — no authentication. */}
<Route path="/share/:shareCode" element={<PublicSharePage />} /> <Route path="/share/:shareCode" element={<PublicSharePage />} />
{/* Public read-only user leaderboard publish page — no authentication. */}
<Route path="/public/leaderboard" element={<PublicLeaderboardPage />} />
{/* Public read-only roundtable share — landing + full result report. */} {/* Public read-only roundtable share — landing + full result report. */}
<Route <Route
path="/roundtable-share/:shareCode" path="/roundtable-share/:shareCode"
@@ -14,6 +14,7 @@ import {
StarIcon, StarIcon,
TagsIcon, TagsIcon,
Trash2Icon, Trash2Icon,
UserIcon,
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
@@ -497,6 +498,18 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
</> </>
); );
// 发布者(仅自定义/已发布的智能体显示;内置无 owner 不显示)。
const publisherNode =
!isBuiltin && agent.owner_username ? (
<div
className="text-muted-foreground flex items-center gap-1 text-xs"
title={`发布者:${desensitize(agent.owner_username)}`}
>
<UserIcon className="size-3 shrink-0" />
<span className="truncate">发布者:{desensitize(agent.owner_username)}</span>
</div>
) : null;
const skillBadgeItems = [ const skillBadgeItems = [
...(agent.tool_groups ?? []).map((group) => ({ ...(agent.tool_groups ?? []).map((group) => ({
key: `tg:${group}`, key: `tg:${group}`,
@@ -585,15 +598,15 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
<SparklesIcon className="h-3.5 w-3.5" /> <SparklesIcon className="h-3.5 w-3.5" />
</Button> </Button>
)} )}
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
className="h-8 w-8 shrink-0" className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)} onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags} title={t.adminTags.manageTags}
> >
<TagsIcon className="h-3.5 w-3.5" /> <TagsIcon className="h-3.5 w-3.5" />
</Button> </Button>
{isOwned && ( {isOwned && (
<Button <Button
size="icon" size="icon"
@@ -702,6 +715,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
{desensitize(agent.description)} {desensitize(agent.description)}
</CardDescription> </CardDescription>
)} )}
{publisherNode && <div className="mt-2">{publisherNode}</div>}
</CardHeader> </CardHeader>
{(agent.tags?.length ?? 0) > 0 && ( {(agent.tags?.length ?? 0) > 0 && (
@@ -747,6 +761,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
{desensitize(agent.description)} {desensitize(agent.description)}
</p> </p>
)} )}
{publisherNode && <div className="mt-0.5">{publisherNode}</div>}
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
{showActionRow && actionButtons} {showActionRow && actionButtons}
@@ -934,13 +949,13 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<TagPickerDialog <TagPickerDialog
open={tagPickerOpen} open={tagPickerOpen}
onOpenChange={setTagPickerOpen} onOpenChange={setTagPickerOpen}
targetType="agent" targetType="agent"
targetId={agent.id} targetId={agent.id}
currentTags={agent.tags ?? []} currentTags={agent.tags ?? []}
/> />
<SkillDetailDialog <SkillDetailDialog
@@ -30,6 +30,10 @@ import {
} from "@/core/system-settings/hooks"; } from "@/core/system-settings/hooks";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage";
import RecommendedQuestionsPage from "@/pages/RecommendedQuestionsPage";
import { SettingsSection } from "./settings-section"; import { SettingsSection } from "./settings-section";
const languageOptions: { value: Locale; label: string }[] = [ const languageOptions: { value: Locale; label: string }[] = [
@@ -336,6 +340,17 @@ export function AppearanceSettingsPage() {
</div> </div>
)} )}
</SettingsSection> </SettingsSection>
{isAdmin && (
<>
<Separator />
<RecommendedQuestionsPage embedded />
<Separator />
<PromptPrefixSettingsPage embedded />
<Separator />
<PrivateSquareSettingsPage embedded />
</>
)}
</div> </div>
); );
} }
@@ -1,6 +1,6 @@
"use client"; "use client";
import { PenLineIcon, PlusIcon, SearchIcon, Trash2Icon, Heart, BookOpen, Map, Zap, Target, ShieldAlert } from "lucide-react"; import { PenLineIcon, PlusIcon, SearchIcon, Trash2Icon, Heart, BookOpen, Map, Zap, Target, ShieldAlert, Briefcase, UserRound, Sparkles, Clock, History, Archive } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -458,12 +458,24 @@ const CTX_LABELS: Record<string, string> = {
topOfMind: "当前重点", topOfMind: "当前重点",
}; };
const CTX_ICONS: Record<string, React.ElementType> = {
workContext: Briefcase,
personalContext: UserRound,
topOfMind: Sparkles,
};
const HISTORY_LABELS: Record<string, string> = { const HISTORY_LABELS: Record<string, string> = {
recentMonths: "近期", recentMonths: "近期",
earlierContext: "早期背景", earlierContext: "早期背景",
longTermBackground: "长期背景", longTermBackground: "长期背景",
}; };
const HISTORY_ICONS: Record<string, React.ElementType> = {
recentMonths: Clock,
earlierContext: History,
longTermBackground: Archive,
};
const CATEGORY_LABELS: Record<string, Record<string, string>> = { const CATEGORY_LABELS: Record<string, Record<string, string>> = {
zh: { zh: {
preference: "偏好", preference: "偏好",
@@ -572,12 +584,15 @@ function MemoryV1View({
<Icon className="w-3.5 h-3.5" /> <Icon className="w-3.5 h-3.5" />
{categoryLabel} {categoryLabel}
</span> </span>
<div className="flex flex-col items-end gap-1"> <div
<span className="text-[11px] text-muted-foreground font-semibold tracking-wider"> className="flex flex-col items-end gap-1"
{Math.round(fact.confidence * 100)}% title={`置信度:系统对这条记忆准确性的把握程度,${Math.round(fact.confidence * 100)}%`}
>
<span className="text-[11px] text-muted-foreground font-semibold tracking-wide">
置信度 {Math.round(fact.confidence * 100)}%
</span> </span>
{/* Confidence Progress Bar */} {/* Confidence Progress Bar */}
<div className="h-1 w-12 rounded-full bg-muted overflow-hidden"> <div className="h-1 w-16 rounded-full bg-muted overflow-hidden">
<div <div
className="h-full rounded-full bg-primary transition-all duration-500" className="h-full rounded-full bg-primary transition-all duration-500"
style={{ width: `${Math.round(fact.confidence * 100)}%` }} style={{ width: `${Math.round(fact.confidence * 100)}%` }}
@@ -611,44 +626,77 @@ function MemoryV1View({
</TabsContent> </TabsContent>
{/* 用户画像 & 历史摘要 Tab */} {/* 用户画像 & 历史摘要 Tab */}
<TabsContent value="profile" className="mt-3 space-y-4"> <TabsContent value="profile" className="mt-3 space-y-5">
{/* 用户画像 (userContext) */} {/* 用户画像 (userContext) */}
<div className="rounded-lg border p-4 space-y-2 dark:border-[#213D5E] dark:bg-[#022248]"> <section className="rounded-xl border border-border/60 bg-card/40 p-4 space-y-3 dark:border-[#213D5E] dark:bg-[#022248]/50">
<h3 className="text-base font-medium">用户画像</h3> <div className="flex items-center gap-2">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-lg bg-primary/10 text-primary dark:bg-primary/20">
<UserRound className="h-4 w-4" />
</span>
<h3 className="text-base font-semibold">用户画像</h3>
</div>
{ctxEntries.length === 0 ? ( {ctxEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">暂无画像信息,对话后系统会自动归纳。</p> <p className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground dark:border-[#213D5E]">
暂无画像信息,对话后系统会自动归纳。
</p>
) : ( ) : (
<dl className="space-y-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{ctxEntries.map(([key, value]) => ( {ctxEntries.map(([key, value]) => {
<div key={key} className="text-sm"> const Icon = CTX_ICONS[key] ?? UserRound;
<dt className="font-medium text-muted-foreground mb-0.5"> return (
{CTX_LABELS[key] ?? key} <div
</dt> key={key}
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(value)}</dd> className="group rounded-lg border border-border/50 bg-gradient-to-br from-card to-card/40 p-3.5 shadow-sm transition-all hover:border-primary/30 hover:shadow-md dark:border-[#213D5E]/60 dark:from-[#022248]/40 dark:to-[#022248]/10"
</div> >
))} <div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
</dl> <Icon className="h-3.5 w-3.5 text-primary/80" />
{CTX_LABELS[key] ?? key}
</div>
<p className="whitespace-pre-wrap text-sm leading-relaxed text-foreground/90 [overflow-wrap:anywhere]">
{desensitize(value)}
</p>
</div>
);
})}
</div>
)} )}
</div> </section>
{/* 历史摘要 (history) */} {/* 历史摘要 (history) */}
<div className="rounded-lg border p-4 space-y-2 dark:border-[#213D5E] dark:bg-[#022248]"> <section className="rounded-xl border border-border/60 bg-card/40 p-4 space-y-3 dark:border-[#213D5E] dark:bg-[#022248]/50">
<h3 className="text-base font-medium">历史摘要</h3> <div className="flex items-center gap-2">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-lg bg-primary/10 text-primary dark:bg-primary/20">
<History className="h-4 w-4" />
</span>
<h3 className="text-base font-semibold">历史摘要</h3>
</div>
{historyEntries.length === 0 ? ( {historyEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">暂无历史摘要。</p> <p className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground dark:border-[#213D5E]">
暂无历史摘要。
</p>
) : ( ) : (
<dl className="space-y-2"> <div className="space-y-3">
{historyEntries.map(([key, value]) => ( {historyEntries.map(([key, value]) => {
<div key={key} className="text-sm"> const Icon = HISTORY_ICONS[key] ?? History;
<dt className="font-medium text-muted-foreground mb-0.5"> return (
{HISTORY_LABELS[key] ?? key} <div
</dt> key={key}
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(value)}</dd> className="relative rounded-lg border border-border/50 bg-card/60 p-3.5 pl-4 shadow-sm transition-all hover:border-primary/30 hover:shadow-md dark:border-[#213D5E]/60 dark:bg-[#022248]/30"
</div> >
))} <span className="absolute inset-y-2 left-0 w-1 rounded-full bg-primary/30" />
</dl> <div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
<Icon className="h-3.5 w-3.5 text-primary/80" />
{HISTORY_LABELS[key] ?? key}
</div>
<p className="whitespace-pre-wrap text-sm leading-relaxed text-foreground/90 [overflow-wrap:anywhere]">
{desensitize(value)}
</p>
</div>
);
})}
</div>
)} )}
</div> </section>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
@@ -22,6 +22,7 @@ import {
Trash2Icon, Trash2Icon,
TrendingUpIcon, TrendingUpIcon,
Upload, Upload,
UserIcon,
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -631,6 +632,12 @@ export function SkillRow({
const metaNodes = ( const metaNodes = (
<> <>
{!isBuiltin && skill.owner_username && (
<div className="text-muted-foreground mt-1 flex items-center gap-1 text-xs">
<UserIcon className="size-3" />
发布者:{desensitize(skill.owner_username)}
</div>
)}
{skill.use_count !== undefined && ( {skill.use_count !== undefined && (
<div className="text-muted-foreground mt-1 text-xs"> <div className="text-muted-foreground mt-1 text-xs">
{skill.use_count > 0 {skill.use_count > 0
@@ -649,15 +656,15 @@ export function SkillRow({
const actionButtons = ( const actionButtons = (
<> <>
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
className="h-8 w-8 shrink-0" className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)} onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags} title={t.adminTags.manageTags}
> >
<TagsIcon className="size-3.5" /> <TagsIcon className="size-3.5" />
</Button> </Button>
{canManage && ( {canManage && (
<Button <Button
size="icon" size="icon"
@@ -842,13 +849,13 @@ export function SkillRow({
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<TagPickerDialog <TagPickerDialog
open={tagPickerOpen} open={tagPickerOpen}
onOpenChange={setTagPickerOpen} onOpenChange={setTagPickerOpen}
targetType="skill" targetType="skill"
targetId={skill.name} targetId={skill.name}
currentTags={skill.tags ?? []} currentTags={skill.tags ?? []}
/> />
<SkillDetailDialog <SkillDetailDialog
skillName={skill.name} skillName={skill.name}
open={detailOpen} open={detailOpen}
@@ -109,13 +109,20 @@ export function SkillDetailDialog({
const canManage = useMemo(() => canManageSkill(skill), [skill]); const canManage = useMemo(() => canManageSkill(skill), [skill]);
const isOwner = useMemo(() => isSkillOwner(skill), [skill]); const isOwner = useMemo(() => isSkillOwner(skill), [skill]);
// Admins may browse any skill's files (the backend authorizes this); owners
// may browse their own custom skill. Edit-ability is decided separately by
// the backend's `filesData.editable` flag.
const canViewFiles = useMemo(() => {
if (!skill) return false;
return isOwner || getStoredAuth()?.system_role === "admin";
}, [skill, isOwner]);
const { const {
data: contentData, data: contentData,
isLoading: isContentLoading, isLoading: isContentLoading,
error: contentError, error: contentError,
} = useSkillContent(open ? skillName : null); } = useSkillContent(open ? skillName : null);
const filesEnabled = open && isOwner && activeTab === "files"; const filesEnabled = open && canViewFiles && activeTab === "files";
const { const {
data: filesData, data: filesData,
isLoading: isFilesLoading, isLoading: isFilesLoading,
@@ -227,7 +234,7 @@ export function SkillDetailDialog({
展示适配 展示适配
</TabsTrigger> </TabsTrigger>
)} )}
{isOwner && ( {canViewFiles && (
<TabsTrigger value="files" className="text-xs"> <TabsTrigger value="files" className="text-xs">
<FilesIcon className="mr-1 size-3.5" /> <FilesIcon className="mr-1 size-3.5" />
{tx.filesSectionTitle} {tx.filesSectionTitle}
@@ -349,7 +356,7 @@ export function SkillDetailDialog({
</TabsContent> </TabsContent>
)} )}
{isOwner && ( {canViewFiles && (
<TabsContent <TabsContent
value="files" value="files"
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-4 data-[state=inactive]:hidden" className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-4 data-[state=inactive]:hidden"
@@ -9,16 +9,12 @@ import {
DatabaseZapIcon, DatabaseZapIcon,
GaugeIcon, GaugeIcon,
HistoryIcon, HistoryIcon,
MessageSquareCodeIcon,
MessageSquarePlusIcon,
ScrollTextIcon, ScrollTextIcon,
Settings2Icon, Settings2Icon,
SettingsIcon, SettingsIcon,
ShieldCheckIcon,
SparklesIcon, SparklesIcon,
TagsIcon, TagsIcon,
TrophyIcon, TrophyIcon,
UsersIcon,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -149,36 +145,6 @@ export function WorkspaceNavMenu() {
<Settings2Icon /> <Settings2Icon />
{t.common.settings} {t.common.settings}
</DropdownMenuItem> </DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/recommended-questions")
}
>
<MessageSquarePlusIcon />
推荐问题配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/prompt-prefix")
}
>
<MessageSquareCodeIcon />
问答前缀配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/private-square")
}
>
<ShieldCheckIcon />
私密广场配置
</DropdownMenuItem>
)}
{isAdmin && ( {isAdmin && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
@@ -199,14 +165,6 @@ export function WorkspaceNavMenu() {
Checkpoint 迁移 Checkpoint 迁移
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin")}
>
<UsersIcon />
用户中心
</DropdownMenuItem>
)}
{isAdmin && ( {isAdmin && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
+12 -10
View File
@@ -1,10 +1,12 @@
import type { Tag } from "@/core/tags"; import type { Tag } from "@/core/tags";
export interface Agent { export interface Agent {
id: string; id: string;
name: string; name: string;
description: string; description: string;
user_id: string | null; user_id: string | null;
/** Publisher username (email local part); null for built-in agents. */
owner_username?: string | null;
published: boolean; published: boolean;
is_private: boolean; is_private: boolean;
builtin: boolean; builtin: boolean;
@@ -17,15 +19,15 @@ export interface Agent {
tags?: Tag[]; tags?: Tag[];
featured_order?: number | null; featured_order?: number | null;
/** Whether the current user has favorited (收藏) this agent. */ /** Whether the current user has favorited (收藏) this agent. */
is_favorite?: boolean; is_favorite?: boolean;
is_pinned?: boolean; is_pinned?: boolean;
} }
export type AgentSort = "time_desc" | "time_asc" | "updated_desc" | "updated_asc" | "name_asc" | "name_desc"; export type AgentSort = "time_desc" | "time_asc" | "updated_desc" | "updated_asc" | "name_asc" | "name_desc";
export interface AgentSelectorResponse { export interface AgentSelectorResponse {
agents: Agent[]; agents: Agent[];
} }
/** Pagination envelope returned by the server-side paginated agent listings. */ /** Pagination envelope returned by the server-side paginated agent listings. */
export interface PaginatedAgents { export interface PaginatedAgents {
@@ -27,12 +27,10 @@ import {
ListChecks, ListChecks,
ListTodo, ListTodo,
MessageSquareCode, MessageSquareCode,
MessageSquarePlus,
Network, Network,
NotebookPen, NotebookPen,
Plus, Plus,
Settings, Settings,
ShieldCheck,
Sparkles, Sparkles,
Target, Target,
Trophy, Trophy,
@@ -216,27 +214,6 @@ export const sidebarMenuItems: MenuItem[] = [
icon: ListChecks, icon: ListChecks,
path: "/page/strategy/scheduled/tasks", path: "/page/strategy/scheduled/tasks",
}, },
{
id: "mgmt-recommended-questions",
label: "推荐问题配置",
icon: MessageSquarePlus,
path: "/page/strategy/settings/recommended-questions",
adminOnly: true,
},
{
id: "mgmt-prompt-prefix",
label: "问答前缀配置",
icon: MessageSquareCode,
path: "/page/strategy/settings/prompt-prefix",
adminOnly: true,
},
{
id: "mgmt-private-square",
label: "私密广场配置",
icon: ShieldCheck,
path: "/page/strategy/settings/private-square",
adminOnly: true,
},
{ {
id: "mgmt-llm-metrics", id: "mgmt-llm-metrics",
label: "大模型调用统计", label: "大模型调用统计",
+3
View File
@@ -49,6 +49,9 @@ export interface Skill {
// Multitenancy fields — owner_user_id is null for built-in / public skills. // Multitenancy fields — owner_user_id is null for built-in / public skills.
// ``builtin`` mirrors that nullness for explicit branching on the UI side. // ``builtin`` mirrors that nullness for explicit branching on the UI side.
owner_user_id?: string | null; owner_user_id?: string | null;
// Publisher username (email local part) resolved from owner_user_id by the
// backend. Null for built-in / public skills with no human owner.
owner_username?: string | null;
published?: boolean; published?: boolean;
builtin?: boolean; builtin?: boolean;
// Long-form detail/introduction authored by the skill owner or an admin. // Long-form detail/introduction authored by the skill owner or an admin.
@@ -9,6 +9,7 @@ import {
Bot, Bot,
CalendarDays, CalendarDays,
Crown, Crown,
ExternalLink,
FileDown, FileDown,
Gauge, Gauge,
Layers3, Layers3,
@@ -68,10 +69,10 @@ const LEADERBOARD_PAGE_SIZE = 20;
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = { const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
crimson: { crimson: {
soft: "bg-[#FFF1F1] dark:bg-[#C1121F]/15", soft: "bg-[#FFF1F1] dark:bg-[#2563EB]/15",
text: "text-[#C1121F] dark:text-[#F2B8BB]", text: "text-[#C1121F] dark:text-[#7FACF2]",
bar: "bg-[#C1121F]", bar: "bg-[#C1121F] dark:bg-[#2563EB]",
border: "border-[#F1D6D6] dark:border-[#462123]", border: "border-[#F1D6D6] dark:border-[#213D5E]",
}, },
rose: { rose: {
soft: "bg-[#FFF1F2] dark:bg-rose-400/15", soft: "bg-[#FFF1F2] dark:bg-rose-400/15",
@@ -284,7 +285,7 @@ function StatCard({
}) { }) {
const color = accentClasses[accent]; const color = accentClasses[accent];
return ( return (
<div className={cn("rounded-lg border bg-white p-4 shadow-sm dark:bg-[#230D0F]", color.border)}> <div className={cn("rounded-lg border bg-white p-4 shadow-sm dark:bg-[#02244C]", color.border)}>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<span className="text-sm text-[#5F6368] dark:text-slate-300">{label}</span> <span className="text-sm text-[#5F6368] dark:text-slate-300">{label}</span>
<span className={cn("flex size-8 items-center justify-center rounded-md", color.soft, color.text)}> <span className={cn("flex size-8 items-center justify-center rounded-md", color.soft, color.text)}>
@@ -337,13 +338,13 @@ function InsightStrip({ overview }: { overview: AdminLeaderboardOverview }) {
return ( return (
<section className="grid gap-3 md:grid-cols-5"> <section className="grid gap-3 md:grid-cols-5">
{items.map((item) => ( {items.map((item) => (
<div key={item.label} className="rounded-lg border border-[#F1D6D6] bg-white p-3 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]" title={item.description}> <div key={item.label} className="rounded-lg border border-[#F1D6D6] bg-white p-3 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]" title={item.description}>
<div className="text-xs font-medium text-[#7A5A5A] dark:text-[#D8B8B8]">{item.label}</div> <div className="text-xs font-medium text-[#7A5A5A] dark:text-[#7FACF2]">{item.label}</div>
<div className="mt-1 text-xl font-semibold tabular-nums text-[#231815] dark:text-white"> <div className="mt-1 text-xl font-semibold tabular-nums text-[#231815] dark:text-white">
{Number(item.value).toFixed(item.unit === "%" || String(item.value).includes(".") ? 1 : 0)} {Number(item.value).toFixed(item.unit === "%" || String(item.value).includes(".") ? 1 : 0)}
<span className="ml-1 text-xs font-normal text-[#8B6B6B]">{item.unit}</span> <span className="ml-1 text-xs font-normal text-[#8B6B6B] dark:text-[#7FACF2]">{item.unit}</span>
</div> </div>
<p className="mt-2 line-clamp-2 text-[11px] leading-4 text-[#8B6B6B] dark:text-[#D8B8B8]">{item.description}</p> <p className="mt-2 line-clamp-2 text-[11px] leading-4 text-[#8B6B6B] dark:text-[#7FACF2]">{item.description}</p>
</div> </div>
))} ))}
</section> </section>
@@ -365,14 +366,14 @@ function GroupComparison({
const groups = buildGroupStats(users, rules); const groups = buildGroupStats(users, rules);
return ( return (
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]"> <section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex flex-wrap items-start justify-between gap-3"> <div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div> <div>
<div className="flex items-center gap-2 font-semibold text-[#231815] dark:text-white"> <div className="flex items-center gap-2 font-semibold text-[#231815] dark:text-white">
<Users className="size-4 text-[#C1121F]" /> <Users className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
分组统计 分组统计
</div> </div>
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]"> <p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
输入账号前缀或关键字,用逗号分隔,例如 zero,two;未命中的用户归入其他。 输入账号前缀或关键字,用逗号分隔,例如 zero,two;未命中的用户归入其他。
</p> </p>
</div> </div>
@@ -380,12 +381,12 @@ function GroupComparison({
value={rulesText} value={rulesText}
onChange={(event) => onRulesChange(event.target.value)} onChange={(event) => onRulesChange(event.target.value)}
placeholder="zero,two" placeholder="zero,two"
className="h-9 w-72 border-[#F1D6D6] bg-white dark:bg-[#19090A]" className="h-9 w-72 border-[#F1D6D6] bg-white dark:bg-[#011226]"
/> />
</div> </div>
<div className="grid gap-3 xl:grid-cols-3"> <div className="grid gap-3 xl:grid-cols-3">
{groups.map((group) => ( {groups.map((group) => (
<div key={group.name} className="rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-3 dark:border-[#462123] dark:bg-[#19090A]"> <div key={group.name} className="rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-3 dark:border-[#213D5E] dark:bg-[#011226]">
<div className="mb-3 flex items-center justify-between gap-2"> <div className="mb-3 flex items-center justify-between gap-2">
<div className="font-semibold text-[#231815] dark:text-white">{group.name}</div> <div className="font-semibold text-[#231815] dark:text-white">{group.name}</div>
<Badge variant="outline">{group.userCount} 人</Badge> <Badge variant="outline">{group.userCount} 人</Badge>
@@ -402,9 +403,9 @@ function GroupComparison({
key={user.user_id} key={user.user_id}
type="button" type="button"
onClick={() => onSelectUser(user)} onClick={() => onSelectUser(user)}
className="grid w-full grid-cols-[24px_minmax(0,1fr)_64px] items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs hover:bg-white dark:hover:bg-[#230D0F]" className="grid w-full grid-cols-[24px_minmax(0,1fr)_64px] items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs hover:bg-white dark:hover:bg-[#02244C]"
> >
<span className="font-semibold text-[#C1121F]">{index + 1}</span> <span className="font-semibold text-[#C1121F] dark:text-[#7FACF2]">{index + 1}</span>
<span className="truncate">{user.email || user.user_id}</span> <span className="truncate">{user.email || user.user_id}</span>
<span className="text-right tabular-nums">{fmtNumber(userScore(user, "activity"))}</span> <span className="text-right tabular-nums">{fmtNumber(userScore(user, "activity"))}</span>
</button> </button>
@@ -438,8 +439,8 @@ function PodiumCard({
if (!user) { if (!user) {
return ( return (
<div className="flex min-h-40 flex-col justify-between rounded-lg border border-dashed border-[#DADCE0] bg-white/70 p-4 dark:border-[#462123] dark:bg-[#230D0F]/70"> <div className="flex min-h-40 flex-col justify-between rounded-lg border border-dashed border-[#DADCE0] bg-white/70 p-4 dark:border-[#213D5E] dark:bg-[#02244C]/70">
<div className="text-sm text-[#5F6368]">第 {rank} 名</div> <div className="text-sm text-[#5F6368] dark:text-slate-400">第 {rank} 名</div>
<div className="text-sm text-[#80868B]">暂无数据</div> <div className="text-sm text-[#80868B]">暂无数据</div>
</div> </div>
); );
@@ -450,8 +451,8 @@ function PodiumCard({
type="button" type="button"
onClick={() => onSelect(user)} onClick={() => onSelect(user)}
className={cn( className={cn(
"flex min-h-40 flex-col justify-between rounded-lg border bg-white p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:bg-[#230D0F]", "flex min-h-40 flex-col justify-between rounded-lg border bg-white p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:bg-[#02244C]",
active ? "border-[#C1121F] ring-2 ring-[#F1D6D6] dark:border-[#F2B8BB] dark:ring-[#C1121F]/20" : "border-[#DADCE0] dark:border-[#462123]", active ? "border-[#C1121F] dark:border-[#2563EB] ring-2 ring-[#F1D6D6] dark:border-[#7FACF2] dark:ring-[#2563EB]/20" : "border-[#DADCE0] dark:border-[#213D5E]",
)} )}
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
@@ -462,11 +463,11 @@ function PodiumCard({
{user.system_role === "admin" && <Badge variant="outline">管理员</Badge>} {user.system_role === "admin" && <Badge variant="outline">管理员</Badge>}
</div> </div>
<div> <div>
<div className="mb-3 flex size-11 items-center justify-center rounded-lg bg-[#C1121F] text-base font-semibold text-white shadow-sm"> <div className="mb-3 flex size-11 items-center justify-center rounded-lg bg-[#C1121F] dark:bg-[#2563EB] text-base font-semibold text-white shadow-sm">
{initials(user)} {initials(user)}
</div> </div>
<div className="truncate font-semibold text-[#202124] dark:text-white">{user.email || user.user_id}</div> <div className="truncate font-semibold text-[#202124] dark:text-white">{user.email || user.user_id}</div>
<div className="mt-1 text-sm font-medium text-[#C1121F] dark:text-[#F2B8BB]">{scoreLabel(user, tab)}</div> <div className="mt-1 text-sm font-medium text-[#C1121F] dark:text-[#7FACF2]">{scoreLabel(user, tab)}</div>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-[#5F6368] dark:text-slate-400"> <div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-[#5F6368] dark:text-slate-400">
<span>运行 {fmtNumber(user.run_count)}</span> <span>运行 {fmtNumber(user.run_count)}</span>
<span>问答 {fmtNumber(user.qa_count)}</span> <span>问答 {fmtNumber(user.qa_count)}</span>
@@ -482,15 +483,15 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
const [metric, setMetric] = useState<"all" | TrendMetric>("all"); const [metric, setMetric] = useState<"all" | TrendMetric>("all");
const chartId = useId().replace(/:/g, ""); const chartId = useId().replace(/:/g, "");
if (points.length === 0) { if (points.length === 0) {
return <div className="flex h-56 items-center justify-center text-sm text-[#5F6368]">暂无趋势数据</div>; return <div className="flex h-56 items-center justify-center text-sm text-[#5F6368] dark:text-slate-400">暂无趋势数据</div>;
} }
const visible = points.slice(-32); const visible = points.slice(-32);
const series = [ const series = [
{ key: "runs", label: "运行", color: "#C1121F", soft: "bg-[#FFF1F1] text-[#C1121F]", description: "每天发起的运行次数" }, { 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]", 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]", 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]", description: "每天模型输入输出 Token 总量" }, { key: "tokens", label: "Token", color: "#7C3AED", soft: "bg-[#F5F3FF] text-[#7C3AED] dark:bg-violet-300/15 dark:text-violet-100", description: "每天模型输入输出 Token 总量" },
] as const; ] as const;
const activeMetric: TrendMetric = metric === "all" ? "runs" : metric; const activeMetric: TrendMetric = metric === "all" ? "runs" : metric;
const activeSeries = series.find((item) => item.key === activeMetric) ?? series[0]; const activeSeries = series.find((item) => item.key === activeMetric) ?? series[0];
@@ -533,15 +534,15 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
"min-w-0 rounded-lg border px-3 py-2 text-left transition hover:-translate-y-0.5 hover:shadow-sm", "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", large && "px-4 py-3",
metric === "all" metric === "all"
? "border-[#C1121F] bg-[#FFF1F1] shadow-sm dark:border-[#F2B8BB]/40 dark:bg-[#3A1215]" ? "border-[#C1121F] dark:border-[#2563EB] bg-[#FFF1F1] shadow-sm dark:border-[#7FACF2]/40 dark:bg-[#0D2D54]"
: "border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#230D0F]", : "border-[#F1D6D6] bg-white dark:border-[#213D5E] dark:bg-[#02244C]",
)} )}
> >
<span className="inline-flex rounded-full bg-[#FFF1F1] px-2 py-0.5 text-xs font-medium text-[#C1121F]">全部趋势</span> <span className="inline-flex rounded-full bg-[#FFF1F1] px-2 py-0.5 text-xs font-medium text-[#C1121F] dark:text-[#7FACF2] dark:bg-[#2563EB]/15 dark:text-[#7FACF2]">全部趋势</span>
<span className={cn("mt-2 block font-semibold tabular-nums text-[#231815] dark:text-white", large ? "text-2xl" : "text-lg")}> <span className={cn("mt-2 block font-semibold tabular-nums text-[#231815] dark:text-white", large ? "text-2xl" : "text-lg")}>
{fmtNumber(visible.reduce((sum, point) => sum + point.runs + point.questions + point.users, 0))} {fmtNumber(visible.reduce((sum, point) => sum + point.runs + point.questions + point.users, 0))}
</span> </span>
{large && <span className="mt-1 block truncate text-xs text-[#8B6B6B] dark:text-[#D8B8B8]">同时显示运行、问答、活跃与 Token</span>} {large && <span className="mt-1 block truncate text-xs text-[#8B6B6B] dark:text-[#7FACF2]">同时显示运行、问答、活跃与 Token</span>}
</button> </button>
)} )}
{series.map((item) => ( {series.map((item) => (
@@ -553,8 +554,8 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
"min-w-0 rounded-lg border px-3 py-2 text-left transition hover:-translate-y-0.5 hover:shadow-sm", "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", large && "px-4 py-3",
metric === item.key metric === item.key
? "border-[#C1121F] bg-[#FFF1F1] shadow-sm dark:border-[#F2B8BB]/40 dark:bg-[#3A1215]" ? "border-[#C1121F] dark:border-[#2563EB] bg-[#FFF1F1] shadow-sm dark:border-[#7FACF2]/40 dark:bg-[#0D2D54]"
: "border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#230D0F]", : "border-[#F1D6D6] bg-white dark:border-[#213D5E] dark:bg-[#02244C]",
)} )}
title={item.description} title={item.description}
> >
@@ -562,7 +563,7 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
<span className={cn("mt-2 block truncate font-semibold tabular-nums text-[#111827] dark:text-white", large ? "text-2xl" : compact ? "text-base" : "text-lg")}> <span className={cn("mt-2 block truncate font-semibold tabular-nums text-[#111827] dark:text-white", large ? "text-2xl" : compact ? "text-base" : "text-lg")}>
{fmtNumber(totalFor(item.key))} {fmtNumber(totalFor(item.key))}
</span> </span>
{(large || compact) && <span className="mt-1 block truncate text-[11px] text-[#8B6B6B] dark:text-[#D8B8B8]">{item.description}</span>} {(large || compact) && <span className="mt-1 block truncate text-[11px] text-[#8B6B6B] dark:text-[#7FACF2]">{item.description}</span>}
</button> </button>
))} ))}
</div> </div>
@@ -583,15 +584,15 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
</DialogHeader> </DialogHeader>
<div className="shrink-0">{metricCards({ includeAll: true, large: true })}</div> <div className="shrink-0">{metricCards({ includeAll: true, large: true })}</div>
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[260px_minmax(0,1fr)]"> <div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[260px_minmax(0,1fr)]">
<div className="rounded-lg border border-[#F1D6D6] bg-white p-4 dark:border-[#462123] dark:bg-[#230D0F]"> <div className="rounded-lg border border-[#F1D6D6] bg-white p-4 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">当前查看</div> <div className="text-xs text-[#7A5A5A] dark:text-[#7FACF2]">当前查看</div>
<div className="mt-2 text-2xl font-semibold text-[#111827] dark:text-white">{metric === "all" ? "全部指标" : activeSeries.label}</div> <div className="mt-2 text-2xl font-semibold text-[#111827] dark:text-white">{metric === "all" ? "全部指标" : activeSeries.label}</div>
<div className="mt-5 text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">最新 {metric === "all" ? "运行" : activeSeries.label}</div> <div className="mt-5 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">最新 {metric === "all" ? "运行" : activeSeries.label}</div>
<div className="mt-2 text-4xl font-semibold tabular-nums text-[#111827] dark:text-white">{fmtNumber(latestValue)}</div> <div className="mt-2 text-4xl font-semibold tabular-nums text-[#111827] dark:text-white">{fmtNumber(latestValue)}</div>
<div className={cn("mt-3 text-sm", delta >= 0 ? "text-[#C1121F]" : "text-[#7F1D1D] dark:text-[#FCA5A5]")}> <div className={cn("mt-3 text-sm", delta >= 0 ? "text-[#C1121F] dark:text-[#7FACF2]" : "text-[#7F1D1D] dark:text-[#FCA5A5]")}>
较上一日 {delta >= 0 ? "+" : ""}{fmtNumber(delta)} 较上一日 {delta >= 0 ? "+" : ""}{fmtNumber(delta)}
</div> </div>
<div className="mt-5 text-xs text-[#7A5A5A] dark:text-[#D8B8B8]"> <div className="mt-5 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">
峰值 {peak.date} / {fmtNumber(Number(peak[activeMetric] ?? 0))} 峰值 {peak.date} / {fmtNumber(Number(peak[activeMetric] ?? 0))}
</div> </div>
</div> </div>
@@ -679,7 +680,7 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
return ( return (
<div className="min-w-0 space-y-3"> <div className="min-w-0 space-y-3">
{metricCards({ includeAll: false })} {metricCards({ includeAll: false })}
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-[#7A5A5A] dark:text-[#D8B8B8]"> <div className="flex flex-wrap items-center justify-between gap-2 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">
<span>每个点代表 1 天;当前显示 {metric === "all" ? "全部指标" : activeSeries.label},覆盖 {activeDays} 个活跃日。</span> <span>每个点代表 1 天;当前显示 {metric === "all" ? "全部指标" : activeSeries.label},覆盖 {activeDays} 个活跃日。</span>
{analysisDialog( {analysisDialog(
"个人使用趋势", "个人使用趋势",
@@ -693,7 +694,7 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-xs leading-5 text-[#7A5A5A] dark:text-[#D8B8B8]"> <p className="text-xs leading-5 text-[#7A5A5A] dark:text-[#7FACF2]">
每个折线点代表一天;点击指标卡可单独查看运行、问答、活跃或 Token,全部趋势会同时展示四条多色折线。 每个折线点代表一天;点击指标卡可单独查看运行、问答、活跃或 Token,全部趋势会同时展示四条多色折线。
</p> </p>
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
@@ -704,13 +705,13 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
)} )}
</div> </div>
<div className="grid gap-3 lg:grid-cols-[220px_minmax(0,1fr)]"> <div className="grid gap-3 lg:grid-cols-[220px_minmax(0,1fr)]">
<div className="rounded-xl border border-[#F1D6D6] bg-white p-4 dark:border-[#462123] dark:bg-[#230D0F]"> <div className="rounded-xl border border-[#F1D6D6] bg-white p-4 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">最新 {metric === "all" ? "运行" : activeSeries.label}</div> <div className="text-xs text-[#7A5A5A] dark:text-[#7FACF2]">最新 {metric === "all" ? "运行" : activeSeries.label}</div>
<div className="mt-2 text-3xl font-semibold tabular-nums text-[#111827] dark:text-white">{fmtNumber(latestValue)}</div> <div className="mt-2 text-3xl font-semibold tabular-nums text-[#111827] dark:text-white">{fmtNumber(latestValue)}</div>
<div className={cn("mt-2 text-sm", delta >= 0 ? "text-[#C1121F]" : "text-[#7F1D1D] dark:text-[#FCA5A5]")}> <div className={cn("mt-2 text-sm", delta >= 0 ? "text-[#C1121F] dark:text-[#7FACF2]" : "text-[#7F1D1D] dark:text-[#FCA5A5]")}>
较上一日 {delta >= 0 ? "+" : ""}{fmtNumber(delta)} 较上一日 {delta >= 0 ? "+" : ""}{fmtNumber(delta)}
</div> </div>
<div className="mt-3 text-xs text-[#7A5A5A] dark:text-[#D8B8B8]"> <div className="mt-3 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">
峰值 {peak.date.slice(5)} / {fmtNumber(Number(peak[activeMetric] ?? 0))} 峰值 {peak.date.slice(5)} / {fmtNumber(Number(peak[activeMetric] ?? 0))}
</div> </div>
</div> </div>
@@ -735,11 +736,11 @@ function UserTable({
}) { }) {
const max = Math.max(1, ...users.map((user) => userScore(user, tab))); const max = Math.max(1, ...users.map((user) => userScore(user, tab)));
if (users.length === 0) { if (users.length === 0) {
return <div className="py-14 text-center text-sm text-[#5F6368]">暂无用户统计</div>; return <div className="py-14 text-center text-sm text-[#5F6368] dark:text-slate-400">暂无用户统计</div>;
} }
return ( return (
<div className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#230D0F]"> <div className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white dark:border-[#213D5E] dark:bg-[#02244C]">
{users.map((user, index) => { {users.map((user, index) => {
const score = userScore(user, tab); const score = userScore(user, tab);
return ( return (
@@ -748,11 +749,11 @@ function UserTable({
type="button" type="button"
onClick={() => onSelect(user)} onClick={() => onSelect(user)}
className={cn( className={cn(
"grid w-full grid-cols-[44px_minmax(0,1fr)_minmax(160px,240px)_120px] items-center gap-4 border-b border-[#F1D6D6] px-4 py-3 text-left last:border-b-0 hover:bg-[#FFF7F7] dark:border-[#462123] dark:hover:bg-[#19090A]", "grid w-full grid-cols-[44px_minmax(0,1fr)_minmax(160px,240px)_120px] items-center gap-4 border-b border-[#F1D6D6] px-4 py-3 text-left last:border-b-0 hover:bg-[#FFF7F7] dark:border-[#213D5E] dark:hover:bg-[#011226]",
selectedUserId === user.user_id && "bg-[#FFF1F1] dark:bg-[#C1121F]/10", selectedUserId === user.user_id && "bg-[#FFF1F1] dark:bg-[#2563EB]/10",
)} )}
> >
<div className="flex size-8 items-center justify-center rounded-md bg-[#FFF1F1] text-sm font-semibold text-[#C1121F] dark:bg-[#3A1215] dark:text-[#F2B8BB]"> <div className="flex size-8 items-center justify-center rounded-md bg-[#FFF1F1] text-sm font-semibold text-[#C1121F] dark:text-[#7FACF2] dark:bg-[#0D2D54] dark:text-[#7FACF2]">
{rankOffset + index + 1} {rankOffset + index + 1}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
@@ -765,9 +766,9 @@ function UserTable({
</div> </div>
</div> </div>
<div> <div>
<div className="h-2 rounded-full bg-[#F1D6D6] dark:bg-[#3A1215]"> <div className="h-2 rounded-full bg-[#F1D6D6] dark:bg-[#0D2D54]">
<div <div
className="h-2 rounded-full bg-[#C1121F]" className="h-2 rounded-full bg-[#C1121F] dark:bg-[#2563EB]"
style={{ width: `${Math.max(4, (score / max) * 100)}%` }} style={{ width: `${Math.max(4, (score / max) * 100)}%` }}
/> />
</div> </div>
@@ -801,7 +802,7 @@ function SkillRanking({
}) { }) {
const max = Math.max(1, ...items.map((item) => item.call_count)); const max = Math.max(1, ...items.map((item) => item.call_count));
return ( return (
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]"> <section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex items-center justify-between gap-3"> <div className="mb-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Icon className="size-4 text-[#5F6368] dark:text-slate-300" /> <Icon className="size-4 text-[#5F6368] dark:text-slate-300" />
@@ -810,7 +811,7 @@ function SkillRanking({
<Badge variant="outline">{items.length} 项</Badge> <Badge variant="outline">{items.length} 项</Badge>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368]">{empty}</div>} {items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368] dark:text-slate-400">{empty}</div>}
{items.slice(0, 10).map((item, index) => ( {items.slice(0, 10).map((item, index) => (
<div key={item.name} className="grid grid-cols-[28px_minmax(0,1fr)_72px] items-center gap-3"> <div key={item.name} className="grid grid-cols-[28px_minmax(0,1fr)_72px] items-center gap-3">
<span className="text-sm font-medium text-[#80868B] tabular-nums">{index + 1}</span> <span className="text-sm font-medium text-[#80868B] tabular-nums">{index + 1}</span>
@@ -821,7 +822,7 @@ function SkillRanking({
{fmtNumber(item.user_count)} 人 {fmtNumber(item.user_count)} 人
</span> </span>
</div> </div>
<div className="mt-2 h-2 rounded-full bg-[#F1D6D6] dark:bg-[#3A1215]"> <div className="mt-2 h-2 rounded-full bg-[#F1D6D6] dark:bg-[#0D2D54]">
<div <div
className="h-2 rounded-full" className="h-2 rounded-full"
style={{ style={{
@@ -859,7 +860,7 @@ function DimensionRanking({
}) { }) {
const max = Math.max(1, ...items.map((item) => item.count)); const max = Math.max(1, ...items.map((item) => item.count));
return ( return (
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]"> <section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex items-center justify-between gap-3"> <div className="mb-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Icon className="size-4 text-[#5F6368] dark:text-slate-300" /> <Icon className="size-4 text-[#5F6368] dark:text-slate-300" />
@@ -868,7 +869,7 @@ function DimensionRanking({
<Badge variant="outline">{items.length} 项</Badge> <Badge variant="outline">{items.length} 项</Badge>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368]">{empty}</div>} {items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368] dark:text-slate-400">{empty}</div>}
{items.slice(0, 8).map((item, index) => ( {items.slice(0, 8).map((item, index) => (
<div key={item.name} className="grid grid-cols-[28px_minmax(0,1fr)_88px] items-center gap-3"> <div key={item.name} className="grid grid-cols-[28px_minmax(0,1fr)_88px] items-center gap-3">
<span className="text-sm font-medium text-[#80868B] tabular-nums">{index + 1}</span> <span className="text-sm font-medium text-[#80868B] tabular-nums">{index + 1}</span>
@@ -879,7 +880,7 @@ function DimensionRanking({
{fmtNumber(item.user_count)} 人 {fmtNumber(item.user_count)} 人
</span> </span>
</div> </div>
<div className="mt-2 h-2 rounded-full bg-[#F1D6D6] dark:bg-[#3A1215]"> <div className="mt-2 h-2 rounded-full bg-[#F1D6D6] dark:bg-[#0D2D54]">
<div <div
className="h-2 rounded-full" className="h-2 rounded-full"
style={{ style={{
@@ -913,8 +914,8 @@ function QuestionPanel({
showUser?: boolean; showUser?: boolean;
}) { }) {
return ( return (
<section className="flex h-[430px] flex-col overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#462123] dark:bg-[#230D0F]"> <section className="flex h-[430px] flex-col overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex items-center justify-between gap-3 border-b border-[#F1D6D6] px-4 py-3 dark:border-[#462123]"> <div className="flex items-center justify-between gap-3 border-b border-[#F1D6D6] px-4 py-3 dark:border-[#213D5E]">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<MessageSquareText className="size-4 text-[#5F6368] dark:text-slate-300" /> <MessageSquareText className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2> <h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
@@ -922,8 +923,8 @@ function QuestionPanel({
<Badge variant="outline">{items.length}</Badge> <Badge variant="outline">{items.length}</Badge>
</div> </div>
<ScrollArea className="min-h-0 flex-1"> <ScrollArea className="min-h-0 flex-1">
<div className="divide-y divide-[#F1D6D6] dark:divide-[#462123]"> <div className="divide-y divide-[#F1D6D6] dark:divide-[#213D5E]">
{items.length === 0 && <div className="py-12 text-center text-sm text-[#5F6368]">暂无问题记录</div>} {items.length === 0 && <div className="py-12 text-center text-sm text-[#5F6368] dark:text-slate-400">暂无问题记录</div>}
{items.map((item, index) => ( {items.map((item, index) => (
<div key={`${item.run_id || item.text}-${index}`} className="px-4 py-3"> <div key={`${item.run_id || item.text}-${index}`} className="px-4 py-3">
<div className="text-sm leading-6 text-[#202124] dark:text-slate-100">{clampText(item.text, 180)}</div> <div className="text-sm leading-6 text-[#202124] dark:text-slate-100">{clampText(item.text, 180)}</div>
@@ -942,8 +943,8 @@ function QuestionPanel({
function Metric({ label, value }: { label: string; value: number }) { function Metric({ label, value }: { label: string; value: number }) {
return ( return (
<div className="min-w-0 border-r border-[#F1D6D6] px-3 py-2 last:border-r-0 dark:border-[#462123]"> <div className="min-w-0 border-r border-[#F1D6D6] px-3 py-2 last:border-r-0 dark:border-[#213D5E]">
<div className="truncate text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">{label}</div> <div className="truncate text-xs text-[#7A5A5A] dark:text-[#7FACF2]">{label}</div>
<div className="mt-1 truncate font-semibold text-[#231815] tabular-nums dark:text-white">{fmtNumber(value)}</div> <div className="mt-1 truncate font-semibold text-[#231815] tabular-nums dark:text-white">{fmtNumber(value)}</div>
</div> </div>
); );
@@ -971,7 +972,7 @@ function UserDetailPanel({
if (isLoading || !data) { if (isLoading || !data) {
return ( return (
<div className="flex h-full items-center justify-center gap-2 text-sm text-[#5F6368]"> <div className="flex h-full items-center justify-center gap-2 text-sm text-[#5F6368] dark:text-slate-400">
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
加载用户详情 加载用户详情
</div> </div>
@@ -981,13 +982,13 @@ function UserDetailPanel({
return ( return (
<ScrollArea className="h-full"> <ScrollArea className="h-full">
<div className="space-y-4 p-4"> <div className="space-y-4 p-4">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 dark:border-[#462123] dark:bg-[#230D0F]"> <section className="rounded-lg border border-[#F1D6D6] bg-white p-4 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-lg font-semibold text-[#231815] dark:text-white"> <div className="truncate text-lg font-semibold text-[#231815] dark:text-white">
{data.user.email || data.user.user_id} {data.user.email || data.user.user_id}
</div> </div>
<div className="mt-1 text-xs text-[#7A5A5A] dark:text-[#D8B8B8]"> <div className="mt-1 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">
最近活跃 {fmtTime(data.user.last_active_at)} 最近活跃 {fmtTime(data.user.last_active_at)}
</div> </div>
</div> </div>
@@ -996,7 +997,7 @@ function UserDetailPanel({
<ArrowRight className="ml-1 size-3.5" /> <ArrowRight className="ml-1 size-3.5" />
</Button> </Button>
</div> </div>
<div className="mt-4 grid grid-cols-4 overflow-hidden rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] text-sm dark:border-[#462123] dark:bg-[#19090A]"> <div className="mt-4 grid grid-cols-4 overflow-hidden rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] text-sm dark:border-[#213D5E] dark:bg-[#011226]">
<Metric label="问答次数" value={data.user.qa_count} /> <Metric label="问答次数" value={data.user.qa_count} />
<Metric label="运行次数" value={data.user.run_count} /> <Metric label="运行次数" value={data.user.run_count} />
<Metric label="技能调用" value={data.user.skill_call_count} /> <Metric label="技能调用" value={data.user.skill_call_count} />
@@ -1016,7 +1017,7 @@ function UserDetailPanel({
</TabsContent> </TabsContent>
<TabsContent value="trend" className="min-h-[420px]"> <TabsContent value="trend" className="min-h-[420px]">
<div className="mb-3 flex items-center gap-2 font-semibold text-[#231815] dark:text-white"> <div className="mb-3 flex items-center gap-2 font-semibold text-[#231815] dark:text-white">
<TrendingUp className="size-4 text-[#C1121F]" /> <TrendingUp className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
个人使用趋势 个人使用趋势
</div> </div>
<TrendBars points={data.trends} compact /> <TrendBars points={data.trends} compact />
@@ -1226,31 +1227,36 @@ export default function AdminUserLeaderboardPage() {
}, [apiRange.since, apiRange.until, reportModelName]); }, [apiRange.since, apiRange.until, reportModelName]);
return ( return (
<div className="flex size-full min-h-0 bg-[#FFF7F7] text-[#231815] dark:bg-[#19090A] dark:text-white"> <div className="flex size-full min-h-0 bg-[#FFF7F7] text-[#231815] dark:bg-[#011226] dark:text-white">
<main className="flex min-w-0 flex-1 flex-col"> <main className="flex min-w-0 flex-1 flex-col">
<div className="border-b border-[#F1D6D6] bg-white px-6 py-5 dark:border-[#462123] dark:bg-[#230D0F]"> <div className="border-b border-[#F1D6D6] bg-white px-6 py-5 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex flex-wrap items-end justify-between gap-4"> <div className="flex flex-col gap-4">
<div> {/* 标题维度:仅承载页面标题与说明 */}
<div className="mb-2 flex items-center gap-2 text-sm text-[#9F343A] dark:text-[#F2B8BB]"> <div className="flex flex-wrap items-start justify-between gap-4">
<Award className="size-4" /> <div>
管理员分析 <div className="mb-2 flex items-center gap-2 text-sm text-[#9F343A] dark:text-[#7FACF2]">
<Award className="size-4" />
管理员分析
</div>
<h1 className="text-2xl font-semibold tracking-normal">用户排行榜</h1>
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
统计用户活跃、问答、Token 消耗、技能热度和近期问题,用于运营和审计。
</p>
</div> </div>
<h1 className="text-2xl font-semibold tracking-normal">用户排行榜</h1>
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
统计用户活跃、问答、Token 消耗、技能热度和近期问题,用于运营和审计。
</p>
</div>
<div className="flex flex-wrap items-end gap-2">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => navigate("/page/workspace/chats/new")} onClick={() => navigate("/page/workspace/chats/new")}
className="h-9" className="h-9 shrink-0"
> >
<ArrowLeft className="mr-1 size-4" /> <ArrowLeft className="mr-1 size-4" />
返回系统 返回系统
</Button> </Button>
</div>
{/* 操作维度:与标题区分开的控制工具条(操作按钮 + 时间筛选) */}
<div className="flex flex-wrap items-end justify-between gap-3 rounded-xl border border-[#F1D6D6] bg-white/70 px-3 py-3 dark:border-[#213D5E] dark:bg-[#02244C]/70">
<div className="flex flex-wrap items-center gap-2">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -1271,6 +1277,16 @@ export default function AdminUserLeaderboardPage() {
<FileDown className="mr-1 size-4" /> <FileDown className="mr-1 size-4" />
导出 Excel 导出 Excel
</Button> </Button>
<Button
variant="outline"
size="sm"
onClick={() => window.open("#/public/leaderboard", "_blank", "noopener")}
className="h-9"
title="打开免登录的公开发布页"
>
<ExternalLink className="mr-1 size-4" />
公开页
</Button>
<Dialog open={reportOpen} onOpenChange={handleReportOpenChange}> <Dialog open={reportOpen} onOpenChange={handleReportOpenChange}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" size="sm" disabled={!data} className="h-9"> <Button variant="outline" size="sm" disabled={!data} className="h-9">
@@ -1279,16 +1295,16 @@ export default function AdminUserLeaderboardPage() {
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="flex h-[92vh] w-[96vw] max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-none"> <DialogContent className="flex h-[92vh] w-[96vw] max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-none">
<DialogHeader className="shrink-0 border-b border-[#F1D6D6] px-6 py-5 pr-12 dark:border-[#462123]"> <DialogHeader className="shrink-0 border-b border-[#F1D6D6] px-6 py-5 pr-12 dark:border-[#213D5E]">
<DialogTitle>AI 榜单分析报告</DialogTitle> <DialogTitle>AI 榜单分析报告</DialogTitle>
<DialogDescription> <DialogDescription>
基于当前时间段的问答、用户、技能、模型和趋势统计生成运营分析。 基于当前时间段的问答、用户、技能、模型和趋势统计生成运营分析。
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="mx-6 mt-4 flex shrink-0 flex-wrap items-center justify-between gap-3 rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#462123] dark:bg-[#230D0F]"> <div className="mx-6 mt-4 flex shrink-0 flex-wrap items-center justify-between gap-3 rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-wrap items-center gap-2">
<Select value={reportModelName} onValueChange={setReportModelName} disabled={reportGenerating || reportModelsLoading}> <Select value={reportModelName} onValueChange={setReportModelName} disabled={reportGenerating || reportModelsLoading}>
<SelectTrigger className="h-9 w-[260px] border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#19090A]"> <SelectTrigger className="h-9 w-[260px] border-[#F1D6D6] bg-white dark:border-[#213D5E] dark:bg-[#011226]">
<SelectValue placeholder="选择报告模型" /> <SelectValue placeholder="选择报告模型" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -1302,7 +1318,7 @@ export default function AdminUserLeaderboardPage() {
</Select> </Select>
{reportStatus === "cached" && <Badge variant="outline">已读取缓存</Badge>} {reportStatus === "cached" && <Badge variant="outline">已读取缓存</Badge>}
{reportResolvedModel && <Badge variant="outline">模型 {reportResolvedModel}</Badge>} {reportResolvedModel && <Badge variant="outline">模型 {reportResolvedModel}</Badge>}
{reportGeneratedAt && <span className="text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">生成于 {fmtTime(reportGeneratedAt)}</span>} {reportGeneratedAt && <span className="text-xs text-[#7A5A5A] dark:text-[#7FACF2]">生成于 {fmtTime(reportGeneratedAt)}</span>}
</div> </div>
<Button size="sm" onClick={startReportGeneration} disabled={!data || reportGenerating}> <Button size="sm" onClick={startReportGeneration} disabled={!data || reportGenerating}>
{reportGenerating ? <Loader2 className="mr-1 size-4 animate-spin" /> : <Sparkles className="mr-1 size-4" />} {reportGenerating ? <Loader2 className="mr-1 size-4 animate-spin" /> : <Sparkles className="mr-1 size-4" />}
@@ -1311,24 +1327,24 @@ export default function AdminUserLeaderboardPage() {
</div> </div>
{data && reportStatus === "idle" && !reportText && ( {data && reportStatus === "idle" && !reportText && (
<div className="mx-6 mt-4 grid shrink-0 gap-3 md:grid-cols-3"> <div className="mx-6 mt-4 grid shrink-0 gap-3 md:grid-cols-3">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#462123] dark:bg-[#230D0F]"> <section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white"> <div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
<MessageSquareText className="size-4 text-[#C1121F]" /> <MessageSquareText className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
最近关注 最近关注
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{data.recent_questions.slice(0, 3).map((item, index) => ( {data.recent_questions.slice(0, 3).map((item, index) => (
<div key={`${item.run_id || item.text}-${index}`} className="text-xs leading-5 text-[#5F6368] dark:text-slate-300"> <div key={`${item.run_id || item.text}-${index}`} className="text-xs leading-5 text-[#5F6368] dark:text-slate-300">
<span className="font-medium text-[#C1121F]">{fmtTime(item.asked_at)}</span> <span className="font-medium text-[#C1121F] dark:text-[#7FACF2]">{fmtTime(item.asked_at)}</span>
<span className="ml-1">{clampText(item.text, 58)}</span> <span className="ml-1">{clampText(item.text, 58)}</span>
</div> </div>
))} ))}
{data.recent_questions.length === 0 && <div className="text-xs text-[#8B6B6B]">暂无数据</div>} {data.recent_questions.length === 0 && <div className="text-xs text-[#8B6B6B] dark:text-[#7FACF2]">暂无数据</div>}
</div> </div>
</section> </section>
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#462123] dark:bg-[#230D0F]"> <section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white"> <div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
<TrendingUp className="size-4 text-[#C1121F]" /> <TrendingUp className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
高频问题 高频问题
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -1337,34 +1353,34 @@ export default function AdminUserLeaderboardPage() {
{index + 1}. {clampText(item.text, 72)} {index + 1}. {clampText(item.text, 72)}
</div> </div>
))} ))}
{data.frequent_questions.length === 0 && <div className="text-xs text-[#8B6B6B]">暂无数据</div>} {data.frequent_questions.length === 0 && <div className="text-xs text-[#8B6B6B] dark:text-[#7FACF2]">暂无数据</div>}
</div> </div>
</section> </section>
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#462123] dark:bg-[#230D0F]"> <section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white"> <div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
<Sparkles className="size-4 text-[#C1121F]" /> <Sparkles className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
使用频率 使用频率
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{[...data.skills.slice(0, 2), ...data.tools.slice(0, 1)].map((item, index) => ( {[...data.skills.slice(0, 2), ...data.tools.slice(0, 1)].map((item, index) => (
<div key={`${item.name}-${index}`} className="flex items-center justify-between gap-2 text-xs leading-5 text-[#5F6368] dark:text-slate-300"> <div key={`${item.name}-${index}`} className="flex items-center justify-between gap-2 text-xs leading-5 text-[#5F6368] dark:text-slate-300">
<span className="truncate">{item.name}</span> <span className="truncate">{item.name}</span>
<span className="shrink-0 font-semibold text-[#C1121F]">{fmtNumber(item.call_count)} 次</span> <span className="shrink-0 font-semibold text-[#C1121F] dark:text-[#7FACF2]">{fmtNumber(item.call_count)} 次</span>
</div> </div>
))} ))}
{data.skills.length + data.tools.length === 0 && <div className="text-xs text-[#8B6B6B]">暂无数据</div>} {data.skills.length + data.tools.length === 0 && <div className="text-xs text-[#8B6B6B] dark:text-[#7FACF2]">暂无数据</div>}
</div> </div>
</section> </section>
</div> </div>
)} )}
<ScrollArea className="mx-6 mt-4 min-h-0 flex-1 rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-5 dark:border-[#462123] dark:bg-[#19090A]"> <ScrollArea className="mx-6 mt-4 min-h-0 flex-1 rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-5 dark:border-[#213D5E] dark:bg-[#011226]">
{reportStatus === "idle" && !reportText && ( {reportStatus === "idle" && !reportText && (
<div className="flex items-center justify-center py-12 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]"> <div className="flex items-center justify-center py-12 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
选择模型后点击生成;同一时间段、同一模型和同一统计设置会直接读取已保存报告。 选择模型后点击生成;同一时间段、同一模型和同一统计设置会直接读取已保存报告。
</div> </div>
)} )}
{reportGenerating && !reportText && ( {reportGenerating && !reportText && (
<div className="flex items-center justify-center gap-2 py-12 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]"> <div className="flex items-center justify-center gap-2 py-12 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
正在调用大模型生成报告,内容会流式显示 正在调用大模型生成报告,内容会流式显示
</div> </div>
@@ -1375,13 +1391,13 @@ export default function AdminUserLeaderboardPage() {
</div> </div>
)} )}
{reportText && ( {reportText && (
<div className="rounded-lg bg-white px-8 py-6 text-base leading-8 text-[#231815] shadow-sm dark:bg-[#230D0F] dark:text-white [&_blockquote]:border-l-4 [&_blockquote]:border-[#C1121F] [&_blockquote]:bg-[#FFF7F7] [&_blockquote]:px-4 [&_blockquote]:py-3 [&_code]:rounded [&_code]:bg-[#FFF1F1] [&_code]:px-1.5 [&_h1]:mb-5 [&_h1]:text-2xl [&_h1]:font-semibold [&_h2]:mb-4 [&_h2]:mt-7 [&_h2]:border-b [&_h2]:border-[#F1D6D6] [&_h2]:pb-3 [&_h2]:text-xl [&_h2]:font-semibold [&_h3]:mb-3 [&_h3]:mt-5 [&_h3]:text-lg [&_h3]:font-semibold [&_li]:my-1.5 [&_ol]:pl-6 [&_p]:my-3 [&_strong]:text-[#C1121F] [&_table]:my-4 [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-[#F1D6D6] [&_td]:p-3 [&_th]:border [&_th]:border-[#F1D6D6] [&_th]:bg-[#FFF1F1] [&_th]:p-3 [&_ul]:pl-6 dark:[&_blockquote]:bg-[#19090A] dark:[&_code]:bg-[#19090A] dark:[&_strong]:text-[#F2B8BB] dark:[&_th]:bg-[#19090A]"> <div className="rounded-lg bg-white px-8 py-6 text-base leading-8 text-[#231815] shadow-sm dark:bg-[#02244C] dark:text-white [&_blockquote]:border-l-4 [&_blockquote]:border-[#C1121F] dark:border-[#2563EB] [&_blockquote]:bg-[#FFF7F7] [&_blockquote]:px-4 [&_blockquote]:py-3 [&_code]:rounded [&_code]:bg-[#FFF1F1] [&_code]:px-1.5 [&_h1]:mb-5 [&_h1]:text-2xl [&_h1]:font-semibold [&_h2]:mb-4 [&_h2]:mt-7 [&_h2]:border-b [&_h2]:border-[#F1D6D6] [&_h2]:pb-3 [&_h2]:text-xl [&_h2]:font-semibold [&_h3]:mb-3 [&_h3]:mt-5 [&_h3]:text-lg [&_h3]:font-semibold [&_li]:my-1.5 [&_ol]:pl-6 [&_p]:my-3 [&_strong]:text-[#C1121F] dark:text-[#7FACF2] [&_table]:my-4 [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-[#F1D6D6] [&_td]:p-3 [&_th]:border [&_th]:border-[#F1D6D6] [&_th]:bg-[#FFF1F1] [&_th]:p-3 [&_ul]:pl-6 dark:[&_blockquote]:bg-[#011226] dark:[&_code]:bg-[#011226] dark:[&_strong]:text-[#7FACF2] dark:[&_th]:bg-[#011226]">
<MarkdownContent content={reportText} isLoading={reportGenerating} rehypePlugins={[]} /> <MarkdownContent content={reportText} isLoading={reportGenerating} rehypePlugins={[]} />
</div> </div>
)} )}
</ScrollArea> </ScrollArea>
{reportText && ( {reportText && (
<div className="mt-4 flex shrink-0 justify-end border-t border-[#F1D6D6] px-6 py-4 dark:border-[#462123]"> <div className="mt-4 flex shrink-0 justify-end border-t border-[#F1D6D6] px-6 py-4 dark:border-[#213D5E]">
<Button <Button
variant="outline" variant="outline"
onClick={exportReportAsWord} onClick={exportReportAsWord}
@@ -1394,6 +1410,8 @@ export default function AdminUserLeaderboardPage() {
)} )}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div>
<div className="flex flex-wrap items-end gap-2">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-xs text-[#5F6368] dark:text-slate-400">开始日期</label> <label className="text-xs text-[#5F6368] dark:text-slate-400">开始日期</label>
<Input <Input
@@ -1402,7 +1420,7 @@ export default function AdminUserLeaderboardPage() {
onClick={(event) => event.currentTarget.showPicker?.()} onClick={(event) => event.currentTarget.showPicker?.()}
onFocus={(event) => event.currentTarget.showPicker?.()} onFocus={(event) => event.currentTarget.showPicker?.()}
onChange={(event) => setRange((prev) => ({ ...prev, since: event.target.value }))} onChange={(event) => setRange((prev) => ({ ...prev, since: event.target.value }))}
className="h-9 w-36 bg-white dark:bg-[#19090A]" className="h-9 w-36 bg-white dark:bg-[#011226]"
/> />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -1413,7 +1431,7 @@ export default function AdminUserLeaderboardPage() {
onClick={(event) => event.currentTarget.showPicker?.()} onClick={(event) => event.currentTarget.showPicker?.()}
onFocus={(event) => event.currentTarget.showPicker?.()} onFocus={(event) => event.currentTarget.showPicker?.()}
onChange={(event) => setRange((prev) => ({ ...prev, until: event.target.value }))} onChange={(event) => setRange((prev) => ({ ...prev, until: event.target.value }))}
className="h-9 w-36 bg-white dark:bg-[#19090A]" className="h-9 w-36 bg-white dark:bg-[#011226]"
/> />
</div> </div>
<Button variant="outline" size="sm" onClick={() => setQuickRange(7)}> <Button variant="outline" size="sm" onClick={() => setQuickRange(7)}>
@@ -1425,6 +1443,7 @@ export default function AdminUserLeaderboardPage() {
<Button variant="outline" size="sm" onClick={() => setQuickRange(90)}> <Button variant="outline" size="sm" onClick={() => setQuickRange(90)}>
近 90 天 近 90 天
</Button> </Button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1432,12 +1451,12 @@ export default function AdminUserLeaderboardPage() {
<ScrollArea className="min-h-0 flex-1"> <ScrollArea className="min-h-0 flex-1">
<div className="space-y-5 p-6"> <div className="space-y-5 p-6">
{isLoading && ( {isLoading && (
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center text-sm text-[#5F6368]"> <div className="flex flex-col items-center justify-center gap-3 py-20 text-center text-sm text-[#5F6368] dark:text-slate-400">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
<span>正在统计排行榜数据</span> <span>正在统计排行榜数据</span>
</div> </div>
<div className="max-w-md text-xs leading-5 text-[#7A5A5A] dark:text-[#D8B8B8]"> <div className="max-w-md text-xs leading-5 text-[#7A5A5A] dark:text-[#7FACF2]">
线上用户较多或时间范围较大时,统计时间可能较长,请耐心等待。为避免影响其他接口,系统会限制排行榜统计的并发数量。 线上用户较多或时间范围较大时,统计时间可能较长,请耐心等待。为避免影响其他接口,系统会限制排行榜统计的并发数量。
</div> </div>
</div> </div>
@@ -1450,12 +1469,12 @@ export default function AdminUserLeaderboardPage() {
)} )}
{!isLoading && !error && leaderboardPending && !leaderboardHasRows && ( {!isLoading && !error && leaderboardPending && !leaderboardHasRows && (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-[#F1D6D6] bg-white px-6 py-16 text-center text-sm text-[#5F6368] shadow-sm dark:border-[#462123] dark:bg-[#230D0F]"> <div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-[#F1D6D6] bg-white px-6 py-16 text-center text-sm text-[#5F6368] shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex items-center gap-2 font-medium text-[#202124] dark:text-white"> <div className="flex items-center gap-2 font-medium text-[#202124] dark:text-white">
<Loader2 className="size-4 animate-spin text-[#C1121F]" /> <Loader2 className="size-4 animate-spin text-[#C1121F] dark:text-[#7FACF2]" />
<span>排行榜正在后台统计</span> <span>排行榜正在后台统计</span>
</div> </div>
<div className="max-w-lg text-xs leading-5 text-[#7A5A5A] dark:text-[#D8B8B8]"> <div className="max-w-lg text-xs leading-5 text-[#7A5A5A] dark:text-[#7FACF2]">
{data?.message || "统计任务已放到后台执行,页面会自动刷新结果。这样排行榜慢慢算,不会长时间占住当前接口请求。"} {data?.message || "统计任务已放到后台执行,页面会自动刷新结果。这样排行榜慢慢算,不会长时间占住当前接口请求。"}
</div> </div>
</div> </div>
@@ -1495,7 +1514,7 @@ export default function AdminUserLeaderboardPage() {
/> />
)} )}
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]"> <section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3"> <div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div> <div>
<h2 className="font-semibold text-[#202124] dark:text-white">榜单前三</h2> <h2 className="font-semibold text-[#202124] dark:text-white">榜单前三</h2>
@@ -1534,7 +1553,7 @@ export default function AdminUserLeaderboardPage() {
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
placeholder="搜索用户邮箱或 ID" placeholder="搜索用户邮箱或 ID"
className="h-9 w-64 bg-white pl-8 dark:bg-[#19090A]" className="h-9 w-64 bg-white pl-8 dark:bg-[#011226]"
/> />
</div> </div>
</div> </div>
@@ -1558,8 +1577,8 @@ export default function AdminUserLeaderboardPage() {
</div> </div>
</section> </section>
<section className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#462123] dark:bg-[#230D0F]"> <section className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex items-center gap-2 border-b border-[#F1D6D6] px-4 py-3 dark:border-[#462123]"> <div className="flex items-center gap-2 border-b border-[#F1D6D6] px-4 py-3 dark:border-[#213D5E]">
<UserRound className="size-4 text-[#5F6368] dark:text-slate-300" /> <UserRound className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">用户详情</h2> <h2 className="font-semibold text-[#202124] dark:text-white">用户详情</h2>
</div> </div>
@@ -1575,7 +1594,7 @@ export default function AdminUserLeaderboardPage() {
</section> </section>
</div> </div>
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]"> <section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3"> <div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TrendingUp className="size-4 text-[#5F6368] dark:text-slate-300" /> <TrendingUp className="size-4 text-[#5F6368] dark:text-slate-300" />
@@ -61,17 +61,17 @@ export default function AdminUserLeaderboardSettingsPage() {
}; };
return ( return (
<div className="flex size-full min-h-0 bg-[#FFF7F7] text-[#231815] dark:bg-[#19090A] dark:text-white"> <div className="flex size-full min-h-0 bg-[#FFF7F7] text-[#231815] dark:bg-[#011226] dark:text-white">
<main className="flex min-w-0 flex-1 flex-col"> <main className="flex min-w-0 flex-1 flex-col">
<div className="border-b border-[#F1D6D6] bg-white px-6 py-5 dark:border-[#462123] dark:bg-[#230D0F]"> <div className="border-b border-[#F1D6D6] bg-white px-6 py-5 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div> <div>
<div className="mb-2 flex items-center gap-2 text-sm text-[#9F343A]"> <div className="mb-2 flex items-center gap-2 text-sm text-[#9F343A] dark:text-[#7FACF2]">
<SlidersHorizontal className="size-4" /> <SlidersHorizontal className="size-4" />
管理员设置 管理员设置
</div> </div>
<h1 className="text-2xl font-semibold">排行榜设置</h1> <h1 className="text-2xl font-semibold">排行榜设置</h1>
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]"> <p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
这里的规则会影响用户排行榜、趋势、技能、模型、问题与个人详情统计。 这里的规则会影响用户排行榜、趋势、技能、模型、问题与个人详情统计。
</p> </p>
</div> </div>
@@ -86,14 +86,14 @@ export default function AdminUserLeaderboardSettingsPage() {
<ScrollArea className="min-h-0 flex-1"> <ScrollArea className="min-h-0 flex-1">
<div className="mx-auto max-w-6xl space-y-5 p-6"> <div className="mx-auto max-w-6xl space-y-5 p-6">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-5 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]"> <section className="rounded-lg border border-[#F1D6D6] bg-white p-5 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex items-start justify-between gap-3"> <div className="mb-4 flex items-start justify-between gap-3">
<div> <div>
<div className="flex items-center gap-2 font-semibold"> <div className="flex items-center gap-2 font-semibold">
<ShieldOff className="size-4 text-[#C1121F]" /> <ShieldOff className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
统计排除名单 统计排除名单
</div> </div>
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]"> <p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
名单中的账号不会进入排行榜、趋势、问题、模型和技能统计。 名单中的账号不会进入排行榜、趋势、问题、模型和技能统计。
</p> </p>
</div> </div>
@@ -111,21 +111,21 @@ export default function AdminUserLeaderboardSettingsPage() {
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
placeholder="搜索账号邮箱或 ID" placeholder="搜索账号邮箱或 ID"
className="h-10 border-[#F1D6D6] bg-white pl-9" className="h-10 border-[#F1D6D6] bg-white pl-9 dark:border-[#213D5E] dark:bg-[#011226] dark:text-white"
/> />
{query.trim() && candidates.length > 0 && ( {query.trim() && candidates.length > 0 && (
<div className="absolute z-20 mt-2 max-h-72 w-full overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-lg dark:border-[#462123] dark:bg-[#230D0F]"> <div className="absolute z-20 mt-2 max-h-72 w-full overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-lg dark:border-[#213D5E] dark:bg-[#02244C]">
<ScrollArea className="max-h-72"> <ScrollArea className="max-h-72">
{candidates.map((user) => ( {candidates.map((user) => (
<button <button
key={user.id} key={user.id}
type="button" type="button"
onClick={() => addUser(user.id)} onClick={() => addUser(user.id)}
className="flex w-full items-center justify-between gap-3 border-b border-[#F8EAEA] px-3 py-2 text-left text-sm last:border-b-0 hover:bg-[#FFF1F1] dark:border-[#462123] dark:hover:bg-[#361315]" className="flex w-full items-center justify-between gap-3 border-b border-[#F8EAEA] px-3 py-2 text-left text-sm last:border-b-0 hover:bg-[#FFF1F1] dark:border-[#213D5E] dark:hover:bg-[#361315]"
> >
<span className="min-w-0"> <span className="min-w-0">
<span className="block truncate font-medium">{user.email}</span> <span className="block truncate font-medium">{user.email}</span>
<span className="block truncate text-xs text-[#8B6B6B]">{user.id}</span> <span className="block truncate text-xs text-[#8B6B6B] dark:text-[#7FACF2]">{user.id}</span>
</span> </span>
<Badge variant="outline">{user.system_role === "admin" ? "管理员" : "用户"}</Badge> <Badge variant="outline">{user.system_role === "admin" ? "管理员" : "用户"}</Badge>
</button> </button>
@@ -134,15 +134,15 @@ export default function AdminUserLeaderboardSettingsPage() {
</div> </div>
)} )}
</div> </div>
<div className="min-h-10 rounded-lg bg-[#FFF7F7] p-3 dark:bg-[#19090A]"> <div className="min-h-10 rounded-lg bg-[#FFF7F7] p-3 dark:bg-[#011226]">
{selectedUsers.length === 0 ? ( {selectedUsers.length === 0 ? (
<span className="text-sm text-[#8B6B6B]">暂无排除账号</span> <span className="text-sm text-[#8B6B6B] dark:text-[#7FACF2]">暂无排除账号</span>
) : ( ) : (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{selectedUsers.map((user) => ( {selectedUsers.map((user) => (
<span <span
key={user.id} key={user.id}
className="inline-flex max-w-[280px] items-center gap-1.5 rounded-full border border-[#F1D6D6] bg-white px-3 py-1.5 text-xs text-[#7A1F25] shadow-sm dark:border-[#462123] dark:bg-[#230D0F] dark:text-[#F2B8BB]" className="inline-flex max-w-[280px] items-center gap-1.5 rounded-full border border-[#F1D6D6] bg-white px-3 py-1.5 text-xs text-[#7A1F25] shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-[#7FACF2]"
title={userLabel(user)} title={userLabel(user)}
> >
<span className="truncate">{userLabel(user)}</span> <span className="truncate">{userLabel(user)}</span>
@@ -157,14 +157,14 @@ export default function AdminUserLeaderboardSettingsPage() {
</div> </div>
</section> </section>
<section className="rounded-lg border border-[#F1D6D6] bg-white p-5 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]"> <section className="rounded-lg border border-[#F1D6D6] bg-white p-5 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex items-start justify-between gap-3"> <div className="mb-4 flex items-start justify-between gap-3">
<div> <div>
<div className="flex items-center gap-2 font-semibold"> <div className="flex items-center gap-2 font-semibold">
<Save className="size-4 text-[#C1121F]" /> <Save className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
统计规则 统计规则
</div> </div>
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]"> <p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
关闭某项后,该类数据会从榜单、趋势、问题列表和个人统计中移除。 关闭某项后,该类数据会从榜单、趋势、问题列表和个人统计中移除。
</p> </p>
</div> </div>
@@ -180,11 +180,11 @@ export default function AdminUserLeaderboardSettingsPage() {
].map(([key, title, desc]) => ( ].map(([key, title, desc]) => (
<label <label
key={key} key={key}
className="flex min-h-28 cursor-pointer items-start justify-between gap-4 rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-4 dark:border-[#462123] dark:bg-[#19090A]" className="flex min-h-28 cursor-pointer items-start justify-between gap-4 rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-4 dark:border-[#213D5E] dark:bg-[#011226]"
> >
<span> <span>
<span className="block font-medium">{title}</span> <span className="block font-medium">{title}</span>
<span className="mt-1 block text-sm leading-6 text-[#7A5A5A] dark:text-[#D8B8B8]">{desc}</span> <span className="mt-1 block text-sm leading-6 text-[#7A5A5A] dark:text-[#7FACF2]">{desc}</span>
</span> </span>
<Switch <Switch
checked={Boolean(settings?.[key as keyof typeof settings])} checked={Boolean(settings?.[key as keyof typeof settings])}
@@ -35,7 +35,11 @@ const errorToastDarkOptions = {
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />, icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
} as const; } as const;
export default function PrivateSquareSettingsPage() { export default function PrivateSquareSettingsPage({
embedded = false,
}: {
embedded?: boolean;
} = {}) {
const { data, isLoading, error } = usePrivateSquareStatus(); const { data, isLoading, error } = usePrivateSquareStatus();
const update = useUpdatePrivateSquareSettings(); const update = useUpdatePrivateSquareSettings();
const { theme } = useTheme(); const { theme } = useTheme();
@@ -95,9 +99,15 @@ export default function PrivateSquareSettingsPage() {
}; };
return ( return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10"> <div
className={
embedded
? "flex w-full flex-col gap-6"
: "mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10"
}
>
<div> <div>
<h1 className="text-xl font-semibold">私密广场配置</h1> <h1 className={embedded ? "text-base font-semibold" : "text-xl font-semibold"}>私密广场配置</h1>
<p className="text-muted-foreground mt-1 text-sm"> <p className="text-muted-foreground mt-1 text-sm">
管理员可在此开启「私密广场」,并配置访问密码。开启后,用户可将自己发布的智能体放入私密广场; 管理员可在此开启「私密广场」,并配置访问密码。开启后,用户可将自己发布的智能体放入私密广场;
其他用户只有输入下方设置的密码后,才能在「智能体 → 私密广场」页签下查看到这些智能体。 其他用户只有输入下方设置的密码后,才能在「智能体 → 私密广场」页签下查看到这些智能体。
@@ -35,7 +35,11 @@ const errorToastDarkOptions = {
}, },
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />, icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
} as const; } as const;
export default function PromptPrefixSettingsPage() { export default function PromptPrefixSettingsPage({
embedded = false,
}: {
embedded?: boolean;
} = {}) {
const { data, isLoading, error } = usePromptPrefixSettings(); const { data, isLoading, error } = usePromptPrefixSettings();
const update = useUpdatePromptPrefixSettings(); const update = useUpdatePromptPrefixSettings();
const { theme } = useTheme(); const { theme } = useTheme();
@@ -83,9 +87,15 @@ export default function PromptPrefixSettingsPage() {
}; };
return ( return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10"> <div
className={
embedded
? "flex w-full flex-col gap-6"
: "mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10"
}
>
<div> <div>
<h1 className="text-xl font-semibold">问答前缀配置</h1> <h1 className={embedded ? "text-base font-semibold" : "text-xl font-semibold"}>问答前缀配置</h1>
<p className="text-muted-foreground mt-1 text-sm"> <p className="text-muted-foreground mt-1 text-sm">
管理员可在此配置问答页输入框中的「前缀开关」。开启后,用户在新会话中第一次提问时, 管理员可在此配置问答页输入框中的「前缀开关」。开启后,用户在新会话中第一次提问时,
会将下方配置的前缀文本和原问题拼接后再发送给模型。 会将下方配置的前缀文本和原问题拼接后再发送给模型。
@@ -0,0 +1,350 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import {
Activity,
Award,
Crown,
Gauge,
Loader2,
Medal,
MessageSquareText,
Sparkles,
TrendingUp,
Users,
type LucideIcon,
} from "lucide-react";
import type {
AdminLeaderboardResponse,
AdminLeaderboardSkill,
AdminLeaderboardUser,
} from "@/core/admin/api";
import { getBackendBaseURL } from "@/core/config";
const RANK_COLORS = ["#C1121F", "#E11D48", "#F59E0B", "#991B1B", "#B45309"] as const;
function fmtNumber(value: number | undefined | null): string {
const n = Number(value ?? 0);
if (!Number.isFinite(n)) return "0";
return n.toLocaleString("zh-CN");
}
function usernameOf(email: string | undefined | null): string {
if (!email) return "匿名用户";
const at = email.indexOf("@");
return at > 0 ? email.slice(0, at) : email;
}
function fmtDate(value: string | undefined | null): string {
if (!value) return "";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleString("zh-CN", { hour12: false });
}
async function fetchPublicLeaderboard(days: number): Promise<AdminLeaderboardResponse> {
const res = await fetch(
`${getBackendBaseURL()}/api/public/leaderboard?days=${days}&limit=20`,
{ headers: { Accept: "application/json" } },
);
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `加载排行榜失败 (HTTP ${res.status})`);
}
return (await res.json()) as AdminLeaderboardResponse;
}
export default function PublicLeaderboardPage() {
const [data, setData] = useState<AdminLeaderboardResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const days = 30;
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchPublicLeaderboard(days)
.then((res) => {
if (!cancelled) {
setData(res);
setError(null);
}
})
.catch((e) => {
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [days]);
const pending = data?.status === "running" || data?.status === "refreshing";
// The snapshot may still be computing on first load — poll briefly until ready.
useEffect(() => {
if (!pending) return;
const timer = setInterval(() => {
fetchPublicLeaderboard(days)
.then((res) => setData(res))
.catch(() => undefined);
}, 3000);
return () => clearInterval(timer);
}, [pending, days]);
return (
<div className="min-h-screen w-full bg-[#FFF7F7] text-[#231815] dark:bg-[#011226] dark:text-white">
<div className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6 sm:py-10">
{/* 标题区:仅展示,不含任何操作按钮 */}
<header className="mb-6">
<div className="mb-2 flex items-center gap-2 text-sm text-[#9F343A] dark:text-[#7FACF2]">
<Award className="size-4" />
公开榜单
</div>
<h1 className="text-2xl font-semibold tracking-normal sm:text-3xl">用户排行榜</h1>
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
展示近 {data?.range_days ?? days} 天用户活跃、问答、Token 消耗与技能热度。
{data?.since && data?.until && (
<span className="ml-1">
({fmtDate(data.since).slice(0, 10)} ~ {fmtDate(data.until).slice(0, 10)})
</span>
)}
</p>
</header>
{loading && !data && (
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center text-sm text-[#5F6368] dark:text-slate-400">
<Loader2 className="size-5 animate-spin" />
正在加载排行榜数据
</div>
)}
{error && (
<div className="rounded-lg border border-[#FAD2CF] bg-[#FCE8E6] p-5 text-sm text-[#DB4437] dark:border-[#DB4437]/30 dark:bg-[#DB4437]/10 dark:text-[#F28B82]">
加载失败:{error}
</div>
)}
{data && (
<div className="space-y-6">
{pending && (
<div className="rounded-lg border border-[#F1D6D6] bg-white px-4 py-3 text-sm text-[#7A5A5A] dark:border-[#213D5E] dark:bg-[#02244C] dark:text-[#7FACF2]">
<Loader2 className="mr-1 inline size-4 animate-spin" />
排行榜正在后台统计,数据会自动刷新。
</div>
)}
{/* 概览指标 */}
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard icon={Users} label="活跃用户" value={fmtNumber(data.overview.active_users)} sub={`共 ${fmtNumber(data.overview.total_users)} 名用户`} />
<StatCard icon={Activity} label="运行次数" value={fmtNumber(data.overview.total_runs)} sub={`${fmtNumber(data.overview.total_threads)} 个会话`} />
<StatCard icon={MessageSquareText} label="问答次数" value={fmtNumber(data.overview.total_questions)} sub={`${fmtNumber(data.overview.total_skill_calls)} 次技能调用`} />
<StatCard icon={Gauge} label="Token 消耗" value={fmtNumber(data.overview.total_tokens)} sub={`${fmtNumber(data.overview.total_tool_calls)} 次工具调用`} />
</section>
{/* 三类用户排名 */}
<section className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<RankCard
title="活跃榜"
icon={Activity}
users={data.users_by_activity}
metric={(u) => u.run_count}
unit="次运行"
/>
<RankCard
title="问答榜"
icon={MessageSquareText}
users={data.users_by_questions}
metric={(u) => u.qa_count}
unit="次问答"
/>
<RankCard
title="Token 榜"
icon={Gauge}
users={data.users_by_tokens}
metric={(u) => u.total_tokens}
unit="Token"
/>
</section>
{/* 技能热度 + 高频问题 */}
<section className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<SkillCard skills={data.skills} />
<QuestionsCard
title="高频问题"
icon={TrendingUp}
questions={data.frequent_questions.map((q) => q.text)}
/>
</section>
</div>
)}
<footer className="mt-10 border-t border-[#F1D6D6] pt-4 text-center text-xs text-[#8B6B6B] dark:border-[#213D5E] dark:text-[#7FACF2]">
{data?.generated_at ? `数据生成于 ${fmtDate(data.generated_at)}` : "仅供查看"}
</footer>
</div>
</div>
);
}
function StatCard({
icon: Icon,
label,
value,
sub,
}: {
icon: LucideIcon;
label: string;
value: string;
sub?: string;
}) {
return (
<div className="rounded-xl border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex items-center gap-2 text-xs font-medium text-[#7A5A5A] dark:text-[#7FACF2]">
<Icon className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
{label}
</div>
<div className="mt-2 text-2xl font-semibold tabular-nums text-[#231815] dark:text-white">
{value}
</div>
{sub && <div className="mt-1 truncate text-xs text-[#8B6B6B] dark:text-[#7FACF2]">{sub}</div>}
</div>
);
}
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);
return (
<div className="rounded-xl border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
<Icon className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
{title}
</div>
{top.length === 0 ? (
<div className="py-8 text-center text-sm text-[#8B6B6B] dark:text-[#7FACF2]">暂无数据</div>
) : (
<ol className="space-y-1.5">
{top.map((user, index) => (
<li
key={user.user_id}
className="flex items-center gap-3 rounded-lg px-2 py-1.5 hover:bg-[#FFF1F1] dark:hover:bg-[#361315]"
>
<RankBadge rank={index + 1} />
<span className="min-w-0 flex-1 truncate text-sm" title={usernameOf(user.email)}>
{usernameOf(user.email)}
</span>
<span className="shrink-0 text-sm font-semibold tabular-nums text-[#C1121F] dark:text-[#7FACF2]">
{fmtNumber(metric(user))}
</span>
<span className="shrink-0 text-xs text-[#8B6B6B] dark:text-[#7FACF2]">{unit}</span>
</li>
))}
</ol>
)}
</div>
);
}
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;
return (
<span
className="inline-flex size-6 shrink-0 items-center justify-center rounded-full text-white"
style={{ backgroundColor: color }}
>
<Icon className="size-3.5" />
</span>
);
}
return (
<span className="inline-flex size-6 shrink-0 items-center justify-center rounded-full bg-[#FFF1F1] text-xs font-semibold text-[#9F343A] dark:text-[#7FACF2] dark:bg-[#361315] dark:text-[#7FACF2]">
{rank}
</span>
);
}
function SkillCard({ skills }: { skills: AdminLeaderboardSkill[] }) {
const top = skills.slice(0, 10);
const max = Math.max(1, ...top.map((s) => s.call_count));
return (
<div className="rounded-xl border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
<Sparkles className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
技能热度
</div>
{top.length === 0 ? (
<div className="py-8 text-center text-sm text-[#8B6B6B] dark:text-[#7FACF2]">暂无数据</div>
) : (
<ul className="space-y-2.5">
{top.map((skill) => (
<li key={skill.name}>
<div className="flex items-center justify-between gap-2 text-sm">
<span className="min-w-0 truncate" title={skill.name}>
{skill.name}
</span>
<span className="shrink-0 font-semibold tabular-nums text-[#C1121F] dark:text-[#7FACF2]">
{fmtNumber(skill.call_count)} 次
</span>
</div>
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-[#FFF1F1] dark:bg-[#361315]">
<div
className="h-full rounded-full bg-[#C1121F] dark:bg-[#2563EB] dark:bg-[#7FACF2]"
style={{ width: `${Math.max(4, Math.round((skill.call_count / max) * 100))}%` }}
/>
</div>
</li>
))}
</ul>
)}
</div>
);
}
function QuestionsCard({
title,
icon: Icon,
questions,
}: {
title: string;
icon: LucideIcon;
questions: string[];
}) {
const top = questions.slice(0, 10);
return (
<div className="rounded-xl border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
<Icon className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
{title}
</div>
{top.length === 0 ? (
<div className="py-8 text-center text-sm text-[#8B6B6B] dark:text-[#7FACF2]">暂无数据</div>
) : (
<ol className="space-y-2">
{top.map((text, index) => (
<li key={`${index}-${text.slice(0, 12)}`} className="flex gap-2 text-sm leading-6">
<span className="shrink-0 font-semibold text-[#C1121F] dark:text-[#7FACF2]">{index + 1}.</span>
<span className="min-w-0 text-[#5F6368] dark:text-slate-300">{text}</span>
</li>
))}
</ol>
)}
</div>
);
}
@@ -59,7 +59,11 @@ const errorToastDarkOptions = {
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />, icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
} as const; } as const;
export default function RecommendedQuestionsPage() { export default function RecommendedQuestionsPage({
embedded = false,
}: {
embedded?: boolean;
} = {}) {
const { theme } = useTheme(); const { theme } = useTheme();
const { questions, isLoading, error } = useRecommendedQuestions(); const { questions, isLoading, error } = useRecommendedQuestions();
const createMutation = useCreateRecommendedQuestion(); const createMutation = useCreateRecommendedQuestion();
@@ -176,10 +180,16 @@ export default function RecommendedQuestionsPage() {
: { success: undefined, error: undefined }; : { success: undefined, error: undefined };
return ( return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10"> <div
className={
embedded
? "flex w-full flex-col gap-6"
: "mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10"
}
>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-xl font-semibold">推荐问题配置</h1> <h1 className={embedded ? "text-base font-semibold" : "text-xl font-semibold"}>推荐问题配置</h1>
<p className="text-muted-foreground mt-1 text-sm"> <p className="text-muted-foreground mt-1 text-sm">
管理首页展示的推荐问题,点击后直接发送到对话。拖拽可调整顺序。 管理首页展示的推荐问题,点击后直接发送到对话。拖拽可调整顺序。
</p> </p>
@@ -250,6 +250,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. | | **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
| **HTML Page Favorites** (`/api/html-page-favorites`) | Bookmarked generated HTML pages, owner-scoped. `GET /` - list; `POST /` `{page_id, title?, description?, tags?}` - bookmark a `scheduled_html_pages` row (extracts a reusable style/layout summary via GLM5 at favorite time); a page can only be favorited once per user (`DuplicateFavoriteError` → HTTP 409). `GET/DELETE /{id}` - read/delete (the create-task reference picker deletes favorites here). A favorite can be selected as a style reference when creating a new `html_page` task. | | **HTML Page Favorites** (`/api/html-page-favorites`) | Bookmarked generated HTML pages, owner-scoped. `GET /` - list; `POST /` `{page_id, title?, description?, tags?}` - bookmark a `scheduled_html_pages` row (extracts a reusable style/layout summary via GLM5 at favorite time); a page can only be favorited once per user (`DuplicateFavoriteError` → HTTP 409). `GET/DELETE /{id}` - read/delete (the create-task reference picker deletes favorites here). A favorite can be selected as a style reference when creating a new `html_page` task. |
| **Public Scheduled Tasks** (`/api/public/scheduled-tasks`) | **Unauthenticated** mirror for embedding. Adds `GET /runs/{rid}/html` and `GET /{task_id}/latest-html` for the `html_page` preview; the run result keeps `result.html` (only file paths are redacted). | | **Public Scheduled Tasks** (`/api/public/scheduled-tasks`) | **Unauthenticated** mirror for embedding. Adds `GET /runs/{rid}/html` and `GET /{task_id}/latest-html` for the `html_page` preview; the run result keeps `result.html` (only file paths are redacted). |
| **Public Leaderboard** (`/api/public/leaderboard`) | **Unauthenticated** read-only user leaderboard snapshot powering the public publish page (`#/public/leaderboard`). `GET /` returns the same `AdminLeaderboardResponse` as the admin endpoint, reusing `_load_or_schedule_daily_leaderboard` and honoring the system-wide leaderboard settings (excluded users, scheduled/admin/failed inclusion). No admin controls, no mutations. Router: `app/gateway/routers/public_leaderboard.py`. |
Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway. Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway.
@@ -2,12 +2,12 @@ import asyncio
import logging import logging
import os import os
import queue import queue
import sys import sys
import threading import threading
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime
from time import perf_counter from time import perf_counter
from dotenv import load_dotenv from dotenv import load_dotenv
from fastapi import FastAPI from fastapi import FastAPI
@@ -29,13 +29,13 @@ from app.gateway.routers import (
ai_writing, ai_writing,
admin_users, admin_users,
agents, agents,
artifacts, artifacts,
assistants_compat, assistants_compat,
auth, auth,
browser_context, browser_context,
channels, channels,
checkpoint_migration, checkpoint_migration,
feedback, feedback,
html_page_favorites, html_page_favorites,
intent, intent,
llm_metrics, llm_metrics,
@@ -45,6 +45,7 @@ from app.gateway.routers import (
multi_agent, multi_agent,
notifications, notifications,
public_embed, public_embed,
public_leaderboard,
public_scheduled_tasks, public_scheduled_tasks,
recommend, recommend,
recommended_questions, recommended_questions,
@@ -381,7 +382,7 @@ def _release_singleton_lock(handle) -> None:
pass pass
async def _curator_scheduler_loop() -> None: async def _curator_scheduler_loop() -> None:
"""常驻后台任务:每隔 _CURATOR_CHECK_INTERVAL_SECONDS 检查一次需不需要进化。 """常驻后台任务:每隔 _CURATOR_CHECK_INTERVAL_SECONDS 检查一次需不需要进化。
多 worker 部署下用文件锁做 leader 选举 —— 只有抢到锁的那个 worker 进程真正 多 worker 部署下用文件锁做 leader 选举 —— 只有抢到锁的那个 worker 进程真正
@@ -407,344 +408,344 @@ async def _curator_scheduler_loop() -> None:
except Exception: except Exception:
logger.exception("Curator scheduler tick failed (non-fatal)") logger.exception("Curator scheduler tick failed (non-fatal)")
await asyncio.sleep(_CURATOR_CHECK_INTERVAL_SECONDS) await asyncio.sleep(_CURATOR_CHECK_INTERVAL_SECONDS)
finally: finally:
_release_singleton_lock(lock_handle) _release_singleton_lock(lock_handle)
def _env_bool(name: str, default: bool) -> bool: def _env_bool(name: str, default: bool) -> bool:
raw = os.getenv(name) raw = os.getenv(name)
if raw is None: if raw is None:
return default return default
return raw.strip().lower() not in {"0", "false", "no", "off"} return raw.strip().lower() not in {"0", "false", "no", "off"}
def _env_int(name: str, default: int, *, alias: str | None = None, minimum: int = 1) -> int: def _env_int(name: str, default: int, *, alias: str | None = None, minimum: int = 1) -> int:
raw = os.getenv(name) raw = os.getenv(name)
if raw is None and alias: if raw is None and alias:
raw = os.getenv(alias) raw = os.getenv(alias)
if raw is None: if raw is None:
raw = str(default) raw = str(default)
try: try:
value = int(raw) value = int(raw)
except ValueError: except ValueError:
return default return default
return max(minimum, value) return max(minimum, value)
def _env_float(name: str, default: float, *, alias: str | None = None, minimum: float = 0.0) -> float: def _env_float(name: str, default: float, *, alias: str | None = None, minimum: float = 0.0) -> float:
raw = os.getenv(name) raw = os.getenv(name)
if raw is None and alias: if raw is None and alias:
raw = os.getenv(alias) raw = os.getenv(alias)
if raw is None: if raw is None:
raw = str(default) raw = str(default)
try: try:
value = float(raw) value = float(raw)
except ValueError: except ValueError:
return default return default
return max(minimum, value) return max(minimum, value)
_LEADERBOARD_SNAPSHOT_BACKGROUND_ENABLED = _env_bool("ADMIN_LEADERBOARD_BACKGROUND_ENABLED", True) _LEADERBOARD_SNAPSHOT_BACKGROUND_ENABLED = _env_bool("ADMIN_LEADERBOARD_BACKGROUND_ENABLED", True)
_LEADERBOARD_SNAPSHOT_PREWARM_DAYS = _env_int( _LEADERBOARD_SNAPSHOT_PREWARM_DAYS = _env_int(
"ADMIN_LEADERBOARD_BACKGROUND_PREWARM_DAYS", "ADMIN_LEADERBOARD_BACKGROUND_PREWARM_DAYS",
30, 30,
) )
_LEADERBOARD_SNAPSHOT_BATCH_SIZE = _env_int( _LEADERBOARD_SNAPSHOT_BATCH_SIZE = _env_int(
"ADMIN_LEADERBOARD_BACKGROUND_BATCH_SIZE", "ADMIN_LEADERBOARD_BACKGROUND_BATCH_SIZE",
1, 1,
) )
_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS = _env_float( _LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS = _env_float(
"ADMIN_LEADERBOARD_BACKGROUND_INTERVAL_SECONDS", "ADMIN_LEADERBOARD_BACKGROUND_INTERVAL_SECONDS",
300.0, 300.0,
) )
_LEADERBOARD_SNAPSHOT_SLEEP_SECONDS = _env_float( _LEADERBOARD_SNAPSHOT_SLEEP_SECONDS = _env_float(
"ADMIN_LEADERBOARD_BACKGROUND_SLEEP_SECONDS", "ADMIN_LEADERBOARD_BACKGROUND_SLEEP_SECONDS",
2.0, 2.0,
) )
_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS = _env_int( _LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS = _env_int(
"ADMIN_LEADERBOARD_BACKGROUND_RUNNING_TTL_SECONDS", "ADMIN_LEADERBOARD_BACKGROUND_RUNNING_TTL_SECONDS",
1800, 1800,
) )
_LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS = _env_float( _LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS = _env_float(
"ADMIN_LEADERBOARD_BACKGROUND_STARTUP_DELAY_SECONDS", "ADMIN_LEADERBOARD_BACKGROUND_STARTUP_DELAY_SECONDS",
60.0, 60.0,
) )
def _leaderboard_scheduler_sql_for_log(stmt) -> str: def _leaderboard_scheduler_sql_for_log(stmt) -> str:
try: try:
return str(stmt.compile(compile_kwargs={"literal_binds": True})) return str(stmt.compile(compile_kwargs={"literal_binds": True}))
except Exception: except Exception:
return str(stmt) return str(stmt)
async def _execute_leaderboard_scheduler_query(session, step: str, stmt, **context): async def _execute_leaderboard_scheduler_query(session, step: str, stmt, **context):
sql = _leaderboard_scheduler_sql_for_log(stmt) sql = _leaderboard_scheduler_sql_for_log(stmt)
logger.info( logger.info(
"Leaderboard snapshot DB query start: step=%s stat_date=%s job=%s settings_hash=%s started_at=%s sql=%s", "Leaderboard snapshot DB query start: step=%s stat_date=%s job=%s settings_hash=%s started_at=%s sql=%s",
step, step,
context.get("stat_date") or context.get("stat_dates") or "", context.get("stat_date") or context.get("stat_dates") or "",
context.get("job_key") or "", context.get("job_key") or "",
context.get("settings_hash") or "", context.get("settings_hash") or "",
datetime.now().isoformat(), datetime.now().isoformat(),
sql, sql,
) )
start = perf_counter() start = perf_counter()
try: try:
result = await session.execute(stmt) result = await session.execute(stmt)
except Exception: except Exception:
logger.exception( logger.exception(
"Leaderboard snapshot DB query failed: step=%s stat_date=%s job=%s settings_hash=%s elapsed_ms=%.2f", "Leaderboard snapshot DB query failed: step=%s stat_date=%s job=%s settings_hash=%s elapsed_ms=%.2f",
step, step,
context.get("stat_date") or context.get("stat_dates") or "", context.get("stat_date") or context.get("stat_dates") or "",
context.get("job_key") or "", context.get("job_key") or "",
context.get("settings_hash") or "", context.get("settings_hash") or "",
(perf_counter() - start) * 1000, (perf_counter() - start) * 1000,
) )
raise raise
logger.info( logger.info(
"Leaderboard snapshot DB query done: step=%s stat_date=%s job=%s settings_hash=%s finished_at=%s elapsed_ms=%.2f", "Leaderboard snapshot DB query done: step=%s stat_date=%s job=%s settings_hash=%s finished_at=%s elapsed_ms=%.2f",
step, step,
context.get("stat_date") or context.get("stat_dates") or "", context.get("stat_date") or context.get("stat_dates") or "",
context.get("job_key") or "", context.get("job_key") or "",
context.get("settings_hash") or "", context.get("settings_hash") or "",
datetime.now().isoformat(), datetime.now().isoformat(),
(perf_counter() - start) * 1000, (perf_counter() - start) * 1000,
) )
return result return result
async def _time_leaderboard_scheduler_db_operation(step: str, operation, *, sql: str = "", **context): async def _time_leaderboard_scheduler_db_operation(step: str, operation, *, sql: str = "", **context):
logger.info( logger.info(
"Leaderboard snapshot DB query start: step=%s stat_date=%s job=%s settings_hash=%s started_at=%s sql=%s", "Leaderboard snapshot DB query start: step=%s stat_date=%s job=%s settings_hash=%s started_at=%s sql=%s",
step, step,
context.get("stat_date") or context.get("stat_dates") or "", context.get("stat_date") or context.get("stat_dates") or "",
context.get("job_key") or "", context.get("job_key") or "",
context.get("settings_hash") or "", context.get("settings_hash") or "",
datetime.now().isoformat(), datetime.now().isoformat(),
sql, sql,
) )
start = perf_counter() start = perf_counter()
try: try:
result = await operation() result = await operation()
except Exception: except Exception:
logger.exception( logger.exception(
"Leaderboard snapshot DB query failed: step=%s stat_date=%s job=%s settings_hash=%s elapsed_ms=%.2f", "Leaderboard snapshot DB query failed: step=%s stat_date=%s job=%s settings_hash=%s elapsed_ms=%.2f",
step, step,
context.get("stat_date") or context.get("stat_dates") or "", context.get("stat_date") or context.get("stat_dates") or "",
context.get("job_key") or "", context.get("job_key") or "",
context.get("settings_hash") or "", context.get("settings_hash") or "",
(perf_counter() - start) * 1000, (perf_counter() - start) * 1000,
) )
raise raise
logger.info( logger.info(
"Leaderboard snapshot DB query done: step=%s stat_date=%s job=%s settings_hash=%s finished_at=%s elapsed_ms=%.2f", "Leaderboard snapshot DB query done: step=%s stat_date=%s job=%s settings_hash=%s finished_at=%s elapsed_ms=%.2f",
step, step,
context.get("stat_date") or context.get("stat_dates") or "", context.get("stat_date") or context.get("stat_dates") or "",
context.get("job_key") or "", context.get("job_key") or "",
context.get("settings_hash") or "", context.get("settings_hash") or "",
datetime.now().isoformat(), datetime.now().isoformat(),
(perf_counter() - start) * 1000, (perf_counter() - start) * 1000,
) )
return result return result
def _recent_leaderboard_stat_dates(days: int) -> list[str]: def _recent_leaderboard_stat_dates(days: int) -> list[str]:
from datetime import datetime, timedelta from datetime import datetime, timedelta
from deerflow.persistence.types import BEIJING_TZ from deerflow.persistence.types import BEIJING_TZ
today = datetime.now(BEIJING_TZ).date() today = datetime.now(BEIJING_TZ).date()
start = today - timedelta(days=max(1, days) - 1) start = today - timedelta(days=max(1, days) - 1)
return [(start + timedelta(days=i)).isoformat() for i in range((today - start).days + 1)] return [(start + timedelta(days=i)).isoformat() for i in range((today - start).days + 1)]
async def _leaderboard_snapshot_due_dates( async def _leaderboard_snapshot_due_dates(
session_factory, session_factory,
*, *,
settings_hash: str, settings_hash: str,
limit: int, limit: int,
running_ttl_seconds: int, running_ttl_seconds: int,
) -> list[str]: ) -> list[str]:
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from sqlalchemy import and_, or_, select from sqlalchemy import and_, or_, select
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
cutoff = datetime.now(UTC) - timedelta(seconds=running_ttl_seconds) cutoff = datetime.now(UTC) - timedelta(seconds=running_ttl_seconds)
async with session_factory() as session: async with session_factory() as session:
stmt = ( stmt = (
select(AdminLeaderboardDailyStatRow.stat_date) select(AdminLeaderboardDailyStatRow.stat_date)
.where( .where(
AdminLeaderboardDailyStatRow.settings_hash == settings_hash, AdminLeaderboardDailyStatRow.settings_hash == settings_hash,
or_( or_(
AdminLeaderboardDailyStatRow.status.in_(["queued", "error"]), AdminLeaderboardDailyStatRow.status.in_(["queued", "error"]),
and_( and_(
AdminLeaderboardDailyStatRow.status == "running", AdminLeaderboardDailyStatRow.status == "running",
AdminLeaderboardDailyStatRow.updated_at < cutoff, AdminLeaderboardDailyStatRow.updated_at < cutoff,
), ),
), ),
) )
.order_by(AdminLeaderboardDailyStatRow.stat_date.desc()) .order_by(AdminLeaderboardDailyStatRow.stat_date.desc())
.limit(limit) .limit(limit)
) )
rows = ( rows = (
await _execute_leaderboard_scheduler_query( await _execute_leaderboard_scheduler_query(
session, session,
"scheduler.due_dates", "scheduler.due_dates",
stmt, stmt,
settings_hash=settings_hash, settings_hash=settings_hash,
) )
).all() ).all()
return [str(row[0]) for row in rows] return [str(row[0]) for row in rows]
async def _claim_leaderboard_snapshot( async def _claim_leaderboard_snapshot(
session_factory, session_factory,
*, *,
stat_date: str, stat_date: str,
settings_hash: str, settings_hash: str,
running_ttl_seconds: int, running_ttl_seconds: int,
) -> bool: ) -> bool:
from datetime import UTC, datetime from datetime import UTC, datetime
from app.gateway.routers.admin_users import _snapshot_is_fresh from app.gateway.routers.admin_users import _snapshot_is_fresh
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
from deerflow.persistence.types import BEIJING_TZ from deerflow.persistence.types import BEIJING_TZ
async with session_factory() as session: async with session_factory() as session:
row = await _time_leaderboard_scheduler_db_operation( row = await _time_leaderboard_scheduler_db_operation(
"scheduler.claim.get", "scheduler.claim.get",
lambda: session.get( lambda: session.get(
AdminLeaderboardDailyStatRow, AdminLeaderboardDailyStatRow,
{"stat_date": stat_date, "settings_hash": settings_hash}, {"stat_date": stat_date, "settings_hash": settings_hash},
), ),
sql="SELECT admin_leaderboard_daily_stats by primary key", sql="SELECT admin_leaderboard_daily_stats by primary key",
stat_date=stat_date, stat_date=stat_date,
settings_hash=settings_hash, settings_hash=settings_hash,
) )
if row is None or _snapshot_is_fresh(row, stat_date): if row is None or _snapshot_is_fresh(row, stat_date):
return False return False
if row.status == "running": if row.status == "running":
updated_at = row.updated_at updated_at = row.updated_at
if isinstance(updated_at, datetime): if isinstance(updated_at, datetime):
if updated_at.tzinfo is None: if updated_at.tzinfo is None:
updated_at = updated_at.replace(tzinfo=BEIJING_TZ) updated_at = updated_at.replace(tzinfo=BEIJING_TZ)
age = (datetime.now(BEIJING_TZ) - updated_at.astimezone(BEIJING_TZ)).total_seconds() age = (datetime.now(BEIJING_TZ) - updated_at.astimezone(BEIJING_TZ)).total_seconds()
if age < running_ttl_seconds: if age < running_ttl_seconds:
return False return False
row.status = "running" row.status = "running"
row.message = "排行榜后台统计任务已认领。" row.message = "排行榜后台统计任务已认领。"
row.error = None row.error = None
row.updated_at = datetime.now(UTC) row.updated_at = datetime.now(UTC)
await _time_leaderboard_scheduler_db_operation( await _time_leaderboard_scheduler_db_operation(
"scheduler.claim.commit", "scheduler.claim.commit",
lambda: session.commit(), lambda: session.commit(),
sql="COMMIT", sql="COMMIT",
stat_date=stat_date, stat_date=stat_date,
settings_hash=settings_hash, settings_hash=settings_hash,
) )
return True return True
async def _run_leaderboard_snapshot_tick() -> tuple[int, int]: async def _run_leaderboard_snapshot_tick() -> tuple[int, int]:
from app.gateway.routers.admin_users import ( from app.gateway.routers.admin_users import (
_daily_job_key, _daily_job_key,
_queue_daily_leaderboard_snapshots, _queue_daily_leaderboard_snapshots,
_refresh_daily_leaderboard_snapshot, _refresh_daily_leaderboard_snapshot,
_resolve_leaderboard_snapshot_scope, _resolve_leaderboard_snapshot_scope,
) )
from deerflow.config.system_settings import load_system_settings from deerflow.config.system_settings import load_system_settings
from deerflow.persistence.engine import get_session_factory from deerflow.persistence.engine import get_session_factory
session_factory = get_session_factory() session_factory = get_session_factory()
if session_factory is None: if session_factory is None:
return 0, 0 return 0, 0
settings = load_system_settings().leaderboard settings = load_system_settings().leaderboard
excluded, include_scheduled, include_admins, include_failed, settings_hash = _resolve_leaderboard_snapshot_scope(settings) excluded, include_scheduled, include_admins, include_failed, settings_hash = _resolve_leaderboard_snapshot_scope(settings)
stat_dates = _recent_leaderboard_stat_dates(_LEADERBOARD_SNAPSHOT_PREWARM_DAYS) stat_dates = _recent_leaderboard_stat_dates(_LEADERBOARD_SNAPSHOT_PREWARM_DAYS)
queued_dates = await _queue_daily_leaderboard_snapshots( queued_dates = await _queue_daily_leaderboard_snapshots(
session_factory, session_factory,
stat_dates, stat_dates,
settings_hash, settings_hash,
message="排行榜后台统计任务定期预热/补算队列。", message="排行榜后台统计任务定期预热/补算队列。",
) )
due_dates = await _leaderboard_snapshot_due_dates( due_dates = await _leaderboard_snapshot_due_dates(
session_factory, session_factory,
settings_hash=settings_hash, settings_hash=settings_hash,
limit=_LEADERBOARD_SNAPSHOT_BATCH_SIZE, limit=_LEADERBOARD_SNAPSHOT_BATCH_SIZE,
running_ttl_seconds=_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS, running_ttl_seconds=_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS,
) )
processed = 0 processed = 0
for stat_date in due_dates: for stat_date in due_dates:
claimed = await _claim_leaderboard_snapshot( claimed = await _claim_leaderboard_snapshot(
session_factory, session_factory,
stat_date=stat_date, stat_date=stat_date,
settings_hash=settings_hash, settings_hash=settings_hash,
running_ttl_seconds=_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS, running_ttl_seconds=_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS,
) )
if not claimed: if not claimed:
continue continue
logger.info("Leaderboard snapshot scheduler: building date=%s hash=%s", stat_date, settings_hash[:10]) logger.info("Leaderboard snapshot scheduler: building date=%s hash=%s", stat_date, settings_hash[:10])
await _refresh_daily_leaderboard_snapshot( await _refresh_daily_leaderboard_snapshot(
_daily_job_key(stat_date, settings_hash), _daily_job_key(stat_date, settings_hash),
session_factory=session_factory, session_factory=session_factory,
settings=settings, settings=settings,
stat_date=stat_date, stat_date=stat_date,
settings_hash=settings_hash, settings_hash=settings_hash,
excluded=excluded, excluded=excluded,
include_scheduled=include_scheduled, include_scheduled=include_scheduled,
include_admins=include_admins, include_admins=include_admins,
include_failed=include_failed, include_failed=include_failed,
) )
processed += 1 processed += 1
if _LEADERBOARD_SNAPSHOT_SLEEP_SECONDS > 0 and processed < len(due_dates): if _LEADERBOARD_SNAPSHOT_SLEEP_SECONDS > 0 and processed < len(due_dates):
await asyncio.sleep(_LEADERBOARD_SNAPSHOT_SLEEP_SECONDS) await asyncio.sleep(_LEADERBOARD_SNAPSHOT_SLEEP_SECONDS)
return processed, len(queued_dates) return processed, len(queued_dates)
async def _leaderboard_snapshot_scheduler_loop() -> None: async def _leaderboard_snapshot_scheduler_loop() -> None:
"""Background task inside the existing gateway process for daily snapshots.""" """Background task inside the existing gateway process for daily snapshots."""
if not _LEADERBOARD_SNAPSHOT_BACKGROUND_ENABLED: if not _LEADERBOARD_SNAPSHOT_BACKGROUND_ENABLED:
logger.info("Leaderboard snapshot scheduler disabled by ADMIN_LEADERBOARD_BACKGROUND_ENABLED") logger.info("Leaderboard snapshot scheduler disabled by ADMIN_LEADERBOARD_BACKGROUND_ENABLED")
return return
from deerflow.config.runtime_paths import runtime_home from deerflow.config.runtime_paths import runtime_home
lock_handle = _acquire_singleton_lock(runtime_home() / ".leaderboard_snapshot_scheduler.lock") lock_handle = _acquire_singleton_lock(runtime_home() / ".leaderboard_snapshot_scheduler.lock")
if lock_handle is None: if lock_handle is None:
logger.info("Leaderboard snapshot scheduler: another gateway worker holds the lock; this worker skips") logger.info("Leaderboard snapshot scheduler: another gateway worker holds the lock; this worker skips")
return return
logger.info( logger.info(
"Leaderboard snapshot scheduler started: prewarm_days=%d batch_size=%d interval=%.1fs", "Leaderboard snapshot scheduler started: prewarm_days=%d batch_size=%d interval=%.1fs",
_LEADERBOARD_SNAPSHOT_PREWARM_DAYS, _LEADERBOARD_SNAPSHOT_PREWARM_DAYS,
_LEADERBOARD_SNAPSHOT_BATCH_SIZE, _LEADERBOARD_SNAPSHOT_BATCH_SIZE,
_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS, _LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS,
) )
try: try:
if _LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS > 0: if _LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS > 0:
await asyncio.sleep(_LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS) await asyncio.sleep(_LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS)
while True: while True:
try: try:
processed, queued = await _run_leaderboard_snapshot_tick() processed, queued = await _run_leaderboard_snapshot_tick()
if processed or queued: if processed or queued:
logger.info( logger.info(
"Leaderboard snapshot scheduler tick: queued=%d processed=%d", "Leaderboard snapshot scheduler tick: queued=%d processed=%d",
queued, queued,
processed, processed,
) )
except Exception: except Exception:
logger.exception("Leaderboard snapshot scheduler tick failed (non-fatal)") logger.exception("Leaderboard snapshot scheduler tick failed (non-fatal)")
await asyncio.sleep(_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS) await asyncio.sleep(_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS)
finally: finally:
_release_singleton_lock(lock_handle) _release_singleton_lock(lock_handle)
def _warn_if_multi_worker() -> None: def _warn_if_multi_worker() -> None:
"""检测 uvicorn / gunicorn 多 worker 部署并打 ERROR 日志。 """检测 uvicorn / gunicorn 多 worker 部署并打 ERROR 日志。
主 agent / canvas / AI 写作流式响应在 Gateway 进程内通过 ``StreamBridge`` 主 agent / canvas / AI 写作流式响应在 Gateway 进程内通过 ``StreamBridge``
@@ -898,21 +899,21 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# 默认每天凌晨 3 点扫一次,删 retention_days(默认 7 天)以前的会话,联动 # 默认每天凌晨 3 点扫一次,删 retention_days(默认 7 天)以前的会话,联动
# 清理 MySQL 业务表 + SQLite checkpointer 里的 thread 状态。多 worker # 清理 MySQL 业务表 + SQLite checkpointer 里的 thread 状态。多 worker
# 下用文件锁选 leader,只有抢到锁的 worker 真正跑清理。 # 下用文件锁选 leader,只有抢到锁的 worker 真正跑清理。
try: try:
from app.gateway.ai_writing_cleanup import cleanup_scheduler_loop from app.gateway.ai_writing_cleanup import cleanup_scheduler_loop
app.state.ai_writing_cleanup_task = asyncio.create_task(cleanup_scheduler_loop(app)) app.state.ai_writing_cleanup_task = asyncio.create_task(cleanup_scheduler_loop(app))
except Exception: except Exception:
logger.exception("AI writing cleanup scheduler failed to start (non-fatal)") logger.exception("AI writing cleanup scheduler failed to start (non-fatal)")
try: try:
app.state.leaderboard_snapshot_scheduler_task = asyncio.create_task(_leaderboard_snapshot_scheduler_loop()) app.state.leaderboard_snapshot_scheduler_task = asyncio.create_task(_leaderboard_snapshot_scheduler_loop())
except Exception: except Exception:
logger.exception("Leaderboard snapshot scheduler failed to start (non-fatal)") logger.exception("Leaderboard snapshot scheduler failed to start (non-fatal)")
# Start IM channel service if any channels are configured # Start IM channel service if any channels are configured
try: try:
from app.channels.service import start_channel_service from app.channels.service import start_channel_service
channel_service = await start_channel_service(app.state.config) channel_service = await start_channel_service(app.state.config)
logger.info("Channel service started: %s", channel_service.get_status()) logger.info("Channel service started: %s", channel_service.get_status())
@@ -956,17 +957,17 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
except Exception: except Exception:
logger.exception("AI writing cleanup scheduler shutdown error") logger.exception("AI writing cleanup scheduler shutdown error")
leaderboard_snapshot_task = getattr(app.state, "leaderboard_snapshot_scheduler_task", None) leaderboard_snapshot_task = getattr(app.state, "leaderboard_snapshot_scheduler_task", None)
if leaderboard_snapshot_task is not None: if leaderboard_snapshot_task is not None:
leaderboard_snapshot_task.cancel() leaderboard_snapshot_task.cancel()
try: try:
await leaderboard_snapshot_task await leaderboard_snapshot_task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
except Exception: except Exception:
logger.exception("Leaderboard snapshot scheduler shutdown error") logger.exception("Leaderboard snapshot scheduler shutdown error")
# 阶段 7 删冗余:AI 写作 graph 不再自管 checkpointer / in-flight task; # 阶段 7 删冗余:AI 写作 graph 不再自管 checkpointer / in-flight task;
# thread 状态由 LangGraph Server 主 checkpointer 接管,lifespan 出口由 # thread 状态由 LangGraph Server 主 checkpointer 接管,lifespan 出口由
@@ -1198,14 +1199,14 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Artifacts API is mounted at /api/threads/{thread_id}/artifacts # Artifacts API is mounted at /api/threads/{thread_id}/artifacts
app.include_router(artifacts.router) app.include_router(artifacts.router)
# Uploads API is mounted at /api/threads/{thread_id}/uploads # Uploads API is mounted at /api/threads/{thread_id}/uploads
app.include_router(uploads.router) app.include_router(uploads.router)
# Optional Chrome-extension page capture API is mounted at /api/browser-context # Optional Chrome-extension page capture API is mounted at /api/browser-context
app.include_router(browser_context.router) app.include_router(browser_context.router)
# Thread cleanup API is mounted at /api/threads/{thread_id} # Thread cleanup API is mounted at /api/threads/{thread_id}
app.include_router(threads.router) app.include_router(threads.router)
# Agents API is mounted at /api/agents # Agents API is mounted at /api/agents
app.include_router(agents.router) app.include_router(agents.router)
@@ -1244,6 +1245,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# surface results without a DeerFlow login. # surface results without a DeerFlow login.
app.include_router(public_scheduled_tasks.router) app.include_router(public_scheduled_tasks.router)
# Public read-only user leaderboard publish page (/api/public/leaderboard)
app.include_router(public_leaderboard.router)
# Public embed Q&A status polling (/api/public/embed/*) # Public embed Q&A status polling (/api/public/embed/*)
app.include_router(public_embed.router) app.include_router(public_embed.router)
app.include_router(html_page_favorites.router) app.include_router(html_page_favorites.router)
@@ -1254,18 +1258,18 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Admin LLM metrics audit + export # Admin LLM metrics audit + export
app.include_router(llm_metrics.router) app.include_router(llm_metrics.router)
# Admin window into other users' threads / messages / memory # Admin window into other users' threads / messages / memory
app.include_router(admin_users.router) app.include_router(admin_users.router)
app.include_router(admin_users.self_router) app.include_router(admin_users.self_router)
# Admin tool / skill call metrics audit + export # Admin tool / skill call metrics audit + export
app.include_router(tool_metrics.router) app.include_router(tool_metrics.router)
# Channels API is mounted at /api/channels # Channels API is mounted at /api/channels
app.include_router(channels.router) app.include_router(channels.router)
# Admin checkpoint migration API: SQLite legacy checkpoints -> Postgres. # Admin checkpoint migration API: SQLite legacy checkpoints -> Postgres.
app.include_router(checkpoint_migration.router) app.include_router(checkpoint_migration.router)
# Assistants compatibility API (LangGraph Platform stub) # Assistants compatibility API (LangGraph Platform stub)
app.include_router(assistants_compat.router) app.include_router(assistants_compat.router)
@@ -15,7 +15,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.gateway.auth.password import verify_password_async from app.gateway.auth.password import verify_password_async
from app.gateway.deps import get_agent_store, get_config, get_tag_store from app.gateway.deps import get_agent_store, get_config, get_tag_store
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
from deerflow.config.agents_api_config import get_agents_api_config from deerflow.config.agents_api_config import get_agents_api_config
from deerflow.config.agents_config import list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id from deerflow.config.agents_config import list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id
@@ -39,6 +39,7 @@ class AgentResponse(BaseModel):
name: str = Field(..., description="Display name. Chinese and duplicate names are allowed.") name: str = Field(..., description="Display name. Chinese and duplicate names are allowed.")
description: str = Field(default="", description="Agent description") description: str = Field(default="", description="Agent description")
user_id: str | None = Field(default=None, description="Owner user id. Null means built-in.") user_id: str | None = Field(default=None, description="Owner user id. Null means built-in.")
owner_username: str | None = Field(default=None, description="Publisher username (email local part). Null for built-in agents.")
published: bool = Field(default=False, description="Whether a user-created agent is public") published: bool = Field(default=False, description="Whether a user-created agent is public")
is_private: bool = Field(default=False, description="Whether the agent lives in the password-gated private square instead of the public one") is_private: bool = Field(default=False, description="Whether the agent lives in the password-gated private square instead of the public one")
builtin: bool = Field(default=False, description="True when user_id is null") builtin: bool = Field(default=False, description="True when user_id is null")
@@ -62,15 +63,15 @@ class AgentResponse(BaseModel):
is_favorite: bool = Field( is_favorite: bool = Field(
default=False, default=False,
description="Whether the requesting user has favorited (收藏) this agent.", description="Whether the requesting user has favorited (收藏) this agent.",
) )
is_pinned: bool = Field( is_pinned: bool = Field(
default=False, default=False,
description="Whether the requesting user has pinned this agent.", description="Whether the requesting user has pinned this agent.",
) )
class AgentsListResponse(BaseModel): class AgentsListResponse(BaseModel):
"""Response model for listing agents. """Response model for listing agents.
``total`` / ``page`` / ``page_size`` describe server-side pagination. When ``total`` / ``page`` / ``page_size`` describe server-side pagination. When
@@ -115,7 +116,7 @@ class AgentUpdateRequest(BaseModel):
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)") tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)")
class AgentSelectorResponse(BaseModel): class AgentSelectorResponse(BaseModel):
"""Response for the homepage agent selector dropdown. """Response for the homepage agent selector dropdown.
The list is fully curated by admins (``featured_order`` on each row). The list is fully curated by admins (``featured_order`` on each row).
@@ -125,10 +126,10 @@ class AgentSelectorResponse(BaseModel):
agents: list[AgentResponse] = Field( agents: list[AgentResponse] = Field(
default_factory=list, default_factory=list,
description="Admin-curated agents, sorted by featured_order ascending.", description="Admin-curated agents, sorted by featured_order ascending.",
) )
class AgentFeaturedRequest(BaseModel): class AgentFeaturedRequest(BaseModel):
"""Request body for setting/clearing an agent's homepage featured rank.""" """Request body for setting/clearing an agent's homepage featured rank."""
order: int | None = Field( order: int | None = Field(
@@ -159,6 +160,41 @@ def _is_admin_user(request: Request) -> bool:
return getattr(user, "system_role", None) == "admin" return getattr(user, "system_role", None) == "admin"
def _email_local_part(email: str | None) -> str | None:
"""Return the username portion (before ``@``) of an email address."""
if not email:
return None
at = email.find("@")
return email[:at] if at > 0 else email
async def _resolve_usernames(user_ids: set[str]) -> dict[str, str]:
"""Batch-resolve owner user ids to friendly usernames (email local part).
One lookup per distinct id; tolerates missing accounts (deleted users) and
an uninitialised auth provider by simply omitting those ids from the map.
"""
user_ids = {uid for uid in user_ids if uid}
if not user_ids:
return {}
try:
from app.gateway.deps import get_local_provider
provider = get_local_provider()
except Exception:
return {}
names: dict[str, str] = {}
for uid in user_ids:
try:
user = await provider.get_user(uid)
except Exception:
user = None
local = _email_local_part(getattr(user, "email", None)) if user is not None else None
if local:
names[uid] = local
return names
def _validate_display_name(name: str) -> str: def _validate_display_name(name: str) -> str:
normalized = name.strip() normalized = name.strip()
if not normalized: if not normalized:
@@ -348,7 +384,7 @@ def _ensure_agent_dir_from_record(record: dict[str, Any]) -> None:
logger.exception("Failed to self-heal agent dir for '%s'", agent_id) logger.exception("Failed to self-heal agent dir for '%s'", agent_id)
def _record_to_response( def _record_to_response(
record: dict[str, Any], record: dict[str, Any],
*, *,
include_soul: bool = False, include_soul: bool = False,
@@ -390,21 +426,21 @@ def _record_to_response(
tool_groups=extras["tool_groups"], tool_groups=extras["tool_groups"],
skills=extras["skills"], skills=extras["skills"],
featured_order=record.get("featured_order"), featured_order=record.get("featured_order"),
) )
def _sort_agent_responses(responses: list[AgentResponse], sort: str) -> list[AgentResponse]: def _sort_agent_responses(responses: list[AgentResponse], sort: str) -> list[AgentResponse]:
reverse = sort not in {"time_asc", "updated_asc", "name_asc"} reverse = sort not in {"time_asc", "updated_asc", "name_asc"}
if sort in {"name_asc", "name_desc"}: if sort in {"name_asc", "name_desc"}:
ordered = sorted(responses, key=lambda r: (r.name or "").lower(), reverse=reverse) ordered = sorted(responses, key=lambda r: (r.name or "").lower(), reverse=reverse)
elif sort in {"updated_desc", "updated_asc"}: elif sort in {"updated_desc", "updated_asc"}:
ordered = sorted(responses, key=lambda r: str(r.updated_at or ""), reverse=reverse) ordered = sorted(responses, key=lambda r: str(r.updated_at or ""), reverse=reverse)
else: else:
ordered = sorted(responses, key=lambda r: str(r.created_at or ""), reverse=reverse) ordered = sorted(responses, key=lambda r: str(r.created_at or ""), reverse=reverse)
return sorted(ordered, key=lambda r: 0 if r.is_pinned else 1) return sorted(ordered, key=lambda r: 0 if r.is_pinned else 1)
async def _record_to_response_async( async def _record_to_response_async(
record: dict[str, Any], record: dict[str, Any],
*, *,
include_soul: bool = False, include_soul: bool = False,
@@ -478,20 +514,23 @@ async def _build_list_responses(
_record_to_response(a, include_soul=False, include_extras=False) for a in records _record_to_response(a, include_soul=False, include_extras=False) for a in records
] ]
ids = [r.id for r in responses] ids = [r.id for r in responses]
extras = await store.get_extras_for(ids) extras = await store.get_extras_for(ids)
assignments = await tag_store.list_assignments("agent", ids) assignments = await tag_store.list_assignments("agent", ids)
favorite_ids = await store.list_favorite_ids(user_id) favorite_ids = await store.list_favorite_ids(user_id)
pin_ids = await store.list_pin_ids(user_id) pin_ids = await store.list_pin_ids(user_id)
for resp in responses: usernames = await _resolve_usernames({r.user_id for r in responses if r.user_id})
for resp in responses:
cached = extras.get(resp.id) cached = extras.get(resp.id)
if cached is not None: if cached is not None:
resp.skills = cached.get("skills") resp.skills = cached.get("skills")
resp.tool_groups = cached.get("tool_groups") resp.tool_groups = cached.get("tool_groups")
resp.model = cached.get("model") resp.model = cached.get("model")
resp.tags = assignments.get(resp.id, []) resp.tags = assignments.get(resp.id, [])
resp.is_favorite = resp.id in favorite_ids resp.is_favorite = resp.id in favorite_ids
resp.is_pinned = resp.id in pin_ids resp.is_pinned = resp.id in pin_ids
return responses if resp.user_id:
resp.owner_username = usernames.get(resp.user_id)
return responses
@router.get( @router.get(
@@ -509,14 +548,14 @@ async def list_agents(
pattern="^(mine|square)$", pattern="^(mine|square)$",
description="Pagination scope. 'mine' = owned + favorited; 'square' = built-in/published public. Omit for the default combined visibility list.", description="Pagination scope. 'mine' = owned + favorited; 'square' = built-in/published public. Omit for the default combined visibility list.",
), ),
page: int | None = Query(default=None, ge=1, description="1-based page index. Providing this (or 'scope') enables server-side pagination."), page: int | None = Query(default=None, ge=1, description="1-based page index. Providing this (or 'scope') enables server-side pagination."),
page_size: int = Query(default=20, ge=1, le=200, description="Items per page when paginating."), page_size: int = Query(default=20, ge=1, le=200, description="Items per page when paginating."),
sort: str = Query( sort: str = Query(
default="time_desc", default="time_desc",
pattern="^(time_desc|time_asc|updated_desc|updated_asc|name_asc|name_desc)$", pattern="^(time_desc|time_asc|updated_desc|updated_asc|name_asc|name_desc)$",
description="Sort order. Default is newest created first.", description="Sort order. Default is newest created first.",
), ),
admin: bool = Query( admin: bool = Query(
default=False, default=False,
description=( description=(
"Admin-only override. When ``true`` and the caller is a system admin, " "Admin-only override. When ``true`` and the caller is a system admin, "
@@ -543,11 +582,11 @@ async def list_agents(
scope=scope, scope=scope,
search=search, search=search,
tag_ids=tag_ids, tag_ids=tag_ids,
page=eff_page, page=eff_page,
page_size=page_size, page_size=page_size,
admin=admin, admin=admin,
sort=sort, sort=sort,
) )
responses = await _build_list_responses(store, tag_store, user_id, rows) responses = await _build_list_responses(store, tag_store, user_id, rows)
return AgentsListResponse(agents=responses, total=total, page=eff_page, page_size=page_size) return AgentsListResponse(agents=responses, total=total, page=eff_page, page_size=page_size)
@@ -559,11 +598,11 @@ async def list_agents(
if search: if search:
needle = search.strip().lower() needle = search.strip().lower()
responses = [r for r in responses if needle in r.name.lower()] responses = [r for r in responses if needle in r.name.lower()]
if tag_ids: if tag_ids:
wanted = set(tag_ids) wanted = set(tag_ids)
responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)] responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)]
responses = _sort_agent_responses(responses, sort) responses = _sort_agent_responses(responses, sort)
return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses)) return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses))
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -576,14 +615,14 @@ async def list_agents(
summary="Check Agent Name", summary="Check Agent Name",
description="Validate an agent display name. Duplicate names are allowed.", description="Validate an agent display name. Duplicate names are allowed.",
) )
async def check_agent_name(name: str) -> dict[str, Any]: async def check_agent_name(name: str) -> dict[str, Any]:
_require_agents_api_enabled() _require_agents_api_enabled()
normalized = _validate_display_name(name) normalized = _validate_display_name(name)
return {"available": True, "name": normalized} return {"available": True, "name": normalized}
@router.get( @router.get(
"/agents/selector", "/agents/selector",
response_model=AgentSelectorResponse, response_model=AgentSelectorResponse,
summary="Agent Homepage Selector", summary="Agent Homepage Selector",
description=( description=(
@@ -635,12 +674,12 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse:
# Detail view legitimately reads SOUL.md + config.yaml — hop to a # Detail view legitimately reads SOUL.md + config.yaml — hop to a
# worker thread so the asyncio loop stays responsive under concurrent # worker thread so the asyncio loop stays responsive under concurrent
# detail fetches. # detail fetches.
response = await _record_to_response_async(record, include_soul=True, include_extras=True) response = await _record_to_response_async(record, include_soul=True, include_extras=True)
favorite_ids = await store.list_favorite_ids(user_id) favorite_ids = await store.list_favorite_ids(user_id)
pin_ids = await store.list_pin_ids(user_id) pin_ids = await store.list_pin_ids(user_id)
response.is_favorite = agent_id in favorite_ids response.is_favorite = agent_id in favorite_ids
response.is_pinned = agent_id in pin_ids response.is_pinned = agent_id in pin_ids
return response return response
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -874,16 +913,16 @@ async def delete_agent(agent_id: str, request: Request) -> None:
await get_tag_store(request).unassign_all("agent", agent_id) await get_tag_store(request).unassign_all("agent", agent_id)
except Exception: # noqa: BLE001 — tag cleanup must not block deletion except Exception: # noqa: BLE001 — tag cleanup must not block deletion
pass pass
try: try:
await store.delete_favorites_for_agent(agent_id) await store.delete_favorites_for_agent(agent_id)
except Exception: # noqa: BLE001 — favorite cleanup must not block deletion except Exception: # noqa: BLE001 — favorite cleanup must not block deletion
pass pass
try: try:
await store.delete_pins_for_agent(agent_id) await store.delete_pins_for_agent(agent_id)
except Exception: # noqa: BLE001 - pin cleanup must not block deletion except Exception: # noqa: BLE001 - pin cleanup must not block deletion
pass pass
try: try:
await store.delete_extras_for_agent(agent_id) await store.delete_extras_for_agent(agent_id)
except Exception: # noqa: BLE001 — cache cleanup must not block deletion except Exception: # noqa: BLE001 — cache cleanup must not block deletion
pass pass
logger.info("Deleted agent '%s' from %s", agent_id, agent_dir) logger.info("Deleted agent '%s' from %s", agent_id, agent_dir)
@@ -925,7 +964,7 @@ async def favorite_agent(agent_id: str, request: Request) -> None:
summary="Unfavorite Agent", summary="Unfavorite Agent",
description="Remove an agent from the current user's favorites. No-op when not favorited.", description="Remove an agent from the current user's favorites. No-op when not favorited.",
) )
async def unfavorite_agent(agent_id: str, request: Request) -> None: async def unfavorite_agent(agent_id: str, request: Request) -> None:
_require_agents_api_enabled() _require_agents_api_enabled()
agent_id = _validate_storage_id(agent_id) agent_id = _validate_storage_id(agent_id)
@@ -936,60 +975,60 @@ async def unfavorite_agent(agent_id: str, request: Request) -> None:
logger.info("User '%s' unfavorited agent '%s'", user_id, agent_id) logger.info("User '%s' unfavorited agent '%s'", user_id, agent_id)
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error("Failed to unfavorite agent '%s': %s", agent_id, e, exc_info=True) logger.error("Failed to unfavorite agent '%s': %s", agent_id, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to unfavorite agent: {str(e)}") from e raise HTTPException(status_code=500, detail=f"Failed to unfavorite agent: {str(e)}") from e
@router.post( @router.post(
"/agents/{agent_id}/pin", "/agents/{agent_id}/pin",
status_code=204, status_code=204,
summary="Pin Agent", summary="Pin Agent",
description="Pin a visible agent to the top of the current user's lists. Idempotent.", description="Pin a visible agent to the top of the current user's lists. Idempotent.",
) )
async def pin_agent(agent_id: str, request: Request) -> None: async def pin_agent(agent_id: str, request: Request) -> None:
_require_agents_api_enabled() _require_agents_api_enabled()
agent_id = _validate_storage_id(agent_id) agent_id = _validate_storage_id(agent_id)
try: try:
store = get_agent_store(request) store = get_agent_store(request)
user_id = _current_user_id(request) user_id = _current_user_id(request)
record = await store.get_visible(agent_id, user_id) record = await store.get_visible(agent_id, user_id)
if record is None: if record is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
await store.add_pin(user_id, agent_id) await store.add_pin(user_id, agent_id)
logger.info("User '%s' pinned agent '%s'", user_id, agent_id) logger.info("User '%s' pinned agent '%s'", user_id, agent_id)
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error("Failed to pin agent '%s': %s", agent_id, e, exc_info=True) logger.error("Failed to pin agent '%s': %s", agent_id, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to pin agent: {str(e)}") from e raise HTTPException(status_code=500, detail=f"Failed to pin agent: {str(e)}") from e
@router.delete( @router.delete(
"/agents/{agent_id}/pin", "/agents/{agent_id}/pin",
status_code=204, status_code=204,
summary="Unpin Agent", summary="Unpin Agent",
description="Remove the current user's pin from an agent. No-op when not pinned.", description="Remove the current user's pin from an agent. No-op when not pinned.",
) )
async def unpin_agent(agent_id: str, request: Request) -> None: async def unpin_agent(agent_id: str, request: Request) -> None:
_require_agents_api_enabled() _require_agents_api_enabled()
agent_id = _validate_storage_id(agent_id) agent_id = _validate_storage_id(agent_id)
try: try:
store = get_agent_store(request) store = get_agent_store(request)
user_id = _current_user_id(request) user_id = _current_user_id(request)
await store.remove_pin(user_id, agent_id) await store.remove_pin(user_id, agent_id)
logger.info("User '%s' unpinned agent '%s'", user_id, agent_id) logger.info("User '%s' unpinned agent '%s'", user_id, agent_id)
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error("Failed to unpin agent '%s': %s", agent_id, e, exc_info=True) logger.error("Failed to unpin agent '%s': %s", agent_id, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to unpin agent: {str(e)}") from e raise HTTPException(status_code=500, detail=f"Failed to unpin agent: {str(e)}") from e
@router.put( @router.put(
"/agents/{agent_id}/featured", "/agents/{agent_id}/featured",
response_model=AgentResponse, response_model=AgentResponse,
summary="Set Agent Featured Rank (admin)", summary="Set Agent Featured Rank (admin)",
description=( description=(
@@ -1031,19 +1070,19 @@ async def set_agent_featured(
raise HTTPException(status_code=500, detail=f"Failed to set featured: {str(e)}") from e raise HTTPException(status_code=500, detail=f"Failed to set featured: {str(e)}") from e
class PrivateSquareListRequest(BaseModel): class PrivateSquareListRequest(BaseModel):
"""Request body for listing all private-square agents.""" """Request body for listing all private-square agents."""
password: str = Field(..., description="Plain-text private-square password. Verified against the admin-configured hash before the listing is returned.") password: str = Field(..., description="Plain-text private-square password. Verified against the admin-configured hash before the listing is returned.")
search: str | None = Field(default=None, description="Case-insensitive fuzzy match on agent name (raw or desensitized).") search: str | None = Field(default=None, description="Case-insensitive fuzzy match on agent name (raw or desensitized).")
tag_ids: list[str] | None = Field(default=None, description="Keep agents carrying any of these tags (OR).") tag_ids: list[str] | None = Field(default=None, description="Keep agents carrying any of these tags (OR).")
page: int | None = Field(default=None, ge=1, description="1-based page index. Providing this enables server-side pagination.") page: int | None = Field(default=None, ge=1, description="1-based page index. Providing this enables server-side pagination.")
page_size: int = Field(default=20, ge=1, le=200, description="Items per page when paginating.") page_size: int = Field(default=20, ge=1, le=200, description="Items per page when paginating.")
sort: str = Field( sort: str = Field(
default="time_desc", default="time_desc",
pattern="^(time_desc|time_asc|updated_desc|updated_asc|name_asc|name_desc)$", pattern="^(time_desc|time_asc|updated_desc|updated_asc|name_asc|name_desc)$",
description="Sort order. Default is newest created first.", description="Sort order. Default is newest created first.",
) )
class AgentPrivateUpdateRequest(BaseModel): class AgentPrivateUpdateRequest(BaseModel):
@@ -1086,17 +1125,17 @@ async def list_private_square(body: PrivateSquareListRequest, request: Request)
scope="private", scope="private",
search=body.search, search=body.search,
tag_ids=body.tag_ids, tag_ids=body.tag_ids,
page=body.page, page=body.page,
page_size=body.page_size, page_size=body.page_size,
sort=body.sort, sort=body.sort,
) )
responses = await _build_list_responses(store, tag_store, user_id, rows) responses = await _build_list_responses(store, tag_store, user_id, rows)
return AgentsListResponse(agents=responses, total=total, page=body.page, page_size=body.page_size) return AgentsListResponse(agents=responses, total=total, page=body.page, page_size=body.page_size)
records = await store.list_private() records = await store.list_private()
responses = await _build_list_responses(store, tag_store, user_id, records) responses = await _build_list_responses(store, tag_store, user_id, records)
responses = _sort_agent_responses(responses, body.sort) responses = _sort_agent_responses(responses, body.sort)
return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses)) return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses))
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -0,0 +1,97 @@
"""Public (unauthenticated) read-only user leaderboard.
Mounted under the ``/api/public/`` prefix so the auth middleware lets this route
through without a session cookie. Powers the public "publish" page for the user
leaderboard — a view-only snapshot with no admin controls and no mutations.
The snapshot honors the same system-wide leaderboard settings (excluded users,
scheduled/admin/failed inclusion) as the authenticated admin endpoint, so any
accounts an admin chose to hide stay hidden on the public page as well.
"""
from __future__ import annotations
import hashlib
import logging
from fastapi import APIRouter, HTTPException, Query
from app.gateway.routers.admin_users import (
AdminLeaderboardResponse,
_load_or_schedule_daily_leaderboard,
_resolve_range,
)
from deerflow.config.system_settings import load_system_settings
from deerflow.persistence.engine import get_session_factory
logger = logging.getLogger(__name__)
# Whitelisted under ``auth_middleware._PUBLIC_PATH_PREFIXES`` (``/api/public/``).
router = APIRouter(prefix="/api/public/leaderboard", tags=["public-leaderboard"])
def _mask_email(email: str | None) -> str:
"""Reduce an email to its local part so the public page can show a
recognizable handle without leaking the full address (domain).
The leaderboard only makes sense if it names its top users, so the local
part is kept intentionally; the privacy gain is dropping the domain and the
raw account id from the unauthenticated payload.
"""
if not email:
return ""
return email.split("@", 1)[0].strip() or ""
def _anonymize(resp: AdminLeaderboardResponse) -> AdminLeaderboardResponse:
"""Strip PII from a leaderboard snapshot before it leaves over the public,
unauthenticated endpoint: email → local part, account/thread ids → opaque
short hashes (kept stable so they still work as React keys)."""
def hashed(value: str | None) -> str:
if not value:
return ""
return hashlib.sha1(value.encode("utf-8")).hexdigest()[:12]
seen_users: set[int] = set()
for bucket in (resp.users_by_activity, resp.users_by_questions, resp.users_by_tokens):
for user in bucket:
if id(user) in seen_users:
continue
seen_users.add(id(user))
user.email = _mask_email(user.email)
user.user_id = hashed(user.user_id)
for bucket in (resp.recent_questions, resp.frequent_questions):
for question in bucket:
question.email = _mask_email(question.email)
question.user_id = hashed(question.user_id)
question.thread_id = ""
question.run_id = ""
return resp
@router.get("", response_model=AdminLeaderboardResponse)
@router.get("/", response_model=AdminLeaderboardResponse)
async def get_public_leaderboard(
days: int = Query(default=30, ge=1, le=365),
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"),
limit: int = Query(default=20, ge=5, le=100),
) -> AdminLeaderboardResponse:
"""Return a read-only leaderboard snapshot for the public publish page."""
session_factory = get_session_factory()
if session_factory is None:
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
settings = load_system_settings().leaderboard
since_dt, until_dt, _range_days = _resolve_range(days, since, until)
resp = await _load_or_schedule_daily_leaderboard(
session_factory,
settings,
since_dt,
until_dt,
limit=limit,
)
return _anonymize(resp)
@@ -56,6 +56,44 @@ def _is_admin_user(request: Request) -> bool:
return getattr(user, "system_role", None) == "admin" return getattr(user, "system_role", None) == "admin"
def _email_local_part(email: str | None) -> str | None:
"""Return the username portion (before ``@``) of an email address."""
if not email:
return None
at = email.find("@")
return email[:at] if at > 0 else email
async def _attach_owner_usernames(responses: list["SkillResponse"]) -> list["SkillResponse"]:
"""Resolve each custom skill's ``owner_user_id`` to a friendly username.
One lookup per distinct owner id; tolerates deleted accounts and an
uninitialised auth provider by leaving ``owner_username`` as None.
"""
owner_ids = {r.owner_user_id for r in responses if r.owner_user_id}
if not owner_ids:
return responses
try:
from app.gateway.deps import get_local_provider
provider = get_local_provider()
except Exception:
return responses
names: dict[str, str] = {}
for uid in owner_ids:
try:
user = await provider.get_user(uid)
except Exception:
user = None
local = _email_local_part(getattr(user, "email", None)) if user is not None else None
if local:
names[uid] = local
for resp in responses:
if resp.owner_user_id:
resp.owner_username = names.get(resp.owner_user_id)
return responses
def _can_view_skill_source( def _can_view_skill_source(
category: SkillCategory, category: SkillCategory,
owner_user_id: str | None, owner_user_id: str | None,
@@ -125,6 +163,7 @@ class SkillResponse(BaseModel):
featured: bool = Field(default=False, description="Whether this skill is pinned to the top of the list (sort only)") featured: bool = Field(default=False, description="Whether this skill is pinned to the top of the list (sort only)")
source: str = Field(default="upload", description="Origin: 'agent' for agent-created, 'upload' for user-uploaded") source: str = Field(default="upload", description="Origin: 'agent' for agent-created, 'upload' for user-uploaded")
owner_user_id: str | None = Field(default=None, description="Custom skill owner (null = built-in or legacy)") owner_user_id: str | None = Field(default=None, description="Custom skill owner (null = built-in or legacy)")
owner_username: str | None = Field(default=None, description="Publisher username (email local part). Null for built-in skills.")
published: bool = Field(default=False, description="Whether the owner has made this skill visible to other users") published: bool = Field(default=False, description="Whether the owner has made this skill visible to other users")
builtin: bool = Field(default=False, description="True when owner_user_id is null (no human owner)") builtin: bool = Field(default=False, description="True when owner_user_id is null (no human owner)")
detail: str | None = Field(default=None, description="Human-authored long-form detail text (Markdown), editable by owner/admin") detail: str | None = Field(default=None, description="Human-authored long-form detail text (Markdown), editable by owner/admin")
@@ -786,6 +825,7 @@ async def list_skills(
) )
) )
responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids) responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids)
responses = await _attach_owner_usernames(responses)
responses = _sort_skill_responses(responses, sort) responses = _sort_skill_responses(responses, sort)
return SkillsListResponse(skills=responses) return SkillsListResponse(skills=responses)
except HTTPException: except HTTPException:
@@ -1176,6 +1216,7 @@ async def list_custom_skills(
display_sample=adapter.get("sample") if adapter else None, display_sample=adapter.get("sample") if adapter else None,
) )
) )
responses = await _attach_owner_usernames(responses)
return SkillsListResponse(skills=responses) return SkillsListResponse(skills=responses)
except Exception as e: except Exception as e:
logger.error("Failed to list custom skills: %s", e, exc_info=True) logger.error("Failed to list custom skills: %s", e, exc_info=True)
@@ -1,39 +1,39 @@
"""Async checkpointer factory. """Async checkpointer factory.
Provides an **async context manager** for long-running async servers that need Provides an **async context manager** for long-running async servers that need
proper resource cleanup. proper resource cleanup.
Supported backends: memory, sqlite, postgres. Supported backends: memory, sqlite, postgres.
Usage (e.g. FastAPI lifespan):: Usage (e.g. FastAPI lifespan)::
from deerflow.runtime.checkpointer.async_provider import make_checkpointer from deerflow.runtime.checkpointer.async_provider import make_checkpointer
async with make_checkpointer() as checkpointer: async with make_checkpointer() as checkpointer:
app.state.checkpointer = checkpointer # InMemorySaver if not configured app.state.checkpointer = checkpointer # InMemorySaver if not configured
For sync usage see :mod:`deerflow.runtime.checkpointer.provider`. For sync usage see :mod:`deerflow.runtime.checkpointer.provider`.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import contextlib import contextlib
import logging import logging
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
import os import os
from pathlib import Path from pathlib import Path
from langgraph.types import Checkpointer from langgraph.types import Checkpointer
from deerflow.config.app_config import AppConfig, get_app_config from deerflow.config.app_config import AppConfig, get_app_config
from deerflow.runtime.checkpointer.provider import ( from deerflow.runtime.checkpointer.provider import (
POSTGRES_CONN_REQUIRED, POSTGRES_CONN_REQUIRED,
POSTGRES_INSTALL, POSTGRES_INSTALL,
SQLITE_INSTALL, SQLITE_INSTALL,
) )
from deerflow.runtime.store._sqlite_utils import ensure_sqlite_parent_dir, resolve_sqlite_conn_str from deerflow.runtime.store._sqlite_utils import ensure_sqlite_parent_dir, resolve_sqlite_conn_str
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -79,6 +79,52 @@ def _sqlite_conn_str_for_postgres_fallback(config) -> str:
return paths[0] if paths else "store.db" return paths[0] if paths else "store.db"
def _pool_size_env(name: str, default: int) -> int:
raw = os.getenv(name, "").strip()
if not raw:
return default
try:
return max(0, int(raw))
except ValueError:
logger.warning("Invalid %s=%r; using default %d", name, raw, default)
return default
@contextlib.asynccontextmanager
async def _postgres_saver_from_pool(conn_string: str) -> AsyncIterator[Checkpointer]:
"""Yield an ``AsyncPostgresSaver`` backed by a self-healing connection pool.
``AsyncPostgresSaver.from_conn_string`` opens a *single* long-lived
connection. If that one connection dies (Postgres restart, idle timeout,
network blip, or a prior broken pipeline), every later checkpointer call
fails permanently with "the connection is closed" until the process is
restarted. A pool with ``check=check_connection`` validates each connection
on checkout and transparently replaces dead ones, so the runtime recovers
on its own.
"""
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from psycopg.rows import dict_row
from psycopg_pool import AsyncConnectionPool
pool = AsyncConnectionPool(
conninfo=conn_string,
min_size=_pool_size_env("DEER_FLOW_PG_POOL_MIN", 1),
max_size=_pool_size_env("DEER_FLOW_PG_POOL_MAX", 10),
max_idle=300.0,
max_lifetime=3600.0,
check=AsyncConnectionPool.check_connection,
kwargs={"autocommit": True, "prepare_threshold": 0, "row_factory": dict_row},
open=False,
)
await pool.open(wait=True)
try:
saver = AsyncPostgresSaver(pool)
await saver.setup()
yield saver
finally:
await pool.close()
@contextlib.asynccontextmanager @contextlib.asynccontextmanager
async def _sqlite_checkpointer_from_conn_str(raw: str) -> AsyncIterator[Checkpointer]: async def _sqlite_checkpointer_from_conn_str(raw: str) -> AsyncIterator[Checkpointer]:
try: try:
@@ -96,17 +142,17 @@ async def _sqlite_checkpointer_from_conn_str(raw: str) -> AsyncIterator[Checkpoi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Async factory # Async factory
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@contextlib.asynccontextmanager @contextlib.asynccontextmanager
async def _async_checkpointer(config) -> AsyncIterator[Checkpointer]: async def _async_checkpointer(config) -> AsyncIterator[Checkpointer]:
"""Async context manager that constructs and tears down a checkpointer.""" """Async context manager that constructs and tears down a checkpointer."""
if config.type == "memory": if config.type == "memory":
from langgraph.checkpoint.memory import InMemorySaver from langgraph.checkpoint.memory import InMemorySaver
yield InMemorySaver() yield InMemorySaver()
return return
if config.type == "sqlite": if config.type == "sqlite":
async with _sqlite_checkpointer_from_conn_str(config.connection_string or "store.db") as saver: async with _sqlite_checkpointer_from_conn_str(config.connection_string or "store.db") as saver:
yield saver yield saver
@@ -121,15 +167,14 @@ async def _async_checkpointer(config) -> AsyncIterator[Checkpointer]:
return return
try: try:
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver # noqa: F401
except ImportError as exc: except ImportError as exc:
raise ImportError(POSTGRES_INSTALL) from exc raise ImportError(POSTGRES_INSTALL) from exc
if not config.connection_string: if not config.connection_string:
raise ValueError(POSTGRES_CONN_REQUIRED) raise ValueError(POSTGRES_CONN_REQUIRED)
async with AsyncPostgresSaver.from_conn_string(config.connection_string) as saver: async with _postgres_saver_from_pool(config.connection_string) as saver:
await saver.setup()
if not _dual_read_enabled(config): if not _dual_read_enabled(config):
yield saver yield saver
return return
@@ -172,87 +217,86 @@ async def _async_checkpointer(config) -> AsyncIterator[Checkpointer]:
finally: finally:
await proxy.aclose_sources() await proxy.aclose_sources()
return return
raise ValueError(f"Unknown checkpointer type: {config.type!r}") raise ValueError(f"Unknown checkpointer type: {config.type!r}")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Public async context manager # Public async context manager
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@contextlib.asynccontextmanager @contextlib.asynccontextmanager
async def _async_checkpointer_from_database(db_config) -> AsyncIterator[Checkpointer]: async def _async_checkpointer_from_database(db_config) -> AsyncIterator[Checkpointer]:
"""Async context manager that constructs a checkpointer from unified DatabaseConfig.""" """Async context manager that constructs a checkpointer from unified DatabaseConfig."""
if db_config.backend == "memory": if db_config.backend == "memory":
from langgraph.checkpoint.memory import InMemorySaver from langgraph.checkpoint.memory import InMemorySaver
yield InMemorySaver() yield InMemorySaver()
return return
if db_config.backend == "sqlite": if db_config.backend == "sqlite":
try: try:
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
except ImportError as exc: except ImportError as exc:
raise ImportError(SQLITE_INSTALL) from exc raise ImportError(SQLITE_INSTALL) from exc
conn_str = db_config.checkpointer_sqlite_path conn_str = db_config.checkpointer_sqlite_path
ensure_sqlite_parent_dir(conn_str) ensure_sqlite_parent_dir(conn_str)
async with AsyncSqliteSaver.from_conn_string(conn_str) as saver: async with AsyncSqliteSaver.from_conn_string(conn_str) as saver:
await saver.setup() await saver.setup()
yield saver yield saver
return return
if db_config.backend == "postgres": if db_config.backend == "postgres":
try: try:
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver # noqa: F401
except ImportError as exc: except ImportError as exc:
raise ImportError(POSTGRES_INSTALL) from exc raise ImportError(POSTGRES_INSTALL) from exc
if not db_config.postgres_url: if not db_config.postgres_url:
raise ValueError("database.postgres_url is required for the postgres backend") raise ValueError("database.postgres_url is required for the postgres backend")
async with AsyncPostgresSaver.from_conn_string(db_config.postgres_url) as saver: async with _postgres_saver_from_pool(db_config.postgres_url) as saver:
await saver.setup() yield saver
yield saver return
return
raise ValueError(f"Unknown database backend: {db_config.backend!r}")
raise ValueError(f"Unknown database backend: {db_config.backend!r}")
@contextlib.asynccontextmanager
@contextlib.asynccontextmanager async def make_checkpointer(app_config: AppConfig | None = None) -> AsyncIterator[Checkpointer]:
async def make_checkpointer(app_config: AppConfig | None = None) -> AsyncIterator[Checkpointer]: """Async context manager that yields a checkpointer for the caller's lifetime.
"""Async context manager that yields a checkpointer for the caller's lifetime. Resources are opened on enter and closed on exit -- no global state::
Resources are opened on enter and closed on exit -- no global state::
async with make_checkpointer(app_config) as checkpointer:
async with make_checkpointer(app_config) as checkpointer: app.state.checkpointer = checkpointer
app.state.checkpointer = checkpointer
Yields an ``InMemorySaver`` when no checkpointer is configured in *config.yaml*.
Yields an ``InMemorySaver`` when no checkpointer is configured in *config.yaml*.
Priority:
Priority: 1. Legacy ``checkpointer:`` config section (backward compatible)
1. Legacy ``checkpointer:`` config section (backward compatible) 2. Unified ``database:`` config section
2. Unified ``database:`` config section 3. Default InMemorySaver
3. Default InMemorySaver """
"""
if app_config is None:
if app_config is None: app_config = get_app_config()
app_config = get_app_config()
# Legacy: standalone checkpointer config takes precedence
# Legacy: standalone checkpointer config takes precedence if app_config.checkpointer is not None:
if app_config.checkpointer is not None: async with _async_checkpointer(app_config.checkpointer) as saver:
async with _async_checkpointer(app_config.checkpointer) as saver: yield saver
yield saver return
return
# Unified database config
# Unified database config db_config = getattr(app_config, "database", None)
db_config = getattr(app_config, "database", None) if db_config is not None and db_config.backend != "memory":
if db_config is not None and db_config.backend != "memory": async with _async_checkpointer_from_database(db_config) as saver:
async with _async_checkpointer_from_database(db_config) as saver: yield saver
yield saver return
return
# Default: in-memory
# Default: in-memory from langgraph.checkpoint.memory import InMemorySaver
from langgraph.checkpoint.memory import InMemorySaver
yield InMemorySaver()
yield InMemorySaver()
+1 -1
View File
@@ -200,7 +200,7 @@ checkpointer:
type: postgres type: postgres
# true: 新 checkpoint 写入 Postgres;false: 保留 PG 配置但继续使用 SQLite。 # true: 新 checkpoint 写入 Postgres;false: 保留 PG 配置但继续使用 SQLite。
# 也可临时用环境变量 DEER_FLOW_USE_PG_CHECKPOINTER=0 覆盖。 # 也可临时用环境变量 DEER_FLOW_USE_PG_CHECKPOINTER=0 覆盖。
use_postgres: true use_postgres: false
connection_string: postgresql://postgres:postgres123@47.88.25.99:8432/deerflow connection_string: postgresql://postgres:postgres123@47.88.25.99:8432/deerflow
sqlite_connection_string: backend/.deer-flow/data/deerflow.db sqlite_connection_string: backend/.deer-flow/data/deerflow.db
dual_read_enabled: true dual_read_enabled: true
+246
View File
@@ -0,0 +1,246 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
axios:
specifier: ^1.16.1
version: 1.17.0
packages:
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
axios@1.17.0:
resolution: {integrity: sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
es-errors@1.3.0:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-object-atoms@1.1.2:
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.1.0:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
follow-redirects@1.16.0:
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
has-tostringtag@1.0.2:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hasown@2.0.4:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
https-proxy-agent@5.0.1:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
snapshots:
agent-base@6.0.2:
dependencies:
debug: 4.4.3
transitivePeerDependencies:
- supports-color
asynckit@0.4.0: {}
axios@1.17.0:
dependencies:
follow-redirects: 1.16.0
form-data: 4.0.5
https-proxy-agent: 5.0.1
proxy-from-env: 2.1.0
transitivePeerDependencies:
- debug
- supports-color
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
debug@4.4.3:
dependencies:
ms: 2.1.3
delayed-stream@1.0.0: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
es-object-atoms@1.1.2:
dependencies:
es-errors: 1.3.0
es-set-tostringtag@2.1.0:
dependencies:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.4
follow-redirects@1.16.0: {}
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.4
mime-types: 2.1.35
function-bind@1.1.2: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.1.2
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.4
math-intrinsics: 1.1.0
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.2
gopd@1.2.0: {}
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
hasown@2.0.4:
dependencies:
function-bind: 1.1.2
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
debug: 4.4.3
transitivePeerDependencies:
- supports-color
math-intrinsics@1.1.0: {}
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
ms@2.1.3: {}
proxy-from-env@2.1.0: {}