mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-19 12:24:46 +08:00
feat: refine citations format and improve content presentation
Backend: - Simplify citations prompt format and rules - Add clear distinction between chat responses and file content - Enforce full URL usage in markdown links, prohibit [cite-1] format - Require content-first approach: write full content, then add citations at end Frontend: - Hide <citations> block in both chat messages and markdown preview - Remove top-level Citations/Sources list for cleaner UI - Auto-remove <citations> block in code editor view for markdown files - Keep inline citation hover cards for reference details This ensures citations are presented like Claude: clean content with inline reference badges. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,7 +7,10 @@ import {
|
||||
SquareArrowOutUpRightIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import remarkMath from "remark-math";
|
||||
import { toast } from "sonner";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
@@ -26,6 +29,12 @@ import {
|
||||
} from "@/components/ai-elements/inline-citation";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { HoverCardTrigger } from "@/components/ui/hover-card";
|
||||
import {
|
||||
buildCitationMap,
|
||||
extractDomainFromUrl,
|
||||
parseCitations,
|
||||
type Citation,
|
||||
} from "@/core/citations";
|
||||
import { Select, SelectItem } from "@/components/ui/select";
|
||||
import {
|
||||
SelectContent,
|
||||
@@ -37,7 +46,6 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { CodeEditor } from "@/components/workspace/code-editor";
|
||||
import { useArtifactContent } from "@/core/artifacts/hooks";
|
||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import { extractDomainFromUrl } from "@/core/citations";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { checkCodeFile, getFileName } from "@/core/utils/files";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -81,6 +89,15 @@ export function ArtifactFileDetail({
|
||||
filepath: filepathFromProps,
|
||||
enabled: isCodeFile && !isWriteFile,
|
||||
});
|
||||
|
||||
// Parse citations and get clean content for code editor
|
||||
const cleanContent = useMemo(() => {
|
||||
if (language === "markdown" && content) {
|
||||
return parseCitations(content).cleanContent;
|
||||
}
|
||||
return content;
|
||||
}, [content, language]);
|
||||
|
||||
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
|
||||
useEffect(() => {
|
||||
if (previewable) {
|
||||
@@ -196,7 +213,7 @@ export function ArtifactFileDetail({
|
||||
{isCodeFile && viewMode === "code" && (
|
||||
<CodeEditor
|
||||
className="size-full resize-none rounded-none border-none"
|
||||
value={content ?? ""}
|
||||
value={cleanContent ?? ""}
|
||||
readonly
|
||||
/>
|
||||
)}
|
||||
@@ -222,11 +239,23 @@ export function ArtifactFilePreview({
|
||||
content: string;
|
||||
language: string;
|
||||
}) {
|
||||
const { citations, cleanContent, citationMap } = React.useMemo(() => {
|
||||
const parsed = parseCitations(content ?? "");
|
||||
const map = buildCitationMap(parsed.citations);
|
||||
return {
|
||||
citations: parsed.citations,
|
||||
cleanContent: parsed.cleanContent,
|
||||
citationMap: map,
|
||||
};
|
||||
}, [content]);
|
||||
|
||||
if (language === "markdown") {
|
||||
return (
|
||||
<div className="size-full px-4">
|
||||
<Streamdown
|
||||
className="size-full"
|
||||
remarkPlugins={[[remarkMath, { singleDollarTextMath: true }]]}
|
||||
rehypePlugins={[[rehypeKatex, { output: "html" }]]}
|
||||
components={{
|
||||
a: ({
|
||||
href,
|
||||
@@ -236,6 +265,16 @@ export function ArtifactFilePreview({
|
||||
return <span>{children}</span>;
|
||||
}
|
||||
|
||||
// Check if it's a citation link
|
||||
const citation = citationMap.get(href);
|
||||
if (citation) {
|
||||
return (
|
||||
<ArtifactCitationLink citation={citation} href={href}>
|
||||
{children}
|
||||
</ArtifactCitationLink>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if it's an external link (http/https)
|
||||
const isExternalLink =
|
||||
href.startsWith("http://") || href.startsWith("https://");
|
||||
@@ -255,7 +294,7 @@ export function ArtifactFilePreview({
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content ?? ""}
|
||||
{cleanContent ?? ""}
|
||||
</Streamdown>
|
||||
</div>
|
||||
);
|
||||
@@ -271,6 +310,61 @@ export function ArtifactFilePreview({
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Citation link component for artifact preview (with full citation data)
|
||||
*/
|
||||
function ArtifactCitationLink({
|
||||
citation,
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
citation: Citation;
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const domain = extractDomainFromUrl(href);
|
||||
|
||||
return (
|
||||
<InlineCitationCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="mx-0.5 cursor-pointer gap-1 rounded-full px-2 py-0.5 text-xs font-normal hover:bg-secondary/80"
|
||||
>
|
||||
{children ?? domain}
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</Badge>
|
||||
</a>
|
||||
</HoverCardTrigger>
|
||||
<InlineCitationCardBody>
|
||||
<div className="p-3">
|
||||
<InlineCitationSource
|
||||
title={citation.title}
|
||||
url={href}
|
||||
description={citation.snippet}
|
||||
/>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary mt-2 inline-flex items-center gap-1 text-xs hover:underline"
|
||||
>
|
||||
Visit source
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
</InlineCitationCardBody>
|
||||
</InlineCitationCard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* External link badge component for artifact preview
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { ExternalLinkIcon, LinkIcon } from "lucide-react";
|
||||
import { ExternalLinkIcon, FileIcon, LinkIcon } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { memo, useMemo } from "react";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import remarkMath from "remark-math";
|
||||
|
||||
import {
|
||||
InlineCitationCard,
|
||||
@@ -26,6 +28,8 @@ import {
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
extractReasoningContentFromMessage,
|
||||
parseUploadedFiles,
|
||||
type UploadedFile,
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -82,16 +86,26 @@ function MessageContent_({
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||
const isHuman = message.type === "human";
|
||||
|
||||
// Extract and parse citations from message content
|
||||
const { citations, cleanContent } = useMemo(() => {
|
||||
// Extract and parse citations and uploaded files from message content
|
||||
const { citations, cleanContent, uploadedFiles } = useMemo(() => {
|
||||
const reasoningContent = extractReasoningContentFromMessage(message);
|
||||
const rawContent = extractContentFromMessage(message);
|
||||
if (!isLoading && reasoningContent && !rawContent) {
|
||||
return { citations: [], cleanContent: reasoningContent };
|
||||
return { citations: [], cleanContent: reasoningContent, uploadedFiles: [] };
|
||||
}
|
||||
return parseCitations(rawContent ?? "");
|
||||
}, [isLoading, message]);
|
||||
|
||||
// For human messages, first parse uploaded files
|
||||
if (isHuman && rawContent) {
|
||||
const { files, cleanContent: contentWithoutFiles } = parseUploadedFiles(rawContent);
|
||||
const { citations, cleanContent: finalContent } = parseCitations(contentWithoutFiles);
|
||||
return { citations, cleanContent: finalContent, uploadedFiles: files };
|
||||
}
|
||||
|
||||
const { citations, cleanContent } = parseCitations(rawContent ?? "");
|
||||
return { citations, cleanContent, uploadedFiles: [] };
|
||||
}, [isLoading, message, isHuman]);
|
||||
|
||||
// Build citation map for quick URL lookup
|
||||
const citationMap = useMemo(
|
||||
@@ -103,75 +117,212 @@ function MessageContent_({
|
||||
|
||||
return (
|
||||
<AIElementMessageContent className={className}>
|
||||
{/* Citations list at the top */}
|
||||
{citations.length > 0 && <CitationsList citations={citations} />}
|
||||
{/* Uploaded files for human messages - show first */}
|
||||
{uploadedFiles.length > 0 && thread_id && (
|
||||
<UploadedFilesList files={uploadedFiles} threadId={thread_id} />
|
||||
)}
|
||||
|
||||
<AIElementMessageResponse
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={{
|
||||
a: ({
|
||||
href,
|
||||
children,
|
||||
}: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
if (!href) {
|
||||
return <span>{children}</span>;
|
||||
}
|
||||
{/* Message content - always show if present */}
|
||||
{cleanContent && (
|
||||
<AIElementMessageResponse
|
||||
remarkPlugins={[[remarkMath, { singleDollarTextMath: true }]]}
|
||||
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
|
||||
components={{
|
||||
a: ({
|
||||
href,
|
||||
children,
|
||||
}: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
if (!href) {
|
||||
return <span>{children}</span>;
|
||||
}
|
||||
|
||||
// Check if this link matches a citation
|
||||
const citation = citationMap.get(href);
|
||||
if (citation) {
|
||||
// Check if this link matches a citation
|
||||
const citation = citationMap.get(href);
|
||||
if (citation) {
|
||||
return (
|
||||
<CitationLink citation={citation} href={href}>
|
||||
{children}
|
||||
</CitationLink>
|
||||
);
|
||||
}
|
||||
|
||||
// Regular external link
|
||||
return (
|
||||
<CitationLink citation={citation} href={href}>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline underline-offset-2 hover:no-underline"
|
||||
>
|
||||
{children}
|
||||
</CitationLink>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// Regular external link
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline underline-offset-2 hover:no-underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
img: ({ src, alt }: React.ImgHTMLAttributes<HTMLImageElement>) => {
|
||||
if (!src) return null;
|
||||
if (typeof src !== "string") {
|
||||
},
|
||||
img: ({ src, alt }: React.ImgHTMLAttributes<HTMLImageElement>) => {
|
||||
if (!src) return null;
|
||||
if (typeof src !== "string") {
|
||||
return (
|
||||
<img
|
||||
className="max-w-full overflow-hidden rounded-lg"
|
||||
src={src}
|
||||
alt={alt}
|
||||
/>
|
||||
);
|
||||
}
|
||||
let url = src;
|
||||
if (src.startsWith("/mnt/")) {
|
||||
url = resolveArtifactURL(src, thread_id);
|
||||
}
|
||||
return (
|
||||
<img
|
||||
className="max-w-full overflow-hidden rounded-lg"
|
||||
src={src}
|
||||
alt={alt}
|
||||
/>
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
className="max-w-full overflow-hidden rounded-lg"
|
||||
src={url}
|
||||
alt={alt}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
let url = src;
|
||||
if (src.startsWith("/mnt/")) {
|
||||
url = resolveArtifactURL(src, thread_id);
|
||||
}
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
className="max-w-full overflow-hidden rounded-lg"
|
||||
src={url}
|
||||
alt={alt}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{cleanContent}
|
||||
</AIElementMessageResponse>
|
||||
},
|
||||
}}
|
||||
>
|
||||
{cleanContent}
|
||||
</AIElementMessageResponse>
|
||||
)}
|
||||
</AIElementMessageContent>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file type label from filename extension
|
||||
*/
|
||||
function getFileTypeLabel(filename: string): string {
|
||||
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
||||
const typeMap: Record<string, string> = {
|
||||
json: "JSON",
|
||||
csv: "CSV",
|
||||
txt: "TXT",
|
||||
md: "Markdown",
|
||||
py: "Python",
|
||||
js: "JavaScript",
|
||||
ts: "TypeScript",
|
||||
tsx: "TSX",
|
||||
jsx: "JSX",
|
||||
html: "HTML",
|
||||
css: "CSS",
|
||||
xml: "XML",
|
||||
yaml: "YAML",
|
||||
yml: "YAML",
|
||||
pdf: "PDF",
|
||||
png: "PNG",
|
||||
jpg: "JPG",
|
||||
jpeg: "JPEG",
|
||||
gif: "GIF",
|
||||
svg: "SVG",
|
||||
zip: "ZIP",
|
||||
tar: "TAR",
|
||||
gz: "GZ",
|
||||
};
|
||||
return typeMap[ext] || ext.toUpperCase() || "FILE";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is an image based on extension
|
||||
*/
|
||||
function isImageFile(filename: string): boolean {
|
||||
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
||||
return ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"].includes(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploaded files list component that displays files as cards or image thumbnails (Claude-style)
|
||||
*/
|
||||
function UploadedFilesList({
|
||||
files,
|
||||
threadId,
|
||||
}: {
|
||||
files: UploadedFile[];
|
||||
threadId: string;
|
||||
}) {
|
||||
if (files.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{files.map((file, index) => (
|
||||
<UploadedFileCard
|
||||
key={`${file.path}-${index}`}
|
||||
file={file}
|
||||
threadId={threadId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single uploaded file card component (Claude-style)
|
||||
* Shows image thumbnail for image files, file card for others
|
||||
*/
|
||||
function UploadedFileCard({
|
||||
file,
|
||||
threadId,
|
||||
}: {
|
||||
file: UploadedFile;
|
||||
threadId: string;
|
||||
}) {
|
||||
const typeLabel = getFileTypeLabel(file.filename);
|
||||
const isImage = isImageFile(file.filename);
|
||||
|
||||
// Don't render if threadId is invalid
|
||||
if (!threadId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build URL - browser will handle encoding automatically
|
||||
const imageUrl = resolveArtifactURL(file.path, threadId);
|
||||
|
||||
// For image files, show thumbnail
|
||||
if (isImage) {
|
||||
return (
|
||||
<a
|
||||
href={imageUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group relative block overflow-hidden rounded-lg border"
|
||||
>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={file.filename}
|
||||
className="h-32 w-auto max-w-[240px] object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// For non-image files, show file card
|
||||
return (
|
||||
<div className="bg-background flex min-w-[120px] max-w-[200px] flex-col gap-1 rounded-lg border p-3 shadow-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<FileIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
|
||||
<span
|
||||
className="text-foreground truncate text-sm font-medium"
|
||||
title={file.filename}
|
||||
>
|
||||
{file.filename}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-normal"
|
||||
>
|
||||
{typeLabel}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-[10px]">{file.size}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Citations list component that displays all sources at the top
|
||||
*/
|
||||
|
||||
@@ -33,41 +33,42 @@ export function parseCitations(content: string): ParseCitationsResult {
|
||||
return { citations: [], cleanContent: content };
|
||||
}
|
||||
|
||||
// Match the citations block at the start of content (with possible leading whitespace)
|
||||
const citationsRegex = /^\s*<citations>([\s\S]*?)<\/citations>/;
|
||||
const match = citationsRegex.exec(content);
|
||||
|
||||
if (!match) {
|
||||
return { citations: [], cleanContent: content };
|
||||
}
|
||||
|
||||
const citationsBlock = match[1] ?? "";
|
||||
// Match ALL citations blocks anywhere in content (not just at the start)
|
||||
const citationsRegex = /<citations>([\s\S]*?)<\/citations>/g;
|
||||
const citations: Citation[] = [];
|
||||
const seenUrls = new Set<string>(); // Deduplicate by URL
|
||||
let cleanContent = content;
|
||||
|
||||
// Parse each line as JSON
|
||||
const lines = citationsBlock.split("\n");
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed?.startsWith("{")) {
|
||||
try {
|
||||
const citation = JSON.parse(trimmed) as Citation;
|
||||
// Validate required fields
|
||||
if (citation.id && citation.url) {
|
||||
citations.push({
|
||||
id: citation.id,
|
||||
title: citation.title || "",
|
||||
url: citation.url,
|
||||
snippet: citation.snippet || "",
|
||||
});
|
||||
let match;
|
||||
while ((match = citationsRegex.exec(content)) !== null) {
|
||||
const citationsBlock = match[1] ?? "";
|
||||
|
||||
// Parse each line as JSON
|
||||
const lines = citationsBlock.split("\n");
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed?.startsWith("{")) {
|
||||
try {
|
||||
const citation = JSON.parse(trimmed) as Citation;
|
||||
// Validate required fields and deduplicate
|
||||
if (citation.id && citation.url && !seenUrls.has(citation.url)) {
|
||||
seenUrls.add(citation.url);
|
||||
citations.push({
|
||||
id: citation.id,
|
||||
title: citation.title || "",
|
||||
url: citation.url,
|
||||
snippet: citation.snippet || "",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid JSON lines - this can happen during streaming
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid JSON lines - this can happen during streaming
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the citations block from content
|
||||
const cleanContent = content.replace(citationsRegex, "").trim();
|
||||
// Remove ALL citations blocks from content
|
||||
cleanContent = content.replace(/<citations>[\s\S]*?<\/citations>/g, "").trim();
|
||||
|
||||
return { citations, cleanContent };
|
||||
}
|
||||
|
||||
@@ -217,3 +217,58 @@ export function findToolCallResult(toolCallId: string, messages: Message[]) {
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an uploaded file parsed from the <uploaded_files> tag
|
||||
*/
|
||||
export interface UploadedFile {
|
||||
filename: string;
|
||||
size: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of parsing uploaded files from message content
|
||||
*/
|
||||
export interface ParsedUploadedFiles {
|
||||
files: UploadedFile[];
|
||||
cleanContent: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse <uploaded_files> tag from message content and extract file information.
|
||||
* Returns the list of uploaded files and the content with the tag removed.
|
||||
*/
|
||||
export function parseUploadedFiles(content: string): ParsedUploadedFiles {
|
||||
// Match <uploaded_files>...</uploaded_files> tag
|
||||
const uploadedFilesRegex = /<uploaded_files>([\s\S]*?)<\/uploaded_files>/;
|
||||
const match = content.match(uploadedFilesRegex);
|
||||
|
||||
if (!match) {
|
||||
return { files: [], cleanContent: content };
|
||||
}
|
||||
|
||||
const uploadedFilesContent = match[1];
|
||||
const cleanContent = content.replace(uploadedFilesRegex, "").trim();
|
||||
|
||||
// Check if it's "No files have been uploaded yet."
|
||||
if (uploadedFilesContent.includes("No files have been uploaded yet.")) {
|
||||
return { files: [], cleanContent };
|
||||
}
|
||||
|
||||
// Parse file list
|
||||
// Format: - filename (size)\n Path: /path/to/file
|
||||
const fileRegex = /- ([^\n(]+)\s*\(([^)]+)\)\s*\n\s*Path:\s*([^\n]+)/g;
|
||||
const files: UploadedFile[] = [];
|
||||
let fileMatch;
|
||||
|
||||
while ((fileMatch = fileRegex.exec(uploadedFilesContent)) !== null) {
|
||||
files.push({
|
||||
filename: fileMatch[1].trim(),
|
||||
size: fileMatch[2].trim(),
|
||||
path: fileMatch[3].trim(),
|
||||
});
|
||||
}
|
||||
|
||||
return { files, cleanContent };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "katex/dist/katex.min.css";
|
||||
|
||||
@source "../node_modules/streamdown/dist/index.js";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user