From c67f1af889b290b09c484a8ef3827134dfba115a Mon Sep 17 00:00:00 2001 From: ruitanglin Date: Wed, 4 Feb 2026 11:56:10 +0800 Subject: [PATCH] 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"],