enhance admin leaderboard and previews

This commit is contained in:
Administrator committed 2026-06-05 01:51:48 +08:00
1 parent a5897caec4
commit 92f5cb8fe3
11 files changed
+937 -282

No files matched your search

@@ -0,0 +1,45 @@
import { useEffect, useState } from "react";
import { injectInPageAnchorHandler } from "@/core/utils/markdown";
/**
* Sandboxed preview of a generated HTML document.
*
* Renders via a `blob:` URL rather than `srcDoc`. A srcDoc document's base URL
* is `about:srcdoc`, so clicking an in-page anchor (`<a href="#section">`)
* navigates the frame to `about:blank#section` and BLANKS it. A `blob:` URL is
* a real document URL, so fragment links scroll within the frame as expected —
* all without enabling scripts (the sandbox stays script-free for security; the
* server already sanitizes script vectors and this is defense-in-depth).
*
* The `charset=utf-8` on the blob type is required: a blob: URL whose
* Content-Type omits the charset is decoded with the OS default (GBK/Latin-1 on
* Chinese Windows), garbling CJK text when the HTML lacks a `<meta charset>`.
*/
export function HtmlPreviewFrame({
html,
className = "h-[70vh] w-full rounded-md border bg-white",
}: {
html: string;
className?: string;
}) {
const [url, setUrl] = useState<string>();
useEffect(() => {
const blob = new Blob([injectInPageAnchorHandler(html ?? "")], {
type: "text/html;charset=utf-8",
});
const objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}, [html]);
return (
<iframe
title="HTML 预览"
sandbox="allow-scripts allow-forms"
src={url}
className={className}
/>
);
}
@@ -1,7 +1,7 @@
"use client";
import {
ArrowDownIcon,
import {
ArrowDownIcon,
Code2Icon,
CopyIcon,
DownloadIcon,
@@ -12,11 +12,11 @@ import {
SaveIcon,
SquareArrowOutUpRightIcon,
XIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { toast } from "sonner";
import { Streamdown } from "streamdown";
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { toast } from "sonner";
import { Streamdown } from "streamdown";
import {
Artifact,
@@ -48,10 +48,15 @@ import { urlOfArtifact } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown";
import { checkCodeFile, getFileName } from "@/core/utils/files";
import { createMarkdownAnchorResolver } from "@/core/utils/markdown";
import { env } from "@/env";
import { streamdownPlugins } from "@/core/streamdown";
import { checkCodeFile, getFileName } from "@/core/utils/files";
import {
createMarkdownAnchorResolver,
injectInPageAnchorHandler,
isInPageAnchor,
scrollToInPageAnchor,
} from "@/core/utils/markdown";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
@@ -61,15 +66,15 @@ import { Tooltip } from "../tooltip";
import { useArtifacts } from "./context";
const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll";
function extractTextFromChildren(children: ReactNode): string {
if (typeof children === "string" || typeof children === "number") return String(children);
if (Array.isArray(children)) return children.map(extractTextFromChildren).join("");
return "";
}
function findScrollableArtifactElement(root: HTMLElement | null) {
const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll";
function extractTextFromChildren(children: ReactNode): string {
if (typeof children === "string" || typeof children === "number") return String(children);
if (Array.isArray(children)) return children.map(extractTextFromChildren).join("");
return "";
}
function findScrollableArtifactElement(root: HTMLElement | null) {
if (!root) {
return null;
}
@@ -714,54 +719,68 @@ export function ArtifactFilePreview({
language: string;
animated?: boolean;
activeCursor?: boolean;
}) {
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
const markdownComponents = useMemo(() => {
const anchorResolver = createMarkdownAnchorResolver(content ?? "");
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
const Tag = `h${level}` as const;
return ({ children, id, ...props }: HTMLAttributes<HTMLHeadingElement>) => (
<Tag id={id ?? anchorResolver.getHeadingId(level, extractTextFromChildren(children))} {...props}>
{children}
</Tag>
);
};
return {
h1: heading(1),
h2: heading(2),
h3: heading(3),
h4: heading(4),
h5: heading(5),
h6: heading(6),
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (typeof props.children === "string" && /^citation:(.+)$/.test(props.children)) {
return <ArtifactLink {...props} />;
}
const { className, ...rest } = props;
const resolvedHref = anchorResolver.resolveHref(
props.href,
extractTextFromChildren(props.children),
);
if (resolvedHref === null) {
return (
<span
className={cn(
"text-primary/80 decoration-dotted underline underline-offset-2 cursor-not-allowed",
className,
)}
title="未在当前 Markdown 中找到对应章节"
>
{props.children}
</span>
);
}
return <ArtifactLink {...rest} className={className} href={resolvedHref ?? props.href} />;
},
};
}, [content]);
useEffect(() => {
}) {
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
const markdownComponents = useMemo(() => {
const anchorResolver = createMarkdownAnchorResolver(content ?? "");
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
const Tag = `h${level}` as const;
return ({ children, id, ...props }: HTMLAttributes<HTMLHeadingElement>) => (
<Tag id={id ?? anchorResolver.getHeadingId(level, extractTextFromChildren(children))} {...props}>
{children}
</Tag>
);
};
return {
h1: heading(1),
h2: heading(2),
h3: heading(3),
h4: heading(4),
h5: heading(5),
h6: heading(6),
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (typeof props.children === "string" && /^citation:(.+)$/.test(props.children)) {
return <ArtifactLink {...props} />;
}
const { className, ...rest } = props;
const resolvedHref = anchorResolver.resolveHref(
props.href,
extractTextFromChildren(props.children),
);
if (resolvedHref === null) {
return (
<span
className={cn(
"text-primary/80 decoration-dotted underline underline-offset-2 cursor-not-allowed",
className,
)}
title="未在当前 Markdown 中找到对应章节"
>
{props.children}
</span>
);
}
const finalHref = resolvedHref ?? props.href;
return (
<ArtifactLink
{...rest}
className={className}
href={finalHref}
onClick={(event) => {
if (isInPageAnchor(finalHref)) {
event.preventDefault();
scrollToInPageAnchor(event.currentTarget, finalHref);
}
rest.onClick?.(event);
}}
/>
);
},
};
}, [content]);
useEffect(() => {
if (language !== "html") {
setHtmlPreviewUrl(undefined);
return;
@@ -770,7 +789,11 @@ export function ArtifactFilePreview({
// 必须带 charset=utf-8:blob: URL 的 Content-Type 不含 charset 时,iframe 会按
// 系统默认编码(中文 Windows 常为 GBK/Latin-1)解码,模型生成的 HTML 若没写
// <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。
const blob = new Blob([content ?? ""], { type: "text/html;charset=utf-8" });
// Intercept in-page anchor clicks so they scroll within the iframe instead
// of navigating the sandboxed blob: document (which blanks the frame).
const blob = new Blob([injectInPageAnchorHandler(content ?? "")], {
type: "text/html;charset=utf-8",
});
const url = URL.createObjectURL(blob);
setHtmlPreviewUrl(url);
@@ -783,16 +806,17 @@ export function ArtifactFilePreview({
return (
<div
data-artifact-preview-scroll
data-markdown-anchor-root
className={cn(
"size-full overflow-auto px-5 py-4",
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
)}
>
<Streamdown
className="size-full"
{...streamdownPlugins}
components={markdownComponents}
>
<Streamdown
className="size-full"
{...streamdownPlugins}
components={markdownComponents}
>
{content ?? ""}
</Streamdown>
{activeCursor && (
@@ -1,18 +1,22 @@
"use client";
"use client";
import { useMemo } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import {
MessageResponse,
type MessageResponseProps,
} from "@/components/ai-elements/message";
import {
MessageResponse,
type MessageResponseProps,
} from "@/components/ai-elements/message";
import { streamdownPlugins } from "@/core/streamdown";
import { createMarkdownAnchorResolver } from "@/core/utils/markdown";
import {
createMarkdownAnchorResolver,
isInPageAnchor,
scrollToInPageAnchor,
} from "@/core/utils/markdown";
import { cn } from "@/lib/utils";
import { CitationLink } from "../citations/citation-link";
import { CitationLink } from "../citations/citation-link";
function isExternalUrl(href: string | undefined): boolean {
return !!href && /^https?:\/\//.test(href);
}
@@ -22,23 +26,23 @@ function extractTextFromChildren(children: ReactNode): string {
if (Array.isArray(children)) return children.map(extractTextFromChildren).join("");
return "";
}
export type MarkdownContentProps = {
content: string;
isLoading: boolean;
rehypePlugins: MessageResponseProps["rehypePlugins"];
className?: string;
remarkPlugins?: MessageResponseProps["remarkPlugins"];
components?: MessageResponseProps["components"];
};
/** Renders markdown content. */
export function MarkdownContent({
content,
rehypePlugins,
className,
remarkPlugins = streamdownPlugins.remarkPlugins,
components: componentsFromProps,
export type MarkdownContentProps = {
content: string;
isLoading: boolean;
rehypePlugins: MessageResponseProps["rehypePlugins"];
className?: string;
remarkPlugins?: MessageResponseProps["remarkPlugins"];
components?: MessageResponseProps["components"];
};
/** Renders markdown content. */
export function MarkdownContent({
content,
rehypePlugins,
className,
remarkPlugins = streamdownPlugins.remarkPlugins,
components: componentsFromProps,
}: MarkdownContentProps) {
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
const components = useMemo(() => {
@@ -61,14 +65,14 @@ export function MarkdownContent({
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (props.href?.startsWith("#reference-")) {
return <CitationLink {...props} />;
}
if (typeof props.children === "string") {
const match = /^citation:(.+)$/.exec(props.children);
if (match) {
const [, text] = match;
return <CitationLink {...props}>{text}</CitationLink>;
}
}
}
if (typeof props.children === "string") {
const match = /^citation:(.+)$/.exec(props.children);
if (match) {
const [, text] = match;
return <CitationLink {...props}>{text}</CitationLink>;
}
}
const { className, target, rel, ...rest } = props;
const external = isExternalUrl(props.href);
const resolvedHref = anchorResolver.resolveHref(
@@ -88,33 +92,45 @@ export function MarkdownContent({
</span>
);
}
const finalHref = resolvedHref ?? props.href;
return (
<a
{...rest}
href={resolvedHref ?? props.href}
href={finalHref}
className={cn(
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
className,
)}
target={target ?? (external ? "_blank" : undefined)}
rel={rel ?? (external ? "noopener noreferrer" : undefined)}
/>
);
)}
target={target ?? (external ? "_blank" : undefined)}
rel={rel ?? (external ? "noopener noreferrer" : undefined)}
onClick={(event) => {
// HashRouter would turn a `#section` click into a route change → 404.
// Scroll to the heading ourselves instead.
if (isInPageAnchor(finalHref)) {
event.preventDefault();
scrollToInPageAnchor(event.currentTarget, finalHref);
}
rest.onClick?.(event);
}}
/>
);
},
...componentsFromProps,
};
}, [anchorResolver, componentsFromProps]);
if (!content) return null;
return (
<MessageResponse
className={className}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={components}
>
{content}
</MessageResponse>
);
}
if (!content) return null;
return (
<div data-markdown-anchor-root className="contents">
<MessageResponse
className={className}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={components}
>
{content}
</MessageResponse>
</div>
);
}
+82
View File
@@ -136,8 +136,18 @@ export interface AdminUserAnalyticsResponse {
export interface AdminLeaderboardReportResponse {
report: string;
generated_at: string;
cached?: boolean;
model_name?: string | null;
}
export type AdminLeaderboardReportStreamEvent =
| { event: "meta"; data: { cached?: boolean; model_name?: string | null } }
| { event: "cached"; data: { report: string; generated_at?: string | null; model_name?: string | null } }
| { event: "thinking"; data: { chunk: string } }
| { event: "text"; data: { chunk: string } }
| { event: "done"; data: { cached?: boolean; generated_at?: string | null; model_name?: string | null; report?: string } }
| { event: "error"; data: { message: string } };
const base = () => `${getBackendBaseURL()}/api/admin/users`;
async function parse<T>(res: Response, what: string): Promise<T> {
@@ -216,6 +226,7 @@ export async function getUserAnalytics(
export async function generateUserLeaderboardReport(
range: { since: string; until: string; days?: number },
limit = 30,
modelName?: string,
): Promise<AdminLeaderboardReportResponse> {
const params = new URLSearchParams({
days: String(range.days ?? 30),
@@ -223,6 +234,7 @@ export async function generateUserLeaderboardReport(
until: range.until,
limit: String(limit),
});
if (modelName) params.set("model_name", modelName);
return parse(
await apiFetch(`${base()}/leaderboard/report?${params}`, {
method: "POST",
@@ -231,6 +243,76 @@ export async function generateUserLeaderboardReport(
);
}
function parseSseFrame(frame: string): AdminLeaderboardReportStreamEvent | null {
let event = "message";
const dataLines: string[] = [];
for (const rawLine of frame.split(/\r?\n/)) {
const line = rawLine.trimEnd();
if (!line || line.startsWith(":")) continue;
if (line.startsWith("event:")) {
event = line.slice("event:".length).trim();
} else if (line.startsWith("data:")) {
dataLines.push(line.slice("data:".length).trimStart());
}
}
if (!dataLines.length) return null;
const data = JSON.parse(dataLines.join("\n")) as AdminLeaderboardReportStreamEvent["data"];
return { event, data } as AdminLeaderboardReportStreamEvent;
}
export async function streamUserLeaderboardReport({
range,
limit = 30,
modelName,
signal,
onEvent,
}: {
range: { since: string; until: string; days?: number };
limit?: number;
modelName?: string;
signal?: AbortSignal;
onEvent: (event: AdminLeaderboardReportStreamEvent) => void;
}) {
const params = new URLSearchParams({
days: String(range.days ?? 30),
since: range.since,
until: range.until,
limit: String(limit),
});
if (modelName) params.set("model_name", modelName);
const response = await apiFetch(`${base()}/leaderboard/report/stream?${params}`, {
method: "POST",
signal,
});
if (!response.ok) {
const err = (await response.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to stream leaderboard report (HTTP ${response.status})`);
}
if (!response.body) {
throw new Error("当前浏览器不支持流式报告读取");
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
for (;;) {
const { done, value } = await reader.read();
buffer += decoder.decode(value ?? new Uint8Array(), { stream: !done });
const frames = buffer.split(/\r?\n\r?\n/);
buffer = frames.pop() ?? "";
for (const frame of frames) {
const parsed = parseSseFrame(frame);
if (parsed) onEvent(parsed);
}
if (done) break;
}
const tail = buffer.trim();
if (tail) {
const parsed = parseSseFrame(tail);
if (parsed) onEvent(parsed);
}
}
export async function addAdminMemoryEntry(
userId: string,
content: string,
+3 -1
View File
@@ -53,10 +53,12 @@ export function useGenerateUserLeaderboardReport() {
mutationFn: ({
range,
limit,
modelName,
}: {
range: { since: string; until: string; days?: number };
limit?: number;
}) => generateUserLeaderboardReport(range, limit ?? 30),
modelName?: string;
}) => generateUserLeaderboardReport(range, limit ?? 30, modelName),
});
}
+71 -2
View File
@@ -3,8 +3,8 @@ export function extractTitleFromMarkdown(markdown: string) {
let title = markdown.split("\n")[0]!.trim();
if (title.startsWith("# ")) {
title = title.slice(2).trim();
}
return title;
}
return title;
}
return undefined;
}
@@ -52,6 +52,36 @@ function decodeLinkPart(value: string): string {
}
}
/** True for hrefs that point to an anchor within the current document (`#section`). */
export function isInPageAnchor(href: string | undefined | null): href is string {
return !!href && href.startsWith("#") && !href.startsWith("#reference-");
}
function escapeAttrValue(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
/**
* Scrolls to an in-page anchor by id without mutating `window.location.hash`.
*
* The app runs under a HashRouter, so letting an `<a href="#...">` click change
* the hash would be interpreted as a route change and 404. We resolve the target
* element ourselves and scroll to it instead. Scoped to the nearest
* `[data-markdown-anchor-root]` so headings in other messages don't collide.
*/
export function scrollToInPageAnchor(anchor: HTMLElement, href: string | undefined | null): void {
if (!isInPageAnchor(href)) return;
const id = decodeLinkPart(href.slice(1)).trim();
if (!id) return;
const scope = anchor.closest<HTMLElement>("[data-markdown-anchor-root]");
const selector = `[id="${escapeAttrValue(id)}"]`;
const target =
(scope?.querySelector<HTMLElement>(selector) ?? null) ??
document.getElementById(id) ??
document.querySelector<HTMLElement>(selector);
target?.scrollIntoView({ behavior: "smooth", block: "start" });
}
function normalizeLookupKey(value: string): string {
return markdownSlug(decodeLinkPart(value).replace(/^#+/, ""));
}
@@ -68,6 +98,45 @@ function candidateTextFromHref(href: string): string {
return decodeLinkPart(lastSegment).replace(/\.(md|markdown)$/i, "");
}
/**
* Injects a small runtime script into generated HTML so that clicking an
* in-page anchor (`<a href="#section">`) scrolls within the iframe instead of
* navigating it. The preview iframe loads from a sandboxed `blob:` URL, where a
* fragment navigation blanks the frame; intercepting the click avoids that.
*/
export function injectInPageAnchorHandler(html: string): string {
const script = `<script>(function(){
function decode(v){try{return decodeURIComponent(v);}catch(e){return v;}}
function bySelector(id){
if(!document.querySelector)return null;
var esc=window.CSS&&CSS.escape?CSS.escape(id):id.replace(/["\\\\]/g,"\\\\$&");
try{return document.querySelector("#"+esc+",[name=\\""+esc+"\\"]");}catch(e){return null;}
}
document.addEventListener("click",function(e){
var el=e.target;
while(el&&el.tagName!=="A"){el=el.parentElement;}
if(!el)return;
var href=el.getAttribute("href");
if(!href||href.charAt(0)!=="#"||href.length<2)return;
e.preventDefault();
var id=decode(href.slice(1));
var raw=href.slice(1);
var target=
document.getElementById(id)||
document.getElementById(raw)||
document.getElementsByName(id)[0]||
document.getElementsByName(raw)[0]||
bySelector(id)||
bySelector(raw);
if(target){target.scrollIntoView({behavior:"smooth",block:"start"});}
},true);
})();</script>`;
if (/<\/body>/i.test(html)) {
return html.replace(/<\/body>/i, `${script}</body>`);
}
return html + script;
}
export function createMarkdownAnchorResolver(markdown: string): MarkdownAnchorResolver {
const headings: MarkdownHeadingAnchor[] = [];
const idsByLookup = new Map<string, string>();
@@ -25,7 +25,7 @@ import {
Users,
type LucideIcon,
} from "lucide-react";
import { useEffect, useId, useMemo, useState } from "react";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
@@ -40,6 +40,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type {
AdminLeaderboardDimension,
@@ -49,19 +50,22 @@ import type {
AdminLeaderboardTrendPoint,
AdminLeaderboardUser,
} from "@/core/admin/api";
import { useGenerateUserLeaderboardReport, useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks";
import { streamUserLeaderboardReport } from "@/core/admin/api";
import { useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks";
import { useModels } from "@/core/models/hooks";
import { cn } from "@/lib/utils";
type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency";
type RangeState = { since: string; until: string; days?: number };
type LeaderboardAccent = "indigo" | "rose" | "amber" | "teal";
type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal";
const DEFAULT_REPORT_MODEL = "__default__";
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
indigo: {
soft: "bg-[#EEF2FF] dark:bg-indigo-400/15",
text: "text-[#4F46E5] dark:text-indigo-200",
bar: "bg-[#4F46E5]",
border: "border-[#C7D2FE] dark:border-indigo-300/25",
crimson: {
soft: "bg-[#FFF1F1] dark:bg-[#C1121F]/15",
text: "text-[#C1121F] dark:text-[#F2B8BB]",
bar: "bg-[#C1121F]",
border: "border-[#F1D6D6] dark:border-[#462123]",
},
rose: {
soft: "bg-[#FFF1F2] dark:bg-rose-400/15",
@@ -83,7 +87,7 @@ const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar
},
};
const RANK_COLORS = ["#4F46E5", "#0F766E", "#F59E0B", "#E11D48", "#7C3AED"] as const;
const RANK_COLORS = ["#C1121F", "#E11D48", "#F59E0B", "#991B1B", "#B45309"] as const;
function toDateInput(date: Date): string {
const year = date.getFullYear();
@@ -241,7 +245,7 @@ function StatCard({
}) {
const color = accentClasses[accent];
return (
<div className={cn("rounded-lg border bg-white p-4 shadow-sm dark:bg-[#06213F]", color.border)}>
<div className={cn("rounded-lg border bg-white p-4 shadow-sm dark:bg-[#230D0F]", color.border)}>
<div className="flex items-center justify-between gap-3">
<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)}>
@@ -260,21 +264,47 @@ function InsightStrip({ overview }: { overview: AdminLeaderboardOverview }) {
const activeUsers = Math.max(1, overview.active_users);
const totalRuns = Math.max(1, overview.total_runs);
const items = [
["人均使用", overview.total_runs / activeUsers, "次/活跃用户"],
["问答密度", overview.total_questions / totalRuns, "问答/运行"],
["平均 Token", overview.total_tokens / totalRuns, "Token/运行"],
["技能覆盖", overview.total_tool_calls ? (overview.total_skill_calls / overview.total_tool_calls) * 100 : 0, "%"],
["失败率", (overview.error_runs / totalRuns) * 100, "%"],
{
label: "人均使用",
value: overview.total_runs / activeUsers,
unit: "次/活跃用户",
description: "当前时间段内,平均每位活跃用户发起了多少次运行。",
},
{
label: "问答密度",
value: overview.total_questions / totalRuns,
unit: "问答/运行",
description: "问答次数 ÷ 运行次数,用来判断每次使用承载了多少有效提问。",
},
{
label: "平均 Token",
value: overview.total_tokens / totalRuns,
unit: "Token/运行",
description: "总 Token ÷ 运行次数,反映单次使用的模型消耗强度。",
},
{
label: "技能覆盖",
value: overview.total_tool_calls ? (overview.total_skill_calls / overview.total_tool_calls) * 100 : 0,
unit: "%",
description: "技能调用 ÷ 工具总调用,越高说明用户越多依赖已配置技能能力。",
},
{
label: "失败率",
value: (overview.error_runs / totalRuns) * 100,
unit: "%",
description: "失败运行 ÷ 总运行,用来快速发现稳定性或配置问题。",
},
];
return (
<section className="grid gap-3 md:grid-cols-5">
{items.map(([label, value, unit]) => (
<div key={label} className="rounded-lg border border-[#F1D6D6] bg-white p-3 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">{label}</div>
{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 className="text-xs font-medium text-[#7A5A5A] dark:text-[#D8B8B8]">{item.label}</div>
<div className="mt-1 text-xl font-semibold tabular-nums text-[#231815] dark:text-white">
{Number(value).toFixed(unit === "%" || String(value).includes(".") ? 1 : 0)}
<span className="ml-1 text-xs font-normal text-[#8B6B6B]">{unit}</span>
{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>
</div>
<p className="mt-2 line-clamp-2 text-[11px] leading-4 text-[#8B6B6B] dark:text-[#D8B8B8]">{item.description}</p>
</div>
))}
</section>
@@ -363,13 +393,13 @@ function PodiumCard({
}) {
const styles = {
1: "border-[#FEEFC3] bg-[#FEF7E0] text-[#B06000] dark:border-[#F4B400]/40 dark:bg-[#F4B400]/10 dark:text-[#FDD663]",
2: "border-[#D2E3FC] bg-[#E8F0FE] text-[#4285F4] dark:border-[#4285F4]/40 dark:bg-[#4285F4]/10 dark:text-[#8AB4F8]",
2: "border-[#E5E7EB] bg-[#F8FAFC] text-[#4B5563] dark:border-[#6B7280]/40 dark:bg-white/5 dark:text-[#E5E7EB]",
3: "border-[#FAD2CF] bg-[#FCE8E6] text-[#DB4437] dark:border-[#DB4437]/40 dark:bg-[#DB4437]/10 dark:text-[#F28B82]",
}[rank];
if (!user) {
return (
<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-[#06213F]/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-[#462123] dark:bg-[#230D0F]/70">
<div className="text-sm text-[#5F6368]">第 {rank} 名</div>
<div className="text-sm text-[#80868B]">暂无数据</div>
</div>
@@ -381,8 +411,8 @@ function PodiumCard({
type="button"
onClick={() => onSelect(user)}
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-[#06213F]",
active ? "border-[#4285F4] ring-2 ring-[#D2E3FC] dark:border-[#8AB4F8] dark:ring-[#4285F4]/20" : "border-[#DADCE0] dark:border-[#213D5E]",
"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]",
active ? "border-[#C1121F] ring-2 ring-[#F1D6D6] dark:border-[#F2B8BB] dark:ring-[#C1121F]/20" : "border-[#DADCE0] dark:border-[#462123]",
)}
>
<div className="flex items-start justify-between gap-3">
@@ -393,11 +423,11 @@ function PodiumCard({
{user.system_role === "admin" && <Badge variant="outline">管理员</Badge>}
</div>
<div>
<div className="mb-3 flex size-11 items-center justify-center rounded-lg bg-[#4285F4] text-base font-semibold text-white shadow-sm">
<div className="mb-3 flex size-11 items-center justify-center rounded-lg bg-[#C1121F] text-base font-semibold text-white shadow-sm">
{initials(user)}
</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-[#4285F4] dark:text-[#8AB4F8]">{scoreLabel(user, tab)}</div>
<div className="mt-1 text-sm font-medium text-[#C1121F] dark:text-[#F2B8BB]">{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">
<span>运行 {fmtNumber(user.run_count)}</span>
<span>问答 {fmtNumber(user.qa_count)}</span>
@@ -408,7 +438,7 @@ function PodiumCard({
);
}
function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendPoint[]; compact?: boolean }) {
type TrendMetric = keyof Pick<AdminLeaderboardTrendPoint, "runs" | "questions" | "users" | "tokens">;
const [metric, setMetric] = useState<"all" | TrendMetric>("all");
const chartId = useId().replace(/:/g, "");
@@ -418,10 +448,10 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
const visible = points.slice(-32);
const series = [
{ key: "runs", label: "运行", color: "#4F46E5", soft: "bg-[#EEF2FF] text-[#4F46E5]" },
{ key: "questions", label: "问答", color: "#0F766E", soft: "bg-[#ECFDF5] text-[#0F766E]" },
{ key: "users", label: "活跃用户", color: "#B45309", soft: "bg-[#FFFBEB] text-[#B45309]" },
{ key: "tokens", label: "Token", color: "#E11D48", soft: "bg-[#FFF1F2] text-[#E11D48]" },
{ key: "runs", label: "运行", color: "#C1121F", soft: "bg-[#FFF1F1] text-[#C1121F]", description: "每天发起的运行次数" },
{ key: "questions", label: "问答", color: "#0F766E", soft: "bg-[#ECFDF5] text-[#0F766E]", description: "每天产生的有效提问次数" },
{ key: "users", label: "活跃", color: "#B45309", soft: "bg-[#FFFBEB] text-[#B45309]", description: "每天有行为的用户数量" },
{ key: "tokens", label: "Token", color: "#7C3AED", soft: "bg-[#F5F3FF] text-[#7C3AED]", description: "每天模型输入输出 Token 总量" },
] as const;
const activeMetric: TrendMetric = metric === "all" ? "runs" : metric;
const activeSeries = series.find((item) => item.key === activeMetric) ?? series[0];
@@ -452,9 +482,11 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
visible[0],
);
const displayedSeries = metric === "all" ? series : [activeSeries];
const totalFor = (key: TrendMetric) => visible.reduce((sum, point) => sum + Number(point[key] ?? 0), 0);
const activeDays = visible.filter((point) => point.runs > 0 || point.questions > 0 || point.tokens > 0).length;
const chart = (large = false) => (
<div className={cn("overflow-hidden rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] p-3 dark:border-[#17395F] dark:bg-[#071F3B]", large && "p-5")}>
<svg viewBox={`0 0 ${width} ${height}`} className={cn("w-full", large ? "h-[520px]" : "h-72")} role="img" aria-label="使用趋势折线图">
<div className={cn("min-w-0 overflow-hidden rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] p-3 dark:border-[#3F3F46] dark:bg-[#18181B]", large && "p-5")}>
<svg viewBox={`0 0 ${width} ${height}`} className={cn("w-full", large ? "h-[520px]" : compact ? "h-56" : "h-72")} role="img" aria-label="使用趋势折线图">
<defs>
<linearGradient id={`trend-area-${chartId}-${activeMetric}`} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={activeSeries.color} stopOpacity="0.22" />
@@ -471,7 +503,7 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
y1={y}
y2={y}
stroke="currentColor"
className="text-[#E5E7EB] dark:text-[#17395F]"
className="text-[#E5E7EB] dark:text-[#3F3F46]"
strokeWidth="1"
/>
<text x={12} y={y + 4} className="fill-[#9CA3AF] text-[11px]">
@@ -526,8 +558,61 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
</div>
);
if (compact) {
return (
<div className="min-w-0 space-y-3">
<div className="grid gap-2 sm:grid-cols-4">
{series.map((item) => (
<button
key={item.key}
type="button"
onClick={() => setMetric(item.key)}
className={cn(
"min-w-0 rounded-lg border px-3 py-2 text-left transition hover:border-[#C1121F]",
metric === item.key
? "border-[#C1121F] bg-[#FFF1F1] dark:bg-[#3A1215]"
: "border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#230D0F]",
)}
title={item.description}
>
<span className={cn("inline-flex rounded-full px-2 py-0.5 text-xs font-medium", item.soft)}>{item.label}</span>
<span className="mt-2 block truncate text-base font-semibold tabular-nums text-[#231815] dark:text-white">
{fmtNumber(totalFor(item.key))}
</span>
<span className="mt-1 block truncate text-[11px] text-[#8B6B6B] dark:text-[#D8B8B8]">{item.description}</span>
</button>
))}
</div>
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">
<span>每个点代表 1 天;当前显示 {metric === "all" ? "全部指标" : activeSeries.label},覆盖 {activeDays} 个活跃日。</span>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
<Maximize2 className="mr-1 size-4" />
放大
</Button>
</DialogTrigger>
<DialogContent className="max-w-[min(1180px,calc(100vw-2rem))]">
<DialogHeader>
<DialogTitle>个人使用趋势</DialogTitle>
<DialogDescription>
每个折线点代表一天。运行=发起次数,问答=有效提问,活跃=有行为的用户数,Token=模型输入输出消耗。
</DialogDescription>
</DialogHeader>
{chart(true)}
</DialogContent>
</Dialog>
</div>
{chart(false)}
</div>
);
}
return (
<div className="space-y-4">
<p className="text-xs leading-5 text-[#7A5A5A] dark:text-[#D8B8B8]">
每个折线点代表一天;点击指标卡可单独查看运行、问答、活跃或 Token,全部趋势会同时展示四条多色折线。
</p>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
<button
@@ -583,13 +668,13 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
</Dialog>
</div>
<div className="grid gap-3 lg:grid-cols-[220px_minmax(0,1fr)]">
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 dark:border-[#17395F] dark:bg-[#071F3B]">
<div className="text-xs text-[#6B7280] dark:text-slate-400">最新 {metric === "all" ? "运行" : activeSeries.label}</div>
<div className="rounded-xl border border-[#F1D6D6] bg-white p-4 dark:border-[#462123] dark:bg-[#230D0F]">
<div className="text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">最新 {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={cn("mt-2 text-sm", delta >= 0 ? "text-[#0F766E]" : "text-[#E11D48]")}>
<div className={cn("mt-2 text-sm", delta >= 0 ? "text-[#C1121F]" : "text-[#7F1D1D] dark:text-[#FCA5A5]")}>
较上一日 {delta >= 0 ? "+" : ""}{fmtNumber(delta)}
</div>
<div className="mt-3 text-xs text-[#6B7280] dark:text-slate-400">
<div className="mt-3 text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">
峰值 {peak.date.slice(5)} / {fmtNumber(Number(peak[activeMetric] ?? 0))}
</div>
</div>
@@ -616,7 +701,7 @@ function UserTable({
}
return (
<div className="overflow-hidden rounded-lg border border-[#DADCE0] bg-white dark:border-[#213D5E] dark:bg-[#06213F]">
<div className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#230D0F]">
{users.map((user, index) => {
const score = userScore(user, tab);
return (
@@ -625,11 +710,11 @@ function UserTable({
type="button"
onClick={() => onSelect(user)}
className={cn(
"grid w-full grid-cols-[44px_minmax(0,1fr)_minmax(160px,240px)_120px] items-center gap-4 border-b border-[#EEF0F1] px-4 py-3 text-left last:border-b-0 hover:bg-[#F8FAFD] dark:border-[#17395F] dark:hover:bg-[#0B2A50]",
selectedUserId === user.user_id && "bg-[#E8F0FE] dark:bg-[#4285F4]/10",
"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]",
selectedUserId === user.user_id && "bg-[#FFF1F1] dark:bg-[#C1121F]/10",
)}
>
<div className="flex size-8 items-center justify-center rounded-md bg-[#F1F3F4] text-sm font-semibold text-[#5F6368] dark:bg-[#11345E] dark:text-slate-200">
<div className="flex size-8 items-center justify-center rounded-md bg-[#FFF1F1] text-sm font-semibold text-[#C1121F] dark:bg-[#3A1215] dark:text-[#F2B8BB]">
{index + 1}
</div>
<div className="min-w-0">
@@ -642,9 +727,9 @@ function UserTable({
</div>
</div>
<div>
<div className="h-2 rounded-full bg-[#E8EAED] dark:bg-[#11345E]">
<div className="h-2 rounded-full bg-[#F1D6D6] dark:bg-[#3A1215]">
<div
className="h-2 rounded-full bg-[#4285F4]"
className="h-2 rounded-full bg-[#C1121F]"
style={{ width: `${Math.max(4, (score / max) * 100)}%` }}
/>
</div>
@@ -678,7 +763,7 @@ function SkillRanking({
}) {
const max = Math.max(1, ...items.map((item) => item.call_count));
return (
<section className="rounded-lg border border-[#DADCE0] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Icon className="size-4 text-[#5F6368] dark:text-slate-300" />
@@ -698,7 +783,7 @@ function SkillRanking({
{fmtNumber(item.user_count)} 人
</span>
</div>
<div className="mt-2 h-2 rounded-full bg-[#E8EAED] dark:bg-[#11345E]">
<div className="mt-2 h-2 rounded-full bg-[#F1D6D6] dark:bg-[#3A1215]">
<div
className="h-2 rounded-full"
style={{
@@ -736,7 +821,7 @@ function DimensionRanking({
}) {
const max = Math.max(1, ...items.map((item) => item.count));
return (
<section className="rounded-lg border border-[#DADCE0] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Icon className="size-4 text-[#5F6368] dark:text-slate-300" />
@@ -756,7 +841,7 @@ function DimensionRanking({
{fmtNumber(item.user_count)} 人
</span>
</div>
<div className="mt-2 h-2 rounded-full bg-[#E8EAED] dark:bg-[#11345E]">
<div className="mt-2 h-2 rounded-full bg-[#F1D6D6] dark:bg-[#3A1215]">
<div
className="h-2 rounded-full"
style={{
@@ -790,8 +875,8 @@ function QuestionPanel({
showUser?: boolean;
}) {
return (
<section className="flex h-[430px] flex-col overflow-hidden rounded-lg border border-[#DADCE0] bg-white shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
<div className="flex items-center justify-between gap-3 border-b border-[#EEF0F1] px-4 py-3 dark:border-[#17395F]">
<section className="flex h-[430px] flex-col overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<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 gap-2">
<MessageSquareText className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
@@ -799,7 +884,7 @@ function QuestionPanel({
<Badge variant="outline">{items.length}</Badge>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="divide-y divide-[#EEF0F1] dark:divide-[#17395F]">
<div className="divide-y divide-[#F1D6D6] dark:divide-[#462123]">
{items.length === 0 && <div className="py-12 text-center text-sm text-[#5F6368]">暂无问题记录</div>}
{items.map((item, index) => (
<div key={`${item.run_id || item.text}-${index}`} className="px-4 py-3">
@@ -881,16 +966,13 @@ function UserDetailPanel({
</div>
</section>
<Tabs defaultValue="skills" className="space-y-3">
<Tabs defaultValue="questions" className="space-y-3">
<TabsList className="mb-3">
<TabsTrigger value="skills">技能</TabsTrigger>
<TabsTrigger value="questions">提问</TabsTrigger>
<TabsTrigger value="trend">趋势</TabsTrigger>
<TabsTrigger value="skills">技能</TabsTrigger>
<TabsTrigger value="dimensions">维度</TabsTrigger>
</TabsList>
<TabsContent value="skills" className="min-h-[420px]">
<SkillRanking title="个人常用技能" icon={Sparkles} items={data.skills.length ? data.skills : data.tools} empty="暂无技能调用" />
</TabsContent>
<TabsContent value="questions" className="min-h-[420px]">
<QuestionPanel title="最近提问" items={data.recent_questions} showUser={false} />
</TabsContent>
@@ -899,7 +981,10 @@ function UserDetailPanel({
<TrendingUp className="size-4 text-[#C1121F]" />
个人使用趋势
</div>
<TrendBars points={data.trends} />
<TrendBars points={data.trends} compact />
</TabsContent>
<TabsContent value="skills" className="min-h-[420px]">
<SkillRanking title="个人常用技能" icon={Sparkles} items={data.skills} empty="暂无技能调用" />
</TabsContent>
<TabsContent value="dimensions" className="min-h-[420px]">
<div className="grid gap-3">
@@ -920,14 +1005,23 @@ export default function AdminUserLeaderboardPage() {
const [tab, setTab] = useState<UserRankTab>("activity");
const [query, setQuery] = useState("");
const [groupRules, setGroupRules] = useState("zero,two");
const [showGroupStats, setShowGroupStats] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const apiRange = useMemo(
() => rangeForApi(range),
[range],
);
const { data, isLoading, error } = useUserLeaderboard(apiRange, 100);
const reportMutation = useGenerateUserLeaderboardReport();
const [reportOpen, setReportOpen] = useState(false);
const { models: reportModels, isLoading: reportModelsLoading } = useModels({ enabled: reportOpen });
const [reportModelName, setReportModelName] = useState(DEFAULT_REPORT_MODEL);
const [reportText, setReportText] = useState("");
const [reportError, setReportError] = useState<string | null>(null);
const [reportStatus, setReportStatus] = useState<"idle" | "streaming" | "done" | "cached" | "error">("idle");
const [reportGeneratedAt, setReportGeneratedAt] = useState<string | null>(null);
const [reportResolvedModel, setReportResolvedModel] = useState<string | null>(null);
const reportAbortRef = useRef<AbortController | null>(null);
const reportGenerating = reportStatus === "streaming";
const rankedUsers = useMemo(() => {
const base = data?.users_by_activity ?? [];
@@ -964,6 +1058,92 @@ export default function AdminUserLeaderboardPage() {
setRange(defaultRange(days));
};
const handleReportOpenChange = (open: boolean) => {
setReportOpen(open);
if (!open) {
reportAbortRef.current?.abort();
reportAbortRef.current = null;
}
};
const startReportGeneration = async () => {
reportAbortRef.current?.abort();
const controller = new AbortController();
reportAbortRef.current = controller;
const selectedModelName = reportModelName === DEFAULT_REPORT_MODEL ? undefined : reportModelName;
let terminalEventSeen = false;
setReportText("");
setReportError(null);
setReportGeneratedAt(null);
setReportResolvedModel(selectedModelName ?? null);
setReportStatus("streaming");
try {
await streamUserLeaderboardReport({
range: apiRange,
limit: 40,
modelName: selectedModelName,
signal: controller.signal,
onEvent: (event) => {
if (event.event === "meta") {
setReportResolvedModel(event.data.model_name ?? selectedModelName ?? null);
return;
}
if (event.event === "cached") {
terminalEventSeen = true;
setReportText(event.data.report);
setReportGeneratedAt(event.data.generated_at ?? null);
setReportResolvedModel(event.data.model_name ?? selectedModelName ?? null);
setReportStatus("cached");
return;
}
if (event.event === "text") {
setReportText((value) => value + event.data.chunk);
return;
}
if (event.event === "done") {
terminalEventSeen = true;
if (event.data.report) setReportText(event.data.report);
setReportGeneratedAt(event.data.generated_at ?? null);
setReportResolvedModel(event.data.model_name ?? selectedModelName ?? null);
setReportStatus(event.data.cached ? "cached" : "done");
return;
}
if (event.event === "error") {
terminalEventSeen = true;
setReportError(event.data.message);
setReportStatus("error");
}
},
});
if (!terminalEventSeen && !controller.signal.aborted) {
setReportStatus("done");
}
} catch (err) {
if (!controller.signal.aborted) {
setReportError(err instanceof Error ? err.message : String(err));
setReportStatus("error");
}
} finally {
if (reportAbortRef.current === controller) {
reportAbortRef.current = null;
}
}
};
useEffect(() => {
return () => reportAbortRef.current?.abort();
}, []);
useEffect(() => {
setReportText("");
setReportError(null);
setReportGeneratedAt(null);
setReportResolvedModel(null);
setReportStatus("idle");
}, [apiRange.since, apiRange.until, reportModelName]);
return (
<div className="flex size-full min-h-0 bg-[#FFF7F7] text-[#231815] dark:bg-[#19090A] dark:text-white">
<main className="flex min-w-0 flex-1 flex-col">
@@ -1010,21 +1190,11 @@ export default function AdminUserLeaderboardPage() {
<FileDown className="mr-1 size-4" />
导出 Excel
</Button>
<Dialog open={reportOpen} onOpenChange={setReportOpen}>
<Dialog open={reportOpen} onOpenChange={handleReportOpenChange}>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={!data || reportMutation.isPending}
onClick={() => reportMutation.mutate({ range: apiRange, limit: 40 })}
className="h-9"
>
{reportMutation.isPending ? (
<Loader2 className="mr-1 size-4 animate-spin" />
) : (
<Sparkles className="mr-1 size-4" />
)}
生成报告
<Button variant="outline" size="sm" disabled={!data} className="h-9">
<Sparkles className="mr-1 size-4" />
AI 报告
</Button>
</DialogTrigger>
<DialogContent className="max-w-[min(920px,calc(100vw-2rem))]">
@@ -1034,32 +1204,61 @@ export default function AdminUserLeaderboardPage() {
基于当前时间段的问答、用户、技能、模型和趋势统计生成运营分析。
</DialogDescription>
</DialogHeader>
<div className="flex 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="flex min-w-0 flex-wrap items-center gap-2">
<Select value={reportModelName} onValueChange={setReportModelName} disabled={reportGenerating || reportModelsLoading}>
<SelectTrigger className="h-9 w-[260px] border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#19090A]">
<SelectValue placeholder="选择报告模型" />
</SelectTrigger>
<SelectContent>
<SelectItem value={DEFAULT_REPORT_MODEL}>默认模型</SelectItem>
{reportModels.map((model) => (
<SelectItem key={model.name} value={model.name}>
{model.display_name || model.name}
</SelectItem>
))}
</SelectContent>
</Select>
{reportStatus === "cached" && <Badge variant="outline">已读取缓存</Badge>}
{reportResolvedModel && <Badge variant="outline">模型 {reportResolvedModel}</Badge>}
{reportGeneratedAt && <span className="text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">生成于 {fmtTime(reportGeneratedAt)}</span>}
</div>
<Button size="sm" onClick={startReportGeneration} disabled={!data || reportGenerating}>
{reportGenerating ? <Loader2 className="mr-1 size-4 animate-spin" /> : <Sparkles className="mr-1 size-4" />}
{reportGenerating ? "生成中" : "生成报告"}
</Button>
</div>
<ScrollArea className="max-h-[70vh] rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-4 dark:border-[#462123] dark:bg-[#19090A]">
{reportMutation.isPending && (
{reportStatus === "idle" && !reportText && (
<div className="flex items-center justify-center py-12 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
选择模型后点击生成;同一时间段、同一模型和同一统计设置会直接读取已保存报告。
</div>
)}
{reportGenerating && !reportText && (
<div className="flex items-center justify-center gap-2 py-12 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
<Loader2 className="size-4 animate-spin" />
正在调用大模型生成报告
正在调用大模型生成报告,内容会流式显示
</div>
)}
{reportMutation.error && (
{reportError && (
<div className="rounded-lg border border-[#FAD2CF] bg-[#FCE8E6] p-3 text-sm text-[#DB4437] dark:border-[#DB4437]/30 dark:bg-[#DB4437]/10 dark:text-[#F28B82]">
生成失败:{reportMutation.error.message}
生成失败:{reportError}
</div>
)}
{reportMutation.data?.report && (
{reportText && (
<pre className="whitespace-pre-wrap font-sans text-sm leading-7 text-[#231815] dark:text-white">
{reportMutation.data.report}
{reportText}
</pre>
)}
</ScrollArea>
{reportMutation.data?.report && (
{reportText && (
<div className="flex justify-end">
<Button
variant="outline"
onClick={() =>
downloadText(
`user-leaderboard-ai-report-${new Date().toISOString().slice(0, 10)}.md`,
reportMutation.data.report,
reportText,
"text/markdown;charset=utf-8",
)
}
@@ -1079,7 +1278,7 @@ export default function AdminUserLeaderboardPage() {
onClick={(event) => event.currentTarget.showPicker?.()}
onFocus={(event) => event.currentTarget.showPicker?.()}
onChange={(event) => setRange((prev) => ({ ...prev, since: event.target.value }))}
className="h-9 w-36 bg-white dark:bg-[#0B2A50]"
className="h-9 w-36 bg-white dark:bg-[#19090A]"
/>
</div>
<div className="flex flex-col gap-1">
@@ -1090,7 +1289,7 @@ export default function AdminUserLeaderboardPage() {
onClick={(event) => event.currentTarget.showPicker?.()}
onFocus={(event) => event.currentTarget.showPicker?.()}
onChange={(event) => setRange((prev) => ({ ...prev, until: event.target.value }))}
className="h-9 w-36 bg-white dark:bg-[#0B2A50]"
className="h-9 w-36 bg-white dark:bg-[#19090A]"
/>
</div>
<Button variant="outline" size="sm" onClick={() => setQuickRange(7)}>
@@ -1124,21 +1323,29 @@ export default function AdminUserLeaderboardPage() {
{!isLoading && data && overview && (
<>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<StatCard icon={Users} accent="indigo" label="总用户" value={fmtNumber(overview.total_users)} note={`${fmtNumber(overview.active_users)} 位在当前时间段活跃`} />
<StatCard icon={Users} accent="crimson" label="总用户" value={fmtNumber(overview.total_users)} note={`${fmtNumber(overview.active_users)} 位在当前时间段活跃`} />
<StatCard icon={MousePointerClick} accent="teal" label="使用次数" value={fmtNumber(overview.total_runs)} note={`${fmtNumber(overview.total_threads)} 个会话被创建`} />
<StatCard icon={MessageSquareText} accent="amber" label="问答次数" value={fmtNumber(overview.total_questions)} note={`失败运行 ${fmtNumber(overview.error_runs)} 次`} />
<StatCard icon={Sparkles} accent="rose" label="技能调用" value={fmtNumber(overview.total_skill_calls)} note={`工具总调用 ${fmtNumber(overview.total_tool_calls)} 次`} />
</div>
<InsightStrip overview={overview} />
<GroupComparison
users={rankedUsers}
rulesText={groupRules}
onRulesChange={setGroupRules}
onSelectUser={(user) => setSelectedUserId(user.user_id)}
/>
<div className="flex justify-end">
<Button variant="ghost" size="sm" onClick={() => setShowGroupStats((value) => !value)}>
<Users className="mr-1 size-4" />
{showGroupStats ? "隐藏分组统计" : "显示分组统计"}
</Button>
</div>
{showGroupStats && (
<GroupComparison
users={rankedUsers}
rulesText={groupRules}
onRulesChange={setGroupRules}
onSelectUser={(user) => setSelectedUserId(user.user_id)}
/>
)}
<section className="rounded-lg border border-[#DADCE0] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="font-semibold text-[#202124] dark:text-white">榜单前三</h2>
@@ -1177,7 +1384,7 @@ export default function AdminUserLeaderboardPage() {
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索用户邮箱或 ID"
className="h-9 w-64 bg-white pl-8 dark:bg-[#0B2A50]"
className="h-9 w-64 bg-white pl-8 dark:bg-[#19090A]"
/>
</div>
</div>
@@ -1189,8 +1396,8 @@ export default function AdminUserLeaderboardPage() {
/>
</section>
<section className="overflow-hidden rounded-lg border border-[#DADCE0] bg-white shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
<div className="flex items-center gap-2 border-b border-[#EEF0F1] px-4 py-3 dark:border-[#17395F]">
<section className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="flex items-center gap-2 border-b border-[#F1D6D6] px-4 py-3 dark:border-[#462123]">
<UserRound className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">用户详情</h2>
</div>
@@ -1206,7 +1413,7 @@ export default function AdminUserLeaderboardPage() {
</section>
</div>
<section className="rounded-lg border border-[#DADCE0] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<TrendingUp className="size-4 text-[#5F6368] dark:text-slate-300" />
@@ -39,6 +39,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { HtmlPreviewFrame } from "@/components/html-preview-frame";
import { getBackendBaseURL } from "@/core/config";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { reasoningPlugins } from "@/core/streamdown";
@@ -953,10 +954,8 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
)}
{activeTabMeta?.kind === "html" ? (
htmlContent ? (
<iframe
title="HTML 预览"
sandbox="allow-same-origin"
srcDoc={htmlContent}
<HtmlPreviewFrame
html={htmlContent}
className="min-h-[60vh] w-full flex-1 rounded-md border bg-white"
/>
) : (
@@ -18,6 +18,7 @@ import {
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { HtmlPreviewFrame } from "@/components/html-preview-frame";
import {
downloadScheduledTaskRun,
downloadScheduledTaskRunFile,
@@ -100,22 +101,6 @@ function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
return <pre className="whitespace-pre-wrap break-words text-sm">{file.content}</pre>;
}
/**
* Sandboxed preview of a generated HTML document. The iframe has no
* ``allow-scripts`` token, so embedded JS cannot run — defense-in-depth on top
* of the server-side sanitization that already stripped script vectors.
*/
function HtmlPreviewFrame({ html }: { html: string }) {
return (
<iframe
title="HTML 预览"
sandbox="allow-same-origin"
srcDoc={html}
className="h-[70vh] w-full rounded-md border bg-white"
/>
);
}
type EditState = { draft: string; saving: boolean };
/** Flatten a LangChain-style message content (string | parts | nested) to text. */
@@ -5,7 +5,11 @@ import remarkGfm from "remark-gfm";
import { Download, FileText, Loader2, X } from "lucide-react";
import { apiFetch } from "@/core/api/fetch-client";
import { createMarkdownAnchorResolver } from "@/core/utils/markdown";
import {
createMarkdownAnchorResolver,
isInPageAnchor,
scrollToInPageAnchor,
} from "@/core/utils/markdown";
import { buildArtifactDownloadUrl } from "@/roundtable-planning/api/multi-agent";
interface ArtifactPreviewModalProps {
@@ -74,13 +78,20 @@ function createMarkdownComponents(content: string) {
</span>
);
}
const finalHref = resolvedHref ?? href;
const isExternal = typeof href === "string" && /^https?:\/\//i.test(href);
return (
<a
href={resolvedHref ?? href}
href={finalHref}
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener noreferrer" : undefined}
className="text-primary underline hover:opacity-80"
onClick={(event) => {
if (isInPageAnchor(finalHref)) {
event.preventDefault();
scrollToInPageAnchor(event.currentTarget, finalHref);
}
}}
>
{children}
</a>
@@ -203,7 +214,10 @@ export const ArtifactPreviewModal: React.FC<ArtifactPreviewModalProps> = ({
</div>
{/* Body */}
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-4 text-sm text-slate-700">
<div
data-markdown-anchor-root
className="flex-1 min-h-0 overflow-y-auto px-5 py-4 text-sm text-slate-700"
>
{loading ? (
<div className="flex items-center justify-center gap-2 py-12 text-slate-400">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
@@ -16,22 +16,27 @@ from __future__ import annotations
import json
import logging
import re
from collections.abc import AsyncIterator
from difflib import SequenceMatcher
from hashlib import sha256
from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from sqlalchemy import and_, case, desc, func, or_, select
from langchain_core.messages import HumanMessage, SystemMessage
from app.gateway.deps import (
get_config,
get_agent_store,
get_checkpointer,
get_current_user_from_request,
get_thread_store,
)
from deerflow.agents.memory import BuiltinFileProvider
from deerflow.config.app_config import AppConfig
from deerflow.config.paths import get_paths
from deerflow.config.system_settings import load_system_settings
from deerflow.models import create_chat_model
@@ -165,6 +170,8 @@ class AdminUserAnalyticsResponse(BaseModel):
class AdminLeaderboardReportResponse(BaseModel):
report: str
generated_at: str
cached: bool = False
model_name: str | None = None
def _now() -> datetime:
@@ -242,6 +249,108 @@ def _message_content_to_text(content) -> str:
return str(content or "")
def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
def _extract_chunk_parts(chunk: object) -> tuple[str, str]:
"""Return (thinking_delta, text_delta) from a LangChain streaming chunk."""
thinking = ""
text = ""
content = getattr(chunk, "content", "")
if isinstance(content, str):
text = content
elif isinstance(content, list):
for block in content:
if isinstance(block, str):
text += block
elif isinstance(block, dict):
block_type = block.get("type", "")
if block_type in {"thinking", "thinking_delta"}:
thinking += block.get("thinking", "")
elif block_type in {"text", "text_delta"}:
text += block.get("text", "")
elif block_type == "reasoning":
thinking += block.get("content", "")
if not thinking:
kwargs = getattr(chunk, "additional_kwargs", {}) or {}
reasoning = kwargs.get("reasoning_content") or ""
if isinstance(reasoning, str):
thinking = reasoning
return thinking, text
def _report_cache_file():
return get_paths().base_dir / "admin_leaderboard_reports.json"
def _load_report_cache() -> dict:
path = _report_cache_file()
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
logger.warning("Failed to read leaderboard report cache: %s", path, exc_info=True)
return {}
return data if isinstance(data, dict) else {}
def _save_report_cache(cache: dict) -> None:
path = _report_cache_file()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(cache, ensure_ascii=False, indent=2), encoding="utf-8")
def _resolve_report_model_name(config: AppConfig, model_name: str | None) -> str | None:
cleaned = (model_name or "").strip()
if cleaned:
return cleaned
return config.models[0].name if config.models else None
def _report_cache_key(
*,
since: datetime,
until: datetime,
limit: int,
model_name: str | None,
settings,
) -> str:
payload = {
"version": 3,
"since": _iso(since),
"until": _iso(until),
"limit": limit,
"model_name": model_name,
"settings": {
"exclude_user_ids": sorted(uid for uid in settings.exclude_user_ids if uid),
"include_scheduled": settings.include_scheduled,
"include_admins": settings.include_admins,
"include_failed": settings.include_failed,
"cleanup_system_questions": settings.cleanup_system_questions,
},
}
raw = json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str)
return sha256(raw.encode("utf-8")).hexdigest()
def _cached_report(cache_key: str) -> dict | None:
item = _load_report_cache().get(cache_key)
if isinstance(item, dict) and isinstance(item.get("report"), str):
return item
return None
def _store_report_cache(cache_key: str, item: dict) -> None:
cache = _load_report_cache()
cache[cache_key] = item
if len(cache) > 80:
ordered = sorted(cache.items(), key=lambda kv: str(kv[1].get("generated_at") or ""))
cache = dict(ordered[-80:])
_save_report_cache(cache)
def _run_time_filter(since: datetime, until: datetime):
return and_(RunRow.created_at >= since, RunRow.created_at <= until)
@@ -360,7 +469,7 @@ async def _query_user_leaderboard(
select(
ToolCallMetricRow.user_id.label("user_id"),
func.count(ToolCallMetricRow.id).label("tool_call_count"),
func.sum(case((func.coalesce(func.nullif(ToolCallMetricRow.skill_name, ""), func.nullif(ToolCallMetricRow.tool_name, "")).is_not(None), 1), else_=0)).label("skill_call_count"),
func.sum(case((func.nullif(ToolCallMetricRow.skill_name, "").is_not(None), 1), else_=0)).label("skill_call_count"),
)
.where(_tool_time_filter(since, until))
.group_by(ToolCallMetricRow.user_id)
@@ -433,11 +542,7 @@ async def _query_call_rankings(
include_admins: bool = True,
) -> list[AdminLeaderboardSkill]:
exclude_user_ids = exclude_user_ids or set()
name_col = (
func.coalesce(func.nullif(ToolCallMetricRow.skill_name, ""), func.nullif(ToolCallMetricRow.tool_name, ""))
if field == "skill"
else ToolCallMetricRow.tool_name
)
name_col = ToolCallMetricRow.skill_name if field == "skill" else ToolCallMetricRow.tool_name
call_count = func.count(ToolCallMetricRow.id).label("call_count")
last_used_at = func.max(ToolCallMetricRow.created_at).label("last_used_at")
stmt = (
@@ -822,7 +927,7 @@ async def _query_overview(
tool_row = (await session.execute(
select(
func.count(ToolCallMetricRow.id),
func.sum(case((func.coalesce(func.nullif(ToolCallMetricRow.skill_name, ""), func.nullif(ToolCallMetricRow.tool_name, "")).is_not(None), 1), else_=0)),
func.sum(case((func.nullif(ToolCallMetricRow.skill_name, "").is_not(None), 1), else_=0)),
).where(*tool_where)
)).one()
@@ -912,22 +1017,13 @@ async def get_user_leaderboard(
)
@router.post("/leaderboard/report", response_model=AdminLeaderboardReportResponse)
async def generate_user_leaderboard_report(
request: Request,
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=30, ge=10, le=100),
) -> AdminLeaderboardReportResponse:
"""Generate an LLM-written admin report for the current leaderboard window."""
await _require_admin(request)
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)
async def _build_leaderboard_report_prompt(
session_factory,
settings,
since_dt: datetime,
until_dt: datetime,
limit: int,
) -> str:
excluded = {uid for uid in settings.exclude_user_ids if uid}
common = {
"exclude_user_ids": excluded,
@@ -951,6 +1047,10 @@ async def generate_user_leaderboard_report(
skills = await _query_call_rankings(session, since_dt, until_dt, user_id=None, field="skill", limit=limit, **tool_common)
tools = await _query_call_rankings(session, since_dt, until_dt, user_id=None, field="tool", limit=limit, **tool_common)
models = await _query_model_rankings(session, since_dt, until_dt, user_id=None, limit=limit, **common)
if not models:
models = await _query_run_dimensions(session, since_dt, until_dt, user_id=None, field="model", limit=limit, **common)
agents = await _query_run_dimensions(session, since_dt, until_dt, user_id=None, field="agent", limit=limit, **common)
run_statuses = await _query_run_dimensions(session, since_dt, until_dt, user_id=None, field="status", limit=limit, **common)
recent_questions = await _query_recent_questions(session, since_dt, until_dt, user_id=None, limit=limit, **question_common)
frequent_questions = await _query_frequent_questions(session, since_dt, until_dt, limit=limit, **question_common)
trends = await _query_trends(session, since_dt, until_dt, **common)
@@ -963,11 +1063,13 @@ async def generate_user_leaderboard_report(
"top_skills": [s.model_dump() for s in skills[:12]],
"top_tools": [t.model_dump() for t in tools[:12]],
"top_models": [m.model_dump() for m in models[:12]],
"frequent_questions": [q.model_dump() for q in frequent_questions[:20]],
"recent_questions": [q.model_dump() for q in recent_questions[:30]],
"trends": [t.model_dump() for t in trends[-14:]],
"agents": [a.model_dump() for a in agents[:12]],
"run_statuses": [s.model_dump() for s in run_statuses[:12]],
"frequent_questions": [q.model_dump() for q in frequent_questions[:30]],
"recent_questions": [q.model_dump() for q in recent_questions[:40]],
"trends": [t.model_dump() for t in trends[-21:]],
}
prompt = (
return (
"请基于以下管理员排行榜统计数据,生成一份中文运营分析报告。"
"报告需要有结论,不要只罗列数字。必须分析:"
"1) 谁使用次数最多、问答最多、Token 消耗最高;"
@@ -976,12 +1078,46 @@ async def generate_user_leaderboard_report(
"4) 从问题重复、失败率、追问密度等信号推断用户是否满意系统回答,注明这是推断;"
"5) 哪些技能/工具/模型最常用,可能说明什么;"
"6) 给管理员 3-5 条可执行建议。"
"输出结构:摘要、关键发现、用户与分组观察、问题主题、满意度推断、建议。"
"输出结构:摘要、关键发现、用户观察、问题主题、满意度推断、建议。"
"\n\n统计数据如下:\n"
f"{json.dumps(context, ensure_ascii=False, default=str)}"
)
@router.post("/leaderboard/report", response_model=AdminLeaderboardReportResponse)
async def generate_user_leaderboard_report(
request: Request,
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=30, ge=10, le=100),
model_name: str | None = Query(default=None, description="Model name used to generate the report"),
config: AppConfig = Depends(get_config),
) -> AdminLeaderboardReportResponse:
"""Generate an LLM-written admin report for the current leaderboard window."""
await _require_admin(request)
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)
resolved_model_name = _resolve_report_model_name(config, model_name)
cache_key = _report_cache_key(since=since_dt, until=until_dt, limit=limit, model_name=resolved_model_name, settings=settings)
cached = _cached_report(cache_key)
if cached:
return AdminLeaderboardReportResponse(
report=cached["report"],
generated_at=str(cached.get("generated_at") or ""),
cached=True,
model_name=str(cached.get("model_name") or resolved_model_name or ""),
)
prompt = await _build_leaderboard_report_prompt(session_factory, settings, since_dt, until_dt, limit)
try:
model = create_chat_model(name=None, thinking_enabled=False)
model_cfg = config.get_model_config(resolved_model_name) if resolved_model_name else None
thinking_enabled = bool(model_cfg and getattr(model_cfg, "supports_thinking", False))
model = create_chat_model(name=resolved_model_name, thinking_enabled=thinking_enabled, app_config=config)
response = await model.ainvoke(
[
SystemMessage(content="你是企业 AI 系统的运营分析师,擅长从用户行为、问题文本和调用指标中生成管理报告。"),
@@ -993,9 +1129,85 @@ async def generate_user_leaderboard_report(
logger.exception("Failed to generate leaderboard report")
raise HTTPException(status_code=503, detail=f"Report generation failed: {exc}") from exc
report = _message_content_to_text(getattr(response, "content", response)).strip()
generated_at = _iso(_now())
_store_report_cache(cache_key, {"report": report, "generated_at": generated_at, "model_name": resolved_model_name})
return AdminLeaderboardReportResponse(
report=_message_content_to_text(getattr(response, "content", response)).strip(),
generated_at=_iso(_now()),
report=report,
generated_at=generated_at,
cached=False,
model_name=resolved_model_name,
)
@router.post("/leaderboard/report/stream")
async def stream_user_leaderboard_report(
request: Request,
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=30, ge=10, le=100),
model_name: str | None = Query(default=None, description="Model name used to generate the report"),
config: AppConfig = Depends(get_config),
) -> StreamingResponse:
"""Stream an LLM-written admin report, returning cached reports when available."""
await _require_admin(request)
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)
resolved_model_name = _resolve_report_model_name(config, model_name)
cache_key = _report_cache_key(since=since_dt, until=until_dt, limit=limit, model_name=resolved_model_name, settings=settings)
async def generate() -> AsyncIterator[str]:
cached = _cached_report(cache_key)
if cached:
yield _sse(
"cached",
{
"report": cached["report"],
"generated_at": cached.get("generated_at"),
"model_name": cached.get("model_name") or resolved_model_name,
},
)
yield _sse("done", {"cached": True, "generated_at": cached.get("generated_at"), "model_name": cached.get("model_name") or resolved_model_name})
return
report_parts: list[str] = []
generated_at = ""
try:
yield _sse("meta", {"cached": False, "model_name": resolved_model_name})
prompt = await _build_leaderboard_report_prompt(session_factory, settings, since_dt, until_dt, limit)
model_cfg = config.get_model_config(resolved_model_name) if resolved_model_name else None
thinking_enabled = bool(model_cfg and getattr(model_cfg, "supports_thinking", False))
model = create_chat_model(name=resolved_model_name, thinking_enabled=thinking_enabled, app_config=config)
async for chunk in model.astream(
[
SystemMessage(content="你是企业 AI 系统的运营分析师,擅长从用户行为、问题文本和调用指标中生成管理报告。"),
HumanMessage(content=prompt),
],
config={"run_name": "admin_user_leaderboard_report"},
):
thinking_delta, text_delta = _extract_chunk_parts(chunk)
if thinking_delta:
yield _sse("thinking", {"chunk": thinking_delta})
if text_delta:
report_parts.append(text_delta)
yield _sse("text", {"chunk": text_delta})
report = "".join(report_parts).strip()
generated_at = _iso(_now())
_store_report_cache(cache_key, {"report": report, "generated_at": generated_at, "model_name": resolved_model_name})
yield _sse("done", {"cached": False, "generated_at": generated_at, "model_name": resolved_model_name, "report": report})
except Exception as exc:
logger.exception("Failed to stream leaderboard report")
yield _sse("error", {"message": str(exc), "generated_at": generated_at, "model_name": resolved_model_name})
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={"X-Accel-Buffering": "no", "Cache-Control": "no-cache"},
)