feat: improve markdown rendering, artifact file preview, and public scheduled task page

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AdministratorandClaude Opus 4.8 committed 2026-06-04 16:52:30 +08:00
1 parent 94c02ded2a
commit 87f764bfa9
5 files changed
+419 -74

No files matched your search

@@ -1,7 +1,7 @@
"use client";
import {
ArrowDownIcon,
import {
ArrowDownIcon,
Code2Icon,
CopyIcon,
DownloadIcon,
@@ -12,10 +12,11 @@ import {
SaveIcon,
SquareArrowOutUpRightIcon,
XIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } 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,
@@ -47,9 +48,10 @@ 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 { env } from "@/env";
import { streamdownPlugins } from "@/core/streamdown";
import { checkCodeFile, getFileName } from "@/core/utils/files";
import { createMarkdownAnchorResolver } from "@/core/utils/markdown";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
@@ -59,9 +61,15 @@ import { Tooltip } from "../tooltip";
import { useArtifacts } from "./context";
const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll";
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;
}
@@ -706,10 +714,54 @@ export function ArtifactFilePreview({
language: string;
animated?: boolean;
activeCursor?: boolean;
}) {
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
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>
);
}
return <ArtifactLink {...rest} className={className} href={resolvedHref ?? props.href} />;
},
};
}, [content]);
useEffect(() => {
if (language !== "html") {
setHtmlPreviewUrl(undefined);
return;
@@ -733,11 +785,11 @@ export function ArtifactFilePreview({
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
)}
>
<Streamdown
className="size-full"
{...streamdownPlugins}
components={{ a: ArtifactLink }}
>
<Streamdown
className="size-full"
{...streamdownPlugins}
components={markdownComponents}
>
{content ?? ""}
</Streamdown>
{activeCursor && (
@@ -1,20 +1,27 @@
"use client";
import { useMemo } from "react";
import type { AnchorHTMLAttributes } from "react";
import { useMemo } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import {
MessageResponse,
type MessageResponseProps,
} from "@/components/ai-elements/message";
import { streamdownPlugins } from "@/core/streamdown";
import { cn } from "@/lib/utils";
import { streamdownPlugins } from "@/core/streamdown";
import { createMarkdownAnchorResolver } from "@/core/utils/markdown";
import { cn } from "@/lib/utils";
import { CitationLink } from "../citations/citation-link";
function isExternalUrl(href: string | undefined): boolean {
return !!href && /^https?:\/\//.test(href);
}
function isExternalUrl(href: string | undefined): boolean {
return !!href && /^https?:\/\//.test(href);
}
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 "";
}
export type MarkdownContentProps = {
content: string;
@@ -32,12 +39,28 @@ export function MarkdownContent({
className,
remarkPlugins = streamdownPlugins.remarkPlugins,
components: componentsFromProps,
}: MarkdownContentProps) {
const components = useMemo(() => {
return {
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (props.href?.startsWith("#reference-")) {
return <CitationLink {...props} />;
}: MarkdownContentProps) {
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
const components = useMemo(() => {
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 (props.href?.startsWith("#reference-")) {
return <CitationLink {...props} />;
}
if (typeof props.children === "string") {
const match = /^citation:(.+)$/.exec(props.children);
@@ -46,23 +69,41 @@ export function MarkdownContent({
return <CitationLink {...props}>{text}</CitationLink>;
}
}
const { className, target, rel, ...rest } = props;
const external = isExternalUrl(props.href);
return (
<a
{...rest}
className={cn(
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
className,
const { className, target, rel, ...rest } = props;
const external = isExternalUrl(props.href);
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 (
<a
{...rest}
href={resolvedHref ?? props.href}
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)}
/>
);
},
...componentsFromProps,
};
}, [componentsFromProps]);
},
...componentsFromProps,
};
}, [anchorResolver, componentsFromProps]);
if (!content) return null;
+125 -8
View File
@@ -1,10 +1,127 @@
export function extractTitleFromMarkdown(markdown: string) {
if (markdown.startsWith("# ")) {
let title = markdown.split("\n")[0]!.trim();
if (title.startsWith("# ")) {
title = title.slice(2).trim();
export function extractTitleFromMarkdown(markdown: string) {
if (markdown.startsWith("# ")) {
let title = markdown.split("\n")[0]!.trim();
if (title.startsWith("# ")) {
title = title.slice(2).trim();
}
return title;
}
return undefined;
}
}
return undefined;
}
export type MarkdownHeadingAnchor = {
level: number;
text: string;
id: string;
};
export type MarkdownAnchorResolver = {
headings: MarkdownHeadingAnchor[];
getHeadingId: (level: number, text: string) => string | undefined;
resolveHref: (href: string | undefined, label?: string) => string | null | undefined;
};
const HEADING_RE = /^(#{1,6})\s+(.+?)\s*#*\s*$/;
const PROTOCOL_RE = /^[a-z][a-z\d+.-]*:/i;
function stripInlineMarkdown(value: string): string {
return value
.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/[`*_~]/g, "")
.replace(/<[^>]+>/g, "")
.trim();
}
export function markdownSlug(value: string): string {
const cleaned = stripInlineMarkdown(value)
.normalize("NFKC")
.toLowerCase()
.replace(/[^\p{L}\p{N}\s._-]+/gu, "")
.trim()
.replace(/[\s._-]+/g, "-")
.replace(/^-+|-+$/g, "");
return cleaned || "section";
}
function decodeLinkPart(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function normalizeLookupKey(value: string): string {
return markdownSlug(decodeLinkPart(value).replace(/^#+/, ""));
}
function isExternalOrSpecialHref(href: string): boolean {
if (href.startsWith("//")) return true;
if (PROTOCOL_RE.test(href)) return true;
return href.startsWith("/") || href.startsWith("?");
}
function candidateTextFromHref(href: string): string {
const withoutQuery = href.split(/[?#]/)[0] ?? href;
const lastSegment = withoutQuery.split("/").filter(Boolean).pop() ?? withoutQuery;
return decodeLinkPart(lastSegment).replace(/\.(md|markdown)$/i, "");
}
export function createMarkdownAnchorResolver(markdown: string): MarkdownAnchorResolver {
const headings: MarkdownHeadingAnchor[] = [];
const idsByLookup = new Map<string, string>();
const seen = new Map<string, number>();
let inFence = false;
for (const rawLine of markdown.split(/\r?\n/)) {
const line = rawLine.trim();
if (/^(```|~~~)/.test(line)) {
inFence = !inFence;
continue;
}
if (inFence) continue;
const match = HEADING_RE.exec(rawLine);
if (!match) continue;
const level = match[1]!.length;
const text = stripInlineMarkdown(match[2]!);
const baseId = markdownSlug(text);
const count = seen.get(baseId) ?? 0;
seen.set(baseId, count + 1);
const id = count === 0 ? baseId : `${baseId}-${count}`;
headings.push({ level, text, id });
idsByLookup.set(normalizeLookupKey(text), id);
idsByLookup.set(normalizeLookupKey(id), id);
}
return {
headings,
getHeadingId: (level: number, text: string) => {
void level;
return idsByLookup.get(normalizeLookupKey(text));
},
resolveHref: (href: string | undefined, label?: string) => {
if (!href) return href;
const trimmed = href.trim();
if (!trimmed || isExternalOrSpecialHref(trimmed)) return href;
const hash = trimmed.includes("#") ? trimmed.slice(trimmed.indexOf("#") + 1) : "";
const candidates = [
hash,
trimmed.startsWith("#") ? trimmed.slice(1) : "",
candidateTextFromHref(trimmed),
label ?? "",
].filter(Boolean);
for (const candidate of candidates) {
const id = idsByLookup.get(normalizeLookupKey(candidate));
if (id) return `#${id}`;
}
return trimmed.startsWith("#") ? null : null;
},
};
}
@@ -3,14 +3,18 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Check,
ChevronLeft,
ChevronRight,
Download,
FileText,
List,
Loader2,
Pencil,
Trash2,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import { useParams } from "react-router-dom";
import { toast } from "sonner";
@@ -253,6 +257,10 @@ function stripTimestampSuffix(title: string): string {
}
const BODY_TAB_VALUE = "__body__";
const RUN_LIST_COLLAPSED_WIDTH = 56;
const RUN_LIST_DEFAULT_WIDTH = 380;
const RUN_LIST_MIN_WIDTH = 280;
const RUN_LIST_MAX_WIDTH = 560;
/**
* Public, unauthenticated scheduled-task viewer.
@@ -269,6 +277,9 @@ export default function PublicScheduledTaskPage() {
const { taskId } = useParams<{ taskId: string }>();
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
const [pendingDeleteRun, setPendingDeleteRun] = useState<{ id: string; title: string } | null>(null);
const [runListExpanded, setRunListExpanded] = useState(false);
const [runListWidth, setRunListWidth] = useState(RUN_LIST_DEFAULT_WIDTH);
const dragStateRef = useRef<{ startX: number; startWidth: number } | null>(null);
const queryClient = useQueryClient();
const { data, isLoading, isError, error } = useQuery({
@@ -332,6 +343,58 @@ export default function PublicScheduledTaskPage() {
}
}, [runs, selectedRunId]);
const task = data?.task;
const visibleRunListWidth = runListExpanded ? runListWidth : RUN_LIST_COLLAPSED_WIDTH;
const expandRunList = useCallback(() => {
setRunListExpanded(true);
setRunListWidth((width) =>
Math.min(RUN_LIST_MAX_WIDTH, Math.max(RUN_LIST_MIN_WIDTH, width)),
);
}, []);
const collapseRunList = useCallback(() => {
setRunListExpanded(false);
}, []);
const handleRunListDragStart = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault();
dragStateRef.current = {
startX: event.clientX,
startWidth: visibleRunListWidth,
};
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
const handlePointerMove = (moveEvent: PointerEvent) => {
const drag = dragStateRef.current;
if (!drag) return;
const nextWidth = drag.startWidth + moveEvent.clientX - drag.startX;
if (nextWidth <= RUN_LIST_COLLAPSED_WIDTH + 28) {
setRunListExpanded(false);
return;
}
setRunListExpanded(true);
setRunListWidth(
Math.min(RUN_LIST_MAX_WIDTH, Math.max(RUN_LIST_MIN_WIDTH, nextWidth)),
);
};
const handlePointerUp = () => {
dragStateRef.current = null;
document.body.style.cursor = "";
document.body.style.userSelect = "";
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
},
[visibleRunListWidth],
);
if (!taskId) {
return (
<div className="text-muted-foreground flex h-screen items-center justify-center text-sm">
@@ -357,12 +420,19 @@ export default function PublicScheduledTaskPage() {
);
}
const task = data?.task;
return (
<TooltipProvider delayDuration={150}>
<div className="bg-muted/20 grid h-screen min-h-0 grid-cols-1 gap-4 p-4 xl:grid-cols-[380px_minmax(0,1fr)]">
<Card className="flex min-h-0 flex-col">
<div className="bg-muted/20 flex h-screen min-h-0 overflow-hidden p-4">
<div
className="relative flex min-h-0 shrink-0 transition-[width] duration-200 ease-out"
style={{ width: visibleRunListWidth }}
>
<Card
className={cn(
"flex min-h-0 w-full flex-col overflow-hidden",
!runListExpanded && "pointer-events-none [&>*]:invisible",
)}
>
<CardHeader className="gap-1">
<div className="flex items-start justify-between gap-2">
<Tooltip>
@@ -481,9 +551,31 @@ export default function PublicScheduledTaskPage() {
</div>
</ScrollArea>
</CardContent>
</Card>
</Card>
<button
type="button"
onClick={runListExpanded ? collapseRunList : expandRunList}
className="bg-background text-muted-foreground hover:bg-accent hover:text-primary focus-visible:ring-ring absolute top-3 left-1/2 z-30 flex size-9 -translate-x-1/2 items-center justify-center rounded-md border shadow-sm transition-colors focus:outline-none focus-visible:ring-2"
title={runListExpanded ? "收起执行记录列表" : "展开执行记录列表"}
aria-label={runListExpanded ? "收起执行记录列表" : "展开执行记录列表"}
>
{runListExpanded ? <ChevronLeft className="size-4" /> : <List className="size-4" />}
</button>
<div
role="separator"
aria-orientation="vertical"
title="拖拽调整列表宽度"
className="group absolute top-0 -right-3 z-20 flex h-full w-6 cursor-col-resize items-center justify-center"
onPointerDown={handleRunListDragStart}
>
<div className="bg-border group-hover:bg-primary/70 h-full w-px transition-colors" />
<div className="bg-background text-muted-foreground group-hover:text-primary absolute top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded-full border shadow-sm transition-colors">
<ChevronRight className="size-3" />
</div>
</div>
</div>
<Card className="flex min-h-0 flex-col">
<Card className="ml-4 flex min-h-0 flex-1 flex-col">
{selectedRun ? (
<RunDetailPane key={selectedRun.id} run={selectedRun} taskId={taskId} />
) : (
@@ -1,9 +1,11 @@
import React, { useCallback, useEffect, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import ReactMarkdown from "react-markdown";
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 { buildArtifactDownloadUrl } from "@/roundtable-planning/api/multi-agent";
interface ArtifactPreviewModalProps {
@@ -18,15 +20,32 @@ interface ArtifactPreviewModalProps {
onClose: () => void;
}
const MARKDOWN_COMPONENTS = {
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 createMarkdownComponents(content: string) {
const anchorResolver = createMarkdownAnchorResolver(content);
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6, className: string) => {
const Tag = `h${level}` as const;
return ({ children, id }: any) => (
<Tag id={id ?? anchorResolver.getHeadingId(level, extractTextFromChildren(children))} className={className}>
{children}
</Tag>
);
};
return {
p: ({ children }: any) => <p className="my-1.5 whitespace-pre-wrap leading-relaxed">{children}</p>,
ul: ({ children }: any) => <ul className="list-disc pl-5 my-1.5 space-y-1">{children}</ul>,
ol: ({ children }: any) => <ol className="list-decimal pl-5 my-1.5 space-y-1">{children}</ol>,
li: ({ children }: any) => <li className="leading-snug">{children}</li>,
h1: ({ children }: any) => <h1 className="text-base font-bold mt-3 mb-2">{children}</h1>,
h2: ({ children }: any) => <h2 className="text-sm font-bold mt-3 mb-1.5">{children}</h2>,
h3: ({ children }: any) => <h3 className="text-xs font-bold mt-2 mb-1">{children}</h3>,
h4: ({ children }: any) => <h4 className="text-xs font-bold mt-1.5 mb-1">{children}</h4>,
h1: heading(1, "text-base font-bold mt-3 mb-2 scroll-mt-4"),
h2: heading(2, "text-sm font-bold mt-3 mb-1.5 scroll-mt-4"),
h3: heading(3, "text-xs font-bold mt-2 mb-1 scroll-mt-4"),
h4: heading(4, "text-xs font-bold mt-1.5 mb-1 scroll-mt-4"),
strong: ({ children }: any) => <strong className="font-bold text-slate-900">{children}</strong>,
em: ({ children }: any) => <em className="italic">{children}</em>,
code: ({ children, className }: any) => {
@@ -43,18 +62,38 @@ const MARKDOWN_COMPONENTS = {
blockquote: ({ children }: any) => (
<blockquote className="border-l-2 border-slate-300 pl-3 my-2 text-slate-600 italic">{children}</blockquote>
),
a: ({ children, href }: any) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline hover:opacity-80">
{children}
</a>
),
a: ({ children, href }: any) => {
const resolvedHref = anchorResolver.resolveHref(href, extractTextFromChildren(children));
if (resolvedHref === null) {
return (
<span
className="text-primary/80 underline decoration-dotted underline-offset-2 cursor-not-allowed"
title="未在当前 Markdown 中找到对应章节"
>
{children}
</span>
);
}
const isExternal = typeof href === "string" && /^https?:\/\//i.test(href);
return (
<a
href={resolvedHref ?? href}
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener noreferrer" : undefined}
className="text-primary underline hover:opacity-80"
>
{children}
</a>
);
},
table: ({ children }: any) => <table className="my-2 border-collapse text-[12px] w-full">{children}</table>,
th: ({ children }: any) => (
<th className="border border-slate-300 px-2 py-1 bg-slate-50 font-bold text-left">{children}</th>
),
td: ({ children }: any) => <td className="border border-slate-200 px-2 py-1">{children}</td>,
hr: () => <hr className="my-3 border-slate-200" />,
};
};
}
function isMarkdown(mimeType: string | null, name: string): boolean {
if (mimeType === "text/markdown") return true;
@@ -121,6 +160,10 @@ export const ArtifactPreviewModal: React.FC<ArtifactPreviewModalProps> = ({
}, [threadId, path]);
const renderAsMarkdown = isMarkdown(mimeType, name);
const markdownComponents = useMemo(
() => createMarkdownComponents(content ?? ""),
[content],
);
return (
<div
@@ -171,7 +214,7 @@ export const ArtifactPreviewModal: React.FC<ArtifactPreviewModalProps> = ({
加载失败:{error}
</div>
) : content === null ? null : renderAsMarkdown ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{content}
</ReactMarkdown>
) : (