Files
links/static_src/jbot/components/ChatPanel.jsx
T
junvandCopilot 4b2a4621fb 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>
2026-05-17 20:34:57 +10:00

245 lines
6.5 KiB
React

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}`
: null;
if (!src) return null;
return (
<audio
className="audio-btn"
controls
src={src}
title={label}
preload="none"
/>
);
}
const TOOL_ICON = {
terminal: '💻',
web_search: '🔍',
web_extract: '📄',
browser: '🌐',
read_file: '📂',
write_file: '✏️',
execute_code: '⚡',
delegate_task:'🤖',
};
function ToolCallsList({ toolCalls }) {
if (!toolCalls || toolCalls.length === 0) return null;
return (
<details className="tool-calls">
<summary className="tool-calls-summary">
🔧 Hermes 使用了 {toolCalls.length} 个工具
</summary>
<ul className="tool-calls-list">
{toolCalls.map((t, i) => (
<li key={i} className="tool-calls-item">
<span className="tool-calls-icon">{TOOL_ICON[t.tool] || '🔧'}</span>
<span className="tool-calls-name">{t.tool}</span>
{t.preview && (
<code className="tool-calls-preview">{t.preview}</code>
)}
</li>
))}
</ul>
</details>
);
}
function Bubble({ role, text, audioBase64, audioMime, toolCalls }) {
const isUser = role === 'user';
return (
<div className={`bubble ${isUser ? 'bubble-user' : 'bubble-bot'}`}>
<div className="bubble-label">{isUser ? '你' : 'JBOT'}</div>
{!isUser && <ToolCallsList toolCalls={toolCalls} />}
<div className="bubble-text"><MarkdownText text={text} /></div>
{audioBase64 && (
<AudioButton base64={audioBase64} mimeType={audioMime || 'audio/mp3'} />
)}
</div>
);
}
export default function ChatPanel({ messages, voiceStatus }) {
const bottomRef = useRef(null);
const navigate = useNavigate();
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
return (
<section className="chat-panel">
<div className="chat-header">
<span className="chat-title">对话</span>
<button className="history-link" onClick={() => navigate('/history')}>
历史记录
</button>
</div>
{voiceStatus && (
<div className={`voice-status voice-status-${voiceStatus.type}`}>
{voiceStatus.text}
</div>
)}
<div className="chat-messages">
{messages.length === 0 ? (
<p className="chat-empty">按住 Space 开始说话</p>
) : (
messages.map((m) => (
<Bubble
key={m.id}
role={m.role}
text={m.text}
audioBase64={m.audioBase64}
audioMime={m.audioMime}
toolCalls={m.toolCalls}
/>
))
)}
<div ref={bottomRef} />
</div>
</section>
);
}