protocol-proxy 2.5.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/public/app.js +251 -4
- package/public/index.html +14 -0
- package/public/style.css +116 -4
- package/server.js +163 -0
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -380,6 +380,42 @@ function renderProviderOptions() {
|
|
|
380
380
|
});
|
|
381
381
|
}
|
|
382
382
|
|
|
383
|
+
function attachMaskedKeyClick(span) {
|
|
384
|
+
span.style.cursor = 'pointer';
|
|
385
|
+
span.title = '点击修改 API Key';
|
|
386
|
+
span.addEventListener('click', () => {
|
|
387
|
+
const row = span.closest('.api-key-entry');
|
|
388
|
+
const group = span.parentElement;
|
|
389
|
+
const input = document.createElement('input');
|
|
390
|
+
input.type = 'password';
|
|
391
|
+
input.className = 'api-key-input';
|
|
392
|
+
input.placeholder = '输入新的 API Key...';
|
|
393
|
+
group.replaceChild(input, span);
|
|
394
|
+
input.focus();
|
|
395
|
+
input.addEventListener('blur', async () => {
|
|
396
|
+
const val = input.value.trim();
|
|
397
|
+
if (!val) {
|
|
398
|
+
restoreMaskedSpan(group, input, row);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (await showConfirm('确认修改此 API Key?<br>取消将恢复为 ****', '确认修改')) {
|
|
402
|
+
row.dataset.masked = 'false';
|
|
403
|
+
} else {
|
|
404
|
+
row.dataset.masked = 'true';
|
|
405
|
+
restoreMaskedSpan(group, input, row);
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function restoreMaskedSpan(group, input, row) {
|
|
412
|
+
const restored = document.createElement('span');
|
|
413
|
+
restored.className = 'api-key-display';
|
|
414
|
+
restored.textContent = '****';
|
|
415
|
+
group.replaceChild(restored, input);
|
|
416
|
+
attachMaskedKeyClick(restored);
|
|
417
|
+
}
|
|
418
|
+
|
|
383
419
|
function renderApiKeys(provider) {
|
|
384
420
|
const container = document.getElementById('api-keys-list');
|
|
385
421
|
if (!container) return;
|
|
@@ -407,6 +443,10 @@ function renderApiKeys(provider) {
|
|
|
407
443
|
</div>
|
|
408
444
|
`).join('');
|
|
409
445
|
|
|
446
|
+
container.querySelectorAll('.api-key-display').forEach(span => {
|
|
447
|
+
attachMaskedKeyClick(span);
|
|
448
|
+
});
|
|
449
|
+
|
|
410
450
|
container.querySelectorAll('.api-key-remove').forEach(btn => {
|
|
411
451
|
btn.addEventListener('click', () => {
|
|
412
452
|
btn.closest('.api-key-entry').remove();
|
|
@@ -453,7 +493,12 @@ function initApiKeyAddBtn() {
|
|
|
453
493
|
});
|
|
454
494
|
}
|
|
455
495
|
|
|
456
|
-
function
|
|
496
|
+
function hasUnsavedMaskedKeyEdits() {
|
|
497
|
+
return !!document.querySelector('#api-keys-list .api-key-entry[data-index][data-masked="false"]');
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function selectProvider(id) {
|
|
501
|
+
if (hasUnsavedMaskedKeyEdits() && !await showConfirm('当前有未保存的 API Key 修改,切换供应商将丢失,确认切换?', '确认切换')) return;
|
|
457
502
|
const provider = providers.find(p => p.id === id);
|
|
458
503
|
document.getElementById('provider-id').value = id || '';
|
|
459
504
|
const protocol = provider ? provider.protocol : '';
|
|
@@ -542,6 +587,59 @@ function initModelDropdown() {
|
|
|
542
587
|
});
|
|
543
588
|
}
|
|
544
589
|
|
|
590
|
+
async function importModels() {
|
|
591
|
+
const providerId = document.getElementById('provider-id').value;
|
|
592
|
+
if (!providerId) {
|
|
593
|
+
showToast('请先选择供应商', true);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
const btn = document.getElementById('model-import-btn');
|
|
597
|
+
btn.disabled = true;
|
|
598
|
+
btn.textContent = '导入中...';
|
|
599
|
+
try {
|
|
600
|
+
const apiKeys = collectApiKeys();
|
|
601
|
+
const res = await fetch(`/api/providers/${providerId}/available-models`, {
|
|
602
|
+
method: 'POST',
|
|
603
|
+
headers: { 'Content-Type': 'application/json' },
|
|
604
|
+
body: JSON.stringify({ apiKeys }),
|
|
605
|
+
});
|
|
606
|
+
const data = await res.json();
|
|
607
|
+
if (!data.models || data.models.length === 0) {
|
|
608
|
+
showToast(data.message || '未获取到模型', true);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
const provider = providers.find(p => p.id === providerId);
|
|
612
|
+
const existing = new Set(provider?.models || []);
|
|
613
|
+
const newModels = data.models.filter(m => !existing.has(m));
|
|
614
|
+
if (newModels.length === 0) {
|
|
615
|
+
showToast(`已全部存在,共 ${data.models.length} 个模型`);
|
|
616
|
+
// 即使没有新模型,也尝试自动选择第一个
|
|
617
|
+
if (!document.getElementById('target-model').value && data.models.length > 0) {
|
|
618
|
+
selectModel(data.models[0]);
|
|
619
|
+
}
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
const merged = [...(provider?.models || []), ...newModels];
|
|
623
|
+
await fetch(`/api/providers/${providerId}`, {
|
|
624
|
+
method: 'PUT',
|
|
625
|
+
headers: { 'Content-Type': 'application/json' },
|
|
626
|
+
body: JSON.stringify({ models: merged }),
|
|
627
|
+
});
|
|
628
|
+
await loadProviders();
|
|
629
|
+
renderModelOptions();
|
|
630
|
+
// 自动选择默认模型
|
|
631
|
+
if (!document.getElementById('target-model').value) {
|
|
632
|
+
selectModel(newModels[0] || data.models[0]);
|
|
633
|
+
}
|
|
634
|
+
showToast(`已导入 ${newModels.length} 个新模型(共 ${data.models.length} 个)`);
|
|
635
|
+
} catch (err) {
|
|
636
|
+
showToast('导入失败: ' + err.message, true);
|
|
637
|
+
} finally {
|
|
638
|
+
btn.disabled = false;
|
|
639
|
+
btn.textContent = '自动导入';
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
545
643
|
function renderModelOptions() {
|
|
546
644
|
const container = document.getElementById('model-dropdown-options');
|
|
547
645
|
const providerId = document.getElementById('provider-id').value;
|
|
@@ -958,14 +1056,14 @@ function copyProxyUrl(port, btn) {
|
|
|
958
1056
|
});
|
|
959
1057
|
}
|
|
960
1058
|
|
|
961
|
-
function showConfirm(text) {
|
|
1059
|
+
function showConfirm(text, okText = '删除') {
|
|
962
1060
|
return new Promise(resolve => {
|
|
963
1061
|
const modal = document.getElementById('confirm-modal');
|
|
964
1062
|
document.getElementById('confirm-text').innerHTML = text;
|
|
965
|
-
modal.classList.add('active');
|
|
966
|
-
|
|
967
1063
|
const okBtn = document.getElementById('confirm-ok');
|
|
968
1064
|
const cancelBtn = document.getElementById('confirm-cancel');
|
|
1065
|
+
okBtn.textContent = okText;
|
|
1066
|
+
modal.classList.add('active');
|
|
969
1067
|
|
|
970
1068
|
function cleanup(result) {
|
|
971
1069
|
modal.classList.remove('active');
|
|
@@ -1156,6 +1254,140 @@ function closeModal() {
|
|
|
1156
1254
|
editingProviderId = null;
|
|
1157
1255
|
}
|
|
1158
1256
|
|
|
1257
|
+
function showTestResultModal(data) {
|
|
1258
|
+
const modal = document.getElementById('test-result-modal');
|
|
1259
|
+
const icon = document.getElementById('test-result-icon');
|
|
1260
|
+
const summary = document.getElementById('test-result-summary');
|
|
1261
|
+
const list = document.getElementById('test-result-list');
|
|
1262
|
+
const closeBtn = document.getElementById('test-result-close');
|
|
1263
|
+
|
|
1264
|
+
if (data.failed === 0) {
|
|
1265
|
+
icon.textContent = '✓';
|
|
1266
|
+
icon.style.background = 'rgba(6, 78, 59, 0.4)';
|
|
1267
|
+
icon.style.color = '#34d399';
|
|
1268
|
+
icon.style.borderColor = 'rgba(52, 211, 153, 0.15)';
|
|
1269
|
+
summary.innerHTML = `<strong>${data.total}</strong> 条 API Key 全部测试通过`;
|
|
1270
|
+
} else if (data.passed === 0) {
|
|
1271
|
+
icon.textContent = '✗';
|
|
1272
|
+
icon.style.background = 'rgba(127, 29, 29, 0.4)';
|
|
1273
|
+
icon.style.color = '#f87171';
|
|
1274
|
+
icon.style.borderColor = 'rgba(248, 113, 113, 0.15)';
|
|
1275
|
+
summary.innerHTML = `<strong>${data.total}</strong> 条 API Key 全部测试失败`;
|
|
1276
|
+
} else {
|
|
1277
|
+
icon.textContent = '!';
|
|
1278
|
+
icon.style.background = 'rgba(69, 26, 3, 0.4)';
|
|
1279
|
+
icon.style.color = '#fbbf24';
|
|
1280
|
+
icon.style.borderColor = 'rgba(251, 191, 36, 0.15)';
|
|
1281
|
+
summary.innerHTML = `<strong>${data.passed}</strong> 条通过,<strong>${data.failed}</strong> 条失败`;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
list.innerHTML = data.results.map(r => `
|
|
1285
|
+
<div class="test-result-item ${r.ok ? 'test-ok' : 'test-fail'}">
|
|
1286
|
+
<div class="test-result-row">
|
|
1287
|
+
<span class="test-result-status">${r.ok ? '✓' : '✗'}</span>
|
|
1288
|
+
<span class="test-result-alias">${escapeHtml(r.alias || `Key #${r.index + 1}`)}</span>
|
|
1289
|
+
${r.latencyMs != null ? `<span class="test-result-latency">${r.latencyMs}ms</span>` : ''}
|
|
1290
|
+
</div>
|
|
1291
|
+
${r.message ? `<div class="test-result-error">${escapeHtml(r.message)}</div>` : ''}
|
|
1292
|
+
</div>
|
|
1293
|
+
`).join('');
|
|
1294
|
+
|
|
1295
|
+
modal.classList.add('active');
|
|
1296
|
+
closeBtn.onclick = () => modal.classList.remove('active');
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function clearKeyErrors() {
|
|
1300
|
+
document.querySelectorAll('#api-keys-list .api-key-entry').forEach(row => {
|
|
1301
|
+
row.querySelector('.api-key-input')?.style.removeProperty('border-color');
|
|
1302
|
+
row.querySelector('.api-key-display')?.style.removeProperty('border-color');
|
|
1303
|
+
row.querySelector('.api-key-error')?.remove();
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
function markKeyErrors(data) {
|
|
1308
|
+
const rows = document.querySelectorAll('#api-keys-list .api-key-entry');
|
|
1309
|
+
for (const r of data.results) {
|
|
1310
|
+
if (!r.ok) {
|
|
1311
|
+
const row = rows[r.index];
|
|
1312
|
+
if (row) {
|
|
1313
|
+
const el = row.querySelector('.api-key-input') || row.querySelector('.api-key-display');
|
|
1314
|
+
if (el) el.style.borderColor = '#ef4444';
|
|
1315
|
+
if (r.message) {
|
|
1316
|
+
const errDiv = document.createElement('div');
|
|
1317
|
+
errDiv.className = 'api-key-error';
|
|
1318
|
+
errDiv.textContent = r.message;
|
|
1319
|
+
// Insert after the API Key form-group
|
|
1320
|
+
const keyGroup = row.querySelectorAll('.form-group')[1];
|
|
1321
|
+
if (keyGroup) keyGroup.appendChild(errDiv);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
async function testConnection() {
|
|
1329
|
+
const providerId = document.getElementById('provider-id').value;
|
|
1330
|
+
if (!providerId) {
|
|
1331
|
+
showToast('请先选择供应商', true);
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
const protocol = document.getElementById('target-protocol').value;
|
|
1335
|
+
const apiKeys = collectApiKeys();
|
|
1336
|
+
const model = document.getElementById('target-model').value.trim() || '';
|
|
1337
|
+
const btn = document.getElementById('test-connection-btn');
|
|
1338
|
+
btn.disabled = true;
|
|
1339
|
+
btn.textContent = '测试中...';
|
|
1340
|
+
clearKeyErrors();
|
|
1341
|
+
try {
|
|
1342
|
+
const res = await fetch(`/api/providers/${providerId}/test`, {
|
|
1343
|
+
method: 'POST',
|
|
1344
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1345
|
+
body: JSON.stringify({ protocol, apiKeys, model }),
|
|
1346
|
+
});
|
|
1347
|
+
const data = await res.json();
|
|
1348
|
+
if (!data.results || data.results.length === 0) {
|
|
1349
|
+
showToast(data.message || '没有可用的 API Key', true);
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
markKeyErrors(data);
|
|
1353
|
+
showTestResultModal(data);
|
|
1354
|
+
} catch (err) {
|
|
1355
|
+
showToast('测试请求失败: ' + err.message, true);
|
|
1356
|
+
} finally {
|
|
1357
|
+
btn.disabled = false;
|
|
1358
|
+
btn.textContent = '测试连接';
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
async function autoTestForSave() {
|
|
1363
|
+
const providerId = document.getElementById('provider-id').value;
|
|
1364
|
+
if (!providerId) return true;
|
|
1365
|
+
const protocol = document.getElementById('target-protocol').value;
|
|
1366
|
+
const apiKeys = collectApiKeys();
|
|
1367
|
+
const model = document.getElementById('target-model').value.trim() || '';
|
|
1368
|
+
clearKeyErrors();
|
|
1369
|
+
try {
|
|
1370
|
+
const res = await fetch(`/api/providers/${providerId}/test`, {
|
|
1371
|
+
method: 'POST',
|
|
1372
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1373
|
+
body: JSON.stringify({ protocol, apiKeys, model }),
|
|
1374
|
+
});
|
|
1375
|
+
const data = await res.json();
|
|
1376
|
+
if (!data.results || data.results.length === 0) return true;
|
|
1377
|
+
if (data.failed === 0) {
|
|
1378
|
+
showToast(`${data.total} 条 API Key 测试通过`);
|
|
1379
|
+
return true;
|
|
1380
|
+
}
|
|
1381
|
+
markKeyErrors(data);
|
|
1382
|
+
return await showConfirm(
|
|
1383
|
+
`${data.passed} 条通过,${data.failed} 条失败。<br><br>是否仍然保存?`,
|
|
1384
|
+
'仍然保存'
|
|
1385
|
+
);
|
|
1386
|
+
} catch (err) {
|
|
1387
|
+
return true;
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1159
1391
|
async function handleSubmit(e) {
|
|
1160
1392
|
e.preventDefault();
|
|
1161
1393
|
|
|
@@ -1165,6 +1397,21 @@ async function handleSubmit(e) {
|
|
|
1165
1397
|
return;
|
|
1166
1398
|
}
|
|
1167
1399
|
|
|
1400
|
+
// 保存前自动测试:如果有 API Key 被修改,先测试连接
|
|
1401
|
+
const hasModifiedKeys = !!document.querySelector('#api-keys-list .api-key-entry[data-masked="false"], #api-keys-list .api-key-entry[data-new="true"]');
|
|
1402
|
+
if (hasModifiedKeys) {
|
|
1403
|
+
const saveBtn = document.querySelector('.modal-footer .btn-primary');
|
|
1404
|
+
saveBtn.disabled = true;
|
|
1405
|
+
saveBtn.textContent = '测试中...';
|
|
1406
|
+
try {
|
|
1407
|
+
const canProceed = await autoTestForSave();
|
|
1408
|
+
if (!canProceed) return;
|
|
1409
|
+
} finally {
|
|
1410
|
+
saveBtn.disabled = false;
|
|
1411
|
+
saveBtn.textContent = '保存';
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1168
1415
|
const port = parseInt(document.getElementById('proxy-port').value);
|
|
1169
1416
|
|
|
1170
1417
|
const conflict = proxies.find(p => p.id !== editingId && p.port === port);
|
package/public/index.html
CHANGED
|
@@ -184,6 +184,7 @@
|
|
|
184
184
|
<div class="model-add-section" id="model-add-section">
|
|
185
185
|
<input type="text" class="model-add-input" id="model-add-input" placeholder="输入模型名称">
|
|
186
186
|
<button type="button" class="btn btn-primary btn-sm" id="model-add-btn">添加</button>
|
|
187
|
+
<button type="button" class="btn btn-sm" id="model-import-btn" onclick="importModels()">自动导入</button>
|
|
187
188
|
</div>
|
|
188
189
|
</div>
|
|
189
190
|
</div>
|
|
@@ -252,6 +253,7 @@
|
|
|
252
253
|
|
|
253
254
|
<div class="modal-footer">
|
|
254
255
|
<button type="button" class="btn" onclick="closeModal()">取消</button>
|
|
256
|
+
<button type="button" class="btn" id="test-connection-btn" onclick="testConnection()">测试连接</button>
|
|
255
257
|
<button type="submit" class="btn btn-primary">保存</button>
|
|
256
258
|
</div>
|
|
257
259
|
</form>
|
|
@@ -271,6 +273,18 @@
|
|
|
271
273
|
</div>
|
|
272
274
|
</div>
|
|
273
275
|
|
|
276
|
+
<!-- 测试结果弹窗 -->
|
|
277
|
+
<div class="modal confirm-modal" id="test-result-modal">
|
|
278
|
+
<div class="confirm-box test-result-box">
|
|
279
|
+
<div class="confirm-icon" id="test-result-icon">!</div>
|
|
280
|
+
<p class="confirm-text" id="test-result-summary"></p>
|
|
281
|
+
<div class="test-result-list" id="test-result-list"></div>
|
|
282
|
+
<div class="confirm-actions">
|
|
283
|
+
<button class="btn btn-primary" id="test-result-close">知道了</button>
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
|
|
274
288
|
<!-- 导入预览弹窗 -->
|
|
275
289
|
<div class="modal" id="import-modal">
|
|
276
290
|
<div class="modal-content" style="max-width:500px">
|
package/public/style.css
CHANGED
|
@@ -611,8 +611,9 @@ form {
|
|
|
611
611
|
/* Toast */
|
|
612
612
|
.toast {
|
|
613
613
|
position: fixed;
|
|
614
|
-
|
|
615
|
-
|
|
614
|
+
top: 50%;
|
|
615
|
+
left: 50%;
|
|
616
|
+
transform: translate(-50%, -50%);
|
|
616
617
|
padding: 12px 24px;
|
|
617
618
|
background: linear-gradient(135deg, #22c55e, #16a34a);
|
|
618
619
|
color: white;
|
|
@@ -626,8 +627,8 @@ form {
|
|
|
626
627
|
}
|
|
627
628
|
|
|
628
629
|
@keyframes toast-in {
|
|
629
|
-
from { opacity: 0; transform:
|
|
630
|
-
to { opacity: 1; transform:
|
|
630
|
+
from { opacity: 0; transform: translate(-50%, -50%) scale(0.95); }
|
|
631
|
+
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
|
631
632
|
}
|
|
632
633
|
|
|
633
634
|
@keyframes toast-out {
|
|
@@ -793,6 +794,10 @@ form {
|
|
|
793
794
|
}
|
|
794
795
|
|
|
795
796
|
/* Confirm modal */
|
|
797
|
+
.confirm-modal {
|
|
798
|
+
align-items: center;
|
|
799
|
+
}
|
|
800
|
+
|
|
796
801
|
.confirm-modal .confirm-box {
|
|
797
802
|
background: rgba(15, 23, 42, 0.8);
|
|
798
803
|
backdrop-filter: blur(24px);
|
|
@@ -1277,6 +1282,13 @@ form {
|
|
|
1277
1282
|
color: #64748b;
|
|
1278
1283
|
font-size: 0.9rem;
|
|
1279
1284
|
letter-spacing: 2px;
|
|
1285
|
+
cursor: pointer;
|
|
1286
|
+
transition: border-color 0.2s, color 0.2s;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
.api-key-display:hover {
|
|
1290
|
+
border-color: rgba(99, 102, 241, 0.5);
|
|
1291
|
+
color: #94a3b8;
|
|
1280
1292
|
}
|
|
1281
1293
|
|
|
1282
1294
|
.api-key-remove {
|
|
@@ -1446,3 +1458,103 @@ form {
|
|
|
1446
1458
|
background: rgba(127, 29, 29, 0.35);
|
|
1447
1459
|
border-color: rgba(248, 113, 113, 0.8);
|
|
1448
1460
|
}
|
|
1461
|
+
|
|
1462
|
+
/* Inline API Key error */
|
|
1463
|
+
.api-key-error {
|
|
1464
|
+
margin-top: 6px;
|
|
1465
|
+
padding: 6px 10px;
|
|
1466
|
+
background: rgba(127, 29, 29, 0.15);
|
|
1467
|
+
border: 1px solid rgba(248, 113, 113, 0.2);
|
|
1468
|
+
border-radius: 6px;
|
|
1469
|
+
color: #fca5a5;
|
|
1470
|
+
font-size: 0.78rem;
|
|
1471
|
+
line-height: 1.4;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/* Test result modal items */
|
|
1475
|
+
.test-result-box {
|
|
1476
|
+
max-width: 460px !important;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
.test-result-list {
|
|
1480
|
+
display: flex;
|
|
1481
|
+
flex-direction: column;
|
|
1482
|
+
gap: 8px;
|
|
1483
|
+
margin-bottom: 20px;
|
|
1484
|
+
max-height: 300px;
|
|
1485
|
+
overflow-y: auto;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
.test-result-item {
|
|
1489
|
+
padding: 10px 14px;
|
|
1490
|
+
border-radius: 10px;
|
|
1491
|
+
background: rgba(6, 8, 15, 0.4);
|
|
1492
|
+
border: 1px solid rgba(51, 65, 85, 0.3);
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
.test-result-item.test-ok {
|
|
1496
|
+
border-color: rgba(52, 211, 153, 0.2);
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
.test-result-item.test-fail {
|
|
1500
|
+
border-color: rgba(248, 113, 113, 0.3);
|
|
1501
|
+
background: rgba(127, 29, 29, 0.1);
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
.test-result-row {
|
|
1505
|
+
display: flex;
|
|
1506
|
+
align-items: center;
|
|
1507
|
+
gap: 10px;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
.test-result-status {
|
|
1511
|
+
font-size: 1rem;
|
|
1512
|
+
font-weight: 700;
|
|
1513
|
+
width: 20px;
|
|
1514
|
+
text-align: center;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
.test-ok .test-result-status {
|
|
1518
|
+
color: #34d399;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
.test-fail .test-result-status {
|
|
1522
|
+
color: #f87171;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
.test-result-alias {
|
|
1526
|
+
flex: 1;
|
|
1527
|
+
color: #e2e8f0;
|
|
1528
|
+
font-size: 0.9rem;
|
|
1529
|
+
font-weight: 500;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
.test-result-latency {
|
|
1533
|
+
color: #60a5fa;
|
|
1534
|
+
font-size: 0.82rem;
|
|
1535
|
+
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
.test-result-error {
|
|
1539
|
+
margin-top: 6px;
|
|
1540
|
+
padding: 5px 10px;
|
|
1541
|
+
color: #fca5a5;
|
|
1542
|
+
font-size: 0.78rem;
|
|
1543
|
+
background: rgba(127, 29, 29, 0.15);
|
|
1544
|
+
border-radius: 6px;
|
|
1545
|
+
border: 1px solid rgba(248, 113, 113, 0.1);
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
/* test-result-list scrollbar */
|
|
1549
|
+
.test-result-list::-webkit-scrollbar {
|
|
1550
|
+
width: 6px;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
.test-result-list::-webkit-scrollbar-track {
|
|
1554
|
+
background: transparent;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
.test-result-list::-webkit-scrollbar-thumb {
|
|
1558
|
+
background: rgba(71, 85, 105, 0.5);
|
|
1559
|
+
border-radius: 3px;
|
|
1560
|
+
}
|
package/server.js
CHANGED
|
@@ -329,6 +329,169 @@ async function init() {
|
|
|
329
329
|
res.json(updated);
|
|
330
330
|
});
|
|
331
331
|
|
|
332
|
+
app.post('/api/providers/:id/test', async (req, res) => {
|
|
333
|
+
const provider = configStore.getProviderById(req.params.id);
|
|
334
|
+
if (!provider) return res.status(404).json({ error: 'Provider not found' });
|
|
335
|
+
|
|
336
|
+
const existingKeys = provider.apiKeys || [];
|
|
337
|
+
const reqKeys = Array.isArray(req.body.apiKeys) ? req.body.apiKeys : [];
|
|
338
|
+
const resolved = reqKeys
|
|
339
|
+
.map((k, i) => {
|
|
340
|
+
if (k && typeof k === 'object' && k.masked && typeof k.index === 'number') {
|
|
341
|
+
const ex = existingKeys[k.index];
|
|
342
|
+
return ex ? { key: ex.key, alias: k.alias || ex.alias || '', domIndex: i } : null;
|
|
343
|
+
}
|
|
344
|
+
if (k && typeof k === 'object' && typeof k.key === 'string' && k.key.trim()) {
|
|
345
|
+
return { key: k.key.trim(), alias: k.alias || '', domIndex: i };
|
|
346
|
+
}
|
|
347
|
+
if (typeof k === 'string' && k.trim()) return { key: k.trim(), alias: '', domIndex: i };
|
|
348
|
+
return null;
|
|
349
|
+
})
|
|
350
|
+
.filter(Boolean);
|
|
351
|
+
|
|
352
|
+
if (resolved.length === 0) {
|
|
353
|
+
return res.json({ ok: false, message: '没有可用的 API Key', results: [] });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const protocol = req.body.protocol || provider.protocol || 'openai';
|
|
357
|
+
const base = provider.url.replace(/\/$/, '');
|
|
358
|
+
const hasV1Suffix = base.endsWith('/v1');
|
|
359
|
+
const isAzure = protocol === 'openai' && !!provider.azureDeployment;
|
|
360
|
+
|
|
361
|
+
function buildTestOpts(key) {
|
|
362
|
+
if (protocol === 'openai') {
|
|
363
|
+
if (isAzure) {
|
|
364
|
+
const ver = provider.azureApiVersion || '2024-02-01';
|
|
365
|
+
return {
|
|
366
|
+
url: `${base}/openai/deployments/${provider.azureDeployment}/models?api-version=${ver}`,
|
|
367
|
+
opts: { headers: { 'api-key': key } },
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
return {
|
|
371
|
+
url: hasV1Suffix ? `${base}/models` : `${base}/v1/models`,
|
|
372
|
+
opts: { headers: { 'Authorization': `Bearer ${key}` } },
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
if (protocol === 'anthropic') {
|
|
376
|
+
const testModel = req.body.model || 'claude-3-haiku-20240307';
|
|
377
|
+
return {
|
|
378
|
+
url: hasV1Suffix ? `${base}/messages` : `${base}/v1/messages`,
|
|
379
|
+
opts: {
|
|
380
|
+
method: 'POST',
|
|
381
|
+
headers: { 'Content-Type': 'application/json', 'x-api-key': key, 'anthropic-version': '2023-06-01' },
|
|
382
|
+
body: JSON.stringify({ model: testModel, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
if (protocol === 'gemini') {
|
|
387
|
+
return { url: `${base}/v1beta/models?key=${key}`, opts: {} };
|
|
388
|
+
}
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (protocol !== 'openai' && protocol !== 'anthropic' && protocol !== 'gemini') {
|
|
393
|
+
return res.json({ ok: false, message: `不支持的协议: ${protocol}`, results: [] });
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const results = await Promise.all(resolved.map(async entry => {
|
|
397
|
+
const { url: testUrl, opts: fetchOpts } = buildTestOpts(entry.key);
|
|
398
|
+
try {
|
|
399
|
+
const startedAt = Date.now();
|
|
400
|
+
const fetchRes = await fetch(testUrl, { ...fetchOpts, signal: AbortSignal.timeout(15000) });
|
|
401
|
+
const latencyMs = Date.now() - startedAt;
|
|
402
|
+
if (!fetchRes.ok) {
|
|
403
|
+
const errText = await fetchRes.text().catch(() => '');
|
|
404
|
+
const hint = fetchRes.status === 401 || fetchRes.status === 403
|
|
405
|
+
? 'API Key 无效或无权限'
|
|
406
|
+
: `HTTP ${fetchRes.status}: ${errText.slice(0, 200) || '未知错误'}`;
|
|
407
|
+
return { ok: false, alias: entry.alias, index: entry.domIndex, message: hint, latencyMs };
|
|
408
|
+
}
|
|
409
|
+
return { ok: true, alias: entry.alias, index: entry.domIndex, latencyMs };
|
|
410
|
+
} catch (err) {
|
|
411
|
+
const msg = err.name === 'TimeoutError' ? '连接超时 (15s)' : `连接失败: ${err.message}`;
|
|
412
|
+
return { ok: false, alias: entry.alias, index: entry.domIndex, message: msg };
|
|
413
|
+
}
|
|
414
|
+
}));
|
|
415
|
+
|
|
416
|
+
const passed = results.filter(r => r.ok).length;
|
|
417
|
+
const failed = results.length - passed;
|
|
418
|
+
res.json({ ok: failed === 0, passed, failed, total: results.length, results });
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
app.post('/api/providers/:id/available-models', async (req, res) => {
|
|
422
|
+
const provider = configStore.getProviderById(req.params.id);
|
|
423
|
+
if (!provider) return res.status(404).json({ error: 'Provider not found' });
|
|
424
|
+
|
|
425
|
+
// Support unsaved API keys from form
|
|
426
|
+
let keys;
|
|
427
|
+
const reqKeys = Array.isArray(req.body?.apiKeys) ? req.body.apiKeys : [];
|
|
428
|
+
if (reqKeys.length > 0) {
|
|
429
|
+
const existingKeys = provider.apiKeys || [];
|
|
430
|
+
keys = reqKeys
|
|
431
|
+
.map(k => {
|
|
432
|
+
if (k && typeof k === 'object' && k.masked && typeof k.index === 'number') {
|
|
433
|
+
return existingKeys[k.index]?.key || null;
|
|
434
|
+
}
|
|
435
|
+
if (k && typeof k === 'object' && typeof k.key === 'string' && k.key.trim()) {
|
|
436
|
+
return k.key.trim();
|
|
437
|
+
}
|
|
438
|
+
return null;
|
|
439
|
+
})
|
|
440
|
+
.filter(Boolean);
|
|
441
|
+
} else {
|
|
442
|
+
keys = (provider.apiKeys || []).map(k => k.key).filter(Boolean);
|
|
443
|
+
}
|
|
444
|
+
if (keys.length === 0) return res.json({ models: [], message: '没有可用的 API Key' });
|
|
445
|
+
|
|
446
|
+
const protocol = provider.protocol || 'openai';
|
|
447
|
+
const base = provider.url.replace(/\/$/, '');
|
|
448
|
+
const hasV1Suffix = base.endsWith('/v1');
|
|
449
|
+
const key = keys[0];
|
|
450
|
+
const isAzure = protocol === 'openai' && !!provider.azureDeployment;
|
|
451
|
+
|
|
452
|
+
try {
|
|
453
|
+
let fetchUrl, fetchOpts;
|
|
454
|
+
if (protocol === 'openai') {
|
|
455
|
+
if (isAzure) {
|
|
456
|
+
const ver = provider.azureApiVersion || '2024-02-01';
|
|
457
|
+
fetchUrl = `${base}/openai/deployments/${provider.azureDeployment}/models?api-version=${ver}`;
|
|
458
|
+
fetchOpts = { headers: { 'api-key': key } };
|
|
459
|
+
} else {
|
|
460
|
+
fetchUrl = hasV1Suffix ? `${base}/models` : `${base}/v1/models`;
|
|
461
|
+
fetchOpts = { headers: { 'Authorization': `Bearer ${key}` } };
|
|
462
|
+
}
|
|
463
|
+
} else if (protocol === 'gemini') {
|
|
464
|
+
fetchUrl = `${base}/v1beta/models?key=${key}`;
|
|
465
|
+
fetchOpts = {};
|
|
466
|
+
} else if (protocol === 'anthropic') {
|
|
467
|
+
fetchUrl = hasV1Suffix ? `${base}/models` : `${base}/v1/models`;
|
|
468
|
+
fetchOpts = { headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01' } };
|
|
469
|
+
} else {
|
|
470
|
+
return res.json({ models: [], message: `不支持的协议: ${protocol}` });
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const fetchRes = await fetch(fetchUrl, { ...fetchOpts, signal: AbortSignal.timeout(15000) });
|
|
474
|
+
if (!fetchRes.ok) {
|
|
475
|
+
const hint = fetchRes.status === 404 ? '该供应商不支持模型列表接口' : `获取失败: HTTP ${fetchRes.status}`;
|
|
476
|
+
return res.json({ models: [], message: hint });
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const data = await fetchRes.json().catch(() => null);
|
|
480
|
+
let models = [];
|
|
481
|
+
if (Array.isArray(data?.data)) {
|
|
482
|
+
// OpenAI 格式(含第三方 Anthropic 兼容供应商)
|
|
483
|
+
models = data.data.map(m => m.id || m.name).filter(Boolean).sort();
|
|
484
|
+
} else if (Array.isArray(data?.models)) {
|
|
485
|
+
// Gemini 格式
|
|
486
|
+
models = data.models.map(m => (m.name || m.id)?.replace('models/', '')).filter(Boolean).sort();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
res.json({ models });
|
|
490
|
+
} catch (err) {
|
|
491
|
+
res.json({ models: [], message: `获取失败: ${err.message}` });
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
|
|
332
495
|
app.delete('/api/providers/:id', (req, res) => {
|
|
333
496
|
const existing = configStore.getProviderById(req.params.id);
|
|
334
497
|
if (!existing) return res.status(404).json({ error: 'Provider not found' });
|