mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
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>
This commit is contained in:
@@ -120,6 +120,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpango-1.0-0 \
|
||||
libcairo2 \
|
||||
libasound2 \
|
||||
ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /tmp/* /var/tmp/* \
|
||||
|
||||
@@ -25,6 +25,7 @@ INSTALLED_APPS = [
|
||||
'nginxmon',
|
||||
'routermon',
|
||||
'pricemon',
|
||||
'jbot',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'core.urls'
|
||||
|
||||
@@ -32,6 +32,7 @@ urlpatterns = [
|
||||
# Add API URLs before locale URLs
|
||||
path('api/', include('links.api_urls')), # New line for API routes
|
||||
path('api/invest/', include('invest.api_urls')),
|
||||
path('api/jbot/', include('jbot.api_urls')), # JBOT API endpoints
|
||||
# Media files
|
||||
path('media/<path:path>', serve, {
|
||||
'document_root': settings.MEDIA_ROOT,
|
||||
@@ -56,6 +57,9 @@ urlpatterns = [
|
||||
path('ui/pricemon/', include('pricemon.urls')),
|
||||
path('ui/files/', include('links.file_urls')),
|
||||
|
||||
# JBOT AI Robot Face SPA
|
||||
path('jbot/', include('jbot.urls')),
|
||||
|
||||
# Import external image by URL — /import/images/<path:image_url> (also plural alias)
|
||||
path('import/images/<path:image_url>', import_image_view, name='import-image'),
|
||||
path('imports/images/<path:image_url>', import_image_view, name='imports-image'),
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('chat/', views.chat_view, name='jbot-api-chat'),
|
||||
path('tts/', views.tts_view, name='jbot-api-tts'),
|
||||
path('asr/', views.asr_view, name='jbot-api-asr'),
|
||||
path('memory/extract/', views.memory_extract_view, name='jbot-api-memory-extract'),
|
||||
path('config/', views.config_view, name='jbot-api-config'),
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class JbotConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'jbot'
|
||||
verbose_name = 'JBOT Robot Face'
|
||||
@@ -0,0 +1,14 @@
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>JBOT · AI Robot Face</title>
|
||||
<link rel="stylesheet" href="{% static 'dist/jbot.css' %}">
|
||||
</head>
|
||||
<body>
|
||||
<div id="jbot-root"></div>
|
||||
<script src="{% static 'dist/jbot.js' %}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
from django.urls import path, re_path
|
||||
from . import views
|
||||
|
||||
# SPA shell: serve index.html for all /jbot/* paths so React Router handles navigation
|
||||
urlpatterns = [
|
||||
path('', views.JbotIndexView.as_view(), name='jbot-index'),
|
||||
re_path(r'^.*$', views.JbotIndexView.as_view(), name='jbot-spa'),
|
||||
]
|
||||
+442
@@ -0,0 +1,442 @@
|
||||
"""
|
||||
JBOT backend views — Python port of the Node.js server/index.js
|
||||
Endpoints:
|
||||
GET /jbot/ → SPA shell (index.html)
|
||||
POST /api/jbot/chat/ → OpenRouter LLM proxy
|
||||
POST /api/jbot/tts/ → Volcengine seed-tts-2.0
|
||||
POST /api/jbot/asr/ → Volcengine BigASR 1.0 (WebSocket binary protocol)
|
||||
POST /api/jbot/memory/extract/ → background memory extraction via LLM
|
||||
GET /api/jbot/config/ → public config flags
|
||||
"""
|
||||
import base64
|
||||
import gzip
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
import subprocess
|
||||
import threading
|
||||
import uuid
|
||||
|
||||
import requests
|
||||
from django.http import JsonResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views import View
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── SPA shell ────────────────────────────────────────────────────────────────
|
||||
|
||||
class JbotIndexView(TemplateView):
|
||||
template_name = 'jbot/index.html'
|
||||
|
||||
|
||||
# ── Helper: require JSON POST ─────────────────────────────────────────────────
|
||||
|
||||
def _json_body(request):
|
||||
try:
|
||||
return json.loads(request.body)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
# ── TTS 2.0 (Volcengine seed-tts-2.0 HTTP streaming NDJSON) ─────────────────
|
||||
|
||||
def _do_tts(text, voice=None):
|
||||
app_id = os.environ.get('VOLC_APP_ID', '')
|
||||
token = os.environ.get('VOLC_ACCESS_TOKEN', '')
|
||||
speaker = voice or os.environ.get('VOLC_TTS_VOICE', 'zh_female_vv_uranus_bigtts')
|
||||
resource_id = os.environ.get('VOLC_TTS_RESOURCE', 'seed-tts-2.0')
|
||||
|
||||
if not app_id or not token:
|
||||
raise ValueError('Volcengine credentials not configured (VOLC_APP_ID / VOLC_ACCESS_TOKEN)')
|
||||
|
||||
resp = requests.post(
|
||||
'https://openspeech.bytedance.com/api/v3/tts/unidirectional',
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'X-Api-App-Id': app_id,
|
||||
'X-Api-Access-Key': token,
|
||||
'X-Api-Resource-Id': resource_id,
|
||||
'X-Api-Request-Id': str(uuid.uuid4()),
|
||||
},
|
||||
json={
|
||||
'user': {'uid': 'jbot-user'},
|
||||
'req_params': {
|
||||
'text': text,
|
||||
'speaker': speaker,
|
||||
'audio_params': {'format': 'mp3', 'sample_rate': 24000, 'speech_rate': 0},
|
||||
},
|
||||
},
|
||||
stream=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if not resp.ok:
|
||||
raise RuntimeError(f'TTS HTTP {resp.status_code}: {resp.text[:200]}')
|
||||
|
||||
audio_chunks = []
|
||||
for line in resp.iter_lines():
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
if obj.get('code') == 0 and obj.get('data'):
|
||||
audio_chunks.append(base64.b64decode(obj['data']))
|
||||
elif obj.get('code') == 20000000:
|
||||
break
|
||||
elif obj.get('code', 0) != 0:
|
||||
raise RuntimeError(f"TTS error code {obj.get('code')}: {obj.get('message', '')}")
|
||||
|
||||
if not audio_chunks:
|
||||
raise RuntimeError('TTS returned no audio data')
|
||||
|
||||
return b''.join(audio_chunks)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
def tts_view(request):
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'error': 'POST required'}, status=405)
|
||||
data = _json_body(request)
|
||||
text = data.get('text', '').strip()
|
||||
voice = data.get('voice', '')
|
||||
if not text:
|
||||
return JsonResponse({'error': 'text required'}, status=400)
|
||||
try:
|
||||
audio_bytes = _do_tts(text, voice or None)
|
||||
return JsonResponse({
|
||||
'audioBase64': base64.b64encode(audio_bytes).decode(),
|
||||
'format': 'mp3',
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('[TTS] %s', e)
|
||||
return JsonResponse({'error': str(e)}, status=500)
|
||||
|
||||
|
||||
# ── ASR 1.0 (Volcengine BigASR WebSocket binary protocol) ────────────────────
|
||||
|
||||
# Message type constants
|
||||
_ASR_MT_FULL_CLIENT = 1
|
||||
_ASR_MT_AUDIO_ONLY = 2
|
||||
_ASR_MT_FULL_SERVER = 9
|
||||
_ASR_MT_ERROR = 15
|
||||
|
||||
# Flag constants
|
||||
_FLAG_NO_SEQ = 0
|
||||
_FLAG_POS_SEQ = 1
|
||||
_FLAG_LAST_NO = 2
|
||||
_FLAG_NEG_SEQ = 3
|
||||
|
||||
# Serialization / compression
|
||||
_SER_NONE = 0
|
||||
_SER_JSON = 1
|
||||
_CMP_NONE = 0
|
||||
_CMP_GZIP = 1
|
||||
|
||||
|
||||
def _asr_pack(msg_type, flag, serialization=_SER_NONE, compression=_CMP_NONE,
|
||||
payload=b'', sequence=None):
|
||||
header = bytes([0x11, (msg_type << 4) | flag, (serialization << 4) | compression, 0x00])
|
||||
parts = [header]
|
||||
if flag in (_FLAG_POS_SEQ, _FLAG_NEG_SEQ):
|
||||
parts.append(struct.pack('>i', sequence or 0))
|
||||
parts.append(struct.pack('>I', len(payload)))
|
||||
parts.append(payload)
|
||||
return b''.join(parts)
|
||||
|
||||
|
||||
def _asr_unpack(data: bytes):
|
||||
header_bytes = (data[0] & 0x0F) * 4
|
||||
msg_type = (data[1] >> 4) & 0x0F
|
||||
flag = data[1] & 0x0F
|
||||
compression = data[2] & 0x0F
|
||||
|
||||
off = header_bytes
|
||||
if flag in (_FLAG_POS_SEQ, _FLAG_NEG_SEQ):
|
||||
off += 4
|
||||
|
||||
if msg_type == _ASR_MT_ERROR:
|
||||
code = struct.unpack_from('>I', data, off)[0]; off += 4
|
||||
msg_len = struct.unpack_from('>I', data, off)[0]; off += 4
|
||||
msg = data[off:off + msg_len].decode('utf-8', errors='replace')
|
||||
raise RuntimeError(f'ASR error {code}: {msg}')
|
||||
|
||||
payload = b''
|
||||
if off + 4 <= len(data):
|
||||
p_len = struct.unpack_from('>I', data, off)[0]; off += 4
|
||||
if p_len > 0:
|
||||
payload = data[off:off + p_len]
|
||||
|
||||
if compression == _CMP_GZIP and payload:
|
||||
payload = gzip.decompress(payload)
|
||||
|
||||
try:
|
||||
return json.loads(payload.decode('utf-8'))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _to_pcm(audio_bytes: bytes, mime_type: str = 'audio/webm') -> bytes:
|
||||
"""Convert browser audio (webm/ogg/mp4) to PCM 16kHz 16-bit mono via ffmpeg."""
|
||||
fmt = mime_type.split('/')[1].split(';')[0].strip() if '/' in mime_type else 'webm'
|
||||
result = subprocess.run(
|
||||
['ffmpeg', '-loglevel', 'error',
|
||||
'-f', fmt, '-i', 'pipe:0',
|
||||
'-ar', '16000', '-ac', '1', '-f', 's16le', 'pipe:1'],
|
||||
input=audio_bytes,
|
||||
capture_output=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f'ffmpeg PCM conversion failed: {result.stderr.decode()[:200]}')
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _do_asr(audio_bytes: bytes, mime_type: str = 'audio/webm') -> str:
|
||||
"""Transcribe audio using Volcengine BigASR 1.0 WebSocket binary protocol."""
|
||||
try:
|
||||
import websocket as ws_module
|
||||
except ImportError:
|
||||
raise RuntimeError('websocket-client not installed — run: uv add websocket-client')
|
||||
|
||||
app_id = os.environ.get('VOLC_APP_ID', '')
|
||||
token = os.environ.get('VOLC_ACCESS_TOKEN', '')
|
||||
resource_id = os.environ.get('VOLC_ASR_RESOURCE', 'volc.bigasr.sauc.duration')
|
||||
|
||||
if not app_id or not token:
|
||||
raise ValueError('Volcengine credentials not configured')
|
||||
|
||||
pcm = _to_pcm(audio_bytes, mime_type)
|
||||
if not pcm:
|
||||
raise RuntimeError('PCM conversion produced empty audio')
|
||||
|
||||
result = {'text': '', 'error': None}
|
||||
done_ev = threading.Event()
|
||||
|
||||
def on_open(ws):
|
||||
config = json.dumps({
|
||||
'user': {'uid': 'jbot-user'},
|
||||
'audio': {'format': 'pcm', 'codec': 'raw', 'rate': 16000, 'bits': 16, 'channel': 1},
|
||||
'request': {
|
||||
'model_name': 'bigmodel',
|
||||
'enable_itn': True,
|
||||
'enable_punc': True,
|
||||
'enable_ddc': False,
|
||||
'result_type': 'full',
|
||||
'end_window_size': 800,
|
||||
},
|
||||
}).encode()
|
||||
ws.send_binary(_asr_pack(_ASR_MT_FULL_CLIENT, _FLAG_NO_SEQ,
|
||||
_SER_JSON, _CMP_GZIP, gzip.compress(config)))
|
||||
|
||||
CHUNK = 6400
|
||||
pcm_data = pcm # captured from outer scope
|
||||
for off in range(0, len(pcm_data), CHUNK):
|
||||
chunk = pcm_data[off:off + CHUNK]
|
||||
is_last = (off + CHUNK >= len(pcm_data))
|
||||
ws.send_binary(_asr_pack(
|
||||
_ASR_MT_AUDIO_ONLY,
|
||||
_FLAG_LAST_NO if is_last else _FLAG_NO_SEQ,
|
||||
_SER_NONE, _CMP_GZIP, gzip.compress(chunk),
|
||||
))
|
||||
|
||||
def on_message(ws, data):
|
||||
try:
|
||||
parsed = _asr_unpack(data)
|
||||
if parsed and parsed.get('result'):
|
||||
t = parsed['result'].get('text', '')
|
||||
if not t:
|
||||
t = ''.join(
|
||||
u['text'] for u in parsed['result'].get('utterances', [])
|
||||
if u.get('definite')
|
||||
)
|
||||
if t:
|
||||
result['text'] = t
|
||||
except Exception as e:
|
||||
logger.warning('[ASR] parse error: %s', e)
|
||||
|
||||
def on_close(ws, code, msg):
|
||||
done_ev.set()
|
||||
|
||||
def on_error(ws, error):
|
||||
result['error'] = str(error)
|
||||
done_ev.set()
|
||||
|
||||
wsa = ws_module.WebSocketApp(
|
||||
'wss://openspeech.bytedance.com/api/v3/sauc/bigmodel',
|
||||
header={
|
||||
'X-Api-App-Key': app_id,
|
||||
'X-Api-Access-Key': token,
|
||||
'X-Api-Resource-Id': resource_id,
|
||||
'X-Api-Connect-Id': str(uuid.uuid4()),
|
||||
},
|
||||
on_open=on_open,
|
||||
on_message=on_message,
|
||||
on_close=on_close,
|
||||
on_error=on_error,
|
||||
)
|
||||
|
||||
t = threading.Thread(target=wsa.run_forever, daemon=True)
|
||||
t.start()
|
||||
done_ev.wait(timeout=15)
|
||||
|
||||
if result['error']:
|
||||
raise RuntimeError(result['error'])
|
||||
|
||||
return result['text']
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
def asr_view(request):
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'error': 'POST required'}, status=405)
|
||||
data = _json_body(request)
|
||||
audio_b64 = data.get('audioBase64', '')
|
||||
mime_type = data.get('format', 'audio/webm')
|
||||
if not audio_b64:
|
||||
return JsonResponse({'error': 'audioBase64 required'}, status=400)
|
||||
try:
|
||||
audio_bytes = base64.b64decode(audio_b64)
|
||||
text = _do_asr(audio_bytes, mime_type)
|
||||
return JsonResponse({'text': text or ''})
|
||||
except Exception as e:
|
||||
logger.error('[ASR] %s', e)
|
||||
return JsonResponse({'error': str(e)}, status=500)
|
||||
|
||||
|
||||
# ── Chat (OpenRouter LLM proxy) ───────────────────────────────────────────────
|
||||
|
||||
@csrf_exempt
|
||||
def chat_view(request):
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'error': 'POST required'}, status=405)
|
||||
|
||||
api_key = os.environ.get('OPENROUTER_API_KEY', '')
|
||||
if not api_key:
|
||||
return JsonResponse({'error': 'OPENROUTER_API_KEY not configured'}, status=503)
|
||||
|
||||
data = _json_body(request)
|
||||
messages = data.get('messages', [])
|
||||
memories = data.get('memories', [])
|
||||
settings = data.get('settings', {})
|
||||
model = os.environ.get('LLM_MODEL', 'deepseek/deepseek-chat')
|
||||
|
||||
robot_name = settings.get('robotName', 'JBOT')
|
||||
personality = settings.get('robotPersonality',
|
||||
'你是一个友好、简洁、偶尔幽默的AI机器人助手。回复控制在80字以内。')
|
||||
lang = settings.get('language', 'auto')
|
||||
lang_hint = ''
|
||||
if lang == 'zh-CN':
|
||||
lang_hint = '\n请用中文回复。'
|
||||
elif lang == 'en-US':
|
||||
lang_hint = '\nPlease reply in English.'
|
||||
|
||||
sys_content = f'你叫 {robot_name}。{personality}{lang_hint}'
|
||||
|
||||
if memories:
|
||||
sys_content += '\n\n关于用户的长期记忆:\n' + '\n'.join(f'- {m}' for m in memories)
|
||||
|
||||
extra = settings.get('extraContext', '').strip()
|
||||
if extra:
|
||||
sys_content += f'\n\n用户背景信息:\n{extra}'
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
'https://openrouter.ai/api/v1/chat/completions',
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {api_key}',
|
||||
'HTTP-Referer': 'https://go.junv.cc',
|
||||
'X-Title': 'JBOT Robot Face',
|
||||
},
|
||||
json={
|
||||
'model': model,
|
||||
'messages': [{'role': 'system', 'content': sys_content}, *messages],
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
text = result.get('choices', [{}])[0].get('message', {}).get('content', '')
|
||||
if not text:
|
||||
logger.error('[CHAT] LLM returned no content: %s', result)
|
||||
return JsonResponse({'error': 'LLM returned no content'}, status=500)
|
||||
return JsonResponse({'text': text})
|
||||
except Exception as e:
|
||||
logger.error('[CHAT] %s', e)
|
||||
return JsonResponse({'error': str(e)}, status=500)
|
||||
|
||||
|
||||
# ── Memory extraction ─────────────────────────────────────────────────────────
|
||||
|
||||
@csrf_exempt
|
||||
def memory_extract_view(request):
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'error': 'POST required'}, status=405)
|
||||
|
||||
api_key = os.environ.get('OPENROUTER_API_KEY', '')
|
||||
if not api_key:
|
||||
return JsonResponse({'facts': []})
|
||||
|
||||
data = _json_body(request)
|
||||
user_text = data.get('userText', '')
|
||||
bot_text = data.get('botText', '')
|
||||
if not user_text or not bot_text:
|
||||
return JsonResponse({'facts': []})
|
||||
|
||||
model = os.environ.get('LLM_MODEL', 'deepseek/deepseek-chat')
|
||||
prompt = (
|
||||
'You are a memory extraction assistant for an AI robot named JBOT.\n\n'
|
||||
f'Conversation exchange:\nUser: "{user_text}"\nJBOT: "{bot_text}"\n\n'
|
||||
'Extract 0–3 short factual statements about the USER worth remembering long-term '
|
||||
'(e.g. name, profession, preferences, ongoing projects, location). '
|
||||
'Max 20 words each. Only lasting facts, not one-time remarks. '
|
||||
'Reply with a JSON array of strings, e.g. ["User is a software engineer", ...]. '
|
||||
'If nothing worth remembering, reply with [].'
|
||||
)
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
'https://openrouter.ai/api/v1/chat/completions',
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {api_key}',
|
||||
'HTTP-Referer': 'https://go.junv.cc',
|
||||
'X-Title': 'JBOT Memory',
|
||||
},
|
||||
json={
|
||||
'model': model,
|
||||
'messages': [{'role': 'user', 'content': prompt}],
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
raw = resp.json().get('choices', [{}])[0].get('message', {}).get('content', '[]')
|
||||
raw = raw.strip()
|
||||
# strip ```json fences if present
|
||||
if raw.startswith('```'):
|
||||
raw = raw.split('\n', 1)[-1].rsplit('```', 1)[0].strip()
|
||||
facts = json.loads(raw)
|
||||
if not isinstance(facts, list):
|
||||
facts = []
|
||||
return JsonResponse({'facts': [str(f) for f in facts[:3]]})
|
||||
except Exception as e:
|
||||
logger.warning('[MEMORY] extraction failed: %s', e)
|
||||
return JsonResponse({'facts': []})
|
||||
|
||||
|
||||
# ── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def config_view(request):
|
||||
has_volc = bool(os.environ.get('VOLC_APP_ID') and os.environ.get('VOLC_ACCESS_TOKEN'))
|
||||
has_llm = bool(os.environ.get('OPENROUTER_API_KEY'))
|
||||
return JsonResponse({
|
||||
'volcengine': has_volc,
|
||||
'llm': has_llm,
|
||||
'ttsVoice': os.environ.get('VOLC_TTS_VOICE', 'zh_female_vv_uranus_bigtts'),
|
||||
})
|
||||
@@ -173,6 +173,28 @@ spec:
|
||||
value: "192.168.1.2"
|
||||
- name: OLLAMA_URL
|
||||
value: "http://ollama-service.ollama.svc.cluster.local:11434"
|
||||
# JBOT — AI Robot Face env vars
|
||||
- name: VOLC_APP_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: jbot-credentials
|
||||
key: app_id
|
||||
- name: VOLC_ACCESS_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: jbot-credentials
|
||||
key: access_token
|
||||
- name: OPENROUTER_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: jbot-credentials
|
||||
key: openrouter_api_key
|
||||
- name: LLM_MODEL
|
||||
value: "deepseek/deepseek-chat"
|
||||
- name: VOLC_TTS_VOICE
|
||||
value: "zh_female_vv_uranus_bigtts"
|
||||
- name: VOLC_ASR_RESOURCE
|
||||
value: "volc.bigasr.sauc.duration"
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
name: links-port
|
||||
|
||||
Generated
+62
@@ -25,6 +25,7 @@
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.15.1",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
},
|
||||
@@ -1596,6 +1597,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -2452,6 +2467,46 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.15.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz",
|
||||
"integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.15.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz",
|
||||
"integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.15.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
@@ -2589,6 +2644,13 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.15.1",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ dependencies = [
|
||||
"kubernetes>=29.0.0",
|
||||
"maxminddb>=3.1.1",
|
||||
"yfinance>=1.3.0",
|
||||
"websocket-client>=1.9.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
Vendored
+40
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+12
File diff suppressed because one or more lines are too long
Vendored
+1
-40
File diff suppressed because one or more lines are too long
@@ -0,0 +1,867 @@
|
||||
/* ── Design tokens ────────────────────────────────────────── */
|
||||
:root {
|
||||
--bg: #F1F5F9;
|
||||
--surface: #FFFFFF;
|
||||
--border: #E2E8F0;
|
||||
--text: #0F172A;
|
||||
--text2: #475569;
|
||||
--text3: #94A3B8;
|
||||
--accent: #0EA5E9;
|
||||
--accent-bg: #E0F7FE;
|
||||
--face-bg: #080C14;
|
||||
}
|
||||
|
||||
/* ── App shell ────────────────────────────────────────────── */
|
||||
.app {
|
||||
height: 100dvh;
|
||||
height: -webkit-fill-available;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Header ───────────────────────────────────────────────── */
|
||||
.hdr {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 20px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.hdr h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
.accent { color: var(--accent); }
|
||||
.subtitle {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text3);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.hdr-nav {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.hdr-link {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text2);
|
||||
font-size: 0.78rem;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.hdr-link:hover {
|
||||
background: var(--bg);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Two-column page body ─────────────────────────────────── */
|
||||
.page-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Left column: face + controls ────────────────────────── */
|
||||
.left-col {
|
||||
flex: 0 0 auto;
|
||||
width: 296px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 14px 14px 20px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* ── Right column: chat ───────────────────────────────────── */
|
||||
.right-col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* ── Screen / bezel ───────────────────────────────────────── */
|
||||
.screen-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.bezel {
|
||||
background: var(--face-bg);
|
||||
border-radius: 18px;
|
||||
padding: 12px;
|
||||
box-shadow: 0 4px 28px rgba(0,0,0,0.22), 0 0 0 1px rgba(255,255,255,0.06);
|
||||
}
|
||||
.expr-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text2);
|
||||
height: 1.4em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Scale toggle ─────────────────────────────────────────── */
|
||||
.scale-controls {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.scale-btn {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text2);
|
||||
padding: 3px 12px;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.scale-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.scale-btn.active {
|
||||
background: var(--accent-bg);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Control panel ────────────────────────────────────────── */
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.panel-section h3 {
|
||||
font-size: 0.67rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text3);
|
||||
margin: 0 0 7px;
|
||||
}
|
||||
.key-hint { font-weight: 400; opacity: 0.7; }
|
||||
|
||||
/* ── Expression button grids ──────────────────────────────── */
|
||||
.btn-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 5px;
|
||||
}
|
||||
.btn-grid-4 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
.btn-grid-4 .ebtn {
|
||||
font-size: 0.62rem;
|
||||
padding: 5px 3px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Expression buttons ───────────────────────────────────── */
|
||||
.ebtn {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
color: var(--text2);
|
||||
padding: 6px 9px;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
text-align: left;
|
||||
}
|
||||
.ebtn:hover {
|
||||
border-color: var(--c);
|
||||
color: var(--c);
|
||||
background: color-mix(in srgb, var(--c) 6%, var(--surface));
|
||||
}
|
||||
.ebtn.active {
|
||||
background: color-mix(in srgb, var(--c) 12%, var(--surface));
|
||||
border-color: var(--c);
|
||||
color: var(--c);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Auto-rotate toggle ───────────────────────────────────── */
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.toggle input[type="checkbox"] {
|
||||
accent-color: var(--accent);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* ── Interval slider ──────────────────────────────────────── */
|
||||
.switch-interval {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.switch-interval-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text2);
|
||||
}
|
||||
.interval-val {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.switch-interval input[type="range"] {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.interval-ticks {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.65rem;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
/* ── Hint ─────────────────────────────────────────────────── */
|
||||
.hint {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text3);
|
||||
line-height: 1.7;
|
||||
}
|
||||
kbd {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
font-size: 0.68rem;
|
||||
font-family: inherit;
|
||||
color: var(--text2);
|
||||
}
|
||||
.hist-inline-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
font-size: 0.72rem;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── Chat panel (fills right-col) ────────────────────────── */
|
||||
.chat-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 14px 18px;
|
||||
gap: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.chat-header {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.chat-title {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text3);
|
||||
}
|
||||
.history-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.history-link:hover { text-decoration: underline; }
|
||||
|
||||
/* Voice status badges */
|
||||
.voice-status {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
width: fit-content;
|
||||
}
|
||||
.voice-status-listening {
|
||||
color: #0369A1;
|
||||
background: #E0F2FE;
|
||||
animation: pulse 0.9s ease-in-out infinite;
|
||||
}
|
||||
.voice-status-thinking {
|
||||
color: #92400E;
|
||||
background: #FEF3C7;
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
.voice-status-speaking {
|
||||
color: #065F46;
|
||||
background: #D1FAE5;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* Messages scroll area */
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 4px 2px;
|
||||
/* Custom scrollbar */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
.chat-messages::-webkit-scrollbar { width: 4px; }
|
||||
.chat-messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
|
||||
.chat-empty {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text3);
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
/* ── Chat bubbles ─────────────────────────────────────────── */
|
||||
.bubble {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
max-width: 82%;
|
||||
}
|
||||
.bubble-user { align-self: flex-end; align-items: flex-end; }
|
||||
.bubble-bot { align-self: flex-start; align-items: flex-start; }
|
||||
.bubble-label {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text3);
|
||||
}
|
||||
.bubble-text {
|
||||
border-radius: 12px;
|
||||
padding: 9px 13px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.bubble-user .bubble-text {
|
||||
background: #DBEAFE;
|
||||
color: #1E3A8A;
|
||||
border-bottom-right-radius: 3px;
|
||||
}
|
||||
.bubble-bot .bubble-text {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-bottom-left-radius: 3px;
|
||||
}
|
||||
.audio-btn {
|
||||
width: 100%;
|
||||
max-width: 220px;
|
||||
height: 28px;
|
||||
margin-top: 3px;
|
||||
border-radius: 6px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.audio-btn:hover { opacity: 1; }
|
||||
|
||||
/* ── History page ─────────────────────────────────────────── */
|
||||
.hist-page {
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
}
|
||||
.hist-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--surface);
|
||||
z-index: 10;
|
||||
}
|
||||
.hist-heading {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
.hist-back {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text2);
|
||||
padding: 5px 12px;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hist-back:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.hist-clear {
|
||||
background: none;
|
||||
border: 1px solid #FECACA;
|
||||
border-radius: 6px;
|
||||
color: #DC2626;
|
||||
padding: 5px 12px;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hist-clear:hover:not(:disabled) { border-color: #DC2626; }
|
||||
.hist-clear:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.hist-empty {
|
||||
text-align: center;
|
||||
color: var(--text3);
|
||||
padding: 64px 32px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.hist-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
.hist-entry {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.hist-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.hist-time {
|
||||
font-size: 0.68rem;
|
||||
color: var(--text3);
|
||||
}
|
||||
.hist-del {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text3);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.hist-del:hover { color: #DC2626; }
|
||||
.hist-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.hist-role {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text3);
|
||||
min-width: 36px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.hist-row-user .hist-role { color: #2563EB; }
|
||||
.hist-row-bot .hist-role { color: #059669; }
|
||||
.hist-body { flex: 1; }
|
||||
.hist-text {
|
||||
font-size: 0.86rem;
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.hist-audio {
|
||||
width: 100%;
|
||||
max-width: 260px;
|
||||
height: 26px;
|
||||
opacity: 0.75;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.hist-audio:hover { opacity: 1; }
|
||||
|
||||
/* ── Mobile responsive ────────────────────────────────────── */
|
||||
@media (max-width: 700px) {
|
||||
.app {
|
||||
height: 100dvh;
|
||||
height: -webkit-fill-available;
|
||||
}
|
||||
|
||||
/* Compact header */
|
||||
.hdr { padding: 8px 14px; }
|
||||
.hdr h1 { font-size: 1.2rem; }
|
||||
.subtitle { display: none; }
|
||||
|
||||
/* Stack columns vertically */
|
||||
.page-body { flex-direction: column; }
|
||||
|
||||
/* Left col: face + controls takes top portion, scrollable if needed */
|
||||
.left-col {
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 10px 12px;
|
||||
gap: 8px;
|
||||
/* On mobile: face + minimal controls in a horizontal row */
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
overflow-x: hidden;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
/* Face sits on the left side on mobile */
|
||||
.screen-wrap {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Bezel smaller padding on mobile */
|
||||
.bezel { padding: 8px; }
|
||||
|
||||
/* Controls sit on the right side of face on mobile */
|
||||
.panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
overflow-y: auto;
|
||||
max-height: 272px; /* matches face height: 240 + 2*8 padding + 8 */
|
||||
}
|
||||
|
||||
/* Compact button grids on mobile */
|
||||
.btn-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
.btn-grid-4 {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 3px;
|
||||
}
|
||||
.ebtn { font-size: 0.7rem; padding: 5px 6px; }
|
||||
.btn-grid-4 .ebtn { font-size: 0.58rem; padding: 4px 2px; }
|
||||
|
||||
/* Hide scale toggle on mobile (always 1x) */
|
||||
.scale-controls { display: none; }
|
||||
|
||||
/* Right col: chat fills remaining height */
|
||||
.right-col {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Chat padding tighter on mobile */
|
||||
.chat-panel { padding: 10px 12px; }
|
||||
}
|
||||
|
||||
|
||||
/* ── Memory panel ─────────────────────────────────────────── */
|
||||
.memory-panel {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.memory-toggle {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 10px;
|
||||
background: var(--bg);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text2);
|
||||
text-align: left;
|
||||
}
|
||||
.memory-toggle:hover { background: #E8EEF6; }
|
||||
.memory-badge {
|
||||
margin-left: auto;
|
||||
background: var(--accent-bg);
|
||||
color: var(--accent);
|
||||
border-radius: 10px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
padding: 1px 6px;
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
.memory-arrow { font-size: 0.6rem; color: var(--text3); }
|
||||
.memory-body {
|
||||
padding: 8px 10px;
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.memory-empty {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text3);
|
||||
text-align: center;
|
||||
padding: 6px 0;
|
||||
margin: 0;
|
||||
}
|
||||
.memory-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
margin: 0 0 8px;
|
||||
padding: 0;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
.memory-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text2);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.memory-text { flex: 1; }
|
||||
.memory-del {
|
||||
flex: 0 0 auto;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text3);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.memory-del:hover { color: #DC2626; }
|
||||
.memory-clear {
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: 1px solid #FECACA;
|
||||
border-radius: 5px;
|
||||
color: #DC2626;
|
||||
font-size: 0.72rem;
|
||||
padding: 4px 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.memory-clear:hover { background: #FEF2F2; }
|
||||
|
||||
/* ── Voice hint ───────────────────────────────────────────── */
|
||||
.voice-hint {
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── Settings page ────────────────────────────────────────── */
|
||||
.settings-page {
|
||||
min-height: 100dvh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
.settings-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px 20px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.settings-back {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text2);
|
||||
font-size: 0.85rem;
|
||||
padding: 5px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.settings-back:hover { background: var(--bg); color: var(--accent); }
|
||||
.settings-heading {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.settings-save {
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
padding: 6px 16px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.settings-save:hover { opacity: 0.85; }
|
||||
|
||||
.settings-body {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 18px 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.settings-section {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.settings-section-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--text2);
|
||||
margin: 0 0 4px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.settings-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
}
|
||||
.settings-label-mt { margin-top: 6px; }
|
||||
.settings-hint {
|
||||
font-size: 0.73rem;
|
||||
font-weight: 400;
|
||||
color: var(--text3);
|
||||
margin-left: 4px;
|
||||
}
|
||||
.settings-hint-block {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text3);
|
||||
margin: 0;
|
||||
}
|
||||
.settings-hint-block a { color: var(--accent); }
|
||||
.settings-input {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 7px 10px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.settings-input:focus { border-color: var(--accent); }
|
||||
.settings-textarea {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 7px 10px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
transition: border-color 0.15s;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.settings-textarea:focus { border-color: var(--accent); }
|
||||
.settings-radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.settings-radio-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.settings-radio-label input { accent-color: var(--accent); }
|
||||
|
||||
/* Voice card grid */
|
||||
.settings-voice-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.settings-voice-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 10px 10px 8px;
|
||||
background: var(--bg);
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.settings-voice-btn:hover { border-color: var(--accent); background: var(--accent-bg); }
|
||||
.settings-voice-btn.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
.voice-btn-label {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.voice-btn-meta {
|
||||
font-size: 0.68rem;
|
||||
color: var(--text3);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Danger zone */
|
||||
.settings-section-danger {
|
||||
border-color: #FECACA;
|
||||
}
|
||||
.settings-reset-btn {
|
||||
background: none;
|
||||
border: 1px solid #FECACA;
|
||||
border-radius: 6px;
|
||||
color: #DC2626;
|
||||
font-size: 0.82rem;
|
||||
padding: 7px 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.settings-reset-btn:hover { background: #FEF2F2; }
|
||||
@@ -0,0 +1,376 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
function AudioButton({ base64, mimeType = 'audio/mp3', label = '🔊' }) {
|
||||
const src = base64
|
||||
? `data:${mimeType};base64,${base64}`
|
||||
: null;
|
||||
|
||||
if (!src) return null;
|
||||
return (
|
||||
<audio
|
||||
className="audio-btn"
|
||||
controls
|
||||
src={src}
|
||||
title={label}
|
||||
preload="none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Bubble({ role, text, audioBase64, audioMime }) {
|
||||
const isUser = role === 'user';
|
||||
return (
|
||||
<div className={`bubble ${isUser ? 'bubble-user' : 'bubble-bot'}`}>
|
||||
<div className="bubble-label">{isUser ? '你' : 'JBOT'}</div>
|
||||
<div className="bubble-text">{text}</div>
|
||||
{audioBase64 && (
|
||||
<AudioButton base64={audioBase64} mimeType={audioMime || 'audio/mp3'} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChatPanel({ messages, voiceStatus }) {
|
||||
const bottomRef = useRef(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<section className="chat-panel">
|
||||
<div className="chat-header">
|
||||
<span className="chat-title">对话</span>
|
||||
<button className="history-link" onClick={() => navigate('/history')}>
|
||||
历史记录 →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{voiceStatus && (
|
||||
<div className={`voice-status voice-status-${voiceStatus.type}`}>
|
||||
{voiceStatus.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="chat-messages">
|
||||
{messages.length === 0 ? (
|
||||
<p className="chat-empty">按住 Space 开始说话…</p>
|
||||
) : (
|
||||
messages.map((m) => (
|
||||
<Bubble
|
||||
key={m.id}
|
||||
role={m.role}
|
||||
text={m.text}
|
||||
audioBase64={m.audioBase64}
|
||||
audioMime={m.audioMime}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState } from 'react';
|
||||
import { deleteMemory, clearMemories } from '../store/memoryStore';
|
||||
|
||||
export default function MemoryPanel({ memories, onMemoriesChange }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleDelete = (id) => {
|
||||
deleteMemory(id);
|
||||
onMemoriesChange();
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
if (!window.confirm('清空所有记忆?')) return;
|
||||
clearMemories();
|
||||
onMemoriesChange();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="memory-panel">
|
||||
<button className="memory-toggle" onClick={() => setOpen(o => !o)}>
|
||||
<span>🧠 长期记忆</span>
|
||||
<span className="memory-badge">{memories.length}</span>
|
||||
<span className="memory-arrow">{open ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="memory-body">
|
||||
{memories.length === 0 ? (
|
||||
<p className="memory-empty">对话后会自动记住关于你的信息</p>
|
||||
) : (
|
||||
<>
|
||||
<ul className="memory-list">
|
||||
{memories.map(m => (
|
||||
<li key={m.id} className="memory-item">
|
||||
<span className="memory-text">{m.text}</span>
|
||||
<button className="memory-del" onClick={() => handleDelete(m.id)} title="删除">×</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button className="memory-clear" onClick={handleClear}>清空全部</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { G, DOT_R, CANVAS, FACE, byId, getSpeakMouth } from '../data/expressions';
|
||||
|
||||
// ── Drawing primitives ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* drawDots with optional twinkling.
|
||||
* When t is provided, dots are split into bright/dim groups each frame
|
||||
* using a per-dot phase hash → asynchronous LED shimmer effect.
|
||||
* When t is null (transition/fade), uses single-batch draw for performance.
|
||||
*/
|
||||
function drawDots(ctx, pts, color, alpha, t = null) {
|
||||
if (!pts || pts.length === 0) return;
|
||||
ctx.save();
|
||||
|
||||
if (t == null) {
|
||||
// ── Static: one batched fill (fast)
|
||||
ctx.globalAlpha = Math.max(0, Math.min(1, alpha));
|
||||
ctx.shadowBlur = 14;
|
||||
ctx.shadowColor = color;
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
for (const [gx, gy] of pts) {
|
||||
const x = gx * G + G / 2, y = gy * G + G / 2;
|
||||
ctx.moveTo(x + DOT_R, y);
|
||||
ctx.arc(x, y, DOT_R, 0, Math.PI * 2);
|
||||
}
|
||||
ctx.fill();
|
||||
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.55)';
|
||||
ctx.beginPath();
|
||||
for (const [gx, gy] of pts) {
|
||||
const x = gx * G + G / 2, y = gy * G + G / 2;
|
||||
ctx.moveTo(x + DOT_R * 0.38, y);
|
||||
ctx.arc(x, y, DOT_R * 0.38, 0, Math.PI * 2);
|
||||
}
|
||||
ctx.fill();
|
||||
|
||||
} else {
|
||||
// ── Live: split by per-dot sine phase → bright/dim groups
|
||||
const bright = [], dim = [];
|
||||
for (const [gx, gy] of pts) {
|
||||
const phase = (gx * 7.3 + gy * 13.1) * 0.31;
|
||||
(Math.sin(t * 1.1 + phase) > 0 ? bright : dim).push([gx, gy]);
|
||||
}
|
||||
|
||||
const drawGroup = (group, a, blur) => {
|
||||
if (!group.length) return;
|
||||
ctx.globalAlpha = Math.max(0, Math.min(1, a));
|
||||
ctx.shadowBlur = blur;
|
||||
ctx.shadowColor = color;
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
for (const [gx, gy] of group) {
|
||||
const x = gx * G + G / 2, y = gy * G + G / 2;
|
||||
ctx.moveTo(x + DOT_R, y);
|
||||
ctx.arc(x, y, DOT_R, 0, Math.PI * 2);
|
||||
}
|
||||
ctx.fill();
|
||||
};
|
||||
|
||||
drawGroup(dim, alpha * 0.68, 7); // dim group: subdued glow
|
||||
drawGroup(bright, alpha, 20); // bright group: extra glow
|
||||
|
||||
// White cores (always batched)
|
||||
ctx.globalAlpha = Math.max(0, Math.min(1, alpha * 0.55));
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.55)';
|
||||
ctx.beginPath();
|
||||
for (const [gx, gy] of pts) {
|
||||
const x = gx * G + G / 2, y = gy * G + G / 2;
|
||||
ctx.moveTo(x + DOT_R * 0.38, y);
|
||||
ctx.arc(x, y, DOT_R * 0.38, 0, Math.PI * 2);
|
||||
}
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Squeeze dot Y-coords toward the eye centre row (blink squash)
|
||||
function applyBlink(pts, eyeCY, blinkScale) {
|
||||
if (blinkScale >= 1) return pts;
|
||||
return pts.map(([gx, gy]) => [gx, eyeCY + (gy - eyeCY) * blinkScale]);
|
||||
}
|
||||
|
||||
function getBlinkScale(blinkStart, now) {
|
||||
if (blinkStart === null) return 1;
|
||||
const e = now - blinkStart;
|
||||
if (e < 120) return 1 - e / 120; // 0–120 ms: closing
|
||||
if (e < 220) return 0; // 120–220 ms: closed
|
||||
if (e < 340) return (e - 220) / 120; // 220–340 ms: opening
|
||||
return 1;
|
||||
}
|
||||
|
||||
function drawEye(ctx, eyeData, cx, cy, color, pupilColor, alpha, blinkScale, t) {
|
||||
if (!eyeData) return;
|
||||
const pc = pupilColor || '#001020';
|
||||
|
||||
if (eyeData.iris) {
|
||||
drawDots(ctx, applyBlink(eyeData.iris, cy, blinkScale), color, alpha, t);
|
||||
drawDots(ctx, applyBlink(eyeData.pupil, cy, blinkScale), pc, alpha, null);
|
||||
if (eyeData.hl)
|
||||
drawDots(ctx, applyBlink(eyeData.hl, cy, blinkScale), '#ffffff', alpha * 0.9, null);
|
||||
if (eyeData.lash)
|
||||
drawDots(ctx, applyBlink(eyeData.lash,cy, blinkScale), color, alpha, null);
|
||||
} else if (eyeData.closed !== undefined) {
|
||||
drawDots(ctx, eyeData.closed, color, alpha, t);
|
||||
if (eyeData.lash) drawDots(ctx, eyeData.lash, color, alpha, null);
|
||||
} else if (eyeData.wink) { drawDots(ctx, eyeData.wink, color, alpha, t); }
|
||||
else if (eyeData.heart) { drawDots(ctx, eyeData.heart, color, alpha, t); }
|
||||
else if (eyeData.spiral) { drawDots(ctx, eyeData.spiral, color, alpha, t); }
|
||||
else if (eyeData.star) { drawDots(ctx, eyeData.star, color, alpha, t); }
|
||||
else if (eyeData.pts) {
|
||||
// pts-based micro-expression eyes: support blink + sparkle
|
||||
drawDots(ctx, applyBlink(eyeData.pts, cy, blinkScale), color, alpha, t);
|
||||
}
|
||||
}
|
||||
|
||||
// ── RobotFace component ───────────────────────────────────────────
|
||||
|
||||
export default function RobotFace({ expressionId, scale = 2 }) {
|
||||
const canvasRef = useRef(null);
|
||||
const animRef = useRef({
|
||||
currentId: expressionId,
|
||||
previousId: null,
|
||||
transitionStart: null,
|
||||
blinkStart: null,
|
||||
nextBlink: performance.now() + 2000 + Math.random() * 2000,
|
||||
t: 0,
|
||||
raf: null,
|
||||
});
|
||||
|
||||
// Trigger cross-fade whenever the prop changes
|
||||
useEffect(() => {
|
||||
const s = animRef.current;
|
||||
if (expressionId !== s.currentId) {
|
||||
s.previousId = s.currentId;
|
||||
s.currentId = expressionId;
|
||||
s.transitionStart = performance.now();
|
||||
s.blinkStart = null;
|
||||
}
|
||||
}, [expressionId]);
|
||||
|
||||
// One-time RAF loop
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const s = animRef.current;
|
||||
|
||||
function drawExpression(expr, alpha, blinkScale, t) {
|
||||
if (!expr) return;
|
||||
drawEye(ctx, expr.leftEye, FACE.LE[0], FACE.LE[1],
|
||||
expr.color, expr.pupilColor, alpha, blinkScale, t);
|
||||
drawEye(ctx, expr.rightEye, FACE.RE[0], FACE.RE[1],
|
||||
expr.color, expr.pupilColor, alpha, blinkScale, t);
|
||||
|
||||
const mouth = expr.speakingMouth
|
||||
? getSpeakMouth(FACE.MO[0], FACE.MO[1], s.t)
|
||||
: expr.mouth;
|
||||
if (mouth) drawDots(ctx, mouth, expr.color, alpha, t);
|
||||
|
||||
if (expr.extras) {
|
||||
for (const extra of expr.extras) {
|
||||
let ea = alpha * extra.alpha;
|
||||
if (extra.zzzIndex !== undefined) {
|
||||
const phase = s.t - extra.zzzIndex * 1.2;
|
||||
ea *= 0.35 + 0.65 * (Math.sin(phase) * 0.5 + 0.5);
|
||||
}
|
||||
drawDots(ctx, extra.dots, extra.color, ea, null); // extras: no sparkle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const now = performance.now();
|
||||
s.t += 0.04;
|
||||
|
||||
ctx.clearRect(0, 0, CANVAS, CANVAS);
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(0, 0, CANVAS, CANVAS);
|
||||
|
||||
// Transition alphas (ease-in-out, 380 ms)
|
||||
let curAlpha = 1, prevAlpha = 0;
|
||||
if (s.transitionStart !== null) {
|
||||
const elapsed = now - s.transitionStart;
|
||||
if (elapsed >= 380) {
|
||||
s.transitionStart = null;
|
||||
s.previousId = null;
|
||||
} else {
|
||||
const p = elapsed / 380;
|
||||
const eased = p < 0.5 ? 2 * p * p : 1 - (-2 * p + 2) ** 2 / 2;
|
||||
curAlpha = eased;
|
||||
prevAlpha = 1 - eased;
|
||||
}
|
||||
}
|
||||
|
||||
// Blink scheduling
|
||||
const curExpr = byId[s.currentId];
|
||||
if (!s.blinkStart && s.transitionStart === null &&
|
||||
curExpr?.canBlink && now >= s.nextBlink) {
|
||||
s.blinkStart = now;
|
||||
s.nextBlink = now + 2500 + Math.random() * 2500; // 2.5–5 s between blinks
|
||||
}
|
||||
if (s.blinkStart !== null && now - s.blinkStart >= 340) s.blinkStart = null;
|
||||
const blinkScale = getBlinkScale(s.blinkStart, now);
|
||||
|
||||
// Previous: fading out, no sparkle
|
||||
if (s.previousId) drawExpression(byId[s.previousId], prevAlpha, 1, null);
|
||||
|
||||
// Current: sparkle only when fully settled (not mid-transition)
|
||||
const liveT = s.transitionStart === null ? s.t : null;
|
||||
drawExpression(curExpr, curAlpha, blinkScale, liveT);
|
||||
|
||||
// CRT scanlines overlay
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.04;
|
||||
ctx.fillStyle = '#000';
|
||||
for (let y = 0; y < CANVAS; y += 2) ctx.fillRect(0, y + 1, CANVAS, 1);
|
||||
ctx.restore();
|
||||
|
||||
s.raf = requestAnimationFrame(render);
|
||||
}
|
||||
|
||||
s.raf = requestAnimationFrame(render);
|
||||
return () => cancelAnimationFrame(s.raf);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={CANVAS}
|
||||
height={CANVAS}
|
||||
style={{
|
||||
width: CANVAS * scale,
|
||||
height: CANVAS * scale,
|
||||
imageRendering: 'pixelated',
|
||||
borderRadius: 14,
|
||||
boxShadow: '0 0 40px rgba(0,255,200,0.2)',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
export const G = 6;
|
||||
export const DOT_R = 2.6;
|
||||
export const CANVAS = 240;
|
||||
export const FACE = { LE: [10, 13], RE: [29, 13], MO: [20, 27] };
|
||||
|
||||
// ── Core geometry helpers ─────────────────────────────────────────
|
||||
|
||||
function circleFill(cx, cy, r) {
|
||||
const pts = [];
|
||||
const ri = Math.ceil(r);
|
||||
for (let dx = -ri; dx <= ri; dx++)
|
||||
for (let dy = -ri; dy <= ri; dy++)
|
||||
if (dx * dx + dy * dy <= r * r) pts.push([cx + dx, cy + dy]);
|
||||
return pts;
|
||||
}
|
||||
|
||||
function arcPts(cx, cy, r, a1, a2, step = 5) {
|
||||
const pts = [], seen = new Set();
|
||||
for (let a = a1; a <= a2; a += step) {
|
||||
const rad = (a * Math.PI) / 180;
|
||||
const x = Math.round(cx + r * Math.cos(rad));
|
||||
const y = Math.round(cy + r * Math.sin(rad));
|
||||
const k = `${x},${y}`;
|
||||
if (!seen.has(k)) { seen.add(k); pts.push([x, y]); }
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
function hLine(cx, cy, half) {
|
||||
const pts = [];
|
||||
for (let x = Math.round(cx - half); x <= Math.round(cx + half); x++)
|
||||
pts.push([x, Math.round(cy)]);
|
||||
return pts;
|
||||
}
|
||||
|
||||
function waveLine(cx, cy, half, amp) {
|
||||
const pts = [], seen = new Set();
|
||||
const h = Math.round(half);
|
||||
for (let dx = -h; dx <= h; dx++) {
|
||||
const t = (dx / h) * Math.PI;
|
||||
const y = Math.round(cy + amp * Math.sin(t));
|
||||
const k = `${cx + dx},${y}`;
|
||||
if (!seen.has(k)) { seen.add(k); pts.push([cx + dx, y]); }
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
function zigzag(cx, cy, half, amp) {
|
||||
const pts = [];
|
||||
const h = Math.round(half);
|
||||
for (let dx = -h; dx <= h; dx++) {
|
||||
const seg = dx + h;
|
||||
pts.push([cx + dx, Math.round(cy + (seg % 4 < 2 ? amp : -amp))]);
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
function sub(a, b) {
|
||||
const set = new Set(b.map(([x, y]) => `${x},${y}`));
|
||||
return a.filter(([x, y]) => !set.has(`${x},${y}`));
|
||||
}
|
||||
|
||||
function heart(cx, cy) {
|
||||
return [
|
||||
[-1, -2], [1, -2],
|
||||
[-2, -1], [-1, -1], [0, -1], [1, -1], [2, -1],
|
||||
[-2, 0], [-1, 0], [0, 0], [1, 0], [2, 0],
|
||||
[-1, 1], [0, 1], [1, 1],
|
||||
[0, 2],
|
||||
].map(([dx, dy]) => [cx + dx, cy + dy]);
|
||||
}
|
||||
|
||||
function spiral(cx, cy, maxR, turns) {
|
||||
const pts = [], seen = new Set();
|
||||
const steps = Math.round(turns * 2 * Math.PI * 8);
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const angle = (i / steps) * turns * 2 * Math.PI;
|
||||
const r = (i / steps) * maxR;
|
||||
const x = Math.round(cx + r * Math.cos(angle));
|
||||
const y = Math.round(cy + r * Math.sin(angle));
|
||||
const k = `${x},${y}`;
|
||||
if (!seen.has(k)) { seen.add(k); pts.push([x, y]); }
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
function star(cx, cy, r) {
|
||||
const pts = [], seen = new Set();
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const angle = (i * 45 * Math.PI) / 180;
|
||||
for (let d = 0; d <= r; d += 0.5) {
|
||||
const x = Math.round(cx + d * Math.cos(angle));
|
||||
const y = Math.round(cy + d * Math.sin(angle));
|
||||
const k = `${x},${y}`;
|
||||
if (!seen.has(k)) { seen.add(k); pts.push([x, y]); }
|
||||
}
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
function makeZ(cx, cy) {
|
||||
return [
|
||||
[cx - 1, cy - 1], [cx, cy - 1], [cx + 1, cy - 1],
|
||||
[cx, cy],
|
||||
[cx - 1, cy + 1], [cx, cy + 1], [cx + 1, cy + 1],
|
||||
];
|
||||
}
|
||||
|
||||
function questionMark(cx, cy) {
|
||||
return [
|
||||
...arcPts(cx, cy - 1, 1.5, 200, 360, 30),
|
||||
[cx, cy + 1],
|
||||
[cx, cy + 3],
|
||||
];
|
||||
}
|
||||
|
||||
// ── Standard (interaction) eye builders ──────────────────────────
|
||||
|
||||
function femEye(cx, cy) {
|
||||
return {
|
||||
iris: circleFill(cx, cy, 3),
|
||||
pupil: circleFill(cx, cy, 1.5),
|
||||
hl: [[cx - 1, cy - 1]],
|
||||
lash: hLine(cx, cy - 3, 3),
|
||||
};
|
||||
}
|
||||
|
||||
function wideEye(cx, cy) {
|
||||
return {
|
||||
iris: circleFill(cx, cy, 4),
|
||||
pupil: circleFill(cx, cy, 2),
|
||||
hl: [[cx - 1, cy - 1], [cx - 2, cy - 2]],
|
||||
lash: hLine(cx, cy - 4, 4),
|
||||
};
|
||||
}
|
||||
|
||||
function squintEye(cx, cy) {
|
||||
const full = circleFill(cx, cy, 3);
|
||||
return {
|
||||
iris: full.filter(([, y]) => y >= cy - 1 && y <= cy + 1),
|
||||
pupil: [[cx, cy]],
|
||||
lash: hLine(cx, cy - 1, 2),
|
||||
};
|
||||
}
|
||||
|
||||
function sleepyEye(cx, cy) {
|
||||
const full = circleFill(cx, cy, 3);
|
||||
return {
|
||||
iris: full.filter(([, y]) => y >= cy),
|
||||
pupil: [[cx - 1, cy], [cx, cy], [cx + 1, cy]],
|
||||
lash: hLine(cx, cy, 3),
|
||||
};
|
||||
}
|
||||
|
||||
function downcastEye(cx, cy) {
|
||||
return {
|
||||
iris: circleFill(cx, cy + 1, 3),
|
||||
pupil: circleFill(cx, cy + 2, 1.5),
|
||||
hl: [[cx - 1, cy]],
|
||||
lash: hLine(cx, cy - 2, 2),
|
||||
};
|
||||
}
|
||||
|
||||
function sideEye(cx, cy, xOff = 2) {
|
||||
return {
|
||||
iris: circleFill(cx, cy, 3),
|
||||
pupil: circleFill(cx + xOff, cy, 1.5),
|
||||
hl: [[cx + xOff - 1, cy - 1]],
|
||||
lash: hLine(cx, cy - 3, 3),
|
||||
};
|
||||
}
|
||||
|
||||
function winkEye(cx, cy) {
|
||||
return { wink: arcPts(cx, cy + 1, 3, 200, 340, 15) };
|
||||
}
|
||||
|
||||
// ── Micro-expression eye builders (pts-based, minimalist) ────────
|
||||
|
||||
// Circle ring outline
|
||||
function ringEye(cx, cy, r = 3) {
|
||||
return { pts: sub(circleFill(cx, cy, r), circleFill(cx, cy, Math.max(0.5, r - 1.5))) };
|
||||
}
|
||||
|
||||
// ∩ upward arch
|
||||
function archEye(cx, cy, r = 3) {
|
||||
return { pts: arcPts(cx, cy, r, 200, 340, 10) };
|
||||
}
|
||||
|
||||
// Horizontal dash bar
|
||||
function dashEye(cx, cy, half = 3) {
|
||||
return { pts: hLine(cx, cy, half) };
|
||||
}
|
||||
|
||||
// ^ peak/tent shape
|
||||
function peakEye(cx, cy) {
|
||||
return { pts: arcPts(cx, cy, 2.5, 228, 312, 12) };
|
||||
}
|
||||
|
||||
// Filled block shifted sideways
|
||||
function blockSideEye(cx, cy, xOff = 2) {
|
||||
const pts = [];
|
||||
for (let dx = -1; dx <= 1; dx++)
|
||||
for (let dy = -2; dy <= 2; dy++)
|
||||
pts.push([cx + dx + xOff, cy + dy]);
|
||||
return { pts };
|
||||
}
|
||||
|
||||
// + cross shape (left-right scanning)
|
||||
function crossEye(cx, cy) {
|
||||
const h = hLine(cx, cy, 2);
|
||||
const seen = new Set(h.map(([x, y]) => `${x},${y}`));
|
||||
const v = [];
|
||||
for (let dy = -2; dy <= 2; dy++) {
|
||||
const k = `${cx},${cy + dy}`;
|
||||
if (!seen.has(k)) v.push([cx, cy + dy]);
|
||||
}
|
||||
return { pts: [...h, ...v] };
|
||||
}
|
||||
|
||||
// ── Mouth builders ────────────────────────────────────────────────
|
||||
|
||||
function smileMouth(cx, cy) { return arcPts(cx, cy, 5, 25, 155); }
|
||||
function wideMouth(cx, cy) { return arcPts(cx, cy, 7, 15, 165); }
|
||||
function straightMouth(cx, cy) { return hLine(cx, cy, 5); }
|
||||
|
||||
function openMouth(cx, cy, openness) {
|
||||
const b = Math.max(1, openness);
|
||||
const top = arcPts(cx, cy - b, 4, 185, 355, 10);
|
||||
const bot = arcPts(cx, cy + b, 4, 5, 175, 10);
|
||||
const seen = new Set(top.map(([x, y]) => `${x},${y}`));
|
||||
return [...top, ...bot.filter(([x, y]) => !seen.has(`${x},${y}`))];
|
||||
}
|
||||
|
||||
function smirkMouth(cx, cy) {
|
||||
const main = arcPts(cx - 2, cy, 4, 30, 120, 10);
|
||||
const dip = arcPts(cx + 3, cy + 2, 2.5, 270, 360, 15);
|
||||
const seen = new Set(main.map(([x, y]) => `${x},${y}`));
|
||||
return [...main, ...dip.filter(([x, y]) => !seen.has(`${x},${y}`))];
|
||||
}
|
||||
|
||||
function poutyMouth(cx, cy) {
|
||||
const lft = arcPts(cx - 2, cy, 2, 200, 340, 20);
|
||||
const rgt = arcPts(cx + 2, cy, 2, 200, 340, 20);
|
||||
const bot = hLine(cx, cy + 2, 3);
|
||||
const seen = new Set(lft.map(([x, y]) => `${x},${y}`));
|
||||
return [...lft, ...rgt.filter(([x, y]) => !seen.has(`${x},${y}`)), ...bot];
|
||||
}
|
||||
|
||||
// ── Animated speaking mouth ───────────────────────────────────────
|
||||
// Three-step animation: closed smile → slight open → more open
|
||||
// Uses smaller radius (3–3.5) and offset (1–2) for a natural look.
|
||||
export function getSpeakMouth(mx, my, t) {
|
||||
const phase = Math.abs(Math.sin(t * 1.4)); // 0 = closed, 1 = max open
|
||||
if (phase < 0.25) {
|
||||
return arcPts(mx, my, 4, 20, 160, 8); // closed: gentle smile
|
||||
}
|
||||
const offset = phase < 0.65 ? 1 : 2;
|
||||
const top = arcPts(mx, my - offset, 3.5, 190, 350, 8);
|
||||
const bot = arcPts(mx, my + offset, 3.0, 10, 170, 8);
|
||||
const seen = new Set(top.map(([x, y]) => `${x},${y}`));
|
||||
return [...top, ...bot.filter(([x, y]) => !seen.has(`${x},${y}`))];
|
||||
}
|
||||
|
||||
// ── Anchor coordinates ────────────────────────────────────────────
|
||||
|
||||
const [LEx, LEy] = FACE.LE;
|
||||
const [REx, REy] = FACE.RE;
|
||||
const [MOx, MOy] = FACE.MO;
|
||||
|
||||
const MC = '#22CCFF'; // micro-expression LED teal
|
||||
|
||||
// ── 8 Interaction Expressions ────────────────────────────────────
|
||||
|
||||
const INTERACTION_EXPRS = [
|
||||
{
|
||||
id: 'idle', label: '😐 待机',
|
||||
color: '#00FFEE', category: 'interaction', canBlink: true,
|
||||
leftEye: femEye(LEx, LEy), rightEye: femEye(REx, REy),
|
||||
mouth: smileMouth(MOx, MOy),
|
||||
},
|
||||
{
|
||||
id: 'listening', label: '👂 聆听',
|
||||
color: '#44AAFF', category: 'interaction', canBlink: true,
|
||||
leftEye: wideEye(LEx, LEy), rightEye: wideEye(REx, REy),
|
||||
mouth: arcPts(MOx, MOy, 3, 0, 180),
|
||||
},
|
||||
{
|
||||
id: 'thinking', label: '🤔 思考',
|
||||
color: '#FFCC00', category: 'interaction', canBlink: true,
|
||||
leftEye: squintEye(LEx, LEy), rightEye: femEye(REx, REy),
|
||||
mouth: waveLine(MOx, MOy, 5, 1),
|
||||
extras: [{ dots: questionMark(LEx - 1, LEy - 6), color: '#FFCC00', alpha: 0.75 }],
|
||||
},
|
||||
{
|
||||
id: 'speaking', label: '💬 说话',
|
||||
color: '#FFFFFF', pupilColor: '#002233',
|
||||
category: 'interaction', canBlink: true,
|
||||
leftEye: femEye(LEx, LEy), rightEye: femEye(REx, REy),
|
||||
speakingMouth: true,
|
||||
},
|
||||
{
|
||||
id: 'happy', label: '😊 开心',
|
||||
color: '#FF44AA', category: 'interaction', canBlink: true,
|
||||
leftEye: { heart: heart(LEx, LEy) },
|
||||
rightEye: { heart: heart(REx, REy) },
|
||||
mouth: wideMouth(MOx, MOy),
|
||||
},
|
||||
{
|
||||
id: 'error', label: '😵 错误',
|
||||
color: '#FF4400', category: 'interaction', canBlink: true,
|
||||
leftEye: { spiral: spiral(LEx, LEy, 4, 2) },
|
||||
rightEye: { spiral: spiral(REx, REy, 4, 2) },
|
||||
mouth: zigzag(MOx, MOy, 5, 1.5),
|
||||
},
|
||||
{
|
||||
id: 'confused', label: '😕 困惑',
|
||||
color: '#BB44FF', category: 'interaction', canBlink: true,
|
||||
leftEye: wideEye(LEx, LEy), rightEye: squintEye(REx, REy),
|
||||
mouth: waveLine(MOx, MOy, 5, 1.5),
|
||||
extras: [{ dots: questionMark(REx + 1, REy - 6), color: '#BB44FF', alpha: 0.75 }],
|
||||
},
|
||||
{
|
||||
id: 'dreaming', label: '💤 做梦',
|
||||
color: '#5566FF', category: 'interaction', canBlink: true,
|
||||
leftEye: sleepyEye(LEx, LEy), rightEye: sleepyEye(REx, REy),
|
||||
mouth: smileMouth(MOx, MOy),
|
||||
extras: [
|
||||
{ dots: makeZ(24, 10), color: '#5566FF', alpha: 0.85, zzzIndex: 0 },
|
||||
{ dots: makeZ(28, 7), color: '#7788FF', alpha: 0.75, zzzIndex: 1 },
|
||||
{ dots: makeZ(32, 4), color: '#99AAFF', alpha: 0.65, zzzIndex: 2 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ── 16 Micro Auto-Expressions ─────────────────────────────────────
|
||||
// All use the same full iris+pupil+lash eye builders as interaction
|
||||
// expressions, giving them the same visual weight and size.
|
||||
|
||||
const AUTO_EXPRS = [
|
||||
{
|
||||
// Natural resting smile — standard open eyes + gentle upward arc
|
||||
id: 'natural_smile', label: '01 自然微笑',
|
||||
color: MC, category: 'auto', canBlink: true,
|
||||
leftEye: femEye(LEx, LEy),
|
||||
rightEye: femEye(REx, REy),
|
||||
mouth: smileMouth(MOx, MOy),
|
||||
},
|
||||
{
|
||||
// Calm / relaxed — half-closed sleepy eyes + neutral straight line
|
||||
id: 'calm_relax', label: '02 平静放松',
|
||||
color: MC, category: 'auto', canBlink: true,
|
||||
leftEye: sleepyEye(LEx, LEy),
|
||||
rightEye: sleepyEye(REx, REy),
|
||||
mouth: straightMouth(MOx, MOy),
|
||||
},
|
||||
{
|
||||
// Curious observing — wide-open eyes + medium smile
|
||||
id: 'curious_observe', label: '03 好奇观察',
|
||||
color: MC, category: 'auto', canBlink: true,
|
||||
leftEye: wideEye(LEx, LEy),
|
||||
rightEye: wideEye(REx, REy),
|
||||
mouth: arcPts(MOx, MOy, 4, 30, 150, 8),
|
||||
},
|
||||
{
|
||||
// Light joy — happy squint (crescent) + wide smile
|
||||
id: 'light_joy', label: '04 轻松愉悦',
|
||||
color: MC, category: 'auto', canBlink: true,
|
||||
leftEye: squintEye(LEx, LEy),
|
||||
rightEye: squintEye(REx, REy),
|
||||
mouth: wideMouth(MOx, MOy),
|
||||
},
|
||||
{
|
||||
// Side-eye thinking — pupils shifted sideways + thinking wave
|
||||
id: 'side_think', label: '05 侧目思考',
|
||||
color: MC, category: 'auto', canBlink: true,
|
||||
leftEye: sideEye(LEx, LEy, 2),
|
||||
rightEye: sideEye(REx, REy, 2),
|
||||
mouth: waveLine(MOx, MOy, 4, 1),
|
||||
},
|
||||
{
|
||||
// Warm & friendly — downcast soft gaze + gentle smile
|
||||
id: 'warm_friendly', label: '06 温和友善',
|
||||
color: MC, category: 'auto', canBlink: true,
|
||||
leftEye: downcastEye(LEx, LEy),
|
||||
rightEye: downcastEye(REx, REy),
|
||||
mouth: smileMouth(MOx, MOy),
|
||||
},
|
||||
{
|
||||
// Quietly listening — attentive standard eyes + neutral mouth
|
||||
id: 'quiet_listen', label: '07 静静聆听',
|
||||
color: MC, category: 'auto', canBlink: true,
|
||||
leftEye: femEye(LEx, LEy),
|
||||
rightEye: femEye(REx, REy),
|
||||
mouth: hLine(MOx, MOy, 4),
|
||||
},
|
||||
{
|
||||
// Focused gaze — wide intent eyes + flat straight mouth
|
||||
id: 'focus_gaze', label: '08 专注注视',
|
||||
color: MC, category: 'auto', canBlink: true,
|
||||
leftEye: wideEye(LEx, LEy),
|
||||
rightEye: wideEye(REx, REy),
|
||||
mouth: straightMouth(MOx, MOy),
|
||||
},
|
||||
{
|
||||
// Quietly waiting — sleepy patient eyes + tiny patient smile
|
||||
id: 'quiet_wait', label: '09 安静等待',
|
||||
color: MC, category: 'auto', canBlink: true,
|
||||
leftEye: sleepyEye(LEx, LEy),
|
||||
rightEye: sleepyEye(REx, REy),
|
||||
mouth: arcPts(MOx, MOy, 4, 30, 150, 8),
|
||||
},
|
||||
{
|
||||
// Slow blink / drowsy — happy squint + content smile
|
||||
id: 'slow_blink', label: '10 慢眨眼',
|
||||
color: MC, category: 'auto', canBlink: true,
|
||||
leftEye: squintEye(LEx, LEy),
|
||||
rightEye: squintEye(REx, REy),
|
||||
mouth: smileMouth(MOx, MOy),
|
||||
},
|
||||
{
|
||||
// Look around — pupils shifted left + neutral mouth
|
||||
id: 'look_around', label: '11 左右扫视',
|
||||
color: MC, category: 'auto', canBlink: true,
|
||||
leftEye: sideEye(LEx, LEy, -2),
|
||||
rightEye: sideEye(REx, REy, -2),
|
||||
mouth: hLine(MOx, MOy, 4),
|
||||
},
|
||||
{
|
||||
// Playful — one wink + wide eye + smirk
|
||||
id: 'playful', label: '12 小小调皮',
|
||||
color: MC, category: 'auto', canBlink: false,
|
||||
leftEye: winkEye(LEx, LEy),
|
||||
rightEye: wideEye(REx, REy),
|
||||
mouth: smirkMouth(MOx, MOy),
|
||||
},
|
||||
{
|
||||
// Joyful & expectant — big wide excited eyes + wide smile
|
||||
id: 'joyful_wait', label: '13 愉悦期待',
|
||||
color: MC, category: 'auto', canBlink: true,
|
||||
leftEye: wideEye(LEx, LEy),
|
||||
rightEye: wideEye(REx, REy),
|
||||
mouth: wideMouth(MOx, MOy),
|
||||
},
|
||||
{
|
||||
// Slight surprise — wide eyes + small open-O mouth
|
||||
id: 'slight_surprise', label: '14 轻微惊喜',
|
||||
color: MC, category: 'auto', canBlink: true,
|
||||
leftEye: wideEye(LEx, LEy),
|
||||
rightEye: wideEye(REx, REy),
|
||||
mouth: arcPts(MOx, MOy, 3, 0, 180, 10),
|
||||
},
|
||||
{
|
||||
// Gentle gaze — downcast warm eyes + soft small smile
|
||||
id: 'gentle_gaze', label: '15 温柔注视',
|
||||
color: MC, category: 'auto', canBlink: true,
|
||||
leftEye: downcastEye(LEx, LEy),
|
||||
rightEye: downcastEye(REx, REy),
|
||||
mouth: arcPts(MOx, MOy, 4, 30, 150, 8),
|
||||
},
|
||||
{
|
||||
// Spaced out / daydreaming — very droopy sleepy eyes + pouty mouth
|
||||
id: 'spaceout', label: '16 发呆放空',
|
||||
color: MC, category: 'auto', canBlink: true,
|
||||
leftEye: sleepyEye(LEx, LEy),
|
||||
rightEye: sleepyEye(REx, REy),
|
||||
mouth: poutyMouth(MOx, MOy),
|
||||
},
|
||||
];
|
||||
|
||||
// ── Exports ───────────────────────────────────────────────────────
|
||||
|
||||
export const EXPRESSIONS = [...INTERACTION_EXPRS, ...AUTO_EXPRS];
|
||||
export const ALL_AUTO_IDS = AUTO_EXPRS.map(e => e.id);
|
||||
export const byId = Object.fromEntries(EXPRESSIONS.map(e => [e.id, e]));
|
||||
@@ -0,0 +1,25 @@
|
||||
// Volcengine seed-tts-2.0 voice catalogue
|
||||
// Full list: https://www.volcengine.com/docs/6561/1257544
|
||||
// All IDs must end in _bigtts for the seed-tts-2.0 resource.
|
||||
|
||||
export const VOICES = [
|
||||
// ── Chinese female ─────────────────────────────────────
|
||||
{ id: 'zh_female_vv_uranus_bigtts', label: '灵儿', lang: '中文', gender: '女', style: '亲切' },
|
||||
{ id: 'zh_female_tianmei_conversation_bigtts', label: '甜美', lang: '中文', gender: '女', style: '温柔' },
|
||||
{ id: 'zh_female_linjing_conversation_bigtts', label: '琳静', lang: '中文', gender: '女', style: '知性' },
|
||||
{ id: 'zh_female_qingxin_conversation_bigtts', label: '清新', lang: '中文', gender: '女', style: '清新' },
|
||||
// ── Chinese male ───────────────────────────────────────
|
||||
{ id: 'zh_male_m191_uranus_bigtts', label: '豪远', lang: '中文', gender: '男', style: '标准' },
|
||||
{ id: 'zh_male_haoran_conversation_bigtts', label: '豪然', lang: '中文', gender: '男', style: '稳重' },
|
||||
{ id: 'zh_male_m372_conversation_bigtts', label: '温和男声', lang: '中文', gender: '男', style: '温和' },
|
||||
// ── English ────────────────────────────────────────────
|
||||
{ id: 'en_female_sarah_jupiter_bigtts', label: 'Sarah', lang: 'EN', gender: '女', style: 'Warm' },
|
||||
{ id: 'en_male_adam_mars_bigtts', label: 'Adam', lang: 'EN', gender: '男', style: 'Clear' },
|
||||
];
|
||||
|
||||
export const DEFAULT_VOICE = 'zh_female_vv_uranus_bigtts';
|
||||
|
||||
export function getVoiceLabel(id) {
|
||||
const v = VOICES.find(v => v.id === id);
|
||||
return v ? `${v.label} · ${v.lang} ${v.gender}` : id;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useRef, useState, useCallback } from 'react';
|
||||
|
||||
export function usePushToTalk() {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const streamRef = useRef(null); // mic stream, acquired on demand
|
||||
const recorderRef = useRef(null);
|
||||
const recognitionRef = useRef(null);
|
||||
const chunksRef = useRef([]);
|
||||
const transcriptRef = useRef('');
|
||||
|
||||
// Resolves once the mic stream is acquired and MediaRecorder is running.
|
||||
// Throws if mic permission is denied.
|
||||
const start = useCallback(async () => {
|
||||
if (isRecording) return;
|
||||
chunksRef.current = [];
|
||||
transcriptRef.current = '';
|
||||
|
||||
// ── Acquire mic stream (on-demand, not pre-warmed) ───────────
|
||||
// If the previous stream is still active (same session, held open) reuse it;
|
||||
// otherwise request a new one — this is where the brief "preparing" delay happens.
|
||||
if (!streamRef.current?.active) {
|
||||
streamRef.current = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
}
|
||||
|
||||
// ── MediaRecorder ────────────────────────────────────────────
|
||||
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
||||
? 'audio/webm;codecs=opus' : 'audio/webm';
|
||||
const recorder = new MediaRecorder(streamRef.current, { mimeType });
|
||||
recorder.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); };
|
||||
recorder.start(100);
|
||||
recorderRef.current = recorder;
|
||||
|
||||
// ── SpeechRecognition: real-time transcript ──────────────────
|
||||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
if (SR) {
|
||||
const sr = new SR();
|
||||
sr.lang = 'zh-CN';
|
||||
sr.interimResults = true;
|
||||
sr.continuous = true;
|
||||
sr.onresult = (e) => {
|
||||
let final = '';
|
||||
for (const res of e.results) {
|
||||
if (res.isFinal) final += res[0].transcript;
|
||||
}
|
||||
if (final) transcriptRef.current = final;
|
||||
};
|
||||
sr.onerror = (e) => { console.warn('SpeechRecognition error:', e.error); };
|
||||
sr.start();
|
||||
recognitionRef.current = sr;
|
||||
}
|
||||
|
||||
setIsRecording(true);
|
||||
// start() resolves here — caller knows mic is live and recording has begun
|
||||
}, [isRecording]);
|
||||
|
||||
// Returns { transcript, audioBase64, mimeType }
|
||||
const stop = useCallback(() => new Promise((resolve) => {
|
||||
setIsRecording(false);
|
||||
|
||||
const transcript = transcriptRef.current;
|
||||
|
||||
try { recognitionRef.current?.stop(); } catch {}
|
||||
recognitionRef.current = null;
|
||||
|
||||
// Release mic stream so the OS indicator light turns off
|
||||
streamRef.current?.getTracks().forEach(t => t.stop());
|
||||
streamRef.current = null;
|
||||
|
||||
const recorder = recorderRef.current;
|
||||
if (!recorder) {
|
||||
return resolve({ transcript, audioBase64: null, mimeType: null });
|
||||
}
|
||||
|
||||
recorder.onstop = async () => {
|
||||
const mimeType = recorder.mimeType;
|
||||
|
||||
if (chunksRef.current.length === 0) {
|
||||
return resolve({ transcript, audioBase64: null, mimeType });
|
||||
}
|
||||
|
||||
const blob = new Blob(chunksRef.current, { type: mimeType });
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
resolve({ transcript, audioBase64: reader.result.split(',')[1], mimeType });
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
};
|
||||
recorder.stop();
|
||||
recorderRef.current = null;
|
||||
}), []);
|
||||
|
||||
return { isRecording, start, stop };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body {
|
||||
background: #F1F5F9;
|
||||
color: #0F172A;
|
||||
font-family: system-ui, 'Segoe UI', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
#root {
|
||||
height: 100dvh;
|
||||
height: -webkit-fill-available;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import './index.css';
|
||||
import App from './App.jsx';
|
||||
import History from './pages/History.jsx';
|
||||
import Settings from './pages/Settings.jsx';
|
||||
|
||||
createRoot(document.getElementById('jbot-root')).render(
|
||||
<StrictMode>
|
||||
{/* basename="/jbot" so React Router matches /jbot/history, /jbot/settings etc. */}
|
||||
<BrowserRouter basename="/jbot">
|
||||
<Routes>
|
||||
<Route path="/" element={<App />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getHistory, clearHistory, deleteEntry } from '../store/historyStore';
|
||||
|
||||
function AudioButton({ base64, mimeType = 'audio/mp3' }) {
|
||||
if (!base64) return null;
|
||||
return (
|
||||
<audio
|
||||
className="hist-audio"
|
||||
controls
|
||||
src={`data:${mimeType};base64,${base64}`}
|
||||
preload="none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryEntry({ entry, onDelete }) {
|
||||
const ts = new Date(entry.timestamp);
|
||||
const timeStr = ts.toLocaleString('zh-CN', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
});
|
||||
return (
|
||||
<article className="hist-entry">
|
||||
<div className="hist-meta">
|
||||
<span className="hist-time">{timeStr}</span>
|
||||
<button className="hist-del" onClick={() => onDelete(entry.id)} title="删除">✕</button>
|
||||
</div>
|
||||
|
||||
<div className="hist-row hist-row-user">
|
||||
<div className="hist-role">你</div>
|
||||
<div className="hist-body">
|
||||
<p className="hist-text">{entry.userText || '(语音消息)'}</p>
|
||||
<AudioButton base64={entry.userAudioBase64} mimeType={entry.userAudioMime || 'audio/webm'} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hist-row hist-row-bot">
|
||||
<div className="hist-role">JBOT</div>
|
||||
<div className="hist-body">
|
||||
<p className="hist-text">{entry.botText || ''}</p>
|
||||
<AudioButton base64={entry.botAudioBase64} mimeType="audio/mp3" />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default function History() {
|
||||
const [entries, setEntries] = useState(() => getHistory());
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleDelete = (id) => {
|
||||
deleteEntry(id);
|
||||
setEntries(getHistory());
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
if (window.confirm('确认清空所有历史记录?')) {
|
||||
clearHistory();
|
||||
setEntries([]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="hist-page">
|
||||
<div className="hist-toolbar">
|
||||
<button className="hist-back" onClick={() => navigate('/')}>← 返回</button>
|
||||
<h1 className="hist-heading">历史对话</h1>
|
||||
<button
|
||||
className="hist-clear"
|
||||
onClick={handleClear}
|
||||
disabled={entries.length === 0}
|
||||
>清空</button>
|
||||
</div>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<p className="hist-empty">暂无历史记录</p>
|
||||
) : (
|
||||
<div className="hist-list">
|
||||
{entries.map(e => (
|
||||
<HistoryEntry key={e.id} entry={e} onDelete={handleDelete} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getSettings, saveSettings, resetSettings } from '../store/settingsStore';
|
||||
import { VOICES, DEFAULT_VOICE } from '../data/voices';
|
||||
|
||||
export default function Settings() {
|
||||
const navigate = useNavigate();
|
||||
const [form, setForm] = useState(() => getSettings());
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const set = (key, val) => {
|
||||
setForm(prev => ({ ...prev, [key]: val }));
|
||||
setSaved(false);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
saveSettings(form);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (window.confirm('重置所有设置为默认值?')) {
|
||||
const defaults = resetSettings();
|
||||
setForm(defaults);
|
||||
setSaved(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-page">
|
||||
<div className="settings-toolbar">
|
||||
<button className="settings-back" onClick={() => navigate('/')}>← 返回</button>
|
||||
<h1 className="settings-heading">全局设置</h1>
|
||||
<button className="settings-save" onClick={handleSave}>
|
||||
{saved ? '✓ 已保存' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-body">
|
||||
|
||||
{/* ── Robot identity ── */}
|
||||
<section className="settings-section">
|
||||
<h2 className="settings-section-title">🤖 机器人身份</h2>
|
||||
|
||||
<label className="settings-label">
|
||||
名字
|
||||
<input
|
||||
className="settings-input"
|
||||
value={form.robotName}
|
||||
onChange={e => set('robotName', e.target.value)}
|
||||
placeholder="JBOT"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="settings-label">
|
||||
人格描述 <span className="settings-hint">会注入到每轮对话的系统提示</span>
|
||||
<textarea
|
||||
className="settings-textarea"
|
||||
rows={3}
|
||||
value={form.robotPersonality}
|
||||
onChange={e => set('robotPersonality', e.target.value)}
|
||||
placeholder="你是一个友好、简洁、偶尔幽默的AI机器人助手。回复控制在80字以内。"
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
{/* ── User context ── */}
|
||||
<section className="settings-section">
|
||||
<h2 className="settings-section-title">👤 用户背景</h2>
|
||||
<label className="settings-label">
|
||||
关于你 <span className="settings-hint">机器人会了解你的背景,更好地回答</span>
|
||||
<textarea
|
||||
className="settings-textarea"
|
||||
rows={3}
|
||||
value={form.extraContext || ''}
|
||||
onChange={e => set('extraContext', e.target.value)}
|
||||
placeholder="例如:我叫小明,是一名软件工程师,在上海工作,对AI和嵌入式系统感兴趣。"
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
{/* ── Language ── */}
|
||||
<section className="settings-section">
|
||||
<h2 className="settings-section-title">🌐 语言</h2>
|
||||
<div className="settings-radio-group">
|
||||
{[
|
||||
{ value: 'auto', label: '自动(跟随用户语言)' },
|
||||
{ value: 'zh-CN', label: '中文' },
|
||||
{ value: 'en-US', label: 'English' },
|
||||
].map(opt => (
|
||||
<label key={opt.value} className="settings-radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="language"
|
||||
value={opt.value}
|
||||
checked={form.language === opt.value}
|
||||
onChange={() => set('language', opt.value)}
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── TTS Voice ── */}
|
||||
<section className="settings-section">
|
||||
<h2 className="settings-section-title">🔊 语音音色</h2>
|
||||
|
||||
<div className="settings-voice-grid">
|
||||
{VOICES.map(v => (
|
||||
<button
|
||||
key={v.id}
|
||||
className={`settings-voice-btn ${!form.customVoiceId && form.voiceId === v.id ? 'active' : ''}`}
|
||||
onClick={() => { set('voiceId', v.id); set('customVoiceId', ''); }}
|
||||
>
|
||||
<span className="voice-btn-label">{v.label}</span>
|
||||
<span className="voice-btn-meta">{v.lang} · {v.gender} · {v.style}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label className="settings-label settings-label-mt">
|
||||
自定义音色 ID <span className="settings-hint">填写后会覆盖上方选择</span>
|
||||
<input
|
||||
className="settings-input"
|
||||
value={form.customVoiceId || ''}
|
||||
onChange={e => set('customVoiceId', e.target.value)}
|
||||
placeholder="例如 zh_female_vv_uranus_bigtts"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<p className="settings-hint-block">
|
||||
完整音色列表见{' '}
|
||||
<a href="https://www.volcengine.com/docs/6561/1257544" target="_blank" rel="noreferrer">
|
||||
火山引擎 TTS 音色文档
|
||||
</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* ── Danger zone ── */}
|
||||
<section className="settings-section settings-section-danger">
|
||||
<h2 className="settings-section-title">⚠️ 重置</h2>
|
||||
<button className="settings-reset-btn" onClick={handleReset}>
|
||||
重置为默认设置
|
||||
</button>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Django backend at /api/jbot/ — no separate Node server needed
|
||||
const BASE = '/api/jbot';
|
||||
|
||||
export async function apiConfig() {
|
||||
const r = await fetch(`${BASE}/config/`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// Send conversation history + optional long-term memories + settings, returns { text }
|
||||
export async function apiChat(messages, memories = [], settings = {}) {
|
||||
const r = await fetch(`${BASE}/chat/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ messages, memories, settings }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const e = await r.json().catch(() => ({}));
|
||||
throw new Error(e.error || `HTTP ${r.status}`);
|
||||
}
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// Send base64 audio blob, returns { text }
|
||||
export async function apiAsr(audioBase64, format = 'audio/webm') {
|
||||
const r = await fetch(`${BASE}/asr/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ audioBase64, format }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const e = await r.json().catch(() => ({}));
|
||||
throw new Error(e.error || `HTTP ${r.status}`);
|
||||
}
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// Send text + optional voice override, returns { audioBase64, format }
|
||||
export async function apiTts(text, voice) {
|
||||
const r = await fetch(`${BASE}/tts/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text, ...(voice ? { voice } : {}) }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const e = await r.json().catch(() => ({}));
|
||||
throw new Error(e.error || `HTTP ${r.status}`);
|
||||
}
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// Extract memorable facts from one conversation exchange.
|
||||
// Returns string[] — never throws (returns [] on any error).
|
||||
export async function apiExtractMemory(userText, botText) {
|
||||
try {
|
||||
const r = await fetch(`${BASE}/memory/extract/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userText, botText }),
|
||||
});
|
||||
if (!r.ok) return [];
|
||||
const { facts = [] } = await r.json();
|
||||
return facts;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
const KEY = 'jbot_history';
|
||||
const MAX_LEN = 200;
|
||||
|
||||
export function getHistory() {
|
||||
try { return JSON.parse(localStorage.getItem(KEY) || '[]'); }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
export function addEntry(entry) {
|
||||
const history = getHistory();
|
||||
const full = [{ id: crypto.randomUUID(), timestamp: Date.now(), ...entry }, ...history];
|
||||
localStorage.setItem(KEY, JSON.stringify(full.slice(0, MAX_LEN)));
|
||||
return full[0];
|
||||
}
|
||||
|
||||
export function clearHistory() {
|
||||
localStorage.removeItem(KEY);
|
||||
}
|
||||
|
||||
export function deleteEntry(id) {
|
||||
localStorage.setItem(KEY, JSON.stringify(getHistory().filter(e => e.id !== id)));
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
const KEY = 'jbot_memory';
|
||||
const MAX_LEN = 100;
|
||||
|
||||
export function getMemories() {
|
||||
try { return JSON.parse(localStorage.getItem(KEY) || '[]'); }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
// Add a fact if it's not already stored (simple text dedup).
|
||||
// Returns the new entry, or null if it was a duplicate.
|
||||
export function addMemory(text) {
|
||||
const mems = getMemories();
|
||||
const norm = text.toLowerCase().trim();
|
||||
if (mems.some(m => m.text.toLowerCase().trim() === norm)) return null;
|
||||
const entry = { id: crypto.randomUUID(), text: text.trim(), createdAt: Date.now() };
|
||||
localStorage.setItem(KEY, JSON.stringify([entry, ...mems].slice(0, MAX_LEN)));
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function deleteMemory(id) {
|
||||
localStorage.setItem(KEY, JSON.stringify(getMemories().filter(m => m.id !== id)));
|
||||
}
|
||||
|
||||
export function clearMemories() {
|
||||
localStorage.removeItem(KEY);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { DEFAULT_VOICE } from '../data/voices';
|
||||
|
||||
const KEY = 'jbot_settings';
|
||||
|
||||
export const DEFAULT_SETTINGS = {
|
||||
robotName: 'JBOT',
|
||||
robotPersonality: '你是一个友好、简洁、偶尔幽默的AI机器人助手。回复控制在80字以内。',
|
||||
language: 'auto', // 'auto' | 'zh-CN' | 'en-US'
|
||||
voiceId: DEFAULT_VOICE,
|
||||
customVoiceId: '', // overrides voiceId if non-empty
|
||||
responseMaxWords: 80,
|
||||
};
|
||||
|
||||
export function getSettings() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(KEY) || '{}');
|
||||
return { ...DEFAULT_SETTINGS, ...saved };
|
||||
} catch {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSettings(patch) {
|
||||
const current = getSettings();
|
||||
const updated = { ...current, ...patch };
|
||||
localStorage.setItem(KEY, JSON.stringify(updated));
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function resetSettings() {
|
||||
localStorage.removeItem(KEY);
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
|
||||
// Returns the effective voice ID (custom takes priority)
|
||||
export function getEffectiveVoice(settings = null) {
|
||||
const s = settings || getSettings();
|
||||
return s.customVoiceId?.trim() || s.voiceId || DEFAULT_VOICE;
|
||||
}
|
||||
@@ -693,6 +693,7 @@ dependencies = [
|
||||
{ name = "qdrant-client" },
|
||||
{ name = "requests" },
|
||||
{ name = "sqlparse" },
|
||||
{ name = "websocket-client" },
|
||||
{ name = "whitenoise" },
|
||||
{ name = "whoosh" },
|
||||
{ name = "yfinance" },
|
||||
@@ -742,6 +743,7 @@ requires-dist = [
|
||||
{ name = "qdrant-client", specifier = ">=1.13.2" },
|
||||
{ name = "requests", specifier = ">=2.32.4,<3" },
|
||||
{ name = "sqlparse", specifier = "==0.5.1" },
|
||||
{ name = "websocket-client", specifier = ">=1.9.0" },
|
||||
{ name = "whitenoise", specifier = "==5.3.0" },
|
||||
{ name = "whoosh", specifier = "==2.7.4" },
|
||||
{ name = "yfinance", specifier = ">=1.3.0" },
|
||||
|
||||
@@ -9,6 +9,7 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
input: {
|
||||
search: 'static_src/search.jsx',
|
||||
jbot: 'static_src/jbot/main.jsx',
|
||||
},
|
||||
output: {
|
||||
entryFileNames: '[name].js',
|
||||
|
||||
Reference in New Issue
Block a user