mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-22 05:34:45 +08:00
When only reasoning content exists (no main content), the citations block was not being parsed and removed. Now reasoning content also goes through parseCitations to hide the raw citations block. Co-authored-by: Cursor <cursoragent@cursor.com>
342 lines
10 KiB
TypeScript
342 lines
10 KiB
TypeScript
import type { Message } from "@langchain/langgraph-sdk";
|
|
import { FileIcon } from "lucide-react";
|
|
import { useParams } from "next/navigation";
|
|
import { memo, useMemo } from "react";
|
|
import rehypeKatex from "rehype-katex";
|
|
|
|
import {
|
|
CitationLink,
|
|
CitationsLoadingIndicator,
|
|
} from "@/components/ai-elements/inline-citation";
|
|
import {
|
|
Message as AIElementMessage,
|
|
MessageContent as AIElementMessageContent,
|
|
MessageResponse as AIElementMessageResponse,
|
|
MessageToolbar,
|
|
} from "@/components/ai-elements/message";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { resolveArtifactURL } from "@/core/artifacts/utils";
|
|
import {
|
|
type Citation,
|
|
buildCitationMap,
|
|
isCitationsBlockIncomplete,
|
|
parseCitations,
|
|
removeAllCitations,
|
|
} from "@/core/citations";
|
|
import {
|
|
extractContentFromMessage,
|
|
extractReasoningContentFromMessage,
|
|
parseUploadedFiles,
|
|
type UploadedFile,
|
|
} from "@/core/messages/utils";
|
|
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
|
import { humanMessagePlugins, streamdownPlugins } from "@/core/streamdown";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
import { CopyButton } from "../copy-button";
|
|
|
|
export function MessageListItem({
|
|
className,
|
|
message,
|
|
isLoading,
|
|
}: {
|
|
className?: string;
|
|
message: Message;
|
|
isLoading?: boolean;
|
|
}) {
|
|
const isHuman = message.type === "human";
|
|
return (
|
|
<AIElementMessage
|
|
className={cn("group/conversation-message relative w-full", className)}
|
|
from={isHuman ? "user" : "assistant"}
|
|
>
|
|
<MessageContent
|
|
className={isHuman ? "w-fit" : "w-full"}
|
|
message={message}
|
|
isLoading={isLoading}
|
|
/>
|
|
<MessageToolbar
|
|
className={cn(
|
|
isHuman ? "justify-end -bottom-9" : "-bottom-8",
|
|
"absolute right-0 left-0 z-20 opacity-0 transition-opacity delay-200 duration-300 group-hover/conversation-message:opacity-100",
|
|
)}
|
|
>
|
|
<div className="flex gap-1">
|
|
<CopyButton
|
|
clipboardData={removeAllCitations(
|
|
extractContentFromMessage(message) ??
|
|
extractReasoningContentFromMessage(message) ??
|
|
""
|
|
)}
|
|
/>
|
|
</div>
|
|
</MessageToolbar>
|
|
</AIElementMessage>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Custom link component that handles citations and external links
|
|
* Only links that are in citationMap are rendered as CitationLink badges
|
|
* Other links (project URLs, regular links) are rendered as plain links
|
|
*/
|
|
function MessageLink({
|
|
href,
|
|
children,
|
|
citationMap,
|
|
isHuman,
|
|
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & {
|
|
citationMap: Map<string, Citation>;
|
|
isHuman: boolean;
|
|
}) {
|
|
if (!href) return <span>{children}</span>;
|
|
|
|
const citation = citationMap.get(href);
|
|
|
|
// Only render as CitationLink badge if it's a citation (in citationMap)
|
|
// This ensures project URLs and regular links are not rendered as badges
|
|
if (citation && !isHuman) {
|
|
return (
|
|
<CitationLink citation={citation} href={href}>
|
|
{children}
|
|
</CitationLink>
|
|
);
|
|
}
|
|
|
|
// All other links (including project URLs) render as plain links
|
|
return (
|
|
<a
|
|
href={href}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-primary underline underline-offset-2 hover:no-underline"
|
|
>
|
|
{children}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Custom image component that handles artifact URLs
|
|
*/
|
|
function MessageImage({
|
|
src,
|
|
alt,
|
|
threadId,
|
|
maxWidth = "90%",
|
|
...props
|
|
}: React.ImgHTMLAttributes<HTMLImageElement> & {
|
|
threadId: string;
|
|
maxWidth?: string;
|
|
}) {
|
|
if (!src) return null;
|
|
|
|
const imgClassName = cn("overflow-hidden rounded-lg", `max-w-[${maxWidth}]`);
|
|
|
|
if (typeof src !== "string") {
|
|
return <img className={imgClassName} src={src} alt={alt} {...props} />;
|
|
}
|
|
|
|
const url = src.startsWith("/mnt/") ? resolveArtifactURL(src, threadId) : src;
|
|
|
|
return (
|
|
<a href={url} target="_blank" rel="noopener noreferrer">
|
|
<img className={imgClassName} src={url} alt={alt} {...props} />
|
|
</a>
|
|
);
|
|
}
|
|
|
|
function MessageContent_({
|
|
className,
|
|
message,
|
|
isLoading = false,
|
|
}: {
|
|
className?: string;
|
|
message: Message;
|
|
isLoading?: boolean;
|
|
}) {
|
|
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
|
const isHuman = message.type === "human";
|
|
const { thread_id } = useParams<{ thread_id: string }>();
|
|
|
|
// Extract and parse citations and uploaded files from message content
|
|
const { citations, cleanContent, uploadedFiles, isLoadingCitations } =
|
|
useMemo(() => {
|
|
const reasoningContent = extractReasoningContentFromMessage(message);
|
|
const rawContent = extractContentFromMessage(message);
|
|
|
|
// When only reasoning content exists (no main content), also parse citations
|
|
if (!isLoading && reasoningContent && !rawContent) {
|
|
const { citations, cleanContent } = parseCitations(reasoningContent);
|
|
return {
|
|
citations,
|
|
cleanContent,
|
|
uploadedFiles: [],
|
|
isLoadingCitations: false,
|
|
};
|
|
}
|
|
|
|
// For human messages, parse uploaded files first
|
|
if (isHuman && rawContent) {
|
|
const { files, cleanContent: contentWithoutFiles } =
|
|
parseUploadedFiles(rawContent);
|
|
const { citations, cleanContent: finalContent } =
|
|
parseCitations(contentWithoutFiles);
|
|
return {
|
|
citations,
|
|
cleanContent: finalContent,
|
|
uploadedFiles: files,
|
|
isLoadingCitations: false,
|
|
};
|
|
}
|
|
|
|
const { citations, cleanContent } = parseCitations(rawContent ?? "");
|
|
const isLoadingCitations =
|
|
isLoading && isCitationsBlockIncomplete(rawContent ?? "");
|
|
|
|
return { citations, cleanContent, uploadedFiles: [], isLoadingCitations };
|
|
}, [isLoading, message, isHuman]);
|
|
|
|
const citationMap = useMemo(() => buildCitationMap(citations), [citations]);
|
|
|
|
// Shared markdown components
|
|
const markdownComponents = useMemo(() => ({
|
|
a: (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
|
|
<MessageLink {...props} citationMap={citationMap} isHuman={isHuman} />
|
|
),
|
|
img: (props: React.ImgHTMLAttributes<HTMLImageElement>) => (
|
|
<MessageImage {...props} threadId={thread_id} maxWidth={isHuman ? "full" : "90%"} />
|
|
),
|
|
}), [citationMap, thread_id, isHuman]);
|
|
|
|
// Render message response
|
|
// Human messages use humanMessagePlugins (no autolink) to prevent URL bleeding into adjacent text
|
|
const messageResponse = cleanContent ? (
|
|
<AIElementMessageResponse
|
|
remarkPlugins={isHuman ? humanMessagePlugins.remarkPlugins : streamdownPlugins.remarkPlugins}
|
|
rehypePlugins={isHuman ? humanMessagePlugins.rehypePlugins : [...rehypePlugins, [rehypeKatex, { output: "html" }]]}
|
|
components={markdownComponents}
|
|
>
|
|
{cleanContent}
|
|
</AIElementMessageResponse>
|
|
) : null;
|
|
|
|
// Uploaded files list
|
|
const filesList = uploadedFiles.length > 0 && thread_id ? (
|
|
<UploadedFilesList files={uploadedFiles} threadId={thread_id} />
|
|
) : null;
|
|
|
|
// Citations loading indicator
|
|
const citationsLoadingIndicator = isLoadingCitations ? (
|
|
<CitationsLoadingIndicator citations={citations} className="my-3" />
|
|
) : null;
|
|
|
|
// Human messages with uploaded files: render outside bubble
|
|
if (isHuman && uploadedFiles.length > 0) {
|
|
return (
|
|
<div className={cn("ml-auto flex flex-col gap-2", className)}>
|
|
{filesList}
|
|
{messageResponse && (
|
|
<AIElementMessageContent className="w-fit">
|
|
{messageResponse}
|
|
</AIElementMessageContent>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Default rendering
|
|
return (
|
|
<AIElementMessageContent className={className}>
|
|
{filesList}
|
|
{messageResponse}
|
|
{citationsLoadingIndicator}
|
|
</AIElementMessageContent>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get file extension and check helpers
|
|
*/
|
|
const getFileExt = (filename: string) => filename.split(".").pop()?.toLowerCase() ?? "";
|
|
|
|
const FILE_TYPE_MAP: 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",
|
|
};
|
|
|
|
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"];
|
|
|
|
function getFileTypeLabel(filename: string): string {
|
|
const ext = getFileExt(filename);
|
|
return FILE_TYPE_MAP[ext] ?? (ext.toUpperCase() || "FILE");
|
|
}
|
|
|
|
function isImageFile(filename: string): boolean {
|
|
return IMAGE_EXTENSIONS.includes(getFileExt(filename));
|
|
}
|
|
|
|
/**
|
|
* Uploaded files list component
|
|
*/
|
|
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
|
|
*/
|
|
function UploadedFileCard({ file, threadId }: { file: UploadedFile; threadId: string }) {
|
|
if (!threadId) return null;
|
|
|
|
const isImage = isImageFile(file.filename);
|
|
const fileUrl = resolveArtifactURL(file.path, threadId);
|
|
|
|
if (isImage) {
|
|
return (
|
|
<a
|
|
href={fileUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="group border-border/40 relative block overflow-hidden rounded-lg border"
|
|
>
|
|
<img
|
|
src={fileUrl}
|
|
alt={file.filename}
|
|
className="h-32 w-auto max-w-[240px] object-cover transition-transform group-hover:scale-105"
|
|
/>
|
|
</a>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="bg-background border-border/40 flex max-w-[200px] min-w-[120px] 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">
|
|
{getFileTypeLabel(file.filename)}
|
|
</Badge>
|
|
<span className="text-muted-foreground text-[10px]">{file.size}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const MessageContent = memo(MessageContent_);
|