diff --git a/backend/src/agents/lead_agent/prompt.py b/backend/src/agents/lead_agent/prompt.py index 7e7da23..12e3714 100644 --- a/backend/src/agents/lead_agent/prompt.py +++ b/backend/src/agents/lead_agent/prompt.py @@ -249,35 +249,30 @@ 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: - -{{"id": "cite-1", "title": "Page Title", "url": "https://example.com/page", "snippet": "Brief description"}} - -Content with inline links... +After web_search, ALWAYS include citations in your output: -**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... +1. Start with a `` block in JSONL format listing all sources +2. In content, use FULL markdown link format: [Short Title](full_url) -**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 -- 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..." +**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:** +- 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:** {{"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 {subagent_reminder}- Skill First: Always load the relevant skill before starting **complex** tasks. diff --git a/backend/src/gateway/routers/artifacts.py b/backend/src/gateway/routers/artifacts.py index f1f14dd..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. @@ -175,9 +238,24 @@ 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..dde6e31 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,114 @@ 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); + + // 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 ( + + + e.stopPropagation()} + > + + {displayText} + + + + + + + + + ); +}; + +/** + * 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..e4315f3 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, }; @@ -314,29 +313,24 @@ 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 ( - + {children} - + ); } - // 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} ); @@ -359,105 +353,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 516bbad..f5d7650 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,8 +120,8 @@ export function MessageGroup({ - {step.reasoning ?? ""} + + {parseCitations(step.reasoning ?? "").cleanContent} } > @@ -165,8 +170,8 @@ export function MessageGroup({ - {lastReasoningStep.reasoning ?? ""} + + {parseCitations(lastReasoningStep.reasoning ?? "").cleanContent} } > @@ -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") { @@ -355,29 +367,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 e77f6e7..7d5cc0d 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -1,12 +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 { - InlineCitationCard, - InlineCitationCardBody, - InlineCitationSource, + CitationLink, + CitationsLoadingIndicator, } from "@/components/ai-elements/inline-citation"; import { Message as AIElementMessage, @@ -15,13 +15,13 @@ 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, + removeAllCitations, } from "@/core/citations"; import { extractContentFromMessage, @@ -29,7 +29,8 @@ import { parseUploadedFiles, type UploadedFile, } from "@/core/messages/utils"; -import { streamdownPlugins } from "@/core/streamdown"; +import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; +import { humanMessagePlugins, streamdownPlugins } from "@/core/streamdown"; import { cn } from "@/lib/utils"; import { CopyButton } from "../copy-button"; @@ -43,30 +44,30 @@ export function MessageListItem({ message: Message; isLoading?: boolean; }) { + const isHuman = message.type === "human"; return (
@@ -74,6 +75,76 @@ export function MessageListItem({ ); } +/** + * Custom link component that handles citations and external 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, +}: React.AnchorHTMLAttributes & { + citationMap: Map; + isHuman: boolean; +}) { + if (!href) return {children}; + + const citation = citationMap.get(href); + + // Only render as CitationLink badge if it's a citation (in citationMap) and not human message + if (citation && !isHuman) { + return ( + + {children} + + ); + } + + // All other links render as plain links + 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, @@ -83,295 +154,164 @@ function MessageContent_({ message: Message; isLoading?: boolean; }) { + 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); + + // 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) => ( + + ), + img: (props: React.ImgHTMLAttributes) => ( + + ), + }), [citationMap, thread_id, isHuman]); + + // Render message response + // Human messages use humanMessagePlugins (no autolink) to prevent URL bleeding into adjacent text + 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} @@ -379,24 +319,17 @@ function UploadedFileCard({ ); } - // For non-image files, show file card return (
- + {file.filename}
- - {typeLabel} + + {getFileTypeLabel(file.filename)} {file.size}
@@ -404,58 +337,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 c909408..a5225e6 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.lastUpdated}**: \`${formatTimeAgo(memory.lastUpdated)}\``, 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 15475b9..c6b8687 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -167,6 +167,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 58ebf09..fb69501 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -115,6 +115,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 3ebd23d..0242fc9 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -163,6 +163,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..b0d9824 100644 --- a/frontend/src/core/streamdown/plugins.ts +++ b/frontend/src/core/streamdown/plugins.ts @@ -5,7 +5,20 @@ import type { StreamdownProps } from "streamdown"; export const streamdownPlugins = { remarkPlugins: [ - [remarkGfm, [remarkMath, { singleDollarTextMath: true }]], + remarkGfm, + [remarkMath, { singleDollarTextMath: true }], + ] as StreamdownProps["remarkPlugins"], + rehypePlugins: [ + [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" }], diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index 5d09d3c..3ecd464 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -207,12 +207,6 @@ export function useRenameThread() { }); }, onSuccess(_, { threadId, title }) { - queryClient.setQueryData( - ["thread", "state", threadId], - (oldData: Array) => { - console.info("oldData", oldData); - }, - ); queryClient.setQueriesData( { queryKey: ["threads", "search"],