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:
2026-05-17 14:13:37 +10:00
co-authored by Copilot
parent 9148966e5c
commit a87b4643fd
35 changed files with 3263 additions and 40 deletions
+1
View File
@@ -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/* \
+1
View File
@@ -25,6 +25,7 @@ INSTALLED_APPS = [
'nginxmon',
'routermon',
'pricemon',
'jbot',
]
ROOT_URLCONF = 'core.urls'
+4
View File
@@ -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'),
View File
+10
View File
@@ -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'),
]
+7
View File
@@ -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'
+14
View File
@@ -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>
+8
View File
@@ -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
View File
@@ -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 03 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'),
})
+22
View File
@@ -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
+62
View File
@@ -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",
+1
View File
@@ -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"
}
}
+1
View File
@@ -29,6 +29,7 @@ dependencies = [
"kubernetes>=29.0.0",
"maxminddb>=3.1.1",
"yfinance>=1.3.0",
"websocket-client>=1.9.0",
]
[build-system]
+40
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+12
View File
File diff suppressed because one or more lines are too long
+1 -40
View File
File diff suppressed because one or more lines are too long
+867
View File
@@ -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; }
+376
View File
@@ -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">QI · 自动</span></h3>
<div className="btn-grid btn-grid-4">
{AUTO.map(e => (
<button
key={e.id}
className={`ebtn${exprId === e.id ? ' active' : ''}`}
style={{ '--c': e.color }}
onClick={() => pick(e.id)}
>{e.label}</button>
))}
</div>
</div>
<label className="toggle">
<input type="checkbox" checked={autoRotate} onChange={ev => setAutoRotate(ev.target.checked)} />
<span>自动轮换微表情</span>
</label>
<div className="switch-interval">
<div className="switch-interval-label">
切换间隔 <span className="interval-val">{Math.round(switchMs / 1000)} </span>
</div>
<input
type="range" min="3" max="30" step="1"
value={Math.round(switchMs / 1000)}
onChange={ev => setSwitchMs(Number(ev.target.value) * 1000)}
/>
<div className="interval-ticks"><span>3s</span><span>30s</span></div>
</div>
<p className="hint">
按住 <kbd>Space</kbd> 说话 · 松开发送 · <button className="hist-inline-link" onClick={() => navigate('/history')}>历史记录</button>
</p>
<p className="hint voice-hint">
🔊 音色: <button className="hist-inline-link" onClick={() => navigate('/settings')}>{currentVoiceLabel}</button>
</p>
<MemoryPanel memories={memories} onMemoriesChange={refreshMemories} />
</section>
</aside>
{/* ── Right column: chat ── */}
<div className="right-col">
<ChatPanel messages={chatMessages} voiceStatus={voiceStatus} />
</div>
</div>
</div>
);
}
+75
View File
@@ -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>
);
}
+245
View File
@@ -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; // 0120 ms: closing
if (e < 220) return 0; // 120220 ms: closed
if (e < 340) return (e - 220) / 120; // 220340 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.55 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',
}}
/>
);
}
+474
View File
@@ -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 (33.5) and offset (12) 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]));
+25
View File
@@ -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;
}
+94
View File
@@ -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 };
}
+17
View File
@@ -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;
}
+20
View File
@@ -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>,
);
+88
View File
@@ -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>
);
}
+152
View File
@@ -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>
);
}
+66
View File
@@ -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 [];
}
}
+22
View File
@@ -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)));
}
+26
View File
@@ -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);
}
+39
View File
@@ -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;
}
Generated
+2
View File
@@ -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" },
+1
View File
@@ -9,6 +9,7 @@ export default defineConfig({
rollupOptions: {
input: {
search: 'static_src/search.jsx',
jbot: 'static_src/jbot/main.jsx',
},
output: {
entryFileNames: '[name].js',