feat: add support for clickable citations in message content (#1381)

* Add support for clickable citations in message content

* update format
This commit is contained in:
lucifer9 2025-02-11 16:21:20 +08:00 committed by GitHub
parent 4d13a8d9c2
commit cb88a48d8b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 115 additions and 12 deletions

View File

@ -301,7 +301,8 @@
"error.notion.export": "Notion import failed", "error.notion.export": "Notion import failed",
"error.notion.no_api_key": "Notion ApiKey or Notion DatabaseID is not configured", "error.notion.no_api_key": "Notion ApiKey or Notion DatabaseID is not configured",
"success.notion.export": "Notion import successful", "success.notion.export": "Notion import successful",
"warn.notion.exporting": "Notion is importing, please do not import repeatedly" "warn.notion.exporting": "Notion is importing, please do not import repeatedly",
"citations": "References"
}, },
"minapp": { "minapp": {
"title": "MinApp", "title": "MinApp",

View File

@ -295,7 +295,8 @@
"error.notion.export": "Notion インポートに失敗", "error.notion.export": "Notion インポートに失敗",
"error.notion.no_api_key": "Notion ApiKey または Notion DatabaseID が設定されていません", "error.notion.no_api_key": "Notion ApiKey または Notion DatabaseID が設定されていません",
"success.notion.export": "Notion へのインポートに成功", "success.notion.export": "Notion へのインポートに成功",
"warn.notion.exporting": "Notion 正在インポート中です。重複インポートしないでください。" "warn.notion.exporting": "Notion 正在インポート中です。重複インポートしないでください。",
"citations": "参考文献"
}, },
"minapp": { "minapp": {
"title": "ミニアプリ", "title": "ミニアプリ",

View File

@ -296,7 +296,8 @@
"error.notion.export": "Импорт в Notion не удался", "error.notion.export": "Импорт в Notion не удался",
"error.notion.no_api_key": "Notion ApiKey или Notion DatabaseID не настроен", "error.notion.no_api_key": "Notion ApiKey или Notion DatabaseID не настроен",
"success.notion.export": "Импорт в Notion выполнен успешно", "success.notion.export": "Импорт в Notion выполнен успешно",
"warn.notion.exporting": "Идет импорт в Notion, пожалуйста, не повторяйте импорт" "warn.notion.exporting": "Идет импорт в Notion, пожалуйста, не повторяйте импорт",
"citations": "Источники"
}, },
"minapp": { "minapp": {
"title": "Встроенные приложения", "title": "Встроенные приложения",

View File

@ -302,8 +302,8 @@
"error.notion.export":"Notion 导入失败", "error.notion.export":"Notion 导入失败",
"error.notion.no_api_key":"未配置Notion ApiKey或Notion DatabaseID", "error.notion.no_api_key":"未配置Notion ApiKey或Notion DatabaseID",
"success.notion.export":"导入Notion成功", "success.notion.export":"导入Notion成功",
"warn.notion.exporting":"Notion正在导入请勿重复导入" "warn.notion.exporting":"Notion正在导入请勿重复导入",
"citations": "引用内容"
}, },
"minapp": { "minapp": {
"title": "小程序", "title": "小程序",

View File

@ -301,7 +301,8 @@
"error.notion.export": "Notion 匯入失敗", "error.notion.export": "Notion 匯入失敗",
"error.notion.no_api_key": "未配置 Notion ApiKey 或 Notion DatabaseID", "error.notion.no_api_key": "未配置 Notion ApiKey 或 Notion DatabaseID",
"success.notion.export": "匯入 Notion 成功", "success.notion.export": "匯入 Notion 成功",
"warn.notion.exporting": "Notion 正在匯入,請勿重複匯入" "warn.notion.exporting": "Notion 正在匯入,請勿重複匯入",
"citations": "參考文獻"
}, },
"minapp": { "minapp": {
"title": "小程序", "title": "小程序",

View File

@ -1,9 +1,9 @@
import { SyncOutlined, TranslationOutlined } from '@ant-design/icons' import { InfoCircleOutlined, SyncOutlined, TranslationOutlined } from '@ant-design/icons'
import { Message, Model } from '@renderer/types' import { Message, Model } from '@renderer/types'
import { getBriefInfo } from '@renderer/utils' import { getBriefInfo } from '@renderer/utils'
import { withMessageThought } from '@renderer/utils/formats' import { withMessageThought } from '@renderer/utils/formats'
import { Divider, Flex } from 'antd' import { Divider, Flex } from 'antd'
import React, { Fragment } from 'react' import React, { Fragment, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import BeatLoader from 'react-spinners/BeatLoader' import BeatLoader from 'react-spinners/BeatLoader'
import styled from 'styled-components' import styled from 'styled-components'
@ -23,6 +23,40 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
const { t } = useTranslation() const { t } = useTranslation()
const message = withMessageThought(_message) const message = withMessageThought(_message)
// Process content to make citation numbers clickable
const processedContent = useMemo(() => {
if (!message.content || !message.metadata?.citations) return message.content
let content = message.content
const citations = message.metadata.citations
// Convert [n] format to superscript numbers and make them clickable
content = content.replace(/\[(\d+)\]/g, (match, num) => {
const index = parseInt(num) - 1
if (index >= 0 && index < citations.length) {
// Use <sup> tag for superscript and make it a link
return `[<sup>${num}</sup>](${citations[index]})`
}
return match
})
return content
}, [message.content, message.metadata?.citations])
// Format citations for display
const formattedCitations = useMemo(() => {
if (!message.metadata?.citations?.length) return null
return message.metadata.citations.map((url, index) => {
try {
const hostname = new URL(url).hostname
return { number: index + 1, url, hostname }
} catch {
return { number: index + 1, url, hostname: url }
}
})
}, [message.metadata?.citations])
if (message.status === 'sending') { if (message.status === 'sending') {
return ( return (
<MessageContentLoading> <MessageContentLoading>
@ -46,7 +80,20 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
{message.mentions?.map((model) => <MentionTag key={model.id}>{'@' + model.name}</MentionTag>)} {message.mentions?.map((model) => <MentionTag key={model.id}>{'@' + model.name}</MentionTag>)}
</Flex> </Flex>
<MessageThought message={message} /> <MessageThought message={message} />
<Markdown message={message} /> <Markdown message={{ ...message, content: processedContent }} />
{formattedCitations && (
<CitationsContainer>
<CitationsTitle>
{t('message.citations')}
<InfoCircleOutlined style={{ fontSize: '14px', marginLeft: '4px', opacity: 0.6 }} />
</CitationsTitle>
{formattedCitations.map(({ number, url, hostname }) => (
<CitationLink key={number} href={url} target="_blank" rel="noopener noreferrer">
{number}. <span className="hostname">{hostname}</span>
</CitationLink>
))}
</CitationsContainer>
)}
{message.translatedContent && ( {message.translatedContent && (
<Fragment> <Fragment>
<Divider style={{ margin: 0, marginBottom: 10 }}> <Divider style={{ margin: 0, marginBottom: 10 }}>
@ -78,4 +125,39 @@ const MentionTag = styled.span`
color: var(--color-link); color: var(--color-link);
` `
const CitationsContainer = styled.div`
background-color: rgb(242, 247, 253);
border-radius: 4px;
padding: 8px 12px;
margin: 12px 0;
display: flex;
flex-direction: column;
gap: 4px;
body[theme-mode='dark'] & {
background-color: rgba(255, 255, 255, 0.05);
}
`
const CitationsTitle = styled.div`
font-weight: 500;
margin-bottom: 4px;
color: var(--color-text-1);
`
const CitationLink = styled.a`
font-size: 14px;
line-height: 1.6;
text-decoration: none;
color: var(--color-text-1);
.hostname {
color: var(--color-link);
}
&:hover {
text-decoration: underline;
}
`
export default React.memo(MessageContent) export default React.memo(MessageContent)

View File

@ -243,6 +243,9 @@ export default class OpenAIProvider extends BaseProvider {
const delta = chunk.choices[0]?.delta const delta = chunk.choices[0]?.delta
// Extract citations from the raw response if available
const citations = (chunk as OpenAI.Chat.Completions.ChatCompletionChunk & { citations?: string[] })?.citations
onChunk({ onChunk({
text: delta?.content || '', text: delta?.content || '',
// @ts-ignore key is not typed // @ts-ignore key is not typed
@ -253,7 +256,8 @@ export default class OpenAIProvider extends BaseProvider {
time_completion_millsec, time_completion_millsec,
time_first_token_millsec, time_first_token_millsec,
time_thinking_millsec time_thinking_millsec
} },
citations
}) })
} }
} }

View File

@ -7,11 +7,12 @@ interface ChunkCallbackData {
usage?: OpenAI.Completions.CompletionUsage usage?: OpenAI.Completions.CompletionUsage
metrics?: Metrics metrics?: Metrics
search?: GroundingMetadata search?: GroundingMetadata
citations?: string[]
} }
interface CompletionsParams { interface CompletionsParams {
messages: Message[] messages: Message[]
assistant: Assistant assistant: Assistant
onChunk: ({ text, reasoning_content, usage, metrics, search }: ChunkCallbackData) => void onChunk: ({ text, reasoning_content, usage, metrics, search, citations }: ChunkCallbackData) => void
onFilterMessages: (messages: Message[]) => void onFilterMessages: (messages: Message[]) => void
} }

View File

@ -52,12 +52,13 @@ export async function fetchChatCompletion({
try { try {
let _messages: Message[] = [] let _messages: Message[] = []
let isFirstChunk = true
await AI.completions({ await AI.completions({
messages: filterUsefulMessages(messages), messages: filterUsefulMessages(messages),
assistant, assistant,
onFilterMessages: (messages) => (_messages = messages), onFilterMessages: (messages) => (_messages = messages),
onChunk: ({ text, reasoning_content, usage, metrics, search }) => { onChunk: ({ text, reasoning_content, usage, metrics, search, citations }) => {
message.content = message.content + text || '' message.content = message.content + text || ''
message.usage = usage message.usage = usage
message.metrics = metrics message.metrics = metrics
@ -70,6 +71,15 @@ export async function fetchChatCompletion({
message.metadata = { groundingMetadata: search } message.metadata = { groundingMetadata: search }
} }
// Handle citations from Perplexity API
if (isFirstChunk && citations) {
message.metadata = {
...message.metadata,
citations
}
isFirstChunk = false
}
onResponse({ ...message, status: 'pending' }) onResponse({ ...message, status: 'pending' })
} }
}) })

View File

@ -67,6 +67,8 @@ export type Message = {
metadata?: { metadata?: {
// Gemini // Gemini
groundingMetadata?: any groundingMetadata?: any
// Perplexity
citations?: string[]
} }
askId?: string askId?: string
useful?: boolean useful?: boolean