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:
2026-05-17 14:13:37 +10:00
co-authored by Copilot
parent a87b4643fd
commit a47ea316c7
14 changed files with 366 additions and 77 deletions
+5 -2
View File
@@ -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"]
+10
View File
@@ -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
+8
View File
@@ -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',)
+1
View File
@@ -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'),
]
+27
View File
@@ -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',
},
),
]
View File
+27
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
File diff suppressed because one or more lines are too long
+23
View File
@@ -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;
}
+111 -11
View File
@@ -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>
+26
View File
@@ -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();
}