Merge pull request #26 from LofiSu/experimental

引用(Citations)优化、Gateway 路径工具抽离、模式悬停说明与中英文国际化
This commit is contained in:
LofiSu
2026-02-09 13:10:37 +08:00
committed by GitHub
21 changed files with 535 additions and 675 deletions

View File

@@ -267,6 +267,7 @@ The key AI trends for 2026 include enhanced reasoning capabilities and multimoda
<critical_reminders>
- **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 `<citations>` 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 `<citations>` block (JSONL format) and use [Title](url) markdown links in your response so citations display correctly.\n"
if subagent_enabled
else ""
)

View File

@@ -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

View File

@@ -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:
- <citations>...</citations> 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'<citations>([\s\S]*?)</citations>'
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 <citations> JSONL blocks. Format must match frontend core/citations/utils.ts."""
urls: set[str] = set()
for match in re.finditer(r"<citations>([\s\S]*?)</citations>", 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'<citations>[\s\S]*?</citations>', '', 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"<citations>[\s\S]*?</citations>", "", content)
if "<citations>" in result:
result = re.sub(r'<citations>[\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"<citations>[\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}")

View File

@@ -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():

View File

@@ -24,10 +24,21 @@ Do NOT use for simple, single-step operations.""",
- Do NOT ask for clarification - work with the information provided
</guidelines>
<citations_format>
If you used web_search (or similar) and cite sources, ALWAYS include citations in your output:
1. Start with a `<citations>` 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:
<citations>
{"id": "cite-1", "title": "...", "url": "https://...", "snippet": "..."}
</citations>
</citations_format>
<output_format>
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)
</output_format>

View File

@@ -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;

View File

@@ -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",

View File

@@ -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) => (
<span
className={cn("group inline items-center gap-1", className)}
{...props}
/>
);
export type InlineCitationTextProps = ComponentProps<"span">;
export const InlineCitationText = ({
className,
...props
}: InlineCitationTextProps) => (
<span
className={cn("transition-colors group-hover:bg-accent", className)}
{...props}
/>
);
export type InlineCitationCardProps = ComponentProps<typeof HoverCard>;
export const InlineCitationCard = (props: InlineCitationCardProps) => (
<HoverCard closeDelay={0} openDelay={0} {...props} />
);
export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & {
sources: string[];
};
export const InlineCitationCardTrigger = ({
sources,
className,
...props
}: InlineCitationCardTriggerProps) => (
<HoverCardTrigger asChild>
<Badge
className={cn("ml-1 rounded-full", className)}
variant="secondary"
{...props}
>
{sources[0] ? (
<>
{new URL(sources[0]).hostname}{" "}
{sources.length > 1 && `+${sources.length - 1}`}
</>
) : (
"unknown"
)}
</Badge>
</HoverCardTrigger>
);
export type InlineCitationCardBodyProps = ComponentProps<"div">;
export const InlineCitationCardBody = ({
@@ -93,155 +37,6 @@ export const InlineCitationCardBody = ({
<HoverCardContent className={cn("relative w-80 p-0", className)} {...props} />
);
const CarouselApiContext = createContext<CarouselApi | undefined>(undefined);
const useCarouselApi = () => {
const context = useContext(CarouselApiContext);
return context;
};
export type InlineCitationCarouselProps = ComponentProps<typeof Carousel>;
export const InlineCitationCarousel = ({
className,
children,
...props
}: InlineCitationCarouselProps) => {
const [api, setApi] = useState<CarouselApi>();
return (
<CarouselApiContext.Provider value={api}>
<Carousel className={cn("w-full", className)} setApi={setApi} {...props}>
{children}
</Carousel>
</CarouselApiContext.Provider>
);
};
export type InlineCitationCarouselContentProps = ComponentProps<"div">;
export const InlineCitationCarouselContent = (
props: InlineCitationCarouselContentProps
) => <CarouselContent {...props} />;
export type InlineCitationCarouselItemProps = ComponentProps<"div">;
export const InlineCitationCarouselItem = ({
className,
...props
}: InlineCitationCarouselItemProps) => (
<CarouselItem
className={cn("w-full space-y-2 p-4 pl-8", className)}
{...props}
/>
);
export type InlineCitationCarouselHeaderProps = ComponentProps<"div">;
export const InlineCitationCarouselHeader = ({
className,
...props
}: InlineCitationCarouselHeaderProps) => (
<div
className={cn(
"flex items-center justify-between gap-2 rounded-t-md bg-secondary p-2",
className
)}
{...props}
/>
);
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 (
<div
className={cn(
"flex flex-1 items-center justify-end px-3 py-1 text-muted-foreground text-xs",
className
)}
{...props}
>
{children ?? `${current}/${count}`}
</div>
);
};
export type InlineCitationCarouselPrevProps = ComponentProps<"button">;
export const InlineCitationCarouselPrev = ({
className,
...props
}: InlineCitationCarouselPrevProps) => {
const api = useCarouselApi();
const handleClick = useCallback(() => {
if (api) {
api.scrollPrev();
}
}, [api]);
return (
<button
aria-label="Previous"
className={cn("shrink-0", className)}
onClick={handleClick}
type="button"
{...props}
>
<ArrowLeftIcon className="size-4 text-muted-foreground" />
</button>
);
};
export type InlineCitationCarouselNextProps = ComponentProps<"button">;
export const InlineCitationCarouselNext = ({
className,
...props
}: InlineCitationCarouselNextProps) => {
const api = useCarouselApi();
const handleClick = useCallback(() => {
if (api) {
api.scrollNext();
}
}, [api]);
return (
<button
aria-label="Next"
className={cn("shrink-0", className)}
onClick={handleClick}
type="button"
{...props}
>
<ArrowRightIcon className="size-4 text-muted-foreground" />
</button>
);
};
export type InlineCitationSourceProps = ComponentProps<"div"> & {
title?: string;
url?: string;
@@ -272,24 +67,6 @@ export const InlineCitationSource = ({
</div>
);
export type InlineCitationQuoteProps = ComponentProps<"blockquote">;
export const InlineCitationQuote = ({
children,
className,
...props
}: InlineCitationQuoteProps) => (
<blockquote
className={cn(
"border-muted border-l-2 pl-3 text-muted-foreground text-sm italic",
className
)}
{...props}
>
{children}
</blockquote>
);
/**
* 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<string, Citation>;
isHuman?: boolean;
isLoadingCitations?: boolean;
syntheticExternal?: boolean;
};
export const CitationAwareLink = ({
href,
children,
citationMap,
isHuman = false,
isLoadingCitations = false,
syntheticExternal = false,
className,
...rest
}: CitationAwareLinkProps) => {
if (!href) return <span>{children}</span>;
const citation = citationMap.get(href);
if (citation && !isHuman) {
return (
<CitationLink citation={citation} href={href}>
{children}
</CitationLink>
);
}
if (syntheticExternal && isExternalUrl(href)) {
const linkText =
typeof children === "string"
? children
: String(Children.toArray(children).join("")).trim() || href;
return (
<CitationLink
citation={syntheticCitationFromLink(href, linkText)}
href={href}
>
{children}
</CitationLink>
);
}
const noUnderline = !isHuman && isLoadingCitations;
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={cn(noUnderline ? externalLinkClassNoUnderline : externalLinkClass, className)}
{...rest}
>
{children}
</a>
);
};
/**
* Shared CitationsLoadingIndicator component
* Used across message-list-item and message-group to show loading citations

View File

@@ -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<string, Citation>;
}) {
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 (
<div className="size-full px-4">
@@ -305,36 +297,13 @@ export function ArtifactFilePreview({
className="size-full"
{...streamdownPlugins}
components={{
a: ({
href,
children,
}: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (!href) {
return <span>{children}</span>;
}
// Only render as CitationLink badge if it's a citation (in citationMap)
const citation = citationMap.get(href);
if (citation) {
return (
<CitationLink citation={citation} href={href}>
{children}
</CitationLink>
);
}
// All other links (including project URLs) render as plain links
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2 hover:no-underline"
>
{children}
</a>
);
},
a: (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
<CitationAwareLink
{...props}
citationMap={citationMap}
syntheticExternal
/>
),
}}
>
{cleanContent ?? ""}

View File

@@ -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({
</PromptInputActionMenu> */}
<AddAttachmentsButton className="px-2!" />
<PromptInputActionMenu>
<PromptInputActionMenuTrigger className="gap-1! px-2!">
<div>
{context.mode === "flash" && <ZapIcon className="size-3" />}
{context.mode === "thinking" && (
<LightbulbIcon className="size-3" />
)}
{context.mode === "pro" && (
<GraduationCapIcon className="size-3" />
)}
{context.mode === "ultra" && (
<RocketIcon className="size-3 text-[#dabb5e]" />
)}
</div>
<div
className={cn(
"text-xs font-normal",
context.mode === "ultra" ? "golden-text" : "",
)}
>
{(context.mode === "flash" && t.inputBox.flashMode) ||
(context.mode === "thinking" && t.inputBox.reasoningMode) ||
(context.mode === "pro" && t.inputBox.proMode) ||
(context.mode === "ultra" && t.inputBox.ultraMode)}
</div>
</PromptInputActionMenuTrigger>
<ModeHoverGuide
mode={
context.mode === "flash" ||
context.mode === "thinking" ||
context.mode === "pro" ||
context.mode === "ultra"
? context.mode
: "flash"
}
>
<PromptInputActionMenuTrigger className="gap-1! px-2!">
<div>
{context.mode === "flash" && <ZapIcon className="size-3" />}
{context.mode === "thinking" && (
<LightbulbIcon className="size-3" />
)}
{context.mode === "pro" && (
<GraduationCapIcon className="size-3" />
)}
{context.mode === "ultra" && (
<RocketIcon className="size-3 text-[#dabb5e]" />
)}
</div>
<div
className={cn(
"text-xs font-normal",
context.mode === "ultra" ? "golden-text" : "",
)}
>
{(context.mode === "flash" && t.inputBox.flashMode) ||
(context.mode === "thinking" && t.inputBox.reasoningMode) ||
(context.mode === "pro" && t.inputBox.proMode) ||
(context.mode === "ultra" && t.inputBox.ultraMode)}
</div>
</PromptInputActionMenuTrigger>
</ModeHoverGuide>
<PromptInputActionMenuContent className="w-80">
<DropdownMenuGroup>
<DropdownMenuLabel className="text-muted-foreground text-xs">

View File

@@ -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 ?? "")}
</MessageResponse>
}
></ChainOfThoughtStep>
@@ -177,10 +181,7 @@ export function MessageGroup({
remarkPlugins={streamdownPlugins.remarkPlugins}
rehypePlugins={rehypePlugins}
>
{
parseCitations(lastReasoningStep.reasoning ?? "")
.cleanContent
}
{getCleanContent(lastReasoningStep.reasoning ?? "")}
</MessageResponse>
}
></ChainOfThoughtStep>
@@ -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) && (
<ChainOfThoughtSearchResults>
{result.map((item) => (
<Tooltip key={item.url} content={item.snippet}>
<ChainOfThoughtSearchResult key={item.url}>
<a href={item.url} target="_blank" rel="noreferrer">
{item.title}
</a>
</ChainOfThoughtSearchResult>
</Tooltip>
<ChainOfThoughtSearchResult key={item.url}>
<a href={item.url} target="_blank" rel="noreferrer">
{item.title}
</a>
</ChainOfThoughtSearchResult>
))}
</ChainOfThoughtSearchResults>
)}
@@ -309,11 +304,9 @@ function ToolCall({
>
<ChainOfThoughtSearchResult>
{url && (
<Tooltip content={<pre>{result as string}</pre>}>
<a href={url} target="_blank" rel="noreferrer">
{title}
</a>
</Tooltip>
<a href={url} target="_blank" rel="noreferrer">
{title}
</a>
)}
</ChainOfThoughtSearchResult>
</ChainOfThoughtStep>
@@ -328,11 +321,9 @@ function ToolCall({
return (
<ChainOfThoughtStep key={id} label={description} icon={FolderOpenIcon}>
{path && (
<Tooltip content={<pre>{result as string}</pre>}>
<ChainOfThoughtSearchResult className="cursor-pointer">
{path}
</ChainOfThoughtSearchResult>
</Tooltip>
<ChainOfThoughtSearchResult className="cursor-pointer">
{path}
</ChainOfThoughtSearchResult>
)}
</ChainOfThoughtStep>
);
@@ -346,17 +337,9 @@ function ToolCall({
return (
<ChainOfThoughtStep key={id} label={description} icon={BookOpenTextIcon}>
{path && (
<Tooltip
content={
<pre className="max-w-[95vw] whitespace-pre-wrap">
{result as string}
</pre>
}
>
<ChainOfThoughtSearchResult className="cursor-pointer">
{path}
</ChainOfThoughtSearchResult>
</Tooltip>
<ChainOfThoughtSearchResult className="cursor-pointer">
{path}
</ChainOfThoughtSearchResult>
)}
</ChainOfThoughtStep>
);
@@ -384,9 +367,8 @@ function ToolCall({
const isMarkdown =
path?.toLowerCase().endsWith(".md") ||
path?.toLowerCase().endsWith(".markdown");
const hasCitationsBlock = fileContent.includes("<citations>");
const showCitationsLoading =
isMarkdown && threadIsLoading && hasCitationsBlock && isLast;
isMarkdown && threadIsLoading && hasCitationsBlock(fileContent) && isLast;
return (
<>
@@ -405,11 +387,9 @@ function ToolCall({
}}
>
{path && (
<Tooltip content={t.toolCalls.clickToViewContent}>
<ChainOfThoughtSearchResult className="cursor-pointer">
{path}
</ChainOfThoughtSearchResult>
</Tooltip>
<ChainOfThoughtSearchResult className="cursor-pointer">
{path}
</ChainOfThoughtSearchResult>
)}
</ChainOfThoughtStep>
{showCitationsLoading && (
@@ -433,14 +413,12 @@ function ToolCall({
icon={SquareTerminalIcon}
>
{command && (
<Tooltip content={<pre>{result as string}</pre>}>
<CodeBlock
className="mx-0 cursor-pointer border-none px-0"
showLineNumbers={false}
language="bash"
code={command}
/>
</Tooltip>
<CodeBlock
className="mx-0 cursor-pointer border-none px-0"
showLineNumbers={false}
language="bash"
code={command}
/>
)}
</ChainOfThoughtStep>
);

View File

@@ -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<HTMLAnchorElement> & {
citationMap: Map<string, Citation>;
isHuman: boolean;
}) {
if (!href) return <span>{children}</span>;
const citation = citationMap.get(href);
// Only render as CitationLink badge if it's a citation (in citationMap) and not human message
if (citation && !isHuman) {
return (
<CitationLink citation={citation} href={href}>
{children}
</CitationLink>
);
}
// All other links render as plain links
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2 hover:no-underline"
>
{children}
</a>
);
}
/**
* Custom image component that handles artifact URLs
*/
@@ -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<HTMLAnchorElement>) => (
<MessageLink {...props} citationMap={citationMap} isHuman={isHuman} />
<CitationAwareLink
{...props}
citationMap={citationMap}
isHuman={isHuman}
isLoadingCitations={isLoadingCitations}
/>
),
img: (props: React.ImgHTMLAttributes<HTMLImageElement>) => (
<MessageImage {...props} threadId={thread_id} maxWidth={isHuman ? "full" : "90%"} />
),
}), [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

View File

@@ -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 <Tooltip content={content}>{children}</Tooltip>;
}

View File

@@ -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.
`;

View File

@@ -2,8 +2,8 @@
import { Streamdown } from "streamdown";
import about from "./about.md";
import { aboutMarkdown } from "./about-content";
export function AboutSettingsPage() {
return <Streamdown>{about}</Streamdown>;
return <Streamdown>{aboutMarkdown}</Streamdown>;
}

View File

@@ -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";

View File

@@ -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<string, Citation>;
}
/**
* 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]);
}

View File

@@ -67,14 +67,7 @@ export function parseCitations(content: string): ParseCitationsResult {
}
}
// Remove ALL citations blocks from content (both complete and incomplete)
cleanContent = content.replace(/<citations>[\s\S]*?<\/citations>/g, "").trim();
// Also remove incomplete citations blocks (during streaming)
// Match <citations> without closing tag or <citations> followed by anything until end of string
if (cleanContent.includes("<citations>")) {
cleanContent = cleanContent.replace(/<citations>[\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 <citations> 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 <citations> 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(/<citations>[\s\S]*?<\/citations>/g, "").trim();
if (result.includes("<citations>")) {
result = result.replace(/<citations>[\s\S]*$/g, "").trim();
}
return result;
}
/**
* Whether content contains a <citations> block (open tag).
*/
export function hasCitationsBlock(content: string): boolean {
return Boolean(content?.includes("<citations>"));
}
/**
* 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("<citations>");
const hasCloseTag = content.includes("</citations>");
return hasOpenTag && !hasCloseTag;
return hasCitationsBlock(content) && !content.includes("</citations>");
}
/**
* Remove ALL citations from content, including:
* - <citations> 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 <citations> blocks (complete and incomplete)
result = result.replace(/<citations>[\s\S]*?<\/citations>/g, "");
result = result.replace(/<citations>[\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));
}

View File

@@ -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",

View File

@@ -75,11 +75,11 @@ export const zhCN: Translations = {
flashModeDescription: "快速且高效的完成任务,但可能不够精准",
reasoningMode: "思考",
reasoningModeDescription: "思考后再行动,在时间与准确性之间取得平衡",
proMode: "专业",
proMode: "Pro",
proModeDescription: "思考、计划再执行,获得更精准的结果,可能需要更多时间",
ultraMode: "超级",
ultraMode: "Ultra",
ultraModeDescription:
"专业模式加子代理,适用于复杂多步骤任务,能最强",
"思考、计划并执行,可调用子代理分工协作,适合复杂多步骤任务,能最强",
searchModels: "搜索模型...",
surpriseMe: "小惊喜",
surpriseMePrompt: "给我一个小惊喜吧",

View File

@@ -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";