diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index a929a457..e2f8c662 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -109,6 +109,7 @@ "message.new.context": "New Context", "message.regenerate.model": "Switch Model", "message.useful": "Helpful", + "message.quote": "Quote", "resend": "Resend", "save": "Save", "settings.code_collapsible": "Code block collapsible", @@ -392,7 +393,7 @@ "message.code_style": "Code style", "message.delete.content": "Are you sure you want to delete this message?", "message.delete.title": "Delete Message", - "message.multi_model_style": "Multi-model response style", + "message.multi_model_style": "Multi-model response style", "message.multi_model_style.fold": "Fold view", "message.multi_model_style.grid": "Grid layout", "message.multi_model_style.horizontal": "Side by side", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 5ac50f14..62465476 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -109,6 +109,7 @@ "message.new.context": "新しいコンテキスト", "message.regenerate.model": "モデルを切り替え", "message.useful": "役立つ", + "message.quote": "引用", "resend": "再送信", "save": "保存", "settings.code_collapsible": "コードブロックを折りたたむ", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 65fc8dc9..1cd745ca 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -109,6 +109,7 @@ "message.new.context": "Новый контекст", "message.regenerate.model": "Переключить модель", "message.useful": "Полезно", + "message.quote": "Цитата", "resend": "Переотправить", "save": "Сохранить", "settings.code_collapsible": "Блок кода свернут", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index c771f8be..75d1b4dc 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -109,6 +109,7 @@ "message.new.context": "清除上下文", "message.regenerate.model": "切换模型", "message.useful": "有用", + "message.quote": "引用", "resend": "重新发送", "save": "保存", "settings.code_collapsible": "代码块可折叠", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index e3cd412c..b64b02a4 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -109,6 +109,7 @@ "message.new.context": "新上下文", "message.regenerate.model": "切換模型", "message.useful": "有用", + "message.quote": "引用", "resend": "重新發送", "save": "保存", "settings.code_collapsible": "代码块可折叠", diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 67da05b7..cbfafd92 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -456,7 +456,15 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic }) => { _setEstimateTokenCount(tokensCount) setContextCount(contextCount) }), - EventEmitter.on(EVENT_NAMES.ADD_NEW_TOPIC, addNewTopic) + EventEmitter.on(EVENT_NAMES.ADD_NEW_TOPIC, addNewTopic), + EventEmitter.on(EVENT_NAMES.QUOTE_TEXT, (quotedText: string) => { + setText((prevText) => { + const newText = prevText ? `${prevText}\n${quotedText}\n` : `${quotedText}\n` + setTimeout(() => resizeTextArea(), 0) + return newText + }) + textareaRef.current?.focus() + }) ] return () => unsubscribes.forEach((unsub) => unsub()) }, [addNewTopic]) diff --git a/src/renderer/src/pages/home/Messages/Message.tsx b/src/renderer/src/pages/home/Messages/Message.tsx index f5971e96..b3e747b1 100644 --- a/src/renderer/src/pages/home/Messages/Message.tsx +++ b/src/renderer/src/pages/home/Messages/Message.tsx @@ -11,7 +11,7 @@ import { getModelUniqId } from '@renderer/services/ModelService' import { estimateHistoryTokens, estimateMessageUsage } from '@renderer/services/TokenService' import { Message, Topic } from '@renderer/types' import { classNames, runAsyncFunction } from '@renderer/utils' -import { Divider } from 'antd' +import { Divider, Dropdown } from 'antd' import { Dispatch, FC, memo, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -62,6 +62,8 @@ const MessageItem: FC = ({ const { showMessageDivider, messageFont, fontSize } = useSettings() const messageContainerRef = useRef(null) const topic = useTopic(assistant, _topic?.id) + const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null) + const [selectedQuoteText, setSelectedQuoteText] = useState('') const isLastMessage = index === 0 const isAssistantMessage = message.role === 'assistant' @@ -75,6 +77,30 @@ const MessageItem: FC = ({ const messageBorder = showMessageDivider ? undefined : 'none' const messageBackground = getMessageBackground(isBubbleStyle, isAssistantMessage) + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault() + const selectedText = window.getSelection()?.toString() + if (selectedText) { + const quotedText = + selectedText + .split('\n') + .map((line) => `> ${line}`) + .join('\n') + '\n-------------' + setSelectedQuoteText(quotedText) + setContextMenuPosition({ x: e.clientX, y: e.clientY }) + } + }, []) + + useEffect(() => { + const handleClick = () => { + setContextMenuPosition(null) + } + document.addEventListener('click', handleClick) + return () => { + document.removeEventListener('click', handleClick) + } + }, []) + const onEditMessage = useCallback( async (msg: Message) => { const usage = await estimateMessageUsage(msg) @@ -185,7 +211,44 @@ const MessageItem: FC = ({ 'message-user': !isAssistantMessage })} ref={messageContainerRef} + onContextMenu={handleContextMenu} style={{ ...style, alignItems: isBubbleStyle ? (isAssistantMessage ? 'start' : 'end') : undefined }}> + {contextMenuPosition && ( + + { + navigator.clipboard.writeText( + selectedQuoteText.replace(/^> /gm, '').replace(/\n-------------$/, '') + ) + window.message.success({ content: t('message.copied'), key: 'copy-message' }) + } + }, + { + key: 'quote', + label: t('chat.message.quote'), + onClick: () => { + EventEmitter.emit(EVENT_NAMES.QUOTE_TEXT, selectedQuoteText) + } + } + ] + }} + open={true} + trigger={['contextMenu']}> +
+ + + )}