enhance admin leaderboard and previews
This commit is contained in:
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user