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} + + + + + +
+ + + Visit source + + +
+
+
+ ); +} 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} + + + + + +
+ + + Visit source + + +
+
+
+ ); +} + +/** + * 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} + + + + + +
+ + + Visit source + + +
+
+
+ ); +} 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; + }); + }, + ); + }, + }); +}