From ad85b720644dd3e5bddac1abb671fdf1878cbc38 Mon Sep 17 00:00:00 2001 From: LofiSu Date: Wed, 28 Jan 2026 19:15:11 +0800 Subject: [PATCH 1/4] feat: add inline citations and thread management features Citations: - Add citations parsing utilities for extracting source references from AI responses - Render inline citations as hover card badges in message content - Display citation cards with title, URL, and description on hover - Add citation badge rendering in artifact markdown preview - Update prompt to guide AI to output citations in correct format Thread Management: - Add rename functionality for chat threads with dialog UI - Add share functionality to copy thread link to clipboard - Share links use Vercel URL for production accessibility - Add useRenameThread hook for thread title updates i18n: - Add translations for rename, share, cancel, save, and linkCopied Co-authored-by: Cursor --- backend/src/agents/lead_agent/prompt.py | 28 +++ .../artifacts/artifact-file-detail.tsx | 90 ++++++- .../workspace/messages/message-list-item.tsx | 191 ++++++++++++++- .../components/workspace/recent-chat-list.tsx | 228 +++++++++++++----- frontend/src/core/citations/index.ts | 8 + frontend/src/core/citations/utils.ts | 124 ++++++++++ frontend/src/core/i18n/locales/en-US.ts | 5 + frontend/src/core/i18n/locales/types.ts | 5 + frontend/src/core/i18n/locales/zh-CN.ts | 5 + frontend/src/core/threads/hooks.ts | 40 +++ 10 files changed, 658 insertions(+), 66 deletions(-) create mode 100644 frontend/src/core/citations/index.ts create mode 100644 frontend/src/core/citations/utils.ts diff --git a/backend/src/agents/lead_agent/prompt.py b/backend/src/agents/lead_agent/prompt.py index 4248d4c..4a317ea 100644 --- a/backend/src/agents/lead_agent/prompt.py +++ b/backend/src/agents/lead_agent/prompt.py @@ -122,6 +122,34 @@ You have access to skills that provide optimized workflows for specific tasks. E - Action-Oriented: Focus on delivering results, not explaining processes + +**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: + +{{"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"}} + + +Then your content: According to [Source Name](url), the findings show... [Another Source](url2) also reports... + +**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). + +**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... + + - **Clarification First**: ALWAYS clarify unclear/missing/ambiguous requirements BEFORE starting work - never assume or guess - Skill First: Always load the relevant skill before starting **complex** tasks. diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx index c52177f..a4b7b48 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -2,6 +2,7 @@ import { Code2Icon, CopyIcon, DownloadIcon, + ExternalLinkIcon, EyeIcon, SquareArrowOutUpRightIcon, XIcon, @@ -18,6 +19,13 @@ import { ArtifactHeader, ArtifactTitle, } from "@/components/ai-elements/artifact"; +import { + InlineCitationCard, + InlineCitationCardBody, + InlineCitationSource, +} from "@/components/ai-elements/inline-citation"; +import { Badge } from "@/components/ui/badge"; +import { HoverCardTrigger } from "@/components/ui/hover-card"; import { Select, SelectItem } from "@/components/ui/select"; import { SelectContent, @@ -29,6 +37,7 @@ 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"; @@ -216,7 +225,38 @@ export function ArtifactFilePreview({ if (language === "markdown") { return (
- {content ?? ""} + ) => { + if (!href) { + return {children}; + } + + // Check if it's an external link (http/https) + const isExternalLink = + href.startsWith("http://") || href.startsWith("https://"); + + if (isExternalLink) { + return ( + {children} + ); + } + + // Internal/anchor link + return ( + + {children} + + ); + }, + }} + > + {content ?? ""} +
); } @@ -230,3 +270,51 @@ export function ArtifactFilePreview({ } return null; } + +/** + * External link badge component for artifact preview + */ +function ExternalLinkBadge({ + href, + children, +}: { + href: string; + children: React.ReactNode; +}) { + const domain = extractDomainFromUrl(href); + + return ( + + + + + {children ?? domain} + + + + + + + + + ); +} diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index 3caf6bd..099f753 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -1,14 +1,28 @@ import type { Message } from "@langchain/langgraph-sdk"; +import { ExternalLinkIcon, LinkIcon } from "lucide-react"; import { useParams } from "next/navigation"; import { memo, useMemo } from "react"; +import { + InlineCitationCard, + InlineCitationCardBody, + InlineCitationSource, +} from "@/components/ai-elements/inline-citation"; import { Message as AIElementMessage, MessageContent as AIElementMessageContent, MessageResponse as AIElementMessageResponse, MessageToolbar, } from "@/components/ai-elements/message"; +import { Badge } from "@/components/ui/badge"; +import { HoverCardTrigger } from "@/components/ui/hover-card"; import { resolveArtifactURL } from "@/core/artifacts/utils"; +import { + type Citation, + buildCitationMap, + extractDomainFromUrl, + parseCitations, +} from "@/core/citations"; import { extractContentFromMessage, extractReasoningContentFromMessage, @@ -68,20 +82,63 @@ function MessageContent_({ isLoading?: boolean; }) { const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); - const content = useMemo(() => { + + // Extract and parse citations from message content + const { citations, cleanContent } = useMemo(() => { const reasoningContent = extractReasoningContentFromMessage(message); - const content = extractContentFromMessage(message); - if (!isLoading && reasoningContent && !content) { - return reasoningContent; + const rawContent = extractContentFromMessage(message); + if (!isLoading && reasoningContent && !rawContent) { + return { citations: [], cleanContent: reasoningContent }; } - return content; + return parseCitations(rawContent ?? ""); }, [isLoading, message]); + + // Build citation map for quick URL lookup + const citationMap = useMemo( + () => buildCitationMap(citations), + [citations], + ); + const { thread_id } = useParams<{ thread_id: string }>(); + return ( + {/* Citations list at the top */} + {citations.length > 0 && } + ) => { + if (!href) { + return {children}; + } + + // Check if this link matches a citation + const citation = citationMap.get(href); + if (citation) { + return ( + + {children} + + ); + } + + // Regular external link + return ( + + {children} + + ); + }, img: ({ src, alt }: React.ImgHTMLAttributes) => { if (!src) return null; if (typeof src !== "string") { @@ -109,9 +166,131 @@ function MessageContent_({ }, }} > - {content} + {cleanContent} ); } + +/** + * Citations list component that displays all sources at the top + */ +function CitationsList({ citations }: { citations: Citation[] }) { + if (citations.length === 0) return null; + + return ( +
+
+ + Sources ({citations.length}) +
+
+ {citations.map((citation) => ( + + ))} +
+
+ ); +} + +/** + * Single citation badge in the citations list + */ +function CitationBadge({ citation }: { citation: Citation }) { + const domain = extractDomainFromUrl(citation.url); + + return ( + + + + + {domain} + + + + + + + + + ); +} + +/** + * Citation link component that renders as a hover card badge + */ +function CitationLink({ + citation, + href, + children, +}: { + citation: Citation; + href: string; + children: React.ReactNode; +}) { + const domain = extractDomainFromUrl(href); + + return ( + + + e.stopPropagation()} + > + + {children ?? domain} + + + + + + + + + ); +} const MessageContent = memo(MessageContent_); diff --git a/frontend/src/components/workspace/recent-chat-list.tsx b/frontend/src/components/workspace/recent-chat-list.tsx index 10e82c6..220aee2 100644 --- a/frontend/src/components/workspace/recent-chat-list.tsx +++ b/frontend/src/components/workspace/recent-chat-list.tsx @@ -1,16 +1,27 @@ "use client"; -import { MoreHorizontal, Trash2 } from "lucide-react"; +import { MoreHorizontal, Pencil, Share2, Trash2 } from "lucide-react"; import Link from "next/link"; import { useParams, usePathname, useRouter } from "next/navigation"; -import { useCallback } from "react"; +import { useCallback, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; import { SidebarGroup, SidebarGroupContent, @@ -21,7 +32,11 @@ import { SidebarMenuItem, } from "@/components/ui/sidebar"; import { useI18n } from "@/core/i18n/hooks"; -import { useDeleteThread, useThreads } from "@/core/threads/hooks"; +import { + useDeleteThread, + useRenameThread, + useThreads, +} from "@/core/threads/hooks"; import { pathOfThread, titleOfThread } from "@/core/threads/utils"; import { env } from "@/env"; @@ -32,6 +47,13 @@ export function RecentChatList() { const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>(); const { data: threads = [] } = useThreads(); const { mutate: deleteThread } = useDeleteThread(); + const { mutate: renameThread } = useRenameThread(); + + // Rename dialog state + const [renameDialogOpen, setRenameDialogOpen] = useState(false); + const [renameThreadId, setRenameThreadId] = useState(null); + const [renameValue, setRenameValue] = useState(""); + const handleDelete = useCallback( (threadId: string) => { deleteThread({ threadId }); @@ -50,67 +72,155 @@ export function RecentChatList() { }, [deleteThread, router, threadIdFromPath, threads], ); + + const handleRenameClick = useCallback( + (threadId: string, currentTitle: string) => { + setRenameThreadId(threadId); + setRenameValue(currentTitle); + setRenameDialogOpen(true); + }, + [], + ); + + const handleRenameSubmit = useCallback(() => { + if (renameThreadId && renameValue.trim()) { + renameThread({ threadId: renameThreadId, title: renameValue.trim() }); + setRenameDialogOpen(false); + setRenameThreadId(null); + setRenameValue(""); + } + }, [renameThread, renameThreadId, renameValue]); + + const handleShare = useCallback( + async (threadId: string) => { + // Always use Vercel URL for sharing so others can access + const VERCEL_URL = "https://deer-flow-v2.vercel.app"; + const isLocalhost = + window.location.hostname === "localhost" || + window.location.hostname === "127.0.0.1"; + // On localhost: use Vercel URL; On production: use current origin + const baseUrl = isLocalhost ? VERCEL_URL : window.location.origin; + const shareUrl = `${baseUrl}/workspace/chats/${threadId}`; + try { + await navigator.clipboard.writeText(shareUrl); + toast.success(t.clipboard.linkCopied); + } catch { + toast.error(t.clipboard.failedToCopyToClipboard); + } + }, + [t], + ); if (threads.length === 0) { return null; } return ( - - - {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" - ? t.sidebar.recentChats - : t.sidebar.demoChats} - - - -
- {threads.map((thread) => { - const isActive = pathOfThread(thread.thread_id) === pathname; - return ( - - -
- - {titleOfThread(thread)} - - {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && ( - - - + + + {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" + ? t.sidebar.recentChats + : t.sidebar.demoChats} + + + +
+ {threads.map((thread) => { + const isActive = pathOfThread(thread.thread_id) === pathname; + return ( + + +
+ + {titleOfThread(thread)} + + {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && ( + + + + + {t.common.more} + + + - - {t.common.more} - - - - handleDelete(thread.thread_id)} - > - - {t.common.delete} - - - - )} -
-
-
- ); - })} + + handleRenameClick( + thread.thread_id, + titleOfThread(thread), + ) + } + > + + {t.common.rename} + + handleShare(thread.thread_id)} + > + + {t.common.share} + + + handleDelete(thread.thread_id)} + > + + {t.common.delete} + + + + )} +
+ + + ); + })} +
+ + + + + {/* Rename Dialog */} + + + + {t.common.rename} + +
+ setRenameValue(e.target.value)} + placeholder={t.common.rename} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleRenameSubmit(); + } + }} + />
- - - + + + + +
+
+ ); } diff --git a/frontend/src/core/citations/index.ts b/frontend/src/core/citations/index.ts new file mode 100644 index 0000000..bf3a9eb --- /dev/null +++ b/frontend/src/core/citations/index.ts @@ -0,0 +1,8 @@ +export { + parseCitations, + buildCitationMap, + extractDomainFromUrl, + isCitationsBlockIncomplete, +} from "./utils"; + +export type { Citation, ParseCitationsResult } from "./utils"; diff --git a/frontend/src/core/citations/utils.ts b/frontend/src/core/citations/utils.ts new file mode 100644 index 0000000..1669e82 --- /dev/null +++ b/frontend/src/core/citations/utils.ts @@ -0,0 +1,124 @@ +/** + * Citation data structure representing a source reference + */ +export interface Citation { + id: string; + title: string; + url: string; + snippet: string; +} + +/** + * Result of parsing citations from content + */ +export interface ParseCitationsResult { + citations: Citation[]; + cleanContent: string; +} + +/** + * Parse citations block from message content. + * + * The citations block format: + * + * {"id": "cite-1", "title": "Page Title", "url": "https://example.com", "snippet": "Description"} + * {"id": "cite-2", "title": "Another Page", "url": "https://example2.com", "snippet": "Description"} + * + * + * @param content - The raw message content that may contain a citations block + * @returns Object containing parsed citations array and content with citations block removed + */ +export function parseCitations(content: string): ParseCitationsResult { + if (!content) { + 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] ?? ""; + const citations: Citation[] = []; + + // 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 || "", + }); + } + } catch { + // Skip invalid JSON lines - this can happen during streaming + } + } + } + + // Remove the citations block from content + const cleanContent = content.replace(citationsRegex, "").trim(); + + return { citations, cleanContent }; +} + +/** + * Build a map from URL to Citation for quick lookup + * + * @param citations - Array of citations + * @returns Map with URL as key and Citation as value + */ +export function buildCitationMap( + citations: Citation[], +): Map { + const map = new Map(); + for (const citation of citations) { + map.set(citation.url, citation); + } + return map; +} + +/** + * Extract the domain name from a URL for display + * + * @param url - Full URL string + * @returns Domain name or the original URL if parsing fails + */ +export function extractDomainFromUrl(url: string): string { + try { + const urlObj = new URL(url); + // Remove 'www.' prefix if present + return urlObj.hostname.replace(/^www\./, ""); + } catch { + return url; + } +} + +/** + * Check if content is still receiving the citations block (streaming) + * This helps determine if we should wait before parsing + * + * @param content - The current content being streamed + * @returns true if citations block appears to be incomplete + */ +export function isCitationsBlockIncomplete(content: string): boolean { + if (!content) { + return false; + } + + // Check if we have an opening tag but no closing tag + const hasOpenTag = content.includes(""); + const hasCloseTag = content.includes(""); + + return hasOpenTag && !hasCloseTag; +} diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 805170a..b1cfe98 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -11,6 +11,8 @@ export const enUS: Translations = { home: "Home", settings: "Settings", delete: "Delete", + rename: "Rename", + share: "Share", openInNewWindow: "Open in new window", close: "Close", more: "More", @@ -24,6 +26,8 @@ export const enUS: Translations = { loading: "Loading...", code: "Code", preview: "Preview", + cancel: "Cancel", + save: "Save", }, // Welcome @@ -38,6 +42,7 @@ export const enUS: Translations = { copyToClipboard: "Copy to clipboard", copiedToClipboard: "Copied to clipboard", failedToCopyToClipboard: "Failed to copy to clipboard", + linkCopied: "Link copied to clipboard", }, // Input Box diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index bb2fab9..797803e 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -9,6 +9,8 @@ export interface Translations { home: string; settings: string; delete: string; + rename: string; + share: string; openInNewWindow: string; close: string; more: string; @@ -22,6 +24,8 @@ export interface Translations { loading: string; code: string; preview: string; + cancel: string; + save: string; }; // Welcome @@ -35,6 +39,7 @@ export interface Translations { copyToClipboard: string; copiedToClipboard: string; failedToCopyToClipboard: string; + linkCopied: string; }; // Input Box diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 8441a82..db9e1d0 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -11,6 +11,8 @@ export const zhCN: Translations = { home: "首页", settings: "设置", delete: "删除", + rename: "重命名", + share: "分享", openInNewWindow: "在新窗口打开", close: "关闭", more: "更多", @@ -24,6 +26,8 @@ export const zhCN: Translations = { loading: "加载中...", code: "代码", preview: "预览", + cancel: "取消", + save: "保存", }, // Welcome @@ -38,6 +42,7 @@ export const zhCN: Translations = { copyToClipboard: "复制到剪贴板", copiedToClipboard: "已复制到剪贴板", failedToCopyToClipboard: "复制到剪贴板失败", + linkCopied: "链接已复制到剪贴板", }, // Input Box diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index aa98a72..b0dfca9 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -186,3 +186,43 @@ export function useDeleteThread() { }, }); } + +export function useRenameThread() { + const queryClient = useQueryClient(); + const apiClient = getAPIClient(); + return useMutation({ + mutationFn: async ({ + threadId, + title, + }: { + threadId: string; + title: string; + }) => { + await apiClient.threads.update(threadId, { + metadata: { title }, + }); + }, + onSuccess(_, { threadId, title }) { + queryClient.setQueriesData( + { + queryKey: ["threads", "search"], + exact: false, + }, + (oldData: Array) => { + return oldData.map((t) => { + if (t.thread_id === threadId) { + return { + ...t, + metadata: { + ...t.metadata, + title, + }, + }; + } + return t; + }); + }, + ); + }, + }); +} From c14378a312c3b616c3866e34a8aa85ad77ec706a Mon Sep 17 00:00:00 2001 From: LofiSu Date: Thu, 29 Jan 2026 12:29:13 +0800 Subject: [PATCH 2/4] 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"; From e2e0fbf11442b9b5377e455fcddb499de77cce75 Mon Sep 17 00:00:00 2001 From: LofiSu Date: Thu, 29 Jan 2026 12:50:09 +0800 Subject: [PATCH 3/4] fix: hide incomplete citations block during streaming Improve UX by hiding citations block while it's being streamed: - Remove complete citations blocks (existing logic) - Also remove incomplete citations blocks during streaming - Prevents flickering of raw citations XML in the UI Co-authored-by: Cursor --- frontend/src/core/citations/utils.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/src/core/citations/utils.ts b/frontend/src/core/citations/utils.ts index f83feba..aadd0e1 100644 --- a/frontend/src/core/citations/utils.ts +++ b/frontend/src/core/citations/utils.ts @@ -67,8 +67,14 @@ export function parseCitations(content: string): ParseCitationsResult { } } - // Remove ALL citations blocks from content + // Remove ALL citations blocks from content (both complete and incomplete) cleanContent = content.replace(/[\s\S]*?<\/citations>/g, "").trim(); + + // Also remove incomplete citations blocks (during streaming) + // Match without closing tag or followed by anything until end of string + if (cleanContent.includes("")) { + cleanContent = cleanContent.replace(/[\s\S]*$/g, "").trim(); + } return { citations, cleanContent }; } From 849cc4d771e74938c7b8d919d3e193e6c425b087 Mon Sep 17 00:00:00 2001 From: LofiSu Date: Thu, 29 Jan 2026 12:51:21 +0800 Subject: [PATCH 4/4] feat: improve file upload message handling and UI Backend: - Handle both string and list format for message content in uploads middleware - Extract text content from structured message blocks - Add logging for debugging file upload flow Frontend: - Separate file display from message bubble for human messages - Show uploaded files outside the message bubble for cleaner layout - Improve file card border styling with subtle border color - Add debug logging for message submission with files Co-authored-by: Cursor --- .../agents/middlewares/uploads_middleware.py | 15 +++- .../workspace/messages/message-list-item.tsx | 84 ++++++++++++++++++- frontend/src/core/threads/hooks.ts | 6 ++ 3 files changed, 102 insertions(+), 3 deletions(-) diff --git a/backend/src/agents/middlewares/uploads_middleware.py b/backend/src/agents/middlewares/uploads_middleware.py index 152512c..04f7018 100644 --- a/backend/src/agents/middlewares/uploads_middleware.py +++ b/backend/src/agents/middlewares/uploads_middleware.py @@ -189,7 +189,20 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]): # Create files message and prepend to the last human message content files_message = self._create_files_message(files) - original_content = last_message.content if isinstance(last_message.content, str) else "" + + # Extract original content - handle both string and list formats + original_content = "" + if isinstance(last_message.content, str): + original_content = last_message.content + elif isinstance(last_message.content, list): + # Content is a list of content blocks (e.g., [{"type": "text", "text": "..."}]) + text_parts = [] + for block in last_message.content: + if isinstance(block, dict) and block.get("type") == "text": + text_parts.append(block.get("text", "")) + original_content = "\n".join(text_parts) + + logger.info(f"Original message content: {original_content[:100] if original_content else '(empty)'}") # Create new message with combined content updated_message = HumanMessage( diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index 9b6206d..5f41c6e 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -115,6 +115,86 @@ function MessageContent_({ const { thread_id } = useParams<{ thread_id: string }>(); + // For human messages with uploaded files, render files outside the bubble + if (isHuman && uploadedFiles.length > 0) { + return ( +
+ {/* Uploaded files outside the message bubble */} + + + {/* Message content inside the bubble (only if there's text) */} + {cleanContent && ( + + ) => { + if (!href) { + return {children}; + } + + // Check if this link matches a citation + const citation = citationMap.get(href); + if (citation) { + return ( + + {children} + + ); + } + + // Regular external link + return ( + + {children} + + ); + }, + 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} + + ); + }, + }} + > + {cleanContent} + + + )} +
+ ); + } + + // Default rendering for non-human messages or human messages without files return ( {/* Uploaded files for human messages - show first */} @@ -287,7 +367,7 @@ function UploadedFileCard({ href={imageUrl} target="_blank" rel="noopener noreferrer" - className="group relative block overflow-hidden rounded-lg border" + className="group relative block overflow-hidden rounded-lg border border-border/40" > +
{ const text = message.text.trim(); + + console.log('[useSubmitThread] Submitting message:', { + text, + hasFiles: !!message.files?.length, + filesCount: message.files?.length || 0 + }); // Upload files first if any if (message.files && message.files.length > 0) {