mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-24 10:40:07 +08:00
feat: change citation list style (#5516)
* feat: change citation list style * feat: add link preview * fix: model inside searching * fix: change button word
This commit is contained in:
parent
599370be4e
commit
b6520cdc9a
@ -313,6 +313,7 @@
|
||||
"fullscreen": "Entered fullscreen mode. Press F11 to exit",
|
||||
"knowledge_base": "Knowledge Base",
|
||||
"language": "Language",
|
||||
"loading": "Loading...",
|
||||
"model": "Model",
|
||||
"models": "Models",
|
||||
"more": "More",
|
||||
@ -533,6 +534,7 @@
|
||||
"backup.start.success": "Backup started",
|
||||
"backup.success": "Backup successful",
|
||||
"chat.completion.paused": "Chat completion paused",
|
||||
"citation": "{{count}} citations",
|
||||
"citations": "References",
|
||||
"copied": "Copied!",
|
||||
"copy.failed": "Copy failed",
|
||||
|
||||
@ -313,6 +313,7 @@
|
||||
"fullscreen": "全画面モードに入りました。F11キーで終了します",
|
||||
"knowledge_base": "ナレッジベース",
|
||||
"language": "言語",
|
||||
"loading": "読み込み中...",
|
||||
"model": "モデル",
|
||||
"models": "モデル",
|
||||
"more": "もっと",
|
||||
@ -533,7 +534,8 @@
|
||||
"backup.start.success": "バックアップを開始しました",
|
||||
"backup.success": "バックアップに成功しました",
|
||||
"chat.completion.paused": "チャットの完了が一時停止されました",
|
||||
"citations": "参考文献",
|
||||
"citation": "{{count}}個の引用内容",
|
||||
"citations": "引用内容",
|
||||
"copied": "コピーしました!",
|
||||
"copy.failed": "コピーに失敗しました",
|
||||
"copy.success": "コピーしました!",
|
||||
|
||||
@ -313,6 +313,7 @@
|
||||
"fullscreen": "Вы вошли в полноэкранный режим. Нажмите F11 для выхода",
|
||||
"knowledge_base": "База знаний",
|
||||
"language": "Язык",
|
||||
"loading": "Загрузка...",
|
||||
"model": "Модель",
|
||||
"models": "Модели",
|
||||
"more": "Ещё",
|
||||
@ -533,7 +534,8 @@
|
||||
"backup.start.success": "Создание резервной копии начато",
|
||||
"backup.success": "Резервная копия успешно создана",
|
||||
"chat.completion.paused": "Завершение чата приостановлено",
|
||||
"citations": "Источники",
|
||||
"citation": "{{count}} цитат",
|
||||
"citations": "Содержание цитат",
|
||||
"copied": "Скопировано!",
|
||||
"copy.failed": "Не удалось скопировать",
|
||||
"copy.success": "Скопировано!",
|
||||
|
||||
@ -312,6 +312,7 @@
|
||||
"fullscreen": "已进入全屏模式,按 F11 退出",
|
||||
"knowledge_base": "知识库",
|
||||
"language": "语言",
|
||||
"loading": "加载中...",
|
||||
"model": "模型",
|
||||
"models": "模型",
|
||||
"more": "更多",
|
||||
@ -532,6 +533,7 @@
|
||||
"backup.start.success": "开始备份",
|
||||
"backup.success": "备份成功",
|
||||
"chat.completion.paused": "会话已停止",
|
||||
"citation": "{{count}}个引用内容",
|
||||
"citations": "引用内容",
|
||||
"copied": "已复制",
|
||||
"copy.failed": "复制失败",
|
||||
|
||||
@ -312,6 +312,7 @@
|
||||
"fullscreen": "已進入全螢幕模式,按 F11 結束",
|
||||
"knowledge_base": "知識庫",
|
||||
"language": "語言",
|
||||
"loading": "加載中...",
|
||||
"model": "模型",
|
||||
"models": "模型",
|
||||
"more": "更多",
|
||||
@ -532,7 +533,8 @@
|
||||
"backup.start.success": "開始備份",
|
||||
"backup.success": "備份成功",
|
||||
"chat.completion.paused": "聊天完成已暫停",
|
||||
"citations": "參考文獻",
|
||||
"citation": "{{count}} 個引用內容",
|
||||
"citations": "引用內容",
|
||||
"copied": "已複製!",
|
||||
"copy.failed": "複製失敗",
|
||||
"copy.success": "已複製!",
|
||||
|
||||
@ -66,13 +66,12 @@ const Markdown: FC<Props> = ({ block }) => {
|
||||
}, [mathEngine, messageContent])
|
||||
|
||||
const components = useMemo(() => {
|
||||
const baseComponents = {
|
||||
return {
|
||||
a: (props: any) => <Link {...props} citationData={parseJSON(findCitationInChildren(props.children))} />,
|
||||
code: CodeBlock,
|
||||
img: ImagePreview,
|
||||
pre: (props: any) => <pre style={{ overflow: 'visible' }} {...props} />
|
||||
} as Partial<Components>
|
||||
return baseComponents
|
||||
}, [])
|
||||
|
||||
// if (role === 'user' && !renderInputMessageAsMarkdown) {
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import Favicon from '@renderer/components/Icons/FallbackFavicon'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { Collapse, theme } from 'antd'
|
||||
import { FileSearch, Info } from 'lucide-react'
|
||||
import React, { useMemo } from 'react'
|
||||
import { fetchWebContent } from '@renderer/utils/fetch'
|
||||
import { Button, Drawer } from 'antd'
|
||||
import { FileSearch } from 'lucide-react'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -18,135 +19,205 @@ export interface Citation {
|
||||
|
||||
interface CitationsListProps {
|
||||
citations: Citation[]
|
||||
hideTitle?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 限制文本长度
|
||||
* @param text
|
||||
* @param maxLength
|
||||
*/
|
||||
const truncateText = (text: string, maxLength = 100) => {
|
||||
if (!text) return ''
|
||||
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)
|
||||
|
||||
const { token } = theme.useToken()
|
||||
const items = useMemo(() => {
|
||||
return !citations || citations.length === 0
|
||||
? []
|
||||
: [
|
||||
{
|
||||
key: '1',
|
||||
label: (
|
||||
<CitationsTitle>
|
||||
<span>{t('message.citations')}</span>
|
||||
<Info size={14} style={{ opacity: 0.6 }} />
|
||||
</CitationsTitle>
|
||||
),
|
||||
style: {
|
||||
backgroundColor: token.colorFillAlter
|
||||
},
|
||||
children: (
|
||||
<>
|
||||
{citations.map((citation) => (
|
||||
<HStack key={citation.url || citation.number} style={{ alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--color-text-2)' }}>{citation.number}.</span>
|
||||
{citation.type === 'websearch' ? (
|
||||
<WebSearchCitation citation={citation} />
|
||||
) : (
|
||||
<KnowledgeCitation citation={citation} />
|
||||
)}
|
||||
</HStack>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
]
|
||||
}, [citations, t])
|
||||
const hasCitations = citations.length > 0
|
||||
const count = citations.length
|
||||
const previewItems = citations.slice(0, 3)
|
||||
|
||||
if (!citations || citations.length === 0) return null
|
||||
if (!hasCitations) return null
|
||||
|
||||
const handleOpen = () => {
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<CitationsContainer>
|
||||
<Collapse items={items} size="small" bordered={false} style={{ background: token.colorBgContainer }} />
|
||||
</CitationsContainer>
|
||||
<>
|
||||
<OpenButton type="text" onClick={handleOpen}>
|
||||
<PreviewIcons>
|
||||
{previewItems.map((c, i) => (
|
||||
<PreviewIcon key={i} style={{ zIndex: previewItems.length - i }}>
|
||||
{c.type === 'websearch' && c.url ? (
|
||||
<Favicon hostname={new URL(c.url).hostname} alt={''} />
|
||||
) : (
|
||||
<FileSearch width={16} />
|
||||
)}
|
||||
</PreviewIcon>
|
||||
))}
|
||||
</PreviewIcons>
|
||||
{t('message.citation', { count: count })}
|
||||
</OpenButton>
|
||||
|
||||
<Drawer
|
||||
title={t('message.citations')}
|
||||
placement="right"
|
||||
onClose={handleClose}
|
||||
open={open}
|
||||
width={680}
|
||||
destroyOnClose
|
||||
styles={{
|
||||
body: {
|
||||
padding: 16,
|
||||
height: 'calc(100% - 55px)'
|
||||
}
|
||||
}}>
|
||||
{citations.map((citation) => (
|
||||
<HStack key={citation.url || citation.number} style={{ alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
{citation.type === 'websearch' ? (
|
||||
<WebSearchCitation citation={citation} />
|
||||
) : (
|
||||
<KnowledgeCitation citation={citation} />
|
||||
)}
|
||||
</HStack>
|
||||
))}
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const handleLinkClick = (url: string, event: React.MouseEvent) => {
|
||||
if (!url) return
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
// 检查是否是网络URL
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
} else {
|
||||
try {
|
||||
window.api.file.openPath(url)
|
||||
} catch (error) {
|
||||
console.error('打开本地文件失败:', error)
|
||||
}
|
||||
}
|
||||
if (url.startsWith('http')) window.open(url, '_blank', 'noopener,noreferrer')
|
||||
else window.api.file.openPath(url)
|
||||
}
|
||||
|
||||
// 网络搜索引用组件
|
||||
const WebSearchCitation: React.FC<{ citation: Citation }> = ({ citation }) => {
|
||||
const { t } = useTranslation()
|
||||
const [fetchedContent, setFetchedContent] = React.useState('')
|
||||
const [isLoading, setIsLoading] = React.useState(false)
|
||||
React.useEffect(() => {
|
||||
if (citation.url) {
|
||||
setIsLoading(true)
|
||||
fetchWebContent(citation.url, 'markdown')
|
||||
.then((res) => {
|
||||
const cleaned = cleanMarkdownContent(res.content)
|
||||
setFetchedContent(truncateText(cleaned, 100))
|
||||
})
|
||||
.finally(() => setIsLoading(false))
|
||||
}
|
||||
}, [citation.url])
|
||||
|
||||
return (
|
||||
<>
|
||||
{citation.showFavicon && citation.url && (
|
||||
<Favicon hostname={new URL(citation.url).hostname} alt={citation.title || citation.hostname || ''} />
|
||||
)}
|
||||
<CitationLink href={citation.url} className="text-nowrap" onClick={(e) => handleLinkClick(citation.url, e)}>
|
||||
{citation.title ? citation.title : <span className="hostname">{citation.hostname}</span>}
|
||||
</CitationLink>
|
||||
</>
|
||||
<WebSearchCard>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
{citation.showFavicon && citation.url && (
|
||||
<Favicon hostname={new URL(citation.url).hostname} alt={citation.title || citation.hostname || ''} />
|
||||
)}
|
||||
<CitationLink href={citation.url} onClick={(e) => handleLinkClick(citation.url, e)}>
|
||||
{citation.title || <span className="hostname">{citation.hostname}</span>}
|
||||
</CitationLink>
|
||||
</div>
|
||||
{isLoading ? <div>{t('common.loading')}</div> : fetchedContent}
|
||||
</WebSearchCard>
|
||||
)
|
||||
}
|
||||
|
||||
// 知识库引用组件
|
||||
const KnowledgeCitation: React.FC<{ citation: Citation }> = ({ citation }) => {
|
||||
return (
|
||||
<>
|
||||
{citation.showFavicon && citation.url && <FileSearch width={16} />}
|
||||
<CitationLink href={citation.url} className="text-nowrap" onClick={(e) => handleLinkClick(citation.url, e)}>
|
||||
{citation.title}
|
||||
</CitationLink>
|
||||
</>
|
||||
)
|
||||
}
|
||||
const KnowledgeCitation: React.FC<{ citation: Citation }> = ({ citation }) => (
|
||||
<>
|
||||
{citation.showFavicon && <FileSearch width={16} />}
|
||||
<CitationLink href={citation.url} onClick={(e) => handleLinkClick(citation.url, e)}>
|
||||
{citation.title}
|
||||
</CitationLink>
|
||||
</>
|
||||
)
|
||||
|
||||
const CitationsContainer = styled.div`
|
||||
background-color: rgb(242, 247, 253);
|
||||
border-radius: 10px;
|
||||
padding: 8px 12px;
|
||||
margin: 12px 0;
|
||||
display: inline-block;
|
||||
/* 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 OpenButton = styled(Button)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 6px;
|
||||
margin-bottom: 8px;
|
||||
align-self: flex-start;
|
||||
font-size: 12px;
|
||||
`
|
||||
|
||||
const PreviewIcons = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 8px;
|
||||
`
|
||||
|
||||
const PreviewIcon = styled.div`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
border: 1px solid #fff;
|
||||
margin-left: -8px;
|
||||
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
`
|
||||
|
||||
const CitationLink = styled.a`
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
text-decoration: none;
|
||||
color: var(--color-text-1);
|
||||
|
||||
.hostname {
|
||||
color: var(--color-link);
|
||||
}
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.hostname {
|
||||
color: var(--color-link);
|
||||
}
|
||||
`
|
||||
|
||||
const WebSearchCard = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
background-color: var(--color-bg-2);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
background-color: var(--color-bg-3);
|
||||
border-color: var(--color-primary-light);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
`
|
||||
|
||||
export default CitationsList
|
||||
|
||||
@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import MessageItem from './Message'
|
||||
|
||||
interface Props {
|
||||
assistant: Assistant
|
||||
route: string
|
||||
@ -33,7 +34,6 @@ const Messages: FC<Props> = ({ assistant, route }) => {
|
||||
// setMessages((prev) => {
|
||||
// const assistantMessage = getAssistantMessage({ assistant, topic: assistant.topics[0] })
|
||||
// store.dispatch(newMessagesActions.addMessage({ topicId: assistant.topics[0].id, message: assistantMessage }))
|
||||
|
||||
// const messages = prev.concat([message, assistantMessage])
|
||||
// return messages
|
||||
// })
|
||||
|
||||
Loading…
Reference in New Issue
Block a user