mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +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 routermon/ ./routermon/
|
||||
COPY pricemon/ ./pricemon/
|
||||
COPY jbot/ ./jbot/
|
||||
COPY new_theme/ ./new_theme/
|
||||
COPY templates/ ./templates/
|
||||
COPY locale/ ./locale/
|
||||
@@ -149,10 +150,12 @@ COPY --chown=appuser:appuser netscan/ ./netscan/
|
||||
COPY --chown=appuser:appuser nginxmon/ ./nginxmon/
|
||||
COPY --chown=appuser:appuser routermon/ ./routermon/
|
||||
COPY --chown=appuser:appuser pricemon/ ./pricemon/
|
||||
COPY --chown=appuser:appuser jbot/ ./jbot/
|
||||
COPY --chown=appuser:appuser new_theme/ ./new_theme/
|
||||
COPY --chown=appuser:appuser templates/ ./templates/
|
||||
COPY --chown=appuser:appuser static/ ./static/
|
||||
COPY --chown=appuser:appuser qdrant_sync.py ./
|
||||
COPY --chown=appuser:appuser entrypoint.sh ./
|
||||
|
||||
# Final cleanup
|
||||
RUN find /app -name "*.pyc" -delete \
|
||||
@@ -168,5 +171,5 @@ EXPOSE 8000
|
||||
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/ || exit 1
|
||||
|
||||
# Run the application
|
||||
CMD ["gunicorn", "--chdir", "/app", "core.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "2", "--threads", "4"]
|
||||
# Run migrations then start gunicorn
|
||||
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('memory/extract/', views.memory_extract_view, name='jbot-api-memory-extract'),
|
||||
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
|
||||
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)
|
||||
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/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
|
||||
@@ -20,8 +22,6 @@ 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
|
||||
|
||||
@@ -43,16 +43,100 @@ def _json_body(request):
|
||||
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) ─────────────────
|
||||
|
||||
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')
|
||||
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 (VOLC_APP_ID / VOLC_ACCESS_TOKEN)')
|
||||
raise ValueError('Volcengine credentials not configured — set them in JBOT Settings → API 配置')
|
||||
|
||||
resp = requests.post(
|
||||
'https://openspeech.bytedance.com/api/v3/tts/unidirectional',
|
||||
@@ -121,19 +205,16 @@ def tts_view(request):
|
||||
|
||||
# ── 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
|
||||
@@ -183,7 +264,6 @@ def _asr_unpack(data: 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'
|
||||
result = subprocess.run(
|
||||
['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:
|
||||
"""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')
|
||||
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')
|
||||
raise ValueError('Volcengine credentials not configured — set them in JBOT Settings → API 配置')
|
||||
|
||||
pcm = _to_pcm(audio_bytes, mime_type)
|
||||
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,
|
||||
_SER_JSON, _CMP_GZIP, gzip.compress(config)))
|
||||
|
||||
CHUNK = 6400
|
||||
pcm_data = pcm # captured from outer scope
|
||||
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))
|
||||
@@ -295,9 +375,9 @@ def _do_asr(audio_bytes: bytes, mime_type: str = 'audio/webm') -> str:
|
||||
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')
|
||||
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:
|
||||
@@ -316,21 +396,22 @@ def chat_view(request):
|
||||
if request.method != 'POST':
|
||||
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:
|
||||
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)
|
||||
messages = data.get('messages', [])
|
||||
memories = data.get('memories', [])
|
||||
settings = data.get('settings', {})
|
||||
model = os.environ.get('LLM_MODEL', 'deepseek/deepseek-chat')
|
||||
model = cfg['llm_model']
|
||||
|
||||
robot_name = settings.get('robotName', 'JBOT')
|
||||
personality = settings.get('robotPersonality',
|
||||
'你是一个友好、简洁、偶尔幽默的AI机器人助手。回复控制在80字以内。')
|
||||
lang = settings.get('language', 'auto')
|
||||
lang_hint = ''
|
||||
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':
|
||||
@@ -379,7 +460,8 @@ def memory_extract_view(request):
|
||||
if request.method != 'POST':
|
||||
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:
|
||||
return JsonResponse({'facts': []})
|
||||
|
||||
@@ -389,7 +471,7 @@ def memory_extract_view(request):
|
||||
if not user_text or not bot_text:
|
||||
return JsonResponse({'facts': []})
|
||||
|
||||
model = os.environ.get('LLM_MODEL', 'deepseek/deepseek-chat')
|
||||
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'
|
||||
@@ -416,9 +498,7 @@ def memory_extract_view(request):
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
raw = resp.json().get('choices', [{}])[0].get('message', {}).get('content', '[]')
|
||||
raw = raw.strip()
|
||||
# strip ```json fences if present
|
||||
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)
|
||||
@@ -430,13 +510,12 @@ def memory_extract_view(request):
|
||||
return JsonResponse({'facts': []})
|
||||
|
||||
|
||||
# ── Config ────────────────────────────────────────────────────────────────────
|
||||
# ── Public config flags ────────────────────────────────────────────────────────
|
||||
|
||||
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'))
|
||||
cfg = _get_cfg()
|
||||
return JsonResponse({
|
||||
'volcengine': has_volc,
|
||||
'llm': has_llm,
|
||||
'ttsVoice': os.environ.get('VOLC_TTS_VOICE', 'zh_female_vv_uranus_bigtts'),
|
||||
'volcengine': bool(cfg['volc_app_id'] and cfg['volc_access_token']),
|
||||
'llm': bool(cfg['openrouter_api_key']),
|
||||
'ttsVoice': cfg['volc_tts_voice'],
|
||||
})
|
||||
|
||||
+1
-16
@@ -173,22 +173,7 @@ 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
|
||||
# JBOT — AI Robot Face env var defaults (API keys configured via UI at /jbot/settings)
|
||||
- name: LLM_MODEL
|
||||
value: "deepseek/deepseek-chat"
|
||||
- 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;
|
||||
}
|
||||
.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 { 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() {
|
||||
const navigate = useNavigate();
|
||||
const [form, setForm] = useState(() => getSettings());
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [form, setForm] = useState(() => getSettings());
|
||||
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) => {
|
||||
setForm(prev => ({ ...prev, [key]: val }));
|
||||
setSaved(false);
|
||||
};
|
||||
useEffect(() => {
|
||||
apiGetApiConfig().then(cfg => {
|
||||
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);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
try {
|
||||
await apiSaveApiConfig(apiCfg);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
} catch (e) {
|
||||
alert('API 配置保存失败:' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (window.confirm('重置所有设置为默认值?')) {
|
||||
const defaults = resetSettings();
|
||||
setForm(defaults);
|
||||
setApiCfg(API_CFG_DEFAULTS);
|
||||
setSaved(false);
|
||||
}
|
||||
};
|
||||
@@ -39,6 +64,81 @@ export default function Settings() {
|
||||
|
||||
<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 ── */}
|
||||
<section className="settings-section">
|
||||
<h2 className="settings-section-title">🤖 机器人身份</h2>
|
||||
|
||||
@@ -64,3 +64,29 @@ export async function apiExtractMemory(userText, botText) {
|
||||
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