From e7c0bbb3486e0654f34465f83d8554714f21bfdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=87=AA=E7=94=B1=E7=9A=84=E4=B8=96=E7=95=8C=E4=BA=BA?= <3196812536@qq.com> Date: Wed, 14 May 2025 00:13:00 +0800 Subject: [PATCH] feat: add citation content copy button (#5966) * feat: add citation content copy button * fix: build error --- .../home/Messages/Blocks/MainTextBlock.tsx | 11 +++- .../src/pages/home/Messages/CitationsList.tsx | 61 +++++++++++++------ src/renderer/src/utils/extract.ts | 4 +- src/renderer/src/utils/formats.ts | 20 +++++- 4 files changed, 72 insertions(+), 24 deletions(-) diff --git a/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx index f21cc47131..b004e784a9 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx @@ -5,6 +5,7 @@ import type { RootState } from '@renderer/store' import { selectFormattedCitationsByBlockId } from '@renderer/store/messageBlock' import { type Model, WebSearchSource } from '@renderer/types' import type { MainTextMessageBlock, Message } from '@renderer/types/newMessage' +import { cleanMarkdownContent } from '@renderer/utils/formats' import { Flex } from 'antd' import React, { useMemo } from 'react' import { useSelector } from 'react-redux' @@ -37,9 +38,13 @@ const MainTextBlock: React.FC = ({ block, citationBlockId, role, mentions // Use the passed citationBlockId directly in the selector const { renderInputMessageAsMarkdown } = useSettings() - const formattedCitations = useSelector((state: RootState) => - selectFormattedCitationsByBlockId(state, citationBlockId) - ) + const formattedCitations = useSelector((state: RootState) => { + const citations = selectFormattedCitationsByBlockId(state, citationBlockId) + return citations.map((citation) => ({ + ...citation, + content: citation.content ? cleanMarkdownContent(citation.content) : citation.content + })) + }) const processedContent = useMemo(() => { let content = block.content diff --git a/src/renderer/src/pages/home/Messages/CitationsList.tsx b/src/renderer/src/pages/home/Messages/CitationsList.tsx index d674db4e18..230767a3a8 100644 --- a/src/renderer/src/pages/home/Messages/CitationsList.tsx +++ b/src/renderer/src/pages/home/Messages/CitationsList.tsx @@ -1,9 +1,10 @@ import Favicon from '@renderer/components/Icons/FallbackFavicon' import { HStack } from '@renderer/components/Layout' import { fetchWebContent } from '@renderer/utils/fetch' +import { cleanMarkdownContent } from '@renderer/utils/formats' import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query' -import { Button, Drawer, Skeleton } from 'antd' -import { FileSearch } from 'lucide-react' +import { Button, Drawer, message, Skeleton } from 'antd' +import { Check, Copy, FileSearch } from 'lucide-react' import React, { useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -44,21 +45,6 @@ const truncateText = (text: string, maxLength = 100) => { return text.length > maxLength ? text.slice(0, maxLength) + '...' : text } -/** - * 清理Markdown内容 - * @param text - */ -const cleanMarkdownContent = (text: string): string => { - if (!text) return '' - let cleaned = text.replace(/!\[.*?]\(.*?\)/g, '') - cleaned = cleaned.replace(/\[(.*?)]\(.*?\)/g, '$1') - cleaned = cleaned.replace(/https?:\/\/\S+/g, '') - cleaned = cleaned.replace(/[-—–_=+]{3,}/g, ' ') - cleaned = cleaned.replace(/[¥$€£¥%@#&*^()[\]{}<>~`'"\\|/_.]+/g, '') - cleaned = cleaned.replace(/\s+/g, ' ').trim() - return cleaned -} - const CitationsList: React.FC = ({ citations }) => { const { t } = useTranslation() const [open, setOpen] = useState(false) @@ -115,6 +101,27 @@ const handleLinkClick = (url: string, event: React.MouseEvent) => { else window.api.file.openPath(url) } +const CopyButton: React.FC<{ content: string }> = ({ content }) => { + const [copied, setCopied] = useState(false) + const { t } = useTranslation() + + const handleCopy = () => { + if (!content) return + navigator.clipboard + .writeText(content) + .then(() => { + setCopied(true) + message.success(t('common.copied')) + setTimeout(() => setCopied(false), 2000) + }) + .catch(() => { + message.error(t('message.copy.failed')) + }) + } + + return {copied ? : } +} + const WebSearchCitation: React.FC<{ citation: Citation }> = ({ citation }) => { const { data: fetchedContent, isLoading } = useQuery({ queryKey: ['webContent', citation.url], @@ -136,6 +143,7 @@ const WebSearchCitation: React.FC<{ citation: Citation }> = ({ citation }) => { handleLinkClick(citation.url, e)}> {citation.title || {citation.hostname}} + {fetchedContent && } {isLoading ? ( @@ -153,6 +161,7 @@ const KnowledgeCitation: React.FC<{ citation: Citation }> = ({ citation }) => ( handleLinkClick(citation.url, e)}> {citation.title} + {citation.content && } {citation.content && truncateText(citation.content, 100)} @@ -203,6 +212,23 @@ const CitationLink = styled.a` } ` +const CopyIconWrapper = styled.div` + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-2); + opacity: 0.6; + margin-left: auto; + padding: 4px; + border-radius: 4px; + + &:hover { + opacity: 1; + background-color: var(--color-background-soft); + } +` + const WebSearchCard = styled.div` display: flex; flex-direction: column; @@ -219,6 +245,7 @@ const WebSearchCardHeader = styled.div` align-items: center; gap: 8px; margin-bottom: 6px; + width: 100%; ` const WebSearchCardContent = styled.div` diff --git a/src/renderer/src/utils/extract.ts b/src/renderer/src/utils/extract.ts index 4dd02ead69..2c71345255 100644 --- a/src/renderer/src/utils/extract.ts +++ b/src/renderer/src/utils/extract.ts @@ -1,4 +1,5 @@ import { XMLParser } from 'fast-xml-parser' + export interface ExtractResults { websearch?: WebsearchExtractResults knowledge?: KnowledgeExtractResults @@ -27,7 +28,6 @@ export const extractInfoFromXML = (text: string): ExtractResults => { return name === 'question' || name === 'links' } }) - const extractResults: ExtractResults = parser.parse(text) // Logger.log('Extracted results:', extractResults) - return extractResults + return parser.parse(text) } diff --git a/src/renderer/src/utils/formats.ts b/src/renderer/src/utils/formats.ts index 43f539d79f..a83ca4c632 100644 --- a/src/renderer/src/utils/formats.ts +++ b/src/renderer/src/utils/formats.ts @@ -2,6 +2,22 @@ import type { Message } from '@renderer/types/newMessage' import { findImageBlocks, getMainTextContent } from './messageUtils/find' +/** + * 清理Markdown内容 + * @param text 要清理的文本 + * @returns 清理后的文本 + */ +export function cleanMarkdownContent(text: string): string { + if (!text) return '' + let cleaned = text.replace(/!\[.*?]\(.*?\)/g, '') // 移除图片 + cleaned = cleaned.replace(/\[(.*?)]\(.*?\)/g, '$1') // 替换链接为纯文本 + cleaned = cleaned.replace(/https?:\/\/\S+/g, '') // 移除URL + cleaned = cleaned.replace(/[-—–_=+]{3,}/g, ' ') // 替换分隔符为空格 + cleaned = cleaned.replace(/[¥$€£¥%@#&*^()[\]{}<>~`'"\\|/_.]+/g, '') // 移除特殊字符 + cleaned = cleaned.replace(/\s+/g, ' ').trim() // 规范化空白 + return cleaned +} + export function escapeDollarNumber(text: string) { let escapedText = '' @@ -20,7 +36,7 @@ export function escapeDollarNumber(text: string) { } export function escapeBrackets(text: string) { - const pattern = /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)/g + const pattern = /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\]|\\\((.*?)\\\)/g return text.replace(pattern, (match, codeBlock, squareBracket, roundBracket) => { if (codeBlock) { return codeBlock @@ -102,7 +118,7 @@ export function withGenerateImage(message: Message): { content: string; images?: const originalContent = getMainTextContent(message) const imagePattern = new RegExp(`!\\[[^\\]]*\\]\\((.*?)\\s*("(?:.*[^"])")?\\s*\\)`) const images: string[] = [] - let processedContent = originalContent + let processedContent: string processedContent = originalContent.replace(imagePattern, (_, url) => { if (url) {