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:
LofiSu
2026-01-29 12:29:13 +08:00
parent ad85b72064
commit c14378a312
10 changed files with 515 additions and 185 deletions

View File

@@ -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
*/

View File

@@ -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
*/

View File

@@ -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 };
}

View File

@@ -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 };
}

View File

@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "katex/dist/katex.min.css";
@source "../node_modules/streamdown/dist/index.js";