mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
feat(jbot): UI-configurable API keys + fix Dockerfile
- Add JbotApiConfig Django model (DB singleton) for storing Volcengine/OpenRouter credentials set via Settings UI - New endpoint GET/POST /api/jbot/api-config/ — tokens masked as *** on read; only non-placeholder values are updated on write - views.py: _get_cfg() helper reads DB first, env vars as fallback - Settings.jsx: new 🔑 API 配置 section with show/hide token fields for VOLC App ID, Access Token, OpenRouter API Key, LLM Model, ASR Resource - api.js: apiGetApiConfig() / apiSaveApiConfig() client helpers - Dockerfile: COPY jbot/ in both builder + production stages (was missing) - entrypoint.sh: runs migrate --noinput before gunicorn (auto-creates table) - k8s/manifest.yaml: remove jbot-credentials secretKeyRef (no Secret needed); keep LLM_MODEL/VOLC_TTS_VOICE/VOLC_ASR_RESOURCE as optional env defaults Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+5
-2
@@ -43,6 +43,7 @@ COPY netscan/ ./netscan/
|
|||||||
COPY nginxmon/ ./nginxmon/
|
COPY nginxmon/ ./nginxmon/
|
||||||
COPY routermon/ ./routermon/
|
COPY routermon/ ./routermon/
|
||||||
COPY pricemon/ ./pricemon/
|
COPY pricemon/ ./pricemon/
|
||||||
|
COPY jbot/ ./jbot/
|
||||||
COPY new_theme/ ./new_theme/
|
COPY new_theme/ ./new_theme/
|
||||||
COPY templates/ ./templates/
|
COPY templates/ ./templates/
|
||||||
COPY locale/ ./locale/
|
COPY locale/ ./locale/
|
||||||
@@ -149,10 +150,12 @@ COPY --chown=appuser:appuser netscan/ ./netscan/
|
|||||||
COPY --chown=appuser:appuser nginxmon/ ./nginxmon/
|
COPY --chown=appuser:appuser nginxmon/ ./nginxmon/
|
||||||
COPY --chown=appuser:appuser routermon/ ./routermon/
|
COPY --chown=appuser:appuser routermon/ ./routermon/
|
||||||
COPY --chown=appuser:appuser pricemon/ ./pricemon/
|
COPY --chown=appuser:appuser pricemon/ ./pricemon/
|
||||||
|
COPY --chown=appuser:appuser jbot/ ./jbot/
|
||||||
COPY --chown=appuser:appuser new_theme/ ./new_theme/
|
COPY --chown=appuser:appuser new_theme/ ./new_theme/
|
||||||
COPY --chown=appuser:appuser templates/ ./templates/
|
COPY --chown=appuser:appuser templates/ ./templates/
|
||||||
COPY --chown=appuser:appuser static/ ./static/
|
COPY --chown=appuser:appuser static/ ./static/
|
||||||
COPY --chown=appuser:appuser qdrant_sync.py ./
|
COPY --chown=appuser:appuser qdrant_sync.py ./
|
||||||
|
COPY --chown=appuser:appuser entrypoint.sh ./
|
||||||
|
|
||||||
# Final cleanup
|
# Final cleanup
|
||||||
RUN find /app -name "*.pyc" -delete \
|
RUN find /app -name "*.pyc" -delete \
|
||||||
@@ -168,5 +171,5 @@ EXPOSE 8000
|
|||||||
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
|
||||||
CMD curl -f http://localhost:8000/ || exit 1
|
CMD curl -f http://localhost:8000/ || exit 1
|
||||||
|
|
||||||
# Run the application
|
# Run migrations then start gunicorn
|
||||||
CMD ["gunicorn", "--chdir", "/app", "core.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "2", "--threads", "4"]
|
CMD ["/bin/sh", "/app/entrypoint.sh"]
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Run DB migrations on startup (idempotent — safe to run every time)
|
||||||
|
uv run manage.py migrate --noinput
|
||||||
|
|
||||||
|
exec gunicorn --chdir /app core.wsgi:application \
|
||||||
|
--bind 0.0.0.0:8000 \
|
||||||
|
--workers 2 \
|
||||||
|
--threads 4
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from .models import JbotApiConfig
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(JbotApiConfig)
|
||||||
|
class JbotApiConfigAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('volc_app_id', 'llm_model', 'volc_tts_voice', 'updated_at')
|
||||||
|
readonly_fields = ('updated_at',)
|
||||||
@@ -7,4 +7,5 @@ urlpatterns = [
|
|||||||
path('asr/', views.asr_view, name='jbot-api-asr'),
|
path('asr/', views.asr_view, name='jbot-api-asr'),
|
||||||
path('memory/extract/', views.memory_extract_view, name='jbot-api-memory-extract'),
|
path('memory/extract/', views.memory_extract_view, name='jbot-api-memory-extract'),
|
||||||
path('config/', views.config_view, name='jbot-api-config'),
|
path('config/', views.config_view, name='jbot-api-config'),
|
||||||
|
path('api-config/', views.api_config_view, name='jbot-api-api-config'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='JbotApiConfig',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('volc_app_id', models.CharField(blank=True, default='', max_length=200)),
|
||||||
|
('volc_access_token', models.CharField(blank=True, default='', max_length=500)),
|
||||||
|
('openrouter_api_key', models.CharField(blank=True, default='', max_length=200)),
|
||||||
|
('llm_model', models.CharField(blank=True, default='deepseek/deepseek-chat', max_length=200)),
|
||||||
|
('volc_tts_voice', models.CharField(blank=True, default='zh_female_vv_uranus_bigtts', max_length=200)),
|
||||||
|
('volc_asr_resource', models.CharField(blank=True, default='volc.bigasr.sauc.duration', max_length=200)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'JBOT API Config',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class JbotApiConfig(models.Model):
|
||||||
|
"""Singleton model storing JBOT API credentials and runtime defaults.
|
||||||
|
Configured via the Settings UI at /jbot/settings — env vars are fallback only.
|
||||||
|
"""
|
||||||
|
volc_app_id = models.CharField(max_length=200, blank=True, default='')
|
||||||
|
volc_access_token = models.CharField(max_length=500, blank=True, default='')
|
||||||
|
openrouter_api_key = models.CharField(max_length=200, blank=True, default='')
|
||||||
|
llm_model = models.CharField(max_length=200, blank=True, default='deepseek/deepseek-chat')
|
||||||
|
volc_tts_voice = models.CharField(max_length=200, blank=True, default='zh_female_vv_uranus_bigtts')
|
||||||
|
volc_asr_resource = models.CharField(max_length=200, blank=True, default='volc.bigasr.sauc.duration')
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'JBOT API Config'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls):
|
||||||
|
"""Return the singleton row, creating it if needed."""
|
||||||
|
obj, _ = cls.objects.get_or_create(pk=1)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
self.pk = 1
|
||||||
|
super().save(*args, **kwargs)
|
||||||
+123
-44
@@ -1,12 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
JBOT backend views — Python port of the Node.js server/index.js
|
JBOT backend views — Python port of the Node.js server/index.js
|
||||||
Endpoints:
|
Endpoints:
|
||||||
GET /jbot/ → SPA shell (index.html)
|
GET /jbot/ → SPA shell (index.html)
|
||||||
POST /api/jbot/chat/ → OpenRouter LLM proxy
|
POST /api/jbot/chat/ → OpenRouter LLM proxy
|
||||||
POST /api/jbot/tts/ → Volcengine seed-tts-2.0
|
POST /api/jbot/tts/ → Volcengine seed-tts-2.0
|
||||||
POST /api/jbot/asr/ → Volcengine BigASR 1.0 (WebSocket binary protocol)
|
POST /api/jbot/asr/ → Volcengine BigASR 1.0 (WebSocket binary protocol)
|
||||||
POST /api/jbot/memory/extract/ → background memory extraction via LLM
|
POST /api/jbot/memory/extract/ → background memory extraction via LLM
|
||||||
GET /api/jbot/config/ → public config flags
|
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 base64
|
||||||
import gzip
|
import gzip
|
||||||
@@ -20,8 +22,6 @@ import uuid
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
from django.http import JsonResponse
|
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.decorators.csrf import csrf_exempt
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
@@ -43,16 +43,100 @@ def _json_body(request):
|
|||||||
return {}
|
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'),
|
||||||
|
}
|
||||||
|
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'),
|
||||||
|
'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',
|
||||||
|
}
|
||||||
|
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')]:
|
||||||
|
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) ─────────────────
|
# ── TTS 2.0 (Volcengine seed-tts-2.0 HTTP streaming NDJSON) ─────────────────
|
||||||
|
|
||||||
def _do_tts(text, voice=None):
|
def _do_tts(text, voice=None):
|
||||||
app_id = os.environ.get('VOLC_APP_ID', '')
|
cfg = _get_cfg()
|
||||||
token = os.environ.get('VOLC_ACCESS_TOKEN', '')
|
app_id = cfg['volc_app_id']
|
||||||
speaker = voice or os.environ.get('VOLC_TTS_VOICE', 'zh_female_vv_uranus_bigtts')
|
token = cfg['volc_access_token']
|
||||||
|
speaker = voice or cfg['volc_tts_voice']
|
||||||
resource_id = os.environ.get('VOLC_TTS_RESOURCE', 'seed-tts-2.0')
|
resource_id = os.environ.get('VOLC_TTS_RESOURCE', 'seed-tts-2.0')
|
||||||
|
|
||||||
if not app_id or not token:
|
if not app_id or not token:
|
||||||
raise ValueError('Volcengine credentials not configured (VOLC_APP_ID / VOLC_ACCESS_TOKEN)')
|
raise ValueError('Volcengine credentials not configured — set them in JBOT Settings → API 配置')
|
||||||
|
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
'https://openspeech.bytedance.com/api/v3/tts/unidirectional',
|
'https://openspeech.bytedance.com/api/v3/tts/unidirectional',
|
||||||
@@ -121,19 +205,16 @@ def tts_view(request):
|
|||||||
|
|
||||||
# ── ASR 1.0 (Volcengine BigASR WebSocket binary protocol) ────────────────────
|
# ── ASR 1.0 (Volcengine BigASR WebSocket binary protocol) ────────────────────
|
||||||
|
|
||||||
# Message type constants
|
|
||||||
_ASR_MT_FULL_CLIENT = 1
|
_ASR_MT_FULL_CLIENT = 1
|
||||||
_ASR_MT_AUDIO_ONLY = 2
|
_ASR_MT_AUDIO_ONLY = 2
|
||||||
_ASR_MT_FULL_SERVER = 9
|
_ASR_MT_FULL_SERVER = 9
|
||||||
_ASR_MT_ERROR = 15
|
_ASR_MT_ERROR = 15
|
||||||
|
|
||||||
# Flag constants
|
|
||||||
_FLAG_NO_SEQ = 0
|
_FLAG_NO_SEQ = 0
|
||||||
_FLAG_POS_SEQ = 1
|
_FLAG_POS_SEQ = 1
|
||||||
_FLAG_LAST_NO = 2
|
_FLAG_LAST_NO = 2
|
||||||
_FLAG_NEG_SEQ = 3
|
_FLAG_NEG_SEQ = 3
|
||||||
|
|
||||||
# Serialization / compression
|
|
||||||
_SER_NONE = 0
|
_SER_NONE = 0
|
||||||
_SER_JSON = 1
|
_SER_JSON = 1
|
||||||
_CMP_NONE = 0
|
_CMP_NONE = 0
|
||||||
@@ -183,7 +264,6 @@ def _asr_unpack(data: bytes):
|
|||||||
|
|
||||||
|
|
||||||
def _to_pcm(audio_bytes: bytes, mime_type: str = 'audio/webm') -> bytes:
|
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'
|
fmt = mime_type.split('/')[1].split(';')[0].strip() if '/' in mime_type else 'webm'
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['ffmpeg', '-loglevel', 'error',
|
['ffmpeg', '-loglevel', 'error',
|
||||||
@@ -198,18 +278,18 @@ def _to_pcm(audio_bytes: bytes, mime_type: str = 'audio/webm') -> bytes:
|
|||||||
|
|
||||||
|
|
||||||
def _do_asr(audio_bytes: bytes, mime_type: str = 'audio/webm') -> str:
|
def _do_asr(audio_bytes: bytes, mime_type: str = 'audio/webm') -> str:
|
||||||
"""Transcribe audio using Volcengine BigASR 1.0 WebSocket binary protocol."""
|
|
||||||
try:
|
try:
|
||||||
import websocket as ws_module
|
import websocket as ws_module
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise RuntimeError('websocket-client not installed — run: uv add websocket-client')
|
raise RuntimeError('websocket-client not installed — run: uv add websocket-client')
|
||||||
|
|
||||||
app_id = os.environ.get('VOLC_APP_ID', '')
|
cfg = _get_cfg()
|
||||||
token = os.environ.get('VOLC_ACCESS_TOKEN', '')
|
app_id = cfg['volc_app_id']
|
||||||
resource_id = os.environ.get('VOLC_ASR_RESOURCE', 'volc.bigasr.sauc.duration')
|
token = cfg['volc_access_token']
|
||||||
|
resource_id = cfg['volc_asr_resource']
|
||||||
|
|
||||||
if not app_id or not token:
|
if not app_id or not token:
|
||||||
raise ValueError('Volcengine credentials not configured')
|
raise ValueError('Volcengine credentials not configured — set them in JBOT Settings → API 配置')
|
||||||
|
|
||||||
pcm = _to_pcm(audio_bytes, mime_type)
|
pcm = _to_pcm(audio_bytes, mime_type)
|
||||||
if not pcm:
|
if not pcm:
|
||||||
@@ -234,8 +314,8 @@ def _do_asr(audio_bytes: bytes, mime_type: str = 'audio/webm') -> str:
|
|||||||
ws.send_binary(_asr_pack(_ASR_MT_FULL_CLIENT, _FLAG_NO_SEQ,
|
ws.send_binary(_asr_pack(_ASR_MT_FULL_CLIENT, _FLAG_NO_SEQ,
|
||||||
_SER_JSON, _CMP_GZIP, gzip.compress(config)))
|
_SER_JSON, _CMP_GZIP, gzip.compress(config)))
|
||||||
|
|
||||||
CHUNK = 6400
|
CHUNK = 6400
|
||||||
pcm_data = pcm # captured from outer scope
|
pcm_data = pcm
|
||||||
for off in range(0, len(pcm_data), CHUNK):
|
for off in range(0, len(pcm_data), CHUNK):
|
||||||
chunk = pcm_data[off:off + CHUNK]
|
chunk = pcm_data[off:off + CHUNK]
|
||||||
is_last = (off + CHUNK >= len(pcm_data))
|
is_last = (off + CHUNK >= len(pcm_data))
|
||||||
@@ -295,9 +375,9 @@ def _do_asr(audio_bytes: bytes, mime_type: str = 'audio/webm') -> str:
|
|||||||
def asr_view(request):
|
def asr_view(request):
|
||||||
if request.method != 'POST':
|
if request.method != 'POST':
|
||||||
return JsonResponse({'error': 'POST required'}, status=405)
|
return JsonResponse({'error': 'POST required'}, status=405)
|
||||||
data = _json_body(request)
|
data = _json_body(request)
|
||||||
audio_b64 = data.get('audioBase64', '')
|
audio_b64 = data.get('audioBase64', '')
|
||||||
mime_type = data.get('format', 'audio/webm')
|
mime_type = data.get('format', 'audio/webm')
|
||||||
if not audio_b64:
|
if not audio_b64:
|
||||||
return JsonResponse({'error': 'audioBase64 required'}, status=400)
|
return JsonResponse({'error': 'audioBase64 required'}, status=400)
|
||||||
try:
|
try:
|
||||||
@@ -316,21 +396,22 @@ def chat_view(request):
|
|||||||
if request.method != 'POST':
|
if request.method != 'POST':
|
||||||
return JsonResponse({'error': 'POST required'}, status=405)
|
return JsonResponse({'error': 'POST required'}, status=405)
|
||||||
|
|
||||||
api_key = os.environ.get('OPENROUTER_API_KEY', '')
|
cfg = _get_cfg()
|
||||||
|
api_key = cfg['openrouter_api_key']
|
||||||
if not api_key:
|
if not api_key:
|
||||||
return JsonResponse({'error': 'OPENROUTER_API_KEY not configured'}, status=503)
|
return JsonResponse({'error': 'OPENROUTER_API_KEY not configured — set it in JBOT Settings → API 配置'}, status=503)
|
||||||
|
|
||||||
data = _json_body(request)
|
data = _json_body(request)
|
||||||
messages = data.get('messages', [])
|
messages = data.get('messages', [])
|
||||||
memories = data.get('memories', [])
|
memories = data.get('memories', [])
|
||||||
settings = data.get('settings', {})
|
settings = data.get('settings', {})
|
||||||
model = os.environ.get('LLM_MODEL', 'deepseek/deepseek-chat')
|
model = cfg['llm_model']
|
||||||
|
|
||||||
robot_name = settings.get('robotName', 'JBOT')
|
robot_name = settings.get('robotName', 'JBOT')
|
||||||
personality = settings.get('robotPersonality',
|
personality = settings.get('robotPersonality',
|
||||||
'你是一个友好、简洁、偶尔幽默的AI机器人助手。回复控制在80字以内。')
|
'你是一个友好、简洁、偶尔幽默的AI机器人助手。回复控制在80字以内。')
|
||||||
lang = settings.get('language', 'auto')
|
lang = settings.get('language', 'auto')
|
||||||
lang_hint = ''
|
lang_hint = ''
|
||||||
if lang == 'zh-CN':
|
if lang == 'zh-CN':
|
||||||
lang_hint = '\n请用中文回复。'
|
lang_hint = '\n请用中文回复。'
|
||||||
elif lang == 'en-US':
|
elif lang == 'en-US':
|
||||||
@@ -379,7 +460,8 @@ def memory_extract_view(request):
|
|||||||
if request.method != 'POST':
|
if request.method != 'POST':
|
||||||
return JsonResponse({'error': 'POST required'}, status=405)
|
return JsonResponse({'error': 'POST required'}, status=405)
|
||||||
|
|
||||||
api_key = os.environ.get('OPENROUTER_API_KEY', '')
|
cfg = _get_cfg()
|
||||||
|
api_key = cfg['openrouter_api_key']
|
||||||
if not api_key:
|
if not api_key:
|
||||||
return JsonResponse({'facts': []})
|
return JsonResponse({'facts': []})
|
||||||
|
|
||||||
@@ -389,7 +471,7 @@ def memory_extract_view(request):
|
|||||||
if not user_text or not bot_text:
|
if not user_text or not bot_text:
|
||||||
return JsonResponse({'facts': []})
|
return JsonResponse({'facts': []})
|
||||||
|
|
||||||
model = os.environ.get('LLM_MODEL', 'deepseek/deepseek-chat')
|
model = cfg['llm_model']
|
||||||
prompt = (
|
prompt = (
|
||||||
'You are a memory extraction assistant for an AI robot named JBOT.\n\n'
|
'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'
|
f'Conversation exchange:\nUser: "{user_text}"\nJBOT: "{bot_text}"\n\n'
|
||||||
@@ -416,9 +498,7 @@ def memory_extract_view(request):
|
|||||||
timeout=15,
|
timeout=15,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
raw = resp.json().get('choices', [{}])[0].get('message', {}).get('content', '[]')
|
raw = resp.json().get('choices', [{}])[0].get('message', {}).get('content', '[]').strip()
|
||||||
raw = raw.strip()
|
|
||||||
# strip ```json fences if present
|
|
||||||
if raw.startswith('```'):
|
if raw.startswith('```'):
|
||||||
raw = raw.split('\n', 1)[-1].rsplit('```', 1)[0].strip()
|
raw = raw.split('\n', 1)[-1].rsplit('```', 1)[0].strip()
|
||||||
facts = json.loads(raw)
|
facts = json.loads(raw)
|
||||||
@@ -430,13 +510,12 @@ def memory_extract_view(request):
|
|||||||
return JsonResponse({'facts': []})
|
return JsonResponse({'facts': []})
|
||||||
|
|
||||||
|
|
||||||
# ── Config ────────────────────────────────────────────────────────────────────
|
# ── Public config flags ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def config_view(request):
|
def config_view(request):
|
||||||
has_volc = bool(os.environ.get('VOLC_APP_ID') and os.environ.get('VOLC_ACCESS_TOKEN'))
|
cfg = _get_cfg()
|
||||||
has_llm = bool(os.environ.get('OPENROUTER_API_KEY'))
|
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'volcengine': has_volc,
|
'volcengine': bool(cfg['volc_app_id'] and cfg['volc_access_token']),
|
||||||
'llm': has_llm,
|
'llm': bool(cfg['openrouter_api_key']),
|
||||||
'ttsVoice': os.environ.get('VOLC_TTS_VOICE', 'zh_female_vv_uranus_bigtts'),
|
'ttsVoice': cfg['volc_tts_voice'],
|
||||||
})
|
})
|
||||||
|
|||||||
+1
-16
@@ -173,22 +173,7 @@ spec:
|
|||||||
value: "192.168.1.2"
|
value: "192.168.1.2"
|
||||||
- name: OLLAMA_URL
|
- name: OLLAMA_URL
|
||||||
value: "http://ollama-service.ollama.svc.cluster.local:11434"
|
value: "http://ollama-service.ollama.svc.cluster.local:11434"
|
||||||
# JBOT — AI Robot Face env vars
|
# JBOT — AI Robot Face env var defaults (API keys configured via UI at /jbot/settings)
|
||||||
- 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
|
- name: LLM_MODEL
|
||||||
value: "deepseek/deepseek-chat"
|
value: "deepseek/deepseek-chat"
|
||||||
- name: VOLC_TTS_VOICE
|
- name: VOLC_TTS_VOICE
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+3
-3
File diff suppressed because one or more lines are too long
@@ -865,3 +865,26 @@ kbd {
|
|||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
.settings-reset-btn:hover { background: #FEF2F2; }
|
.settings-reset-btn:hover { background: #FEF2F2; }
|
||||||
|
|
||||||
|
/* ── Settings: secret input row ── */
|
||||||
|
.settings-secret-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.settings-secret-row .settings-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.settings-show-btn {
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
background: #e2e8f0;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
.settings-show-btn:hover {
|
||||||
|
background: #cbd5e1;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,28 +1,53 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { getSettings, saveSettings, resetSettings } from '../store/settingsStore';
|
import { getSettings, saveSettings, resetSettings } from '../store/settingsStore';
|
||||||
import { VOICES, DEFAULT_VOICE } from '../data/voices';
|
import { VOICES } from '../data/voices';
|
||||||
|
import { apiGetApiConfig, apiSaveApiConfig } from '../services/api';
|
||||||
|
|
||||||
|
const API_CFG_DEFAULTS = {
|
||||||
|
volcAppId: '',
|
||||||
|
volcAccessToken: '',
|
||||||
|
openrouterApiKey: '',
|
||||||
|
llmModel: 'deepseek/deepseek-chat',
|
||||||
|
volcTtsVoice: 'zh_female_vv_uranus_bigtts',
|
||||||
|
volcAsrResource: 'volc.bigasr.sauc.duration',
|
||||||
|
};
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [form, setForm] = useState(() => getSettings());
|
const [form, setForm] = useState(() => getSettings());
|
||||||
const [saved, setSaved] = useState(false);
|
const [apiCfg, setApiCfg] = useState(API_CFG_DEFAULTS);
|
||||||
|
const [apiLoading, setApiLoading] = useState(true);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [showTokens, setShowTokens] = useState({});
|
||||||
|
|
||||||
const set = (key, val) => {
|
useEffect(() => {
|
||||||
setForm(prev => ({ ...prev, [key]: val }));
|
apiGetApiConfig().then(cfg => {
|
||||||
setSaved(false);
|
if (cfg && !cfg.error) setApiCfg(prev => ({ ...prev, ...cfg }));
|
||||||
};
|
}).finally(() => setApiLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSave = () => {
|
const set = (key, val) => { setForm(prev => ({ ...prev, [key]: val })); setSaved(false); };
|
||||||
|
const setApi = (key, val) => { setApiCfg(prev => ({ ...prev, [key]: val })); setSaved(false); };
|
||||||
|
|
||||||
|
const toggleShow = (key) => setShowTokens(prev => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
saveSettings(form);
|
saveSettings(form);
|
||||||
setSaved(true);
|
try {
|
||||||
setTimeout(() => setSaved(false), 2000);
|
await apiSaveApiConfig(apiCfg);
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => setSaved(false), 2000);
|
||||||
|
} catch (e) {
|
||||||
|
alert('API 配置保存失败:' + e.message);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
if (window.confirm('重置所有设置为默认值?')) {
|
if (window.confirm('重置所有设置为默认值?')) {
|
||||||
const defaults = resetSettings();
|
const defaults = resetSettings();
|
||||||
setForm(defaults);
|
setForm(defaults);
|
||||||
|
setApiCfg(API_CFG_DEFAULTS);
|
||||||
setSaved(false);
|
setSaved(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -39,6 +64,81 @@ export default function Settings() {
|
|||||||
|
|
||||||
<div className="settings-body">
|
<div className="settings-body">
|
||||||
|
|
||||||
|
{/* ── API 配置 ── */}
|
||||||
|
<section className="settings-section">
|
||||||
|
<h2 className="settings-section-title">🔑 API 配置</h2>
|
||||||
|
<p className="settings-hint-block">
|
||||||
|
配置保存在服务器数据库中。Token 字段显示 *** 表示已配置,留空则保持不变。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{apiLoading ? (
|
||||||
|
<p className="settings-hint-block">加载中…</p>
|
||||||
|
) : (<>
|
||||||
|
<label className="settings-label">
|
||||||
|
Volcengine App ID
|
||||||
|
<input
|
||||||
|
className="settings-input"
|
||||||
|
value={apiCfg.volcAppId}
|
||||||
|
onChange={e => setApi('volcAppId', e.target.value)}
|
||||||
|
placeholder="填写火山引擎 App ID"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="settings-label">
|
||||||
|
Volcengine Access Token
|
||||||
|
<div className="settings-secret-row">
|
||||||
|
<input
|
||||||
|
className="settings-input"
|
||||||
|
type={showTokens.volcAccessToken ? 'text' : 'password'}
|
||||||
|
value={apiCfg.volcAccessToken}
|
||||||
|
onChange={e => setApi('volcAccessToken', e.target.value)}
|
||||||
|
placeholder="留空则保持不变"
|
||||||
|
/>
|
||||||
|
<button className="settings-show-btn" onClick={() => toggleShow('volcAccessToken')}>
|
||||||
|
{showTokens.volcAccessToken ? '隐藏' : '显示'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="settings-label">
|
||||||
|
OpenRouter API Key
|
||||||
|
<div className="settings-secret-row">
|
||||||
|
<input
|
||||||
|
className="settings-input"
|
||||||
|
type={showTokens.openrouterApiKey ? 'text' : 'password'}
|
||||||
|
value={apiCfg.openrouterApiKey}
|
||||||
|
onChange={e => setApi('openrouterApiKey', e.target.value)}
|
||||||
|
placeholder="留空则保持不变"
|
||||||
|
/>
|
||||||
|
<button className="settings-show-btn" onClick={() => toggleShow('openrouterApiKey')}>
|
||||||
|
{showTokens.openrouterApiKey ? '隐藏' : '显示'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="settings-label">
|
||||||
|
LLM 模型
|
||||||
|
<input
|
||||||
|
className="settings-input"
|
||||||
|
value={apiCfg.llmModel}
|
||||||
|
onChange={e => setApi('llmModel', e.target.value)}
|
||||||
|
placeholder="deepseek/deepseek-chat"
|
||||||
|
/>
|
||||||
|
<span className="settings-hint">OpenRouter 模型 ID,例如 openai/gpt-4o</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="settings-label">
|
||||||
|
ASR Resource ID
|
||||||
|
<input
|
||||||
|
className="settings-input"
|
||||||
|
value={apiCfg.volcAsrResource}
|
||||||
|
onChange={e => setApi('volcAsrResource', e.target.value)}
|
||||||
|
placeholder="volc.bigasr.sauc.duration"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>)}
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* ── Robot identity ── */}
|
{/* ── Robot identity ── */}
|
||||||
<section className="settings-section">
|
<section className="settings-section">
|
||||||
<h2 className="settings-section-title">🤖 机器人身份</h2>
|
<h2 className="settings-section-title">🤖 机器人身份</h2>
|
||||||
|
|||||||
@@ -64,3 +64,29 @@ export async function apiExtractMemory(userText, botText) {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read JBOT API credentials/settings from server (tokens are masked as '***')
|
||||||
|
export async function apiGetApiConfig() {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${BASE}/api-config/`);
|
||||||
|
if (!r.ok) return {};
|
||||||
|
return r.json();
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save JBOT API credentials/settings to server DB
|
||||||
|
// Pass '***' for secret fields to leave them unchanged
|
||||||
|
export async function apiSaveApiConfig(config) {
|
||||||
|
const r = await fetch(`${BASE}/api-config/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const e = await r.json().catch(() => ({}));
|
||||||
|
throw new Error(e.error || `HTTP ${r.status}`);
|
||||||
|
}
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user