From c67f1af889b290b09c484a8ef3827134dfba115a Mon Sep 17 00:00:00 2001 From: ruitanglin Date: Wed, 4 Feb 2026 11:56:10 +0800 Subject: [PATCH 01/12] feat(citations): add shared citation components and optimize code ## New Features - Add `CitationLink` shared component for rendering citation hover cards - Add `CitationsLoadingIndicator` component for showing loading state - Add `removeAllCitations` utility to strip all citations from content - Add backend support for removing citations when downloading markdown files - Add i18n support for citation loading messages (en-US, zh-CN) ## Code Optimizations - Remove duplicate `ExternalLinkBadge` component, reuse `CitationLink` instead - Consolidate `remarkPlugins` config in `streamdownPlugins` to avoid duplication - Remove unused imports: `Citation`, `buildCitationMap`, `extractDomainFromUrl`, etc. - Remove unused `messages` parameter from `ToolCall` component - Remove unused `isWriteFile` parameter from `ArtifactFilePreview` component - Remove unused `useI18n` hook from `MessageContent` component ## Bug Fixes - Fix `remarkGfm` plugin configuration that prevented table rendering - Fix React Hooks rule violation: move `useMemo` to component top level - Replace `||` with `??` for nullish coalescing in clipboard data ## Code Cleanup - Remove debug console.log/info statements from: - `threads/hooks.ts` - `notification/hooks.ts` - `memory-settings-page.tsx` - Fix import order in `message-group.tsx` Co-authored-by: Cursor --- backend/src/gateway/routers/artifacts.py | 78 +++ .../ai-elements/inline-citation.tsx | 108 +++- .../artifacts/artifact-file-detail.tsx | 131 +---- .../workspace/messages/message-group.tsx | 71 ++- .../workspace/messages/message-list-item.tsx | 499 +++++++----------- .../settings/memory-settings-page.tsx | 2 - frontend/src/core/citations/index.ts | 1 + frontend/src/core/citations/utils.ts | 71 +++ frontend/src/core/i18n/locales/en-US.ts | 7 + frontend/src/core/i18n/locales/types.ts | 6 + frontend/src/core/i18n/locales/zh-CN.ts | 6 + frontend/src/core/notification/hooks.ts | 1 - frontend/src/core/streamdown/plugins.ts | 3 +- frontend/src/core/threads/hooks.ts | 6 - 14 files changed, 522 insertions(+), 468 deletions(-) diff --git a/backend/src/gateway/routers/artifacts.py b/backend/src/gateway/routers/artifacts.py index ec7a16a..9798193 100644 --- a/backend/src/gateway/routers/artifacts.py +++ b/backend/src/gateway/routers/artifacts.py @@ -1,5 +1,6 @@ import mimetypes import os +import re import zipfile from pathlib import Path from urllib.parse import quote @@ -61,6 +62,68 @@ def is_text_file_by_content(path: Path, sample_size: int = 8192) -> bool: return False +def remove_citations_block(content: str) -> str: + """Remove ALL citations from markdown content. + + Removes: + - ... blocks (complete and incomplete) + - [cite-N] references + - Citation markdown links that were converted from [cite-N] + + This is used for downloads to provide clean markdown without any citation references. + + Args: + content: The markdown content that may contain citations blocks. + + Returns: + Clean content with all citations completely removed. + """ + if not content: + return content + + result = content + + # Step 1: Parse and extract citation URLs before removing blocks + citation_urls = set() + citations_pattern = r'([\s\S]*?)' + for match in re.finditer(citations_pattern, content): + citations_block = match.group(1) + # Extract URLs from JSON lines + import json + for line in citations_block.split('\n'): + line = line.strip() + if line.startswith('{'): + try: + citation = json.loads(line) + if 'url' in citation: + citation_urls.add(citation['url']) + except (json.JSONDecodeError, ValueError): + pass + + # Step 2: Remove complete citations blocks + result = re.sub(r'[\s\S]*?', '', result) + + # Step 3: Remove incomplete citations blocks (at end of content during streaming) + if "" in result: + result = re.sub(r'[\s\S]*$', '', result) + + # Step 4: Remove all [cite-N] references + result = re.sub(r'\[cite-\d+\]', '', result) + + # Step 5: Remove markdown links that point to citation URLs + # Pattern: [text](url) + if citation_urls: + for url in citation_urls: + # Escape special regex characters in URL + escaped_url = re.escape(url) + result = re.sub(rf'\[[^\]]+\]\({escaped_url}\)', '', result) + + # Step 6: Clean up extra whitespace and newlines + result = re.sub(r'\n{3,}', '\n\n', result) # Replace 3+ newlines with 2 + + return result.strip() + + def _extract_file_from_skill_archive(zip_path: Path, internal_path: str) -> bytes | None: """Extract a file from a .skill ZIP archive. @@ -176,8 +239,23 @@ async def get_artifact(thread_id: str, path: str, request: Request) -> FileRespo # Encode filename for Content-Disposition header (RFC 5987) encoded_filename = quote(actual_path.name) + # Check if this is a markdown file that might contain citations + is_markdown = mime_type == "text/markdown" or actual_path.suffix.lower() in [".md", ".markdown"] + # if `download` query parameter is true, return the file as a download if request.query_params.get("download"): + # For markdown files, remove citations block before download + if is_markdown: + content = actual_path.read_text() + clean_content = remove_citations_block(content) + return Response( + content=clean_content.encode("utf-8"), + media_type="text/markdown", + headers={ + "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}", + "Content-Type": "text/markdown; charset=utf-8" + } + ) return FileResponse(path=actual_path, filename=actual_path.name, media_type=mime_type, headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"}) if mime_type and mime_type == "text/html": diff --git a/frontend/src/components/ai-elements/inline-citation.tsx b/frontend/src/components/ai-elements/inline-citation.tsx index 5977081..be6c651 100644 --- a/frontend/src/components/ai-elements/inline-citation.tsx +++ b/frontend/src/components/ai-elements/inline-citation.tsx @@ -13,7 +13,7 @@ import { HoverCardTrigger, } from "@/components/ui/hover-card"; import { cn } from "@/lib/utils"; -import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; +import { ExternalLinkIcon, ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; import { type ComponentProps, createContext, @@ -22,6 +22,10 @@ import { useEffect, useState, } from "react"; +import type { Citation } from "@/core/citations"; +import { extractDomainFromUrl } from "@/core/citations"; +import { Shimmer } from "./shimmer"; +import { useI18n } from "@/core/i18n/hooks"; export type InlineCitationProps = ComponentProps<"span">; @@ -285,3 +289,105 @@ export const InlineCitationQuote = ({ {children} ); + +/** + * Shared CitationLink component that renders a citation as a hover card badge + * Used across message-list-item, artifact-file-detail, and message-group + * + * When citation is provided, displays title and snippet from the citation. + * When citation is omitted, falls back to displaying the domain name extracted from href. + */ +export type CitationLinkProps = { + citation?: Citation; + href: string; + children: React.ReactNode; +}; + +export const CitationLink = ({ + citation, + href, + children, +}: CitationLinkProps) => { + const domain = extractDomainFromUrl(href); + + return ( + + + e.stopPropagation()} + > + + {children ?? domain} + + + + + + + + + ); +}; + +/** + * Shared CitationsLoadingIndicator component + * Used across message-list-item and message-group to show loading citations + */ +export type CitationsLoadingIndicatorProps = { + citations: Citation[]; + className?: string; +}; + +export const CitationsLoadingIndicator = ({ + citations, + className, +}: CitationsLoadingIndicatorProps) => { + const { t } = useI18n(); + + return ( +
+ + {citations.length > 0 + ? t.citations.loadingCitationsWithCount(citations.length) + : t.citations.loadingCitations} + + {citations.length > 0 && ( +
+ {citations.map((citation) => ( + + + {citation.title || extractDomainFromUrl(citation.url)} + + + ))} +
+ )} +
+ ); +}; diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx index 50528c8..f2496c5 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -2,7 +2,6 @@ import { Code2Icon, CopyIcon, DownloadIcon, - ExternalLinkIcon, EyeIcon, LoaderIcon, PackageIcon, @@ -22,13 +21,7 @@ import { ArtifactHeader, ArtifactTitle, } from "@/components/ai-elements/artifact"; -import { - InlineCitationCard, - InlineCitationCardBody, - InlineCitationSource, -} from "@/components/ai-elements/inline-citation"; -import { Badge } from "@/components/ui/badge"; -import { HoverCardTrigger } from "@/components/ui/hover-card"; +import { CitationLink } from "@/components/ai-elements/inline-citation"; import { Select, SelectItem } from "@/components/ui/select"; import { SelectContent, @@ -42,9 +35,8 @@ import { useArtifactContent } from "@/core/artifacts/hooks"; import { urlOfArtifact } from "@/core/artifacts/utils"; import { buildCitationMap, - extractDomainFromUrl, parseCitations, - type Citation, + removeAllCitations, } from "@/core/citations"; import { useI18n } from "@/core/i18n/hooks"; import { installSkill } from "@/core/skills/api"; @@ -110,6 +102,14 @@ export function ArtifactFileDetail({ return content; }, [content, language]); + // Get content without ANY citations for copy/download + const contentWithoutCitations = useMemo(() => { + if (language === "markdown" && content) { + return removeAllCitations(content); + } + return content; + }, [content, language]); + const [viewMode, setViewMode] = useState<"code" | "preview">("code"); const [isInstalling, setIsInstalling] = useState(false); @@ -220,7 +220,7 @@ export function ArtifactFileDetail({ disabled={!content} onClick={async () => { try { - await navigator.clipboard.writeText(content ?? ""); + await navigator.clipboard.writeText(contentWithoutCitations ?? ""); toast.success(t.clipboard.copiedToClipboard); } catch (error) { toast.error("Failed to copy to clipboard"); @@ -293,7 +293,6 @@ export function ArtifactFilePreview({ const parsed = parseCitations(content ?? ""); const map = buildCitationMap(parsed.citations); return { - citations: parsed.citations, cleanContent: parsed.cleanContent, citationMap: map, }; @@ -318,9 +317,9 @@ export function ArtifactFilePreview({ const citation = citationMap.get(href); if (citation) { return ( - + {children} - + ); } @@ -330,7 +329,7 @@ export function ArtifactFilePreview({ if (isExternalLink) { return ( - {children} + {children} ); } @@ -359,105 +358,3 @@ 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 ( - - - e.stopPropagation()} - > - - {children ?? domain} - - - - - - - - - ); -} - -/** - * External link badge component for artifact preview - */ -function ExternalLinkBadge({ - href, - children, -}: { - href: string; - children: React.ReactNode; -}) { - const domain = extractDomainFromUrl(href); - - return ( - - - - - {children ?? domain} - - - - - - - - - ); -} diff --git a/frontend/src/components/workspace/messages/message-group.tsx b/frontend/src/components/workspace/messages/message-group.tsx index 8a3d576..7b161ed 100644 --- a/frontend/src/components/workspace/messages/message-group.tsx +++ b/frontend/src/components/workspace/messages/message-group.tsx @@ -22,14 +22,17 @@ import { ChainOfThoughtStep, } from "@/components/ai-elements/chain-of-thought"; import { CodeBlock } from "@/components/ai-elements/code-block"; +import { CitationsLoadingIndicator } from "@/components/ai-elements/inline-citation"; import { MessageResponse } from "@/components/ai-elements/message"; import { Button } from "@/components/ui/button"; +import { parseCitations } from "@/core/citations"; import { useI18n } from "@/core/i18n/hooks"; import { extractReasoningContentFromMessage, findToolCallResult, } from "@/core/messages/utils"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; +import { streamdownPlugins } from "@/core/streamdown"; import { extractTitleFromMarkdown } from "@/core/utils/markdown"; import { env } from "@/env"; import { cn } from "@/lib/utils"; @@ -38,6 +41,8 @@ import { useArtifacts } from "../artifacts"; import { FlipDisplay } from "../flip-display"; import { Tooltip } from "../tooltip"; +import { useThread } from "./context"; + export function MessageGroup({ className, messages, @@ -115,7 +120,7 @@ export function MessageGroup({ + {step.reasoning ?? ""} } @@ -165,7 +170,7 @@ export function MessageGroup({ + {lastReasoningStep.reasoning ?? ""} } @@ -198,6 +203,13 @@ function ToolCall({ const { t } = useI18n(); const { setOpen, autoOpen, autoSelect, selectedArtifact, select } = useArtifacts(); + const { thread } = useThread(); + const threadIsLoading = thread.isLoading; + + // Move useMemo to top level to comply with React Hooks rules + const fileContent = typeof args.content === "string" ? args.content : ""; + const { citations } = useMemo(() => parseCitations(fileContent), [fileContent]); + if (name === "web_search") { let label: React.ReactNode = t.toolCalls.searchForRelatedInfo; if (typeof args.query === "string") { @@ -353,29 +365,42 @@ function ToolCall({ setOpen(true); }, 100); } + + // Check if this is a markdown file with citations + const isMarkdown = path?.toLowerCase().endsWith(".md") || path?.toLowerCase().endsWith(".markdown"); + const hasCitationsBlock = fileContent.includes(""); + const showCitationsLoading = isMarkdown && threadIsLoading && hasCitationsBlock && isLast; + return ( - { - select( - new URL( - `write-file:${path}?message_id=${messageId}&tool_call_id=${id}`, - ).toString(), - ); - setOpen(true); - }} - > - {path && ( - - - {path} - - + <> + { + select( + new URL( + `write-file:${path}?message_id=${messageId}&tool_call_id=${id}`, + ).toString(), + ); + setOpen(true); + }} + > + {path && ( + + + {path} + + + )} + + {showCitationsLoading && ( +
+ +
)} -
+ ); } else if (name === "bash") { const description: string | undefined = (args as { description: string }) diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index dc516a0..28927aa 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -1,14 +1,12 @@ import type { Message } from "@langchain/langgraph-sdk"; -import { ExternalLinkIcon, FileIcon } from "lucide-react"; +import { FileIcon } 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, - InlineCitationCardBody, - InlineCitationSource, + CitationLink, + CitationsLoadingIndicator, } from "@/components/ai-elements/inline-citation"; import { Message as AIElementMessage, @@ -17,12 +15,11 @@ import { MessageToolbar, } from "@/components/ai-elements/message"; import { Badge } from "@/components/ui/badge"; -import { HoverCardTrigger } from "@/components/ui/hover-card"; import { resolveArtifactURL } from "@/core/artifacts/utils"; import { type Citation, buildCitationMap, - extractDomainFromUrl, + isCitationsBlockIncomplete, parseCitations, } from "@/core/citations"; import { @@ -46,29 +43,29 @@ export function MessageListItem({ message: Message; isLoading?: boolean; }) { + const isHuman = message.type === "human"; return (
@@ -77,6 +74,71 @@ export function MessageListItem({ ); } +/** + * Custom link component that handles citations and external links + */ +function MessageLink({ + href, + children, + citationMap, + ...props +}: React.AnchorHTMLAttributes & { + citationMap: Map; +}) { + if (!href) return {children}; + + const citation = citationMap.get(href); + if (citation) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +} + +/** + * Custom image component that handles artifact URLs + */ +function MessageImage({ + src, + alt, + threadId, + maxWidth = "90%", + ...props +}: React.ImgHTMLAttributes & { + threadId: string; + maxWidth?: string; +}) { + if (!src) return null; + + const imgClassName = cn("overflow-hidden rounded-lg", `max-w-[${maxWidth}]`); + + if (typeof src !== "string") { + return {alt}; + } + + const url = src.startsWith("/mnt/") ? resolveArtifactURL(src, threadId) : src; + + return ( + + {alt} + + ); +} + function MessageContent_({ className, message, @@ -88,295 +150,159 @@ function MessageContent_({ }) { const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); const isHuman = message.type === "human"; - - // 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, - uploadedFiles: [], - }; - } - - // 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(() => buildCitationMap(citations), [citations]); - const { thread_id } = useParams<{ thread_id: string }>(); - // For human messages with uploaded files, render files outside the bubble + // Extract and parse citations and uploaded files from message content + const { citations, cleanContent, uploadedFiles, isLoadingCitations } = + useMemo(() => { + const reasoningContent = extractReasoningContentFromMessage(message); + const rawContent = extractContentFromMessage(message); + + if (!isLoading && reasoningContent && !rawContent) { + return { + citations: [], + cleanContent: reasoningContent, + 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) => ( + + ), + img: (props: React.ImgHTMLAttributes) => ( + + ), + }), [citationMap, thread_id, isHuman]); + + // Render message response + const messageResponse = cleanContent ? ( + + {cleanContent} + + ) : null; + + // Uploaded files list + const filesList = uploadedFiles.length > 0 && thread_id ? ( + + ) : null; + + // Citations loading indicator + const citationsLoadingIndicator = isLoadingCitations ? ( + + ) : null; + + // Human messages with uploaded files: render outside bubble if (isHuman && uploadedFiles.length > 0) { return (
- {/* Uploaded files outside the message bubble */} - - - {/* Message content inside the bubble (only if there's text) */} - {cleanContent && ( + {filesList} + {messageResponse && ( - ) => { - if (!href) { - return {children}; - } - - // Check if this link matches a citation - const citation = citationMap.get(href); - if (citation) { - return ( - - {children} - - ); - } - - // Regular external link - return ( - - {children} - - ); - }, - img: ({ - src, - alt, - }: React.ImgHTMLAttributes) => { - if (!src) return null; - if (typeof src !== "string") { - return ( - {alt} - ); - } - let url = src; - if (src.startsWith("/mnt/")) { - url = resolveArtifactURL(src, thread_id); - } - return ( - - {alt} - - ); - }, - }} - > - {cleanContent} - + {messageResponse} )}
); } - // Default rendering for non-human messages or human messages without files + // Default rendering return ( - {/* Uploaded files for human messages - show first */} - {uploadedFiles.length > 0 && thread_id && ( - - )} - - {/* Message content - always show if present */} - {cleanContent && ( - ) => { - if (!href) { - return {children}; - } - - // Check if this link matches a citation - const citation = citationMap.get(href); - if (citation) { - return ( - - {children} - - ); - } - - // Regular external link - return ( - - {children} - - ); - }, - img: ({ src, alt }: React.ImgHTMLAttributes) => { - if (!src) return null; - if (typeof src !== "string") { - return ( - {alt} - ); - } - let url = src; - if (src.startsWith("/mnt/")) { - url = resolveArtifactURL(src, thread_id); - } - return ( - - {alt} - - ); - }, - }} - > - {cleanContent} - - )} + {filesList} + {messageResponse} + {citationsLoadingIndicator} ); } /** - * Get file type label from filename extension + * Get file extension and check helpers */ +const getFileExt = (filename: string) => filename.split(".").pop()?.toLowerCase() ?? ""; + +const FILE_TYPE_MAP: Record = { + 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 = filename.split(".").pop()?.toLowerCase() ?? ""; - const typeMap: Record = { - 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"; + const ext = getFileExt(filename); + return FILE_TYPE_MAP[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); + return IMAGE_EXTENSIONS.includes(getFileExt(filename)); } /** - * Uploaded files list component that displays files as cards or image thumbnails (Claude-style) + * Uploaded files list component */ -function UploadedFilesList({ - files, - threadId, -}: { - files: UploadedFile[]; - threadId: string; -}) { +function UploadedFilesList({ files, threadId }: { files: UploadedFile[]; threadId: string }) { if (files.length === 0) return null; return (
{files.map((file, index) => ( - + ))}
); } /** - * Single uploaded file card component (Claude-style) - * Shows image thumbnail for image files, file card for others + * Single uploaded file card component */ -function UploadedFileCard({ - file, - threadId, -}: { - file: UploadedFile; - threadId: string; -}) { - const typeLabel = getFileTypeLabel(file.filename); +function UploadedFileCard({ file, threadId }: { file: UploadedFile; threadId: string }) { + if (!threadId) return null; + const isImage = isImageFile(file.filename); + const fileUrl = resolveArtifactURL(file.path, threadId); - // 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 ( {file.filename} @@ -384,24 +310,17 @@ function UploadedFileCard({ ); } - // For non-image files, show file card return (
- + {file.filename}
- - {typeLabel} + + {getFileTypeLabel(file.filename)} {file.size}
@@ -409,58 +328,4 @@ function UploadedFileCard({ ); } -/** - * Citation link component that renders as a hover card badge - */ -function CitationLink({ - citation, - href, - children, -}: { - citation: Citation; - href: string; - children: React.ReactNode; -}) { - const domain = extractDomainFromUrl(href); - - return ( - - -
e.stopPropagation()} - > - - {children ?? domain} - - - - - - - - - ); -} const MessageContent = memo(MessageContent_); diff --git a/frontend/src/components/workspace/settings/memory-settings-page.tsx b/frontend/src/components/workspace/settings/memory-settings-page.tsx index 860aef3..5bd16a9 100644 --- a/frontend/src/components/workspace/settings/memory-settings-page.tsx +++ b/frontend/src/components/workspace/settings/memory-settings-page.tsx @@ -37,8 +37,6 @@ function memoryToMarkdown( ) { const parts: string[] = []; - console.info(memory); - parts.push(`## ${t.settings.memory.markdown.overview}`); parts.push(`- **${t.common.version}**: \`${memory.version}\``); parts.push( diff --git a/frontend/src/core/citations/index.ts b/frontend/src/core/citations/index.ts index bf3a9eb..fd2a2aa 100644 --- a/frontend/src/core/citations/index.ts +++ b/frontend/src/core/citations/index.ts @@ -3,6 +3,7 @@ export { buildCitationMap, extractDomainFromUrl, isCitationsBlockIncomplete, + removeAllCitations, } from "./utils"; export type { Citation, ParseCitationsResult } from "./utils"; diff --git a/frontend/src/core/citations/utils.ts b/frontend/src/core/citations/utils.ts index aadd0e1..699900b 100644 --- a/frontend/src/core/citations/utils.ts +++ b/frontend/src/core/citations/utils.ts @@ -76,6 +76,29 @@ export function parseCitations(content: string): ParseCitationsResult { cleanContent = cleanContent.replace(/[\s\S]*$/g, "").trim(); } + // Convert [cite-N] references to markdown links + // Example: [cite-1] -> [Title](url) + if (citations.length > 0) { + // Build a map from citation id to citation object + const idMap = new Map(); + for (const citation of citations) { + idMap.set(citation.id, citation); + } + + // Replace all [cite-N] patterns with markdown links + cleanContent = cleanContent.replace(/\[cite-(\d+)\]/g, (match, num) => { + const citeId = `cite-${num}`; + const citation = idMap.get(citeId); + if (citation) { + // Use title if available, otherwise use domain + const linkText = citation.title || extractDomainFromUrl(citation.url); + return `[${linkText}](${citation.url})`; + } + // If citation not found, keep the original text + return match; + }); + } + return { citations, cleanContent }; } @@ -129,3 +152,51 @@ export function isCitationsBlockIncomplete(content: string): boolean { return hasOpenTag && !hasCloseTag; } + +/** + * Remove ALL citations from content, including: + * - blocks + * - [cite-N] references + * - Citation markdown links that were converted from [cite-N] + * + * This is used for copy/download operations where we want clean content without any references. + * + * @param content - The raw content that may contain citations + * @returns Content with all citations completely removed + */ +export function removeAllCitations(content: string): string { + if (!content) { + return content; + } + + let result = content; + + // Step 1: Remove all blocks (complete and incomplete) + result = result.replace(/[\s\S]*?<\/citations>/g, ""); + result = result.replace(/[\s\S]*$/g, ""); + + // Step 2: Remove all [cite-N] references + result = result.replace(/\[cite-\d+\]/g, ""); + + // Step 3: Parse to find citation URLs and remove those specific links + const parsed = parseCitations(content); + const citationUrls = new Set(parsed.citations.map(c => c.url)); + + // Remove markdown links that point to citation URLs + // Pattern: [text](url) + result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => { + // If this URL is a citation, remove the entire link + if (citationUrls.has(url)) { + return ""; + } + // Keep non-citation links + return match; + }); + + // Step 4: Clean up extra whitespace and newlines + result = result + .replace(/\n{3,}/g, "\n\n") // Replace 3+ newlines with 2 + .trim(); + + return result; +} diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 202c726..c0ef46b 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -162,6 +162,13 @@ export const enUS: Translations = { startConversation: "Start a conversation to see messages here", }, + // Citations + citations: { + loadingCitations: "Organizing citations...", + loadingCitationsWithCount: (count: number) => + `Organizing ${count} citation${count === 1 ? "" : "s"}...`, + }, + // Chats chats: { searchChats: "Search chats", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index aa83482..8da8d53 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -111,6 +111,12 @@ export interface Translations { startConversation: string; }; + // Citations + citations: { + loadingCitations: string; + loadingCitationsWithCount: (count: number) => string; + }; + // Chats chats: { searchChats: string; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 07847b6..a4b5209 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -159,6 +159,12 @@ export const zhCN: Translations = { startConversation: "开始新的对话以查看消息", }, + // Citations + citations: { + loadingCitations: "正在整理引用...", + loadingCitationsWithCount: (count: number) => `正在整理 ${count} 个引用...`, + }, + // Chats chats: { searchChats: "搜索对话", diff --git a/frontend/src/core/notification/hooks.ts b/frontend/src/core/notification/hooks.ts index 102e750..e58a51d 100644 --- a/frontend/src/core/notification/hooks.ts +++ b/frontend/src/core/notification/hooks.ts @@ -78,7 +78,6 @@ export function useNotification(): UseNotificationReturn { // Optional: Add event listeners notification.onclick = () => { - console.log("Notification clicked"); window.focus(); notification.close(); }; diff --git a/frontend/src/core/streamdown/plugins.ts b/frontend/src/core/streamdown/plugins.ts index a3cf74f..ce99f31 100644 --- a/frontend/src/core/streamdown/plugins.ts +++ b/frontend/src/core/streamdown/plugins.ts @@ -5,7 +5,8 @@ import type { StreamdownProps } from "streamdown"; export const streamdownPlugins = { remarkPlugins: [ - [remarkGfm, [remarkMath, { singleDollarTextMath: true }]], + remarkGfm, + [remarkMath, { singleDollarTextMath: true }], ] as StreamdownProps["remarkPlugins"], rehypePlugins: [ [rehypeKatex, { output: "html" }], diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index dbb0e1d..9f76e55 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -206,12 +206,6 @@ export function useRenameThread() { }); }, onSuccess(_, { threadId, title }) { - queryClient.setQueryData( - ["thread", "state", threadId], - (oldData: Array) => { - console.info("oldData", oldData); - }, - ); queryClient.setQueriesData( { queryKey: ["threads", "search"], From f6e625ec3b7ffc74fc9768cd273b067b6c584540 Mon Sep 17 00:00:00 2001 From: ruitanglin Date: Wed, 4 Feb 2026 16:34:12 +0800 Subject: [PATCH 02/12] fix(citations): improve citation link rendering and copy behavior - Use citation.title for display text in CitationLink to ensure correct titles show during streaming (instead of generic "Source" text) - Render all external links as CitationLink badges for consistent styling during streaming output - Add removeAllCitations when copying message content to clipboard - Simplify citations_format prompt for cleaner AI output Co-authored-by: Cursor --- backend/src/agents/lead_agent/prompt.py | 26 ++++++------------- .../ai-elements/inline-citation.tsx | 7 ++++- .../workspace/messages/message-list-item.tsx | 19 +++++++++----- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/backend/src/agents/lead_agent/prompt.py b/backend/src/agents/lead_agent/prompt.py index 2076374..b97d6f5 100644 --- a/backend/src/agents/lead_agent/prompt.py +++ b/backend/src/agents/lead_agent/prompt.py @@ -123,35 +123,25 @@ You have access to skills that provide optimized workflows for specific tasks. E -**FORMAT** - After web_search, ALWAYS include citations in your output: -**For chat responses:** -Your visible response MUST start with citations block, then content with inline links: +After web_search, ALWAYS include citations in your output and MUST start with a `` block in JSONL format: -{{"id": "cite-1", "title": "Page Title", "url": "https://example.com/page", "snippet": "Brief description"}} +{{"id": "cite-1", "title": "Source Title 1", "url": "https://example.com/page1", "snippet": "Brief description of source 1"}} +... -Content with inline links... -**For files (write_file):** -File content MUST start with citations block, then content with inline links: - -{{"id": "cite-1", "title": "Page Title", "url": "https://example.com/page", "snippet": "Brief description"}} - -# Document Title -Content with inline [Source Name](full_url) links... - -**RULES:** -- `` block MUST be FIRST (in both chat response AND file content) -- Write full content naturally, add [Source Name](full_url) at end of sentence/paragraph +**Rules:** +- Write content naturally, add [Source Name](full_url) at end of sentence/paragraph - NEVER use "According to [Source]" format - write content first, then add citation link at end -- Example: "AI agents will transform digital work ([Microsoft](url))" NOT "According to [Microsoft](url), AI agents will..." **Example:** {{"id": "cite-1", "title": "AI Trends 2026", "url": "https://techcrunch.com/ai-trends", "snippet": "Tech industry predictions"}} +{{"id": "cite-2", "title": "OpenAI Research", "url": "https://openai.com/research", "snippet": "Latest AI research developments"}} -The key AI trends for 2026 include enhanced reasoning capabilities, multimodal integration, and improved efficiency [TechCrunch](https://techcrunch.com/ai-trends). +The key AI trends for 2026 include enhanced reasoning capabilities and multimodal integration [TechCrunch](https://techcrunch.com/ai-trends). Recent breakthroughs in language models have also accelerated progress [OpenAI](https://openai.com/research). + - **Clarification First**: ALWAYS clarify unclear/missing/ambiguous requirements BEFORE starting work - never assume or guess - Skill First: Always load the relevant skill before starting **complex** tasks. diff --git a/frontend/src/components/ai-elements/inline-citation.tsx b/frontend/src/components/ai-elements/inline-citation.tsx index be6c651..b9e206e 100644 --- a/frontend/src/components/ai-elements/inline-citation.tsx +++ b/frontend/src/components/ai-elements/inline-citation.tsx @@ -309,6 +309,11 @@ export const CitationLink = ({ children, }: CitationLinkProps) => { const domain = extractDomainFromUrl(href); + + // Priority: citation.title > domain + // When citation has title, use it for consistent display + // This ensures correct title shows even during streaming when children might be generic + const displayText = citation?.title || domain; return ( @@ -324,7 +329,7 @@ export const CitationLink = ({ variant="secondary" className="hover:bg-secondary/80 mx-0.5 cursor-pointer gap-1 rounded-full px-2 py-0.5 text-xs font-normal" > - {children ?? domain} + {displayText} diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index 28927aa..6336e0f 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -21,6 +21,7 @@ import { buildCitationMap, isCitationsBlockIncomplete, parseCitations, + removeAllCitations, } from "@/core/citations"; import { extractContentFromMessage, @@ -62,11 +63,11 @@ export function MessageListItem({ >
@@ -76,19 +77,25 @@ export function MessageListItem({ /** * Custom link component that handles citations and external links + * All external links (http/https) are rendered as CitationLink badges + * to ensure consistent styling during streaming */ function MessageLink({ href, children, citationMap, - ...props }: React.AnchorHTMLAttributes & { citationMap: Map; }) { if (!href) return {children}; const citation = citationMap.get(href); - if (citation) { + + // Check if it's an external link (http/https) + const isExternalLink = href.startsWith("http://") || href.startsWith("https://"); + + // All external links use CitationLink for consistent styling during streaming + if (isExternalLink) { return ( {children} @@ -96,13 +103,11 @@ function MessageLink({ ); } + // Internal/anchor links use simple anchor tag return ( {children} From 1b0c0160939ae29198a169056064d9e96480c5d9 Mon Sep 17 00:00:00 2001 From: ruitanglin Date: Fri, 6 Feb 2026 14:28:28 +0800 Subject: [PATCH 03/12] fix(citations): only render CitationLink badges for AI messages Human messages should display links as plain underlined text, not as citation badges. This preserves the original user input appearance when users paste URLs in their messages. Co-authored-by: Cursor --- .../workspace/messages/message-list-item.tsx | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index 6336e0f..c69dc10 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -77,24 +77,40 @@ export function MessageListItem({ /** * Custom link component that handles citations and external links - * All external links (http/https) are rendered as CitationLink badges - * to ensure consistent styling during streaming + * For AI messages: external links (http/https) are rendered as CitationLink badges + * For human messages: links are rendered as plain text/links */ function MessageLink({ href, children, citationMap, + isHuman, }: React.AnchorHTMLAttributes & { citationMap: Map; + isHuman: boolean; }) { if (!href) return {children}; + // Human messages: render links as plain underlined text + if (isHuman) { + return ( + + {children} + + ); + } + const citation = citationMap.get(href); // Check if it's an external link (http/https) const isExternalLink = href.startsWith("http://") || href.startsWith("https://"); - // All external links use CitationLink for consistent styling during streaming + // AI messages: external links use CitationLink for consistent styling during streaming if (isExternalLink) { return ( @@ -198,7 +214,7 @@ function MessageContent_({ // Shared markdown components const markdownComponents = useMemo(() => ({ a: (props: React.AnchorHTMLAttributes) => ( - + ), img: (props: React.ImgHTMLAttributes) => ( From c8c4d2fc953c634867e721698ce6fa1d0e1020fe Mon Sep 17 00:00:00 2001 From: ruitanglin Date: Fri, 6 Feb 2026 14:30:57 +0800 Subject: [PATCH 04/12] fix(messages): prevent URL autolink bleeding into adjacent text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For human messages, disable remark-gfm autolink feature to prevent URLs from incorrectly including adjacent text (especially Chinese characters) as part of the link. This ensures that when users input "https://example.com 帮我分析", only the URL becomes a link. Co-authored-by: Cursor --- .../workspace/messages/message-list-item.tsx | 7 ++++--- frontend/src/core/streamdown/plugins.ts | 12 ++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index c69dc10..96049ae 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -30,7 +30,7 @@ import { type UploadedFile, } from "@/core/messages/utils"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; -import { streamdownPlugins } from "@/core/streamdown"; +import { humanMessagePlugins, streamdownPlugins } from "@/core/streamdown"; import { cn } from "@/lib/utils"; import { CopyButton } from "../copy-button"; @@ -222,10 +222,11 @@ function MessageContent_({ }), [citationMap, thread_id, isHuman]); // Render message response + // Human messages use humanMessagePlugins (no autolink) to prevent URL bleeding into adjacent text const messageResponse = cleanContent ? ( {cleanContent} diff --git a/frontend/src/core/streamdown/plugins.ts b/frontend/src/core/streamdown/plugins.ts index ce99f31..b0d9824 100644 --- a/frontend/src/core/streamdown/plugins.ts +++ b/frontend/src/core/streamdown/plugins.ts @@ -12,3 +12,15 @@ export const streamdownPlugins = { [rehypeKatex, { output: "html" }], ] as StreamdownProps["rehypePlugins"], }; + +// Plugins for human messages - no autolink to prevent URL bleeding into adjacent text +export const humanMessagePlugins = { + remarkPlugins: [ + // Use remark-gfm without autolink literals by not including it + // Only include math support for human messages + [remarkMath, { singleDollarTextMath: true }], + ] as StreamdownProps["remarkPlugins"], + rehypePlugins: [ + [rehypeKatex, { output: "html" }], + ] as StreamdownProps["rehypePlugins"], +}; From f43522bd274579567004b312c28ccb4096d81b6d Mon Sep 17 00:00:00 2001 From: ruitanglin Date: Fri, 6 Feb 2026 14:38:31 +0800 Subject: [PATCH 05/12] fix(prompt): clarify citation link format must include URL AI was outputting bare brackets like [arXiv:xxx] without URLs, which do not render as links. Updated prompt to explicitly show correct vs wrong formats and require complete markdown links. Co-authored-by: Cursor --- backend/src/agents/lead_agent/prompt.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/backend/src/agents/lead_agent/prompt.py b/backend/src/agents/lead_agent/prompt.py index b97d6f5..b59df08 100644 --- a/backend/src/agents/lead_agent/prompt.py +++ b/backend/src/agents/lead_agent/prompt.py @@ -123,15 +123,20 @@ You have access to skills that provide optimized workflows for specific tasks. E -After web_search, ALWAYS include citations in your output and MUST start with a `` block in JSONL format: - -{{"id": "cite-1", "title": "Source Title 1", "url": "https://example.com/page1", "snippet": "Brief description of source 1"}} -... - +After web_search, ALWAYS include citations in your output: + +1. Start with a `` block in JSONL format listing all sources +2. In content, use FULL markdown link format: [Short Title](full_url) + +**CRITICAL - Citation Link Format:** +- CORRECT: `[TechCrunch](https://techcrunch.com/ai-trends)` - full markdown link with URL +- WRONG: `[arXiv:2502.19166]` - missing URL, will NOT render as link +- WRONG: `[Source]` - missing URL, will NOT render as link **Rules:** -- Write content naturally, add [Source Name](full_url) at end of sentence/paragraph -- NEVER use "According to [Source]" format - write content first, then add citation link at end +- Every citation MUST be a complete markdown link with URL: `[Title](https://...)` +- Write content naturally, add citation link at end of sentence/paragraph +- NEVER use bare brackets like `[arXiv:xxx]` or `[Source]` without URL **Example:** From acbf2fb453f21f05badaacdfcb7a45f3d0a46ab2 Mon Sep 17 00:00:00 2001 From: ruitanglin Date: Fri, 6 Feb 2026 15:06:51 +0800 Subject: [PATCH 06/12] fix(citations): use markdown link text as fallback for display When citation data is not available, use the markdown link text (children) as display text instead of just the domain. This ensures that links like [OpenJudge](github.com/...) show 'OpenJudge' instead of just 'github.com'. Co-authored-by: Cursor --- .../src/components/ai-elements/inline-citation.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/ai-elements/inline-citation.tsx b/frontend/src/components/ai-elements/inline-citation.tsx index b9e206e..dde6e31 100644 --- a/frontend/src/components/ai-elements/inline-citation.tsx +++ b/frontend/src/components/ai-elements/inline-citation.tsx @@ -310,10 +310,14 @@ export const CitationLink = ({ }: CitationLinkProps) => { const domain = extractDomainFromUrl(href); - // Priority: citation.title > domain - // When citation has title, use it for consistent display - // This ensures correct title shows even during streaming when children might be generic - const displayText = citation?.title || domain; + // Priority: citation.title > children (if meaningful) > domain + // - citation.title: from parsed block, most accurate + // - children: from markdown link text [Text](url), used when no citation data + // - domain: fallback when both above are unavailable + // Skip children if it's a generic placeholder like "Source" + const childrenText = typeof children === "string" ? children : null; + const isGenericText = childrenText === "Source" || childrenText === "来源"; + const displayText = citation?.title || (!isGenericText && childrenText) || domain; return ( From 1ce154fa71c1264f319eb1c571664f17f284b57a Mon Sep 17 00:00:00 2001 From: ruitanglin Date: Fri, 6 Feb 2026 15:15:45 +0800 Subject: [PATCH 07/12] fix(citations): only render citation badges for links in citationMap Project URLs and regular links should be rendered as plain underlined links, not as citation badges. Only links that are actual citations (present in citationMap) should be rendered as badges. Co-authored-by: Cursor --- .../workspace/messages/message-list-item.tsx | 30 +++++-------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index 96049ae..51323a2 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -77,8 +77,8 @@ export function MessageListItem({ /** * Custom link component that handles citations and external links - * For AI messages: external links (http/https) are rendered as CitationLink badges - * For human messages: links are rendered as plain text/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, @@ -91,27 +91,11 @@ function MessageLink({ }) { if (!href) return {children}; - // Human messages: render links as plain underlined text - if (isHuman) { - return ( - - {children} - - ); - } - const citation = citationMap.get(href); - // Check if it's an external link (http/https) - const isExternalLink = href.startsWith("http://") || href.startsWith("https://"); - - // AI messages: external links use CitationLink for consistent styling during streaming - if (isExternalLink) { + // 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 ( {children} @@ -119,10 +103,12 @@ function MessageLink({ ); } - // Internal/anchor links use simple anchor tag + // All other links (including project URLs) render as plain links return ( {children} From 365e3f430478d462fa8caa4bd3cad416ecdbb6f8 Mon Sep 17 00:00:00 2001 From: ruitanglin Date: Fri, 6 Feb 2026 15:55:53 +0800 Subject: [PATCH 08/12] fix(artifacts): only render citation badges for links in citationMap Same fix as message-list-item: project URLs and regular links in artifact file preview should be rendered as plain links, not badges. Only actual citations (in citationMap) should be rendered as badges. Co-authored-by: Cursor --- .../artifacts/artifact-file-detail.tsx | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx index f2496c5..e4315f3 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -313,7 +313,7 @@ export function ArtifactFilePreview({ return {children}; } - // Check if it's a citation link + // Only render as CitationLink badge if it's a citation (in citationMap) const citation = citationMap.get(href); if (citation) { return ( @@ -323,19 +323,14 @@ export function ArtifactFilePreview({ ); } - // Check if it's an external link (http/https) - const isExternalLink = - href.startsWith("http://") || href.startsWith("https://"); - - if (isExternalLink) { - return ( - {children} - ); - } - - // Internal/anchor link + // All other links (including project URLs) render as plain links return ( - + {children} ); From 579dccbdcec13c5622deadeb90f938d1dca8be3f Mon Sep 17 00:00:00 2001 From: ruitanglin Date: Fri, 6 Feb 2026 16:04:49 +0800 Subject: [PATCH 09/12] fix(citations): parse citations in reasoning content 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 --- .../src/components/workspace/messages/message-list-item.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index 51323a2..333b42a 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -165,10 +165,12 @@ function MessageContent_({ 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: reasoningContent, + citations, + cleanContent, uploadedFiles: [], isLoadingCitations: false, }; From 697c683dfaf4badbe0818e44e30889f9729c78a4 Mon Sep 17 00:00:00 2001 From: ruitanglin Date: Fri, 6 Feb 2026 16:09:03 +0800 Subject: [PATCH 10/12] fix(citations): render external links as badges during streaming During streaming when citations are still loading (isLoadingCitations=true), all external links should be rendered as badges since we don't know yet which links are citations. After streaming completes, only links in citationMap are rendered as badges. Co-authored-by: Cursor --- .../workspace/messages/message-list-item.tsx | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index 333b42a..80a5438 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -77,25 +77,43 @@ export function MessageListItem({ /** * 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 + * - During streaming (isLoadingCitations=true): all external links render as badges + * - After streaming: only links in citationMap render as badges + * - Human messages and non-citation links render as plain links */ function MessageLink({ href, children, citationMap, isHuman, + isLoadingCitations, }: React.AnchorHTMLAttributes & { citationMap: Map; isHuman: boolean; + isLoadingCitations: boolean; }) { if (!href) return {children}; + // Human messages always render as plain links + if (isHuman) { + return ( + + {children} + + ); + } + const citation = citationMap.get(href); + const isExternalLink = href.startsWith("http://") || href.startsWith("https://"); - // 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) { + // During streaming: render all external links as badges (citations not yet fully loaded) + // After streaming: only render links in citationMap as badges + if (citation || (isLoadingCitations && isExternalLink)) { return ( {children} @@ -103,7 +121,7 @@ function MessageLink({ ); } - // All other links (including project URLs) render as plain links + // Non-citation links render as plain links return ( ({ a: (props: React.AnchorHTMLAttributes) => ( - + ), img: (props: React.ImgHTMLAttributes) => ( ), - }), [citationMap, thread_id, isHuman]); + }), [citationMap, thread_id, isHuman, isLoadingCitations]); // Render message response // Human messages use humanMessagePlugins (no autolink) to prevent URL bleeding into adjacent text From 666b747b8ad14bad771fb4755016267ae55215c2 Mon Sep 17 00:00:00 2001 From: ruitanglin Date: Fri, 6 Feb 2026 16:10:29 +0800 Subject: [PATCH 11/12] fix(citations): only citation links in citationMap render as badges Revert streaming logic - only links that are actually in citationMap should render as badges. This prevents project URLs and other regular links from being incorrectly rendered as citation badges. During streaming, links may initially appear as plain links until the citations block is fully parsed, then they will update to badge style. Co-authored-by: Cursor --- .../workspace/messages/message-list-item.tsx | 33 ++++--------------- 1 file changed, 7 insertions(+), 26 deletions(-) diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index 80a5438..1af53c4 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -77,43 +77,24 @@ export function MessageListItem({ /** * Custom link component that handles citations and external links - * - During streaming (isLoadingCitations=true): all external links render as badges - * - After streaming: only links in citationMap render as badges - * - Human messages and non-citation links render as plain links + * Only links in citationMap are rendered as CitationLink badges + * Other links (project URLs, regular links) are rendered as plain links */ function MessageLink({ href, children, citationMap, isHuman, - isLoadingCitations, }: React.AnchorHTMLAttributes & { citationMap: Map; isHuman: boolean; - isLoadingCitations: boolean; }) { if (!href) return {children}; - // Human messages always render as plain links - if (isHuman) { - return ( - - {children} - - ); - } - const citation = citationMap.get(href); - const isExternalLink = href.startsWith("http://") || href.startsWith("https://"); - // During streaming: render all external links as badges (citations not yet fully loaded) - // After streaming: only render links in citationMap as badges - if (citation || (isLoadingCitations && isExternalLink)) { + // Only render as CitationLink badge if it's a citation (in citationMap) and not human message + if (citation && !isHuman) { return ( {children} @@ -121,7 +102,7 @@ function MessageLink({ ); } - // Non-citation links render as plain links + // All other links render as plain links return ( ({ a: (props: React.AnchorHTMLAttributes) => ( - + ), img: (props: React.ImgHTMLAttributes) => ( ), - }), [citationMap, thread_id, isHuman, isLoadingCitations]); + }), [citationMap, thread_id, isHuman]); // Render message response // Human messages use humanMessagePlugins (no autolink) to prevent URL bleeding into adjacent text From ca6bcaa31cd7fa1dc273c1495d297b3c25f76808 Mon Sep 17 00:00:00 2001 From: ruitanglin Date: Fri, 6 Feb 2026 16:12:43 +0800 Subject: [PATCH 12/12] fix(citations): hide citations block in reasoning/thinking content The reasoning content in message-group.tsx was not being processed through parseCitations, causing raw blocks to be visible. Now reasoning content is parsed to remove citations blocks. Co-authored-by: Cursor --- frontend/src/components/workspace/messages/message-group.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/workspace/messages/message-group.tsx b/frontend/src/components/workspace/messages/message-group.tsx index 7b161ed..49d5fe7 100644 --- a/frontend/src/components/workspace/messages/message-group.tsx +++ b/frontend/src/components/workspace/messages/message-group.tsx @@ -121,7 +121,7 @@ export function MessageGroup({ key={step.id} label={ - {step.reasoning ?? ""} + {parseCitations(step.reasoning ?? "").cleanContent} } > @@ -171,7 +171,7 @@ export function MessageGroup({ key={lastReasoningStep.id} label={ - {lastReasoningStep.reasoning ?? ""} + {parseCitations(lastReasoningStep.reasoning ?? "").cleanContent} } >