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.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",
|
||||||
|
|||||||
@ -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": "ミニアプリ",
|
||||||
|
|||||||
@ -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": "Встроенные приложения",
|
||||||
|
|||||||
@ -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": "小程序",
|
||||||
|
|||||||
@ -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": "小程序",
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user