feat: add copy button on message footer

This commit is contained in:
kangfenmao 2024-09-16 11:51:20 +08:00
parent fa1f00f4f5
commit e7f7f8509e
5 changed files with 107 additions and 72 deletions

View File

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

View File

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

View File

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

View File

@ -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;

View File

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