feat: add citation content copy button (#5966)

* feat: add citation content copy button

* fix: build error
This commit is contained in:
自由的世界人 2025-05-14 00:13:00 +08:00 committed by GitHub
parent 71cd2def2e
commit e7c0bbb348
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 72 additions and 24 deletions

View File

@ -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<Props> = ({ 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

View File

@ -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<CitationsListProps> = ({ 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 <CopyIconWrapper onClick={handleCopy}>{copied ? <Check size={14} /> : <Copy size={14} />}</CopyIconWrapper>
}
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 }) => {
<CitationLink className="text-nowrap" href={citation.url} onClick={(e) => handleLinkClick(citation.url, e)}>
{citation.title || <span className="hostname">{citation.hostname}</span>}
</CitationLink>
{fetchedContent && <CopyButton content={fetchedContent} />}
</WebSearchCardHeader>
{isLoading ? (
<Skeleton active paragraph={{ rows: 1 }} title={false} />
@ -153,6 +161,7 @@ const KnowledgeCitation: React.FC<{ citation: Citation }> = ({ citation }) => (
<CitationLink className="text-nowrap" href={citation.url} onClick={(e) => handleLinkClick(citation.url, e)}>
{citation.title}
</CitationLink>
{citation.content && <CopyButton content={citation.content} />}
</WebSearchCardHeader>
<WebSearchCardContent>{citation.content && truncateText(citation.content, 100)}</WebSearchCardContent>
</WebSearchCard>
@ -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`

View File

@ -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)
}

View File

@ -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) {