From 87f764bfa921c490c693b2daf6cdd6a895d4e465 Mon Sep 17 00:00:00 2001 From: Administrator Date: Thu, 4 Jun 2026 16:52:30 +0800 Subject: [PATCH 1/2] feat: improve markdown rendering, artifact file preview, and public scheduled task page Co-Authored-By: Claude Opus 4.8 --- .../artifacts/artifact-file-detail.tsx | 94 ++++++++++--- .../workspace/messages/markdown-content.tsx | 91 ++++++++---- frontend-web/src/core/utils/markdown.ts | 133 ++++++++++++++++-- .../src/pages/PublicScheduledTaskPage.tsx | 106 +++++++++++++- .../components/ArtifactPreviewModal.tsx | 69 +++++++-- 5 files changed, 419 insertions(+), 74 deletions(-) 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 01800c52..32a16a17 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,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(); - - 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} + + ); + } + return ; + }, + }; + }, [content]); + + useEffect(() => { if (language !== "html") { setHtmlPreviewUrl(undefined); return; @@ -733,11 +785,11 @@ export function ArtifactFilePreview({ animated && "animate-[sandbox-preview-enter_320ms_ease-out]", )} > - + {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 739bcd77..7e32483a 100644 --- a/frontend-web/src/components/workspace/messages/markdown-content.tsx +++ b/frontend-web/src/components/workspace/messages/markdown-content.tsx @@ -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) => { - if (props.href?.startsWith("#reference-")) { - return ; +}: 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) => ( + + {children} + + ); + }; + + return { + h1: heading(1), + h2: heading(2), + h3: heading(3), + h4: heading(4), + h5: heading(5), + h6: heading(6), + a: (props: AnchorHTMLAttributes) => { + if (props.href?.startsWith("#reference-")) { + return ; } if (typeof props.children === "string") { const match = /^citation:(.+)$/.exec(props.children); @@ -46,23 +69,41 @@ export function MarkdownContent({ return {text}; } } - const { className, target, rel, ...rest } = props; - const external = isExternalUrl(props.href); - return ( - + {props.children} + + ); + } + return ( + ); - }, - ...componentsFromProps, - }; - }, [componentsFromProps]); + }, + ...componentsFromProps, + }; + }, [anchorResolver, componentsFromProps]); if (!content) return null; diff --git a/frontend-web/src/core/utils/markdown.ts b/frontend-web/src/core/utils/markdown.ts index 93615b43..d6dca9a9 100644 --- a/frontend-web/src/core/utils/markdown.ts +++ b/frontend-web/src/core/utils/markdown.ts @@ -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(); + const seen = new Map(); + 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; + }, + }; +} diff --git a/frontend-web/src/pages/PublicScheduledTaskPage.tsx b/frontend-web/src/pages/PublicScheduledTaskPage.tsx index 87300e09..3da475e9 100644 --- a/frontend-web/src/pages/PublicScheduledTaskPage.tsx +++ b/frontend-web/src/pages/PublicScheduledTaskPage.tsx @@ -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(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) => { + 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 (
@@ -357,12 +420,19 @@ export default function PublicScheduledTaskPage() { ); } - const task = data?.task; - return ( -
- +
+
+ *]:invisible", + )} + >
@@ -481,9 +551,31 @@ export default function PublicScheduledTaskPage() {
-
+ + +
+
+
+ +
+
+
- + {selectedRun ? ( ) : ( diff --git a/frontend-web/src/roundtable-planning/components/ArtifactPreviewModal.tsx b/frontend-web/src/roundtable-planning/components/ArtifactPreviewModal.tsx index 2b5cdc95..b5df69ec 100644 --- a/frontend-web/src/roundtable-planning/components/ArtifactPreviewModal.tsx +++ b/frontend-web/src/roundtable-planning/components/ArtifactPreviewModal.tsx @@ -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) => ( + + {children} + + ); + }; + + return { p: ({ children }: any) =>

{children}

, ul: ({ children }: any) =>
    {children}
, ol: ({ children }: any) =>
    {children}
, li: ({ children }: any) =>
  • {children}
  • , - h1: ({ children }: any) =>

    {children}

    , - h2: ({ children }: any) =>

    {children}

    , - h3: ({ children }: any) =>

    {children}

    , - h4: ({ children }: any) =>

    {children}

    , + 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) => {children}, em: ({ children }: any) => {children}, code: ({ children, className }: any) => { @@ -43,18 +62,38 @@ const MARKDOWN_COMPONENTS = { blockquote: ({ children }: any) => (
    {children}
    ), - a: ({ children, href }: any) => ( -
    - {children} - - ), + a: ({ children, href }: any) => { + const resolvedHref = anchorResolver.resolveHref(href, extractTextFromChildren(children)); + if (resolvedHref === null) { + return ( + + {children} + + ); + } + const isExternal = typeof href === "string" && /^https?:\/\//i.test(href); + return ( + + {children} + + ); + }, table: ({ children }: any) => {children}
    , th: ({ children }: any) => ( {children} ), td: ({ children }: any) => {children}, hr: () =>
    , -}; + }; +} function isMarkdown(mimeType: string | null, name: string): boolean { if (mimeType === "text/markdown") return true; @@ -121,6 +160,10 @@ export const ArtifactPreviewModal: React.FC = ({ }, [threadId, path]); const renderAsMarkdown = isMarkdown(mimeType, name); + const markdownComponents = useMemo( + () => createMarkdownComponents(content ?? ""), + [content], + ); return (
    = ({ 加载失败:{error}
    ) : content === null ? null : renderAsMarkdown ? ( - + {content} ) : ( From 225398972d06ebd014a3fc249aaf7179e73ed319 Mon Sep 17 00:00:00 2001 From: Administrator Date: Thu, 4 Jun 2026 16:53:07 +0800 Subject: [PATCH 2/2] fix: add missing axios dependency to frontend-web Co-Authored-By: Claude Opus 4.8 --- frontend-web/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend-web/package.json b/frontend-web/package.json index ee266aaa..a5921463 100644 --- a/frontend-web/package.json +++ b/frontend-web/package.json @@ -55,6 +55,7 @@ "@uiw/react-codemirror": "^4.25.4", "@xyflow/react": "^12.10.0", "ai": "^6.0.33", + "axios": "1.16.1", "best-effort-json-parser": "^1.2.1", "canvas-confetti": "^1.9.4", "class-variance-authority": "^0.7.1",