Merge remote-tracking branch 'origin/ji' into ji
This commit is contained in:
commit
6c30e649e3
6 files changed
+369
-23
No files matched your search
@@ -55,6 +55,7 @@
|
|||||||
"@uiw/react-codemirror": "^4.25.4",
|
"@uiw/react-codemirror": "^4.25.4",
|
||||||
"@xyflow/react": "^12.10.0",
|
"@xyflow/react": "^12.10.0",
|
||||||
"ai": "^6.0.33",
|
"ai": "^6.0.33",
|
||||||
|
"axios": "1.16.1",
|
||||||
"best-effort-json-parser": "^1.2.1",
|
"best-effort-json-parser": "^1.2.1",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
XIcon,
|
XIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Streamdown } from "streamdown";
|
import { Streamdown } from "streamdown";
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
|||||||
import { installSkill } from "@/core/skills/api";
|
import { installSkill } from "@/core/skills/api";
|
||||||
import { streamdownPlugins } from "@/core/streamdown";
|
import { streamdownPlugins } from "@/core/streamdown";
|
||||||
import { checkCodeFile, getFileName } from "@/core/utils/files";
|
import { checkCodeFile, getFileName } from "@/core/utils/files";
|
||||||
|
import { createMarkdownAnchorResolver } from "@/core/utils/markdown";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -61,6 +63,12 @@ import { useArtifacts } from "./context";
|
|||||||
|
|
||||||
const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll";
|
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) {
|
function findScrollableArtifactElement(root: HTMLElement | null) {
|
||||||
if (!root) {
|
if (!root) {
|
||||||
return null;
|
return null;
|
||||||
@@ -708,6 +716,50 @@ export function ArtifactFilePreview({
|
|||||||
activeCursor?: boolean;
|
activeCursor?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
|
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(() => {
|
useEffect(() => {
|
||||||
if (language !== "html") {
|
if (language !== "html") {
|
||||||
@@ -736,7 +788,7 @@ export function ArtifactFilePreview({
|
|||||||
<Streamdown
|
<Streamdown
|
||||||
className="size-full"
|
className="size-full"
|
||||||
{...streamdownPlugins}
|
{...streamdownPlugins}
|
||||||
components={{ a: ArtifactLink }}
|
components={markdownComponents}
|
||||||
>
|
>
|
||||||
{content ?? ""}
|
{content ?? ""}
|
||||||
</Streamdown>
|
</Streamdown>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import type { AnchorHTMLAttributes } from "react";
|
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
MessageResponse,
|
MessageResponse,
|
||||||
type MessageResponseProps,
|
type MessageResponseProps,
|
||||||
} from "@/components/ai-elements/message";
|
} from "@/components/ai-elements/message";
|
||||||
import { streamdownPlugins } from "@/core/streamdown";
|
import { streamdownPlugins } from "@/core/streamdown";
|
||||||
|
import { createMarkdownAnchorResolver } from "@/core/utils/markdown";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { CitationLink } from "../citations/citation-link";
|
import { CitationLink } from "../citations/citation-link";
|
||||||
@@ -16,6 +17,12 @@ function isExternalUrl(href: string | undefined): boolean {
|
|||||||
return !!href && /^https?:\/\//.test(href);
|
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 = {
|
export type MarkdownContentProps = {
|
||||||
content: string;
|
content: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -33,8 +40,24 @@ export function MarkdownContent({
|
|||||||
remarkPlugins = streamdownPlugins.remarkPlugins,
|
remarkPlugins = streamdownPlugins.remarkPlugins,
|
||||||
components: componentsFromProps,
|
components: componentsFromProps,
|
||||||
}: MarkdownContentProps) {
|
}: MarkdownContentProps) {
|
||||||
|
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
|
||||||
const components = useMemo(() => {
|
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 {
|
return {
|
||||||
|
h1: heading(1),
|
||||||
|
h2: heading(2),
|
||||||
|
h3: heading(3),
|
||||||
|
h4: heading(4),
|
||||||
|
h5: heading(5),
|
||||||
|
h6: heading(6),
|
||||||
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||||
if (props.href?.startsWith("#reference-")) {
|
if (props.href?.startsWith("#reference-")) {
|
||||||
return <CitationLink {...props} />;
|
return <CitationLink {...props} />;
|
||||||
@@ -48,9 +71,27 @@ export function MarkdownContent({
|
|||||||
}
|
}
|
||||||
const { className, target, rel, ...rest } = props;
|
const { className, target, rel, ...rest } = props;
|
||||||
const external = isExternalUrl(props.href);
|
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 (
|
return (
|
||||||
<a
|
<a
|
||||||
{...rest}
|
{...rest}
|
||||||
|
href={resolvedHref ?? props.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
|
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
|
||||||
className,
|
className,
|
||||||
@@ -62,7 +103,7 @@ export function MarkdownContent({
|
|||||||
},
|
},
|
||||||
...componentsFromProps,
|
...componentsFromProps,
|
||||||
};
|
};
|
||||||
}, [componentsFromProps]);
|
}, [anchorResolver, componentsFromProps]);
|
||||||
|
|
||||||
if (!content) return null;
|
if (!content) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -8,3 +8,120 @@ export function extractTitleFromMarkdown(markdown: string) {
|
|||||||
}
|
}
|
||||||
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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
Download,
|
Download,
|
||||||
FileText,
|
FileText,
|
||||||
|
List,
|
||||||
Loader2,
|
Loader2,
|
||||||
Pencil,
|
Pencil,
|
||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} 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 { useParams } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -253,6 +257,10 @@ function stripTimestampSuffix(title: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const BODY_TAB_VALUE = "__body__";
|
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.
|
* Public, unauthenticated scheduled-task viewer.
|
||||||
@@ -269,6 +277,9 @@ export default function PublicScheduledTaskPage() {
|
|||||||
const { taskId } = useParams<{ taskId: string }>();
|
const { taskId } = useParams<{ taskId: string }>();
|
||||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
|
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
|
||||||
const [pendingDeleteRun, setPendingDeleteRun] = useState<{ id: string; title: 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 queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data, isLoading, isError, error } = useQuery({
|
const { data, isLoading, isError, error } = useQuery({
|
||||||
@@ -332,6 +343,58 @@ export default function PublicScheduledTaskPage() {
|
|||||||
}
|
}
|
||||||
}, [runs, selectedRunId]);
|
}, [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) {
|
if (!taskId) {
|
||||||
return (
|
return (
|
||||||
<div className="text-muted-foreground flex h-screen items-center justify-center text-sm">
|
<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 (
|
return (
|
||||||
<TooltipProvider delayDuration={150}>
|
<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)]">
|
<div className="bg-muted/20 flex h-screen min-h-0 overflow-hidden p-4">
|
||||||
<Card className="flex min-h-0 flex-col">
|
<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">
|
<CardHeader className="gap-1">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -481,9 +551,31 @@ export default function PublicScheduledTaskPage() {
|
|||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</CardContent>
|
</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 ? (
|
{selectedRun ? (
|
||||||
<RunDetailPane key={selectedRun.id} run={selectedRun} taskId={taskId} />
|
<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 ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { Download, FileText, Loader2, X } from "lucide-react";
|
import { Download, FileText, Loader2, X } from "lucide-react";
|
||||||
|
|
||||||
import { apiFetch } from "@/core/api/fetch-client";
|
import { apiFetch } from "@/core/api/fetch-client";
|
||||||
|
import { createMarkdownAnchorResolver } from "@/core/utils/markdown";
|
||||||
import { buildArtifactDownloadUrl } from "@/roundtable-planning/api/multi-agent";
|
import { buildArtifactDownloadUrl } from "@/roundtable-planning/api/multi-agent";
|
||||||
|
|
||||||
interface ArtifactPreviewModalProps {
|
interface ArtifactPreviewModalProps {
|
||||||
@@ -18,15 +20,32 @@ interface ArtifactPreviewModalProps {
|
|||||||
onClose: () => void;
|
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>,
|
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>,
|
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>,
|
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>,
|
li: ({ children }: any) => <li className="leading-snug">{children}</li>,
|
||||||
h1: ({ children }: any) => <h1 className="text-base font-bold mt-3 mb-2">{children}</h1>,
|
h1: heading(1, "text-base font-bold mt-3 mb-2 scroll-mt-4"),
|
||||||
h2: ({ children }: any) => <h2 className="text-sm font-bold mt-3 mb-1.5">{children}</h2>,
|
h2: heading(2, "text-sm font-bold mt-3 mb-1.5 scroll-mt-4"),
|
||||||
h3: ({ children }: any) => <h3 className="text-xs font-bold mt-2 mb-1">{children}</h3>,
|
h3: heading(3, "text-xs font-bold mt-2 mb-1 scroll-mt-4"),
|
||||||
h4: ({ children }: any) => <h4 className="text-xs font-bold mt-1.5 mb-1">{children}</h4>,
|
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>,
|
strong: ({ children }: any) => <strong className="font-bold text-slate-900">{children}</strong>,
|
||||||
em: ({ children }: any) => <em className="italic">{children}</em>,
|
em: ({ children }: any) => <em className="italic">{children}</em>,
|
||||||
code: ({ children, className }: any) => {
|
code: ({ children, className }: any) => {
|
||||||
@@ -43,18 +62,38 @@ const MARKDOWN_COMPONENTS = {
|
|||||||
blockquote: ({ children }: any) => (
|
blockquote: ({ children }: any) => (
|
||||||
<blockquote className="border-l-2 border-slate-300 pl-3 my-2 text-slate-600 italic">{children}</blockquote>
|
<blockquote className="border-l-2 border-slate-300 pl-3 my-2 text-slate-600 italic">{children}</blockquote>
|
||||||
),
|
),
|
||||||
a: ({ children, href }: any) => (
|
a: ({ children, href }: any) => {
|
||||||
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline hover:opacity-80">
|
const resolvedHref = anchorResolver.resolveHref(href, extractTextFromChildren(children));
|
||||||
{children}
|
if (resolvedHref === null) {
|
||||||
</a>
|
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>,
|
table: ({ children }: any) => <table className="my-2 border-collapse text-[12px] w-full">{children}</table>,
|
||||||
th: ({ children }: any) => (
|
th: ({ children }: any) => (
|
||||||
<th className="border border-slate-300 px-2 py-1 bg-slate-50 font-bold text-left">{children}</th>
|
<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>,
|
td: ({ children }: any) => <td className="border border-slate-200 px-2 py-1">{children}</td>,
|
||||||
hr: () => <hr className="my-3 border-slate-200" />,
|
hr: () => <hr className="my-3 border-slate-200" />,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function isMarkdown(mimeType: string | null, name: string): boolean {
|
function isMarkdown(mimeType: string | null, name: string): boolean {
|
||||||
if (mimeType === "text/markdown") return true;
|
if (mimeType === "text/markdown") return true;
|
||||||
@@ -121,6 +160,10 @@ export const ArtifactPreviewModal: React.FC<ArtifactPreviewModalProps> = ({
|
|||||||
}, [threadId, path]);
|
}, [threadId, path]);
|
||||||
|
|
||||||
const renderAsMarkdown = isMarkdown(mimeType, name);
|
const renderAsMarkdown = isMarkdown(mimeType, name);
|
||||||
|
const markdownComponents = useMemo(
|
||||||
|
() => createMarkdownComponents(content ?? ""),
|
||||||
|
[content],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -171,7 +214,7 @@ export const ArtifactPreviewModal: React.FC<ArtifactPreviewModalProps> = ({
|
|||||||
加载失败:{error}
|
加载失败:{error}
|
||||||
</div>
|
</div>
|
||||||
) : content === null ? null : renderAsMarkdown ? (
|
) : content === null ? null : renderAsMarkdown ? (
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||||
{content}
|
{content}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in new issue
Block a user