diff --git a/backend/src/agents/lead_agent/prompt.py b/backend/src/agents/lead_agent/prompt.py index 192e788..ce175c2 100644 --- a/backend/src/agents/lead_agent/prompt.py +++ b/backend/src/agents/lead_agent/prompt.py @@ -267,6 +267,7 @@ The key AI trends for 2026 include enhanced reasoning capabilities and multimoda - **Clarification First**: ALWAYS clarify unclear/missing/ambiguous requirements BEFORE starting work - never assume or guess +- **Web search citations**: When you use web_search (or synthesize subagent results that used it), you MUST output the `` block and [Title](url) links as specified in citations_format so citations display for the user. {subagent_reminder}- Skill First: Always load the relevant skill before starting **complex** tasks. - Progressive Loading: Load resources incrementally as referenced in skills - Output Files: Final deliverables must be in `/mnt/user-data/outputs` @@ -340,6 +341,7 @@ def apply_prompt_template(subagent_enabled: bool = False) -> str: # Add subagent reminder to critical_reminders if enabled subagent_reminder = ( "- **Orchestrator Mode**: You are a task orchestrator - decompose complex tasks into parallel sub-tasks and launch multiple subagents simultaneously. Synthesize results, don't execute directly.\n" + "- **Citations when synthesizing**: When you synthesize subagent results that used web search or cite sources, you MUST include a consolidated `` block (JSONL format) and use [Title](url) markdown links in your response so citations display correctly.\n" if subagent_enabled else "" ) diff --git a/backend/src/gateway/path_utils.py b/backend/src/gateway/path_utils.py new file mode 100644 index 0000000..119752e --- /dev/null +++ b/backend/src/gateway/path_utils.py @@ -0,0 +1,44 @@ +"""Shared path resolution for thread virtual paths (e.g. mnt/user-data/outputs/...).""" + +import os +from pathlib import Path + +from fastapi import HTTPException + +from src.agents.middlewares.thread_data_middleware import THREAD_DATA_BASE_DIR + +# Virtual path prefix used in sandbox environments (without leading slash for URL path matching) +VIRTUAL_PATH_PREFIX = "mnt/user-data" + + +def resolve_thread_virtual_path(thread_id: str, virtual_path: str) -> Path: + """Resolve a virtual path to the actual filesystem path under thread user-data. + + Args: + thread_id: The thread ID. + virtual_path: The virtual path (e.g., mnt/user-data/outputs/file.txt). + Leading slashes are stripped. + + Returns: + The resolved filesystem path. + + Raises: + HTTPException: If the path is invalid or outside allowed directories. + """ + virtual_path = virtual_path.lstrip("/") + if not virtual_path.startswith(VIRTUAL_PATH_PREFIX): + raise HTTPException(status_code=400, detail=f"Path must start with /{VIRTUAL_PATH_PREFIX}") + relative_path = virtual_path[len(VIRTUAL_PATH_PREFIX) :].lstrip("/") + + base_dir = Path(os.getcwd()) / THREAD_DATA_BASE_DIR / thread_id / "user-data" + actual_path = base_dir / relative_path + + try: + actual_path = actual_path.resolve() + base_resolved = base_dir.resolve() + if not str(actual_path).startswith(str(base_resolved)): + raise HTTPException(status_code=403, detail="Access denied: path traversal detected") + except (ValueError, RuntimeError): + raise HTTPException(status_code=400, detail="Invalid path") + + return actual_path diff --git a/backend/src/gateway/routers/artifacts.py b/backend/src/gateway/routers/artifacts.py index 9798193..a2a13a7 100644 --- a/backend/src/gateway/routers/artifacts.py +++ b/backend/src/gateway/routers/artifacts.py @@ -1,5 +1,5 @@ +import json import mimetypes -import os import re import zipfile from pathlib import Path @@ -8,49 +8,11 @@ from urllib.parse import quote from fastapi import APIRouter, HTTPException, Request, Response from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse -# Base directory for thread data (relative to backend/) -THREAD_DATA_BASE_DIR = ".deer-flow/threads" - -# Virtual path prefix used in sandbox environments (without leading slash for URL path matching) -VIRTUAL_PATH_PREFIX = "mnt/user-data" +from src.gateway.path_utils import resolve_thread_virtual_path router = APIRouter(prefix="/api", tags=["artifacts"]) -def _resolve_artifact_path(thread_id: str, artifact_path: str) -> Path: - """Resolve a virtual artifact path to the actual filesystem path. - - Args: - thread_id: The thread ID. - artifact_path: The virtual path (e.g., mnt/user-data/outputs/file.txt). - - Returns: - The resolved filesystem path. - - Raises: - HTTPException: If the path is invalid or outside allowed directories. - """ - # Validate and remove virtual path prefix - if not artifact_path.startswith(VIRTUAL_PATH_PREFIX): - raise HTTPException(status_code=400, detail=f"Path must start with /{VIRTUAL_PATH_PREFIX}") - relative_path = artifact_path[len(VIRTUAL_PATH_PREFIX) :].lstrip("/") - - # Build the actual path - base_dir = Path(os.getcwd()) / THREAD_DATA_BASE_DIR / thread_id / "user-data" - actual_path = base_dir / relative_path - - # Security check: ensure the path is within the thread's user-data directory - try: - actual_path = actual_path.resolve() - base_dir = base_dir.resolve() - if not str(actual_path).startswith(str(base_dir)): - raise HTTPException(status_code=403, detail="Access denied: path traversal detected") - except (ValueError, RuntimeError): - raise HTTPException(status_code=400, detail="Invalid path") - - return actual_path - - def is_text_file_by_content(path: Path, sample_size: int = 8192) -> bool: """Check if file is text by examining content for null bytes.""" try: @@ -62,66 +24,38 @@ 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'): +def _extract_citation_urls(content: str) -> set[str]: + """Extract URLs from JSONL blocks. Format must match frontend core/citations/utils.ts.""" + urls: set[str] = set() + for match in re.finditer(r"([\s\S]*?)", content): + for line in match.group(1).split("\n"): line = line.strip() - if line.startswith('{'): + if line.startswith("{"): try: - citation = json.loads(line) - if 'url' in citation: - citation_urls.add(citation['url']) + obj = json.loads(line) + if "url" in obj: + urls.add(obj["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) + return urls + + +def remove_citations_block(content: str) -> str: + """Remove ALL citations from markdown (blocks, [cite-N], and citation links). Used for downloads.""" + if not content: + return content + + citation_urls = _extract_citation_urls(content) + + result = re.sub(r"[\s\S]*?", "", content) 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() + result = re.sub(r"[\s\S]*$", "", result) + result = re.sub(r"\[cite-\d+\]", "", result) + + for url in citation_urls: + result = re.sub(rf"\[[^\]]+\]\({re.escape(url)}\)", "", result) + + return re.sub(r"\n{3,}", "\n\n", result).strip() def _extract_file_from_skill_archive(zip_path: Path, internal_path: str) -> bytes | None: @@ -200,7 +134,7 @@ async def get_artifact(thread_id: str, path: str, request: Request) -> FileRespo skill_file_path = path[: marker_pos + len(".skill")] # e.g., "mnt/user-data/outputs/my-skill.skill" internal_path = path[marker_pos + len(skill_marker) :] # e.g., "SKILL.md" - actual_skill_path = _resolve_artifact_path(thread_id, skill_file_path) + actual_skill_path = resolve_thread_virtual_path(thread_id, skill_file_path) if not actual_skill_path.exists(): raise HTTPException(status_code=404, detail=f"Skill file not found: {skill_file_path}") @@ -226,7 +160,7 @@ async def get_artifact(thread_id: str, path: str, request: Request) -> FileRespo except UnicodeDecodeError: return Response(content=content, media_type=mime_type or "application/octet-stream", headers=cache_headers) - actual_path = _resolve_artifact_path(thread_id, path) + actual_path = resolve_thread_virtual_path(thread_id, path) if not actual_path.exists(): raise HTTPException(status_code=404, detail=f"Artifact not found: {path}") diff --git a/backend/src/gateway/routers/skills.py b/backend/src/gateway/routers/skills.py index 67bca69..11c5356 100644 --- a/backend/src/gateway/routers/skills.py +++ b/backend/src/gateway/routers/skills.py @@ -1,6 +1,5 @@ import json import logging -import os import re import shutil import tempfile @@ -12,6 +11,7 @@ from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field from src.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config +from src.gateway.path_utils import resolve_thread_virtual_path from src.skills import Skill, load_skills from src.skills.loader import get_skills_root_path @@ -56,53 +56,10 @@ class SkillInstallResponse(BaseModel): message: str = Field(..., description="Installation result message") -# Base directory for thread data (relative to backend/) -THREAD_DATA_BASE_DIR = ".deer-flow/threads" - -# Virtual path prefix used in sandbox environments (without leading slash for URL path matching) -VIRTUAL_PATH_PREFIX = "mnt/user-data" - # Allowed properties in SKILL.md frontmatter ALLOWED_FRONTMATTER_PROPERTIES = {"name", "description", "license", "allowed-tools", "metadata"} -def _resolve_skill_file_path(thread_id: str, virtual_path: str) -> Path: - """Resolve a virtual skill file path to the actual filesystem path. - - Args: - thread_id: The thread ID. - virtual_path: The virtual path (e.g., mnt/user-data/outputs/my-skill.skill). - - Returns: - The resolved filesystem path. - - Raises: - HTTPException: If the path is invalid or outside allowed directories. - """ - # Remove leading slash if present - virtual_path = virtual_path.lstrip("/") - - # Validate and remove virtual path prefix - if not virtual_path.startswith(VIRTUAL_PATH_PREFIX): - raise HTTPException(status_code=400, detail=f"Path must start with /{VIRTUAL_PATH_PREFIX}") - relative_path = virtual_path[len(VIRTUAL_PATH_PREFIX) :].lstrip("/") - - # Build the actual path - base_dir = Path(os.getcwd()) / THREAD_DATA_BASE_DIR / thread_id / "user-data" - actual_path = base_dir / relative_path - - # Security check: ensure the path is within the thread's user-data directory - try: - actual_path = actual_path.resolve() - base_dir_resolved = base_dir.resolve() - if not str(actual_path).startswith(str(base_dir_resolved)): - raise HTTPException(status_code=403, detail="Access denied: path traversal detected") - except (ValueError, RuntimeError): - raise HTTPException(status_code=400, detail="Invalid path") - - return actual_path - - def _validate_skill_frontmatter(skill_dir: Path) -> tuple[bool, str, str | None]: """Validate a skill directory's SKILL.md frontmatter. @@ -414,7 +371,7 @@ async def install_skill(request: SkillInstallRequest) -> SkillInstallResponse: """ try: # Resolve the virtual path to actual file path - skill_file_path = _resolve_skill_file_path(request.thread_id, request.path) + skill_file_path = resolve_thread_virtual_path(request.thread_id, request.path) # Check if file exists if not skill_file_path.exists(): diff --git a/backend/src/subagents/builtins/general_purpose.py b/backend/src/subagents/builtins/general_purpose.py index 1ab6562..0854422 100644 --- a/backend/src/subagents/builtins/general_purpose.py +++ b/backend/src/subagents/builtins/general_purpose.py @@ -24,10 +24,21 @@ Do NOT use for simple, single-step operations.""", - Do NOT ask for clarification - work with the information provided + +If you used web_search (or similar) and cite sources, ALWAYS include citations in your output: +1. Start with a `` block in JSONL format listing all sources (one JSON object per line) +2. In content, use FULL markdown link format: [Short Title](full_url) +- Every citation MUST be a complete markdown link with URL: [Title](https://...) +- Example block: + +{"id": "cite-1", "title": "...", "url": "https://...", "snippet": "..."} + + + When you complete the task, provide: 1. A brief summary of what was accomplished -2. Key findings or results +2. Key findings or results (with citation links when from web search) 3. Any relevant file paths, data, or artifacts created 4. Issues encountered (if any) diff --git a/frontend/next.config.js b/frontend/next.config.js index 3dff517..7159179 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -7,15 +7,6 @@ import "./src/env.js"; /** @type {import("next").NextConfig} */ const config = { devIndicators: false, - turbopack: { - root: import.meta.dirname, - rules: { - "*.md": { - loaders: ["raw-loader"], - as: "*.js", - }, - }, - }, }; export default config; diff --git a/frontend/package.json b/frontend/package.json index a1708dc..e936c34 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -97,7 +97,6 @@ "postcss": "^8.5.3", "prettier": "^3.5.3", "prettier-plugin-tailwindcss": "^0.6.11", - "raw-loader": "^4.0.2", "tailwindcss": "^4.0.15", "tw-animate-css": "^1.4.0", "typescript": "^5.8.2", diff --git a/frontend/src/components/ai-elements/inline-citation.tsx b/frontend/src/components/ai-elements/inline-citation.tsx index dde6e31..49ba280 100644 --- a/frontend/src/components/ai-elements/inline-citation.tsx +++ b/frontend/src/components/ai-elements/inline-citation.tsx @@ -1,89 +1,33 @@ "use client"; import { Badge } from "@/components/ui/badge"; -import { - Carousel, - type CarouselApi, - CarouselContent, - CarouselItem, -} from "@/components/ui/carousel"; import { HoverCard, HoverCardContent, HoverCardTrigger, } from "@/components/ui/hover-card"; -import { cn } from "@/lib/utils"; -import { ExternalLinkIcon, ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; import { - type ComponentProps, - createContext, - useCallback, - useContext, - useEffect, - useState, -} from "react"; + cn, + externalLinkClass, + externalLinkClassNoUnderline, +} from "@/lib/utils"; +import { ExternalLinkIcon } from "lucide-react"; +import { type ComponentProps, Children } from "react"; import type { Citation } from "@/core/citations"; -import { extractDomainFromUrl } from "@/core/citations"; +import { + extractDomainFromUrl, + isExternalUrl, + syntheticCitationFromLink, +} from "@/core/citations"; import { Shimmer } from "./shimmer"; import { useI18n } from "@/core/i18n/hooks"; -export type InlineCitationProps = ComponentProps<"span">; - -export const InlineCitation = ({ - className, - ...props -}: InlineCitationProps) => ( - -); - -export type InlineCitationTextProps = ComponentProps<"span">; - -export const InlineCitationText = ({ - className, - ...props -}: InlineCitationTextProps) => ( - -); - export type InlineCitationCardProps = ComponentProps; export const InlineCitationCard = (props: InlineCitationCardProps) => ( ); -export type InlineCitationCardTriggerProps = ComponentProps & { - sources: string[]; -}; - -export const InlineCitationCardTrigger = ({ - sources, - className, - ...props -}: InlineCitationCardTriggerProps) => ( - - - {sources[0] ? ( - <> - {new URL(sources[0]).hostname}{" "} - {sources.length > 1 && `+${sources.length - 1}`} - - ) : ( - "unknown" - )} - - -); - export type InlineCitationCardBodyProps = ComponentProps<"div">; export const InlineCitationCardBody = ({ @@ -93,155 +37,6 @@ export const InlineCitationCardBody = ({ ); -const CarouselApiContext = createContext(undefined); - -const useCarouselApi = () => { - const context = useContext(CarouselApiContext); - return context; -}; - -export type InlineCitationCarouselProps = ComponentProps; - -export const InlineCitationCarousel = ({ - className, - children, - ...props -}: InlineCitationCarouselProps) => { - const [api, setApi] = useState(); - - return ( - - - {children} - - - ); -}; - -export type InlineCitationCarouselContentProps = ComponentProps<"div">; - -export const InlineCitationCarouselContent = ( - props: InlineCitationCarouselContentProps -) => ; - -export type InlineCitationCarouselItemProps = ComponentProps<"div">; - -export const InlineCitationCarouselItem = ({ - className, - ...props -}: InlineCitationCarouselItemProps) => ( - -); - -export type InlineCitationCarouselHeaderProps = ComponentProps<"div">; - -export const InlineCitationCarouselHeader = ({ - className, - ...props -}: InlineCitationCarouselHeaderProps) => ( -
-); - -export type InlineCitationCarouselIndexProps = ComponentProps<"div">; - -export const InlineCitationCarouselIndex = ({ - children, - className, - ...props -}: InlineCitationCarouselIndexProps) => { - const api = useCarouselApi(); - const [current, setCurrent] = useState(0); - const [count, setCount] = useState(0); - - useEffect(() => { - if (!api) { - return; - } - - setCount(api.scrollSnapList().length); - setCurrent(api.selectedScrollSnap() + 1); - - api.on("select", () => { - setCurrent(api.selectedScrollSnap() + 1); - }); - }, [api]); - - return ( -
- {children ?? `${current}/${count}`} -
- ); -}; - -export type InlineCitationCarouselPrevProps = ComponentProps<"button">; - -export const InlineCitationCarouselPrev = ({ - className, - ...props -}: InlineCitationCarouselPrevProps) => { - const api = useCarouselApi(); - - const handleClick = useCallback(() => { - if (api) { - api.scrollPrev(); - } - }, [api]); - - return ( - - ); -}; - -export type InlineCitationCarouselNextProps = ComponentProps<"button">; - -export const InlineCitationCarouselNext = ({ - className, - ...props -}: InlineCitationCarouselNextProps) => { - const api = useCarouselApi(); - - const handleClick = useCallback(() => { - if (api) { - api.scrollNext(); - } - }, [api]); - - return ( - - ); -}; - export type InlineCitationSourceProps = ComponentProps<"div"> & { title?: string; url?: string; @@ -272,24 +67,6 @@ export const InlineCitationSource = ({
); -export type InlineCitationQuoteProps = ComponentProps<"blockquote">; - -export const InlineCitationQuote = ({ - children, - className, - ...props -}: InlineCitationQuoteProps) => ( -
- {children} -
-); - /** * Shared CitationLink component that renders a citation as a hover card badge * Used across message-list-item, artifact-file-detail, and message-group @@ -360,6 +137,71 @@ export const CitationLink = ({ ); }; +/** + * Renders a link with optional citation badge. Use in markdown components (message + artifact). + * - citationMap: URL -> Citation; links in map render as CitationLink. + * - isHuman: when true, never render as CitationLink (plain link). + * - isLoadingCitations: when true and not human, non-citation links use no-underline style. + * - syntheticExternal: when true, external URLs not in citationMap render as CitationLink with synthetic citation. + */ +export type CitationAwareLinkProps = ComponentProps<"a"> & { + citationMap: Map; + isHuman?: boolean; + isLoadingCitations?: boolean; + syntheticExternal?: boolean; +}; + +export const CitationAwareLink = ({ + href, + children, + citationMap, + isHuman = false, + isLoadingCitations = false, + syntheticExternal = false, + className, + ...rest +}: CitationAwareLinkProps) => { + if (!href) return {children}; + + const citation = citationMap.get(href); + + if (citation && !isHuman) { + return ( + + {children} + + ); + } + + if (syntheticExternal && isExternalUrl(href)) { + const linkText = + typeof children === "string" + ? children + : String(Children.toArray(children).join("")).trim() || href; + return ( + + {children} + + ); + } + + const noUnderline = !isHuman && isLoadingCitations; + return ( + + {children} + + ); +}; + /** * Shared CitationsLoadingIndicator component * Used across message-list-item and message-group to show loading citations diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx index e4315f3..4c88aa9 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -21,7 +21,7 @@ import { ArtifactHeader, ArtifactTitle, } from "@/components/ai-elements/artifact"; -import { CitationLink } from "@/components/ai-elements/inline-citation"; +import { CitationAwareLink } from "@/components/ai-elements/inline-citation"; import { Select, SelectItem } from "@/components/ui/select"; import { SelectContent, @@ -33,10 +33,11 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { CodeEditor } from "@/components/workspace/code-editor"; import { useArtifactContent } from "@/core/artifacts/hooks"; import { urlOfArtifact } from "@/core/artifacts/utils"; +import type { Citation } from "@/core/citations"; import { - buildCitationMap, - parseCitations, + contentWithoutCitationsFromParsed, removeAllCitations, + useParsedCitations, } from "@/core/citations"; import { useI18n } from "@/core/i18n/hooks"; import { installSkill } from "@/core/skills/api"; @@ -94,21 +95,15 @@ export function ArtifactFileDetail({ enabled: isCodeFile && !isWriteFile, }); - // Parse citations and get clean content for code editor - const cleanContent = useMemo(() => { - if (language === "markdown" && content) { - return parseCitations(content).cleanContent; - } - return content; - }, [content, language]); - - // Get content without ANY citations for copy/download - const contentWithoutCitations = useMemo(() => { - if (language === "markdown" && content) { - return removeAllCitations(content); - } - return content; - }, [content, language]); + const parsed = useParsedCitations( + language === "markdown" ? (content ?? "") : "", + ); + const cleanContent = + language === "markdown" && content ? parsed.cleanContent : (content ?? ""); + const contentWithoutCitations = + language === "markdown" && content + ? contentWithoutCitationsFromParsed(parsed) + : (content ?? ""); const [viewMode, setViewMode] = useState<"code" | "preview">("code"); const [isInstalling, setIsInstalling] = useState(false); @@ -258,6 +253,8 @@ export function ArtifactFileDetail({ threadId={threadId} content={content} language={language ?? "text"} + cleanContent={parsed.cleanContent} + citationMap={parsed.citationMap} /> )} {isCodeFile && viewMode === "code" && ( @@ -283,21 +280,16 @@ export function ArtifactFilePreview({ threadId, content, language, + cleanContent, + citationMap, }: { filepath: string; threadId: string; content: string; language: string; + cleanContent: string; + citationMap: Map; }) { - const { cleanContent, citationMap } = React.useMemo(() => { - const parsed = parseCitations(content ?? ""); - const map = buildCitationMap(parsed.citations); - return { - cleanContent: parsed.cleanContent, - citationMap: map, - }; - }, [content]); - if (language === "markdown") { return (
@@ -305,36 +297,13 @@ export function ArtifactFilePreview({ className="size-full" {...streamdownPlugins} components={{ - a: ({ - href, - children, - }: React.AnchorHTMLAttributes) => { - if (!href) { - return {children}; - } - - // Only render as CitationLink badge if it's a citation (in citationMap) - const citation = citationMap.get(href); - if (citation) { - return ( - - {children} - - ); - } - - // All other links (including project URLs) render as plain links - return ( - - {children} - - ); - }, + a: (props: React.AnchorHTMLAttributes) => ( + + ), }} > {cleanContent ?? ""} diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index 68c2c08..b32febf 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -60,6 +60,7 @@ import { DropdownMenuTrigger, } from "../ui/dropdown-menu"; +import { ModeHoverGuide } from "./mode-hover-guide"; import { Tooltip } from "./tooltip"; export function InputBox({ @@ -197,31 +198,42 @@ export function InputBox({ */} - -
- {context.mode === "flash" && } - {context.mode === "thinking" && ( - - )} - {context.mode === "pro" && ( - - )} - {context.mode === "ultra" && ( - - )} -
-
- {(context.mode === "flash" && t.inputBox.flashMode) || - (context.mode === "thinking" && t.inputBox.reasoningMode) || - (context.mode === "pro" && t.inputBox.proMode) || - (context.mode === "ultra" && t.inputBox.ultraMode)} -
-
+ + +
+ {context.mode === "flash" && } + {context.mode === "thinking" && ( + + )} + {context.mode === "pro" && ( + + )} + {context.mode === "ultra" && ( + + )} +
+
+ {(context.mode === "flash" && t.inputBox.flashMode) || + (context.mode === "thinking" && t.inputBox.reasoningMode) || + (context.mode === "pro" && t.inputBox.proMode) || + (context.mode === "ultra" && t.inputBox.ultraMode)} +
+
+
diff --git a/frontend/src/components/workspace/messages/message-group.tsx b/frontend/src/components/workspace/messages/message-group.tsx index a0782bb..524d16f 100644 --- a/frontend/src/components/workspace/messages/message-group.tsx +++ b/frontend/src/components/workspace/messages/message-group.tsx @@ -25,7 +25,11 @@ 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 { + getCleanContent, + hasCitationsBlock, + useParsedCitations, +} from "@/core/citations"; import { useI18n } from "@/core/i18n/hooks"; import { extractReasoningContentFromMessage, @@ -124,7 +128,7 @@ export function MessageGroup({ remarkPlugins={streamdownPlugins.remarkPlugins} rehypePlugins={rehypePlugins} > - {parseCitations(step.reasoning ?? "").cleanContent} + {getCleanContent(step.reasoning ?? "")} } > @@ -177,10 +181,7 @@ export function MessageGroup({ remarkPlugins={streamdownPlugins.remarkPlugins} rehypePlugins={rehypePlugins} > - { - parseCitations(lastReasoningStep.reasoning ?? "") - .cleanContent - } + {getCleanContent(lastReasoningStep.reasoning ?? "")} } > @@ -215,12 +216,8 @@ function ToolCall({ 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], - ); + const { citations } = useParsedCitations(fileContent); if (name === "web_search") { let label: React.ReactNode = t.toolCalls.searchForRelatedInfo; @@ -232,13 +229,11 @@ function ToolCall({ {Array.isArray(result) && ( {result.map((item) => ( - - - - {item.title} - - - + + + {item.title} + + ))} )} @@ -309,11 +304,9 @@ function ToolCall({ > {url && ( - {result as string}}> - - {title} - - + + {title} + )} @@ -328,11 +321,9 @@ function ToolCall({ return ( {path && ( - {result as string}}> - - {path} - - + + {path} + )} ); @@ -346,17 +337,9 @@ function ToolCall({ return ( {path && ( - - {result as string} - - } - > - - {path} - - + + {path} + )} ); @@ -384,9 +367,8 @@ function ToolCall({ const isMarkdown = path?.toLowerCase().endsWith(".md") || path?.toLowerCase().endsWith(".markdown"); - const hasCitationsBlock = fileContent.includes(""); const showCitationsLoading = - isMarkdown && threadIsLoading && hasCitationsBlock && isLast; + isMarkdown && threadIsLoading && hasCitationsBlock(fileContent) && isLast; return ( <> @@ -405,11 +387,9 @@ function ToolCall({ }} > {path && ( - - - {path} - - + + {path} + )} {showCitationsLoading && ( @@ -433,14 +413,12 @@ function ToolCall({ icon={SquareTerminalIcon} > {command && ( - {result as string}}> - - + )} ); diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index 7d5cc0d..787d921 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -5,7 +5,7 @@ import { memo, useMemo } from "react"; import rehypeKatex from "rehype-katex"; import { - CitationLink, + CitationAwareLink, CitationsLoadingIndicator, } from "@/components/ai-elements/inline-citation"; import { @@ -17,11 +17,9 @@ import { import { Badge } from "@/components/ui/badge"; import { resolveArtifactURL } from "@/core/artifacts/utils"; import { - type Citation, - buildCitationMap, isCitationsBlockIncomplete, - parseCitations, removeAllCitations, + useParsedCitations, } from "@/core/citations"; import { extractContentFromMessage, @@ -75,46 +73,6 @@ 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 */ @@ -158,55 +116,54 @@ function MessageContent_({ const isHuman = message.type === "human"; const { thread_id } = useParams<{ thread_id: string }>(); - // Extract and parse citations and uploaded files from message content - const { citations, cleanContent, uploadedFiles, isLoadingCitations } = - useMemo(() => { - const reasoningContent = extractReasoningContentFromMessage(message); - const rawContent = extractContentFromMessage(message); + // Content to parse for citations (and optionally uploaded files) + const { contentToParse, 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, - }; - } + if (!isLoading && reasoningContent && !rawContent) { + return { + contentToParse: reasoningContent, + uploadedFiles: [] as UploadedFile[], + 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, - }; - } + if (isHuman && rawContent) { + const { files, cleanContent: contentWithoutFiles } = + parseUploadedFiles(rawContent); + return { + contentToParse: contentWithoutFiles, + uploadedFiles: files, + isLoadingCitations: false, + }; + } - const { citations, cleanContent } = parseCitations(rawContent ?? ""); - const isLoadingCitations = - isLoading && isCitationsBlockIncomplete(rawContent ?? ""); + return { + contentToParse: rawContent ?? "", + uploadedFiles: [] as UploadedFile[], + isLoadingCitations: + isLoading && isCitationsBlockIncomplete(rawContent ?? ""), + }; + }, [isLoading, message, isHuman]); - return { citations, cleanContent, uploadedFiles: [], isLoadingCitations }; - }, [isLoading, message, isHuman]); - - const citationMap = useMemo(() => buildCitationMap(citations), [citations]); + const { citations, cleanContent, citationMap } = + useParsedCitations(contentToParse); // Shared markdown components const markdownComponents = useMemo(() => ({ 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 diff --git a/frontend/src/components/workspace/mode-hover-guide.tsx b/frontend/src/components/workspace/mode-hover-guide.tsx new file mode 100644 index 0000000..e78e82b --- /dev/null +++ b/frontend/src/components/workspace/mode-hover-guide.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useI18n } from "@/core/i18n/hooks"; +import { Tooltip } from "./tooltip"; + +export type AgentMode = "flash" | "thinking" | "pro" | "ultra"; + +function getModeLabelKey( + mode: AgentMode, +): keyof Pick< + import("@/core/i18n/locales/types").Translations["inputBox"], + "flashMode" | "reasoningMode" | "proMode" | "ultraMode" +> { + switch (mode) { + case "flash": + return "flashMode"; + case "thinking": + return "reasoningMode"; + case "pro": + return "proMode"; + case "ultra": + return "ultraMode"; + } +} + +function getModeDescriptionKey( + mode: AgentMode, +): keyof Pick< + import("@/core/i18n/locales/types").Translations["inputBox"], + "flashModeDescription" | "reasoningModeDescription" | "proModeDescription" | "ultraModeDescription" +> { + switch (mode) { + case "flash": + return "flashModeDescription"; + case "thinking": + return "reasoningModeDescription"; + case "pro": + return "proModeDescription"; + case "ultra": + return "ultraModeDescription"; + } +} + +export function ModeHoverGuide({ + mode, + children, + showTitle = true, +}: { + mode: AgentMode; + children: React.ReactNode; + /** When true, tooltip shows "ModeName: Description". When false, only description. */ + showTitle?: boolean; +}) { + const { t } = useI18n(); + const label = t.inputBox[getModeLabelKey(mode)]; + const description = t.inputBox[getModeDescriptionKey(mode)]; + const content = showTitle ? `${label}: ${description}` : description; + + return {children}; +} diff --git a/frontend/src/components/workspace/settings/about-content.ts b/frontend/src/components/workspace/settings/about-content.ts new file mode 100644 index 0000000..c744be0 --- /dev/null +++ b/frontend/src/components/workspace/settings/about-content.ts @@ -0,0 +1,57 @@ +/** + * About DeerFlow markdown content. Inlined to avoid raw-loader dependency + * (Turbopack cannot resolve raw-loader for .md imports). + */ +export const aboutMarkdown = `# 🦌 [About DeerFlow 2.0](https://github.com/bytedance/deer-flow) + +> **From Open Source, Back to Open Source** + +**DeerFlow** (**D**eep **E**xploration and **E**fficient **R**esearch **Flow**) is a community-driven SuperAgent harness that researches, codes, and creates. +With the help of sandboxes, memories, tools and skills, it handles +different levels of tasks that could take minutes to hours. + +--- + +## 🌟 GitHub Repository + +Explore DeerFlow on GitHub: [github.com/bytedance/deer-flow](https://github.com/bytedance/deer-flow) + +## 🌐 Official Website + +Visit the official website of DeerFlow: [deerflow.tech](https://deerflow.tech/) + +## 📧 Support + +If you have any questions or need help, please contact us at [support@deerflow.tech](mailto:support@deerflow.tech). + +--- + +## 📜 License + +DeerFlow is proudly open source and distributed under the **MIT License**. + +--- + +## 🙌 Acknowledgments + +We extend our heartfelt gratitude to the open source projects and contributors who have made DeerFlow a reality. We truly stand on the shoulders of giants. + +### Core Frameworks +- **[LangChain](https://github.com/langchain-ai/langchain)**: A phenomenal framework that powers our LLM interactions and chains. +- **[LangGraph](https://github.com/langchain-ai/langgraph)**: Enabling sophisticated multi-agent orchestration. +- **[Next.js](https://nextjs.org/)**: A cutting-edge framework for building web applications. + +### UI Libraries +- **[Shadcn](https://ui.shadcn.com/)**: Minimalistic components that power our UI. +- **[SToneX](https://github.com/stonexer)**: For his invaluable contribution to token-by-token visual effects. + +These outstanding projects form the backbone of DeerFlow and exemplify the transformative power of open source collaboration. + +### Special Thanks +Finally, we want to express our heartfelt gratitude to the core authors of DeerFlow 1.0 and 2.0: + +- **[Daniel Walnut](https://github.com/hetaoBackend/)** +- **[Henry Li](https://github.com/magiccube/)** + +Without their vision, passion and dedication, \`DeerFlow\` would not be what it is today. +`; diff --git a/frontend/src/components/workspace/settings/about-settings-page.tsx b/frontend/src/components/workspace/settings/about-settings-page.tsx index e3c215f..8635f8d 100644 --- a/frontend/src/components/workspace/settings/about-settings-page.tsx +++ b/frontend/src/components/workspace/settings/about-settings-page.tsx @@ -2,8 +2,8 @@ import { Streamdown } from "streamdown"; -import about from "./about.md"; +import { aboutMarkdown } from "./about-content"; export function AboutSettingsPage() { - return {about}; + return {aboutMarkdown}; } diff --git a/frontend/src/core/citations/index.ts b/frontend/src/core/citations/index.ts index fd2a2aa..c56d293 100644 --- a/frontend/src/core/citations/index.ts +++ b/frontend/src/core/citations/index.ts @@ -1,9 +1,15 @@ export { - parseCitations, - buildCitationMap, + contentWithoutCitationsFromParsed, extractDomainFromUrl, + getCleanContent, + hasCitationsBlock, isCitationsBlockIncomplete, + isExternalUrl, + parseCitations, removeAllCitations, + syntheticCitationFromLink, } from "./utils"; +export { useParsedCitations } from "./use-parsed-citations"; +export type { UseParsedCitationsResult } from "./use-parsed-citations"; export type { Citation, ParseCitationsResult } from "./utils"; diff --git a/frontend/src/core/citations/use-parsed-citations.ts b/frontend/src/core/citations/use-parsed-citations.ts new file mode 100644 index 0000000..1dc8463 --- /dev/null +++ b/frontend/src/core/citations/use-parsed-citations.ts @@ -0,0 +1,28 @@ +"use client"; + +import { useMemo } from "react"; + +import { buildCitationMap, parseCitations } from "./utils"; +import type { Citation } from "./utils"; + +export interface UseParsedCitationsResult { + citations: Citation[]; + cleanContent: string; + citationMap: Map; +} + +/** + * Parse content for citations and build citation map. Memoized by content. + * Use in message and artifact components to avoid repeating parseCitations + buildCitationMap. + */ +export function useParsedCitations(content: string): UseParsedCitationsResult { + return useMemo(() => { + const parsed = parseCitations(content ?? ""); + const citationMap = buildCitationMap(parsed.citations); + return { + citations: parsed.citations, + cleanContent: parsed.cleanContent, + citationMap, + }; + }, [content]); +} diff --git a/frontend/src/core/citations/utils.ts b/frontend/src/core/citations/utils.ts index 699900b..0567b02 100644 --- a/frontend/src/core/citations/utils.ts +++ b/frontend/src/core/citations/utils.ts @@ -67,14 +67,7 @@ export function parseCitations(content: string): ParseCitationsResult { } } - // Remove ALL citations blocks from content (both complete and incomplete) - cleanContent = content.replace(/[\s\S]*?<\/citations>/g, "").trim(); - - // Also remove incomplete citations blocks (during streaming) - // Match without closing tag or followed by anything until end of string - if (cleanContent.includes("")) { - cleanContent = cleanContent.replace(/[\s\S]*$/g, "").trim(); - } + cleanContent = removeCitationsBlocks(content); // Convert [cite-N] references to markdown links // Example: [cite-1] -> [Title](url) @@ -102,6 +95,13 @@ export function parseCitations(content: string): ParseCitationsResult { return { citations, cleanContent }; } +/** + * Return content with citations block removed and [cite-N] replaced by markdown links. + */ +export function getCleanContent(content: string): string { + return parseCitations(content ?? "").cleanContent; +} + /** * Build a map from URL to Citation for quick lookup * @@ -118,6 +118,25 @@ export function buildCitationMap( return map; } +/** + * Whether the URL is external (http/https). + */ +export function isExternalUrl(url: string): boolean { + return url.startsWith("http://") || url.startsWith("https://"); +} + +/** + * Build a synthetic Citation from a link (e.g. in artifact markdown without block). + */ +export function syntheticCitationFromLink(href: string, title: string): Citation { + return { + id: `artifact-cite-${href}`, + title: title || href, + url: href, + snippet: "", + }; +} + /** * Extract the domain name from a URL for display * @@ -134,6 +153,26 @@ export function extractDomainFromUrl(url: string): string { } } +/** + * Remove all blocks from content (complete and incomplete). + * Does not remove [cite-N] or markdown links; use removeAllCitations for that. + */ +export function removeCitationsBlocks(content: string): string { + if (!content) return content; + let result = content.replace(/[\s\S]*?<\/citations>/g, "").trim(); + if (result.includes("")) { + result = result.replace(/[\s\S]*$/g, "").trim(); + } + return result; +} + +/** + * Whether content contains a block (open tag). + */ +export function hasCitationsBlock(content: string): boolean { + return Boolean(content?.includes("")); +} + /** * Check if content is still receiving the citations block (streaming) * This helps determine if we should wait before parsing @@ -142,61 +181,29 @@ export function extractDomainFromUrl(url: string): string { * @returns true if citations block appears to be incomplete */ export function isCitationsBlockIncomplete(content: string): boolean { - if (!content) { - return false; - } - - // Check if we have an opening tag but no closing tag - const hasOpenTag = content.includes(""); - const hasCloseTag = content.includes(""); - - return hasOpenTag && !hasCloseTag; + return hasCitationsBlock(content) && !content.includes(""); } /** - * 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 + * Strip citation markdown links from already-cleaned content (from parseCitations). + * Use when you already have ParseCitationsResult to avoid parsing twice. + */ +export function contentWithoutCitationsFromParsed( + parsed: ParseCitationsResult, +): string { + const citationUrls = new Set(parsed.citations.map((c) => c.url)); + const withoutLinks = parsed.cleanContent.replace( + /\[([^\]]+)\]\(([^)]+)\)/g, + (fullMatch, _text, url) => (citationUrls.has(url) ? "" : fullMatch), + ); + return withoutLinks.replace(/\n{3,}/g, "\n\n").trim(); +} + +/** + * Remove ALL citations from content (blocks, [cite-N], and citation links). + * Used for copy/download. For display you typically use parseCitations/useParsedCitations. */ 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; + if (!content) return content; + return contentWithoutCitationsFromParsed(parseCitations(content)); } diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 5eeeda0..262c412 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -81,7 +81,7 @@ export const enUS: Translations = { "Reasoning, planning and executing, get more accurate results, may take more time", ultraMode: "Ultra", ultraModeDescription: - "Pro mode with subagents enabled, maximum capability for complex tasks", + "Reasoning, planning and execution with subagents to divide work; best for complex multi-step tasks", searchModels: "Search models...", surpriseMe: "Surprise", surpriseMePrompt: "Surprise me", diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 316e762..6f10f68 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -75,11 +75,11 @@ export const zhCN: Translations = { flashModeDescription: "快速且高效的完成任务,但可能不够精准", reasoningMode: "思考", reasoningModeDescription: "思考后再行动,在时间与准确性之间取得平衡", - proMode: "专业", + proMode: "Pro", proModeDescription: "思考、计划再执行,获得更精准的结果,可能需要更多时间", - ultraMode: "超级", + ultraMode: "Ultra", ultraModeDescription: - "专业模式加子代理,适用于复杂的多步骤任务,功能最强大", + "思考、计划并执行,可调用子代理分工协作,适合复杂多步骤任务,能力最强", searchModels: "搜索模型...", surpriseMe: "小惊喜", surpriseMePrompt: "给我一个小惊喜吧", diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index bd0c391..a414622 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1,6 +1,12 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); } + +/** Shared class for external links (underline by default). */ +export const externalLinkClass = + "text-primary underline underline-offset-2 hover:no-underline"; +/** For streaming / loading state when link may be a citation (no underline). */ +export const externalLinkClassNoUnderline = "text-primary hover:underline";