From 842c4ecac0806359cba55fda80f22f926d905782 Mon Sep 17 00:00:00 2001 From: LofiSu Date: Mon, 9 Feb 2026 03:23:02 +0800 Subject: [PATCH 1/9] fix(frontend): Turbopack about page + remove hover on web search/citations - About: use aboutMarkdown from about-content.ts instead of raw-loader for about.md (fixes Turbopack 'Cannot find module raw-loader') - Web search: remove Tooltip from web_search and web_fetch result links - Citations: remove HoverCard from CitationLink so no hover popup on badges Co-authored-by: Cursor --- .../ai-elements/inline-citation.tsx | 52 ++++++------------ .../workspace/messages/message-group.tsx | 20 +++---- .../workspace/settings/about-content.ts | 54 +++++++++++++++++++ .../settings/about-settings-page.tsx | 4 +- 4 files changed, 79 insertions(+), 51 deletions(-) create mode 100644 frontend/src/components/workspace/settings/about-content.ts diff --git a/frontend/src/components/ai-elements/inline-citation.tsx b/frontend/src/components/ai-elements/inline-citation.tsx index dde6e31..2c9fda2 100644 --- a/frontend/src/components/ai-elements/inline-citation.tsx +++ b/frontend/src/components/ai-elements/inline-citation.tsx @@ -320,43 +320,21 @@ export const CitationLink = ({ const displayText = citation?.title || (!isGenericText && childrenText) || domain; return ( - - - e.stopPropagation()} - > - - {displayText} - - - - - -
- - - Visit source - - -
-
-
+ e.stopPropagation()} + > + + {displayText} + + + ); }; diff --git a/frontend/src/components/workspace/messages/message-group.tsx b/frontend/src/components/workspace/messages/message-group.tsx index a0782bb..1dffa33 100644 --- a/frontend/src/components/workspace/messages/message-group.tsx +++ b/frontend/src/components/workspace/messages/message-group.tsx @@ -232,13 +232,11 @@ function ToolCall({ {Array.isArray(result) && ( {result.map((item) => ( - - - - {item.title} - - - + + + {item.title} + + ))} )} @@ -309,11 +307,9 @@ function ToolCall({ > {url && ( - {result as string}}> - - {title} - - + + {title} + )} diff --git a/frontend/src/components/workspace/settings/about-content.ts b/frontend/src/components/workspace/settings/about-content.ts new file mode 100644 index 0000000..9866434 --- /dev/null +++ b/frontend/src/components/workspace/settings/about-content.ts @@ -0,0 +1,54 @@ +/** About page markdown content. Exported as string to avoid raw-loader with Turbopack. */ +export const aboutMarkdown = `# 🦌 [About DeerFlow 2.0](https://github.com/bytedance/deer-flow) + +> **From Open Source, Back to Open Source** + +**DeerFlow** (**D**eep **E**xploration and **E**fficient **R**esearch **Flow**) is a community-driven SuperAgent harness that researches, codes, and creates. +With the help of sandboxes, memories, tools and skills, it handles +different levels of tasks that could take minutes to hours. + +--- + +## 🌟 GitHub Repository + +Explore DeerFlow on GitHub: [github.com/bytedance/deer-flow](https://github.com/bytedance/deer-flow) + +## 🌐 Official Website + +Visit the official website of DeerFlow: [deerflow.tech](https://deerflow.tech/) + +## 📧 Support + +If you have any questions or need help, please contact us at [support@deerflow.tech](mailto:support@deerflow.tech). + +--- + +## 📜 License + +DeerFlow is proudly open source and distributed under the **MIT License**. + +--- + +## 🙌 Acknowledgments + +We extend our heartfelt gratitude to the open source projects and contributors who have made DeerFlow a reality. We truly stand on the shoulders of giants. + +### Core Frameworks +- **[LangChain](https://github.com/langchain-ai/langchain)**: A phenomenal framework that powers our LLM interactions and chains. +- **[LangGraph](https://github.com/langchain-ai/langgraph)**: Enabling sophisticated multi-agent orchestration. +- **[Next.js](https://nextjs.org/)**: A cutting-edge framework for building web applications. + +### UI Libraries +- **[Shadcn](https://ui.shadcn.com/)**: Minimalistic components that power our UI. +- **[SToneX](https://github.com/stonexer)**: For his invaluable contribution to token-by-token visual effects. + +These outstanding projects form the backbone of DeerFlow and exemplify the transformative power of open source collaboration. + +### Special Thanks +Finally, we want to express our heartfelt gratitude to the core authors of DeerFlow 1.0 and 2.0: + +- **[Daniel Walnut](https://github.com/hetaoBackend/)** +- **[Henry Li](https://github.com/magiccube/)** + +Without their vision, passion and dedication, \`DeerFlow\` would not be what it is today. +`; diff --git a/frontend/src/components/workspace/settings/about-settings-page.tsx b/frontend/src/components/workspace/settings/about-settings-page.tsx index e3c215f..8635f8d 100644 --- a/frontend/src/components/workspace/settings/about-settings-page.tsx +++ b/frontend/src/components/workspace/settings/about-settings-page.tsx @@ -2,8 +2,8 @@ import { Streamdown } from "streamdown"; -import about from "./about.md"; +import { aboutMarkdown } from "./about-content"; export function AboutSettingsPage() { - return {about}; + return {aboutMarkdown}; } From fe06be825801bf3f747fff0436c376e359a0b355 Mon Sep 17 00:00:00 2001 From: LofiSu Date: Mon, 9 Feb 2026 03:23:51 +0800 Subject: [PATCH 2/9] Revert "fix(frontend): Turbopack about page + remove hover on web search/citations" This reverts commit 7e9e061f20fcec1f1a9c35be40d9407c05ed82be. --- .../ai-elements/inline-citation.tsx | 52 ++++++++++++------ .../workspace/messages/message-group.tsx | 20 ++++--- .../workspace/settings/about-content.ts | 54 ------------------- .../settings/about-settings-page.tsx | 4 +- 4 files changed, 51 insertions(+), 79 deletions(-) delete mode 100644 frontend/src/components/workspace/settings/about-content.ts diff --git a/frontend/src/components/ai-elements/inline-citation.tsx b/frontend/src/components/ai-elements/inline-citation.tsx index 2c9fda2..dde6e31 100644 --- a/frontend/src/components/ai-elements/inline-citation.tsx +++ b/frontend/src/components/ai-elements/inline-citation.tsx @@ -320,21 +320,43 @@ export const CitationLink = ({ const displayText = citation?.title || (!isGenericText && childrenText) || domain; return ( - e.stopPropagation()} - > - - {displayText} - - - + + + e.stopPropagation()} + > + + {displayText} + + + + + + + + ); }; diff --git a/frontend/src/components/workspace/messages/message-group.tsx b/frontend/src/components/workspace/messages/message-group.tsx index 1dffa33..a0782bb 100644 --- a/frontend/src/components/workspace/messages/message-group.tsx +++ b/frontend/src/components/workspace/messages/message-group.tsx @@ -232,11 +232,13 @@ function ToolCall({ {Array.isArray(result) && ( {result.map((item) => ( - - - {item.title} - - + + + + {item.title} + + + ))} )} @@ -307,9 +309,11 @@ function ToolCall({ > {url && ( - - {title} - + {result as string}}> + + {title} + + )} diff --git a/frontend/src/components/workspace/settings/about-content.ts b/frontend/src/components/workspace/settings/about-content.ts deleted file mode 100644 index 9866434..0000000 --- a/frontend/src/components/workspace/settings/about-content.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** About page markdown content. Exported as string to avoid raw-loader with Turbopack. */ -export const aboutMarkdown = `# 🦌 [About DeerFlow 2.0](https://github.com/bytedance/deer-flow) - -> **From Open Source, Back to Open Source** - -**DeerFlow** (**D**eep **E**xploration and **E**fficient **R**esearch **Flow**) is a community-driven SuperAgent harness that researches, codes, and creates. -With the help of sandboxes, memories, tools and skills, it handles -different levels of tasks that could take minutes to hours. - ---- - -## 🌟 GitHub Repository - -Explore DeerFlow on GitHub: [github.com/bytedance/deer-flow](https://github.com/bytedance/deer-flow) - -## 🌐 Official Website - -Visit the official website of DeerFlow: [deerflow.tech](https://deerflow.tech/) - -## 📧 Support - -If you have any questions or need help, please contact us at [support@deerflow.tech](mailto:support@deerflow.tech). - ---- - -## 📜 License - -DeerFlow is proudly open source and distributed under the **MIT License**. - ---- - -## 🙌 Acknowledgments - -We extend our heartfelt gratitude to the open source projects and contributors who have made DeerFlow a reality. We truly stand on the shoulders of giants. - -### Core Frameworks -- **[LangChain](https://github.com/langchain-ai/langchain)**: A phenomenal framework that powers our LLM interactions and chains. -- **[LangGraph](https://github.com/langchain-ai/langgraph)**: Enabling sophisticated multi-agent orchestration. -- **[Next.js](https://nextjs.org/)**: A cutting-edge framework for building web applications. - -### UI Libraries -- **[Shadcn](https://ui.shadcn.com/)**: Minimalistic components that power our UI. -- **[SToneX](https://github.com/stonexer)**: For his invaluable contribution to token-by-token visual effects. - -These outstanding projects form the backbone of DeerFlow and exemplify the transformative power of open source collaboration. - -### Special Thanks -Finally, we want to express our heartfelt gratitude to the core authors of DeerFlow 1.0 and 2.0: - -- **[Daniel Walnut](https://github.com/hetaoBackend/)** -- **[Henry Li](https://github.com/magiccube/)** - -Without their vision, passion and dedication, \`DeerFlow\` would not be what it is today. -`; diff --git a/frontend/src/components/workspace/settings/about-settings-page.tsx b/frontend/src/components/workspace/settings/about-settings-page.tsx index 8635f8d..e3c215f 100644 --- a/frontend/src/components/workspace/settings/about-settings-page.tsx +++ b/frontend/src/components/workspace/settings/about-settings-page.tsx @@ -2,8 +2,8 @@ import { Streamdown } from "streamdown"; -import { aboutMarkdown } from "./about-content"; +import about from "./about.md"; export function AboutSettingsPage() { - return {aboutMarkdown}; + return {about}; } From d72aad806347c501794faf3126fb6792278542df Mon Sep 17 00:00:00 2001 From: LofiSu Date: Mon, 9 Feb 2026 03:42:16 +0800 Subject: [PATCH 3/9] fix(frontend): build + remove hover tooltips in step links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix Turbopack build: replace raw-loader .md import with inlined about-content.ts; drop raw-loader from next.config and package.json - Remove all hover tooltips on step-area links (web_fetch, read_file, ls, bash, write_file, web_search) so hidden steps no longer show popups 修复:构建错误与步骤链接悬停提示 - 修复 Turbopack 构建:用内联 about-content.ts 替代 raw-loader 导入 about.md,并移除 next.config 与 package.json 中的 raw-loader - 移除步骤区域内所有链接的悬停提示(查看网页、读文件、列目录、bash、写文件、网页搜索),隐藏步骤悬停不再弹出内容 Co-authored-by: Cursor --- frontend/next.config.js | 9 --- frontend/package.json | 1 - .../workspace/messages/message-group.tsx | 64 +++++++------------ .../workspace/settings/about-content.ts | 57 +++++++++++++++++ .../settings/about-settings-page.tsx | 4 +- 5 files changed, 82 insertions(+), 53 deletions(-) create mode 100644 frontend/src/components/workspace/settings/about-content.ts diff --git a/frontend/next.config.js b/frontend/next.config.js index 3dff517..7159179 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -7,15 +7,6 @@ import "./src/env.js"; /** @type {import("next").NextConfig} */ const config = { devIndicators: false, - turbopack: { - root: import.meta.dirname, - rules: { - "*.md": { - loaders: ["raw-loader"], - as: "*.js", - }, - }, - }, }; export default config; diff --git a/frontend/package.json b/frontend/package.json index a1708dc..e936c34 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -97,7 +97,6 @@ "postcss": "^8.5.3", "prettier": "^3.5.3", "prettier-plugin-tailwindcss": "^0.6.11", - "raw-loader": "^4.0.2", "tailwindcss": "^4.0.15", "tw-animate-css": "^1.4.0", "typescript": "^5.8.2", diff --git a/frontend/src/components/workspace/messages/message-group.tsx b/frontend/src/components/workspace/messages/message-group.tsx index a0782bb..f1b7a71 100644 --- a/frontend/src/components/workspace/messages/message-group.tsx +++ b/frontend/src/components/workspace/messages/message-group.tsx @@ -232,13 +232,11 @@ function ToolCall({ {Array.isArray(result) && ( {result.map((item) => ( - - - - {item.title} - - - + + + {item.title} + + ))} )} @@ -309,11 +307,9 @@ function ToolCall({ > {url && ( - {result as string}}> - - {title} - - + + {title} + )} @@ -328,11 +324,9 @@ function ToolCall({ return ( {path && ( - {result as string}}> - - {path} - - + + {path} + )} ); @@ -346,17 +340,9 @@ function ToolCall({ return ( {path && ( - - {result as string} - - } - > - - {path} - - + + {path} + )} ); @@ -405,11 +391,9 @@ function ToolCall({ }} > {path && ( - - - {path} - - + + {path} + )} {showCitationsLoading && ( @@ -433,14 +417,12 @@ function ToolCall({ icon={SquareTerminalIcon} > {command && ( - {result as string}}> - - + )} ); diff --git a/frontend/src/components/workspace/settings/about-content.ts b/frontend/src/components/workspace/settings/about-content.ts new file mode 100644 index 0000000..c744be0 --- /dev/null +++ b/frontend/src/components/workspace/settings/about-content.ts @@ -0,0 +1,57 @@ +/** + * About DeerFlow markdown content. Inlined to avoid raw-loader dependency + * (Turbopack cannot resolve raw-loader for .md imports). + */ +export const aboutMarkdown = `# 🦌 [About DeerFlow 2.0](https://github.com/bytedance/deer-flow) + +> **From Open Source, Back to Open Source** + +**DeerFlow** (**D**eep **E**xploration and **E**fficient **R**esearch **Flow**) is a community-driven SuperAgent harness that researches, codes, and creates. +With the help of sandboxes, memories, tools and skills, it handles +different levels of tasks that could take minutes to hours. + +--- + +## 🌟 GitHub Repository + +Explore DeerFlow on GitHub: [github.com/bytedance/deer-flow](https://github.com/bytedance/deer-flow) + +## 🌐 Official Website + +Visit the official website of DeerFlow: [deerflow.tech](https://deerflow.tech/) + +## 📧 Support + +If you have any questions or need help, please contact us at [support@deerflow.tech](mailto:support@deerflow.tech). + +--- + +## 📜 License + +DeerFlow is proudly open source and distributed under the **MIT License**. + +--- + +## 🙌 Acknowledgments + +We extend our heartfelt gratitude to the open source projects and contributors who have made DeerFlow a reality. We truly stand on the shoulders of giants. + +### Core Frameworks +- **[LangChain](https://github.com/langchain-ai/langchain)**: A phenomenal framework that powers our LLM interactions and chains. +- **[LangGraph](https://github.com/langchain-ai/langgraph)**: Enabling sophisticated multi-agent orchestration. +- **[Next.js](https://nextjs.org/)**: A cutting-edge framework for building web applications. + +### UI Libraries +- **[Shadcn](https://ui.shadcn.com/)**: Minimalistic components that power our UI. +- **[SToneX](https://github.com/stonexer)**: For his invaluable contribution to token-by-token visual effects. + +These outstanding projects form the backbone of DeerFlow and exemplify the transformative power of open source collaboration. + +### Special Thanks +Finally, we want to express our heartfelt gratitude to the core authors of DeerFlow 1.0 and 2.0: + +- **[Daniel Walnut](https://github.com/hetaoBackend/)** +- **[Henry Li](https://github.com/magiccube/)** + +Without their vision, passion and dedication, \`DeerFlow\` would not be what it is today. +`; diff --git a/frontend/src/components/workspace/settings/about-settings-page.tsx b/frontend/src/components/workspace/settings/about-settings-page.tsx index e3c215f..8635f8d 100644 --- a/frontend/src/components/workspace/settings/about-settings-page.tsx +++ b/frontend/src/components/workspace/settings/about-settings-page.tsx @@ -2,8 +2,8 @@ import { Streamdown } from "streamdown"; -import about from "./about.md"; +import { aboutMarkdown } from "./about-content"; export function AboutSettingsPage() { - return {about}; + return {aboutMarkdown}; } From 2d70aaa969dc575d448911b4dd836387b2fc2588 Mon Sep 17 00:00:00 2001 From: LofiSu Date: Mon, 9 Feb 2026 04:03:15 +0800 Subject: [PATCH 4/9] fix(frontend): citations display + refactor link/citation utils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Citations: no underline while streaming (message links); artifact markdown external links as citation cards - Refactor: add isExternalUrl, syntheticCitationFromLink in core/citations; shared externalLinkClass in lib/utils; simplify message-list-item and artifact-file-detail link rendering 修复引用展示并抽离链接/引用工具 - 引用:流式输出时链接不这下划线;Artifact 内 Markdown 外链以引用卡片展示 - 重构:core/citations 新增 isExternalUrl、syntheticCitationFromLink;lib/utils 共享 externalLinkClass;精简消息与 Artifact 中的链接渲染逻辑 Co-authored-by: Cursor --- .../artifacts/artifact-file-detail.tsx | 29 +++++++++++++------ .../workspace/messages/message-list-item.tsx | 24 +++++++++++---- frontend/src/core/citations/index.ts | 4 ++- frontend/src/core/citations/utils.ts | 19 ++++++++++++ frontend/src/lib/utils.ts | 12 ++++++-- 5 files changed, 69 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx index e4315f3..1178899 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -35,15 +35,17 @@ import { useArtifactContent } from "@/core/artifacts/hooks"; import { urlOfArtifact } from "@/core/artifacts/utils"; import { buildCitationMap, + isExternalUrl, parseCitations, removeAllCitations, + syntheticCitationFromLink, } from "@/core/citations"; import { useI18n } from "@/core/i18n/hooks"; import { installSkill } from "@/core/skills/api"; import { streamdownPlugins } from "@/core/streamdown"; import { checkCodeFile, getFileName } from "@/core/utils/files"; import { env } from "@/env"; -import { cn } from "@/lib/utils"; +import { cn, externalLinkClass } from "@/lib/utils"; import { Tooltip } from "../tooltip"; @@ -309,11 +311,7 @@ export function ArtifactFilePreview({ href, children, }: React.AnchorHTMLAttributes) => { - if (!href) { - return {children}; - } - - // Only render as CitationLink badge if it's a citation (in citationMap) + if (!href) return {children}; const citation = citationMap.get(href); if (citation) { return ( @@ -322,14 +320,27 @@ export function ArtifactFilePreview({ ); } - - // All other links (including project URLs) render as plain links + if (isExternalUrl(href)) { + const linkText = + typeof children === "string" + ? children + : String(React.Children.toArray(children).join("")).trim() || + href; + return ( + + {children} + + ); + } return ( {children} diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index 7d5cc0d..7858951 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -31,7 +31,11 @@ import { } from "@/core/messages/utils"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { humanMessagePlugins, streamdownPlugins } from "@/core/streamdown"; -import { cn } from "@/lib/utils"; +import { + cn, + externalLinkClass, + externalLinkClassNoUnderline, +} from "@/lib/utils"; import { CopyButton } from "../copy-button"; @@ -79,20 +83,23 @@ export function MessageListItem({ * Custom link component that handles citations and external links * Only links in citationMap are rendered as CitationLink badges * Other links (project URLs, regular links) are rendered as plain links + * During citation loading (streaming), non-citation links are rendered without underline so they match final citation style (p3) */ function MessageLink({ href, children, citationMap, isHuman, + isLoadingCitations, }: React.AnchorHTMLAttributes & { citationMap: Map; isHuman: boolean; + isLoadingCitations?: boolean; }) { if (!href) return {children}; const citation = citationMap.get(href); - + // Only render as CitationLink badge if it's a citation (in citationMap) and not human message if (citation && !isHuman) { return ( @@ -102,13 +109,13 @@ function MessageLink({ ); } - // All other links render as plain links + const noUnderline = !isHuman && isLoadingCitations; return ( {children} @@ -201,12 +208,17 @@ function MessageContent_({ // Shared markdown components const markdownComponents = useMemo(() => ({ a: (props: React.AnchorHTMLAttributes) => ( - + ), img: (props: React.ImgHTMLAttributes) => ( ), - }), [citationMap, thread_id, isHuman]); + }), [citationMap, thread_id, isHuman, isLoadingCitations]); // Render message response // Human messages use humanMessagePlugins (no autolink) to prevent URL bleeding into adjacent text diff --git a/frontend/src/core/citations/index.ts b/frontend/src/core/citations/index.ts index fd2a2aa..3efa83c 100644 --- a/frontend/src/core/citations/index.ts +++ b/frontend/src/core/citations/index.ts @@ -1,9 +1,11 @@ export { - parseCitations, buildCitationMap, extractDomainFromUrl, isCitationsBlockIncomplete, + isExternalUrl, + parseCitations, removeAllCitations, + syntheticCitationFromLink, } from "./utils"; export type { Citation, ParseCitationsResult } from "./utils"; diff --git a/frontend/src/core/citations/utils.ts b/frontend/src/core/citations/utils.ts index 699900b..1937af1 100644 --- a/frontend/src/core/citations/utils.ts +++ b/frontend/src/core/citations/utils.ts @@ -118,6 +118,25 @@ export function buildCitationMap( return map; } +/** + * Whether the URL is external (http/https). + */ +export function isExternalUrl(url: string): boolean { + return url.startsWith("http://") || url.startsWith("https://"); +} + +/** + * Build a synthetic Citation from a link (e.g. in artifact markdown without block). + */ +export function syntheticCitationFromLink(href: string, title: string): Citation { + return { + id: `artifact-cite-${href}`, + title: title || href, + url: href, + snippet: "", + }; +} + /** * Extract the domain name from a URL for display * diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index bd0c391..a414622 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1,6 +1,12 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); } + +/** Shared class for external links (underline by default). */ +export const externalLinkClass = + "text-primary underline underline-offset-2 hover:no-underline"; +/** For streaming / loading state when link may be a citation (no underline). */ +export const externalLinkClassNoUnderline = "text-primary hover:underline"; From 30e1760211b384ce1d271c54061ec5318de92f6c Mon Sep 17 00:00:00 2001 From: LofiSu Date: Mon, 9 Feb 2026 12:13:06 +0800 Subject: [PATCH 5/9] refactor(frontend): simplify and deduplicate Citation-related code - Extract removeCitationsBlocks in utils, reuse in parseCitations and removeAllCitations - Add hasCitationsBlock; isCitationsBlockIncomplete now uses it - Add useParsedCitations hook (parseCitations + buildCitationMap) for message/artifact - Add CitationAwareLink to unify link rendering (message-list-item + artifact-file-detail) - Add getCleanContent helper; message-group uses it and useParsedCitations - ArtifactFileDetail: single useParsedCitations, pass cleanContent/citationMap to Preview - Stop exporting buildCitationMap and removeCitationsBlocks from citations index - Remove duplicate MessageLink and inline link logic in artifact preview Co-authored-by: Cursor --- .../ai-elements/inline-citation.tsx | 78 ++++++++++- .../artifacts/artifact-file-detail.tsx | 88 ++++--------- .../workspace/messages/message-group.tsx | 22 ++-- .../workspace/messages/message-list-item.tsx | 121 +++++------------- frontend/src/core/citations/index.ts | 5 +- .../core/citations/use-parsed-citations.ts | 28 ++++ frontend/src/core/citations/utils.ts | 51 +++++--- 7 files changed, 202 insertions(+), 191 deletions(-) create mode 100644 frontend/src/core/citations/use-parsed-citations.ts diff --git a/frontend/src/components/ai-elements/inline-citation.tsx b/frontend/src/components/ai-elements/inline-citation.tsx index dde6e31..ef9f893 100644 --- a/frontend/src/components/ai-elements/inline-citation.tsx +++ b/frontend/src/components/ai-elements/inline-citation.tsx @@ -12,10 +12,15 @@ import { HoverCardContent, HoverCardTrigger, } from "@/components/ui/hover-card"; -import { cn } from "@/lib/utils"; +import { + cn, + externalLinkClass, + externalLinkClassNoUnderline, +} from "@/lib/utils"; import { ExternalLinkIcon, ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; import { type ComponentProps, + Children, createContext, useCallback, useContext, @@ -23,7 +28,11 @@ import { useState, } from "react"; import type { Citation } from "@/core/citations"; -import { extractDomainFromUrl } from "@/core/citations"; +import { + extractDomainFromUrl, + isExternalUrl, + syntheticCitationFromLink, +} from "@/core/citations"; import { Shimmer } from "./shimmer"; import { useI18n } from "@/core/i18n/hooks"; @@ -360,6 +369,71 @@ export const CitationLink = ({ ); }; +/** + * Renders a link with optional citation badge. Use in markdown components (message + artifact). + * - citationMap: URL -> Citation; links in map render as CitationLink. + * - isHuman: when true, never render as CitationLink (plain link). + * - isLoadingCitations: when true and not human, non-citation links use no-underline style. + * - syntheticExternal: when true, external URLs not in citationMap render as CitationLink with synthetic citation. + */ +export type CitationAwareLinkProps = ComponentProps<"a"> & { + citationMap: Map; + isHuman?: boolean; + isLoadingCitations?: boolean; + syntheticExternal?: boolean; +}; + +export const CitationAwareLink = ({ + href, + children, + citationMap, + isHuman = false, + isLoadingCitations = false, + syntheticExternal = false, + className, + ...rest +}: CitationAwareLinkProps) => { + if (!href) return {children}; + + const citation = citationMap.get(href); + + if (citation && !isHuman) { + return ( + + {children} + + ); + } + + if (syntheticExternal && isExternalUrl(href)) { + const linkText = + typeof children === "string" + ? children + : String(Children.toArray(children).join("")).trim() || href; + return ( + + {children} + + ); + } + + const noUnderline = !isHuman && isLoadingCitations; + return ( + + {children} + + ); +}; + /** * Shared CitationsLoadingIndicator component * Used across message-list-item and message-group to show loading citations diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx index 1178899..53bfcb1 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -21,7 +21,7 @@ import { ArtifactHeader, ArtifactTitle, } from "@/components/ai-elements/artifact"; -import { CitationLink } from "@/components/ai-elements/inline-citation"; +import { CitationAwareLink } from "@/components/ai-elements/inline-citation"; import { Select, SelectItem } from "@/components/ui/select"; import { SelectContent, @@ -33,19 +33,14 @@ 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 { - buildCitationMap, - isExternalUrl, - parseCitations, - removeAllCitations, - syntheticCitationFromLink, -} from "@/core/citations"; +import type { Citation } from "@/core/citations"; +import { removeAllCitations, useParsedCitations } from "@/core/citations"; import { useI18n } from "@/core/i18n/hooks"; import { installSkill } from "@/core/skills/api"; import { streamdownPlugins } from "@/core/streamdown"; import { checkCodeFile, getFileName } from "@/core/utils/files"; import { env } from "@/env"; -import { cn, externalLinkClass } from "@/lib/utils"; +import { cn } from "@/lib/utils"; import { Tooltip } from "../tooltip"; @@ -96,15 +91,11 @@ export function ArtifactFileDetail({ 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]); - - // Get content without ANY citations for copy/download + const parsed = useParsedCitations( + language === "markdown" ? (content ?? "") : "", + ); + const cleanContent = + language === "markdown" && content ? parsed.cleanContent : (content ?? ""); const contentWithoutCitations = useMemo(() => { if (language === "markdown" && content) { return removeAllCitations(content); @@ -260,6 +251,8 @@ export function ArtifactFileDetail({ threadId={threadId} content={content} language={language ?? "text"} + cleanContent={parsed.cleanContent} + citationMap={parsed.citationMap} /> )} {isCodeFile && viewMode === "code" && ( @@ -285,21 +278,16 @@ export function ArtifactFilePreview({ threadId, content, language, + cleanContent, + citationMap, }: { filepath: string; threadId: string; content: string; language: string; + cleanContent: string; + citationMap: Map; }) { - const { cleanContent, citationMap } = React.useMemo(() => { - const parsed = parseCitations(content ?? ""); - const map = buildCitationMap(parsed.citations); - return { - cleanContent: parsed.cleanContent, - citationMap: map, - }; - }, [content]); - if (language === "markdown") { return (
@@ -307,45 +295,13 @@ export function ArtifactFilePreview({ className="size-full" {...streamdownPlugins} components={{ - a: ({ - href, - children, - }: React.AnchorHTMLAttributes) => { - if (!href) return {children}; - const citation = citationMap.get(href); - if (citation) { - return ( - - {children} - - ); - } - if (isExternalUrl(href)) { - const linkText = - typeof children === "string" - ? children - : String(React.Children.toArray(children).join("")).trim() || - href; - return ( - - {children} - - ); - } - return ( - - {children} - - ); - }, + a: (props: React.AnchorHTMLAttributes) => ( + + ), }} > {cleanContent ?? ""} diff --git a/frontend/src/components/workspace/messages/message-group.tsx b/frontend/src/components/workspace/messages/message-group.tsx index f1b7a71..524d16f 100644 --- a/frontend/src/components/workspace/messages/message-group.tsx +++ b/frontend/src/components/workspace/messages/message-group.tsx @@ -25,7 +25,11 @@ import { CodeBlock } from "@/components/ai-elements/code-block"; import { CitationsLoadingIndicator } from "@/components/ai-elements/inline-citation"; import { MessageResponse } from "@/components/ai-elements/message"; import { Button } from "@/components/ui/button"; -import { parseCitations } from "@/core/citations"; +import { + getCleanContent, + hasCitationsBlock, + useParsedCitations, +} from "@/core/citations"; import { useI18n } from "@/core/i18n/hooks"; import { extractReasoningContentFromMessage, @@ -124,7 +128,7 @@ export function MessageGroup({ remarkPlugins={streamdownPlugins.remarkPlugins} rehypePlugins={rehypePlugins} > - {parseCitations(step.reasoning ?? "").cleanContent} + {getCleanContent(step.reasoning ?? "")} } > @@ -177,10 +181,7 @@ export function MessageGroup({ remarkPlugins={streamdownPlugins.remarkPlugins} rehypePlugins={rehypePlugins} > - { - parseCitations(lastReasoningStep.reasoning ?? "") - .cleanContent - } + {getCleanContent(lastReasoningStep.reasoning ?? "")} } > @@ -215,12 +216,8 @@ function ToolCall({ const { thread } = useThread(); const threadIsLoading = thread.isLoading; - // Move useMemo to top level to comply with React Hooks rules const fileContent = typeof args.content === "string" ? args.content : ""; - const { citations } = useMemo( - () => parseCitations(fileContent), - [fileContent], - ); + const { citations } = useParsedCitations(fileContent); if (name === "web_search") { let label: React.ReactNode = t.toolCalls.searchForRelatedInfo; @@ -370,9 +367,8 @@ function ToolCall({ const isMarkdown = path?.toLowerCase().endsWith(".md") || path?.toLowerCase().endsWith(".markdown"); - const hasCitationsBlock = fileContent.includes(""); const showCitationsLoading = - isMarkdown && threadIsLoading && hasCitationsBlock && isLast; + isMarkdown && threadIsLoading && hasCitationsBlock(fileContent) && isLast; return ( <> diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index 7858951..787d921 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -5,7 +5,7 @@ import { memo, useMemo } from "react"; import rehypeKatex from "rehype-katex"; import { - CitationLink, + CitationAwareLink, CitationsLoadingIndicator, } from "@/components/ai-elements/inline-citation"; import { @@ -17,11 +17,9 @@ import { import { Badge } from "@/components/ui/badge"; import { resolveArtifactURL } from "@/core/artifacts/utils"; import { - type Citation, - buildCitationMap, isCitationsBlockIncomplete, - parseCitations, removeAllCitations, + useParsedCitations, } from "@/core/citations"; import { extractContentFromMessage, @@ -31,11 +29,7 @@ import { } from "@/core/messages/utils"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { humanMessagePlugins, streamdownPlugins } from "@/core/streamdown"; -import { - cn, - externalLinkClass, - externalLinkClassNoUnderline, -} from "@/lib/utils"; +import { cn } from "@/lib/utils"; import { CopyButton } from "../copy-button"; @@ -79,49 +73,6 @@ export function MessageListItem({ ); } -/** - * Custom link component that handles citations and external links - * Only links in citationMap are rendered as CitationLink badges - * Other links (project URLs, regular links) are rendered as plain links - * During citation loading (streaming), non-citation links are rendered without underline so they match final citation style (p3) - */ -function MessageLink({ - href, - children, - citationMap, - isHuman, - isLoadingCitations, -}: React.AnchorHTMLAttributes & { - citationMap: Map; - isHuman: boolean; - isLoadingCitations?: boolean; -}) { - if (!href) return {children}; - - const citation = citationMap.get(href); - - // Only render as CitationLink badge if it's a citation (in citationMap) and not human message - if (citation && !isHuman) { - return ( - - {children} - - ); - } - - const noUnderline = !isHuman && isLoadingCitations; - return ( - - {children} - - ); -} - /** * Custom image component that handles artifact URLs */ @@ -165,50 +116,44 @@ function MessageContent_({ const isHuman = message.type === "human"; const { thread_id } = useParams<{ thread_id: string }>(); - // Extract and parse citations and uploaded files from message content - const { citations, cleanContent, uploadedFiles, isLoadingCitations } = - useMemo(() => { - const reasoningContent = extractReasoningContentFromMessage(message); - const rawContent = extractContentFromMessage(message); + // Content to parse for citations (and optionally uploaded files) + const { contentToParse, uploadedFiles, isLoadingCitations } = useMemo(() => { + const reasoningContent = extractReasoningContentFromMessage(message); + const rawContent = extractContentFromMessage(message); - // When only reasoning content exists (no main content), also parse citations - if (!isLoading && reasoningContent && !rawContent) { - const { citations, cleanContent } = parseCitations(reasoningContent); - return { - citations, - cleanContent, - uploadedFiles: [], - isLoadingCitations: false, - }; - } + if (!isLoading && reasoningContent && !rawContent) { + return { + contentToParse: reasoningContent, + uploadedFiles: [] as UploadedFile[], + isLoadingCitations: false, + }; + } - // For human messages, parse uploaded files first - if (isHuman && rawContent) { - const { files, cleanContent: contentWithoutFiles } = - parseUploadedFiles(rawContent); - const { citations, cleanContent: finalContent } = - parseCitations(contentWithoutFiles); - return { - citations, - cleanContent: finalContent, - uploadedFiles: files, - isLoadingCitations: false, - }; - } + if (isHuman && rawContent) { + const { files, cleanContent: contentWithoutFiles } = + parseUploadedFiles(rawContent); + return { + contentToParse: contentWithoutFiles, + uploadedFiles: files, + isLoadingCitations: false, + }; + } - const { citations, cleanContent } = parseCitations(rawContent ?? ""); - const isLoadingCitations = - isLoading && isCitationsBlockIncomplete(rawContent ?? ""); + return { + contentToParse: rawContent ?? "", + uploadedFiles: [] as UploadedFile[], + isLoadingCitations: + isLoading && isCitationsBlockIncomplete(rawContent ?? ""), + }; + }, [isLoading, message, isHuman]); - return { citations, cleanContent, uploadedFiles: [], isLoadingCitations }; - }, [isLoading, message, isHuman]); - - const citationMap = useMemo(() => buildCitationMap(citations), [citations]); + const { citations, cleanContent, citationMap } = + useParsedCitations(contentToParse); // Shared markdown components const markdownComponents = useMemo(() => ({ a: (props: React.AnchorHTMLAttributes) => ( - ; +} + +/** + * Parse content for citations and build citation map. Memoized by content. + * Use in message and artifact components to avoid repeating parseCitations + buildCitationMap. + */ +export function useParsedCitations(content: string): UseParsedCitationsResult { + return useMemo(() => { + const parsed = parseCitations(content ?? ""); + const citationMap = buildCitationMap(parsed.citations); + return { + citations: parsed.citations, + cleanContent: parsed.cleanContent, + citationMap, + }; + }, [content]); +} diff --git a/frontend/src/core/citations/utils.ts b/frontend/src/core/citations/utils.ts index 1937af1..d2384a4 100644 --- a/frontend/src/core/citations/utils.ts +++ b/frontend/src/core/citations/utils.ts @@ -67,14 +67,7 @@ export function parseCitations(content: string): ParseCitationsResult { } } - // 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(); - } + cleanContent = removeCitationsBlocks(content); // Convert [cite-N] references to markdown links // Example: [cite-1] -> [Title](url) @@ -102,6 +95,13 @@ export function parseCitations(content: string): ParseCitationsResult { return { citations, cleanContent }; } +/** + * Return content with citations block removed and [cite-N] replaced by markdown links. + */ +export function getCleanContent(content: string): string { + return parseCitations(content ?? "").cleanContent; +} + /** * Build a map from URL to Citation for quick lookup * @@ -153,6 +153,26 @@ export function extractDomainFromUrl(url: string): string { } } +/** + * Remove all blocks from content (complete and incomplete). + * Does not remove [cite-N] or markdown links; use removeAllCitations for that. + */ +export function removeCitationsBlocks(content: string): string { + if (!content) return content; + let result = content.replace(/[\s\S]*?<\/citations>/g, "").trim(); + if (result.includes("")) { + result = result.replace(/[\s\S]*$/g, "").trim(); + } + return result; +} + +/** + * Whether content contains a block (open tag). + */ +export function hasCitationsBlock(content: string): boolean { + return Boolean(content?.includes("")); +} + /** * Check if content is still receiving the citations block (streaming) * This helps determine if we should wait before parsing @@ -161,15 +181,7 @@ export function extractDomainFromUrl(url: string): string { * @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; + return hasCitationsBlock(content) && !content.includes(""); } /** @@ -188,11 +200,8 @@ export function removeAllCitations(content: string): string { return content; } - let result = content; - // Step 1: Remove all blocks (complete and incomplete) - result = result.replace(/[\s\S]*?<\/citations>/g, ""); - result = result.replace(/[\s\S]*$/g, ""); + let result = removeCitationsBlocks(content); // Step 2: Remove all [cite-N] references result = result.replace(/\[cite-\d+\]/g, ""); From d265bdb24519e313ee97d615b58fdb6fa7dc4e22 Mon Sep 17 00:00:00 2001 From: LofiSu Date: Mon, 9 Feb 2026 12:33:16 +0800 Subject: [PATCH 6/9] feat(frontend): add mode hover guide and adjust mode i18n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 中文 ### 代码改动 - **新增** `frontend/src/components/workspace/mode-hover-guide.tsx` - 新增 ModeHoverGuide 组件:接收 mode (flash/thinking/pro/ultra) 与 children,用 Tooltip 包裹 - hover 时展示该模式名称与简介,支持 showTitle 控制是否显示模式名 - 文案通过 useI18n 从 inputBox 的 *Mode / *ModeDescription 读取,中英文已支持 - **修改** `frontend/src/components/workspace/input-box.tsx` - 在模式选择器触发按钮外包一层 ModeHoverGuide,悬停当前模式时显示说明 - **修改** `frontend/src/core/i18n/locales/zh-CN.ts` - ultraModeDescription:改为完整描述「思考、计划并执行,可调用子代理分工协作,适合复杂多步骤任务,能力最强」(不再仅写「专业模式加子代理」) - proMode / ultraMode:中文环境下保留英文原文 "Pro"、"Ultra",不再翻译为「专业」「超级」 - **修改** `frontend/src/core/i18n/locales/en-US.ts` - ultraModeDescription:改为 "Reasoning, planning and execution with subagents to divide work; best for complex multi-step tasks" ### 说明 为 Flash / 思考 / Pro / Ultra 四种模式增加 hover 说明,并统一超级模式文案与 Pro/Ultra 在中文下的展示。 Co-authored-by: Cursor --- ## English ### Code changes - **Add** `frontend/src/components/workspace/mode-hover-guide.tsx` - New ModeHoverGuide component: takes mode (flash/thinking/pro/ultra) and children, wraps in Tooltip - On hover shows mode name and short description; showTitle toggles mode name in tooltip - Copy from useI18n (inputBox *Mode / *ModeDescription), i18n in zh-CN and en-US - **Update** `frontend/src/components/workspace/input-box.tsx` - Wrap mode selector trigger with ModeHoverGuide so hovering shows current mode description - **Update** `frontend/src/core/i18n/locales/zh-CN.ts` - ultraModeDescription: full description (reasoning, planning, execution, subagents, complex tasks); no longer "Pro + subagents" only - proMode / ultraMode: keep English "Pro" and "Ultra" in zh locale instead of "专业" / "超级" - **Update** `frontend/src/core/i18n/locales/en-US.ts` - ultraModeDescription: "Reasoning, planning and execution with subagents to divide work; best for complex multi-step tasks" ### Summary Hover guide for all four modes (Flash / Reasoning / Pro / Ultra); clearer Ultra copy and Pro/Ultra labels in Chinese. --- .../src/components/workspace/input-box.tsx | 62 +++++++++++-------- .../components/workspace/mode-hover-guide.tsx | 60 ++++++++++++++++++ frontend/src/core/i18n/locales/en-US.ts | 2 +- frontend/src/core/i18n/locales/zh-CN.ts | 6 +- 4 files changed, 101 insertions(+), 29 deletions(-) create mode 100644 frontend/src/components/workspace/mode-hover-guide.tsx diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index 68c2c08..b32febf 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -60,6 +60,7 @@ import { DropdownMenuTrigger, } from "../ui/dropdown-menu"; +import { ModeHoverGuide } from "./mode-hover-guide"; import { Tooltip } from "./tooltip"; export function InputBox({ @@ -197,31 +198,42 @@ export function InputBox({ */} - -
- {context.mode === "flash" && } - {context.mode === "thinking" && ( - - )} - {context.mode === "pro" && ( - - )} - {context.mode === "ultra" && ( - - )} -
-
- {(context.mode === "flash" && t.inputBox.flashMode) || - (context.mode === "thinking" && t.inputBox.reasoningMode) || - (context.mode === "pro" && t.inputBox.proMode) || - (context.mode === "ultra" && t.inputBox.ultraMode)} -
-
+ + +
+ {context.mode === "flash" && } + {context.mode === "thinking" && ( + + )} + {context.mode === "pro" && ( + + )} + {context.mode === "ultra" && ( + + )} +
+
+ {(context.mode === "flash" && t.inputBox.flashMode) || + (context.mode === "thinking" && t.inputBox.reasoningMode) || + (context.mode === "pro" && t.inputBox.proMode) || + (context.mode === "ultra" && t.inputBox.ultraMode)} +
+
+
diff --git a/frontend/src/components/workspace/mode-hover-guide.tsx b/frontend/src/components/workspace/mode-hover-guide.tsx new file mode 100644 index 0000000..e78e82b --- /dev/null +++ b/frontend/src/components/workspace/mode-hover-guide.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useI18n } from "@/core/i18n/hooks"; +import { Tooltip } from "./tooltip"; + +export type AgentMode = "flash" | "thinking" | "pro" | "ultra"; + +function getModeLabelKey( + mode: AgentMode, +): keyof Pick< + import("@/core/i18n/locales/types").Translations["inputBox"], + "flashMode" | "reasoningMode" | "proMode" | "ultraMode" +> { + switch (mode) { + case "flash": + return "flashMode"; + case "thinking": + return "reasoningMode"; + case "pro": + return "proMode"; + case "ultra": + return "ultraMode"; + } +} + +function getModeDescriptionKey( + mode: AgentMode, +): keyof Pick< + import("@/core/i18n/locales/types").Translations["inputBox"], + "flashModeDescription" | "reasoningModeDescription" | "proModeDescription" | "ultraModeDescription" +> { + switch (mode) { + case "flash": + return "flashModeDescription"; + case "thinking": + return "reasoningModeDescription"; + case "pro": + return "proModeDescription"; + case "ultra": + return "ultraModeDescription"; + } +} + +export function ModeHoverGuide({ + mode, + children, + showTitle = true, +}: { + mode: AgentMode; + children: React.ReactNode; + /** When true, tooltip shows "ModeName: Description". When false, only description. */ + showTitle?: boolean; +}) { + const { t } = useI18n(); + const label = t.inputBox[getModeLabelKey(mode)]; + const description = t.inputBox[getModeDescriptionKey(mode)]; + const content = showTitle ? `${label}: ${description}` : description; + + return {children}; +} diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 5eeeda0..262c412 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -81,7 +81,7 @@ export const enUS: Translations = { "Reasoning, planning and executing, get more accurate results, may take more time", ultraMode: "Ultra", ultraModeDescription: - "Pro mode with subagents enabled, maximum capability for complex tasks", + "Reasoning, planning and execution with subagents to divide work; best for complex multi-step tasks", searchModels: "Search models...", surpriseMe: "Surprise", surpriseMePrompt: "Surprise me", diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 316e762..6f10f68 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -75,11 +75,11 @@ export const zhCN: Translations = { flashModeDescription: "快速且高效的完成任务,但可能不够精准", reasoningMode: "思考", reasoningModeDescription: "思考后再行动,在时间与准确性之间取得平衡", - proMode: "专业", + proMode: "Pro", proModeDescription: "思考、计划再执行,获得更精准的结果,可能需要更多时间", - ultraMode: "超级", + ultraMode: "Ultra", ultraModeDescription: - "专业模式加子代理,适用于复杂的多步骤任务,功能最强大", + "思考、计划并执行,可调用子代理分工协作,适合复杂多步骤任务,能力最强", searchModels: "搜索模型...", surpriseMe: "小惊喜", surpriseMePrompt: "给我一个小惊喜吧", From 8168ea47b3503b41b4e0db6576c05d28d01760b4 Mon Sep 17 00:00:00 2001 From: LofiSu Date: Mon, 9 Feb 2026 12:48:06 +0800 Subject: [PATCH 7/9] chore(frontend): remove unused Citation UI components from inline-citation - Remove InlineCitation, InlineCitationText, InlineCitationCardTrigger - Remove InlineCitationCarousel and all Carousel subcomponents (Content, Item, Header, Index, Prev, Next) - Remove InlineCitationQuote - Drop Carousel/carousel and ArrowLeft/ArrowRight icon imports; keep only CitationLink, CitationAwareLink, CitationsLoadingIndicator and their dependencies Co-authored-by: Cursor --- .../ai-elements/inline-citation.tsx | 236 +----------------- 1 file changed, 2 insertions(+), 234 deletions(-) diff --git a/frontend/src/components/ai-elements/inline-citation.tsx b/frontend/src/components/ai-elements/inline-citation.tsx index ef9f893..49ba280 100644 --- a/frontend/src/components/ai-elements/inline-citation.tsx +++ b/frontend/src/components/ai-elements/inline-citation.tsx @@ -1,12 +1,6 @@ "use client"; import { Badge } from "@/components/ui/badge"; -import { - Carousel, - type CarouselApi, - CarouselContent, - CarouselItem, -} from "@/components/ui/carousel"; import { HoverCard, HoverCardContent, @@ -17,16 +11,8 @@ import { externalLinkClass, externalLinkClassNoUnderline, } from "@/lib/utils"; -import { ExternalLinkIcon, ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; -import { - type ComponentProps, - Children, - createContext, - useCallback, - useContext, - useEffect, - useState, -} from "react"; +import { ExternalLinkIcon } from "lucide-react"; +import { type ComponentProps, Children } from "react"; import type { Citation } from "@/core/citations"; import { extractDomainFromUrl, @@ -36,63 +22,12 @@ import { import { Shimmer } from "./shimmer"; import { useI18n } from "@/core/i18n/hooks"; -export type InlineCitationProps = ComponentProps<"span">; - -export const InlineCitation = ({ - className, - ...props -}: InlineCitationProps) => ( - -); - -export type InlineCitationTextProps = ComponentProps<"span">; - -export const InlineCitationText = ({ - className, - ...props -}: InlineCitationTextProps) => ( - -); - export type InlineCitationCardProps = ComponentProps; export const InlineCitationCard = (props: InlineCitationCardProps) => ( ); -export type InlineCitationCardTriggerProps = ComponentProps & { - sources: string[]; -}; - -export const InlineCitationCardTrigger = ({ - sources, - className, - ...props -}: InlineCitationCardTriggerProps) => ( - - - {sources[0] ? ( - <> - {new URL(sources[0]).hostname}{" "} - {sources.length > 1 && `+${sources.length - 1}`} - - ) : ( - "unknown" - )} - - -); - export type InlineCitationCardBodyProps = ComponentProps<"div">; export const InlineCitationCardBody = ({ @@ -102,155 +37,6 @@ export const InlineCitationCardBody = ({ ); -const CarouselApiContext = createContext(undefined); - -const useCarouselApi = () => { - const context = useContext(CarouselApiContext); - return context; -}; - -export type InlineCitationCarouselProps = ComponentProps; - -export const InlineCitationCarousel = ({ - className, - children, - ...props -}: InlineCitationCarouselProps) => { - const [api, setApi] = useState(); - - return ( - - - {children} - - - ); -}; - -export type InlineCitationCarouselContentProps = ComponentProps<"div">; - -export const InlineCitationCarouselContent = ( - props: InlineCitationCarouselContentProps -) => ; - -export type InlineCitationCarouselItemProps = ComponentProps<"div">; - -export const InlineCitationCarouselItem = ({ - className, - ...props -}: InlineCitationCarouselItemProps) => ( - -); - -export type InlineCitationCarouselHeaderProps = ComponentProps<"div">; - -export const InlineCitationCarouselHeader = ({ - className, - ...props -}: InlineCitationCarouselHeaderProps) => ( -
-); - -export type InlineCitationCarouselIndexProps = ComponentProps<"div">; - -export const InlineCitationCarouselIndex = ({ - children, - className, - ...props -}: InlineCitationCarouselIndexProps) => { - const api = useCarouselApi(); - const [current, setCurrent] = useState(0); - const [count, setCount] = useState(0); - - useEffect(() => { - if (!api) { - return; - } - - setCount(api.scrollSnapList().length); - setCurrent(api.selectedScrollSnap() + 1); - - api.on("select", () => { - setCurrent(api.selectedScrollSnap() + 1); - }); - }, [api]); - - return ( -
- {children ?? `${current}/${count}`} -
- ); -}; - -export type InlineCitationCarouselPrevProps = ComponentProps<"button">; - -export const InlineCitationCarouselPrev = ({ - className, - ...props -}: InlineCitationCarouselPrevProps) => { - const api = useCarouselApi(); - - const handleClick = useCallback(() => { - if (api) { - api.scrollPrev(); - } - }, [api]); - - return ( - - ); -}; - -export type InlineCitationCarouselNextProps = ComponentProps<"button">; - -export const InlineCitationCarouselNext = ({ - className, - ...props -}: InlineCitationCarouselNextProps) => { - const api = useCarouselApi(); - - const handleClick = useCallback(() => { - if (api) { - api.scrollNext(); - } - }, [api]); - - return ( - - ); -}; - export type InlineCitationSourceProps = ComponentProps<"div"> & { title?: string; url?: string; @@ -281,24 +67,6 @@ export const InlineCitationSource = ({
); -export type InlineCitationQuoteProps = ComponentProps<"blockquote">; - -export const InlineCitationQuote = ({ - children, - className, - ...props -}: InlineCitationQuoteProps) => ( -
- {children} -
-); - /** * Shared CitationLink component that renders a citation as a hover card badge * Used across message-list-item, artifact-file-detail, and message-group From 2a399478307bdd827632bdeddf5baf11c1412d22 Mon Sep 17 00:00:00 2001 From: LofiSu Date: Mon, 9 Feb 2026 12:55:12 +0800 Subject: [PATCH 8/9] feat: citations prompts, path_utils, and citation code cleanup - Prompt: add citation reminders for web_search and subagent synthesis (lead_agent, general_purpose) - Gateway: add path_utils for shared thread virtual path resolution; refactor artifacts and skills to use it - Citations: simplify removeAllCitations (single parse); backend _extract_citation_urls and remove_citations_block cleanup Co-authored-by: Cursor --- backend/src/agents/lead_agent/prompt.py | 2 + backend/src/gateway/path_utils.py | 44 ++++++ backend/src/gateway/routers/artifacts.py | 128 +++++------------- backend/src/gateway/routers/skills.py | 47 +------ .../src/subagents/builtins/general_purpose.py | 13 +- frontend/src/core/citations/utils.ts | 43 ++---- 6 files changed, 103 insertions(+), 174 deletions(-) create mode 100644 backend/src/gateway/path_utils.py diff --git a/backend/src/agents/lead_agent/prompt.py b/backend/src/agents/lead_agent/prompt.py index 192e788..ce175c2 100644 --- a/backend/src/agents/lead_agent/prompt.py +++ b/backend/src/agents/lead_agent/prompt.py @@ -267,6 +267,7 @@ The key AI trends for 2026 include enhanced reasoning capabilities and multimoda - **Clarification First**: ALWAYS clarify unclear/missing/ambiguous requirements BEFORE starting work - never assume or guess +- **Web search citations**: When you use web_search (or synthesize subagent results that used it), you MUST output the `` block and [Title](url) links as specified in citations_format so citations display for the user. {subagent_reminder}- Skill First: Always load the relevant skill before starting **complex** tasks. - Progressive Loading: Load resources incrementally as referenced in skills - Output Files: Final deliverables must be in `/mnt/user-data/outputs` @@ -340,6 +341,7 @@ def apply_prompt_template(subagent_enabled: bool = False) -> str: # Add subagent reminder to critical_reminders if enabled subagent_reminder = ( "- **Orchestrator Mode**: You are a task orchestrator - decompose complex tasks into parallel sub-tasks and launch multiple subagents simultaneously. Synthesize results, don't execute directly.\n" + "- **Citations when synthesizing**: When you synthesize subagent results that used web search or cite sources, you MUST include a consolidated `` block (JSONL format) and use [Title](url) markdown links in your response so citations display correctly.\n" if subagent_enabled else "" ) diff --git a/backend/src/gateway/path_utils.py b/backend/src/gateway/path_utils.py new file mode 100644 index 0000000..119752e --- /dev/null +++ b/backend/src/gateway/path_utils.py @@ -0,0 +1,44 @@ +"""Shared path resolution for thread virtual paths (e.g. mnt/user-data/outputs/...).""" + +import os +from pathlib import Path + +from fastapi import HTTPException + +from src.agents.middlewares.thread_data_middleware import THREAD_DATA_BASE_DIR + +# Virtual path prefix used in sandbox environments (without leading slash for URL path matching) +VIRTUAL_PATH_PREFIX = "mnt/user-data" + + +def resolve_thread_virtual_path(thread_id: str, virtual_path: str) -> Path: + """Resolve a virtual path to the actual filesystem path under thread user-data. + + Args: + thread_id: The thread ID. + virtual_path: The virtual path (e.g., mnt/user-data/outputs/file.txt). + Leading slashes are stripped. + + Returns: + The resolved filesystem path. + + Raises: + HTTPException: If the path is invalid or outside allowed directories. + """ + virtual_path = virtual_path.lstrip("/") + if not virtual_path.startswith(VIRTUAL_PATH_PREFIX): + raise HTTPException(status_code=400, detail=f"Path must start with /{VIRTUAL_PATH_PREFIX}") + relative_path = virtual_path[len(VIRTUAL_PATH_PREFIX) :].lstrip("/") + + base_dir = Path(os.getcwd()) / THREAD_DATA_BASE_DIR / thread_id / "user-data" + actual_path = base_dir / relative_path + + try: + actual_path = actual_path.resolve() + base_resolved = base_dir.resolve() + if not str(actual_path).startswith(str(base_resolved)): + raise HTTPException(status_code=403, detail="Access denied: path traversal detected") + except (ValueError, RuntimeError): + raise HTTPException(status_code=400, detail="Invalid path") + + return actual_path diff --git a/backend/src/gateway/routers/artifacts.py b/backend/src/gateway/routers/artifacts.py index 9798193..a2a13a7 100644 --- a/backend/src/gateway/routers/artifacts.py +++ b/backend/src/gateway/routers/artifacts.py @@ -1,5 +1,5 @@ +import json import mimetypes -import os import re import zipfile from pathlib import Path @@ -8,49 +8,11 @@ from urllib.parse import quote from fastapi import APIRouter, HTTPException, Request, Response from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse -# Base directory for thread data (relative to backend/) -THREAD_DATA_BASE_DIR = ".deer-flow/threads" - -# Virtual path prefix used in sandbox environments (without leading slash for URL path matching) -VIRTUAL_PATH_PREFIX = "mnt/user-data" +from src.gateway.path_utils import resolve_thread_virtual_path router = APIRouter(prefix="/api", tags=["artifacts"]) -def _resolve_artifact_path(thread_id: str, artifact_path: str) -> Path: - """Resolve a virtual artifact path to the actual filesystem path. - - Args: - thread_id: The thread ID. - artifact_path: The virtual path (e.g., mnt/user-data/outputs/file.txt). - - Returns: - The resolved filesystem path. - - Raises: - HTTPException: If the path is invalid or outside allowed directories. - """ - # Validate and remove virtual path prefix - if not artifact_path.startswith(VIRTUAL_PATH_PREFIX): - raise HTTPException(status_code=400, detail=f"Path must start with /{VIRTUAL_PATH_PREFIX}") - relative_path = artifact_path[len(VIRTUAL_PATH_PREFIX) :].lstrip("/") - - # Build the actual path - base_dir = Path(os.getcwd()) / THREAD_DATA_BASE_DIR / thread_id / "user-data" - actual_path = base_dir / relative_path - - # Security check: ensure the path is within the thread's user-data directory - try: - actual_path = actual_path.resolve() - base_dir = base_dir.resolve() - if not str(actual_path).startswith(str(base_dir)): - raise HTTPException(status_code=403, detail="Access denied: path traversal detected") - except (ValueError, RuntimeError): - raise HTTPException(status_code=400, detail="Invalid path") - - return actual_path - - def is_text_file_by_content(path: Path, sample_size: int = 8192) -> bool: """Check if file is text by examining content for null bytes.""" try: @@ -62,66 +24,38 @@ def is_text_file_by_content(path: Path, sample_size: int = 8192) -> bool: return False -def remove_citations_block(content: str) -> str: - """Remove ALL citations from markdown content. - - Removes: - - ... blocks (complete and incomplete) - - [cite-N] references - - Citation markdown links that were converted from [cite-N] - - This is used for downloads to provide clean markdown without any citation references. - - Args: - content: The markdown content that may contain citations blocks. - - Returns: - Clean content with all citations completely removed. - """ - if not content: - return content - - result = content - - # Step 1: Parse and extract citation URLs before removing blocks - citation_urls = set() - citations_pattern = r'([\s\S]*?)' - for match in re.finditer(citations_pattern, content): - citations_block = match.group(1) - # Extract URLs from JSON lines - import json - for line in citations_block.split('\n'): +def _extract_citation_urls(content: str) -> set[str]: + """Extract URLs from JSONL blocks. Format must match frontend core/citations/utils.ts.""" + urls: set[str] = set() + for match in re.finditer(r"([\s\S]*?)", content): + for line in match.group(1).split("\n"): line = line.strip() - if line.startswith('{'): + if line.startswith("{"): try: - citation = json.loads(line) - if 'url' in citation: - citation_urls.add(citation['url']) + obj = json.loads(line) + if "url" in obj: + urls.add(obj["url"]) except (json.JSONDecodeError, ValueError): pass - - # Step 2: Remove complete citations blocks - result = re.sub(r'[\s\S]*?', '', result) - - # Step 3: Remove incomplete citations blocks (at end of content during streaming) + return urls + + +def remove_citations_block(content: str) -> str: + """Remove ALL citations from markdown (blocks, [cite-N], and citation links). Used for downloads.""" + if not content: + return content + + citation_urls = _extract_citation_urls(content) + + result = re.sub(r"[\s\S]*?", "", content) if "" in result: - result = re.sub(r'[\s\S]*$', '', result) - - # Step 4: Remove all [cite-N] references - result = re.sub(r'\[cite-\d+\]', '', result) - - # Step 5: Remove markdown links that point to citation URLs - # Pattern: [text](url) - if citation_urls: - for url in citation_urls: - # Escape special regex characters in URL - escaped_url = re.escape(url) - result = re.sub(rf'\[[^\]]+\]\({escaped_url}\)', '', result) - - # Step 6: Clean up extra whitespace and newlines - result = re.sub(r'\n{3,}', '\n\n', result) # Replace 3+ newlines with 2 - - return result.strip() + result = re.sub(r"[\s\S]*$", "", result) + result = re.sub(r"\[cite-\d+\]", "", result) + + for url in citation_urls: + result = re.sub(rf"\[[^\]]+\]\({re.escape(url)}\)", "", result) + + return re.sub(r"\n{3,}", "\n\n", result).strip() def _extract_file_from_skill_archive(zip_path: Path, internal_path: str) -> bytes | None: @@ -200,7 +134,7 @@ async def get_artifact(thread_id: str, path: str, request: Request) -> FileRespo skill_file_path = path[: marker_pos + len(".skill")] # e.g., "mnt/user-data/outputs/my-skill.skill" internal_path = path[marker_pos + len(skill_marker) :] # e.g., "SKILL.md" - actual_skill_path = _resolve_artifact_path(thread_id, skill_file_path) + actual_skill_path = resolve_thread_virtual_path(thread_id, skill_file_path) if not actual_skill_path.exists(): raise HTTPException(status_code=404, detail=f"Skill file not found: {skill_file_path}") @@ -226,7 +160,7 @@ async def get_artifact(thread_id: str, path: str, request: Request) -> FileRespo except UnicodeDecodeError: return Response(content=content, media_type=mime_type or "application/octet-stream", headers=cache_headers) - actual_path = _resolve_artifact_path(thread_id, path) + actual_path = resolve_thread_virtual_path(thread_id, path) if not actual_path.exists(): raise HTTPException(status_code=404, detail=f"Artifact not found: {path}") diff --git a/backend/src/gateway/routers/skills.py b/backend/src/gateway/routers/skills.py index 67bca69..11c5356 100644 --- a/backend/src/gateway/routers/skills.py +++ b/backend/src/gateway/routers/skills.py @@ -1,6 +1,5 @@ import json import logging -import os import re import shutil import tempfile @@ -12,6 +11,7 @@ from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field from src.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config +from src.gateway.path_utils import resolve_thread_virtual_path from src.skills import Skill, load_skills from src.skills.loader import get_skills_root_path @@ -56,53 +56,10 @@ class SkillInstallResponse(BaseModel): message: str = Field(..., description="Installation result message") -# Base directory for thread data (relative to backend/) -THREAD_DATA_BASE_DIR = ".deer-flow/threads" - -# Virtual path prefix used in sandbox environments (without leading slash for URL path matching) -VIRTUAL_PATH_PREFIX = "mnt/user-data" - # Allowed properties in SKILL.md frontmatter ALLOWED_FRONTMATTER_PROPERTIES = {"name", "description", "license", "allowed-tools", "metadata"} -def _resolve_skill_file_path(thread_id: str, virtual_path: str) -> Path: - """Resolve a virtual skill file path to the actual filesystem path. - - Args: - thread_id: The thread ID. - virtual_path: The virtual path (e.g., mnt/user-data/outputs/my-skill.skill). - - Returns: - The resolved filesystem path. - - Raises: - HTTPException: If the path is invalid or outside allowed directories. - """ - # Remove leading slash if present - virtual_path = virtual_path.lstrip("/") - - # Validate and remove virtual path prefix - if not virtual_path.startswith(VIRTUAL_PATH_PREFIX): - raise HTTPException(status_code=400, detail=f"Path must start with /{VIRTUAL_PATH_PREFIX}") - relative_path = virtual_path[len(VIRTUAL_PATH_PREFIX) :].lstrip("/") - - # Build the actual path - base_dir = Path(os.getcwd()) / THREAD_DATA_BASE_DIR / thread_id / "user-data" - actual_path = base_dir / relative_path - - # Security check: ensure the path is within the thread's user-data directory - try: - actual_path = actual_path.resolve() - base_dir_resolved = base_dir.resolve() - if not str(actual_path).startswith(str(base_dir_resolved)): - raise HTTPException(status_code=403, detail="Access denied: path traversal detected") - except (ValueError, RuntimeError): - raise HTTPException(status_code=400, detail="Invalid path") - - return actual_path - - def _validate_skill_frontmatter(skill_dir: Path) -> tuple[bool, str, str | None]: """Validate a skill directory's SKILL.md frontmatter. @@ -414,7 +371,7 @@ async def install_skill(request: SkillInstallRequest) -> SkillInstallResponse: """ try: # Resolve the virtual path to actual file path - skill_file_path = _resolve_skill_file_path(request.thread_id, request.path) + skill_file_path = resolve_thread_virtual_path(request.thread_id, request.path) # Check if file exists if not skill_file_path.exists(): diff --git a/backend/src/subagents/builtins/general_purpose.py b/backend/src/subagents/builtins/general_purpose.py index 1ab6562..0854422 100644 --- a/backend/src/subagents/builtins/general_purpose.py +++ b/backend/src/subagents/builtins/general_purpose.py @@ -24,10 +24,21 @@ Do NOT use for simple, single-step operations.""", - Do NOT ask for clarification - work with the information provided + +If you used web_search (or similar) and cite sources, ALWAYS include citations in your output: +1. Start with a `` block in JSONL format listing all sources (one JSON object per line) +2. In content, use FULL markdown link format: [Short Title](full_url) +- Every citation MUST be a complete markdown link with URL: [Title](https://...) +- Example block: + +{"id": "cite-1", "title": "...", "url": "https://...", "snippet": "..."} + + + When you complete the task, provide: 1. A brief summary of what was accomplished -2. Key findings or results +2. Key findings or results (with citation links when from web search) 3. Any relevant file paths, data, or artifacts created 4. Issues encountered (if any) diff --git a/frontend/src/core/citations/utils.ts b/frontend/src/core/citations/utils.ts index d2384a4..965c2a6 100644 --- a/frontend/src/core/citations/utils.ts +++ b/frontend/src/core/citations/utils.ts @@ -187,44 +187,25 @@ export function isCitationsBlockIncomplete(content: string): boolean { /** * Remove ALL citations from content, including: * - blocks - * - [cite-N] references - * - Citation markdown links that were converted from [cite-N] - * - * This is used for copy/download operations where we want clean content without any references. + * - [cite-N] references (and their converted markdown links) + * + * Uses parseCitations once, then strips citation links from cleanContent. + * Used for copy/download to produce content without any citation references. * * @param content - The raw content that may contain citations * @returns Content with all citations completely removed */ export function removeAllCitations(content: string): string { - if (!content) { - return content; - } + if (!content) return content; - // Step 1: Remove all blocks (complete and incomplete) - let result = removeCitationsBlocks(content); - - // Step 2: Remove all [cite-N] references - result = result.replace(/\[cite-\d+\]/g, ""); - - // Step 3: Parse to find citation URLs and remove those specific links const parsed = parseCitations(content); - const citationUrls = new Set(parsed.citations.map(c => c.url)); - - // Remove markdown links that point to citation URLs - // Pattern: [text](url) - result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => { - // If this URL is a citation, remove the entire link - if (citationUrls.has(url)) { - return ""; - } - // Keep non-citation links - return match; - }); + const citationUrls = new Set(parsed.citations.map((c) => c.url)); - // Step 4: Clean up extra whitespace and newlines - result = result - .replace(/\n{3,}/g, "\n\n") // Replace 3+ newlines with 2 - .trim(); + // Remove markdown links that point to citation URLs; keep non-citation links + const withoutLinks = parsed.cleanContent.replace( + /\[([^\]]+)\]\(([^)]+)\)/g, + (fullMatch, _text, url) => (citationUrls.has(url) ? "" : fullMatch), + ); - return result; + return withoutLinks.replace(/\n{3,}/g, "\n\n").trim(); } From 1c9a969a70ea9316d413c51c90ea42937ec4ec12 Mon Sep 17 00:00:00 2001 From: LofiSu Date: Mon, 9 Feb 2026 13:02:54 +0800 Subject: [PATCH 9/9] i18n(zh-CN): keep Pro and Ultra as English in mode labels Co-authored-by: Cursor --- frontend/src/core/i18n/locales/zh-CN.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index faa0210..6f10f68 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -77,7 +77,7 @@ export const zhCN: Translations = { reasoningModeDescription: "思考后再行动,在时间与准确性之间取得平衡", proMode: "Pro", proModeDescription: "思考、计划再执行,获得更精准的结果,可能需要更多时间", - ultraMode: "超级", + ultraMode: "Ultra", ultraModeDescription: "思考、计划并执行,可调用子代理分工协作,适合复杂多步骤任务,能力最强", searchModels: "搜索模型...",