""" 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 GET /api/jbot/api-config/ → read API credentials/settings (tokens masked) POST /api/jbot/api-config/ → save API credentials/settings to DB """ 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.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 {} # ── Config helper: DB values override env vars ──────────────────────────────── def _get_cfg(): """Return merged config: DB row first, env vars as fallback.""" defaults = { 'volc_app_id': os.environ.get('VOLC_APP_ID', ''), 'volc_access_token': os.environ.get('VOLC_ACCESS_TOKEN', ''), 'openrouter_api_key': os.environ.get('OPENROUTER_API_KEY', ''), 'llm_model': os.environ.get('LLM_MODEL', 'deepseek/deepseek-chat'), 'volc_tts_voice': os.environ.get('VOLC_TTS_VOICE', 'zh_female_vv_uranus_bigtts'), 'volc_asr_resource': os.environ.get('VOLC_ASR_RESOURCE', 'volc.bigasr.sauc.duration'), 'hermes_url': os.environ.get('HERMES_URL', 'http://192.168.1.10:8642'), 'hermes_api_key': os.environ.get('HERMES_API_KEY', ''), 'backend_mode': os.environ.get('BACKEND_MODE', 'openrouter'), } try: from .models import JbotApiConfig db = JbotApiConfig.load() for k in defaults: db_val = getattr(db, k, '') if db_val: defaults[k] = db_val except Exception: pass return defaults # ── API Config CRUD (read/write credentials via UI) ────────────────────────── @csrf_exempt def api_config_view(request): if request.method == 'GET': try: from .models import JbotApiConfig db = JbotApiConfig.load() env = { 'volc_app_id': os.environ.get('VOLC_APP_ID', ''), 'volc_access_token': os.environ.get('VOLC_ACCESS_TOKEN', ''), 'openrouter_api_key': os.environ.get('OPENROUTER_API_KEY', ''), } def _masked(db_val, env_val): return '***' if (db_val or env_val) else '' return JsonResponse({ 'volcAppId': db.volc_app_id or env['volc_app_id'], 'volcAccessToken': _masked(db.volc_access_token, env['volc_access_token']), 'openrouterApiKey': _masked(db.openrouter_api_key, env['openrouter_api_key']), 'llmModel': db.llm_model or os.environ.get('LLM_MODEL', 'deepseek/deepseek-chat'), 'volcTtsVoice': db.volc_tts_voice or os.environ.get('VOLC_TTS_VOICE', 'zh_female_vv_uranus_bigtts'), 'volcAsrResource': db.volc_asr_resource or os.environ.get('VOLC_ASR_RESOURCE', 'volc.bigasr.sauc.duration'), 'hermesUrl': db.hermes_url or os.environ.get('HERMES_URL', 'http://192.168.1.10:8642'), 'hermesApiKey': _masked(db.hermes_api_key, os.environ.get('HERMES_API_KEY', '')), 'backendMode': db.backend_mode or os.environ.get('BACKEND_MODE', 'openrouter'), 'hasVolc': bool(db.volc_app_id or env['volc_app_id']), 'hasLlm': bool(db.openrouter_api_key or env['openrouter_api_key']), }) except Exception as e: return JsonResponse({'error': str(e)}, status=500) if request.method == 'POST': data = _json_body(request) try: from .models import JbotApiConfig db = JbotApiConfig.load() # Only update fields that are present and not the masked placeholder mapping = { 'volcAppId': 'volc_app_id', 'llmModel': 'llm_model', 'volcTtsVoice': 'volc_tts_voice', 'volcAsrResource': 'volc_asr_resource', 'hermesUrl': 'hermes_url', 'backendMode': 'backend_mode', } for json_key, db_field in mapping.items(): if json_key in data: setattr(db, db_field, data[json_key]) # Secret fields: only update if non-empty and not the placeholder for json_key, db_field in [('volcAccessToken', 'volc_access_token'), ('openrouterApiKey', 'openrouter_api_key'), ('hermesApiKey', 'hermes_api_key')]: val = data.get(json_key, '') if val and val != '***': setattr(db, db_field, val) db.save() return JsonResponse({'ok': True}) except Exception as e: logger.error('[API-CONFIG] save failed: %s', e) return JsonResponse({'error': str(e)}, status=500) return JsonResponse({'error': 'GET or POST required'}, status=405) # ── TTS 2.0 (Volcengine seed-tts-2.0 HTTP streaming NDJSON) ───────────────── def _do_tts(text, voice=None): cfg = _get_cfg() app_id = cfg['volc_app_id'] token = cfg['volc_access_token'] speaker = voice or cfg['volc_tts_voice'] 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 — set them in JBOT Settings → API 配置') 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) ──────────────────── _ASR_MT_FULL_CLIENT = 1 _ASR_MT_AUDIO_ONLY = 2 _ASR_MT_FULL_SERVER = 9 _ASR_MT_ERROR = 15 _FLAG_NO_SEQ = 0 _FLAG_POS_SEQ = 1 _FLAG_LAST_NO = 2 _FLAG_NEG_SEQ = 3 _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: 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: try: import websocket as ws_module except ImportError: raise RuntimeError('websocket-client not installed — run: uv add websocket-client') cfg = _get_cfg() app_id = cfg['volc_app_id'] token = cfg['volc_access_token'] resource_id = cfg['volc_asr_resource'] if not app_id or not token: raise ValueError('Volcengine credentials not configured — set them in JBOT Settings → API 配置') 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(_asr_pack(_ASR_MT_FULL_CLIENT, _FLAG_NO_SEQ, _SER_JSON, _CMP_GZIP, gzip.compress(config)), ws_module.ABNF.OPCODE_BINARY) CHUNK = 6400 pcm_data = pcm for off in range(0, len(pcm_data), CHUNK): chunk = pcm_data[off:off + CHUNK] is_last = (off + CHUNK >= len(pcm_data)) ws.send(_asr_pack( _ASR_MT_AUDIO_ONLY, _FLAG_LAST_NO if is_last else _FLAG_NO_SEQ, _SER_NONE, _CMP_GZIP, gzip.compress(chunk), ), ws_module.ABNF.OPCODE_BINARY) 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): err_str = str(error) # opcode=8 is a normal WebSocket close frame — Volcengine server closes # the connection after receiving the last audio chunk. Not a real error. if 'opcode=8' in err_str or 'connection is already closed' in err_str.lower(): logger.debug('[ASR] server closed connection normally: %s', err_str) done_ev.set() return result['error'] = err_str 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) ─────────────────────────────────────────────── def _build_sys_prompt(settings, memories): """Shared system prompt builder for both backends.""" 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}' return sys_content def _do_openrouter_chat(messages, sys_content, cfg): """Call OpenRouter /v1/chat/completions (non-streaming).""" api_key = cfg['openrouter_api_key'] if not api_key: raise ValueError('OPENROUTER_API_KEY not configured — set it in JBOT Settings → API 配置') 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': cfg['llm_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: raise RuntimeError(f'LLM returned no content: {result}') return text, [] def _do_hermes_chat(messages, sys_content, cfg): """Call Hermes Agent /v1/chat/completions (streaming). Returns (final_text, tool_calls) where tool_calls is a list of dicts like {"tool": "terminal", "preview": "ls -la", "status": "start"}. The stream is consumed completely before returning — callers get a clean JSON response, not an SSE stream. Only the final assistant text is spoken via TTS; tool-progress events are shown as status in the UI. """ base_url = cfg.get('hermes_url', 'http://192.168.1.10:8642').rstrip('/') api_key = cfg.get('hermes_api_key', '') headers = {'Content-Type': 'application/json'} if api_key: headers['Authorization'] = f'Bearer {api_key}' payload = { 'model': 'hermes-agent', 'messages': [{'role': 'system', 'content': sys_content}, *messages], 'stream': True, } try: resp = requests.post( f'{base_url}/v1/chat/completions', headers=headers, json=payload, stream=True, timeout=120, ) resp.raise_for_status() except requests.exceptions.ConnectionError as e: raise RuntimeError(f'Cannot connect to Hermes Agent at {base_url} — is it running? ({e})') final_text = [] tool_calls = [] current_event = None for raw_line in resp.iter_lines(decode_unicode=True): if not raw_line: # Blank line resets current event type (SSE separator) current_event = None continue if raw_line.startswith('event:'): current_event = raw_line[6:].strip() continue if raw_line.startswith('data:'): data_str = raw_line[5:].strip() if data_str == '[DONE]': break try: obj = json.loads(data_str) except Exception: continue if current_event == 'hermes.tool.progress': tool_name = obj.get('tool', '') preview = obj.get('preview', '') status = obj.get('status', '') if tool_name and status == 'start': tool_calls.append({'tool': tool_name, 'preview': preview}) else: delta = (obj.get('choices') or [{}])[0].get('delta', {}) content = delta.get('content') or '' if content: final_text.append(content) text = ''.join(final_text).strip() if not text: raise RuntimeError('Hermes Agent returned no text response') return text, tool_calls @csrf_exempt def chat_view(request): if request.method != 'POST': return JsonResponse({'error': 'POST required'}, status=405) cfg = _get_cfg() data = _json_body(request) messages = data.get('messages', []) memories = data.get('memories', []) settings = data.get('settings', {}) mode = cfg.get('backend_mode', 'openrouter') # In Hermes mode, don't inject local memories — Hermes manages its own memory sys_content = _build_sys_prompt(settings, [] if mode == 'hermes' else memories) try: if mode == 'hermes': text, tool_calls = _do_hermes_chat(messages, sys_content, cfg) return JsonResponse({'text': text, 'toolCalls': tool_calls, 'backend': 'hermes'}) else: text, tool_calls = _do_openrouter_chat(messages, sys_content, cfg) return JsonResponse({'text': text, 'backend': 'openrouter'}) except Exception as e: logger.error('[CHAT/%s] %s', mode, 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) cfg = _get_cfg() api_key = cfg['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 = cfg['llm_model'] 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', '[]').strip() 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': []}) # ── Public config flags ──────────────────────────────────────────────────────── def config_view(request): cfg = _get_cfg() return JsonResponse({ 'volcengine': bool(cfg['volc_app_id'] and cfg['volc_access_token']), 'llm': bool(cfg['openrouter_api_key']), 'ttsVoice': cfg['volc_tts_voice'], 'backendMode': cfg.get('backend_mode', 'openrouter'), })