diff --git a/frontend-web/src/components/html-preview-frame.tsx b/frontend-web/src/components/html-preview-frame.tsx
new file mode 100644
index 00000000..2030e398
--- /dev/null
+++ b/frontend-web/src/components/html-preview-frame.tsx
@@ -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 (``)
+ * 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 ``.
+ */
+export function HtmlPreviewFrame({
+ html,
+ className = "h-[70vh] w-full rounded-md border bg-white",
+}: {
+ html: string;
+ className?: string;
+}) {
+ const [url, setUrl] = useState();
+
+ 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 (
+
+ );
+}
diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx
index ef9416f2..0628cf72 100644
--- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx
+++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx
@@ -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();
- 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) => (
-
- {children}
-
- );
- };
-
- return {
- h1: heading(1),
- h2: heading(2),
- h3: heading(3),
- h4: heading(4),
- h5: heading(5),
- h6: heading(6),
- a: (props: AnchorHTMLAttributes) => {
- if (typeof props.children === "string" && /^citation:(.+)$/.test(props.children)) {
- return ;
- }
- const { className, ...rest } = props;
- const resolvedHref = anchorResolver.resolveHref(
- props.href,
- extractTextFromChildren(props.children),
- );
- if (resolvedHref === null) {
- return (
-
- {props.children}
-
- );
- }
- return ;
- },
- };
- }, [content]);
-
- useEffect(() => {
+}) {
+ const [htmlPreviewUrl, setHtmlPreviewUrl] = useState();
+ 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) => (
+
+ {children}
+
+ );
+ };
+
+ return {
+ h1: heading(1),
+ h2: heading(2),
+ h3: heading(3),
+ h4: heading(4),
+ h5: heading(5),
+ h6: heading(6),
+ a: (props: AnchorHTMLAttributes) => {
+ if (typeof props.children === "string" && /^citation:(.+)$/.test(props.children)) {
+ return ;
+ }
+ const { className, ...rest } = props;
+ const resolvedHref = anchorResolver.resolveHref(
+ props.href,
+ extractTextFromChildren(props.children),
+ );
+ if (resolvedHref === null) {
+ return (
+
+ {props.children}
+
+ );
+ }
+ const finalHref = resolvedHref ?? props.href;
+ return (
+ {
+ 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 若没写
// 就会中文乱码。与下方下载/新窗口打开的 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 (
-
+
{content ?? ""}
{activeCursor && (
diff --git a/frontend-web/src/components/workspace/messages/markdown-content.tsx b/frontend-web/src/components/workspace/messages/markdown-content.tsx
index 7e32483a..e7b6380e 100644
--- a/frontend-web/src/components/workspace/messages/markdown-content.tsx
+++ b/frontend-web/src/components/workspace/messages/markdown-content.tsx
@@ -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) => {
if (props.href?.startsWith("#reference-")) {
return ;
- }
- if (typeof props.children === "string") {
- const match = /^citation:(.+)$/.exec(props.children);
- if (match) {
- const [, text] = match;
- return {text};
- }
- }
+ }
+ if (typeof props.children === "string") {
+ const match = /^citation:(.+)$/.exec(props.children);
+ if (match) {
+ const [, text] = match;
+ return {text};
+ }
+ }
const { className, target, rel, ...rest } = props;
const external = isExternalUrl(props.href);
const resolvedHref = anchorResolver.resolveHref(
@@ -88,33 +92,45 @@ export function MarkdownContent({
);
}
+ const finalHref = resolvedHref ?? props.href;
return (
- );
+ )}
+ 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 (
-
- {content}
-
- );
-}
+
+ if (!content) return null;
+
+ return (
+
+
+ {content}
+
+
+ );
+}
diff --git a/frontend-web/src/core/admin/api.ts b/frontend-web/src/core/admin/api.ts
index 725a02b9..d56004d9 100644
--- a/frontend-web/src/core/admin/api.ts
+++ b/frontend-web/src/core/admin/api.ts
@@ -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(res: Response, what: string): Promise {
@@ -216,6 +226,7 @@ export async function getUserAnalytics(
export async function generateUserLeaderboardReport(
range: { since: string; until: string; days?: number },
limit = 30,
+ modelName?: string,
): Promise {
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,
diff --git a/frontend-web/src/core/admin/hooks.ts b/frontend-web/src/core/admin/hooks.ts
index f08aec27..9d19457b 100644
--- a/frontend-web/src/core/admin/hooks.ts
+++ b/frontend-web/src/core/admin/hooks.ts
@@ -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),
});
}
diff --git a/frontend-web/src/core/utils/markdown.ts b/frontend-web/src/core/utils/markdown.ts
index d6dca9a9..96060708 100644
--- a/frontend-web/src/core/utils/markdown.ts
+++ b/frontend-web/src/core/utils/markdown.ts
@@ -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 `` 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("[data-markdown-anchor-root]");
+ const selector = `[id="${escapeAttrValue(id)}"]`;
+ const target =
+ (scope?.querySelector(selector) ?? null) ??
+ document.getElementById(id) ??
+ document.querySelector(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 (``) 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 = ``;
+ if (/<\/body>/i.test(html)) {
+ return html.replace(/<\/body>/i, `${script}