From cb88a48d8bf0f2cf05a69ea305347934dc52d947 Mon Sep 17 00:00:00 2001 From: lucifer9 Date: Tue, 11 Feb 2025 16:21:20 +0800 Subject: [PATCH] feat: add support for clickable citations in message content (#1381) * Add support for clickable citations in message content * update format --- src/renderer/src/i18n/locales/en-us.json | 3 +- src/renderer/src/i18n/locales/ja-jp.json | 3 +- src/renderer/src/i18n/locales/ru-ru.json | 3 +- src/renderer/src/i18n/locales/zh-cn.json | 4 +- src/renderer/src/i18n/locales/zh-tw.json | 3 +- .../pages/home/Messages/MessageContent.tsx | 88 ++++++++++++++++++- src/renderer/src/providers/OpenAIProvider.ts | 6 +- src/renderer/src/providers/index.d.ts | 3 +- src/renderer/src/services/ApiService.ts | 12 ++- src/renderer/src/types/index.ts | 2 + 10 files changed, 115 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 17f84628..1f3e9bd4 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -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", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 37bd620b..093acb8f 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -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": "ミニアプリ", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index d2a492b4..649d47f7 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -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": "Встроенные приложения", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 1451bf27..e0803e51 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -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": "小程序", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 58490eeb..ded256f1 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -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": "小程序", diff --git a/src/renderer/src/pages/home/Messages/MessageContent.tsx b/src/renderer/src/pages/home/Messages/MessageContent.tsx index 8129cc0c..bf03a1c8 100644 --- a/src/renderer/src/pages/home/Messages/MessageContent.tsx +++ b/src/renderer/src/pages/home/Messages/MessageContent.tsx @@ -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 = ({ 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 tag for superscript and make it a link + return `[${num}](${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 ( @@ -46,7 +80,20 @@ const MessageContent: React.FC = ({ message: _message, model }) => { {message.mentions?.map((model) => {'@' + model.name})} - + + {formattedCitations && ( + + + {t('message.citations')} + + + {formattedCitations.map(({ number, url, hostname }) => ( + + {number}. {hostname} + + ))} + + )} {message.translatedContent && ( @@ -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) diff --git a/src/renderer/src/providers/OpenAIProvider.ts b/src/renderer/src/providers/OpenAIProvider.ts index 34ac9dd8..2e4837fc 100644 --- a/src/renderer/src/providers/OpenAIProvider.ts +++ b/src/renderer/src/providers/OpenAIProvider.ts @@ -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 }) } } diff --git a/src/renderer/src/providers/index.d.ts b/src/renderer/src/providers/index.d.ts index 186a1002..f14f810d 100644 --- a/src/renderer/src/providers/index.d.ts +++ b/src/renderer/src/providers/index.d.ts @@ -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 } diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index ee6e034b..86ae8ea4 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -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' }) } }) diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 07ca3531..978411aa 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -67,6 +67,8 @@ export type Message = { metadata?: { // Gemini groundingMetadata?: any + // Perplexity + citations?: string[] } askId?: string useful?: boolean