From ba95e13b369d13e9bc1013ca30c0046ce23f98d2 Mon Sep 17 00:00:00 2001 From: yangyaofei Date: Tue, 9 Jun 2026 00:31:21 +0800 Subject: [PATCH] feat(tts): add TTS buttons to PlanView and FilesView markdown preview with configurable input mode (#1443) Add TTS controls to markdown preview surfaces so users can listen to plans and markdown files directly from the preview toolbar. Changes: - Add read-aloud / stop buttons to PlanView markdown preview. - Add read-aloud / stop buttons to FilesView markdown preview for markdown files. - Respect the existing showMessageTTSButtons preference in both preview views. - Add a persisted ttsInputMode setting with sanitized/raw modes. - Keep sanitized mode as the default for backward compatibility. - Allow raw markdown only for server TTS providers that can handle markdown. - Always use sanitized text for browser and macOS say fallback paths. - Add Voice Settings controls for TTS input mode. - Add i18n keys for the new preview buttons and setting labels. - Merge latest main and preserve newer FilesView toolbar/editor changes. Validation: - bun test packages/ui/src/stores/useConfigStore.test.ts packages/ui/src/components/chat/message/parts/ToolPart.test.ts packages/web/server/lib/tts/routes.test.js - bun run type-check - bun run lint - git diff --check --------- Co-authored-by: Bohdan Triapitsyn --- .../sections/openchamber/VoiceSettings.tsx | 32 +++++++++ .../ui/src/components/views/FilesView.tsx | 65 ++++++++++++++----- packages/ui/src/components/views/PlanView.tsx | 31 +++++++++ packages/ui/src/hooks/useMessageTTS.ts | 10 ++- .../ui/src/lib/i18n/messages/en.settings.ts | 3 + packages/ui/src/lib/i18n/messages/en.ts | 4 ++ .../ui/src/lib/i18n/messages/es.settings.ts | 3 + packages/ui/src/lib/i18n/messages/es.ts | 4 ++ .../ui/src/lib/i18n/messages/ko.settings.ts | 3 + packages/ui/src/lib/i18n/messages/ko.ts | 4 ++ .../ui/src/lib/i18n/messages/pl.settings.ts | 3 + packages/ui/src/lib/i18n/messages/pl.ts | 4 ++ .../src/lib/i18n/messages/pt-BR.settings.ts | 3 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 4 ++ .../ui/src/lib/i18n/messages/uk.settings.ts | 3 + packages/ui/src/lib/i18n/messages/uk.ts | 4 ++ .../src/lib/i18n/messages/zh-CN.settings.ts | 3 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 4 ++ .../src/lib/i18n/messages/zh-TW.settings.ts | 3 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 4 ++ packages/ui/src/stores/useConfigStore.ts | 16 +++++ 21 files changed, 191 insertions(+), 19 deletions(-) diff --git a/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx b/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx index 8b1bf656..33b2a0b8 100644 --- a/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx @@ -161,6 +161,8 @@ export const VoiceSettings: React.FC = () => { const openaiCompatibleTtsModel = useConfigStore((state) => state.openaiCompatibleTtsModel); const setOpenaiCompatibleTtsModel = useConfigStore((state) => state.setOpenaiCompatibleTtsModel); const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons); + const ttsInputMode = useConfigStore((state) => state.ttsInputMode); + const setTtsInputMode = useConfigStore((state) => state.setTtsInputMode); // STT settings const sttProvider = useConfigStore((state) => state.sttProvider); const setSttProvider = useConfigStore((state) => state.setSttProvider); @@ -1053,6 +1055,36 @@ export const VoiceSettings: React.FC = () => { {t('settings.voice.page.field.messageReadAloudButton')} +
+
+
+ + {t('settings.voice.page.field.ttsInputMode')} + +
+
+ + +
+
+
+ {voiceModeEnabled && isSupported && ( diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 2014df0f..f8bf1385 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -54,9 +54,10 @@ import { useDirectoryShowHidden } from '@/lib/directoryShowHidden'; import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; -import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; -import { Icon } from "@/components/icon/Icon"; -import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry'; +import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; +import { Icon } from "@/components/icon/Icon"; +import { useMessageTTS } from '@/hooks/useMessageTTS'; +import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry'; import { getDefaultTheme } from '@/lib/theme/themes'; import { openDesktopFileInApp, openDesktopPath } from '@/lib/desktop'; import { useOpenInAppsStore } from '@/stores/useOpenInAppsStore'; @@ -785,11 +786,12 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const activeDirectoryLoadIdsRef = React.useRef>(new Map()); const nextDirectoryLoadIdRef = React.useRef(0); - const [searchResults, setSearchResults] = React.useState([]); - const [searching, setSearching] = React.useState(false); - - const [fileContent, setFileContent] = React.useState(''); - const [fileLoading, setFileLoading] = React.useState(false); + const [searchResults, setSearchResults] = React.useState([]); + const [searching, setSearching] = React.useState(false); + + const [fileContent, setFileContent] = React.useState(''); + const { isPlaying: isTTSPlaying, play: playTTS, stop: stopTTS } = useMessageTTS(); + const [fileLoading, setFileLoading] = React.useState(false); const [fileError, setFileError] = React.useState(null); const [desktopImageSrc, setDesktopImageSrc] = React.useState(''); const [imageAssetAuthReadyKey, setImageAssetAuthReadyKey] = React.useState(''); @@ -911,6 +913,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); const fileEditorKeymap = useUIStore((state) => state.fileEditorKeymap); const settingsDefaultFileViewerPreview = useConfigStore((state) => state.settingsDefaultFileViewerPreview); + const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons); // Global mouseup to end drag selection React.useEffect(() => { @@ -3065,20 +3068,50 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { /> )} - {(isMarkdown || isHtmlFile(selectedFile?.path ?? '')) && ( - { if (isHtmlFile(selectedFile?.path ?? '')) { saveHtmlViewMode(getHtmlViewMode() === 'preview' ? 'edit' : 'preview'); } else { saveMdViewMode(getMdViewMode() === 'preview' ? 'edit' : 'preview'); } - }} - /> - )} - {isDrawio && ( - <> + }} + /> + )} + + {isMarkdown && getMdViewMode() === 'preview' && showMessageTTSButtons && ( + + + + + + {isTTSPlaying ? t('filesView.tts.stopSpeaking') : t('filesView.tts.readAloud')} + + + )} + + {isDrawio && ( + <> saveDrawioViewMode(drawioViewMode === 'preview' ? 'edit' : 'preview')} diff --git a/packages/ui/src/components/views/PlanView.tsx b/packages/ui/src/components/views/PlanView.tsx index c621c1c5..7d7a820a 100644 --- a/packages/ui/src/components/views/PlanView.tsx +++ b/packages/ui/src/components/views/PlanView.tsx @@ -40,6 +40,7 @@ import { parseProjectPlanMarkdown } from '@/lib/openchamberConfig'; import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator'; import { TodoSendDialog, type TodoSendExecution } from '@/components/session/TodoSendDialog'; import { Icon } from "@/components/icon/Icon"; +import { useMessageTTS } from '@/hooks/useMessageTTS'; import { renderMagicPrompt } from '@/lib/magicPrompts'; import { useI18n } from '@/lib/i18n'; @@ -197,6 +198,8 @@ export const PlanView: React.FC = ({ targetPath = null }) => { return toDisplayPath(resolvedPath, { currentDirectory: sessionDirectory, homeDirectory }); }, [resolvedPath, sessionDirectory, homeDirectory]); const [content, setContent] = React.useState(''); + const { isPlaying: isTTSPlaying, play: playTTS, stop: stopTTS } = useMessageTTS(); + const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons); const [saveError, setSaveError] = React.useState(null); const planFileLabel = React.useMemo(() => { return displayPath ? displayPath.split('/').pop() || t('planView.file.defaultName') : t('planView.file.defaultName'); @@ -689,6 +692,34 @@ export const PlanView: React.FC = ({ targetPath = null }) => { currentMode={mdViewMode} onToggle={() => saveMdViewMode(mdViewMode === 'preview' ? 'edit' : 'preview')} /> + {mdViewMode === 'preview' && showMessageTTSButtons && ( + + + + + + {isTTSPlaying ? t('planView.tts.stopSpeaking') : t('planView.tts.readAloud')} + + + )}