Files
links/static_src/jbot/App.jsx
T
junvandCopilot a87b4643fd feat: port JBOT AI robot face into links as Django app
- Add jbot Django app (jbot/__init__.py, apps.py, urls.py, api_urls.py)
- Python backend (jbot/views.py): TTS 2.0, ASR 1.0 BigASR, LLM/memory via OpenRouter
- React SPA frontend at /jbot/ (static_src/jbot/): RobotFace, ChatPanel, expressions,
  voices, push-to-talk, history, memory, settings pages
- Vite entry jbot: static_src/jbot/main.jsx → dist/jbot.js + dist/jbot.css
- react-router-dom added; BrowserRouter basename=/jbot for SPA routing
- core/settings.py: added jbot to INSTALLED_APPS
- core/urls.py: /jbot/ + /api/jbot/ URL includes
- pyproject.toml: websocket-client>=1.9.0 for BigASR binary WS protocol
- Dockerfile: ffmpeg added to production apt-get for WebM→PCM audio conversion
- k8s/manifest.yaml: VOLC_APP_ID, VOLC_ACCESS_TOKEN, OPENROUTER_API_KEY env vars
  via jbot-credentials Secret; LLM_MODEL, VOLC_TTS_VOICE, VOLC_ASR_RESOURCE defaults

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

377 lines
15 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 } 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);
});
}
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);
const [switchMs, setSwitchMs] = useState(10000);
const autoTimerRef = useRef(null);
const autoRotateRef = useRef(true);
const exprIdRef = useRef(ALL_AUTO_IDS[0]);
const scheduleRef = useRef(null);
const switchMsRef = useRef(10000);
useEffect(() => { autoRotateRef.current = autoRotate; }, [autoRotate]);
useEffect(() => { exprIdRef.current = exprId; }, [exprId]);
useEffect(() => { switchMsRef.current = switchMs; }, [switchMs]);
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 voicePhaseRef = useRef('idle');
const isSpaceDownRef = useRef(false);
const refreshMemories = useCallback(() => setMemories(getMemories()), []);
useEffect(() => { voicePhaseRef.current = voicePhase; }, [voicePhase]);
const appendMsg = (role, text, audioBase64, audioMime) => {
const msg = { id: crypto.randomUUID(), role, text, audioBase64, audioMime };
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: '🎙 请讲话 · 松开 Space 结束' });
}, [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 = '';
try {
const currentMemories = getMemories().map(m => m.text);
const currentSettings = getSettings();
const newHistory = [...llmHistory, { role: 'user', content: userText }];
const { text } = await apiChat(newHistory.slice(-10), currentMemories, currentSettings);
botText = text;
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');
// Save to history
addEntry({
userText,
botText,
userAudioBase64: userAudio,
userAudioMime: userMime,
botAudioBase64: botAudio,
botAudioMime: 'audio/mp3',
});
// Background memory extraction — never blocks the voice pipeline
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 ─────────────────────────────────────────────────
const currentVoiceLabel = getVoiceLabel(getEffectiveVoice());
return (
<div className="app">
<header className="hdr">
<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>
<div className="switch-interval">
<div className="switch-interval-label">
切换间隔 <span className="interval-val">{Math.round(switchMs / 1000)} </span>
</div>
<input
type="range" min="3" max="30" step="1"
value={Math.round(switchMs / 1000)}
onChange={ev => setSwitchMs(Number(ev.target.value) * 1000)}
/>
<div className="interval-ticks"><span>3s</span><span>30s</span></div>
</div>
<p className="hint">
按住 <kbd>Space</kbd> 说话 · 松开发送 · <button className="hist-inline-link" onClick={() => navigate('/history')}>历史记录</button>
</p>
<p className="hint voice-hint">
🔊 音色: <button className="hist-inline-link" onClick={() => navigate('/settings')}>{currentVoiceLabel}</button>
</p>
<MemoryPanel memories={memories} onMemoriesChange={refreshMemories} />
</section>
</aside>
{/* ── Right column: chat ── */}
<div className="right-col">
<ChatPanel messages={chatMessages} voiceStatus={voiceStatus} />
</div>
</div>
</div>
);
}