enhance admin leaderboard and previews

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

No files matched your search

@@ -0,0 +1,45 @@
import { useEffect, useState } from "react";
import { injectInPageAnchorHandler } from "@/core/utils/markdown";
/**
* Sandboxed preview of a generated HTML document.
*
* Renders via a `blob:` URL rather than `srcDoc`. A srcDoc document's base URL
* is `about:srcdoc`, so clicking an in-page anchor (`<a href="#section">`)
* navigates the frame to `about:blank#section` and BLANKS it. A `blob:` URL is
* a real document URL, so fragment links scroll within the frame as expected —
* all without enabling scripts (the sandbox stays script-free for security; the
* server already sanitizes script vectors and this is defense-in-depth).
*
* The `charset=utf-8` on the blob type is required: a blob: URL whose
* Content-Type omits the charset is decoded with the OS default (GBK/Latin-1 on
* Chinese Windows), garbling CJK text when the HTML lacks a `<meta charset>`.
*/
export function HtmlPreviewFrame({
html,
className = "h-[70vh] w-full rounded-md border bg-white",
}: {
html: string;
className?: string;
}) {
const [url, setUrl] = useState<string>();
useEffect(() => {
const blob = new Blob([injectInPageAnchorHandler(html ?? "")], {
type: "text/html;charset=utf-8",
});
const objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}, [html]);
return (
<iframe
title="HTML 预览"
sandbox="allow-scripts allow-forms"
src={url}
className={className}
/>
);
}
@@ -1,7 +1,7 @@
"use client";
import {
ArrowDownIcon,
import {
ArrowDownIcon,
Code2Icon,
CopyIcon,
DownloadIcon,
@@ -12,11 +12,11 @@ import {
SaveIcon,
SquareArrowOutUpRightIcon,
XIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { toast } from "sonner";
import { Streamdown } from "streamdown";
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { toast } from "sonner";
import { Streamdown } from "streamdown";
import {
Artifact,
@@ -48,10 +48,15 @@ import { urlOfArtifact } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown";
import { checkCodeFile, getFileName } from "@/core/utils/files";
import { createMarkdownAnchorResolver } from "@/core/utils/markdown";
import { env } from "@/env";
import { streamdownPlugins } from "@/core/streamdown";
import { checkCodeFile, getFileName } from "@/core/utils/files";
import {
createMarkdownAnchorResolver,
injectInPageAnchorHandler,
isInPageAnchor,
scrollToInPageAnchor,
} from "@/core/utils/markdown";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
@@ -61,15 +66,15 @@ import { Tooltip } from "../tooltip";
import { useArtifacts } from "./context";
const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll";
function extractTextFromChildren(children: ReactNode): string {
if (typeof children === "string" || typeof children === "number") return String(children);
if (Array.isArray(children)) return children.map(extractTextFromChildren).join("");
return "";
}
function findScrollableArtifactElement(root: HTMLElement | null) {
const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll";
function extractTextFromChildren(children: ReactNode): string {
if (typeof children === "string" || typeof children === "number") return String(children);
if (Array.isArray(children)) return children.map(extractTextFromChildren).join("");
return "";
}
function findScrollableArtifactElement(root: HTMLElement | null) {
if (!root) {
return null;
}
@@ -714,54 +719,68 @@ export function ArtifactFilePreview({
language: string;
animated?: boolean;
activeCursor?: boolean;
}) {
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
const markdownComponents = useMemo(() => {
const anchorResolver = createMarkdownAnchorResolver(content ?? "");
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
const Tag = `h${level}` as const;
return ({ children, id, ...props }: HTMLAttributes<HTMLHeadingElement>) => (
<Tag id={id ?? anchorResolver.getHeadingId(level, extractTextFromChildren(children))} {...props}>
{children}
</Tag>
);
};
return {
h1: heading(1),
h2: heading(2),
h3: heading(3),
h4: heading(4),
h5: heading(5),
h6: heading(6),
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (typeof props.children === "string" && /^citation:(.+)$/.test(props.children)) {
return <ArtifactLink {...props} />;
}
const { className, ...rest } = props;
const resolvedHref = anchorResolver.resolveHref(
props.href,
extractTextFromChildren(props.children),
);
if (resolvedHref === null) {
return (
<span
className={cn(
"text-primary/80 decoration-dotted underline underline-offset-2 cursor-not-allowed",
className,
)}
title="未在当前 Markdown 中找到对应章节"
>
{props.children}
</span>
);
}
return <ArtifactLink {...rest} className={className} href={resolvedHref ?? props.href} />;
},
};
}, [content]);
useEffect(() => {
}) {
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
const markdownComponents = useMemo(() => {
const anchorResolver = createMarkdownAnchorResolver(content ?? "");
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
const Tag = `h${level}` as const;
return ({ children, id, ...props }: HTMLAttributes<HTMLHeadingElement>) => (
<Tag id={id ?? anchorResolver.getHeadingId(level, extractTextFromChildren(children))} {...props}>
{children}
</Tag>
);
};
return {
h1: heading(1),
h2: heading(2),
h3: heading(3),
h4: heading(4),
h5: heading(5),
h6: heading(6),
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (typeof props.children === "string" && /^citation:(.+)$/.test(props.children)) {
return <ArtifactLink {...props} />;
}
const { className, ...rest } = props;
const resolvedHref = anchorResolver.resolveHref(
props.href,
extractTextFromChildren(props.children),
);
if (resolvedHref === null) {
return (
<span
className={cn(
"text-primary/80 decoration-dotted underline underline-offset-2 cursor-not-allowed",
className,
)}
title="未在当前 Markdown 中找到对应章节"
>
{props.children}
</span>
);
}
const finalHref = resolvedHref ?? props.href;
return (
<ArtifactLink
{...rest}
className={className}
href={finalHref}
onClick={(event) => {
if (isInPageAnchor(finalHref)) {
event.preventDefault();
scrollToInPageAnchor(event.currentTarget, finalHref);
}
rest.onClick?.(event);
}}
/>
);
},
};
}, [content]);
useEffect(() => {
if (language !== "html") {
setHtmlPreviewUrl(undefined);
return;
@@ -770,7 +789,11 @@ export function ArtifactFilePreview({
// 必须带 charset=utf-8:blob: URL 的 Content-Type 不含 charset 时,iframe 会按
// 系统默认编码(中文 Windows 常为 GBK/Latin-1)解码,模型生成的 HTML 若没写
// <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。
const blob = new Blob([content ?? ""], { type: "text/html;charset=utf-8" });
// Intercept in-page anchor clicks so they scroll within the iframe instead
// of navigating the sandboxed blob: document (which blanks the frame).
const blob = new Blob([injectInPageAnchorHandler(content ?? "")], {
type: "text/html;charset=utf-8",
});
const url = URL.createObjectURL(blob);
setHtmlPreviewUrl(url);
@@ -783,16 +806,17 @@ export function ArtifactFilePreview({
return (
<div
data-artifact-preview-scroll
data-markdown-anchor-root
className={cn(
"size-full overflow-auto px-5 py-4",
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
)}
>
<Streamdown
className="size-full"
{...streamdownPlugins}
components={markdownComponents}
>
<Streamdown
className="size-full"
{...streamdownPlugins}
components={markdownComponents}
>
{content ?? ""}
</Streamdown>
{activeCursor && (
@@ -1,18 +1,22 @@
"use client";
"use client";
import { useMemo } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import {
MessageResponse,
type MessageResponseProps,
} from "@/components/ai-elements/message";
import {
MessageResponse,
type MessageResponseProps,
} from "@/components/ai-elements/message";
import { streamdownPlugins } from "@/core/streamdown";
import { createMarkdownAnchorResolver } from "@/core/utils/markdown";
import {
createMarkdownAnchorResolver,
isInPageAnchor,
scrollToInPageAnchor,
} from "@/core/utils/markdown";
import { cn } from "@/lib/utils";
import { CitationLink } from "../citations/citation-link";
import { CitationLink } from "../citations/citation-link";
function isExternalUrl(href: string | undefined): boolean {
return !!href && /^https?:\/\//.test(href);
}
@@ -22,23 +26,23 @@ function extractTextFromChildren(children: ReactNode): string {
if (Array.isArray(children)) return children.map(extractTextFromChildren).join("");
return "";
}
export type MarkdownContentProps = {
content: string;
isLoading: boolean;
rehypePlugins: MessageResponseProps["rehypePlugins"];
className?: string;
remarkPlugins?: MessageResponseProps["remarkPlugins"];
components?: MessageResponseProps["components"];
};
/** Renders markdown content. */
export function MarkdownContent({
content,
rehypePlugins,
className,
remarkPlugins = streamdownPlugins.remarkPlugins,
components: componentsFromProps,
export type MarkdownContentProps = {
content: string;
isLoading: boolean;
rehypePlugins: MessageResponseProps["rehypePlugins"];
className?: string;
remarkPlugins?: MessageResponseProps["remarkPlugins"];
components?: MessageResponseProps["components"];
};
/** Renders markdown content. */
export function MarkdownContent({
content,
rehypePlugins,
className,
remarkPlugins = streamdownPlugins.remarkPlugins,
components: componentsFromProps,
}: MarkdownContentProps) {
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
const components = useMemo(() => {
@@ -61,14 +65,14 @@ export function MarkdownContent({
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (props.href?.startsWith("#reference-")) {
return <CitationLink {...props} />;
}
if (typeof props.children === "string") {
const match = /^citation:(.+)$/.exec(props.children);
if (match) {
const [, text] = match;
return <CitationLink {...props}>{text}</CitationLink>;
}
}
}
if (typeof props.children === "string") {
const match = /^citation:(.+)$/.exec(props.children);
if (match) {
const [, text] = match;
return <CitationLink {...props}>{text}</CitationLink>;
}
}
const { className, target, rel, ...rest } = props;
const external = isExternalUrl(props.href);
const resolvedHref = anchorResolver.resolveHref(
@@ -88,33 +92,45 @@ export function MarkdownContent({
</span>
);
}
const finalHref = resolvedHref ?? props.href;
return (
<a
{...rest}
href={resolvedHref ?? props.href}
href={finalHref}
className={cn(
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
className,
)}
target={target ?? (external ? "_blank" : undefined)}
rel={rel ?? (external ? "noopener noreferrer" : undefined)}
/>
);
)}
target={target ?? (external ? "_blank" : undefined)}
rel={rel ?? (external ? "noopener noreferrer" : undefined)}
onClick={(event) => {
// HashRouter would turn a `#section` click into a route change → 404.
// Scroll to the heading ourselves instead.
if (isInPageAnchor(finalHref)) {
event.preventDefault();
scrollToInPageAnchor(event.currentTarget, finalHref);
}
rest.onClick?.(event);
}}
/>
);
},
...componentsFromProps,
};
}, [anchorResolver, componentsFromProps]);
if (!content) return null;
return (
<MessageResponse
className={className}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={components}
>
{content}
</MessageResponse>
);
}
if (!content) return null;
return (
<div data-markdown-anchor-root className="contents">
<MessageResponse
className={className}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={components}
>
{content}
</MessageResponse>
</div>
);
}