From 4b63e70b7ee7bd292a597585a4aaa819259bcb92 Mon Sep 17 00:00:00 2001 From: ruitanglin Date: Thu, 29 Jan 2026 12:29:13 +0800 Subject: [PATCH] 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 block in both chat messages and markdown preview - Remove top-level Citations/Sources list for cleaner UI - Auto-remove 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 --- backend/src/agents/lead_agent/prompt.py | 30 +- .../agents/middlewares/uploads_middleware.py | 91 +++++- backend/src/gateway/routers/artifacts.py | 8 +- frontend/package.json | 3 + frontend/pnpm-lock.yaml | 76 +---- .../artifacts/artifact-file-detail.tsx | 100 ++++++- .../workspace/messages/message-list-item.tsx | 279 ++++++++++++++---- frontend/src/core/citations/utils.ts | 57 ++-- frontend/src/core/messages/utils.ts | 55 ++++ frontend/src/styles/globals.css | 1 + 10 files changed, 515 insertions(+), 185 deletions(-) diff --git a/backend/src/agents/lead_agent/prompt.py b/backend/src/agents/lead_agent/prompt.py index 4a317ea..653e61a 100644 --- a/backend/src/agents/lead_agent/prompt.py +++ b/backend/src/agents/lead_agent/prompt.py @@ -123,31 +123,33 @@ You have access to skills that provide optimized workflows for specific tasks. E -**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: {{"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"}} +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: + +{{"id": "cite-1", "title": "Page Title", "url": "https://example.com/page", "snippet": "Brief description"}} + +# 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 `` 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 block needed in files). +- `` 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:** {{"id": "cite-1", "title": "AI Trends 2026", "url": "https://techcrunch.com/ai-trends", "snippet": "Tech industry predictions"}} -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). diff --git a/backend/src/agents/middlewares/uploads_middleware.py b/backend/src/agents/middlewares/uploads_middleware.py index 823ea92..152512c 100644 --- a/backend/src/agents/middlewares/uploads_middleware.py +++ b/backend/src/agents/middlewares/uploads_middleware.py @@ -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 tag. + + Returns: + Set of filenames mentioned in the tag. + """ + # Match ... tag + match = re.search(r"([\s\S]*?)", 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, diff --git a/backend/src/gateway/routers/artifacts.py b/backend/src/gateway/routers/artifacts.py index bd2c44e..3661b15 100644 --- a/backend/src/gateway/routers/artifacts.py +++ b/backend/src/gateway/routers/artifacts.py @@ -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}"}) diff --git a/frontend/package.json b/frontend/package.json index 05a2a26..1462e6e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -61,6 +61,7 @@ "embla-carousel-react": "^8.6.0", "gsap": "^3.13.0", "hast": "^1.0.0", + "katex": "^0.16.28", "lucide-react": "^0.562.0", "motion": "^12.26.2", "nanoid": "^5.1.6", @@ -71,6 +72,8 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-resizable-panels": "^4.4.1", + "rehype-katex": "^7.0.1", + "remark-math": "^6.0.0", "shiki": "3.15.0", "sonner": "^2.0.7", "streamdown": "1.4.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 7eff819..6836964 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -140,6 +140,9 @@ importers: hast: specifier: ^1.0.0 version: 1.0.0 + katex: + specifier: ^0.16.28 + version: 0.16.28 lucide-react: specifier: ^0.562.0 version: 0.562.0(react@19.2.3) @@ -170,6 +173,12 @@ importers: react-resizable-panels: specifier: ^4.4.1 version: 4.4.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + rehype-katex: + specifier: ^7.0.1 + version: 7.0.1 + remark-math: + specifier: ^6.0.0 + version: 6.0.0 shiki: specifier: 3.15.0 version: 3.15.0 @@ -695,105 +704,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -935,28 +928,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@16.1.4': resolution: {integrity: sha512-3Wm0zGYVCs6qDFAiSSDL+Z+r46EdtCv/2l+UlIdMbAq9hPJBvGu/rZOeuvCaIUjbArkmXac8HnTyQPJFzFWA0Q==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@16.1.4': resolution: {integrity: sha512-lWAYAezFinaJiD5Gv8HDidtsZdT3CDaCeqoPoJjeB57OqzvMajpIhlZFce5sCAH6VuX4mdkxCRqecCJFwfm2nQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@16.1.4': resolution: {integrity: sha512-fHaIpT7x4gA6VQbdEpYUXRGyge/YbRrkG6DXM60XiBqDM2g2NcrsQaIuj375egnGFkJow4RHacgBOEsHfGbiUw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@16.1.4': resolution: {integrity: sha512-MCrXxrTSE7jPN1NyXJr39E+aNFBrQZtO154LoCz7n99FuKqJDekgxipoodLNWdQP7/DZ5tKMc/efybx1l159hw==} @@ -1537,28 +1526,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@resvg/resvg-js-linux-arm64-musl@2.6.2': resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@resvg/resvg-js-linux-x64-gnu@2.6.2': resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@resvg/resvg-js-linux-x64-musl@2.6.2': resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@resvg/resvg-js-win32-arm64-msvc@2.6.2': resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==} @@ -1620,79 +1605,66 @@ packages: resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.55.1': resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.55.1': resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.55.1': resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.55.1': resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.55.1': resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.55.1': resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.55.1': resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.55.1': resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.55.1': resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.55.1': resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.55.1': resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.55.1': resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.55.1': resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==} @@ -1835,28 +1807,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -2219,49 +2187,41 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -3643,8 +3603,8 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} - katex@0.16.27: - resolution: {integrity: sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw==} + katex@0.16.28: + resolution: {integrity: sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg==} hasBin: true keyv@4.5.4: @@ -3740,28 +3700,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -8817,7 +8773,7 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 - katex@0.16.27: + katex@0.16.28: dependencies: commander: 8.3.0 @@ -9149,7 +9105,7 @@ snapshots: dagre-d3-es: 7.0.13 dayjs: 1.11.19 dompurify: 3.3.1 - katex: 0.16.27 + katex: 0.16.28 khroma: 2.1.0 lodash-es: 4.17.22 marked: 16.4.2 @@ -9239,7 +9195,7 @@ snapshots: dependencies: '@types/katex': 0.16.8 devlop: 1.1.0 - katex: 0.16.27 + katex: 0.16.28 micromark-factory-space: 2.0.1 micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 @@ -9860,7 +9816,7 @@ snapshots: '@types/katex': 0.16.8 hast-util-from-html-isomorphic: 2.0.0 hast-util-to-text: 4.0.2 - katex: 0.16.27 + katex: 0.16.28 unist-util-visit-parents: 6.0.2 vfile: 6.0.3 @@ -10163,7 +10119,7 @@ snapshots: streamdown@1.4.0(@types/react@19.2.8)(react@19.2.3): dependencies: clsx: 2.1.1 - katex: 0.16.27 + katex: 0.16.28 lucide-react: 0.542.0(react@19.2.3) marked: 16.4.2 mermaid: 11.12.2 diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx index a4b7b48..777cdff 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -7,7 +7,10 @@ import { SquareArrowOutUpRightIcon, XIcon, } from "lucide-react"; +import * as React from "react"; import { useEffect, useMemo, useState } from "react"; +import rehypeKatex from "rehype-katex"; +import remarkMath from "remark-math"; import { toast } from "sonner"; import { Streamdown } from "streamdown"; @@ -26,6 +29,12 @@ import { } from "@/components/ai-elements/inline-citation"; import { Badge } from "@/components/ui/badge"; import { HoverCardTrigger } from "@/components/ui/hover-card"; +import { + buildCitationMap, + extractDomainFromUrl, + parseCitations, + type Citation, +} from "@/core/citations"; import { Select, SelectItem } from "@/components/ui/select"; import { SelectContent, @@ -37,7 +46,6 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { CodeEditor } from "@/components/workspace/code-editor"; import { useArtifactContent } from "@/core/artifacts/hooks"; import { urlOfArtifact } from "@/core/artifacts/utils"; -import { extractDomainFromUrl } from "@/core/citations"; import { useI18n } from "@/core/i18n/hooks"; import { checkCodeFile, getFileName } from "@/core/utils/files"; import { cn } from "@/lib/utils"; @@ -81,6 +89,15 @@ export function ArtifactFileDetail({ filepath: filepathFromProps, enabled: isCodeFile && !isWriteFile, }); + + // Parse citations and get clean content for code editor + const cleanContent = useMemo(() => { + if (language === "markdown" && content) { + return parseCitations(content).cleanContent; + } + return content; + }, [content, language]); + const [viewMode, setViewMode] = useState<"code" | "preview">("code"); useEffect(() => { if (previewable) { @@ -196,7 +213,7 @@ export function ArtifactFileDetail({ {isCodeFile && viewMode === "code" && ( )} @@ -222,11 +239,23 @@ export function ArtifactFilePreview({ content: string; language: string; }) { + const { citations, cleanContent, citationMap } = React.useMemo(() => { + const parsed = parseCitations(content ?? ""); + const map = buildCitationMap(parsed.citations); + return { + citations: parsed.citations, + cleanContent: parsed.cleanContent, + citationMap: map, + }; + }, [content]); + if (language === "markdown") { return (
{children}; } + // Check if it's a citation link + const citation = citationMap.get(href); + if (citation) { + return ( + + {children} + + ); + } + // Check if it's an external link (http/https) const isExternalLink = href.startsWith("http://") || href.startsWith("https://"); @@ -255,7 +294,7 @@ export function ArtifactFilePreview({ }, }} > - {content ?? ""} + {cleanContent ?? ""}
); @@ -271,6 +310,61 @@ export function ArtifactFilePreview({ return null; } +/** + * Citation link component for artifact preview (with full citation data) + */ +function ArtifactCitationLink({ + citation, + href, + children, +}: { + citation: Citation; + href: string; + children: React.ReactNode; +}) { + const domain = extractDomainFromUrl(href); + + return ( + + + e.stopPropagation()} + > + + {children ?? domain} + + + + + + + + + ); +} + /** * External link badge component for artifact preview */ diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index 099f753..9b6206d 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -1,7 +1,9 @@ import type { Message } from "@langchain/langgraph-sdk"; -import { ExternalLinkIcon, LinkIcon } from "lucide-react"; +import { ExternalLinkIcon, FileIcon, LinkIcon } from "lucide-react"; import { useParams } from "next/navigation"; import { memo, useMemo } from "react"; +import rehypeKatex from "rehype-katex"; +import remarkMath from "remark-math"; import { InlineCitationCard, @@ -26,6 +28,8 @@ import { import { extractContentFromMessage, extractReasoningContentFromMessage, + parseUploadedFiles, + type UploadedFile, } from "@/core/messages/utils"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { cn } from "@/lib/utils"; @@ -82,16 +86,26 @@ function MessageContent_({ isLoading?: boolean; }) { const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); + const isHuman = message.type === "human"; - // Extract and parse citations from message content - const { citations, cleanContent } = useMemo(() => { + // Extract and parse citations and uploaded files from message content + const { citations, cleanContent, uploadedFiles } = useMemo(() => { const reasoningContent = extractReasoningContentFromMessage(message); const rawContent = extractContentFromMessage(message); if (!isLoading && reasoningContent && !rawContent) { - return { citations: [], cleanContent: reasoningContent }; + return { citations: [], cleanContent: reasoningContent, uploadedFiles: [] }; } - return parseCitations(rawContent ?? ""); - }, [isLoading, message]); + + // For human messages, first parse uploaded files + if (isHuman && rawContent) { + const { files, cleanContent: contentWithoutFiles } = parseUploadedFiles(rawContent); + const { citations, cleanContent: finalContent } = parseCitations(contentWithoutFiles); + return { citations, cleanContent: finalContent, uploadedFiles: files }; + } + + const { citations, cleanContent } = parseCitations(rawContent ?? ""); + return { citations, cleanContent, uploadedFiles: [] }; + }, [isLoading, message, isHuman]); // Build citation map for quick URL lookup const citationMap = useMemo( @@ -103,75 +117,212 @@ function MessageContent_({ return ( - {/* Citations list at the top */} - {citations.length > 0 && } + {/* Uploaded files for human messages - show first */} + {uploadedFiles.length > 0 && thread_id && ( + + )} - ) => { - if (!href) { - return {children}; - } + {/* Message content - always show if present */} + {cleanContent && ( + ) => { + if (!href) { + return {children}; + } - // Check if this link matches a citation - const citation = citationMap.get(href); - if (citation) { + // Check if this link matches a citation + const citation = citationMap.get(href); + if (citation) { + return ( + + {children} + + ); + } + + // Regular external link return ( - + {children} - + ); - } - - // Regular external link - return ( - - {children} - - ); - }, - img: ({ src, alt }: React.ImgHTMLAttributes) => { - if (!src) return null; - if (typeof src !== "string") { + }, + img: ({ src, alt }: React.ImgHTMLAttributes) => { + if (!src) return null; + if (typeof src !== "string") { + return ( + {alt} + ); + } + let url = src; + if (src.startsWith("/mnt/")) { + url = resolveArtifactURL(src, thread_id); + } return ( - {alt} + + {alt} + ); - } - let url = src; - if (src.startsWith("/mnt/")) { - url = resolveArtifactURL(src, thread_id); - } - return ( - - {alt} - - ); - }, - }} - > - {cleanContent} - + }, + }} + > + {cleanContent} + + )} ); } +/** + * Get file type label from filename extension + */ +function getFileTypeLabel(filename: string): string { + const ext = filename.split(".").pop()?.toLowerCase() ?? ""; + const typeMap: Record = { + json: "JSON", + csv: "CSV", + txt: "TXT", + md: "Markdown", + py: "Python", + js: "JavaScript", + ts: "TypeScript", + tsx: "TSX", + jsx: "JSX", + html: "HTML", + css: "CSS", + xml: "XML", + yaml: "YAML", + yml: "YAML", + pdf: "PDF", + png: "PNG", + jpg: "JPG", + jpeg: "JPEG", + gif: "GIF", + svg: "SVG", + zip: "ZIP", + tar: "TAR", + gz: "GZ", + }; + return typeMap[ext] || ext.toUpperCase() || "FILE"; +} + +/** + * Check if a file is an image based on extension + */ +function isImageFile(filename: string): boolean { + const ext = filename.split(".").pop()?.toLowerCase() ?? ""; + return ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"].includes(ext); +} + +/** + * Uploaded files list component that displays files as cards or image thumbnails (Claude-style) + */ +function UploadedFilesList({ + files, + threadId, +}: { + files: UploadedFile[]; + threadId: string; +}) { + if (files.length === 0) return null; + + return ( +
+ {files.map((file, index) => ( + + ))} +
+ ); +} + +/** + * Single uploaded file card component (Claude-style) + * Shows image thumbnail for image files, file card for others + */ +function UploadedFileCard({ + file, + threadId, +}: { + file: UploadedFile; + threadId: string; +}) { + const typeLabel = getFileTypeLabel(file.filename); + const isImage = isImageFile(file.filename); + + // Don't render if threadId is invalid + if (!threadId) { + return null; + } + + // Build URL - browser will handle encoding automatically + const imageUrl = resolveArtifactURL(file.path, threadId); + + // For image files, show thumbnail + if (isImage) { + return ( + + {file.filename} + + ); + } + + // For non-image files, show file card + return ( +
+
+ + + {file.filename} + +
+
+ + {typeLabel} + + {file.size} +
+
+ ); +} + /** * Citations list component that displays all sources at the top */ diff --git a/frontend/src/core/citations/utils.ts b/frontend/src/core/citations/utils.ts index 1669e82..f83feba 100644 --- a/frontend/src/core/citations/utils.ts +++ b/frontend/src/core/citations/utils.ts @@ -33,41 +33,42 @@ export function parseCitations(content: string): ParseCitationsResult { return { citations: [], cleanContent: content }; } - // Match the citations block at the start of content (with possible leading whitespace) - const citationsRegex = /^\s*([\s\S]*?)<\/citations>/; - const match = citationsRegex.exec(content); - - if (!match) { - return { citations: [], cleanContent: content }; - } - - const citationsBlock = match[1] ?? ""; + // Match ALL citations blocks anywhere in content (not just at the start) + const citationsRegex = /([\s\S]*?)<\/citations>/g; const citations: Citation[] = []; + const seenUrls = new Set(); // Deduplicate by URL + let cleanContent = content; - // Parse each line as JSON - const lines = citationsBlock.split("\n"); - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed?.startsWith("{")) { - try { - const citation = JSON.parse(trimmed) as Citation; - // Validate required fields - if (citation.id && citation.url) { - citations.push({ - id: citation.id, - title: citation.title || "", - url: citation.url, - snippet: citation.snippet || "", - }); + let match; + while ((match = citationsRegex.exec(content)) !== null) { + const citationsBlock = match[1] ?? ""; + + // Parse each line as JSON + const lines = citationsBlock.split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed?.startsWith("{")) { + try { + const citation = JSON.parse(trimmed) as Citation; + // Validate required fields and deduplicate + if (citation.id && citation.url && !seenUrls.has(citation.url)) { + seenUrls.add(citation.url); + citations.push({ + id: citation.id, + title: citation.title || "", + url: citation.url, + snippet: citation.snippet || "", + }); + } + } catch { + // Skip invalid JSON lines - this can happen during streaming } - } catch { - // Skip invalid JSON lines - this can happen during streaming } } } - // Remove the citations block from content - const cleanContent = content.replace(citationsRegex, "").trim(); + // Remove ALL citations blocks from content + cleanContent = content.replace(/[\s\S]*?<\/citations>/g, "").trim(); return { citations, cleanContent }; } diff --git a/frontend/src/core/messages/utils.ts b/frontend/src/core/messages/utils.ts index 75ecbff..b835e94 100644 --- a/frontend/src/core/messages/utils.ts +++ b/frontend/src/core/messages/utils.ts @@ -217,3 +217,58 @@ export function findToolCallResult(toolCallId: string, messages: Message[]) { } return undefined; } + +/** + * Represents an uploaded file parsed from the tag + */ +export interface UploadedFile { + filename: string; + size: string; + path: string; +} + +/** + * Result of parsing uploaded files from message content + */ +export interface ParsedUploadedFiles { + files: UploadedFile[]; + cleanContent: string; +} + +/** + * Parse tag from message content and extract file information. + * Returns the list of uploaded files and the content with the tag removed. + */ +export function parseUploadedFiles(content: string): ParsedUploadedFiles { + // Match ... tag + const uploadedFilesRegex = /([\s\S]*?)<\/uploaded_files>/; + const match = content.match(uploadedFilesRegex); + + if (!match) { + return { files: [], cleanContent: content }; + } + + const uploadedFilesContent = match[1]; + const cleanContent = content.replace(uploadedFilesRegex, "").trim(); + + // Check if it's "No files have been uploaded yet." + if (uploadedFilesContent.includes("No files have been uploaded yet.")) { + return { files: [], cleanContent }; + } + + // Parse file list + // Format: - filename (size)\n Path: /path/to/file + const fileRegex = /- ([^\n(]+)\s*\(([^)]+)\)\s*\n\s*Path:\s*([^\n]+)/g; + const files: UploadedFile[] = []; + let fileMatch; + + while ((fileMatch = fileRegex.exec(uploadedFilesContent)) !== null) { + files.push({ + filename: fileMatch[1].trim(), + size: fileMatch[2].trim(), + path: fileMatch[3].trim(), + }); + } + + return { files, cleanContent }; +} diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 1fb06f3..d4c5c20 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -1,5 +1,6 @@ @import "tailwindcss"; @import "tw-animate-css"; +@import "katex/dist/katex.min.css"; @source "../node_modules/streamdown/dist/index.js";