jbot: cancel button + abort + better Hermes error message

- VoiceButton gets a red ✕ cancel button that appears during thinking/speaking
- AbortController per pipeline run — ✕ aborts the in-flight apiChat fetch,
  stops TTS playback, and returns to idle cleanly
- Hermes connection-refused error shows readable message instead of raw stack
- apiChat() accepts optional AbortSignal (4th arg), passed from App.jsx
- handleSpaceUp dep array updated

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-17 14:13:37 +10:00
co-authored by Copilot
parent bb3fe1b554
commit 2c41b46da0
5 changed files with 78 additions and 15 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
File diff suppressed because one or more lines are too long
+21
View File
@@ -1032,6 +1032,27 @@ kbd {
cursor: not-allowed;
}
/* Cancel button — appears next to the main button when busy */
.vbtn-cancel {
flex: 0 0 auto;
width: 52px;
height: 52px;
margin-left: 10px;
border: none;
border-radius: 50%;
background: #EF4444;
color: #fff;
font-size: 1.1rem;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 3px 10px rgba(239,68,68,0.45);
transition: transform 0.1s, box-shadow 0.1s;
touch-action: manipulation;
}
.vbtn-cancel:active { transform: scale(0.93); box-shadow: none; }
/* ── Mobile overrides ─────────────────────────────────────── */
@media (max-width: 700px) {
/* Fixed bar at bottom so it's always reachable */
+51 -10
View File
@@ -46,7 +46,7 @@ function speakText(text) {
}
// ── Hold-to-talk voice button (defined outside App to keep stable identity) ──
function VoiceButton({ phase, onStart, onEnd }) {
function VoiceButton({ phase, onStart, onEnd, onCancel }) {
const busy = phase === 'thinking' || phase === 'speaking';
let label = '🎙 按住说话';
let mod = '';
@@ -72,6 +72,9 @@ function VoiceButton({ phase, onStart, onEnd }) {
>
{label}
</button>
{busy && (
<button className="vbtn-cancel" onClick={onCancel} title="取消"></button>
)}
</div>
);
}
@@ -140,6 +143,18 @@ export default function App() {
const refreshMemories = useCallback(() => setMemories(getMemories()), []);
// AbortController for the current voice pipeline (chat + TTS)
const abortRef = useRef(null);
const handleCancel = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setVoicePhase('idle');
setVoiceStatus(null);
setExprId('idle');
scheduleNext();
}, [scheduleNext]);
// Load backend mode from server config on mount
useEffect(() => {
apiConfig().then(cfg => {
@@ -224,6 +239,10 @@ export default function App() {
appendMsg('user', userText, userAudio, userMime);
setVoiceStatus({ type: 'thinking', text: '⚙ 思考中…' });
// Create a fresh AbortController for this pipeline run
const ac = new AbortController();
abortRef.current = ac;
// ── Step 2: LLM ─────────────────────────────────────────────
let botText = '';
let toolCalls = [];
@@ -232,12 +251,29 @@ export default function App() {
const currentMemories = isHermesMode ? [] : getMemories().map(m => m.text);
const currentSettings = getSettings();
const newHistory = [...llmHistory, { role: 'user', content: userText }];
const result = await apiChat(newHistory.slice(-10), currentMemories, currentSettings);
const result = await apiChat(newHistory.slice(-10), currentMemories, currentSettings, ac.signal);
botText = result.text;
toolCalls = result.toolCalls || [];
setLlmHistory([...newHistory, { role: 'assistant', content: botText }].slice(-20));
} catch (err) {
botText = `对话出错: ${err.message}`;
if (err.name === 'AbortError') {
// User cancelled — clean up and return without appending a bot message
setVoicePhase('idle');
setExprId('idle');
setVoiceStatus(null);
scheduleNext();
return;
}
// Friendly label for Hermes connection failures
const msg = err.message.includes('Cannot connect to Hermes')
? `⚠ Hermes Agent 无法连接,请检查 ${err.message.match(/at (.+?) —/)?.[1] ?? '地址'} 是否在线`
: `对话出错: ${err.message}`;
botText = msg;
}
// If aborted between LLM and TTS, bail out
if (ac.signal.aborted) {
setVoicePhase('idle'); setExprId('idle'); setVoiceStatus(null); scheduleNext(); return;
}
// ── Step 3: TTS ──────────────────────────────────────────────
@@ -281,20 +317,24 @@ export default function App() {
}).catch(() => {});
}
// Play audio
if (botAudio) {
await playAudioBase64(botAudio, 'audio/mp3');
} else {
await speakText(botText);
// Play audio (only if not cancelled)
if (!ac.signal.aborted) {
if (botAudio) {
await playAudioBase64(botAudio, 'audio/mp3');
} else {
await speakText(botText);
}
}
abortRef.current = null;
// ── Done: resume auto-rotate ─────────────────────────────────
setVoicePhase('idle');
setVoiceStatus(null);
const nextId = ALL_AUTO_IDS[Math.floor(Math.random() * ALL_AUTO_IDS.length)];
setExprId(nextId);
scheduleNext();
}, [stopRec, llmHistory, scheduleNext]);
}, [stopRec, llmHistory, scheduleNext, isHermesMode, refreshMemories]);
// ── Keyboard handler ──────────────────────────────────────────
useEffect(() => {
@@ -397,7 +437,8 @@ export default function App() {
<div className="hermes-mode-badge">🧠 Hermes Agent 模式 · 记忆由 Agent 管理</div>
)}
<ChatPanel messages={chatMessages} voiceStatus={voiceStatus} />
<VoiceButton phase={voicePhase} onStart={handleSpaceDown} onEnd={handleSpaceUp} />
<VoiceButton phase={voicePhase} onStart={handleSpaceDown} onEnd={handleSpaceUp}
onCancel={handleCancel} />
</div>
</div>
</div>
+2 -1
View File
@@ -7,11 +7,12 @@ export async function apiConfig() {
}
// Send conversation history + optional long-term memories + settings, returns { text }
export async function apiChat(messages, memories = [], settings = {}) {
export async function apiChat(messages, memories = [], settings = {}, signal) {
const r = await fetch(`${BASE}/chat/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, memories, settings }),
signal,
});
if (!r.ok) {
const e = await r.json().catch(() => ({}));