mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-08 14:29:15 +08:00
feat: add copy button on message footer
This commit is contained in:
parent
fa1f00f4f5
commit
e7f7f8509e
@ -8,6 +8,7 @@ import {
|
|||||||
PauseCircleOutlined,
|
PauseCircleOutlined,
|
||||||
QuestionCircleOutlined
|
QuestionCircleOutlined
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
|
import db from '@renderer/databases'
|
||||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||||
import { useSettings } from '@renderer/hooks/useSettings'
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
import { useRuntime, useShowTopics } from '@renderer/hooks/useStore'
|
import { useRuntime, useShowTopics } from '@renderer/hooks/useStore'
|
||||||
@ -120,6 +121,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
|
|||||||
const topic = getDefaultTopic()
|
const topic = getDefaultTopic()
|
||||||
addTopic(topic)
|
addTopic(topic)
|
||||||
setActiveTopic(topic)
|
setActiveTopic(topic)
|
||||||
|
db.topics.add({ id: topic.id, messages: [] })
|
||||||
}, [addTopic, setActiveTopic])
|
}, [addTopic, setActiveTopic])
|
||||||
|
|
||||||
const clearTopic = async () => {
|
const clearTopic = async () => {
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import CopyIcon from '@renderer/components/Icons/CopyIcon'
|
|||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
import { initMermaid } from '@renderer/init'
|
import { initMermaid } from '@renderer/init'
|
||||||
import { ThemeMode } from '@renderer/types'
|
import { ThemeMode } from '@renderer/types'
|
||||||
import React, { useState } from 'react'
|
import React, { memo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||||
import { atomDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'
|
import { atomDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'
|
||||||
@ -17,34 +17,23 @@ interface CodeBlockProps {
|
|||||||
[key: string]: any
|
[key: string]: any
|
||||||
}
|
}
|
||||||
|
|
||||||
const CodeBlock: React.FC<CodeBlockProps> = ({ children, className, ...rest }) => {
|
const CodeBlock: React.FC<CodeBlockProps> = ({ children, className }) => {
|
||||||
const match = /language-(\w+)/.exec(className || '')
|
const match = /language-(\w+)/.exec(className || '')
|
||||||
const [copied, setCopied] = useState(false)
|
const showFooterCopyButton = children && children.length > 500
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
|
|
||||||
const { t } = useTranslation()
|
|
||||||
|
|
||||||
const onCopy = () => {
|
|
||||||
navigator.clipboard.writeText(children)
|
|
||||||
window.message.success({ content: t('message.copied'), key: 'copy-code' })
|
|
||||||
setCopied(true)
|
|
||||||
setTimeout(() => setCopied(false), 2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (match && match[1] === 'mermaid') {
|
if (match && match[1] === 'mermaid') {
|
||||||
initMermaid(theme)
|
initMermaid(theme)
|
||||||
return <Mermaid chart={children} />
|
return <Mermaid chart={children} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return match ? (
|
return match ? (
|
||||||
<>
|
<div className="code-block">
|
||||||
<CodeHeader>
|
<CodeHeader>
|
||||||
<CodeLanguage>{'<' + match[1].toUpperCase() + '>'}</CodeLanguage>
|
<CodeLanguage>{'<' + match[1].toUpperCase() + '>'}</CodeLanguage>
|
||||||
{!copied && <CopyIcon className="copy" onClick={onCopy} />}
|
<CopyButton text={children} />
|
||||||
{copied && <CheckOutlined style={{ color: 'var(--color-primary)' }} />}
|
|
||||||
</CodeHeader>
|
</CodeHeader>
|
||||||
<SyntaxHighlighter
|
<SyntaxHighlighter
|
||||||
{...rest}
|
|
||||||
language={match[1]}
|
language={match[1]}
|
||||||
style={theme === ThemeMode.dark ? atomDark : oneLight}
|
style={theme === ThemeMode.dark ? atomDark : oneLight}
|
||||||
wrapLongLines={true}
|
wrapLongLines={true}
|
||||||
@ -56,11 +45,32 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ children, className, ...rest }) =
|
|||||||
}}>
|
}}>
|
||||||
{String(children).replace(/\n$/, '')}
|
{String(children).replace(/\n$/, '')}
|
||||||
</SyntaxHighlighter>
|
</SyntaxHighlighter>
|
||||||
</>
|
{showFooterCopyButton && (
|
||||||
|
<CodeFooter>
|
||||||
|
<CopyButton text={children} style={{ marginTop: -40, marginRight: 10 }} />
|
||||||
|
</CodeFooter>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<code {...rest} className={className}>
|
<code className={className}>{children}</code>
|
||||||
{children}
|
)
|
||||||
</code>
|
}
|
||||||
|
|
||||||
|
const CopyButton: React.FC<{ text: string; style?: React.CSSProperties }> = ({ text, style }) => {
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const onCopy = () => {
|
||||||
|
navigator.clipboard.writeText(text)
|
||||||
|
window.message.success({ content: t('message.copied'), key: 'copy-code' })
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return copied ? (
|
||||||
|
<CheckOutlined style={{ color: 'var(--color-primary)', ...style }} />
|
||||||
|
) : (
|
||||||
|
<CopyIcon className="copy" style={style} onClick={onCopy} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,4 +100,19 @@ const CodeLanguage = styled.div`
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
`
|
`
|
||||||
|
|
||||||
export default CodeBlock
|
const CodeFooter = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
.copy {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
transition: color 0.3s;
|
||||||
|
}
|
||||||
|
.copy:hover {
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export default memo(CodeBlock)
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { Message } from '@renderer/types'
|
|||||||
import { isEmpty } from 'lodash'
|
import { isEmpty } from 'lodash'
|
||||||
import { FC, useMemo } from 'react'
|
import { FC, useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown, { Components } from 'react-markdown'
|
||||||
import rehypeKatex from 'rehype-katex'
|
import rehypeKatex from 'rehype-katex'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import remarkMath from 'remark-math'
|
import remarkMath from 'remark-math'
|
||||||
@ -16,6 +16,14 @@ interface Props {
|
|||||||
message: Message
|
message: Message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rehypePlugins = [rehypeKatex]
|
||||||
|
const remarkPlugins = [remarkGfm, remarkMath]
|
||||||
|
|
||||||
|
const components = {
|
||||||
|
code: CodeBlock,
|
||||||
|
a: Link
|
||||||
|
}
|
||||||
|
|
||||||
const Markdown: FC<Props> = ({ message }) => {
|
const Markdown: FC<Props> = ({ message }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
@ -26,22 +34,20 @@ const Markdown: FC<Props> = ({ message }) => {
|
|||||||
return content
|
return content
|
||||||
}, [message.content, message.status, t])
|
}, [message.content, message.status, t])
|
||||||
|
|
||||||
return useMemo(() => {
|
return (
|
||||||
return (
|
<ReactMarkdown
|
||||||
<ReactMarkdown
|
className="markdown"
|
||||||
className="markdown"
|
rehypePlugins={rehypePlugins}
|
||||||
rehypePlugins={[rehypeKatex]}
|
remarkPlugins={remarkPlugins}
|
||||||
remarkPlugins={[remarkMath, remarkGfm]}
|
components={components as Partial<Components>}
|
||||||
remarkRehypeOptions={{
|
remarkRehypeOptions={{
|
||||||
footnoteLabel: t('common.footnotes'),
|
footnoteLabel: t('common.footnotes'),
|
||||||
footnoteLabelTagName: 'h4',
|
footnoteLabelTagName: 'h4',
|
||||||
footnoteBackContent: ' '
|
footnoteBackContent: ' '
|
||||||
}}
|
}}>
|
||||||
components={{ code: CodeBlock as any, a: Link as any }}>
|
{messageContent}
|
||||||
{messageContent}
|
</ReactMarkdown>
|
||||||
</ReactMarkdown>
|
)
|
||||||
)
|
|
||||||
}, [messageContent, t])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Markdown
|
export default Markdown
|
||||||
|
|||||||
@ -107,34 +107,6 @@ const MessageItem: FC<Props> = ({ message, index, showMenu, onDeleteMessage }) =
|
|||||||
[t, message]
|
[t, message]
|
||||||
)
|
)
|
||||||
|
|
||||||
const MessageItem = useCallback(() => {
|
|
||||||
if (message.status === 'sending') {
|
|
||||||
return (
|
|
||||||
<MessageContentLoading>
|
|
||||||
<SyncOutlined spin size={24} />
|
|
||||||
</MessageContentLoading>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.status === 'error') {
|
|
||||||
return (
|
|
||||||
<Alert
|
|
||||||
message={<div style={{ fontSize: 14 }}>{t('error.chat.response')}</div>}
|
|
||||||
description={<Markdown message={message} />}
|
|
||||||
type="error"
|
|
||||||
style={{ marginBottom: 15, padding: 10, fontSize: 12 }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Markdown message={message} />
|
|
||||||
<MessageAttachments message={message} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}, [message, t])
|
|
||||||
|
|
||||||
const showMiniApp = () => model?.provider && startMinAppById(model?.provider)
|
const showMiniApp = () => model?.provider && startMinAppById(model?.provider)
|
||||||
|
|
||||||
if (message.type === 'clear') {
|
if (message.type === 'clear') {
|
||||||
@ -175,8 +147,8 @@ const MessageItem: FC<Props> = ({ message, index, showMenu, onDeleteMessage }) =
|
|||||||
</UserWrap>
|
</UserWrap>
|
||||||
</AvatarWrapper>
|
</AvatarWrapper>
|
||||||
</MessageHeader>
|
</MessageHeader>
|
||||||
<MessageContent style={{ fontFamily, fontSize }}>
|
<MessageContentContainer style={{ fontFamily, fontSize }}>
|
||||||
<MessageItem />
|
<MessageContent message={message} />
|
||||||
<MessageFooter style={{ border: messageBorder }}>
|
<MessageFooter style={{ border: messageBorder }}>
|
||||||
{showMenu && (
|
{showMenu && (
|
||||||
<MenusBar className={`menubar ${isLastMessage && 'show'} ${(!isLastMessage || isUserMessage) && 'user'}`}>
|
<MenusBar className={`menubar ${isLastMessage && 'show'} ${(!isLastMessage || isUserMessage) && 'user'}`}>
|
||||||
@ -229,11 +201,41 @@ const MessageItem: FC<Props> = ({ message, index, showMenu, onDeleteMessage }) =
|
|||||||
</MessageMetadata>
|
</MessageMetadata>
|
||||||
)}
|
)}
|
||||||
</MessageFooter>
|
</MessageFooter>
|
||||||
</MessageContent>
|
</MessageContentContainer>
|
||||||
</MessageContainer>
|
</MessageContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MessageContent: React.FC<{ message: Message }> = ({ message }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
if (message.status === 'sending') {
|
||||||
|
return (
|
||||||
|
<MessageContentLoading>
|
||||||
|
<SyncOutlined spin size={24} />
|
||||||
|
</MessageContentLoading>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.status === 'error') {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
message={<div style={{ fontSize: 14 }}>{t('error.chat.response')}</div>}
|
||||||
|
description={<Markdown message={message} />}
|
||||||
|
type="error"
|
||||||
|
style={{ marginBottom: 15, padding: 10, fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Markdown message={message} />
|
||||||
|
<MessageAttachments message={message} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const MessageContainer = styled.div`
|
const MessageContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -290,7 +292,7 @@ const MessageTime = styled.div`
|
|||||||
color: var(--color-text-3);
|
color: var(--color-text-3);
|
||||||
`
|
`
|
||||||
|
|
||||||
const MessageContent = styled.div`
|
const MessageContentContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@ -38,7 +38,7 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
|
|||||||
(message: Message) => {
|
(message: Message) => {
|
||||||
const _messages = [...messages, message]
|
const _messages = [...messages, message]
|
||||||
setMessages(_messages)
|
setMessages(_messages)
|
||||||
db.topics.add({ id: topic.id, messages: _messages })
|
db.topics.put({ id: topic.id, messages: _messages })
|
||||||
},
|
},
|
||||||
[messages, topic]
|
[messages, topic]
|
||||||
)
|
)
|
||||||
@ -142,7 +142,7 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
|
|||||||
return (
|
return (
|
||||||
<Container id="messages" key={assistant.id} ref={containerRef}>
|
<Container id="messages" key={assistant.id} ref={containerRef}>
|
||||||
<Suggestions assistant={assistant} messages={messages} lastMessage={lastMessage} />
|
<Suggestions assistant={assistant} messages={messages} lastMessage={lastMessage} />
|
||||||
{lastMessage && <MessageItem message={lastMessage} />}
|
{lastMessage && <MessageItem key={lastMessage.id} message={lastMessage} />}
|
||||||
{reverse([...messages]).map((message, index) => (
|
{reverse([...messages]).map((message, index) => (
|
||||||
<MessageItem key={message.id} message={message} showMenu index={index} onDeleteMessage={onDeleteMessage} />
|
<MessageItem key={message.id} message={message} showMenu index={index} onDeleteMessage={onDeleteMessage} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user