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