mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
- 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>
245 lines
6.5 KiB
React
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>
|
|
);
|
|
}
|