jbot: delay TTS status and strip markdown from speech

- Show a dedicated '生成语音中…' state while TTS is being generated
- Only switch to '播放中…' once audio is actually ready/playing
- Strip markdown formatting from the spoken text while preserving the raw
  assistant message in the chat UI
- Render assistant messages as lightweight markdown in the bubble view

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-17 20:34:57 +10:00
co-authored by Copilot
parent a06be33896
commit 4b2a4621fb
5 changed files with 220 additions and 11 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+9 -3
View File
File diff suppressed because one or more lines are too long
+50
View File
@@ -342,6 +342,11 @@ kbd {
background: #FEF3C7;
animation: pulse 1.2s ease-in-out infinite;
}
.voice-status-tts {
color: #1D4ED8;
background: #DBEAFE;
animation: pulse 1s ease-in-out infinite;
}
.voice-status-speaking {
color: #065F46;
background: #D1FAE5;
@@ -397,6 +402,51 @@ kbd {
font-size: 0.9rem;
line-height: 1.55;
}
.bubble-markdown {
display: flex;
flex-direction: column;
gap: 6px;
}
.bubble-paragraph {
margin: 0;
}
.bubble-heading {
margin: 0;
font-weight: 700;
line-height: 1.3;
}
.bubble-heading-1 { font-size: 1.05rem; }
.bubble-heading-2 { font-size: 0.98rem; }
.bubble-heading-3 { font-size: 0.94rem; }
.bubble-list {
margin: 0;
padding-left: 18px;
display: flex;
flex-direction: column;
gap: 4px;
}
.bubble-list li {
margin: 0;
}
.bubble-code {
margin: 0;
padding: 8px 10px;
border-radius: 8px;
background: rgba(15, 23, 42, 0.06);
overflow-x: auto;
white-space: pre-wrap;
font-size: 0.84rem;
}
.bubble-markdown code {
padding: 0 4px;
border-radius: 4px;
background: rgba(15, 23, 42, 0.06);
font-size: 0.86em;
}
.bubble-markdown a {
color: var(--accent);
word-break: break-word;
}
.bubble-user .bubble-text {
background: #DBEAFE;
color: #1E3A8A;
+25 -6
View File
@@ -45,6 +45,26 @@ function speakText(text) {
});
}
function stripMarkdownForSpeech(text) {
if (!text) return '';
return text
.replace(/```(?:[a-zA-Z0-9_-]+)?\n([\s\S]*?)```/g, (_, code) => code)
.replace(/`([^`]+)`/g, '$1')
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
.replace(/^\s{0,3}#{1,6}\s+/gm, '')
.replace(/^\s{0,3}>\s?/gm, '')
.replace(/^\s*[-*+]\s+/gm, '• ')
.replace(/^\s*\d+\.\s+/gm, '')
.replace(/(\*\*|__)(.*?)\1/g, '$2')
.replace(/(\*|_)(.*?)\1/g, '$2')
.replace(/\|/g, ' ')
.replace(/\r/g, '')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
// ── Hold-to-talk voice button (defined outside App to keep stable identity) ──
function VoiceButton({ phase, onStart, onEnd, onCancel }) {
const busy = phase === 'thinking' || phase === 'speaking';
@@ -297,22 +317,21 @@ export default function App() {
// ── Step 3: TTS ──────────────────────────────────────────────
setExprId('speaking');
setVoicePhase('speaking');
setVoiceStatus({ type: 'speaking', text: '🔊 播放中…' });
setVoiceStatus({ type: 'tts', text: '🔊 生成语音中…' });
const voice = getEffectiveVoice();
const speechText = stripMarkdownForSpeech(botText);
let botAudio = null;
let usingBrowserTts = false;
try {
const { audioBase64 } = await apiTts(botText, voice);
const { audioBase64 } = await apiTts(speechText, voice);
botAudio = audioBase64;
} catch (err) {
console.warn('[TTS] Volcengine unavailable, using browser TTS:', err.message);
usingBrowserTts = true;
}
if (usingBrowserTts) {
setVoiceStatus({ type: 'speaking', text: '🔊 播放中… (浏览器语音)' });
}
setVoiceStatus({ type: 'speaking', text: usingBrowserTts ? '🔊 播放中… (浏览器语音)' : '🔊 播放中…' });
appendMsg('bot', botText, botAudio, 'audio/mp3', toolCalls);
@@ -340,7 +359,7 @@ export default function App() {
if (botAudio) {
await playAudioBase64(botAudio, 'audio/mp3');
} else {
await speakText(botText);
await speakText(speechText);
}
}
+135 -1
View File
@@ -1,6 +1,140 @@
import { useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
function renderInlineMarkdown(text, keyPrefix) {
const out = [];
const re = /(\*\*[^*]+?\*\*|__[^_]+?__|`[^`]+?`|\[[^\]]+?\]\([^)]+?\))/g;
let last = 0;
let match;
let idx = 0;
while ((match = re.exec(text)) !== null) {
if (match.index > last) {
out.push(text.slice(last, match.index));
}
const token = match[0];
if (token.startsWith('**') || token.startsWith('__')) {
out.push(<strong key={`${keyPrefix}-b-${idx++}`}>{token.slice(2, -2)}</strong>);
} else if (token.startsWith('`')) {
out.push(<code key={`${keyPrefix}-c-${idx++}`}>{token.slice(1, -1)}</code>);
} else {
const close = token.indexOf(']');
const label = token.slice(1, close);
const href = token.slice(close + 2, -1);
out.push(
<a key={`${keyPrefix}-a-${idx++}`} href={href} target="_blank" rel="noreferrer">
{label}
</a>,
);
}
last = match.index + token.length;
}
if (last < text.length) {
out.push(text.slice(last));
}
return out;
}
function MarkdownText({ text }) {
const source = (text || '').replace(/\r\n/g, '\n');
const lines = source.split('\n');
const blocks = [];
let i = 0;
let blockKey = 0;
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) {
i += 1;
continue;
}
if (line.startsWith('```')) {
const codeLines = [];
i += 1;
while (i < lines.length && !lines[i].startsWith('```')) {
codeLines.push(lines[i]);
i += 1;
}
if (i < lines.length) i += 1;
blocks.push(
<pre key={`md-${blockKey++}`} className="bubble-code">
<code>{codeLines.join('\n')}</code>
</pre>,
);
continue;
}
const heading = line.match(/^(#{1,6})\s+(.*)$/);
if (heading) {
const level = Math.min(heading[1].length, 3);
const Tag = `h${level}`;
blocks.push(
<Tag key={`md-${blockKey++}`} className={`bubble-heading bubble-heading-${level}`}>
{renderInlineMarkdown(heading[2], `h-${blockKey}`)}
</Tag>,
);
i += 1;
continue;
}
if (/^\s*[-*+]\s+/.test(line)) {
const items = [];
while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) {
items.push(lines[i].replace(/^\s*[-*+]\s+/, ''));
i += 1;
}
blocks.push(
<ul key={`md-${blockKey++}`} className="bubble-list">
{items.map((item, idx) => (
<li key={`md-${blockKey}-${idx}`}>{renderInlineMarkdown(item, `ul-${blockKey}-${idx}`)}</li>
))}
</ul>,
);
continue;
}
if (/^\s*\d+\.\s+/.test(line)) {
const items = [];
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
items.push(lines[i].replace(/^\s*\d+\.\s+/, ''));
i += 1;
}
blocks.push(
<ol key={`md-${blockKey++}`} className="bubble-list bubble-list-ordered">
{items.map((item, idx) => (
<li key={`md-${blockKey}-${idx}`}>{renderInlineMarkdown(item, `ol-${blockKey}-${idx}`)}</li>
))}
</ol>,
);
continue;
}
const para = [];
while (
i < lines.length &&
lines[i].trim() &&
!lines[i].startsWith('```') &&
!/^(#{1,6})\s+/.test(lines[i]) &&
!/^\s*[-*+]\s+/.test(lines[i]) &&
!/^\s*\d+\.\s+/.test(lines[i])
) {
para.push(lines[i]);
i += 1;
}
blocks.push(
<p key={`md-${blockKey++}`} className="bubble-paragraph">
{renderInlineMarkdown(para.join(' '), `p-${blockKey}`)}
</p>,
);
}
return <div className="bubble-markdown">{blocks}</div>;
}
function AudioButton({ base64, mimeType = 'audio/mp3', label = '🔊' }) {
const src = base64
? `data:${mimeType};base64,${base64}`
@@ -57,7 +191,7 @@ function Bubble({ role, text, audioBase64, audioMime, toolCalls }) {
<div className={`bubble ${isUser ? 'bubble-user' : 'bubble-bot'}`}>
<div className="bubble-label">{isUser ? '你' : 'JBOT'}</div>
{!isUser && <ToolCallsList toolCalls={toolCalls} />}
<div className="bubble-text">{text}</div>
<div className="bubble-text"><MarkdownText text={text} /></div>
{audioBase64 && (
<AudioButton base64={audioBase64} mimeType={audioMime || 'audio/mp3'} />
)}