diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5a2f025..c87535b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@fluentui/react-components": "^9.20.0", "@fluentui/react-icons": "^2.0.201", "@microsoft/fetch-event-source": "^2.0.1", + "@primer/octicons-react": "^19.1.0", "classnames": "^2.3.2", "github-markdown-css": "^5.2.0", "i18next": "^22.4.15", @@ -1936,6 +1937,17 @@ "node": ">= 8" } }, + "node_modules/@primer/octicons-react": { + "version": "19.1.0", + "resolved": "https://registry.npmmirror.com/@primer/octicons-react/-/octicons-react-19.1.0.tgz", + "integrity": "sha512-owWS3jHcsMOQMRzXURSnbqkkQCgmNOZWmm/vejzwnPU21m8Wz1Xng5i0pu1B/VuW7cmsNh5+r5XsVM8r1igY6A==", + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.3" + } + }, "node_modules/@remix-run/router": { "version": "1.6.1", "resolved": "https://registry.npmmirror.com/@remix-run/router/-/router-1.6.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index b966469..17afc8f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "@fluentui/react-components": "^9.20.0", "@fluentui/react-icons": "^2.0.201", "@microsoft/fetch-event-source": "^2.0.1", + "@primer/octicons-react": "^19.1.0", "classnames": "^2.3.2", "github-markdown-css": "^5.2.0", "i18next": "^22.4.15", diff --git a/frontend/src/_locales/zh-hans/main.json b/frontend/src/_locales/zh-hans/main.json index f59006c..0e0bb40 100644 --- a/frontend/src/_locales/zh-hans/main.json +++ b/frontend/src/_locales/zh-hans/main.json @@ -66,5 +66,7 @@ "Model Status": "模型状态", "Clear": "清除", "Send": "发送", - "Type your message here": "在此输入消息" + "Type your message here": "在此输入消息", + "Copy": "复制", + "Read Aloud": "朗读" } \ No newline at end of file diff --git a/frontend/src/components/CopyButton.tsx b/frontend/src/components/CopyButton.tsx new file mode 100644 index 0000000..74abc24 --- /dev/null +++ b/frontend/src/components/CopyButton.tsx @@ -0,0 +1,25 @@ +import {FC, useState} from 'react'; +import {CheckIcon, CopyIcon} from '@primer/octicons-react'; +import {useTranslation} from 'react-i18next'; +import {ClipboardSetText} from '../../wailsjs/runtime'; +import {ToolTipButton} from './ToolTipButton'; + +export const CopyButton: FC<{ content: string }> = ({content}) => { + const {t} = useTranslation(); + const [copied, setCopied] = useState(false); + + const onClick = () => { + ClipboardSetText(content) + .then(() => setCopied(true)) + .then(() => + setTimeout(() => { + setCopied(false); + }, 600) + ); + }; + + return ( + : } + onClick={onClick}/> + ); +}; diff --git a/frontend/src/components/ReadButton.tsx b/frontend/src/components/ReadButton.tsx new file mode 100644 index 0000000..9a2f7ea --- /dev/null +++ b/frontend/src/components/ReadButton.tsx @@ -0,0 +1,52 @@ +import {FC, useState} from 'react'; +import {MuteIcon, UnmuteIcon} from '@primer/octicons-react'; +import {useTranslation} from 'react-i18next'; +import {ToolTipButton} from './ToolTipButton'; +import commonStore from '../stores/commonStore'; +import {observer} from 'mobx-react-lite'; + +const synth = window.speechSynthesis; + +export const ReadButton: FC<{ content: string }> = observer(({content}) => { + const {t} = useTranslation(); + const [speaking, setSpeaking] = useState(false); + let lang: string = commonStore.settings.language; + if (lang === 'dev') + lang = 'en'; + + const startSpeak = () => { + synth.cancel(); + + const utterance = new SpeechSynthesisUtterance(content); + const voices = synth.getVoices(); + + let voice; + if (lang === 'en') + voice = voices.find((v) => v.name.toLowerCase().includes('microsoft aria')); + else if (lang === 'zh') + voice = voices.find((v) => v.name.toLowerCase().includes('xiaoyi')); + if (!voice) voice = voices.find((v) => v.lang.substring(0, 2) === lang); + if (!voice) voice = voices.find((v) => v.lang === navigator.language); + + Object.assign(utterance, { + rate: 1, + volume: 1, + onend: () => setSpeaking(false), + onerror: () => setSpeaking(false), + voice: voice + }); + + synth.speak(utterance); + setSpeaking(true); + }; + + const stopSpeak = () => { + synth.cancel(); + setSpeaking(false); + }; + + return ( + : } + onClick={speaking ? stopSpeak : startSpeak}/> + ); +}); diff --git a/frontend/src/pages/Chat.tsx b/frontend/src/pages/Chat.tsx index bd9c8d4..0dea717 100644 --- a/frontend/src/pages/Chat.tsx +++ b/frontend/src/pages/Chat.tsx @@ -14,6 +14,8 @@ import logo from '../../../build/appicon.png'; import MarkdownRender from '../components/MarkdownRender'; import {ToolTipButton} from '../components/ToolTipButton'; import {ArrowCircleUp28Regular, Delete28Regular, RecordStop28Regular} from '@fluentui/react-icons'; +import {CopyButton} from '../components/CopyButton'; +import {ReadButton} from '../components/ReadButton'; const userName = 'M E'; const botName = 'A I'; @@ -189,6 +191,14 @@ const ChatPanel: FC = observer(() => { 'flex gap-2 mb-2 overflow-hidden', conversation.side === 'left' ? 'flex-row' : 'flex-row-reverse' )} + onMouseEnter={() => { + const utils = document.getElementById('utils-' + uuid); + if (utils) utils.classList.remove('invisible'); + }} + onMouseLeave={() => { + const utils = document.getElementById('utils-' + uuid); + if (utils) utils.classList.add('invisible'); + }} > { > {conversation.content} - {(conversation.type === MessageType.Error || !conversation.done) && - - } + + + {(conversation.type === MessageType.Error || !conversation.done) && + + } + + + + + ; })}