mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-16 03:14:45 +08:00
feat: refine citations format and improve content presentation
Backend: - Simplify citations prompt format and rules - Add clear distinction between chat responses and file content - Enforce full URL usage in markdown links, prohibit [cite-1] format - Require content-first approach: write full content, then add citations at end Frontend: - Hide <citations> block in both chat messages and markdown preview - Remove top-level Citations/Sources list for cleaner UI - Auto-remove <citations> block in code editor view for markdown files - Keep inline citation hover cards for reference details This ensures citations are presented like Claude: clean content with inline reference badges. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -123,31 +123,33 @@ You have access to skills that provide optimized workflows for specific tasks. E
|
||||
</response_style>
|
||||
|
||||
<citations_format>
|
||||
**AUTOMATIC CITATION REQUIREMENT**: After using web_search tool, you MUST include citations in your response.
|
||||
|
||||
**FORMAT** - Your response MUST start with a citations block, then content with inline links:
|
||||
**FORMAT** - After web_search, ALWAYS include citations in your output:
|
||||
**For chat responses:**
|
||||
Your visible response MUST start with citations block, then content with inline links:
|
||||
<citations>
|
||||
{{"id": "cite-1", "title": "Page Title", "url": "https://example.com/page", "snippet": "Brief description"}}
|
||||
{{"id": "cite-2", "title": "Another Source", "url": "https://another.com/article", "snippet": "What this covers"}}
|
||||
</citations>
|
||||
Content with inline links...
|
||||
|
||||
Then your content: According to [Source Name](url), the findings show... [Another Source](url2) also reports...
|
||||
**For files (write_file):**
|
||||
File content MUST start with citations block, then content with inline links:
|
||||
<citations>
|
||||
{{"id": "cite-1", "title": "Page Title", "url": "https://example.com/page", "snippet": "Brief description"}}
|
||||
</citations>
|
||||
# Document Title
|
||||
Content with inline [Source Name](full_url) links...
|
||||
|
||||
**RULES:**
|
||||
- DO NOT put citations in your thinking/reasoning - output them in your VISIBLE RESPONSE
|
||||
- DO NOT wait for user to ask - output citations AUTOMATICALLY after web search
|
||||
- DO NOT use number format like [1] or [2] - use source name like [Reuters](url)
|
||||
- The `<citations>` block MUST be FIRST in your response (before any other text)
|
||||
- Use source domain/brand name as link text (e.g., "Reuters", "TechCrunch", "智源研究院")
|
||||
- The URL in markdown link must match a URL in your citations block
|
||||
|
||||
**IF writing markdown files**: When user asks you to create a report/document and you use write_file, use `[Source Name](url)` links in the file content (no <citations> block needed in files).
|
||||
- `<citations>` block MUST be FIRST (in both chat response AND file content)
|
||||
- Write full content naturally, add [Source Name](full_url) at end of sentence/paragraph
|
||||
- NEVER use "According to [Source]" format - write content first, then add citation link at end
|
||||
- Example: "AI agents will transform digital work ([Microsoft](url))" NOT "According to [Microsoft](url), AI agents will..."
|
||||
|
||||
**Example:**
|
||||
<citations>
|
||||
{{"id": "cite-1", "title": "AI Trends 2026", "url": "https://techcrunch.com/ai-trends", "snippet": "Tech industry predictions"}}
|
||||
</citations>
|
||||
Based on [TechCrunch](https://techcrunch.com/ai-trends), the key AI trends for 2026 include...
|
||||
The key AI trends for 2026 include enhanced reasoning capabilities, multimodal integration, and improved efficiency [TechCrunch](https://techcrunch.com/ai-trends).
|
||||
</citations_format>
|
||||
|
||||
<critical_reminders>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Middleware to inject uploaded files information into agent context."""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import NotRequired, override
|
||||
|
||||
@@ -47,14 +48,15 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
|
||||
"""
|
||||
return Path(self._base_dir) / THREAD_DATA_BASE_DIR / thread_id / "user-data" / "uploads"
|
||||
|
||||
def _list_uploaded_files(self, thread_id: str) -> list[dict]:
|
||||
"""List all files in the uploads directory.
|
||||
def _list_newly_uploaded_files(self, thread_id: str, last_message_files: set[str]) -> list[dict]:
|
||||
"""List only newly uploaded files that weren't in the last message.
|
||||
|
||||
Args:
|
||||
thread_id: The thread ID.
|
||||
last_message_files: Set of filenames that were already shown in previous messages.
|
||||
|
||||
Returns:
|
||||
List of file information dictionaries.
|
||||
List of new file information dictionaries.
|
||||
"""
|
||||
uploads_dir = self._get_uploads_dir(thread_id)
|
||||
|
||||
@@ -63,7 +65,7 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
|
||||
|
||||
files = []
|
||||
for file_path in sorted(uploads_dir.iterdir()):
|
||||
if file_path.is_file():
|
||||
if file_path.is_file() and file_path.name not in last_message_files:
|
||||
stat = file_path.stat()
|
||||
files.append(
|
||||
{
|
||||
@@ -106,10 +108,41 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _extract_files_from_message(self, content: str) -> set[str]:
|
||||
"""Extract filenames from uploaded_files tag in message content.
|
||||
|
||||
Args:
|
||||
content: Message content that may contain <uploaded_files> tag.
|
||||
|
||||
Returns:
|
||||
Set of filenames mentioned in the tag.
|
||||
"""
|
||||
# Match <uploaded_files>...</uploaded_files> tag
|
||||
match = re.search(r"<uploaded_files>([\s\S]*?)</uploaded_files>", content)
|
||||
if not match:
|
||||
return set()
|
||||
|
||||
files_content = match.group(1)
|
||||
|
||||
# Extract filenames from lines like "- filename.ext (size)"
|
||||
# Need to capture everything before the opening parenthesis, including spaces
|
||||
filenames = set()
|
||||
for line in files_content.split("\n"):
|
||||
# Match pattern: - filename with spaces.ext (size)
|
||||
# Changed from [^\s(]+ to [^(]+ to allow spaces in filename
|
||||
file_match = re.match(r"^-\s+(.+?)\s*\(", line.strip())
|
||||
if file_match:
|
||||
filenames.add(file_match.group(1).strip())
|
||||
|
||||
return filenames
|
||||
|
||||
@override
|
||||
def before_agent(self, state: UploadsMiddlewareState, runtime: Runtime) -> dict | None:
|
||||
"""Inject uploaded files information before agent execution.
|
||||
|
||||
Only injects files that weren't already shown in previous messages.
|
||||
Prepends file info to the last human message content.
|
||||
|
||||
Args:
|
||||
state: Current agent state.
|
||||
runtime: Runtime context containing thread_id.
|
||||
@@ -117,26 +150,56 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
|
||||
Returns:
|
||||
State updates including uploaded files list.
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
thread_id = runtime.context.get("thread_id")
|
||||
if thread_id is None:
|
||||
return None
|
||||
|
||||
# List uploaded files
|
||||
files = self._list_uploaded_files(thread_id)
|
||||
messages = list(state.get("messages", []))
|
||||
if not messages:
|
||||
return None
|
||||
|
||||
# Track all filenames that have been shown in previous messages (EXCEPT the last one)
|
||||
shown_files: set[str] = set()
|
||||
for msg in messages[:-1]: # Scan all messages except the last one
|
||||
if isinstance(msg, HumanMessage):
|
||||
content = msg.content if isinstance(msg.content, str) else ""
|
||||
extracted = self._extract_files_from_message(content)
|
||||
shown_files.update(extracted)
|
||||
if extracted:
|
||||
logger.info(f"Found previously shown files: {extracted}")
|
||||
|
||||
logger.info(f"Total shown files from history: {shown_files}")
|
||||
|
||||
# List only newly uploaded files
|
||||
files = self._list_newly_uploaded_files(thread_id, shown_files)
|
||||
logger.info(f"Newly uploaded files to inject: {[f['filename'] for f in files]}")
|
||||
|
||||
if not files:
|
||||
return None
|
||||
|
||||
# Create system message with file list
|
||||
# Find the last human message and prepend file info to it
|
||||
last_message_index = len(messages) - 1
|
||||
last_message = messages[last_message_index]
|
||||
|
||||
if not isinstance(last_message, HumanMessage):
|
||||
return None
|
||||
|
||||
# Create files message and prepend to the last human message content
|
||||
files_message = self._create_files_message(files)
|
||||
files_human_message = HumanMessage(content=files_message)
|
||||
original_content = last_message.content if isinstance(last_message.content, str) else ""
|
||||
|
||||
# Create new message with combined content
|
||||
updated_message = HumanMessage(
|
||||
content=f"{files_message}\n\n{original_content}",
|
||||
id=last_message.id,
|
||||
additional_kwargs=last_message.additional_kwargs,
|
||||
)
|
||||
|
||||
# Inject the message into the message history
|
||||
# This will be added before user messages
|
||||
messages = list(state.get("messages", []))
|
||||
|
||||
insert_index = 0
|
||||
messages.insert(insert_index, files_human_message)
|
||||
# Replace the last message
|
||||
messages[last_message_index] = updated_message
|
||||
|
||||
return {
|
||||
"uploaded_files": files,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import mimetypes
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse
|
||||
@@ -104,9 +105,12 @@ async def get_artifact(thread_id: str, path: str, request: Request) -> FileRespo
|
||||
|
||||
mime_type, _ = mimetypes.guess_type(actual_path)
|
||||
|
||||
# Encode filename for Content-Disposition header (RFC 5987)
|
||||
encoded_filename = quote(actual_path.name)
|
||||
|
||||
# if `download` query parameter is true, return the file as a download
|
||||
if request.query_params.get("download"):
|
||||
return FileResponse(path=actual_path, filename=actual_path.name, media_type=mime_type, headers={"Content-Disposition": f'attachment; filename="{actual_path.name}"'})
|
||||
return FileResponse(path=actual_path, filename=actual_path.name, media_type=mime_type, headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"})
|
||||
|
||||
if mime_type and mime_type == "text/html":
|
||||
return HTMLResponse(content=actual_path.read_text())
|
||||
@@ -117,4 +121,4 @@ async def get_artifact(thread_id: str, path: str, request: Request) -> FileRespo
|
||||
if is_text_file_by_content(actual_path):
|
||||
return PlainTextResponse(content=actual_path.read_text(), media_type=mime_type)
|
||||
|
||||
return Response(content=actual_path.read_bytes(), media_type=mime_type, headers={"Content-Disposition": f'inline; filename="{actual_path.name}"'})
|
||||
return Response(content=actual_path.read_bytes(), media_type=mime_type, headers={"Content-Disposition": f"inline; filename*=UTF-8''{encoded_filename}"})
|
||||
|
||||
Reference in New Issue
Block a user