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:
parent
4d13a8d9c2
commit
cb88a48d8b
@ -301,7 +301,8 @@
|
||||
"error.notion.export": "Notion import failed",
|
||||
"error.notion.no_api_key": "Notion ApiKey or Notion DatabaseID is not configured",
|
||||
"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": {
|
||||
"title": "MinApp",
|
||||
|
||||
@ -295,7 +295,8 @@
|
||||
"error.notion.export": "Notion インポートに失敗",
|
||||
"error.notion.no_api_key": "Notion ApiKey または Notion DatabaseID が設定されていません",
|
||||
"success.notion.export": "Notion へのインポートに成功",
|
||||
"warn.notion.exporting": "Notion 正在インポート中です。重複インポートしないでください。"
|
||||
"warn.notion.exporting": "Notion 正在インポート中です。重複インポートしないでください。",
|
||||
"citations": "参考文献"
|
||||
},
|
||||
"minapp": {
|
||||
"title": "ミニアプリ",
|
||||
|
||||
@ -296,7 +296,8 @@
|
||||
"error.notion.export": "Импорт в Notion не удался",
|
||||
"error.notion.no_api_key": "Notion ApiKey или Notion DatabaseID не настроен",
|
||||
"success.notion.export": "Импорт в Notion выполнен успешно",
|
||||
"warn.notion.exporting": "Идет импорт в Notion, пожалуйста, не повторяйте импорт"
|
||||
"warn.notion.exporting": "Идет импорт в Notion, пожалуйста, не повторяйте импорт",
|
||||
"citations": "Источники"
|
||||
},
|
||||
"minapp": {
|
||||
"title": "Встроенные приложения",
|
||||
|
||||
@ -302,8 +302,8 @@
|
||||
"error.notion.export":"Notion 导入失败",
|
||||
"error.notion.no_api_key":"未配置Notion ApiKey或Notion DatabaseID",
|
||||
"success.notion.export":"导入Notion成功",
|
||||
"warn.notion.exporting":"Notion正在导入,请勿重复导入"
|
||||
|
||||
"warn.notion.exporting":"Notion正在导入,请勿重复导入",
|
||||
"citations": "引用内容"
|
||||
},
|
||||
"minapp": {
|
||||
"title": "小程序",
|
||||
|
||||
@ -301,7 +301,8 @@
|
||||
"error.notion.export": "Notion 匯入失敗",
|
||||
"error.notion.no_api_key": "未配置 Notion ApiKey 或 Notion DatabaseID",
|
||||
"success.notion.export": "匯入 Notion 成功",
|
||||
"warn.notion.exporting": "Notion 正在匯入,請勿重複匯入"
|
||||
"warn.notion.exporting": "Notion 正在匯入,請勿重複匯入",
|
||||
"citations": "參考文獻"
|
||||
},
|
||||
"minapp": {
|
||||
"title": "小程序",
|
||||
|
||||
@ -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 { getBriefInfo } from '@renderer/utils'
|
||||
import { withMessageThought } from '@renderer/utils/formats'
|
||||
import { Divider, Flex } from 'antd'
|
||||
import React, { Fragment } from 'react'
|
||||
import React, { Fragment, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import BeatLoader from 'react-spinners/BeatLoader'
|
||||
import styled from 'styled-components'
|
||||
@ -23,6 +23,40 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
|
||||
const { t } = useTranslation()
|
||||
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') {
|
||||
return (
|
||||
<MessageContentLoading>
|
||||
@ -46,7 +80,20 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
|
||||
{message.mentions?.map((model) => <MentionTag key={model.id}>{'@' + model.name}</MentionTag>)}
|
||||
</Flex>
|
||||
<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 && (
|
||||
<Fragment>
|
||||
<Divider style={{ margin: 0, marginBottom: 10 }}>
|
||||
@ -78,4 +125,39 @@ const MentionTag = styled.span`
|
||||
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)
|
||||
|
||||
@ -243,6 +243,9 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
|
||||
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({
|
||||
text: delta?.content || '',
|
||||
// @ts-ignore key is not typed
|
||||
@ -253,7 +256,8 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
time_completion_millsec,
|
||||
time_first_token_millsec,
|
||||
time_thinking_millsec
|
||||
}
|
||||
},
|
||||
citations
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
3
src/renderer/src/providers/index.d.ts
vendored
3
src/renderer/src/providers/index.d.ts
vendored
@ -7,11 +7,12 @@ interface ChunkCallbackData {
|
||||
usage?: OpenAI.Completions.CompletionUsage
|
||||
metrics?: Metrics
|
||||
search?: GroundingMetadata
|
||||
citations?: string[]
|
||||
}
|
||||
|
||||
interface CompletionsParams {
|
||||
messages: Message[]
|
||||
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
|
||||
}
|
||||
|
||||
@ -52,12 +52,13 @@ export async function fetchChatCompletion({
|
||||
|
||||
try {
|
||||
let _messages: Message[] = []
|
||||
let isFirstChunk = true
|
||||
|
||||
await AI.completions({
|
||||
messages: filterUsefulMessages(messages),
|
||||
assistant,
|
||||
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.usage = usage
|
||||
message.metrics = metrics
|
||||
@ -70,6 +71,15 @@ export async function fetchChatCompletion({
|
||||
message.metadata = { groundingMetadata: search }
|
||||
}
|
||||
|
||||
// Handle citations from Perplexity API
|
||||
if (isFirstChunk && citations) {
|
||||
message.metadata = {
|
||||
...message.metadata,
|
||||
citations
|
||||
}
|
||||
isFirstChunk = false
|
||||
}
|
||||
|
||||
onResponse({ ...message, status: 'pending' })
|
||||
}
|
||||
})
|
||||
|
||||
@ -67,6 +67,8 @@ export type Message = {
|
||||
metadata?: {
|
||||
// Gemini
|
||||
groundingMetadata?: any
|
||||
// Perplexity
|
||||
citations?: string[]
|
||||
}
|
||||
askId?: string
|
||||
useful?: boolean
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user