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