mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-19 12:24:46 +08:00
Merge pull request #26 from LofiSu/experimental
引用(Citations)优化、Gateway 路径工具抽离、模式悬停说明与中英文国际化
This commit is contained in:
@@ -267,6 +267,7 @@ The key AI trends for 2026 include enhanced reasoning capabilities and multimoda
|
|||||||
|
|
||||||
<critical_reminders>
|
<critical_reminders>
|
||||||
- **Clarification First**: ALWAYS clarify unclear/missing/ambiguous requirements BEFORE starting work - never assume or guess
|
- **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.
|
{subagent_reminder}- Skill First: Always load the relevant skill before starting **complex** tasks.
|
||||||
- Progressive Loading: Load resources incrementally as referenced in skills
|
- Progressive Loading: Load resources incrementally as referenced in skills
|
||||||
- Output Files: Final deliverables must be in `/mnt/user-data/outputs`
|
- 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
|
# Add subagent reminder to critical_reminders if enabled
|
||||||
subagent_reminder = (
|
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"
|
"- **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
|
if subagent_enabled
|
||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
|
|||||||
44
backend/src/gateway/path_utils.py
Normal file
44
backend/src/gateway/path_utils.py
Normal 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
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -8,49 +8,11 @@ from urllib.parse import quote
|
|||||||
from fastapi import APIRouter, HTTPException, Request, Response
|
from fastapi import APIRouter, HTTPException, Request, Response
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse
|
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse
|
||||||
|
|
||||||
# Base directory for thread data (relative to backend/)
|
from src.gateway.path_utils import resolve_thread_virtual_path
|
||||||
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"
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["artifacts"])
|
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:
|
def is_text_file_by_content(path: Path, sample_size: int = 8192) -> bool:
|
||||||
"""Check if file is text by examining content for null bytes."""
|
"""Check if file is text by examining content for null bytes."""
|
||||||
try:
|
try:
|
||||||
@@ -62,66 +24,38 @@ def is_text_file_by_content(path: Path, sample_size: int = 8192) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def remove_citations_block(content: str) -> str:
|
def _extract_citation_urls(content: str) -> set[str]:
|
||||||
"""Remove ALL citations from markdown content.
|
"""Extract URLs from <citations> JSONL blocks. Format must match frontend core/citations/utils.ts."""
|
||||||
|
urls: set[str] = set()
|
||||||
Removes:
|
for match in re.finditer(r"<citations>([\s\S]*?)</citations>", content):
|
||||||
- <citations>...</citations> blocks (complete and incomplete)
|
for line in match.group(1).split("\n"):
|
||||||
- [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'):
|
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if line.startswith('{'):
|
if line.startswith("{"):
|
||||||
try:
|
try:
|
||||||
citation = json.loads(line)
|
obj = json.loads(line)
|
||||||
if 'url' in citation:
|
if "url" in obj:
|
||||||
citation_urls.add(citation['url'])
|
urls.add(obj["url"])
|
||||||
except (json.JSONDecodeError, ValueError):
|
except (json.JSONDecodeError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
return urls
|
||||||
# Step 2: Remove complete citations blocks
|
|
||||||
result = re.sub(r'<citations>[\s\S]*?</citations>', '', result)
|
|
||||||
|
def remove_citations_block(content: str) -> str:
|
||||||
# Step 3: Remove incomplete citations blocks (at end of content during streaming)
|
"""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:
|
if "<citations>" in result:
|
||||||
result = re.sub(r'<citations>[\s\S]*$', '', result)
|
result = re.sub(r"<citations>[\s\S]*$", "", result)
|
||||||
|
result = re.sub(r"\[cite-\d+\]", "", result)
|
||||||
# Step 4: Remove all [cite-N] references
|
|
||||||
result = re.sub(r'\[cite-\d+\]', '', result)
|
for url in citation_urls:
|
||||||
|
result = re.sub(rf"\[[^\]]+\]\({re.escape(url)}\)", "", result)
|
||||||
# Step 5: Remove markdown links that point to citation URLs
|
|
||||||
# Pattern: [text](url)
|
return re.sub(r"\n{3,}", "\n\n", result).strip()
|
||||||
if citation_urls:
|
|
||||||
for url in citation_urls:
|
|
||||||
# Escape special regex characters in URL
|
|
||||||
escaped_url = re.escape(url)
|
|
||||||
result = re.sub(rf'\[[^\]]+\]\({escaped_url}\)', '', result)
|
|
||||||
|
|
||||||
# Step 6: Clean up extra whitespace and newlines
|
|
||||||
result = re.sub(r'\n{3,}', '\n\n', result) # Replace 3+ newlines with 2
|
|
||||||
|
|
||||||
return result.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_file_from_skill_archive(zip_path: Path, internal_path: str) -> bytes | None:
|
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"
|
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"
|
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():
|
if not actual_skill_path.exists():
|
||||||
raise HTTPException(status_code=404, detail=f"Skill file not found: {skill_file_path}")
|
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:
|
except UnicodeDecodeError:
|
||||||
return Response(content=content, media_type=mime_type or "application/octet-stream", headers=cache_headers)
|
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():
|
if not actual_path.exists():
|
||||||
raise HTTPException(status_code=404, detail=f"Artifact not found: {path}")
|
raise HTTPException(status_code=404, detail=f"Artifact not found: {path}")
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -12,6 +11,7 @@ from fastapi import APIRouter, HTTPException
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from src.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
|
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 import Skill, load_skills
|
||||||
from src.skills.loader import get_skills_root_path
|
from src.skills.loader import get_skills_root_path
|
||||||
|
|
||||||
@@ -56,53 +56,10 @@ class SkillInstallResponse(BaseModel):
|
|||||||
message: str = Field(..., description="Installation result message")
|
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 properties in SKILL.md frontmatter
|
||||||
ALLOWED_FRONTMATTER_PROPERTIES = {"name", "description", "license", "allowed-tools", "metadata"}
|
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]:
|
def _validate_skill_frontmatter(skill_dir: Path) -> tuple[bool, str, str | None]:
|
||||||
"""Validate a skill directory's SKILL.md frontmatter.
|
"""Validate a skill directory's SKILL.md frontmatter.
|
||||||
|
|
||||||
@@ -414,7 +371,7 @@ async def install_skill(request: SkillInstallRequest) -> SkillInstallResponse:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Resolve the virtual path to actual file path
|
# 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
|
# Check if file exists
|
||||||
if not skill_file_path.exists():
|
if not skill_file_path.exists():
|
||||||
|
|||||||
@@ -24,10 +24,21 @@ Do NOT use for simple, single-step operations.""",
|
|||||||
- Do NOT ask for clarification - work with the information provided
|
- Do NOT ask for clarification - work with the information provided
|
||||||
</guidelines>
|
</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>
|
<output_format>
|
||||||
When you complete the task, provide:
|
When you complete the task, provide:
|
||||||
1. A brief summary of what was accomplished
|
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
|
3. Any relevant file paths, data, or artifacts created
|
||||||
4. Issues encountered (if any)
|
4. Issues encountered (if any)
|
||||||
</output_format>
|
</output_format>
|
||||||
|
|||||||
@@ -7,15 +7,6 @@ import "./src/env.js";
|
|||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
devIndicators: false,
|
devIndicators: false,
|
||||||
turbopack: {
|
|
||||||
root: import.meta.dirname,
|
|
||||||
rules: {
|
|
||||||
"*.md": {
|
|
||||||
loaders: ["raw-loader"],
|
|
||||||
as: "*.js",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -97,7 +97,6 @@
|
|||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
"raw-loader": "^4.0.2",
|
|
||||||
"tailwindcss": "^4.0.15",
|
"tailwindcss": "^4.0.15",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.8.2",
|
||||||
|
|||||||
@@ -1,89 +1,33 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
|
||||||
Carousel,
|
|
||||||
type CarouselApi,
|
|
||||||
CarouselContent,
|
|
||||||
CarouselItem,
|
|
||||||
} from "@/components/ui/carousel";
|
|
||||||
import {
|
import {
|
||||||
HoverCard,
|
HoverCard,
|
||||||
HoverCardContent,
|
HoverCardContent,
|
||||||
HoverCardTrigger,
|
HoverCardTrigger,
|
||||||
} from "@/components/ui/hover-card";
|
} from "@/components/ui/hover-card";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { ExternalLinkIcon, ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
type ComponentProps,
|
cn,
|
||||||
createContext,
|
externalLinkClass,
|
||||||
useCallback,
|
externalLinkClassNoUnderline,
|
||||||
useContext,
|
} from "@/lib/utils";
|
||||||
useEffect,
|
import { ExternalLinkIcon } from "lucide-react";
|
||||||
useState,
|
import { type ComponentProps, Children } from "react";
|
||||||
} from "react";
|
|
||||||
import type { Citation } from "@/core/citations";
|
import type { Citation } from "@/core/citations";
|
||||||
import { extractDomainFromUrl } from "@/core/citations";
|
import {
|
||||||
|
extractDomainFromUrl,
|
||||||
|
isExternalUrl,
|
||||||
|
syntheticCitationFromLink,
|
||||||
|
} from "@/core/citations";
|
||||||
import { Shimmer } from "./shimmer";
|
import { Shimmer } from "./shimmer";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
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 type InlineCitationCardProps = ComponentProps<typeof HoverCard>;
|
||||||
|
|
||||||
export const InlineCitationCard = (props: InlineCitationCardProps) => (
|
export const InlineCitationCard = (props: InlineCitationCardProps) => (
|
||||||
<HoverCard closeDelay={0} openDelay={0} {...props} />
|
<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 type InlineCitationCardBodyProps = ComponentProps<"div">;
|
||||||
|
|
||||||
export const InlineCitationCardBody = ({
|
export const InlineCitationCardBody = ({
|
||||||
@@ -93,155 +37,6 @@ export const InlineCitationCardBody = ({
|
|||||||
<HoverCardContent className={cn("relative w-80 p-0", className)} {...props} />
|
<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"> & {
|
export type InlineCitationSourceProps = ComponentProps<"div"> & {
|
||||||
title?: string;
|
title?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
@@ -272,24 +67,6 @@ export const InlineCitationSource = ({
|
|||||||
</div>
|
</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
|
* Shared CitationLink component that renders a citation as a hover card badge
|
||||||
* Used across message-list-item, artifact-file-detail, and message-group
|
* 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
|
* Shared CitationsLoadingIndicator component
|
||||||
* Used across message-list-item and message-group to show loading citations
|
* Used across message-list-item and message-group to show loading citations
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
ArtifactHeader,
|
ArtifactHeader,
|
||||||
ArtifactTitle,
|
ArtifactTitle,
|
||||||
} from "@/components/ai-elements/artifact";
|
} 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 { Select, SelectItem } from "@/components/ui/select";
|
||||||
import {
|
import {
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -33,10 +33,11 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
|||||||
import { CodeEditor } from "@/components/workspace/code-editor";
|
import { CodeEditor } from "@/components/workspace/code-editor";
|
||||||
import { useArtifactContent } from "@/core/artifacts/hooks";
|
import { useArtifactContent } from "@/core/artifacts/hooks";
|
||||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||||
|
import type { Citation } from "@/core/citations";
|
||||||
import {
|
import {
|
||||||
buildCitationMap,
|
contentWithoutCitationsFromParsed,
|
||||||
parseCitations,
|
|
||||||
removeAllCitations,
|
removeAllCitations,
|
||||||
|
useParsedCitations,
|
||||||
} from "@/core/citations";
|
} from "@/core/citations";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { installSkill } from "@/core/skills/api";
|
import { installSkill } from "@/core/skills/api";
|
||||||
@@ -94,21 +95,15 @@ export function ArtifactFileDetail({
|
|||||||
enabled: isCodeFile && !isWriteFile,
|
enabled: isCodeFile && !isWriteFile,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse citations and get clean content for code editor
|
const parsed = useParsedCitations(
|
||||||
const cleanContent = useMemo(() => {
|
language === "markdown" ? (content ?? "") : "",
|
||||||
if (language === "markdown" && content) {
|
);
|
||||||
return parseCitations(content).cleanContent;
|
const cleanContent =
|
||||||
}
|
language === "markdown" && content ? parsed.cleanContent : (content ?? "");
|
||||||
return content;
|
const contentWithoutCitations =
|
||||||
}, [content, language]);
|
language === "markdown" && content
|
||||||
|
? contentWithoutCitationsFromParsed(parsed)
|
||||||
// Get content without ANY citations for copy/download
|
: (content ?? "");
|
||||||
const contentWithoutCitations = useMemo(() => {
|
|
||||||
if (language === "markdown" && content) {
|
|
||||||
return removeAllCitations(content);
|
|
||||||
}
|
|
||||||
return content;
|
|
||||||
}, [content, language]);
|
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
|
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
|
||||||
const [isInstalling, setIsInstalling] = useState(false);
|
const [isInstalling, setIsInstalling] = useState(false);
|
||||||
@@ -258,6 +253,8 @@ export function ArtifactFileDetail({
|
|||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
content={content}
|
content={content}
|
||||||
language={language ?? "text"}
|
language={language ?? "text"}
|
||||||
|
cleanContent={parsed.cleanContent}
|
||||||
|
citationMap={parsed.citationMap}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isCodeFile && viewMode === "code" && (
|
{isCodeFile && viewMode === "code" && (
|
||||||
@@ -283,21 +280,16 @@ export function ArtifactFilePreview({
|
|||||||
threadId,
|
threadId,
|
||||||
content,
|
content,
|
||||||
language,
|
language,
|
||||||
|
cleanContent,
|
||||||
|
citationMap,
|
||||||
}: {
|
}: {
|
||||||
filepath: string;
|
filepath: string;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
content: string;
|
content: string;
|
||||||
language: 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") {
|
if (language === "markdown") {
|
||||||
return (
|
return (
|
||||||
<div className="size-full px-4">
|
<div className="size-full px-4">
|
||||||
@@ -305,36 +297,13 @@ export function ArtifactFilePreview({
|
|||||||
className="size-full"
|
className="size-full"
|
||||||
{...streamdownPlugins}
|
{...streamdownPlugins}
|
||||||
components={{
|
components={{
|
||||||
a: ({
|
a: (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
|
||||||
href,
|
<CitationAwareLink
|
||||||
children,
|
{...props}
|
||||||
}: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
citationMap={citationMap}
|
||||||
if (!href) {
|
syntheticExternal
|
||||||
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>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{cleanContent ?? ""}
|
{cleanContent ?? ""}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "../ui/dropdown-menu";
|
} from "../ui/dropdown-menu";
|
||||||
|
|
||||||
|
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||||
import { Tooltip } from "./tooltip";
|
import { Tooltip } from "./tooltip";
|
||||||
|
|
||||||
export function InputBox({
|
export function InputBox({
|
||||||
@@ -197,31 +198,42 @@ export function InputBox({
|
|||||||
</PromptInputActionMenu> */}
|
</PromptInputActionMenu> */}
|
||||||
<AddAttachmentsButton className="px-2!" />
|
<AddAttachmentsButton className="px-2!" />
|
||||||
<PromptInputActionMenu>
|
<PromptInputActionMenu>
|
||||||
<PromptInputActionMenuTrigger className="gap-1! px-2!">
|
<ModeHoverGuide
|
||||||
<div>
|
mode={
|
||||||
{context.mode === "flash" && <ZapIcon className="size-3" />}
|
context.mode === "flash" ||
|
||||||
{context.mode === "thinking" && (
|
context.mode === "thinking" ||
|
||||||
<LightbulbIcon className="size-3" />
|
context.mode === "pro" ||
|
||||||
)}
|
context.mode === "ultra"
|
||||||
{context.mode === "pro" && (
|
? context.mode
|
||||||
<GraduationCapIcon className="size-3" />
|
: "flash"
|
||||||
)}
|
}
|
||||||
{context.mode === "ultra" && (
|
>
|
||||||
<RocketIcon className="size-3 text-[#dabb5e]" />
|
<PromptInputActionMenuTrigger className="gap-1! px-2!">
|
||||||
)}
|
<div>
|
||||||
</div>
|
{context.mode === "flash" && <ZapIcon className="size-3" />}
|
||||||
<div
|
{context.mode === "thinking" && (
|
||||||
className={cn(
|
<LightbulbIcon className="size-3" />
|
||||||
"text-xs font-normal",
|
)}
|
||||||
context.mode === "ultra" ? "golden-text" : "",
|
{context.mode === "pro" && (
|
||||||
)}
|
<GraduationCapIcon className="size-3" />
|
||||||
>
|
)}
|
||||||
{(context.mode === "flash" && t.inputBox.flashMode) ||
|
{context.mode === "ultra" && (
|
||||||
(context.mode === "thinking" && t.inputBox.reasoningMode) ||
|
<RocketIcon className="size-3 text-[#dabb5e]" />
|
||||||
(context.mode === "pro" && t.inputBox.proMode) ||
|
)}
|
||||||
(context.mode === "ultra" && t.inputBox.ultraMode)}
|
</div>
|
||||||
</div>
|
<div
|
||||||
</PromptInputActionMenuTrigger>
|
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">
|
<PromptInputActionMenuContent className="w-80">
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuLabel className="text-muted-foreground text-xs">
|
<DropdownMenuLabel className="text-muted-foreground text-xs">
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ import { CodeBlock } from "@/components/ai-elements/code-block";
|
|||||||
import { CitationsLoadingIndicator } from "@/components/ai-elements/inline-citation";
|
import { CitationsLoadingIndicator } from "@/components/ai-elements/inline-citation";
|
||||||
import { MessageResponse } from "@/components/ai-elements/message";
|
import { MessageResponse } from "@/components/ai-elements/message";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { useI18n } from "@/core/i18n/hooks";
|
||||||
import {
|
import {
|
||||||
extractReasoningContentFromMessage,
|
extractReasoningContentFromMessage,
|
||||||
@@ -124,7 +128,7 @@ export function MessageGroup({
|
|||||||
remarkPlugins={streamdownPlugins.remarkPlugins}
|
remarkPlugins={streamdownPlugins.remarkPlugins}
|
||||||
rehypePlugins={rehypePlugins}
|
rehypePlugins={rehypePlugins}
|
||||||
>
|
>
|
||||||
{parseCitations(step.reasoning ?? "").cleanContent}
|
{getCleanContent(step.reasoning ?? "")}
|
||||||
</MessageResponse>
|
</MessageResponse>
|
||||||
}
|
}
|
||||||
></ChainOfThoughtStep>
|
></ChainOfThoughtStep>
|
||||||
@@ -177,10 +181,7 @@ export function MessageGroup({
|
|||||||
remarkPlugins={streamdownPlugins.remarkPlugins}
|
remarkPlugins={streamdownPlugins.remarkPlugins}
|
||||||
rehypePlugins={rehypePlugins}
|
rehypePlugins={rehypePlugins}
|
||||||
>
|
>
|
||||||
{
|
{getCleanContent(lastReasoningStep.reasoning ?? "")}
|
||||||
parseCitations(lastReasoningStep.reasoning ?? "")
|
|
||||||
.cleanContent
|
|
||||||
}
|
|
||||||
</MessageResponse>
|
</MessageResponse>
|
||||||
}
|
}
|
||||||
></ChainOfThoughtStep>
|
></ChainOfThoughtStep>
|
||||||
@@ -215,12 +216,8 @@ function ToolCall({
|
|||||||
const { thread } = useThread();
|
const { thread } = useThread();
|
||||||
const threadIsLoading = thread.isLoading;
|
const threadIsLoading = thread.isLoading;
|
||||||
|
|
||||||
// Move useMemo to top level to comply with React Hooks rules
|
|
||||||
const fileContent = typeof args.content === "string" ? args.content : "";
|
const fileContent = typeof args.content === "string" ? args.content : "";
|
||||||
const { citations } = useMemo(
|
const { citations } = useParsedCitations(fileContent);
|
||||||
() => parseCitations(fileContent),
|
|
||||||
[fileContent],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (name === "web_search") {
|
if (name === "web_search") {
|
||||||
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
||||||
@@ -232,13 +229,11 @@ function ToolCall({
|
|||||||
{Array.isArray(result) && (
|
{Array.isArray(result) && (
|
||||||
<ChainOfThoughtSearchResults>
|
<ChainOfThoughtSearchResults>
|
||||||
{result.map((item) => (
|
{result.map((item) => (
|
||||||
<Tooltip key={item.url} content={item.snippet}>
|
<ChainOfThoughtSearchResult key={item.url}>
|
||||||
<ChainOfThoughtSearchResult key={item.url}>
|
<a href={item.url} target="_blank" rel="noreferrer">
|
||||||
<a href={item.url} target="_blank" rel="noreferrer">
|
{item.title}
|
||||||
{item.title}
|
</a>
|
||||||
</a>
|
</ChainOfThoughtSearchResult>
|
||||||
</ChainOfThoughtSearchResult>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
))}
|
||||||
</ChainOfThoughtSearchResults>
|
</ChainOfThoughtSearchResults>
|
||||||
)}
|
)}
|
||||||
@@ -309,11 +304,9 @@ function ToolCall({
|
|||||||
>
|
>
|
||||||
<ChainOfThoughtSearchResult>
|
<ChainOfThoughtSearchResult>
|
||||||
{url && (
|
{url && (
|
||||||
<Tooltip content={<pre>{result as string}</pre>}>
|
<a href={url} target="_blank" rel="noreferrer">
|
||||||
<a href={url} target="_blank" rel="noreferrer">
|
{title}
|
||||||
{title}
|
</a>
|
||||||
</a>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
)}
|
||||||
</ChainOfThoughtSearchResult>
|
</ChainOfThoughtSearchResult>
|
||||||
</ChainOfThoughtStep>
|
</ChainOfThoughtStep>
|
||||||
@@ -328,11 +321,9 @@ function ToolCall({
|
|||||||
return (
|
return (
|
||||||
<ChainOfThoughtStep key={id} label={description} icon={FolderOpenIcon}>
|
<ChainOfThoughtStep key={id} label={description} icon={FolderOpenIcon}>
|
||||||
{path && (
|
{path && (
|
||||||
<Tooltip content={<pre>{result as string}</pre>}>
|
<ChainOfThoughtSearchResult className="cursor-pointer">
|
||||||
<ChainOfThoughtSearchResult className="cursor-pointer">
|
{path}
|
||||||
{path}
|
</ChainOfThoughtSearchResult>
|
||||||
</ChainOfThoughtSearchResult>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
)}
|
||||||
</ChainOfThoughtStep>
|
</ChainOfThoughtStep>
|
||||||
);
|
);
|
||||||
@@ -346,17 +337,9 @@ function ToolCall({
|
|||||||
return (
|
return (
|
||||||
<ChainOfThoughtStep key={id} label={description} icon={BookOpenTextIcon}>
|
<ChainOfThoughtStep key={id} label={description} icon={BookOpenTextIcon}>
|
||||||
{path && (
|
{path && (
|
||||||
<Tooltip
|
<ChainOfThoughtSearchResult className="cursor-pointer">
|
||||||
content={
|
{path}
|
||||||
<pre className="max-w-[95vw] whitespace-pre-wrap">
|
</ChainOfThoughtSearchResult>
|
||||||
{result as string}
|
|
||||||
</pre>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ChainOfThoughtSearchResult className="cursor-pointer">
|
|
||||||
{path}
|
|
||||||
</ChainOfThoughtSearchResult>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
)}
|
||||||
</ChainOfThoughtStep>
|
</ChainOfThoughtStep>
|
||||||
);
|
);
|
||||||
@@ -384,9 +367,8 @@ function ToolCall({
|
|||||||
const isMarkdown =
|
const isMarkdown =
|
||||||
path?.toLowerCase().endsWith(".md") ||
|
path?.toLowerCase().endsWith(".md") ||
|
||||||
path?.toLowerCase().endsWith(".markdown");
|
path?.toLowerCase().endsWith(".markdown");
|
||||||
const hasCitationsBlock = fileContent.includes("<citations>");
|
|
||||||
const showCitationsLoading =
|
const showCitationsLoading =
|
||||||
isMarkdown && threadIsLoading && hasCitationsBlock && isLast;
|
isMarkdown && threadIsLoading && hasCitationsBlock(fileContent) && isLast;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -405,11 +387,9 @@ function ToolCall({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{path && (
|
{path && (
|
||||||
<Tooltip content={t.toolCalls.clickToViewContent}>
|
<ChainOfThoughtSearchResult className="cursor-pointer">
|
||||||
<ChainOfThoughtSearchResult className="cursor-pointer">
|
{path}
|
||||||
{path}
|
</ChainOfThoughtSearchResult>
|
||||||
</ChainOfThoughtSearchResult>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
)}
|
||||||
</ChainOfThoughtStep>
|
</ChainOfThoughtStep>
|
||||||
{showCitationsLoading && (
|
{showCitationsLoading && (
|
||||||
@@ -433,14 +413,12 @@ function ToolCall({
|
|||||||
icon={SquareTerminalIcon}
|
icon={SquareTerminalIcon}
|
||||||
>
|
>
|
||||||
{command && (
|
{command && (
|
||||||
<Tooltip content={<pre>{result as string}</pre>}>
|
<CodeBlock
|
||||||
<CodeBlock
|
className="mx-0 cursor-pointer border-none px-0"
|
||||||
className="mx-0 cursor-pointer border-none px-0"
|
showLineNumbers={false}
|
||||||
showLineNumbers={false}
|
language="bash"
|
||||||
language="bash"
|
code={command}
|
||||||
code={command}
|
/>
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
)}
|
||||||
</ChainOfThoughtStep>
|
</ChainOfThoughtStep>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { memo, useMemo } from "react";
|
|||||||
import rehypeKatex from "rehype-katex";
|
import rehypeKatex from "rehype-katex";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CitationLink,
|
CitationAwareLink,
|
||||||
CitationsLoadingIndicator,
|
CitationsLoadingIndicator,
|
||||||
} from "@/components/ai-elements/inline-citation";
|
} from "@/components/ai-elements/inline-citation";
|
||||||
import {
|
import {
|
||||||
@@ -17,11 +17,9 @@ import {
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { resolveArtifactURL } from "@/core/artifacts/utils";
|
import { resolveArtifactURL } from "@/core/artifacts/utils";
|
||||||
import {
|
import {
|
||||||
type Citation,
|
|
||||||
buildCitationMap,
|
|
||||||
isCitationsBlockIncomplete,
|
isCitationsBlockIncomplete,
|
||||||
parseCitations,
|
|
||||||
removeAllCitations,
|
removeAllCitations,
|
||||||
|
useParsedCitations,
|
||||||
} from "@/core/citations";
|
} from "@/core/citations";
|
||||||
import {
|
import {
|
||||||
extractContentFromMessage,
|
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
|
* Custom image component that handles artifact URLs
|
||||||
*/
|
*/
|
||||||
@@ -158,55 +116,54 @@ function MessageContent_({
|
|||||||
const isHuman = message.type === "human";
|
const isHuman = message.type === "human";
|
||||||
const { thread_id } = useParams<{ thread_id: string }>();
|
const { thread_id } = useParams<{ thread_id: string }>();
|
||||||
|
|
||||||
// Extract and parse citations and uploaded files from message content
|
// Content to parse for citations (and optionally uploaded files)
|
||||||
const { citations, cleanContent, uploadedFiles, isLoadingCitations } =
|
const { contentToParse, uploadedFiles, isLoadingCitations } = useMemo(() => {
|
||||||
useMemo(() => {
|
const reasoningContent = extractReasoningContentFromMessage(message);
|
||||||
const reasoningContent = extractReasoningContentFromMessage(message);
|
const rawContent = extractContentFromMessage(message);
|
||||||
const rawContent = extractContentFromMessage(message);
|
|
||||||
|
|
||||||
// When only reasoning content exists (no main content), also parse citations
|
if (!isLoading && reasoningContent && !rawContent) {
|
||||||
if (!isLoading && reasoningContent && !rawContent) {
|
return {
|
||||||
const { citations, cleanContent } = parseCitations(reasoningContent);
|
contentToParse: reasoningContent,
|
||||||
return {
|
uploadedFiles: [] as UploadedFile[],
|
||||||
citations,
|
isLoadingCitations: false,
|
||||||
cleanContent,
|
};
|
||||||
uploadedFiles: [],
|
}
|
||||||
isLoadingCitations: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// For human messages, parse uploaded files first
|
if (isHuman && rawContent) {
|
||||||
if (isHuman && rawContent) {
|
const { files, cleanContent: contentWithoutFiles } =
|
||||||
const { files, cleanContent: contentWithoutFiles } =
|
parseUploadedFiles(rawContent);
|
||||||
parseUploadedFiles(rawContent);
|
return {
|
||||||
const { citations, cleanContent: finalContent } =
|
contentToParse: contentWithoutFiles,
|
||||||
parseCitations(contentWithoutFiles);
|
uploadedFiles: files,
|
||||||
return {
|
isLoadingCitations: false,
|
||||||
citations,
|
};
|
||||||
cleanContent: finalContent,
|
}
|
||||||
uploadedFiles: files,
|
|
||||||
isLoadingCitations: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const { citations, cleanContent } = parseCitations(rawContent ?? "");
|
return {
|
||||||
const isLoadingCitations =
|
contentToParse: rawContent ?? "",
|
||||||
isLoading && isCitationsBlockIncomplete(rawContent ?? "");
|
uploadedFiles: [] as UploadedFile[],
|
||||||
|
isLoadingCitations:
|
||||||
|
isLoading && isCitationsBlockIncomplete(rawContent ?? ""),
|
||||||
|
};
|
||||||
|
}, [isLoading, message, isHuman]);
|
||||||
|
|
||||||
return { citations, cleanContent, uploadedFiles: [], isLoadingCitations };
|
const { citations, cleanContent, citationMap } =
|
||||||
}, [isLoading, message, isHuman]);
|
useParsedCitations(contentToParse);
|
||||||
|
|
||||||
const citationMap = useMemo(() => buildCitationMap(citations), [citations]);
|
|
||||||
|
|
||||||
// Shared markdown components
|
// Shared markdown components
|
||||||
const markdownComponents = useMemo(() => ({
|
const markdownComponents = useMemo(() => ({
|
||||||
a: (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
|
a: (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
|
||||||
<MessageLink {...props} citationMap={citationMap} isHuman={isHuman} />
|
<CitationAwareLink
|
||||||
|
{...props}
|
||||||
|
citationMap={citationMap}
|
||||||
|
isHuman={isHuman}
|
||||||
|
isLoadingCitations={isLoadingCitations}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
img: (props: React.ImgHTMLAttributes<HTMLImageElement>) => (
|
img: (props: React.ImgHTMLAttributes<HTMLImageElement>) => (
|
||||||
<MessageImage {...props} threadId={thread_id} maxWidth={isHuman ? "full" : "90%"} />
|
<MessageImage {...props} threadId={thread_id} maxWidth={isHuman ? "full" : "90%"} />
|
||||||
),
|
),
|
||||||
}), [citationMap, thread_id, isHuman]);
|
}), [citationMap, thread_id, isHuman, isLoadingCitations]);
|
||||||
|
|
||||||
// Render message response
|
// Render message response
|
||||||
// Human messages use humanMessagePlugins (no autolink) to prevent URL bleeding into adjacent text
|
// Human messages use humanMessagePlugins (no autolink) to prevent URL bleeding into adjacent text
|
||||||
|
|||||||
60
frontend/src/components/workspace/mode-hover-guide.tsx
Normal file
60
frontend/src/components/workspace/mode-hover-guide.tsx
Normal 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>;
|
||||||
|
}
|
||||||
57
frontend/src/components/workspace/settings/about-content.ts
Normal file
57
frontend/src/components/workspace/settings/about-content.ts
Normal 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.
|
||||||
|
`;
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { Streamdown } from "streamdown";
|
import { Streamdown } from "streamdown";
|
||||||
|
|
||||||
import about from "./about.md";
|
import { aboutMarkdown } from "./about-content";
|
||||||
|
|
||||||
export function AboutSettingsPage() {
|
export function AboutSettingsPage() {
|
||||||
return <Streamdown>{about}</Streamdown>;
|
return <Streamdown>{aboutMarkdown}</Streamdown>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
export {
|
export {
|
||||||
parseCitations,
|
contentWithoutCitationsFromParsed,
|
||||||
buildCitationMap,
|
|
||||||
extractDomainFromUrl,
|
extractDomainFromUrl,
|
||||||
|
getCleanContent,
|
||||||
|
hasCitationsBlock,
|
||||||
isCitationsBlockIncomplete,
|
isCitationsBlockIncomplete,
|
||||||
|
isExternalUrl,
|
||||||
|
parseCitations,
|
||||||
removeAllCitations,
|
removeAllCitations,
|
||||||
|
syntheticCitationFromLink,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
|
||||||
|
export { useParsedCitations } from "./use-parsed-citations";
|
||||||
|
export type { UseParsedCitationsResult } from "./use-parsed-citations";
|
||||||
export type { Citation, ParseCitationsResult } from "./utils";
|
export type { Citation, ParseCitationsResult } from "./utils";
|
||||||
|
|||||||
28
frontend/src/core/citations/use-parsed-citations.ts
Normal file
28
frontend/src/core/citations/use-parsed-citations.ts
Normal 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]);
|
||||||
|
}
|
||||||
@@ -67,14 +67,7 @@ export function parseCitations(content: string): ParseCitationsResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove ALL citations blocks from content (both complete and incomplete)
|
cleanContent = removeCitationsBlocks(content);
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert [cite-N] references to markdown links
|
// Convert [cite-N] references to markdown links
|
||||||
// Example: [cite-1] -> [Title](url)
|
// Example: [cite-1] -> [Title](url)
|
||||||
@@ -102,6 +95,13 @@ export function parseCitations(content: string): ParseCitationsResult {
|
|||||||
return { citations, cleanContent };
|
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
|
* Build a map from URL to Citation for quick lookup
|
||||||
*
|
*
|
||||||
@@ -118,6 +118,25 @@ export function buildCitationMap(
|
|||||||
return map;
|
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
|
* 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)
|
* Check if content is still receiving the citations block (streaming)
|
||||||
* This helps determine if we should wait before parsing
|
* 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
|
* @returns true if citations block appears to be incomplete
|
||||||
*/
|
*/
|
||||||
export function isCitationsBlockIncomplete(content: string): boolean {
|
export function isCitationsBlockIncomplete(content: string): boolean {
|
||||||
if (!content) {
|
return hasCitationsBlock(content) && !content.includes("</citations>");
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove ALL citations from content, including:
|
* Strip citation markdown links from already-cleaned content (from parseCitations).
|
||||||
* - <citations> blocks
|
* Use when you already have ParseCitationsResult to avoid parsing twice.
|
||||||
* - [cite-N] references
|
*/
|
||||||
* - Citation markdown links that were converted from [cite-N]
|
export function contentWithoutCitationsFromParsed(
|
||||||
*
|
parsed: ParseCitationsResult,
|
||||||
* This is used for copy/download operations where we want clean content without any references.
|
): string {
|
||||||
*
|
const citationUrls = new Set(parsed.citations.map((c) => c.url));
|
||||||
* @param content - The raw content that may contain citations
|
const withoutLinks = parsed.cleanContent.replace(
|
||||||
* @returns Content with all citations completely removed
|
/\[([^\]]+)\]\(([^)]+)\)/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 {
|
export function removeAllCitations(content: string): string {
|
||||||
if (!content) {
|
if (!content) return content;
|
||||||
return content;
|
return contentWithoutCitationsFromParsed(parseCitations(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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export const enUS: Translations = {
|
|||||||
"Reasoning, planning and executing, get more accurate results, may take more time",
|
"Reasoning, planning and executing, get more accurate results, may take more time",
|
||||||
ultraMode: "Ultra",
|
ultraMode: "Ultra",
|
||||||
ultraModeDescription:
|
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...",
|
searchModels: "Search models...",
|
||||||
surpriseMe: "Surprise",
|
surpriseMe: "Surprise",
|
||||||
surpriseMePrompt: "Surprise me",
|
surpriseMePrompt: "Surprise me",
|
||||||
|
|||||||
@@ -75,11 +75,11 @@ export const zhCN: Translations = {
|
|||||||
flashModeDescription: "快速且高效的完成任务,但可能不够精准",
|
flashModeDescription: "快速且高效的完成任务,但可能不够精准",
|
||||||
reasoningMode: "思考",
|
reasoningMode: "思考",
|
||||||
reasoningModeDescription: "思考后再行动,在时间与准确性之间取得平衡",
|
reasoningModeDescription: "思考后再行动,在时间与准确性之间取得平衡",
|
||||||
proMode: "专业",
|
proMode: "Pro",
|
||||||
proModeDescription: "思考、计划再执行,获得更精准的结果,可能需要更多时间",
|
proModeDescription: "思考、计划再执行,获得更精准的结果,可能需要更多时间",
|
||||||
ultraMode: "超级",
|
ultraMode: "Ultra",
|
||||||
ultraModeDescription:
|
ultraModeDescription:
|
||||||
"专业模式加子代理,适用于复杂的多步骤任务,功能最强大",
|
"思考、计划并执行,可调用子代理分工协作,适合复杂多步骤任务,能力最强",
|
||||||
searchModels: "搜索模型...",
|
searchModels: "搜索模型...",
|
||||||
surpriseMe: "小惊喜",
|
surpriseMe: "小惊喜",
|
||||||
surpriseMePrompt: "给我一个小惊喜吧",
|
surpriseMePrompt: "给我一个小惊喜吧",
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
import { clsx, type ClassValue } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
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";
|
||||||
|
|||||||
Reference in New Issue
Block a user