Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/_locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,5 +160,12 @@
"Type": "Type",
"Mode": "Mode",
"Custom": "Custom",
"Crop Text to ensure the input tokens do not exceed the model's limit": "Crop Text to ensure the input tokens do not exceed the model's limit"
"Crop Text to ensure the input tokens do not exceed the model's limit": "Crop Text to ensure the input tokens do not exceed the model's limit",
"Text-to-Speech Settings": "Text-to-Speech Settings",
"Enable OpenAI TTS (requires API key)": "Enable OpenAI TTS (requires API key)",
"TTS Voice": "TTS Voice",
"TTS Model": "TTS Model",
"TTS Speed": "TTS Speed",
"Read Aloud": "Read Aloud",
"Read Selected Text": "Read Selected Text"
}
9 changes: 8 additions & 1 deletion src/_locales/zh-hans/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -166,5 +166,12 @@
"ChatGLM (Emohaa)": "ChatGLM (Emohaa, 专业情绪咨询)",
"ChatGLM (CharGLM-3)": "ChatGLM (CharGLM-3, 角色扮演)",
"Crop Text to ensure the input tokens do not exceed the model's limit": "裁剪文本以确保输入token不超过模型限制",
"Thinking Content": "思考内容"
"Thinking Content": "思考内容",
"Text-to-Speech Settings": "语音朗读设置",
"Enable OpenAI TTS (requires API key)": "启用 OpenAI TTS (需要 API 密钥)",
"TTS Voice": "TTS 声音",
"TTS Model": "TTS 模型",
"TTS Speed": "朗读速度",
"Read Aloud": "朗读",
"Read Selected Text": "朗读选中文本"
}
82 changes: 77 additions & 5 deletions src/components/ReadButton/index.jsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { MuteIcon, UnmuteIcon } from '@primer/octicons-react'
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
import { useConfig } from '../../hooks/use-config.mjs'
import { speakText, isTtsAvailable } from '../../services/openai-tts.mjs'

ReadButton.propTypes = {
contentFn: PropTypes.func.isRequired,
Expand All @@ -15,9 +16,62 @@ const synth = window.speechSynthesis
function ReadButton({ className, contentFn, size }) {
const { t } = useTranslation()
const [speaking, setSpeaking] = useState(false)
const [loading, setLoading] = useState(false)
const [useOpenAiTts, setUseOpenAiTts] = useState(false)
const [currentAudio, setCurrentAudio] = useState(null)
const config = useConfig()

const startSpeak = () => {
// Check if OpenAI TTS is available on component mount and config changes
useEffect(() => {
const checkTtsAvailability = async () => {
const available = await isTtsAvailable()
setUseOpenAiTts(available)
}
checkTtsAvailability()
}, [config.enableOpenAiTts, config.apiKey])

const startOpenAiTtsSpeak = async () => {
try {
setLoading(true)
setSpeaking(true)

const text = contentFn()
const audio = await speakText(text, {
voice: config.openAiTtsVoice,
model: config.openAiTtsModel,
speed: config.openAiTtsSpeed,
})

setCurrentAudio(audio)
setLoading(false)

// Play the audio
await audio.play()

// Handle audio end
audio.onended = () => {
setSpeaking(false)
setCurrentAudio(null)
}

audio.onerror = () => {
setSpeaking(false)
setCurrentAudio(null)
setLoading(false)
console.error('Audio playback error')
}
} catch (error) {
console.error('OpenAI TTS error:', error)
setLoading(false)
setSpeaking(false)
setCurrentAudio(null)

// Fallback to system TTS on error
startSystemTtsSpeak()
}
}

const startSystemTtsSpeak = () => {
synth.cancel()

const text = contentFn()
Expand Down Expand Up @@ -46,18 +100,36 @@ function ReadButton({ className, contentFn, size }) {
setSpeaking(true)
}

const startSpeak = () => {
if (useOpenAiTts) {
startOpenAiTtsSpeak()
} else {
startSystemTtsSpeak()
}
}

const stopSpeak = () => {
if (currentAudio) {
currentAudio.pause()
currentAudio.currentTime = 0
setCurrentAudio(null)
}
synth.cancel()
setSpeaking(false)
setLoading(false)
}

// Show loading state or speaking state
const isActive = speaking || loading

return (
<span
title={t('Read Aloud')}
className={`gpt-util-icon ${className ? className : ''}`}
onClick={speaking ? stopSpeak : startSpeak}
className={`gpt-util-icon ${className ? className : ''} ${loading ? 'loading' : ''}`}
onClick={isActive ? stopSpeak : startSpeak}
style={{ opacity: loading ? 0.6 : 1 }}
>
{speaking ? <MuteIcon size={size} /> : <UnmuteIcon size={size} />}
{isActive ? <MuteIcon size={size} /> : <UnmuteIcon size={size} />}
</span>
)
}
Expand Down
6 changes: 6 additions & 0 deletions src/config/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,12 @@ export const defaultConfig = {
alwaysPinWindow: false,
focusAfterAnswer: true,

// TTS settings
enableOpenAiTts: false,
openAiTtsVoice: 'alloy',
openAiTtsModel: 'tts-1',
openAiTtsSpeed: 1.0,

apiKey: '', // openai ApiKey

azureApiKey: '',
Expand Down
56 changes: 56 additions & 0 deletions src/content-script/menu-tools/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getCoreContentText } from '../../utils/get-core-content-text'
import Browser from 'webextension-polyfill'
import { getUserConfig } from '../../config/index.mjs'
import { openUrl } from '../../utils/open-url'
import { speakText, isTtsAvailable } from '../../services/openai-tts.mjs'

export const config = {
newChat: {
Expand All @@ -16,6 +17,61 @@ export const config = {
return `You are an expert summarizer. Carefully analyze the following web page content and provide a concise summary focusing on the key points:\n${getCoreContentText()}`
},
},
readSelectedText: {
label: 'Read Selected Text',
action: async (fromBackground) => {
console.debug('read selected text action from background', fromBackground)

const selection = window.getSelection()
const selectedText = selection ? selection.toString().trim() : ''

if (!selectedText) {
alert('Please select some text first')
return
}

try {
const config = await getUserConfig()
const useTts = await isTtsAvailable()

if (useTts) {
// Use OpenAI TTS
await speakText(selectedText, {
voice: config.openAiTtsVoice,
model: config.openAiTtsModel,
speed: config.openAiTtsSpeed,
})
} else {
// Fallback to system TTS
const synth = window.speechSynthesis
synth.cancel()

const utterance = new SpeechSynthesisUtterance(selectedText)
const voices = synth.getVoices()

let voice
if (config.preferredLanguage.includes('en') && navigator.language.includes('en'))
voice = voices.find((v) => v.name.toLowerCase().includes('microsoft aria'))
else if (config.preferredLanguage.includes('zh') || navigator.language.includes('zh'))
voice = voices.find((v) => v.name.toLowerCase().includes('xiaoyi'))
else if (config.preferredLanguage.includes('ja') || navigator.language.includes('ja'))
voice = voices.find((v) => v.name.toLowerCase().includes('nanami'))
if (!voice)
voice = voices.find((v) => v.lang.substring(0, 2) === config.preferredLanguage)
if (!voice) voice = voices.find((v) => v.lang === navigator.language)

if (voice) utterance.voice = voice
utterance.rate = 1
utterance.volume = 1

synth.speak(utterance)
}
} catch (error) {
console.error('Error reading selected text:', error)
alert('Error reading selected text: ' + error.message)
}
},
},
openConversationPage: {
label: 'Open Conversation Page',
action: async (fromBackground) => {
Expand Down
82 changes: 82 additions & 0 deletions src/popup/sections/GeneralPart.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,88 @@ export function GeneralPart({ config, updateConfig, setTabIndex }) {
/>
{t("Crop Text to ensure the input tokens do not exceed the model's limit")}
</label>

{/* Text-to-Speech Settings */}
<br />
<fieldset>
<legend>{t('Text-to-Speech Settings')}</legend>
<label>
<input
type="checkbox"
checked={config.enableOpenAiTts}
onChange={(e) => {
const checked = e.target.checked
updateConfig({ enableOpenAiTts: checked })
}}
/>
{t('Enable OpenAI TTS (requires API key)')}
</label>
{config.enableOpenAiTts && (
<>
<label>
<legend>{t('TTS Voice')}</legend>
Copy link
Preview

Copilot AI Aug 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The legend element should not be used inside a label. Use a span or div element instead, or move the legend outside the label if you need fieldset grouping.

Suggested change
<legend>{t('TTS Voice')}</legend>
<span>{t('TTS Voice')}</span>

Copilot uses AI. Check for mistakes.

<select
required
onChange={(e) => {
const voice = e.target.value
updateConfig({ openAiTtsVoice: voice })
}}
>
<option value="alloy" selected={config.openAiTtsVoice === 'alloy'}>
Alloy
</option>
<option value="echo" selected={config.openAiTtsVoice === 'echo'}>
Echo
</option>
<option value="fable" selected={config.openAiTtsVoice === 'fable'}>
Fable
</option>
<option value="onyx" selected={config.openAiTtsVoice === 'onyx'}>
Onyx
</option>
<option value="nova" selected={config.openAiTtsVoice === 'nova'}>
Nova
</option>
<option value="shimmer" selected={config.openAiTtsVoice === 'shimmer'}>
Copy link
Preview

Copilot AI Aug 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use the value prop instead of selected attribute for React select elements. The selected attribute is deprecated in React. Set the value prop on the select element to config.openAiTtsVoice || 'alloy'.

Suggested change
<option value="shimmer" selected={config.openAiTtsVoice === 'shimmer'}>
value={config.openAiTtsVoice || 'alloy'}
onChange={(e) => {
const voice = e.target.value
updateConfig({ openAiTtsVoice: voice })
}}
>
<option value="alloy">
Alloy
</option>
<option value="echo">
Echo
</option>
<option value="fable">
Fable
</option>
<option value="onyx">
Onyx
</option>
<option value="nova">
Nova
</option>
<option value="shimmer">

Copilot uses AI. Check for mistakes.

Shimmer
</option>
</select>
</label>
<label>
<legend>{t('TTS Model')}</legend>
Copy link
Preview

Copilot AI Aug 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The legend element should not be used inside a label. Use a span or div element instead, or move the legend outside the label if you need fieldset grouping.

Suggested change
<legend>{t('TTS Model')}</legend>
<span>{t('TTS Model')}</span>

Copilot uses AI. Check for mistakes.

<select
required
onChange={(e) => {
const model = e.target.value
updateConfig({ openAiTtsModel: model })
}}
>
<option value="tts-1" selected={config.openAiTtsModel === 'tts-1'}>
TTS-1 (Standard)
</option>
<option value="tts-1-hd" selected={config.openAiTtsModel === 'tts-1-hd'}>
Copy link
Preview

Copilot AI Aug 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use the value prop instead of selected attribute for React select elements. The selected attribute is deprecated in React. Set the value prop on the select element to config.openAiTtsModel || 'tts-1'.

Suggested change
<option value="tts-1-hd" selected={config.openAiTtsModel === 'tts-1-hd'}>
value={config.openAiTtsModel || 'tts-1'}
onChange={(e) => {
const model = e.target.value
updateConfig({ openAiTtsModel: model })
}}
>
<option value="tts-1">
TTS-1 (Standard)
</option>
<option value="tts-1-hd">

Copilot uses AI. Check for mistakes.

TTS-1-HD (High Quality)
</option>
</select>
</label>
<label>
<legend>{t('TTS Speed')}</legend>
Copy link
Preview

Copilot AI Aug 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The legend element should not be used inside a label. Use a span or div element instead, or move the legend outside the label if you need fieldset grouping.

Suggested change
<legend>{t('TTS Speed')}</legend>
<span>{t('TTS Speed')}</span>

Copilot uses AI. Check for mistakes.

<input
type="range"
min="0.25"
max="4.0"
step="0.25"
value={config.openAiTtsSpeed}
onChange={(e) => {
const speed = parseFloat(e.target.value)
updateConfig({ openAiTtsSpeed: speed })
}}
/>
<span>{config.openAiTtsSpeed}x</span>
</label>
</>
)}
</fieldset>
<br />
<div style={{ display: 'flex', gap: '10px' }}>
<button
Expand Down
Loading