Files
links/static_src/jbot/pages/Settings.jsx
T
junvandCopilot a47ea316c7 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>
2026-05-17 14:13:37 +10:00

253 lines
9.4 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { getSettings, saveSettings, resetSettings } from '../store/settingsStore';
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 [apiCfg, setApiCfg] = useState(API_CFG_DEFAULTS);
const [apiLoading, setApiLoading] = useState(true);
const [saved, setSaved] = useState(false);
const [showTokens, setShowTokens] = useState({});
useEffect(() => {
apiGetApiConfig().then(cfg => {
if (cfg && !cfg.error) setApiCfg(prev => ({ ...prev, ...cfg }));
}).finally(() => setApiLoading(false));
}, []);
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);
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);
}
};
return (
<div className="settings-page">
<div className="settings-toolbar">
<button className="settings-back" onClick={() => navigate('/')}> 返回</button>
<h1 className="settings-heading">全局设置</h1>
<button className="settings-save" onClick={handleSave}>
{saved ? '✓ 已保存' : '保存'}
</button>
</div>
<div className="settings-body">
{/* ── 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>
<label className="settings-label">
名字
<input
className="settings-input"
value={form.robotName}
onChange={e => set('robotName', e.target.value)}
placeholder="JBOT"
/>
</label>
<label className="settings-label">
人格描述 <span className="settings-hint">会注入到每轮对话的系统提示</span>
<textarea
className="settings-textarea"
rows={3}
value={form.robotPersonality}
onChange={e => set('robotPersonality', e.target.value)}
placeholder="你是一个友好、简洁、偶尔幽默的AI机器人助手。回复控制在80字以内。"
/>
</label>
</section>
{/* ── User context ── */}
<section className="settings-section">
<h2 className="settings-section-title">👤 用户背景</h2>
<label className="settings-label">
关于你 <span className="settings-hint">机器人会了解你的背景更好地回答</span>
<textarea
className="settings-textarea"
rows={3}
value={form.extraContext || ''}
onChange={e => set('extraContext', e.target.value)}
placeholder="例如:我叫小明,是一名软件工程师,在上海工作,对AI和嵌入式系统感兴趣。"
/>
</label>
</section>
{/* ── Language ── */}
<section className="settings-section">
<h2 className="settings-section-title">🌐 语言</h2>
<div className="settings-radio-group">
{[
{ value: 'auto', label: '自动(跟随用户语言)' },
{ value: 'zh-CN', label: '中文' },
{ value: 'en-US', label: 'English' },
].map(opt => (
<label key={opt.value} className="settings-radio-label">
<input
type="radio"
name="language"
value={opt.value}
checked={form.language === opt.value}
onChange={() => set('language', opt.value)}
/>
{opt.label}
</label>
))}
</div>
</section>
{/* ── TTS Voice ── */}
<section className="settings-section">
<h2 className="settings-section-title">🔊 语音音色</h2>
<div className="settings-voice-grid">
{VOICES.map(v => (
<button
key={v.id}
className={`settings-voice-btn ${!form.customVoiceId && form.voiceId === v.id ? 'active' : ''}`}
onClick={() => { set('voiceId', v.id); set('customVoiceId', ''); }}
>
<span className="voice-btn-label">{v.label}</span>
<span className="voice-btn-meta">{v.lang} · {v.gender} · {v.style}</span>
</button>
))}
</div>
<label className="settings-label settings-label-mt">
自定义音色 ID <span className="settings-hint">填写后会覆盖上方选择</span>
<input
className="settings-input"
value={form.customVoiceId || ''}
onChange={e => set('customVoiceId', e.target.value)}
placeholder="例如 zh_female_vv_uranus_bigtts"
/>
</label>
<p className="settings-hint-block">
完整音色列表见{' '}
<a href="https://www.volcengine.com/docs/6561/1257544" target="_blank" rel="noreferrer">
火山引擎 TTS 音色文档
</a>
</p>
</section>
{/* ── Danger zone ── */}
<section className="settings-section settings-section-danger">
<h2 className="settings-section-title">⚠️ 重置</h2>
<button className="settings-reset-btn" onClick={handleReset}>
重置为默认设置
</button>
</section>
</div>
</div>
);
}