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"; "use client";
import { import {
ArrowDownIcon, ArrowDownIcon,
Code2Icon, Code2Icon,
CopyIcon, CopyIcon,
DownloadIcon, DownloadIcon,
@@ -12,11 +12,11 @@ import {
SaveIcon, SaveIcon,
SquareArrowOutUpRightIcon, SquareArrowOutUpRightIcon,
XIcon, XIcon,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react"; import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Streamdown } from "streamdown"; import { Streamdown } from "streamdown";
import { import {
Artifact, Artifact,
@@ -48,10 +48,15 @@ import { urlOfArtifact } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx"; import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { installSkill } from "@/core/skills/api"; import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown"; import { streamdownPlugins } from "@/core/streamdown";
import { checkCodeFile, getFileName } from "@/core/utils/files"; import { checkCodeFile, getFileName } from "@/core/utils/files";
import { createMarkdownAnchorResolver } from "@/core/utils/markdown"; import {
import { env } from "@/env"; createMarkdownAnchorResolver,
injectInPageAnchorHandler,
isInPageAnchor,
scrollToInPageAnchor,
} from "@/core/utils/markdown";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -61,15 +66,15 @@ import { Tooltip } from "../tooltip";
import { useArtifacts } from "./context"; import { useArtifacts } from "./context";
const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll"; const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll";
function extractTextFromChildren(children: ReactNode): string { function extractTextFromChildren(children: ReactNode): string {
if (typeof children === "string" || typeof children === "number") return String(children); if (typeof children === "string" || typeof children === "number") return String(children);
if (Array.isArray(children)) return children.map(extractTextFromChildren).join(""); if (Array.isArray(children)) return children.map(extractTextFromChildren).join("");
return ""; return "";
} }
function findScrollableArtifactElement(root: HTMLElement | null) { function findScrollableArtifactElement(root: HTMLElement | null) {
if (!root) { if (!root) {
return null; return null;
} }
@@ -714,54 +719,68 @@ export function ArtifactFilePreview({
language: string; language: string;
animated?: boolean; animated?: boolean;
activeCursor?: boolean; activeCursor?: boolean;
}) { }) {
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>(); const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
const markdownComponents = useMemo(() => { const markdownComponents = useMemo(() => {
const anchorResolver = createMarkdownAnchorResolver(content ?? ""); const anchorResolver = createMarkdownAnchorResolver(content ?? "");
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => { const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
const Tag = `h${level}` as const; const Tag = `h${level}` as const;
return ({ children, id, ...props }: HTMLAttributes<HTMLHeadingElement>) => ( return ({ children, id, ...props }: HTMLAttributes<HTMLHeadingElement>) => (
<Tag id={id ?? anchorResolver.getHeadingId(level, extractTextFromChildren(children))} {...props}> <Tag id={id ?? anchorResolver.getHeadingId(level, extractTextFromChildren(children))} {...props}>
{children} {children}
</Tag> </Tag>
); );
}; };
return { return {
h1: heading(1), h1: heading(1),
h2: heading(2), h2: heading(2),
h3: heading(3), h3: heading(3),
h4: heading(4), h4: heading(4),
h5: heading(5), h5: heading(5),
h6: heading(6), h6: heading(6),
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => { a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (typeof props.children === "string" && /^citation:(.+)$/.test(props.children)) { if (typeof props.children === "string" && /^citation:(.+)$/.test(props.children)) {
return <ArtifactLink {...props} />; return <ArtifactLink {...props} />;
} }
const { className, ...rest } = props; const { className, ...rest } = props;
const resolvedHref = anchorResolver.resolveHref( const resolvedHref = anchorResolver.resolveHref(
props.href, props.href,
extractTextFromChildren(props.children), extractTextFromChildren(props.children),
); );
if (resolvedHref === null) { if (resolvedHref === null) {
return ( return (
<span <span
className={cn( className={cn(
"text-primary/80 decoration-dotted underline underline-offset-2 cursor-not-allowed", "text-primary/80 decoration-dotted underline underline-offset-2 cursor-not-allowed",
className, className,
)} )}
title="未在当前 Markdown 中找到对应章节" title="未在当前 Markdown 中找到对应章节"
> >
{props.children} {props.children}
</span> </span>
); );
} }
return <ArtifactLink {...rest} className={className} href={resolvedHref ?? props.href} />; const finalHref = resolvedHref ?? props.href;
}, return (
}; <ArtifactLink
}, [content]); {...rest}
className={className}
useEffect(() => { href={finalHref}
onClick={(event) => {
if (isInPageAnchor(finalHref)) {
event.preventDefault();
scrollToInPageAnchor(event.currentTarget, finalHref);
}
rest.onClick?.(event);
}}
/>
);
},
};
}, [content]);
useEffect(() => {
if (language !== "html") { if (language !== "html") {
setHtmlPreviewUrl(undefined); setHtmlPreviewUrl(undefined);
return; return;
@@ -770,7 +789,11 @@ export function ArtifactFilePreview({
// 必须带 charset=utf-8:blob: URL 的 Content-Type 不含 charset 时,iframe 会按 // 必须带 charset=utf-8:blob: URL 的 Content-Type 不含 charset 时,iframe 会按
// 系统默认编码(中文 Windows 常为 GBK/Latin-1)解码,模型生成的 HTML 若没写 // 系统默认编码(中文 Windows 常为 GBK/Latin-1)解码,模型生成的 HTML 若没写
// <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。 // <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); const url = URL.createObjectURL(blob);
setHtmlPreviewUrl(url); setHtmlPreviewUrl(url);
@@ -783,16 +806,17 @@ export function ArtifactFilePreview({
return ( return (
<div <div
data-artifact-preview-scroll data-artifact-preview-scroll
data-markdown-anchor-root
className={cn( className={cn(
"size-full overflow-auto px-5 py-4", "size-full overflow-auto px-5 py-4",
animated && "animate-[sandbox-preview-enter_320ms_ease-out]", animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
)} )}
> >
<Streamdown <Streamdown
className="size-full" className="size-full"
{...streamdownPlugins} {...streamdownPlugins}
components={markdownComponents} components={markdownComponents}
> >
{content ?? ""} {content ?? ""}
</Streamdown> </Streamdown>
{activeCursor && ( {activeCursor && (
@@ -1,18 +1,22 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react"; import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { import {
MessageResponse, MessageResponse,
type MessageResponseProps, type MessageResponseProps,
} from "@/components/ai-elements/message"; } from "@/components/ai-elements/message";
import { streamdownPlugins } from "@/core/streamdown"; 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 { cn } from "@/lib/utils";
import { CitationLink } from "../citations/citation-link"; import { CitationLink } from "../citations/citation-link";
function isExternalUrl(href: string | undefined): boolean { function isExternalUrl(href: string | undefined): boolean {
return !!href && /^https?:\/\//.test(href); return !!href && /^https?:\/\//.test(href);
} }
@@ -22,23 +26,23 @@ function extractTextFromChildren(children: ReactNode): string {
if (Array.isArray(children)) return children.map(extractTextFromChildren).join(""); if (Array.isArray(children)) return children.map(extractTextFromChildren).join("");
return ""; return "";
} }
export type MarkdownContentProps = { export type MarkdownContentProps = {
content: string; content: string;
isLoading: boolean; isLoading: boolean;
rehypePlugins: MessageResponseProps["rehypePlugins"]; rehypePlugins: MessageResponseProps["rehypePlugins"];
className?: string; className?: string;
remarkPlugins?: MessageResponseProps["remarkPlugins"]; remarkPlugins?: MessageResponseProps["remarkPlugins"];
components?: MessageResponseProps["components"]; components?: MessageResponseProps["components"];
}; };
/** Renders markdown content. */ /** Renders markdown content. */
export function MarkdownContent({ export function MarkdownContent({
content, content,
rehypePlugins, rehypePlugins,
className, className,
remarkPlugins = streamdownPlugins.remarkPlugins, remarkPlugins = streamdownPlugins.remarkPlugins,
components: componentsFromProps, components: componentsFromProps,
}: MarkdownContentProps) { }: MarkdownContentProps) {
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]); const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
const components = useMemo(() => { const components = useMemo(() => {
@@ -61,14 +65,14 @@ export function MarkdownContent({
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => { a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (props.href?.startsWith("#reference-")) { if (props.href?.startsWith("#reference-")) {
return <CitationLink {...props} />; return <CitationLink {...props} />;
} }
if (typeof props.children === "string") { if (typeof props.children === "string") {
const match = /^citation:(.+)$/.exec(props.children); const match = /^citation:(.+)$/.exec(props.children);
if (match) { if (match) {
const [, text] = match; const [, text] = match;
return <CitationLink {...props}>{text}</CitationLink>; return <CitationLink {...props}>{text}</CitationLink>;
} }
} }
const { className, target, rel, ...rest } = props; const { className, target, rel, ...rest } = props;
const external = isExternalUrl(props.href); const external = isExternalUrl(props.href);
const resolvedHref = anchorResolver.resolveHref( const resolvedHref = anchorResolver.resolveHref(
@@ -88,33 +92,45 @@ export function MarkdownContent({
</span> </span>
); );
} }
const finalHref = resolvedHref ?? props.href;
return ( return (
<a <a
{...rest} {...rest}
href={resolvedHref ?? props.href} href={finalHref}
className={cn( className={cn(
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors", "text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
className, className,
)} )}
target={target ?? (external ? "_blank" : undefined)} target={target ?? (external ? "_blank" : undefined)}
rel={rel ?? (external ? "noopener noreferrer" : 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, ...componentsFromProps,
}; };
}, [anchorResolver, componentsFromProps]); }, [anchorResolver, componentsFromProps]);
if (!content) return null; if (!content) return null;
return ( return (
<MessageResponse <div data-markdown-anchor-root className="contents">
className={className} <MessageResponse
remarkPlugins={remarkPlugins} className={className}
rehypePlugins={rehypePlugins} remarkPlugins={remarkPlugins}
components={components} rehypePlugins={rehypePlugins}
> components={components}
{content} >
</MessageResponse> {content}
); </MessageResponse>
} </div>
);
}
+82
View File
@@ -136,8 +136,18 @@ export interface AdminUserAnalyticsResponse {
export interface AdminLeaderboardReportResponse { export interface AdminLeaderboardReportResponse {
report: string; report: string;
generated_at: 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`; const base = () => `${getBackendBaseURL()}/api/admin/users`;
async function parse<T>(res: Response, what: string): Promise<T> { async function parse<T>(res: Response, what: string): Promise<T> {
@@ -216,6 +226,7 @@ export async function getUserAnalytics(
export async function generateUserLeaderboardReport( export async function generateUserLeaderboardReport(
range: { since: string; until: string; days?: number }, range: { since: string; until: string; days?: number },
limit = 30, limit = 30,
modelName?: string,
): Promise<AdminLeaderboardReportResponse> { ): Promise<AdminLeaderboardReportResponse> {
const params = new URLSearchParams({ const params = new URLSearchParams({
days: String(range.days ?? 30), days: String(range.days ?? 30),
@@ -223,6 +234,7 @@ export async function generateUserLeaderboardReport(
until: range.until, until: range.until,
limit: String(limit), limit: String(limit),
}); });
if (modelName) params.set("model_name", modelName);
return parse( return parse(
await apiFetch(`${base()}/leaderboard/report?${params}`, { await apiFetch(`${base()}/leaderboard/report?${params}`, {
method: "POST", 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( export async function addAdminMemoryEntry(
userId: string, userId: string,
content: string, content: string,
+3 -1
View File
@@ -53,10 +53,12 @@ export function useGenerateUserLeaderboardReport() {
mutationFn: ({ mutationFn: ({
range, range,
limit, limit,
modelName,
}: { }: {
range: { since: string; until: string; days?: number }; range: { since: string; until: string; days?: number };
limit?: 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(); let title = markdown.split("\n")[0]!.trim();
if (title.startsWith("# ")) { if (title.startsWith("# ")) {
title = title.slice(2).trim(); title = title.slice(2).trim();
} }
return title; return title;
} }
return undefined; 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 { function normalizeLookupKey(value: string): string {
return markdownSlug(decodeLinkPart(value).replace(/^#+/, "")); return markdownSlug(decodeLinkPart(value).replace(/^#+/, ""));
} }
@@ -68,6 +98,45 @@ function candidateTextFromHref(href: string): string {
return decodeLinkPart(lastSegment).replace(/\.(md|markdown)$/i, ""); 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 { export function createMarkdownAnchorResolver(markdown: string): MarkdownAnchorResolver {
const headings: MarkdownHeadingAnchor[] = []; const headings: MarkdownHeadingAnchor[] = [];
const idsByLookup = new Map<string, string>(); const idsByLookup = new Map<string, string>();
@@ -25,7 +25,7 @@ import {
Users, Users,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } 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 { useNavigate } from "react-router-dom";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -40,6 +40,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area"; 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { import type {
AdminLeaderboardDimension, AdminLeaderboardDimension,
@@ -49,19 +50,22 @@ import type {
AdminLeaderboardTrendPoint, AdminLeaderboardTrendPoint,
AdminLeaderboardUser, AdminLeaderboardUser,
} from "@/core/admin/api"; } 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"; import { cn } from "@/lib/utils";
type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency"; type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency";
type RangeState = { since: string; until: string; days?: number }; 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 }> = { const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
indigo: { crimson: {
soft: "bg-[#EEF2FF] dark:bg-indigo-400/15", soft: "bg-[#FFF1F1] dark:bg-[#C1121F]/15",
text: "text-[#4F46E5] dark:text-indigo-200", text: "text-[#C1121F] dark:text-[#F2B8BB]",
bar: "bg-[#4F46E5]", bar: "bg-[#C1121F]",
border: "border-[#C7D2FE] dark:border-indigo-300/25", border: "border-[#F1D6D6] dark:border-[#462123]",
}, },
rose: { rose: {
soft: "bg-[#FFF1F2] dark:bg-rose-400/15", 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 { function toDateInput(date: Date): string {
const year = date.getFullYear(); const year = date.getFullYear();
@@ -241,7 +245,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-[#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"> <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)}>
@@ -260,21 +264,47 @@ function InsightStrip({ overview }: { overview: AdminLeaderboardOverview }) {
const activeUsers = Math.max(1, overview.active_users); const activeUsers = Math.max(1, overview.active_users);
const totalRuns = Math.max(1, overview.total_runs); const totalRuns = Math.max(1, overview.total_runs);
const items = [ const items = [
["人均使用", overview.total_runs / activeUsers, "次/活跃用户"], {
["问答密度", overview.total_questions / totalRuns, "问答/运行"], label: "人均使用",
["平均 Token", overview.total_tokens / totalRuns, "Token/运行"], value: overview.total_runs / activeUsers,
["技能覆盖", overview.total_tool_calls ? (overview.total_skill_calls / overview.total_tool_calls) * 100 : 0, "%"], unit: "次/活跃用户",
["失败率", (overview.error_runs / totalRuns) * 100, "%"], 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 ( return (
<section className="grid gap-3 md:grid-cols-5"> <section className="grid gap-3 md:grid-cols-5">
{items.map(([label, value, unit]) => ( {items.map((item) => (
<div key={label} className="rounded-lg border border-[#F1D6D6] bg-white p-3 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]"> <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 text-[#7A5A5A] dark:text-[#D8B8B8]">{label}</div> <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"> <div className="mt-1 text-xl font-semibold tabular-nums text-[#231815] dark:text-white">
{Number(value).toFixed(unit === "%" || String(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]">{unit}</span> <span className="ml-1 text-xs font-normal text-[#8B6B6B]">{item.unit}</span>
</div> </div>
<p className="mt-2 line-clamp-2 text-[11px] leading-4 text-[#8B6B6B] dark:text-[#D8B8B8]">{item.description}</p>
</div> </div>
))} ))}
</section> </section>
@@ -363,13 +393,13 @@ function PodiumCard({
}) { }) {
const styles = { const styles = {
1: "border-[#FEEFC3] bg-[#FEF7E0] text-[#B06000] dark:border-[#F4B400]/40 dark:bg-[#F4B400]/10 dark:text-[#FDD663]", 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]", 3: "border-[#FAD2CF] bg-[#FCE8E6] text-[#DB4437] dark:border-[#DB4437]/40 dark:bg-[#DB4437]/10 dark:text-[#F28B82]",
}[rank]; }[rank];
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-[#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-[#5F6368]">第 {rank} 名</div>
<div className="text-sm text-[#80868B]">暂无数据</div> <div className="text-sm text-[#80868B]">暂无数据</div>
</div> </div>
@@ -381,8 +411,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-[#06213F]", "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-[#4285F4] ring-2 ring-[#D2E3FC] dark:border-[#8AB4F8] dark:ring-[#4285F4]/20" : "border-[#DADCE0] dark:border-[#213D5E]", 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"> <div className="flex items-start justify-between gap-3">
@@ -393,11 +423,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-[#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)} {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-[#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"> <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>
@@ -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">; type TrendMetric = keyof Pick<AdminLeaderboardTrendPoint, "runs" | "questions" | "users" | "tokens">;
const [metric, setMetric] = useState<"all" | TrendMetric>("all"); const [metric, setMetric] = useState<"all" | TrendMetric>("all");
const chartId = useId().replace(/:/g, ""); const chartId = useId().replace(/:/g, "");
@@ -418,10 +448,10 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
const visible = points.slice(-32); const visible = points.slice(-32);
const series = [ const series = [
{ key: "runs", label: "运行", color: "#4F46E5", soft: "bg-[#EEF2FF] text-[#4F46E5]" }, { key: "runs", label: "运行", color: "#C1121F", soft: "bg-[#FFF1F1] text-[#C1121F]", description: "每天发起的运行次数" },
{ key: "questions", label: "问答", color: "#0F766E", soft: "bg-[#ECFDF5] text-[#0F766E]" }, { key: "questions", label: "问答", color: "#0F766E", soft: "bg-[#ECFDF5] text-[#0F766E]", description: "每天产生的有效提问次数" },
{ key: "users", label: "活跃用户", color: "#B45309", soft: "bg-[#FFFBEB] text-[#B45309]" }, { key: "users", label: "活跃", color: "#B45309", soft: "bg-[#FFFBEB] text-[#B45309]", description: "每天有行为的用户数量" },
{ key: "tokens", label: "Token", color: "#E11D48", soft: "bg-[#FFF1F2] text-[#E11D48]" }, { key: "tokens", label: "Token", color: "#7C3AED", soft: "bg-[#F5F3FF] text-[#7C3AED]", 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];
@@ -452,9 +482,11 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
visible[0], visible[0],
); );
const displayedSeries = metric === "all" ? series : [activeSeries]; 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) => ( 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")}> <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]" : "h-72")} role="img" aria-label="使用趋势折线图"> <svg viewBox={`0 0 ${width} ${height}`} className={cn("w-full", large ? "h-[520px]" : compact ? "h-56" : "h-72")} role="img" aria-label="使用趋势折线图">
<defs> <defs>
<linearGradient id={`trend-area-${chartId}-${activeMetric}`} x1="0" x2="0" y1="0" y2="1"> <linearGradient id={`trend-area-${chartId}-${activeMetric}`} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={activeSeries.color} stopOpacity="0.22" /> <stop offset="0%" stopColor={activeSeries.color} stopOpacity="0.22" />
@@ -471,7 +503,7 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
y1={y} y1={y}
y2={y} y2={y}
stroke="currentColor" stroke="currentColor"
className="text-[#E5E7EB] dark:text-[#17395F]" className="text-[#E5E7EB] dark:text-[#3F3F46]"
strokeWidth="1" strokeWidth="1"
/> />
<text x={12} y={y + 4} className="fill-[#9CA3AF] text-[11px]"> <text x={12} y={y + 4} className="fill-[#9CA3AF] text-[11px]">
@@ -526,8 +558,61 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
</div> </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 ( return (
<div className="space-y-4"> <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="flex flex-wrap items-center justify-between gap-3">
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-5"> <div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
<button <button
@@ -583,13 +668,13 @@ function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
</Dialog> </Dialog>
</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-[#E5E7EB] bg-white p-4 dark:border-[#17395F] dark:bg-[#071F3B]"> <div className="rounded-xl border border-[#F1D6D6] bg-white p-4 dark:border-[#462123] dark:bg-[#230D0F]">
<div className="text-xs text-[#6B7280] dark:text-slate-400">最新 {metric === "all" ? "运行" : activeSeries.label}</div> <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="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)} 较上一日 {delta >= 0 ? "+" : ""}{fmtNumber(delta)}
</div> </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))} 峰值 {peak.date.slice(5)} / {fmtNumber(Number(peak[activeMetric] ?? 0))}
</div> </div>
</div> </div>
@@ -616,7 +701,7 @@ function UserTable({
} }
return ( 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) => { {users.map((user, index) => {
const score = userScore(user, tab); const score = userScore(user, tab);
return ( return (
@@ -625,11 +710,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-[#EEF0F1] px-4 py-3 text-left last:border-b-0 hover:bg-[#F8FAFD] dark:border-[#17395F] dark:hover:bg-[#0B2A50]", "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-[#E8F0FE] dark:bg-[#4285F4]/10", 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} {index + 1}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
@@ -642,9 +727,9 @@ function UserTable({
</div> </div>
</div> </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 <div
className="h-2 rounded-full bg-[#4285F4]" className="h-2 rounded-full bg-[#C1121F]"
style={{ width: `${Math.max(4, (score / max) * 100)}%` }} style={{ width: `${Math.max(4, (score / max) * 100)}%` }}
/> />
</div> </div>
@@ -678,7 +763,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-[#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="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" />
@@ -698,7 +783,7 @@ function SkillRanking({
{fmtNumber(item.user_count)} 人 {fmtNumber(item.user_count)} 人
</span> </span>
</div> </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 <div
className="h-2 rounded-full" className="h-2 rounded-full"
style={{ style={{
@@ -736,7 +821,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-[#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="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" />
@@ -756,7 +841,7 @@ function DimensionRanking({
{fmtNumber(item.user_count)} 人 {fmtNumber(item.user_count)} 人
</span> </span>
</div> </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 <div
className="h-2 rounded-full" className="h-2 rounded-full"
style={{ style={{
@@ -790,8 +875,8 @@ function QuestionPanel({
showUser?: boolean; showUser?: boolean;
}) { }) {
return ( 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]"> <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-[#EEF0F1] px-4 py-3 dark:border-[#17395F]"> <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"> <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>
@@ -799,7 +884,7 @@ 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-[#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.length === 0 && <div className="py-12 text-center text-sm text-[#5F6368]">暂无问题记录</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">
@@ -881,16 +966,13 @@ function UserDetailPanel({
</div> </div>
</section> </section>
<Tabs defaultValue="skills" className="space-y-3"> <Tabs defaultValue="questions" className="space-y-3">
<TabsList className="mb-3"> <TabsList className="mb-3">
<TabsTrigger value="skills">技能</TabsTrigger>
<TabsTrigger value="questions">提问</TabsTrigger> <TabsTrigger value="questions">提问</TabsTrigger>
<TabsTrigger value="trend">趋势</TabsTrigger> <TabsTrigger value="trend">趋势</TabsTrigger>
<TabsTrigger value="skills">技能</TabsTrigger>
<TabsTrigger value="dimensions">维度</TabsTrigger> <TabsTrigger value="dimensions">维度</TabsTrigger>
</TabsList> </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]"> <TabsContent value="questions" className="min-h-[420px]">
<QuestionPanel title="最近提问" items={data.recent_questions} showUser={false} /> <QuestionPanel title="最近提问" items={data.recent_questions} showUser={false} />
</TabsContent> </TabsContent>
@@ -899,7 +981,10 @@ function UserDetailPanel({
<TrendingUp className="size-4 text-[#C1121F]" /> <TrendingUp className="size-4 text-[#C1121F]" />
个人使用趋势 个人使用趋势
</div> </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>
<TabsContent value="dimensions" className="min-h-[420px]"> <TabsContent value="dimensions" className="min-h-[420px]">
<div className="grid gap-3"> <div className="grid gap-3">
@@ -920,14 +1005,23 @@ export default function AdminUserLeaderboardPage() {
const [tab, setTab] = useState<UserRankTab>("activity"); const [tab, setTab] = useState<UserRankTab>("activity");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [groupRules, setGroupRules] = useState("zero,two"); const [groupRules, setGroupRules] = useState("zero,two");
const [showGroupStats, setShowGroupStats] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null); const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const apiRange = useMemo( const apiRange = useMemo(
() => rangeForApi(range), () => rangeForApi(range),
[range], [range],
); );
const { data, isLoading, error } = useUserLeaderboard(apiRange, 100); const { data, isLoading, error } = useUserLeaderboard(apiRange, 100);
const reportMutation = useGenerateUserLeaderboardReport();
const [reportOpen, setReportOpen] = useState(false); 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 rankedUsers = useMemo(() => {
const base = data?.users_by_activity ?? []; const base = data?.users_by_activity ?? [];
@@ -964,6 +1058,92 @@ export default function AdminUserLeaderboardPage() {
setRange(defaultRange(days)); 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 ( 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-[#19090A] dark:text-white">
<main className="flex min-w-0 flex-1 flex-col"> <main className="flex min-w-0 flex-1 flex-col">
@@ -1010,21 +1190,11 @@ export default function AdminUserLeaderboardPage() {
<FileDown className="mr-1 size-4" /> <FileDown className="mr-1 size-4" />
导出 Excel 导出 Excel
</Button> </Button>
<Dialog open={reportOpen} onOpenChange={setReportOpen}> <Dialog open={reportOpen} onOpenChange={handleReportOpenChange}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button <Button variant="outline" size="sm" disabled={!data} className="h-9">
variant="outline" <Sparkles className="mr-1 size-4" />
size="sm" AI 报告
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> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-[min(920px,calc(100vw-2rem))]"> <DialogContent className="max-w-[min(920px,calc(100vw-2rem))]">
@@ -1034,32 +1204,61 @@ export default function AdminUserLeaderboardPage() {
基于当前时间段的问答、用户、技能、模型和趋势统计生成运营分析。 基于当前时间段的问答、用户、技能、模型和趋势统计生成运营分析。
</DialogDescription> </DialogDescription>
</DialogHeader> </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]"> <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]"> <div className="flex items-center justify-center gap-2 py-12 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
正在调用大模型生成报告 正在调用大模型生成报告,内容会流式显示
</div> </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]"> <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> </div>
)} )}
{reportMutation.data?.report && ( {reportText && (
<pre className="whitespace-pre-wrap font-sans text-sm leading-7 text-[#231815] dark:text-white"> <pre className="whitespace-pre-wrap font-sans text-sm leading-7 text-[#231815] dark:text-white">
{reportMutation.data.report} {reportText}
</pre> </pre>
)} )}
</ScrollArea> </ScrollArea>
{reportMutation.data?.report && ( {reportText && (
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
variant="outline" variant="outline"
onClick={() => onClick={() =>
downloadText( downloadText(
`user-leaderboard-ai-report-${new Date().toISOString().slice(0, 10)}.md`, `user-leaderboard-ai-report-${new Date().toISOString().slice(0, 10)}.md`,
reportMutation.data.report, reportText,
"text/markdown;charset=utf-8", "text/markdown;charset=utf-8",
) )
} }
@@ -1079,7 +1278,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-[#0B2A50]" className="h-9 w-36 bg-white dark:bg-[#19090A]"
/> />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -1090,7 +1289,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-[#0B2A50]" className="h-9 w-36 bg-white dark:bg-[#19090A]"
/> />
</div> </div>
<Button variant="outline" size="sm" onClick={() => setQuickRange(7)}> <Button variant="outline" size="sm" onClick={() => setQuickRange(7)}>
@@ -1124,21 +1323,29 @@ export default function AdminUserLeaderboardPage() {
{!isLoading && data && overview && ( {!isLoading && data && overview && (
<> <>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4"> <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={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={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)} 次`} /> <StatCard icon={Sparkles} accent="rose" label="技能调用" value={fmtNumber(overview.total_skill_calls)} note={`工具总调用 ${fmtNumber(overview.total_tool_calls)} 次`} />
</div> </div>
<InsightStrip overview={overview} /> <InsightStrip overview={overview} />
<GroupComparison <div className="flex justify-end">
users={rankedUsers} <Button variant="ghost" size="sm" onClick={() => setShowGroupStats((value) => !value)}>
rulesText={groupRules} <Users className="mr-1 size-4" />
onRulesChange={setGroupRules} {showGroupStats ? "隐藏分组统计" : "显示分组统计"}
onSelectUser={(user) => setSelectedUserId(user.user_id)} </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 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>
@@ -1177,7 +1384,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-[#0B2A50]" className="h-9 w-64 bg-white pl-8 dark:bg-[#19090A]"
/> />
</div> </div>
</div> </div>
@@ -1189,8 +1396,8 @@ export default function AdminUserLeaderboardPage() {
/> />
</section> </section>
<section className="overflow-hidden rounded-lg border border-[#DADCE0] bg-white shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]"> <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-[#EEF0F1] px-4 py-3 dark:border-[#17395F]"> <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" /> <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>
@@ -1206,7 +1413,7 @@ export default function AdminUserLeaderboardPage() {
</section> </section>
</div> </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="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" />
@@ -39,6 +39,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { HtmlPreviewFrame } from "@/components/html-preview-frame";
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx"; import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { reasoningPlugins } from "@/core/streamdown"; import { reasoningPlugins } from "@/core/streamdown";
@@ -953,10 +954,8 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
)} )}
{activeTabMeta?.kind === "html" ? ( {activeTabMeta?.kind === "html" ? (
htmlContent ? ( htmlContent ? (
<iframe <HtmlPreviewFrame
title="HTML 预览" html={htmlContent}
sandbox="allow-same-origin"
srcDoc={htmlContent}
className="min-h-[60vh] w-full flex-1 rounded-md border bg-white" className="min-h-[60vh] w-full flex-1 rounded-md border bg-white"
/> />
) : ( ) : (
@@ -18,6 +18,7 @@ import {
WorkspaceHeader, WorkspaceHeader,
} from "@/components/workspace/workspace-container"; } from "@/components/workspace/workspace-container";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { HtmlPreviewFrame } from "@/components/html-preview-frame";
import { import {
downloadScheduledTaskRun, downloadScheduledTaskRun,
downloadScheduledTaskRunFile, downloadScheduledTaskRunFile,
@@ -100,22 +101,6 @@ function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
return <pre className="whitespace-pre-wrap break-words text-sm">{file.content}</pre>; 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 }; type EditState = { draft: string; saving: boolean };
/** Flatten a LangChain-style message content (string | parts | nested) to text. */ /** 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 { Download, FileText, Loader2, X } from "lucide-react";
import { apiFetch } from "@/core/api/fetch-client"; 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"; import { buildArtifactDownloadUrl } from "@/roundtable-planning/api/multi-agent";
interface ArtifactPreviewModalProps { interface ArtifactPreviewModalProps {
@@ -74,13 +78,20 @@ function createMarkdownComponents(content: string) {
</span> </span>
); );
} }
const finalHref = resolvedHref ?? href;
const isExternal = typeof href === "string" && /^https?:\/\//i.test(href); const isExternal = typeof href === "string" && /^https?:\/\//i.test(href);
return ( return (
<a <a
href={resolvedHref ?? href} href={finalHref}
target={isExternal ? "_blank" : undefined} target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener noreferrer" : undefined} rel={isExternal ? "noopener noreferrer" : undefined}
className="text-primary underline hover:opacity-80" className="text-primary underline hover:opacity-80"
onClick={(event) => {
if (isInPageAnchor(finalHref)) {
event.preventDefault();
scrollToInPageAnchor(event.currentTarget, finalHref);
}
}}
> >
{children} {children}
</a> </a>
@@ -203,7 +214,10 @@ export const ArtifactPreviewModal: React.FC<ArtifactPreviewModalProps> = ({
</div> </div>
{/* Body */} {/* 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 ? ( {loading ? (
<div className="flex items-center justify-center gap-2 py-12 text-slate-400"> <div className="flex items-center justify-center gap-2 py-12 text-slate-400">
<Loader2 className="w-4 h-4 animate-spin text-primary" /> <Loader2 className="w-4 h-4 animate-spin text-primary" />
@@ -16,22 +16,27 @@ from __future__ import annotations
import json import json
import logging import logging
import re import re
from collections.abc import AsyncIterator
from difflib import SequenceMatcher from difflib import SequenceMatcher
from hashlib import sha256
from datetime import datetime, timedelta 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 pydantic import BaseModel, Field
from sqlalchemy import and_, case, desc, func, or_, select from sqlalchemy import and_, case, desc, func, or_, select
from langchain_core.messages import HumanMessage, SystemMessage from langchain_core.messages import HumanMessage, SystemMessage
from app.gateway.deps import ( from app.gateway.deps import (
get_config,
get_agent_store, get_agent_store,
get_checkpointer, get_checkpointer,
get_current_user_from_request, get_current_user_from_request,
get_thread_store, get_thread_store,
) )
from deerflow.agents.memory import BuiltinFileProvider from deerflow.agents.memory import BuiltinFileProvider
from deerflow.config.app_config import AppConfig
from deerflow.config.paths import get_paths from deerflow.config.paths import get_paths
from deerflow.config.system_settings import load_system_settings from deerflow.config.system_settings import load_system_settings
from deerflow.models import create_chat_model from deerflow.models import create_chat_model
@@ -165,6 +170,8 @@ class AdminUserAnalyticsResponse(BaseModel):
class AdminLeaderboardReportResponse(BaseModel): class AdminLeaderboardReportResponse(BaseModel):
report: str report: str
generated_at: str generated_at: str
cached: bool = False
model_name: str | None = None
def _now() -> datetime: def _now() -> datetime:
@@ -242,6 +249,108 @@ def _message_content_to_text(content) -> str:
return str(content or "") 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): def _run_time_filter(since: datetime, until: datetime):
return and_(RunRow.created_at >= since, RunRow.created_at <= until) return and_(RunRow.created_at >= since, RunRow.created_at <= until)
@@ -360,7 +469,7 @@ async def _query_user_leaderboard(
select( select(
ToolCallMetricRow.user_id.label("user_id"), ToolCallMetricRow.user_id.label("user_id"),
func.count(ToolCallMetricRow.id).label("tool_call_count"), 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)) .where(_tool_time_filter(since, until))
.group_by(ToolCallMetricRow.user_id) .group_by(ToolCallMetricRow.user_id)
@@ -433,11 +542,7 @@ async def _query_call_rankings(
include_admins: bool = True, include_admins: bool = True,
) -> list[AdminLeaderboardSkill]: ) -> list[AdminLeaderboardSkill]:
exclude_user_ids = exclude_user_ids or set() exclude_user_ids = exclude_user_ids or set()
name_col = ( name_col = ToolCallMetricRow.skill_name if field == "skill" else ToolCallMetricRow.tool_name
func.coalesce(func.nullif(ToolCallMetricRow.skill_name, ""), func.nullif(ToolCallMetricRow.tool_name, ""))
if field == "skill"
else ToolCallMetricRow.tool_name
)
call_count = func.count(ToolCallMetricRow.id).label("call_count") call_count = func.count(ToolCallMetricRow.id).label("call_count")
last_used_at = func.max(ToolCallMetricRow.created_at).label("last_used_at") last_used_at = func.max(ToolCallMetricRow.created_at).label("last_used_at")
stmt = ( stmt = (
@@ -822,7 +927,7 @@ async def _query_overview(
tool_row = (await session.execute( tool_row = (await session.execute(
select( select(
func.count(ToolCallMetricRow.id), 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) ).where(*tool_where)
)).one() )).one()
@@ -912,22 +1017,13 @@ async def get_user_leaderboard(
) )
@router.post("/leaderboard/report", response_model=AdminLeaderboardReportResponse) async def _build_leaderboard_report_prompt(
async def generate_user_leaderboard_report( session_factory,
request: Request, settings,
days: int = Query(default=30, ge=1, le=365), since_dt: datetime,
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"), until_dt: datetime,
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"), limit: int,
limit: int = Query(default=30, ge=10, le=100), ) -> str:
) -> 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)
excluded = {uid for uid in settings.exclude_user_ids if uid} excluded = {uid for uid in settings.exclude_user_ids if uid}
common = { common = {
"exclude_user_ids": excluded, "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) 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) 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) 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) 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) 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) 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_skills": [s.model_dump() for s in skills[:12]],
"top_tools": [t.model_dump() for t in tools[:12]], "top_tools": [t.model_dump() for t in tools[:12]],
"top_models": [m.model_dump() for m in models[:12]], "top_models": [m.model_dump() for m in models[:12]],
"frequent_questions": [q.model_dump() for q in frequent_questions[:20]], "agents": [a.model_dump() for a in agents[:12]],
"recent_questions": [q.model_dump() for q in recent_questions[:30]], "run_statuses": [s.model_dump() for s in run_statuses[:12]],
"trends": [t.model_dump() for t in trends[-14:]], "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 消耗最高;" "1) 谁使用次数最多、问答最多、Token 消耗最高;"
@@ -976,12 +1078,46 @@ async def generate_user_leaderboard_report(
"4) 从问题重复、失败率、追问密度等信号推断用户是否满意系统回答,注明这是推断;" "4) 从问题重复、失败率、追问密度等信号推断用户是否满意系统回答,注明这是推断;"
"5) 哪些技能/工具/模型最常用,可能说明什么;" "5) 哪些技能/工具/模型最常用,可能说明什么;"
"6) 给管理员 3-5 条可执行建议。" "6) 给管理员 3-5 条可执行建议。"
"输出结构:摘要、关键发现、用户与分组观察、问题主题、满意度推断、建议。" "输出结构:摘要、关键发现、用户观察、问题主题、满意度推断、建议。"
"\n\n统计数据如下:\n" "\n\n统计数据如下:\n"
f"{json.dumps(context, ensure_ascii=False, default=str)}" 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: 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( response = await model.ainvoke(
[ [
SystemMessage(content="你是企业 AI 系统的运营分析师,擅长从用户行为、问题文本和调用指标中生成管理报告。"), SystemMessage(content="你是企业 AI 系统的运营分析师,擅长从用户行为、问题文本和调用指标中生成管理报告。"),
@@ -993,9 +1129,85 @@ async def generate_user_leaderboard_report(
logger.exception("Failed to generate leaderboard report") logger.exception("Failed to generate leaderboard report")
raise HTTPException(status_code=503, detail=f"Report generation failed: {exc}") from exc 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( return AdminLeaderboardReportResponse(
report=_message_content_to_text(getattr(response, "content", response)).strip(), report=report,
generated_at=_iso(_now()), 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"},
) )