docs: v1.5.7 动态API提供商管理,OpenCode Go集成,Bug修复
This commit is contained in:
+236
-145
@@ -4,8 +4,11 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="bi bi-key"></i> AI API 配置</h5>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="showAddProviderModal()">
|
||||
<i class="bi bi-plus-lg"></i> 新增提供商
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info">
|
||||
@@ -14,24 +17,21 @@
|
||||
|
||||
<form id="apiConfigForm">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="col-md-8 mb-3">
|
||||
<label class="form-label">提供商</label>
|
||||
<select class="form-select" id="apiProvider" onchange="onProviderChange()">
|
||||
<option value="minimax">MiniMax (Token Plan)</option>
|
||||
<option value="volcengine">火山引擎 (Volcengine)</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="openrouter">OpenRouter</option>
|
||||
</select>
|
||||
<div class="input-group">
|
||||
<select class="form-select" id="apiProvider" onchange="onProviderChange()"></select>
|
||||
<button class="btn btn-outline-danger" type="button" onclick="deleteCurrentProvider()" title="删除当前提供商">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">模型</label>
|
||||
<select class="form-select" id="apiModel">
|
||||
<option value="MiniMax-M2.7-highspeed">MiniMax-M2.7-highspeed</option>
|
||||
<option value="doubao-seed-2.0-pro">doubao-seed-2.0-pro</option>
|
||||
<option value="doubao-seed-code">doubao-seed-code</option>
|
||||
<option value="doubao-seed-2.0-lite">doubao-seed-2.0-lite</option>
|
||||
</select>
|
||||
<div id="modelInputArea">
|
||||
<select class="form-select" id="apiModelSelect" onchange="onModelSelectChange()" style="display:none"></select>
|
||||
<input type="text" class="form-control" id="apiModelText" placeholder="如 gpt-4o-mini">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -77,41 +77,59 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增提供商 Modal -->
|
||||
<div class="modal fade" id="addProviderModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="providerModalTitle">新增API提供商</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">提供商ID <small class="text-muted">(英文标识)</small></label>
|
||||
<input type="text" class="form-control" id="newProviderId" placeholder="如 opencodego">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">显示名称</label>
|
||||
<input type="text" class="form-control" id="newProviderName" placeholder="如 OpenCode Go">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">API Endpoint</label>
|
||||
<input type="text" class="form-control" id="newProviderEndpoint" placeholder="https://api.example.com/v1">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">模型列表</label>
|
||||
<div id="newModelsContainer">
|
||||
<div class="input-group mb-1">
|
||||
<input type="text" class="form-control newModelInput" placeholder="模型名称,如 gpt-4o">
|
||||
<button class="btn btn-outline-danger" type="button" onclick="removeModelInput(this)" title="删除">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-1" onclick="addModelInput()">
|
||||
<i class="bi bi-plus"></i> 添加模型
|
||||
</button>
|
||||
<small class="text-muted d-block">一个提供商可以有多个模型</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||||
<button type="button" class="btn btn-primary" id="providerModalSaveBtn" onclick="saveProvider()">确认添加</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="bi bi-lightbulb"></i> 推荐配置</h6>
|
||||
<h6 class="mb-0"><i class="bi bi-lightbulb"></i> 已注册提供商</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm">
|
||||
<table class="table table-sm" id="providersTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>提供商</th>
|
||||
<th>推荐模型</th>
|
||||
<th>Endpoint</th>
|
||||
</tr>
|
||||
<tr><th>ID</th><th>名称</th><th>Endpoint</th><th>模型</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>MiniMax</td>
|
||||
<td>MiniMax-M2.7-highspeed</td>
|
||||
<td><code>https://api.minimaxi.com/anthropic/v1</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>火山方舟</td>
|
||||
<td>doubao-seed-2.0-pro</td>
|
||||
<td><code>https://ark.cn-beijing.volces.com/api/coding/v3</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>DeepSeek</td>
|
||||
<td>deepseek-chat</td>
|
||||
<td><code>https://api.deepseek.com</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>OpenRouter</td>
|
||||
<td>deepseek/deepseek-r1</td>
|
||||
<td><code>https://openrouter.ai/api/v1</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,126 +138,199 @@
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
window.pageInit = function(data) {
|
||||
if (data.role !== 'admin') {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
if (data.role !== 'admin') { window.location.href = '/'; return; }
|
||||
loadApiConfig();
|
||||
};
|
||||
|
||||
const providerDefaults = {
|
||||
'minimax': {
|
||||
endpoint: 'https://api.minimaxi.com/anthropic/v1',
|
||||
model: 'MiniMax-M2.7-highspeed',
|
||||
temperature: 0.7,
|
||||
api_key: ''
|
||||
},
|
||||
'volcengine': {
|
||||
endpoint: 'https://ark.cn-beijing.volces.com/api/coding/v3',
|
||||
model: 'doubao-seed-2.0-pro',
|
||||
temperature: 0.7,
|
||||
api_key: ''
|
||||
},
|
||||
'deepseek': {
|
||||
endpoint: 'https://api.deepseek.com',
|
||||
model: 'deepseek-chat',
|
||||
temperature: 0.7,
|
||||
api_key: ''
|
||||
},
|
||||
'openai': {
|
||||
endpoint: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o-mini',
|
||||
temperature: 0.7,
|
||||
api_key: ''
|
||||
},
|
||||
'openrouter': {
|
||||
endpoint: 'https://openrouter.ai/api/v1',
|
||||
model: 'anthropic/claude-3-haiku',
|
||||
temperature: 0.7,
|
||||
api_key: ''
|
||||
}
|
||||
};
|
||||
let allProviders = {};
|
||||
let currentModel = '';
|
||||
|
||||
async function onProviderChange() {
|
||||
const provider = document.getElementById('apiProvider').value;
|
||||
const defaults = providerDefaults[provider] || providerDefaults['volcengine'];
|
||||
|
||||
document.getElementById('apiModel').value = defaults.model;
|
||||
document.getElementById('apiEndpoint').value = defaults.endpoint;
|
||||
document.getElementById('apiTemperature').value = defaults.temperature;
|
||||
document.getElementById('apiKey').value = defaults.api_key || '';
|
||||
async function loadProviders() {
|
||||
const r = await fetch('/api/config/providers');
|
||||
allProviders = await r.json();
|
||||
renderProviderDropdown();
|
||||
renderProvidersTable();
|
||||
}
|
||||
|
||||
async function saveApiConfig(silent = false, overrideProvider = null) {
|
||||
const provider = overrideProvider || document.getElementById('apiProvider').value;
|
||||
function renderProviderDropdown() {
|
||||
const sel = document.getElementById('apiProvider');
|
||||
const cur = sel.value;
|
||||
sel.innerHTML = '';
|
||||
for (const [id, p] of Object.entries(allProviders)) {
|
||||
sel.innerHTML += `<option value="${id}">${p.name}</option>`;
|
||||
}
|
||||
if (cur && allProviders[cur]) sel.value = cur;
|
||||
}
|
||||
|
||||
function renderProvidersTable() {
|
||||
const tbody = document.getElementById('providersTable').querySelector('tbody');
|
||||
tbody.innerHTML = '';
|
||||
for (const [id, p] of Object.entries(allProviders)) {
|
||||
const ml = (p.models || []).join(', ');
|
||||
tbody.innerHTML += `<tr>
|
||||
<td><code>${id}</code></td><td>${p.name}</td>
|
||||
<td><code style="font-size:11px">${p.endpoint}</code></td>
|
||||
<td>${ml || '<span class="text-muted">—</span>'}</td>
|
||||
<td><button class="btn btn-sm btn-outline-secondary" onclick="editProvider('${id}')" title="编辑"><i class="bi bi-pencil"></i></button></td>
|
||||
</tr>`;
|
||||
}
|
||||
if (!Object.keys(allProviders).length) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="text-muted text-center">暂无提供商</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderModelInput(models) {
|
||||
const sel = document.getElementById('apiModelSelect');
|
||||
const txt = document.getElementById('apiModelText');
|
||||
if (models && models.length > 1) {
|
||||
sel.style.display = ''; txt.style.display = 'none';
|
||||
sel.innerHTML = '';
|
||||
models.forEach(m => { const o = document.createElement('option'); o.value = m; o.textContent = m; sel.appendChild(o); });
|
||||
if (models.includes(currentModel)) sel.value = currentModel; else sel.value = models[0];
|
||||
} else {
|
||||
sel.style.display = 'none'; txt.style.display = '';
|
||||
txt.value = (models && models.length === 1) ? models[0] : (currentModel || '');
|
||||
}
|
||||
}
|
||||
|
||||
function getSelectedModel() {
|
||||
const sel = document.getElementById('apiModelSelect');
|
||||
return sel.style.display !== 'none' ? sel.value : document.getElementById('apiModelText').value;
|
||||
}
|
||||
|
||||
function onModelSelectChange() {
|
||||
currentModel = document.getElementById('apiModelSelect').value;
|
||||
}
|
||||
|
||||
function onProviderChange() {
|
||||
const id = document.getElementById('apiProvider').value;
|
||||
const p = allProviders[id];
|
||||
if (p) {
|
||||
document.getElementById('apiEndpoint').value = p.endpoint || '';
|
||||
renderModelInput(p.models || []);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadApiConfig() {
|
||||
await loadProviders();
|
||||
try {
|
||||
const r = await fetch('/api/config');
|
||||
const c = await r.json();
|
||||
document.getElementById('apiProvider').value = c.provider || '';
|
||||
document.getElementById('apiEndpoint').value = c.base_url || '';
|
||||
document.getElementById('apiTemperature').value = c.temperature || 0.7;
|
||||
document.getElementById('watermarkText').value = c.watermark_text || '';
|
||||
currentModel = c.model || '';
|
||||
const p = allProviders[c.provider];
|
||||
renderModelInput(p ? p.models : []);
|
||||
if (c.api_key_preview) document.getElementById('apiKeyPreview').textContent = '当前: ' + c.api_key_preview;
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
async function saveApiConfig() {
|
||||
const m = getSelectedModel();
|
||||
if (!m) { alert('请选择或输入模型'); return; }
|
||||
const config = {
|
||||
provider: provider,
|
||||
model: document.getElementById('apiModel').value,
|
||||
provider: document.getElementById('apiProvider').value,
|
||||
model: m,
|
||||
api_key: document.getElementById('apiKey').value,
|
||||
base_url: document.getElementById('apiEndpoint').value,
|
||||
temperature: parseFloat(document.getElementById('apiTemperature').value),
|
||||
watermark_text: document.getElementById('watermarkText').value.trim()
|
||||
};
|
||||
|
||||
const r = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!silent) {
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
alert('保存成功');
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadApiConfig() {
|
||||
try {
|
||||
const r = await fetch('/api/config');
|
||||
const config = await r.json();
|
||||
document.getElementById('apiProvider').value = config.provider || 'volcengine';
|
||||
document.getElementById('apiModel').value = config.model || 'doubao-seed-2.0-pro';
|
||||
document.getElementById('apiKey').value = config.api_key || '';
|
||||
document.getElementById('apiEndpoint').value = config.base_url || '';
|
||||
document.getElementById('apiTemperature').value = config.temperature || 0.7;
|
||||
document.getElementById('watermarkText').value = config.watermark_text || '';
|
||||
|
||||
if (config.api_key_preview) {
|
||||
document.getElementById('apiKeyPreview').textContent = '当前: ' + config.api_key_preview;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
const r = await fetch('/api/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config) });
|
||||
const d = await r.json();
|
||||
if (d.error) alert(d.error); else { alert('保存成功'); loadApiConfig(); }
|
||||
}
|
||||
|
||||
async function testApiConnection() {
|
||||
const resultDiv = document.getElementById('apiTestResult');
|
||||
resultDiv.innerHTML = '<div class="alert alert-info">测试中...</div>';
|
||||
|
||||
const div = document.getElementById('apiTestResult');
|
||||
div.innerHTML = '<div class="alert alert-info">测试中...</div>';
|
||||
const r = await fetch('/api/config/test', { method: 'POST' });
|
||||
const data = await r.json();
|
||||
const d = await r.json();
|
||||
div.innerHTML = d.success ? '<div class="alert alert-success"><i class="bi bi-check-circle"></i> 连接成功</div>'
|
||||
: '<div class="alert alert-danger"><i class="bi bi-x-circle"></i> 连接失败: ' + (d.error || '未知错误') + '</div>';
|
||||
}
|
||||
|
||||
if (data.success) {
|
||||
resultDiv.innerHTML = '<div class="alert alert-success"><i class="bi bi-check-circle"></i> 连接成功</div>';
|
||||
} else {
|
||||
resultDiv.innerHTML = '<div class="alert alert-danger"><i class="bi bi-x-circle"></i> 连接失败: ' + (data.error || '未知错误') + '</div>';
|
||||
}
|
||||
// --- 新增/编辑提供商 ---
|
||||
let editingProviderId = null;
|
||||
|
||||
function showAddProviderModal() {
|
||||
editingProviderId = null;
|
||||
document.getElementById('providerModalTitle').textContent = '新增API提供商';
|
||||
document.getElementById('providerModalSaveBtn').textContent = '确认添加';
|
||||
document.getElementById('newProviderId').value = '';
|
||||
document.getElementById('newProviderId').disabled = false;
|
||||
document.getElementById('newProviderName').value = '';
|
||||
document.getElementById('newProviderEndpoint').value = '';
|
||||
document.getElementById('newModelsContainer').innerHTML = `<div class="input-group mb-1">
|
||||
<input type="text" class="form-control newModelInput" placeholder="模型名称">
|
||||
<button class="btn btn-outline-danger" type="button" onclick="removeModelInput(this)" title="删除">×</button>
|
||||
</div>`;
|
||||
new bootstrap.Modal(document.getElementById('addProviderModal')).show();
|
||||
}
|
||||
|
||||
function editProvider(id) {
|
||||
editingProviderId = id;
|
||||
const p = allProviders[id];
|
||||
document.getElementById('providerModalTitle').textContent = '编辑提供商 - ' + p.name;
|
||||
document.getElementById('providerModalSaveBtn').textContent = '保存修改';
|
||||
document.getElementById('newProviderId').value = id;
|
||||
document.getElementById('newProviderId').disabled = true;
|
||||
document.getElementById('newProviderName').value = p.name || '';
|
||||
document.getElementById('newProviderEndpoint').value = p.endpoint || '';
|
||||
const container = document.getElementById('newModelsContainer');
|
||||
const models = p.models || [];
|
||||
if (!models.length) models.push('');
|
||||
container.innerHTML = models.map(m => `<div class="input-group mb-1">
|
||||
<input type="text" class="form-control newModelInput" value="${m}" placeholder="模型名称">
|
||||
<button class="btn btn-outline-danger" type="button" onclick="removeModelInput(this)" title="删除">×</button>
|
||||
</div>`).join('');
|
||||
new bootstrap.Modal(document.getElementById('addProviderModal')).show();
|
||||
}
|
||||
|
||||
async function saveProvider() {
|
||||
const id = document.getElementById('newProviderId').value.trim().toLowerCase();
|
||||
const name = document.getElementById('newProviderName').value.trim();
|
||||
const endpoint = document.getElementById('newProviderEndpoint').value.trim();
|
||||
const models = []; document.querySelectorAll('.newModelInput').forEach(inp => { const v = inp.value.trim(); if (v) models.push(v); });
|
||||
|
||||
if (!name || !endpoint) { alert('请填写名称和Endpoint'); return; }
|
||||
if (!models.length) { alert('请至少添加一个模型'); return; }
|
||||
|
||||
const isEdit = !!editingProviderId;
|
||||
const url = isEdit ? '/api/config/providers/' + editingProviderId : '/api/config/providers';
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
const body = isEdit ? { name, endpoint, models } : { id, name, endpoint, models };
|
||||
|
||||
const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const d = await r.json();
|
||||
if (d.error) { alert(d.error); return; }
|
||||
bootstrap.Modal.getInstance(document.getElementById('addProviderModal')).hide();
|
||||
await loadProviders();
|
||||
document.getElementById('apiProvider').value = editingProviderId || id;
|
||||
onProviderChange();
|
||||
alert(isEdit ? '修改成功' : '添加成功');
|
||||
}
|
||||
|
||||
// --- 删除提供商 ---
|
||||
|
||||
async function deleteCurrentProvider() {
|
||||
const id = document.getElementById('apiProvider').value;
|
||||
if (Object.keys(allProviders).length <= 1) { alert('至少保留一个提供商'); return; }
|
||||
if (!confirm('确定要删除提供商 "' + (allProviders[id]?.name || id) + '" 吗?')) return;
|
||||
const r = await fetch('/api/config/providers/' + id, { method: 'DELETE' });
|
||||
const d = await r.json();
|
||||
if (d.error) { alert(d.error); return; }
|
||||
alert('删除成功');
|
||||
loadApiConfig();
|
||||
}
|
||||
|
||||
function toggleApiKey() {
|
||||
const input = document.getElementById('apiKey');
|
||||
const icon = document.getElementById('apiKeyToggleIcon');
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
icon.className = 'bi bi-eye-slash';
|
||||
} else {
|
||||
input.type = 'password';
|
||||
icon.className = 'bi bi-eye';
|
||||
}
|
||||
const inp = document.getElementById('apiKey');
|
||||
const ico = document.getElementById('apiKeyToggleIcon');
|
||||
if (inp.type === 'password') { inp.type = 'text'; ico.className = 'bi bi-eye-slash'; }
|
||||
else { inp.type = 'password'; ico.className = 'bi bi-eye'; }
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user