From 2f50e5d96946859127a202d26b625ef3ba624487 Mon Sep 17 00:00:00 2001 From: LofiSu Date: Mon, 9 Feb 2026 21:40:20 +0800 Subject: [PATCH] feat(citations): inline citation links with [citation:Title](URL) - Backend: add citation format to lead_agent and general_purpose prompts - Add CitationLink component (Badge + HoverCard) for citation cards - MarkdownContent: detect citation: prefix in link text, render CitationLink - Message/artifact/subtask: use MarkdownContent or Streamdown with CitationLink - message-list-item: pass img via components prop (remove isHuman/img) - message-group, subtask-card: drop unused imports; fix import order (lint) Co-authored-by: Cursor --- backend/src/agents/lead_agent/prompt.py | 1 + .../src/subagents/builtins/general_purpose.py | 1 + .../app/workspace/chats/[thread_id]/page.tsx | 2 +- .../artifacts/artifact-file-detail.tsx | 7 +- .../workspace/citations/citation-link.tsx | 79 +++++++++++++++++++ .../workspace/messages/markdown-content.tsx | 29 +++++-- .../workspace/messages/message-group.tsx | 2 - .../workspace/messages/message-list-item.tsx | 18 +++-- .../workspace/messages/message-list.tsx | 4 +- .../workspace/messages/subtask-card.tsx | 11 +-- .../components/workspace/mode-hover-guide.tsx | 6 +- 11 files changed, 133 insertions(+), 27 deletions(-) create mode 100644 frontend/src/components/workspace/citations/citation-link.tsx diff --git a/backend/src/agents/lead_agent/prompt.py b/backend/src/agents/lead_agent/prompt.py index 2dea977..2660170 100644 --- a/backend/src/agents/lead_agent/prompt.py +++ b/backend/src/agents/lead_agent/prompt.py @@ -259,6 +259,7 @@ You have access to skills that provide optimized workflows for specific tasks. E - Clear and Concise: Avoid over-formatting unless requested - Natural Tone: Use paragraphs and prose, not bullet points by default - Action-Oriented: Focus on delivering results, not explaining processes +- Citations: Use `[citation:Title](URL)` format for external sources diff --git a/backend/src/subagents/builtins/general_purpose.py b/backend/src/subagents/builtins/general_purpose.py index 1ab6562..2ffad29 100644 --- a/backend/src/subagents/builtins/general_purpose.py +++ b/backend/src/subagents/builtins/general_purpose.py @@ -30,6 +30,7 @@ When you complete the task, provide: 2. Key findings or results 3. Any relevant file paths, data, or artifacts created 4. Issues encountered (if any) +5. Citations: Use `[citation:Title](URL)` format for external sources diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index 5d39207..e2ae9f8 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -1,5 +1,6 @@ "use client"; +import type { Message } from "@langchain/langgraph-sdk"; import { FilesIcon, XIcon } from "lucide-react"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -30,7 +31,6 @@ import { useNotification } from "@/core/notification/hooks"; import { useLocalSettings } from "@/core/settings"; import { type AgentThread, type AgentThreadState } from "@/core/threads"; import { useSubmitThread, useThreadStream } from "@/core/threads/hooks"; -import type { Message } from "@langchain/langgraph-sdk"; import { pathOfThread, textOfMessage, diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx index 3ab8608..00d1290 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -38,6 +38,7 @@ import { checkCodeFile, getFileName } from "@/core/utils/files"; import { env } from "@/env"; import { cn } from "@/lib/utils"; +import { CitationLink } from "../citations/citation-link"; import { Tooltip } from "../tooltip"; import { useArtifacts } from "./context"; @@ -274,7 +275,11 @@ export function ArtifactFilePreview({ if (language === "markdown") { return (
- + {content ?? ""}
diff --git a/frontend/src/components/workspace/citations/citation-link.tsx b/frontend/src/components/workspace/citations/citation-link.tsx new file mode 100644 index 0000000..72894e2 --- /dev/null +++ b/frontend/src/components/workspace/citations/citation-link.tsx @@ -0,0 +1,79 @@ +import { ExternalLinkIcon } from "lucide-react"; +import type { ComponentProps } from "react"; + +import { Badge } from "@/components/ui/badge"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { cn } from "@/lib/utils"; + +export function CitationLink({ + href, + children, + ...props +}: ComponentProps<"a">) { + const domain = extractDomain(href ?? ""); + + // Priority: children > domain + const childrenText = typeof children === "string" ? children : null; + const isGenericText = childrenText === "Source" || childrenText === "来源"; + const displayText = (!isGenericText && childrenText) ?? domain; + + return ( + + + e.stopPropagation()} + {...props} + > + + {displayText} + + + + + +
+
+ {displayText && ( +

+ {displayText} +

+ )} + {href && ( +

+ {href} +

+ )} +
+ + Visit source + + +
+
+
+ ); +} + +function extractDomain(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./i, ""); + } catch { + return url; + } +} diff --git a/frontend/src/components/workspace/messages/markdown-content.tsx b/frontend/src/components/workspace/messages/markdown-content.tsx index 12465de..2e240d8 100644 --- a/frontend/src/components/workspace/messages/markdown-content.tsx +++ b/frontend/src/components/workspace/messages/markdown-content.tsx @@ -1,7 +1,7 @@ "use client"; -import type { ImgHTMLAttributes } from "react"; -import type { ReactNode } from "react"; +import { useMemo } from "react"; +import type { HTMLAttributes } from "react"; import { MessageResponse, @@ -9,14 +9,15 @@ import { } from "@/components/ai-elements/message"; import { streamdownPlugins } from "@/core/streamdown"; +import { CitationLink } from "../citations/citation-link"; + export type MarkdownContentProps = { content: string; isLoading: boolean; rehypePlugins: MessageResponseProps["rehypePlugins"]; className?: string; remarkPlugins?: MessageResponseProps["remarkPlugins"]; - isHuman?: boolean; - img?: (props: ImgHTMLAttributes & { threadId?: string; maxWidth?: string }) => ReactNode; + components?: MessageResponseProps["components"]; }; /** Renders markdown content. */ @@ -25,10 +26,26 @@ export function MarkdownContent({ rehypePlugins, className, remarkPlugins = streamdownPlugins.remarkPlugins, - img, + components: componentsFromProps, }: MarkdownContentProps) { + const components = useMemo(() => { + return { + a: (props: HTMLAttributes) => { + if (typeof props.children === "string") { + const match = /^citation:(.+)$/.exec(props.children); + if (match) { + const [, text] = match; + return {text}; + } + } + return ; + }, + ...componentsFromProps, + }; + }, [componentsFromProps]); + if (!content) return null; - const components = img ? { img } : undefined; + return ( ( - - )} + components={{ + img: (props) => ( + + ), + }} /> ); diff --git a/frontend/src/components/workspace/messages/message-list.tsx b/frontend/src/components/workspace/messages/message-list.tsx index 9c0b1b0..8f577fd 100644 --- a/frontend/src/components/workspace/messages/message-list.tsx +++ b/frontend/src/components/workspace/messages/message-list.tsx @@ -1,3 +1,4 @@ +import type { Message } from "@langchain/langgraph-sdk"; import type { UseStream } from "@langchain/langgraph-sdk/react"; import { @@ -18,15 +19,14 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import type { Subtask } from "@/core/tasks"; import { useUpdateSubtask } from "@/core/tasks/context"; import type { AgentThreadState } from "@/core/threads"; -import type { Message } from "@langchain/langgraph-sdk"; import { cn } from "@/lib/utils"; import { ArtifactFileList } from "../artifacts/artifact-file-list"; import { StreamingIndicator } from "../streaming-indicator"; +import { MarkdownContent } from "./markdown-content"; import { MessageGroup } from "./message-group"; import { MessageListItem } from "./message-list-item"; -import { MarkdownContent } from "./markdown-content"; import { MessageListSkeleton } from "./skeleton"; import { SubtaskCard } from "./subtask-card"; diff --git a/frontend/src/components/workspace/messages/subtask-card.tsx b/frontend/src/components/workspace/messages/subtask-card.tsx index 128d032..eb88eb1 100644 --- a/frontend/src/components/workspace/messages/subtask-card.tsx +++ b/frontend/src/components/workspace/messages/subtask-card.tsx @@ -19,14 +19,12 @@ import { ShineBorder } from "@/components/ui/shine-border"; import { useI18n } from "@/core/i18n/hooks"; import { hasToolCalls } from "@/core/messages/utils"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; -import { - streamdownPlugins, - streamdownPluginsWithWordAnimation, -} from "@/core/streamdown"; +import { streamdownPluginsWithWordAnimation } from "@/core/streamdown"; import { useSubtask } from "@/core/tasks/context"; import { explainLastToolCall } from "@/core/tools/utils"; import { cn } from "@/lib/utils"; +import { CitationLink } from "../citations/citation-link"; import { FlipDisplay } from "../flip-display"; import { MarkdownContent } from "./markdown-content"; @@ -128,7 +126,10 @@ export function SubtaskCard({ {task.prompt && ( + {task.prompt} } diff --git a/frontend/src/components/workspace/mode-hover-guide.tsx b/frontend/src/components/workspace/mode-hover-guide.tsx index e78e82b..6bd12d4 100644 --- a/frontend/src/components/workspace/mode-hover-guide.tsx +++ b/frontend/src/components/workspace/mode-hover-guide.tsx @@ -1,6 +1,8 @@ "use client"; import { useI18n } from "@/core/i18n/hooks"; +import type { Translations } from "@/core/i18n/locales/types"; + import { Tooltip } from "./tooltip"; export type AgentMode = "flash" | "thinking" | "pro" | "ultra"; @@ -8,7 +10,7 @@ export type AgentMode = "flash" | "thinking" | "pro" | "ultra"; function getModeLabelKey( mode: AgentMode, ): keyof Pick< - import("@/core/i18n/locales/types").Translations["inputBox"], + Translations["inputBox"], "flashMode" | "reasoningMode" | "proMode" | "ultraMode" > { switch (mode) { @@ -26,7 +28,7 @@ function getModeLabelKey( function getModeDescriptionKey( mode: AgentMode, ): keyof Pick< - import("@/core/i18n/locales/types").Translations["inputBox"], + Translations["inputBox"], "flashModeDescription" | "reasoningModeDescription" | "proModeDescription" | "ultraModeDescription" > { switch (mode) {