Files
links/static_src/jbot/App.jsx
T
junvandCopilot bb3fe1b554 jbot: disable local memory in Hermes Agent mode
In Hermes mode JBOT is a pure voice UI — memory is owned by the Agent:
- Backend: sys prompt excludes local memories when backend_mode=hermes
- Backend: config_view now returns backendMode so the frontend can adapt
- Frontend: App.jsx loads backendMode on mount via apiConfig()
- Frontend: memories not passed to apiChat() when isHermesMode
- Frontend: memory extraction (apiExtractMemory) skipped when isHermesMode
- Frontend: MemoryPanel hidden in Hermes mode; replaced by subtle
  '🧠 Hermes Agent 模式 · 记忆由 Agent 管理' badge

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-17 14:13:37 +10:00

406 lines
16 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import RobotFace from './components/RobotFace';
import ChatPanel from './components/ChatPanel';
import MemoryPanel from './components/MemoryPanel';
import { EXPRESSIONS, ALL_AUTO_IDS, byId } from './data/expressions';
import { apiChat, apiTts, apiAsr, apiExtractMemory, apiConfig } from './services/api';
import { usePushToTalk } from './hooks/usePushToTalk';
import { addEntry } from './store/historyStore';
import { getMemories, addMemory } from './store/memoryStore';
import { getSettings, getEffectiveVoice } from './store/settingsStore';
import { VOICES, getVoiceLabel } from './data/voices';
import './App.css';
const INTERACTION = EXPRESSIONS.filter(e => e.category === 'interaction');
const AUTO = EXPRESSIONS.filter(e => e.category === 'auto');
const KEY_MAP = {
'1': 'idle', '2': 'listening', '3': 'thinking', '4': 'speaking',
'5': 'happy', '6': 'error', '7': 'confused', '8': 'dreaming',
'q': 'natural_smile', 'w': 'calm_relax', 'e': 'curious_observe', 'r': 'light_joy',
't': 'side_think', 'y': 'warm_friendly', 'u': 'quiet_listen', 'i': 'focus_gaze',
};
// Play base64-encoded audio blob; resolves when playback ends or on error
async function playAudioBase64(base64, mimeType = 'audio/mp3') {
return new Promise((resolve) => {
const audio = new Audio(`data:${mimeType};base64,${base64}`);
audio.onended = resolve;
audio.onerror = resolve; // don't block on error
audio.play().catch(resolve);
});
}
// Browser TTS fallback
function speakText(text) {
return new Promise((resolve) => {
if (!window.speechSynthesis) return resolve();
const utt = new SpeechSynthesisUtterance(text);
utt.lang = 'zh-CN';
utt.rate = 1.05;
utt.onend = resolve;
utt.onerror = resolve;
window.speechSynthesis.speak(utt);
});
}
// ── Hold-to-talk voice button (defined outside App to keep stable identity) ──
function VoiceButton({ phase, onStart, onEnd }) {
const busy = phase === 'thinking' || phase === 'speaking';
let label = '🎙 按住说话';
let mod = '';
if (phase === 'acquiring') { label = '⏳ 准备中…'; mod = ' vbtn--acquiring'; }
else if (phase === 'listening') { label = '🔴 松开发送'; mod = ' vbtn--recording'; }
else if (phase === 'thinking') { label = '⚙ 思考中…'; mod = ' vbtn--busy'; }
else if (phase === 'speaking') { label = '🔊 播放中…'; mod = ' vbtn--busy'; }
const startEvt = (e) => { e.preventDefault(); if (!busy) onStart(); };
const endEvt = (e) => { e.preventDefault(); onEnd(); };
return (
<div className="vbtn-wrap">
<button
className={`vbtn${mod}`}
disabled={busy}
onMouseDown={startEvt}
onMouseUp={endEvt}
onMouseLeave={endEvt}
onTouchStart={startEvt}
onTouchEnd={endEvt}
onTouchCancel={endEvt}
>
{label}
</button>
</div>
);
}
export default function App() {
const navigate = useNavigate();
// ── Expression / auto-rotate state ───────────────────────────
const [exprId, setExprId] = useState(ALL_AUTO_IDS[0]);
const [autoRotate, setAutoRotate] = useState(true);
const [scale, setScale] = useState(1);
// switchMs is read from settings on mount (changed only via Settings page)
const [switchMs] = useState(() => getSettings().switchIntervalMs ?? 10000);
const autoTimerRef = useRef(null);
const autoRotateRef = useRef(true);
const exprIdRef = useRef(ALL_AUTO_IDS[0]);
const scheduleRef = useRef(null);
const switchMsRef = useRef(switchMs);
useEffect(() => { autoRotateRef.current = autoRotate; }, [autoRotate]);
useEffect(() => { exprIdRef.current = exprId; }, [exprId]);
const clearAuto = useCallback(() => {
clearTimeout(autoTimerRef.current);
autoTimerRef.current = null;
}, []);
scheduleRef.current = () => {
clearAuto();
if (!autoRotateRef.current) return;
const jitter = 1 + (Math.random() - 0.5) * 0.3;
autoTimerRef.current = setTimeout(() => {
const candidates = ALL_AUTO_IDS.filter(id => id !== exprIdRef.current);
const nextId = candidates[Math.floor(Math.random() * candidates.length)];
setExprId(nextId);
scheduleRef.current?.();
}, switchMsRef.current * jitter);
};
const scheduleNext = useCallback(() => scheduleRef.current?.(), []);
useEffect(() => {
if (autoRotate) scheduleNext();
else clearAuto();
return clearAuto;
}, [autoRotate, scheduleNext, clearAuto]);
const pick = useCallback((id) => {
clearAuto();
setExprId(id);
if (autoRotateRef.current) scheduleNext();
}, [clearAuto, scheduleNext]);
// ── Voice pipeline state ─────────────────────────────────────
const { isRecording, start: startRec, stop: stopRec } = usePushToTalk();
// voicePhase: 'idle' | 'listening' | 'thinking' | 'speaking'
const [voicePhase, setVoicePhase] = useState('idle');
const [voiceStatus, setVoiceStatus] = useState(null); // { type, text }
const [chatMessages, setChatMessages] = useState([]);
const [llmHistory, setLlmHistory] = useState([]); // last N {role,content} for context
const [memories, setMemories] = useState(() => getMemories());
const [isHermesMode, setIsHermesMode] = useState(false);
const voicePhaseRef = useRef('idle');
const isSpaceDownRef = useRef(false);
const refreshMemories = useCallback(() => setMemories(getMemories()), []);
// Load backend mode from server config on mount
useEffect(() => {
apiConfig().then(cfg => {
if (cfg?.backendMode === 'hermes') setIsHermesMode(true);
}).catch(() => {});
}, []);
useEffect(() => { voicePhaseRef.current = voicePhase; }, [voicePhase]);
const appendMsg = (role, text, audioBase64, audioMime, toolCalls) => {
const msg = { id: crypto.randomUUID(), role, text, audioBase64, audioMime, toolCalls };
setChatMessages(prev => [...prev.slice(-29), msg]); // keep last 30
return msg;
};
// ── Full push-to-talk flow ────────────────────────────────────
const handleSpaceDown = useCallback(async () => {
if (isSpaceDownRef.current || voicePhaseRef.current !== 'idle') return;
isSpaceDownRef.current = true;
clearAuto();
// Show "acquiring mic" state while getUserMedia is pending
setVoicePhase('acquiring');
setVoiceStatus({ type: 'acquiring', text: '⏳ 麦克风准备中…' });
try {
await startRec(); // resolves once stream is live and MediaRecorder.start() called
} catch (err) {
console.warn('[PTT] Mic acquisition failed:', err.message);
isSpaceDownRef.current = false;
setVoicePhase('idle');
setVoiceStatus({ type: 'error', text: '⚠ 麦克风不可用' });
setTimeout(() => setVoiceStatus(null), 2500);
scheduleNext();
return;
}
// Only show "listening" once the mic is actually recording
setExprId('listening');
setVoicePhase('listening');
setVoiceStatus({ type: 'listening', text: '🎙 请讲话 · 松开结束' });
}, [clearAuto, startRec, scheduleNext]);
const handleSpaceUp = useCallback(async () => {
if (!isSpaceDownRef.current) return;
isSpaceDownRef.current = false;
// If still acquiring (Space released before mic was ready) — just cancel
if (voicePhaseRef.current === 'acquiring') {
setVoicePhase('idle');
setVoiceStatus(null);
scheduleNext();
return;
}
if (voicePhaseRef.current !== 'listening') return;
// ── Step 1: stop recording ────────────────────────────────
setVoicePhase('thinking');
setExprId('thinking');
setVoiceStatus({ type: 'thinking', text: '⚙ 识别中…' });
const { transcript: browserTranscript, audioBase64: userAudio, mimeType: userMime } = await stopRec();
// ── Transcribe with Volcengine ASR 2.0; fall back to browser SpeechRecognition ──
let userText = '';
if (userAudio) {
try {
setVoiceStatus({ type: 'thinking', text: '⚙ 识别中…' });
const { text } = await apiAsr(userAudio, userMime || 'audio/webm');
userText = text?.trim() || '';
} catch (err) {
console.warn('[ASR] Volcengine failed, using browser transcript:', err.message);
}
}
if (!userText) userText = browserTranscript.trim();
if (!userText) {
// Nothing recognised at all — silently abort
setVoicePhase('idle');
setExprId('idle');
setVoiceStatus(null);
return;
}
appendMsg('user', userText, userAudio, userMime);
setVoiceStatus({ type: 'thinking', text: '⚙ 思考中…' });
// ── Step 2: LLM ─────────────────────────────────────────────
let botText = '';
let toolCalls = [];
try {
// In Hermes mode don't pass local memories — Hermes owns its own memory
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);
botText = result.text;
toolCalls = result.toolCalls || [];
setLlmHistory([...newHistory, { role: 'assistant', content: botText }].slice(-20));
} catch (err) {
botText = `对话出错: ${err.message}`;
}
// ── Step 3: TTS ──────────────────────────────────────────────
setExprId('speaking');
setVoicePhase('speaking');
setVoiceStatus({ type: 'speaking', text: '🔊 播放中…' });
const voice = getEffectiveVoice();
let botAudio = null;
let usingBrowserTts = false;
try {
const { audioBase64 } = await apiTts(botText, voice);
botAudio = audioBase64;
} catch (err) {
console.warn('[TTS] Volcengine unavailable, using browser TTS:', err.message);
usingBrowserTts = true;
}
if (usingBrowserTts) {
setVoiceStatus({ type: 'speaking', text: '🔊 播放中… (浏览器语音)' });
}
appendMsg('bot', botText, botAudio, 'audio/mp3', toolCalls);
// Save to history
addEntry({
userText,
botText,
userAudioBase64: userAudio,
userAudioMime: userMime,
botAudioBase64: botAudio,
botAudioMime: 'audio/mp3',
});
// Background memory extraction — skip in Hermes mode (Hermes owns its memory)
if (!isHermesMode) {
apiExtractMemory(userText, botText).then(facts => {
let added = false;
facts.forEach(f => { if (addMemory(f)) added = true; });
if (added) refreshMemories();
}).catch(() => {});
}
// Play audio
if (botAudio) {
await playAudioBase64(botAudio, 'audio/mp3');
} else {
await speakText(botText);
}
// ── 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]);
// ── Keyboard handler ──────────────────────────────────────────
useEffect(() => {
function onKeyDown(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.code === 'Space' && !e.repeat) {
e.preventDefault();
handleSpaceDown();
return;
}
if (voicePhaseRef.current === 'idle') {
const id = KEY_MAP[e.key.toLowerCase()];
if (id) pick(id);
}
}
function onKeyUp(e) {
if (e.code === 'Space') {
e.preventDefault();
handleSpaceUp();
}
}
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
};
}, [handleSpaceDown, handleSpaceUp, pick]);
// ── Render ─────────────────────────────────────────────────
return (
<div className="app">
<header className="hdr">
<a href="/" className="hdr-home" title="返回主页">🏠</a>
<h1>J<span className="accent">BOT</span></h1>
<p className="subtitle">AI Robot Face · 240×240</p>
<nav className="hdr-nav">
<button className="hdr-link" onClick={() => navigate('/history')}>历史</button>
<button className="hdr-link" onClick={() => navigate('/settings')}>设置</button>
</nav>
</header>
<div className="page-body">
{/* ── Left column: face + controls ── */}
<aside className="left-col">
<section className="screen-wrap">
<div className="scale-controls">
<button className={`scale-btn${scale === 1 ? ' active' : ''}`} onClick={() => setScale(1)}>1×</button>
<button className={`scale-btn${scale === 2 ? ' active' : ''}`} onClick={() => setScale(2)}>2×</button>
</div>
<div className="bezel">
<RobotFace expressionId={exprId} scale={scale} />
</div>
<div className="expr-label">{byId[exprId]?.label}</div>
</section>
<section className="panel">
<div className="panel-section">
<h3>交互状态 <span className="key-hint">1 8</span></h3>
<div className="btn-grid">
{INTERACTION.map(e => (
<button
key={e.id}
className={`ebtn${exprId === e.id ? ' active' : ''}`}
style={{ '--c': e.color }}
onClick={() => pick(e.id)}
>{e.label}</button>
))}
</div>
</div>
<div className="panel-section">
<h3>微表情 <span className="key-hint">QI · 自动</span></h3>
<div className="btn-grid btn-grid-4">
{AUTO.map(e => (
<button
key={e.id}
className={`ebtn${exprId === e.id ? ' active' : ''}`}
style={{ '--c': e.color }}
onClick={() => pick(e.id)}
>{e.label}</button>
))}
</div>
</div>
<label className="toggle">
<input type="checkbox" checked={autoRotate} onChange={ev => setAutoRotate(ev.target.checked)} />
<span>自动轮换微表情</span>
</label>
</section>
</aside>
{/* ── Right column: memory + chat + bottom bar ── */}
<div className="right-col">
{!isHermesMode && (
<MemoryPanel memories={memories} onMemoriesChange={refreshMemories} />
)}
{isHermesMode && (
<div className="hermes-mode-badge">🧠 Hermes Agent 模式 · 记忆由 Agent 管理</div>
)}
<ChatPanel messages={chatMessages} voiceStatus={voiceStatus} />
<VoiceButton phase={voicePhase} onStart={handleSpaceDown} onEnd={handleSpaceUp} />
</div>
</div>
</div>
);
}