mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
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:
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+9
-3
File diff suppressed because one or more lines are too long
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'} />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user