mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
- 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>
253 lines
9.4 KiB
React
253 lines
9.4 KiB
React
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>
|
||
);
|
||
}
|