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 <cursoragent@cursor.com>
This commit is contained in:
LofiSu
2026-02-09 21:40:20 +08:00
parent 715d7436f1
commit 2f50e5d969
11 changed files with 133 additions and 27 deletions

View File

@@ -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 - Clear and Concise: Avoid over-formatting unless requested
- Natural Tone: Use paragraphs and prose, not bullet points by default - Natural Tone: Use paragraphs and prose, not bullet points by default
- Action-Oriented: Focus on delivering results, not explaining processes - Action-Oriented: Focus on delivering results, not explaining processes
- Citations: Use `[citation:Title](URL)` format for external sources
</response_style> </response_style>
<critical_reminders> <critical_reminders>

View File

@@ -30,6 +30,7 @@ When you complete the task, provide:
2. Key findings or results 2. Key findings or results
3. Any relevant file paths, data, or artifacts created 3. Any relevant file paths, data, or artifacts created
4. Issues encountered (if any) 4. Issues encountered (if any)
5. Citations: Use `[citation:Title](URL)` format for external sources
</output_format> </output_format>
<working_directory> <working_directory>

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import type { Message } from "@langchain/langgraph-sdk";
import { FilesIcon, XIcon } from "lucide-react"; import { FilesIcon, XIcon } from "lucide-react";
import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
@@ -30,7 +31,6 @@ import { useNotification } from "@/core/notification/hooks";
import { useLocalSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
import { type AgentThread, type AgentThreadState } from "@/core/threads"; import { type AgentThread, type AgentThreadState } from "@/core/threads";
import { useSubmitThread, useThreadStream } from "@/core/threads/hooks"; import { useSubmitThread, useThreadStream } from "@/core/threads/hooks";
import type { Message } from "@langchain/langgraph-sdk";
import { import {
pathOfThread, pathOfThread,
textOfMessage, textOfMessage,

View File

@@ -38,6 +38,7 @@ import { checkCodeFile, getFileName } from "@/core/utils/files";
import { env } from "@/env"; import { env } from "@/env";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CitationLink } from "../citations/citation-link";
import { Tooltip } from "../tooltip"; import { Tooltip } from "../tooltip";
import { useArtifacts } from "./context"; import { useArtifacts } from "./context";
@@ -274,7 +275,11 @@ export function ArtifactFilePreview({
if (language === "markdown") { if (language === "markdown") {
return ( return (
<div className="size-full px-4"> <div className="size-full px-4">
<Streamdown className="size-full" {...streamdownPlugins}> <Streamdown
className="size-full"
{...streamdownPlugins}
components={{ a: CitationLink }}
>
{content ?? ""} {content ?? ""}
</Streamdown> </Streamdown>
</div> </div>

View File

@@ -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 (
<HoverCard closeDelay={0} openDelay={0}>
<HoverCardTrigger asChild>
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center"
onClick={(e) => e.stopPropagation()}
{...props}
>
<Badge
variant="secondary"
className="hover:bg-secondary/80 mx-0.5 cursor-pointer gap-1 rounded-full px-2 py-0.5 text-xs font-normal"
>
{displayText}
<ExternalLinkIcon className="size-3" />
</Badge>
</a>
</HoverCardTrigger>
<HoverCardContent className={cn("relative w-80 p-0", props.className)}>
<div className="p-3">
<div className="space-y-1">
{displayText && (
<h4 className="truncate font-medium text-sm leading-tight">
{displayText}
</h4>
)}
{href && (
<p className="truncate break-all text-muted-foreground text-xs">
{href}
</p>
)}
</div>
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary mt-2 inline-flex items-center gap-1 text-xs hover:underline"
>
Visit source
<ExternalLinkIcon className="size-3" />
</a>
</div>
</HoverCardContent>
</HoverCard>
);
}
function extractDomain(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./i, "");
} catch {
return url;
}
}

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import type { ImgHTMLAttributes } from "react"; import { useMemo } from "react";
import type { ReactNode } from "react"; import type { HTMLAttributes } from "react";
import { import {
MessageResponse, MessageResponse,
@@ -9,14 +9,15 @@ import {
} from "@/components/ai-elements/message"; } from "@/components/ai-elements/message";
import { streamdownPlugins } from "@/core/streamdown"; import { streamdownPlugins } from "@/core/streamdown";
import { CitationLink } from "../citations/citation-link";
export type MarkdownContentProps = { export type MarkdownContentProps = {
content: string; content: string;
isLoading: boolean; isLoading: boolean;
rehypePlugins: MessageResponseProps["rehypePlugins"]; rehypePlugins: MessageResponseProps["rehypePlugins"];
className?: string; className?: string;
remarkPlugins?: MessageResponseProps["remarkPlugins"]; remarkPlugins?: MessageResponseProps["remarkPlugins"];
isHuman?: boolean; components?: MessageResponseProps["components"];
img?: (props: ImgHTMLAttributes<HTMLImageElement> & { threadId?: string; maxWidth?: string }) => ReactNode;
}; };
/** Renders markdown content. */ /** Renders markdown content. */
@@ -25,10 +26,26 @@ export function MarkdownContent({
rehypePlugins, rehypePlugins,
className, className,
remarkPlugins = streamdownPlugins.remarkPlugins, remarkPlugins = streamdownPlugins.remarkPlugins,
img, components: componentsFromProps,
}: MarkdownContentProps) { }: MarkdownContentProps) {
const components = useMemo(() => {
return {
a: (props: HTMLAttributes<HTMLAnchorElement>) => {
if (typeof props.children === "string") {
const match = /^citation:(.+)$/.exec(props.children);
if (match) {
const [, text] = match;
return <CitationLink {...props}>{text}</CitationLink>;
}
}
return <a {...props} />;
},
...componentsFromProps,
};
}, [componentsFromProps]);
if (!content) return null; if (!content) return null;
const components = img ? { img } : undefined;
return ( return (
<MessageResponse <MessageResponse
className={className} className={className}

View File

@@ -22,7 +22,6 @@ import {
ChainOfThoughtStep, ChainOfThoughtStep,
} from "@/components/ai-elements/chain-of-thought"; } from "@/components/ai-elements/chain-of-thought";
import { CodeBlock } from "@/components/ai-elements/code-block"; import { CodeBlock } from "@/components/ai-elements/code-block";
import { MessageResponse } from "@/components/ai-elements/message";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
@@ -30,7 +29,6 @@ import {
findToolCallResult, findToolCallResult,
} from "@/core/messages/utils"; } from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { streamdownPlugins } from "@/core/streamdown";
import { extractTitleFromMarkdown } from "@/core/utils/markdown"; import { extractTitleFromMarkdown } from "@/core/utils/markdown";
import { env } from "@/env"; import { env } from "@/env";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";

View File

@@ -23,6 +23,7 @@ import { humanMessagePlugins } from "@/core/streamdown";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CopyButton } from "../copy-button"; import { CopyButton } from "../copy-button";
import { MarkdownContent } from "./markdown-content"; import { MarkdownContent } from "./markdown-content";
export function MessageListItem({ export function MessageListItem({
@@ -158,14 +159,15 @@ function MessageContent_({
isLoading={isLoading} isLoading={isLoading}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]} rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
className="my-3" className="my-3"
isHuman={false} components={{
img={(props) => ( img: (props) => (
<MessageImage <MessageImage
{...props} {...props}
threadId={thread_id} threadId={thread_id}
maxWidth="90%" maxWidth="90%"
/> />
)} ),
}}
/> />
</AIElementMessageContent> </AIElementMessageContent>
); );

View File

@@ -1,3 +1,4 @@
import type { Message } from "@langchain/langgraph-sdk";
import type { UseStream } from "@langchain/langgraph-sdk/react"; import type { UseStream } from "@langchain/langgraph-sdk/react";
import { import {
@@ -18,15 +19,14 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import type { Subtask } from "@/core/tasks"; import type { Subtask } from "@/core/tasks";
import { useUpdateSubtask } from "@/core/tasks/context"; import { useUpdateSubtask } from "@/core/tasks/context";
import type { AgentThreadState } from "@/core/threads"; import type { AgentThreadState } from "@/core/threads";
import type { Message } from "@langchain/langgraph-sdk";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ArtifactFileList } from "../artifacts/artifact-file-list"; import { ArtifactFileList } from "../artifacts/artifact-file-list";
import { StreamingIndicator } from "../streaming-indicator"; import { StreamingIndicator } from "../streaming-indicator";
import { MarkdownContent } from "./markdown-content";
import { MessageGroup } from "./message-group"; import { MessageGroup } from "./message-group";
import { MessageListItem } from "./message-list-item"; import { MessageListItem } from "./message-list-item";
import { MarkdownContent } from "./markdown-content";
import { MessageListSkeleton } from "./skeleton"; import { MessageListSkeleton } from "./skeleton";
import { SubtaskCard } from "./subtask-card"; import { SubtaskCard } from "./subtask-card";

View File

@@ -19,14 +19,12 @@ import { ShineBorder } from "@/components/ui/shine-border";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { hasToolCalls } from "@/core/messages/utils"; import { hasToolCalls } from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { import { streamdownPluginsWithWordAnimation } from "@/core/streamdown";
streamdownPlugins,
streamdownPluginsWithWordAnimation,
} from "@/core/streamdown";
import { useSubtask } from "@/core/tasks/context"; import { useSubtask } from "@/core/tasks/context";
import { explainLastToolCall } from "@/core/tools/utils"; import { explainLastToolCall } from "@/core/tools/utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CitationLink } from "../citations/citation-link";
import { FlipDisplay } from "../flip-display"; import { FlipDisplay } from "../flip-display";
import { MarkdownContent } from "./markdown-content"; import { MarkdownContent } from "./markdown-content";
@@ -128,7 +126,10 @@ export function SubtaskCard({
{task.prompt && ( {task.prompt && (
<ChainOfThoughtStep <ChainOfThoughtStep
label={ label={
<Streamdown {...streamdownPluginsWithWordAnimation}> <Streamdown
{...streamdownPluginsWithWordAnimation}
components={{ a: CitationLink }}
>
{task.prompt} {task.prompt}
</Streamdown> </Streamdown>
} }

View File

@@ -1,6 +1,8 @@
"use client"; "use client";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import type { Translations } from "@/core/i18n/locales/types";
import { Tooltip } from "./tooltip"; import { Tooltip } from "./tooltip";
export type AgentMode = "flash" | "thinking" | "pro" | "ultra"; export type AgentMode = "flash" | "thinking" | "pro" | "ultra";
@@ -8,7 +10,7 @@ export type AgentMode = "flash" | "thinking" | "pro" | "ultra";
function getModeLabelKey( function getModeLabelKey(
mode: AgentMode, mode: AgentMode,
): keyof Pick< ): keyof Pick<
import("@/core/i18n/locales/types").Translations["inputBox"], Translations["inputBox"],
"flashMode" | "reasoningMode" | "proMode" | "ultraMode" "flashMode" | "reasoningMode" | "proMode" | "ultraMode"
> { > {
switch (mode) { switch (mode) {
@@ -26,7 +28,7 @@ function getModeLabelKey(
function getModeDescriptionKey( function getModeDescriptionKey(
mode: AgentMode, mode: AgentMode,
): keyof Pick< ): keyof Pick<
import("@/core/i18n/locales/types").Translations["inputBox"], Translations["inputBox"],
"flashModeDescription" | "reasoningModeDescription" | "proModeDescription" | "ultraModeDescription" "flashModeDescription" | "reasoningModeDescription" | "proModeDescription" | "ultraModeDescription"
> { > {
switch (mode) { switch (mode) {