mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
- 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>
377 lines
15 KiB
React
377 lines
15 KiB
React
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">Q–I · 自动</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>
|
||
);
|
||
}
|