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:
ruitanglin
2026-01-29 12:29:13 +08:00
parent 33c47e0c56
commit 4b63e70b7e
10 changed files with 515 additions and 185 deletions

View File

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

View File

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

View File

@@ -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}"})