feat: add support for citation preview (#3354)

* feat: add support for citation preview

#3217

* feat(MessageContent): Add HTML entity encoding to enhance the security of quoted data

* fix(MessageContent): recognize citation format like `[[1]]`
This commit is contained in:
shiquda 2025-03-18 16:48:07 +08:00 committed by GitHub
parent 570d6aeaf1
commit 60eb08a982
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 220 additions and 10 deletions

View File

@ -0,0 +1,109 @@
import Favicon from '@renderer/components/Icons/FallbackFavicon'
import { Tooltip } from 'antd'
import React from 'react'
import styled from 'styled-components'
interface CitationTooltipProps {
children: React.ReactNode
citation: {
url: string
title?: string
content?: string
}
}
const CitationTooltip: React.FC<CitationTooltipProps> = ({ children, citation }) => {
let hostname = ''
try {
hostname = new URL(citation.url).hostname
} catch {
hostname = citation.url
}
// 自定义悬浮卡片内容
const tooltipContent = (
<TooltipContentWrapper>
<TooltipHeader onClick={() => window.open(citation.url, '_blank')}>
<Favicon hostname={hostname} alt={citation.title || hostname} />
<TooltipTitle title={citation.title || hostname}>{citation.title || hostname}</TooltipTitle>
</TooltipHeader>
{citation.content && <TooltipBody>{citation.content}</TooltipBody>}
<TooltipFooter onClick={() => window.open(citation.url, '_blank')}>{hostname}</TooltipFooter>
</TooltipContentWrapper>
)
return (
<StyledTooltip
title={tooltipContent}
placement="top"
arrow={false}
overlayInnerStyle={{
padding: 0,
borderRadius: '8px'
}}>
{children}
</StyledTooltip>
)
}
// 使用styled-components来自定义Tooltip的样式包括箭头
const StyledTooltip = styled(Tooltip)`
.ant-tooltip-arrow {
.ant-tooltip-arrow-content {
background-color: var(--color-background-1);
}
}
`
const TooltipContentWrapper = styled.div`
padding: 12px;
background-color: var(--color-background-soft);
border-radius: 8px;
`
const TooltipHeader = styled.div`
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
cursor: pointer;
&:hover {
opacity: 0.8;
}
`
const TooltipTitle = styled.div`
color: var(--color-text-1);
font-size: 14px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`
const TooltipBody = styled.div`
font-size: 13px;
line-height: 1.5;
margin-bottom: 8px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
color: var(--color-text-2);
`
const TooltipFooter = styled.div`
font-size: 12px;
color: var(--color-link);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
&:hover {
text-decoration: underline;
}
`
export default CitationTooltip

View File

@ -1,12 +1,54 @@
import { omit } from 'lodash' import { omit } from 'lodash'
import React from 'react' import React from 'react'
const Link: React.FC = (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => { import CitationTooltip from './CitationTooltip'
interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
node?: any
citationData?: {
url: string
title?: string
content?: string
}
}
const Link: React.FC<LinkProps> = (props) => {
// 处理内部链接
if (props.href?.startsWith('#')) { if (props.href?.startsWith('#')) {
return <span className="link">{props.children}</span> return <span className="link">{props.children}</span>
} }
return <a {...omit(props, 'node')} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()} /> // 包含<sup>标签表示是一个引用链接
const isCitation = React.Children.toArray(props.children).some((child) => {
if (typeof child === 'object' && 'type' in child) {
return child.type === 'sup'
}
return false
})
// 如果是引用链接并且有引用数据则使用CitationTooltip
if (isCitation && props.citationData) {
return (
<CitationTooltip citation={props.citationData}>
<a
{...omit(props, ['node', 'citationData'])}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
/>
</CitationTooltip>
)
}
// 普通链接
return (
<a
{...omit(props, ['node', 'citationData'])}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
/>
)
} }
export default Link export default Link

View File

@ -26,9 +26,17 @@ const ALLOWED_ELEMENTS =
interface Props { interface Props {
message: Message message: Message
citationsData?: Map<
string,
{
url: string
title?: string
content?: string
}
>
} }
const Markdown: FC<Props> = ({ message }) => { const Markdown: FC<Props> = ({ message, citationsData }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { renderInputMessageAsMarkdown, mathEngine } = useSettings() const { renderInputMessageAsMarkdown, mathEngine } = useSettings()
@ -48,7 +56,12 @@ const Markdown: FC<Props> = ({ message }) => {
const components = useCallback(() => { const components = useCallback(() => {
const baseComponents = { const baseComponents = {
a: Link, a: (props: any) => {
if (props.href && citationsData?.has(props.href)) {
return <Link {...props} citationData={citationsData.get(props.href)} />
}
return <Link {...props} />
},
code: CodeBlock, code: CodeBlock,
img: ImagePreview img: ImagePreview
} as Partial<Components> } as Partial<Components>
@ -58,7 +71,7 @@ const Markdown: FC<Props> = ({ message }) => {
} }
return baseComponents return baseComponents
}, [messageContent]) }, [messageContent, citationsData])
if (message.role === 'user' && !renderInputMessageAsMarkdown) { if (message.role === 'user' && !renderInputMessageAsMarkdown) {
return <p style={{ marginBottom: 5, whiteSpace: 'pre-wrap' }}>{messageContent}</p> return <p style={{ marginBottom: 5, whiteSpace: 'pre-wrap' }}>{messageContent}</p>

View File

@ -29,6 +29,50 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
const { t } = useTranslation() const { t } = useTranslation()
const message = withMessageThought(clone(_message)) const message = withMessageThought(clone(_message))
// HTML实体编码辅助函数
const encodeHTML = (str: string) => {
return str.replace(/[&<>"']/g, (match) => {
const entities: { [key: string]: string } = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&apos;'
}
return entities[match]
})
}
// 获取引用数据
const citationsData = useMemo(() => {
const searchResults = message?.metadata?.webSearch?.results || []
const citationsUrls = message?.metadata?.citations || []
// 合并引用数据
const data = new Map()
// 添加webSearch结果
searchResults.forEach((result) => {
data.set(result.url, {
url: result.url,
title: result.title,
content: result.content
})
})
// 添加citations
citationsUrls.forEach((url) => {
if (!data.has(url)) {
data.set(url, {
url: url
// 如果没有title和content将在CitationTooltip中显示hostname
})
}
})
return data
}, [message.metadata?.citations, message.metadata?.webSearch?.results])
// Process content to make citation numbers clickable // Process content to make citation numbers clickable
const processedContent = useMemo(() => { const processedContent = useMemo(() => {
if (!(message.metadata?.citations || message.metadata?.webSearch)) { if (!(message.metadata?.citations || message.metadata?.webSearch)) {
@ -42,18 +86,20 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
const citations = message?.metadata?.citations || searchResultsCitations const citations = message?.metadata?.citations || searchResultsCitations
// Convert [n] format to superscript numbers and make them clickable // Convert [n] format to superscript numbers and make them clickable
// Use <sup> tag for superscript and make it a link // Use <sup> tag for superscript and make it a link with citation data
content = content.replace(/\[(\d+)\]/g, (match, num) => { content = content.replace(/\[\[(\d+)\]\]|\[(\d+)\]/g, (match, num1, num2) => {
const num = num1 || num2
const index = parseInt(num) - 1 const index = parseInt(num) - 1
if (index >= 0 && index < citations.length) { if (index >= 0 && index < citations.length) {
const link = citations[index] const link = citations[index]
return link ? `[<sup>${num}</sup>](${link})` : `<sup>${num}</sup>` const citationData = link ? encodeHTML(JSON.stringify(citationsData.get(link) || { url: link })) : null
return link ? `[<sup data-citation='${citationData}'>${num}</sup>](${link})` : `<sup>${num}</sup>`
} }
return match return match
}) })
return content return content
}, [message.content, message.metadata]) }, [message.content, message.metadata, citationsData])
// Format citations for display // Format citations for display
const formattedCitations = useMemo(() => { const formattedCitations = useMemo(() => {
@ -103,7 +149,7 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
</Flex> </Flex>
<MessageThought message={message} /> <MessageThought message={message} />
<MessageTools message={message} /> <MessageTools message={message} />
<Markdown message={{ ...message, content: processedContent }} /> <Markdown message={{ ...message, content: processedContent }} citationsData={citationsData} />
{message.translatedContent && ( {message.translatedContent && (
<Fragment> <Fragment>
<Divider style={{ margin: 0, marginBottom: 10 }}> <Divider style={{ margin: 0, marginBottom: 10 }}>