protocol-proxy 1.0.5 → 1.1.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "protocol-proxy",
3
- "version": "1.0.5",
3
+ "version": "1.1.1",
4
4
  "description": "OpenAI / Anthropic 协议转换透明代理",
5
5
  "main": "server.js",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -1,63 +1,188 @@
1
1
  let proxies = [];
2
2
  let editingId = null;
3
3
 
4
- // ==================== Model 管理(按代理独立) ====================
5
- function getLegacyModelKey(proxyId) {
6
- return `protocol-proxy-models-${proxyId || '__new'}`;
4
+ // ==================== 供应商管理(全局共享) ====================
5
+ const PROVIDERS_KEY = 'protocol-proxy-providers';
6
+
7
+ function loadProviders() {
8
+ try {
9
+ return JSON.parse(localStorage.getItem(PROVIDERS_KEY)) || [];
10
+ } catch { return []; }
11
+ }
12
+
13
+ function saveProviders(providers) {
14
+ localStorage.setItem(PROVIDERS_KEY, JSON.stringify(providers));
7
15
  }
8
16
 
9
- function getModelKey(proxyId) {
10
- return getLegacyModelKey(proxyId);
17
+ function addProvider(name, url) {
18
+ const providers = loadProviders();
19
+ if (providers.some(p => p.url === url)) return;
20
+ providers.push({ id: 'p-' + Date.now(), name, url, protocol: detectProtocol(url) });
21
+ saveProviders(providers);
11
22
  }
12
23
 
13
- function loadLegacyModels(proxyId) {
14
- const saved = localStorage.getItem(getLegacyModelKey(proxyId));
24
+ function findProviderByUrl(url) {
25
+ return loadProviders().find(p => p.url === url);
26
+ }
27
+
28
+ function getProviderDisplayName(url) {
29
+ const p = findProviderByUrl(url);
30
+ return p ? p.name : url;
31
+ }
32
+
33
+ function detectProtocol(url) {
34
+ return /anthropic/i.test(url) ? 'anthropic' : 'openai';
35
+ }
36
+
37
+ // ==================== Model 管理(按供应商 URL) ====================
38
+ function getModelKey(providerUrl) {
39
+ return providerUrl ? `protocol-proxy-models-${providerUrl}` : 'protocol-proxy-models-__new';
40
+ }
41
+
42
+ function loadModelsByProvider(providerUrl) {
43
+ const saved = localStorage.getItem(getModelKey(providerUrl));
15
44
  if (saved) {
16
45
  try { return JSON.parse(saved); } catch { /* fall through */ }
17
46
  }
18
47
  return [];
19
48
  }
20
49
 
21
- function loadModels(proxyId) {
22
- const proxy = proxyId ? proxies.find(p => p.id === proxyId) : null;
23
- const serverModels = proxy?.target?.models;
24
- if (Array.isArray(serverModels)) {
25
- return serverModels.filter(Boolean);
26
- }
27
-
28
- return loadLegacyModels(proxyId);
29
- }
30
-
31
- function saveModels(proxyId, models) {
50
+ function saveModelsByProvider(providerUrl, models) {
32
51
  const normalized = Array.from(new Set((models || []).map(m => m.trim()).filter(Boolean)));
33
- if (proxyId) {
34
- const proxy = proxies.find(p => p.id === proxyId);
35
- if (proxy) {
36
- proxy.target = proxy.target || {};
37
- proxy.target.models = normalized;
38
- }
39
- return;
40
- }
41
- localStorage.setItem(getLegacyModelKey(proxyId), JSON.stringify(normalized));
52
+ localStorage.setItem(getModelKey(providerUrl), JSON.stringify(normalized));
42
53
  }
43
54
 
44
- function addModel(proxyId, name) {
45
- const models = loadModels(proxyId);
55
+ function addModel(providerUrl, name) {
56
+ if (!providerUrl) return;
57
+ const models = loadModelsByProvider(providerUrl);
46
58
  if (!models.includes(name)) {
47
59
  models.push(name);
48
- saveModels(proxyId, models);
60
+ saveModelsByProvider(providerUrl, models);
49
61
  }
50
62
  }
51
63
 
52
- function removeModel(proxyId, name) {
53
- const models = loadModels(proxyId).filter(m => m !== name);
54
- saveModels(proxyId, models);
64
+ function removeModel(providerUrl, name) {
65
+ const models = loadModelsByProvider(providerUrl).filter(m => m !== name);
66
+ saveModelsByProvider(providerUrl, models);
55
67
  }
56
68
 
57
69
  function getCurrentProxyId() {
58
70
  return document.getElementById('modal').dataset.proxyId || null;
59
71
  }
60
72
 
73
+ function getSelectedProviderUrl() {
74
+ return document.getElementById('target-url').value || '';
75
+ }
76
+
77
+ // ==================== 供应商下拉框 ====================
78
+ function initProviderDropdown() {
79
+ const trigger = document.getElementById('provider-dropdown-trigger');
80
+ const dropdown = document.getElementById('provider-dropdown');
81
+ const addNameInput = document.getElementById('provider-add-name');
82
+ const addUrlInput = document.getElementById('provider-add-url');
83
+ const addBtn = document.getElementById('provider-add-btn');
84
+
85
+ trigger.addEventListener('click', (e) => {
86
+ e.stopPropagation();
87
+ dropdown.classList.toggle('open');
88
+ if (dropdown.classList.contains('open')) {
89
+ addNameInput.value = '';
90
+ addUrlInput.value = '';
91
+ renderProviderOptions();
92
+ addNameInput.focus();
93
+ }
94
+ });
95
+
96
+ document.addEventListener('click', (e) => {
97
+ if (!dropdown.contains(e.target)) {
98
+ dropdown.classList.remove('open');
99
+ }
100
+ });
101
+
102
+ addBtn.addEventListener('click', () => {
103
+ const name = addNameInput.value.trim();
104
+ const url = addUrlInput.value.trim();
105
+ if (!name || !url) {
106
+ showToast('请填写供应商名称和地址', true);
107
+ return;
108
+ }
109
+ addProvider(name, url);
110
+ selectProvider(url);
111
+ dropdown.classList.remove('open');
112
+ });
113
+
114
+ addUrlInput.addEventListener('keydown', (e) => {
115
+ if (e.key === 'Enter') {
116
+ e.preventDefault();
117
+ addBtn.click();
118
+ }
119
+ if (e.key === 'Escape') {
120
+ dropdown.classList.remove('open');
121
+ }
122
+ });
123
+
124
+ addNameInput.addEventListener('keydown', (e) => {
125
+ if (e.key === 'Escape') {
126
+ dropdown.classList.remove('open');
127
+ }
128
+ });
129
+ }
130
+
131
+ function renderProviderOptions() {
132
+ const container = document.getElementById('provider-dropdown-options');
133
+ const providers = loadProviders();
134
+ const currentUrl = getSelectedProviderUrl();
135
+
136
+ container.innerHTML = providers.map(p => `
137
+ <div class="model-option${p.url === currentUrl ? ' selected' : ''}" data-url="${escapeHtml(p.url)}">
138
+ <span class="model-option-name">${escapeHtml(p.name)}</span>
139
+ <span style="color:#64748b;font-size:12px;margin-left:4px">${escapeHtml(p.url)}</span>
140
+ <button type="button" class="model-option-delete" data-delete-url="${escapeHtml(p.url)}" title="删除此供应商">&times;</button>
141
+ </div>
142
+ `).join('');
143
+
144
+ if (providers.length === 0) {
145
+ container.innerHTML = '<div style="padding:8px 12px;color:#64748b;font-size:13px">暂无供应商,请在下方添加</div>';
146
+ }
147
+
148
+ container.querySelectorAll('.model-option').forEach(opt => {
149
+ opt.addEventListener('click', (e) => {
150
+ if (e.target.closest('.model-option-delete')) return;
151
+ selectProvider(opt.dataset.url);
152
+ document.getElementById('provider-dropdown').classList.remove('open');
153
+ });
154
+ });
155
+
156
+ container.querySelectorAll('.model-option-delete').forEach(btn => {
157
+ btn.addEventListener('click', async (e) => {
158
+ e.stopPropagation();
159
+ const url = btn.dataset.deleteUrl;
160
+ const p = findProviderByUrl(url);
161
+ const ok = await showConfirm(`确定要删除供应商 <strong>${escapeHtml(p?.name || url)}</strong> 吗?`);
162
+ if (!ok) return;
163
+ const providers = loadProviders().filter(pr => pr.url !== url);
164
+ saveProviders(providers);
165
+ if (getSelectedProviderUrl() === url) {
166
+ selectProvider('');
167
+ }
168
+ renderProviderOptions();
169
+ });
170
+ });
171
+ }
172
+
173
+ function selectProvider(url) {
174
+ document.getElementById('target-url').value = url || '';
175
+ const provider = findProviderByUrl(url);
176
+ document.getElementById('target-protocol').value = url ? (provider?.protocol || detectProtocol(url)) : '';
177
+ document.getElementById('provider-dropdown-value').textContent = url
178
+ ? (provider ? `${provider.name} - ${url}` : url)
179
+ : '选择供应商...';
180
+ // 切换供应商后刷新模型列表
181
+ renderModelOptions();
182
+ updateModelAddState();
183
+ }
184
+
185
+ // ==================== Model 下拉框 ====================
61
186
  function initModelDropdown() {
62
187
  const trigger = document.getElementById('model-dropdown-trigger');
63
188
  const dropdown = document.getElementById('model-dropdown');
@@ -80,10 +205,14 @@ function initModelDropdown() {
80
205
  });
81
206
 
82
207
  addBtn.addEventListener('click', () => {
208
+ const providerUrl = getSelectedProviderUrl();
209
+ if (!providerUrl) {
210
+ showToast('请先选择供应商地址', true);
211
+ return;
212
+ }
83
213
  const name = addInput.value.trim();
84
214
  if (!name) return;
85
- const proxyId = getCurrentProxyId();
86
- addModel(proxyId, name);
215
+ addModel(providerUrl, name);
87
216
  selectModel(name);
88
217
  renderModelOptions();
89
218
  addInput.value = '';
@@ -103,8 +232,8 @@ function initModelDropdown() {
103
232
 
104
233
  function renderModelOptions() {
105
234
  const container = document.getElementById('model-dropdown-options');
106
- const proxyId = getCurrentProxyId();
107
- const models = loadModels(proxyId);
235
+ const providerUrl = getSelectedProviderUrl();
236
+ const models = providerUrl ? loadModelsByProvider(providerUrl) : [];
108
237
  const current = document.getElementById('target-model').value;
109
238
 
110
239
  container.innerHTML = models.map(m => `
@@ -114,6 +243,12 @@ function renderModelOptions() {
114
243
  </div>
115
244
  `).join('');
116
245
 
246
+ if (!providerUrl) {
247
+ container.innerHTML = '<div style="padding:8px 12px;color:#64748b;font-size:13px">请先选择供应商</div>';
248
+ } else if (models.length === 0) {
249
+ container.innerHTML = '<div style="padding:8px 12px;color:#64748b;font-size:13px">暂无模型,请在下方添加</div>';
250
+ }
251
+
117
252
  container.querySelectorAll('.model-option').forEach(opt => {
118
253
  opt.addEventListener('click', (e) => {
119
254
  if (e.target.closest('.model-option-delete')) return;
@@ -128,8 +263,7 @@ function renderModelOptions() {
128
263
  const name = btn.dataset.delete;
129
264
  const ok = await showConfirm(`确定要删除模型 <strong>${escapeHtml(name)}</strong> 吗?`);
130
265
  if (!ok) return;
131
- const pid = getCurrentProxyId();
132
- removeModel(pid, name);
266
+ removeModel(getSelectedProviderUrl(), name);
133
267
  if (document.getElementById('target-model').value === name) {
134
268
  selectModel('');
135
269
  }
@@ -144,9 +278,25 @@ function selectModel(value) {
144
278
  renderModelOptions();
145
279
  }
146
280
 
281
+ function updateModelAddState() {
282
+ const section = document.getElementById('model-add-section');
283
+ const providerUrl = getSelectedProviderUrl();
284
+ const addInput = document.getElementById('model-add-input');
285
+ const addBtn = document.getElementById('model-add-btn');
286
+ if (providerUrl) {
287
+ addInput.disabled = false;
288
+ addBtn.disabled = false;
289
+ addInput.placeholder = '输入模型名称';
290
+ } else {
291
+ addInput.disabled = true;
292
+ addBtn.disabled = true;
293
+ addInput.placeholder = '请先选择供应商';
294
+ }
295
+ }
296
+
147
297
  function getSelectedModels() {
148
- const proxyId = getCurrentProxyId();
149
- const models = [...loadModels(proxyId), ...loadLegacyModels(proxyId)];
298
+ const providerUrl = getSelectedProviderUrl();
299
+ const models = providerUrl ? [...loadModelsByProvider(providerUrl)] : [];
150
300
  const current = document.getElementById('target-model').value.trim();
151
301
  if (current && !models.includes(current)) {
152
302
  models.unshift(current);
@@ -163,6 +313,7 @@ function generateToken() {
163
313
 
164
314
  async function init() {
165
315
  await loadProxies();
316
+ initProviderDropdown();
166
317
  initModelDropdown();
167
318
  document.getElementById('proxy-auth').addEventListener('change', (e) => {
168
319
  const enabled = e.target.value === 'true';
@@ -253,6 +404,7 @@ function renderProxies() {
253
404
 
254
405
  container.innerHTML = proxies.map(p => {
255
406
  const t = p.target || {};
407
+ const providerName = getProviderDisplayName(t.providerUrl || '');
256
408
  return `
257
409
  <div class="proxy-item">
258
410
  <div class="proxy-header">
@@ -277,17 +429,17 @@ function renderProxies() {
277
429
  <table class="target-table">
278
430
  <thead>
279
431
  <tr>
280
- <th>供应商地址</th>
432
+ <th>供应商</th>
281
433
  <th>协议</th>
282
434
  <th>默认 Model</th>
283
435
  </tr>
284
436
  </thead>
285
437
  <tbody>
286
438
  <tr>
287
- <td>${escapeHtml(t.providerUrl)}</td>
439
+ <td>${escapeHtml(providerName)}${providerName !== t.providerUrl ? ` <span style="color:#64748b;font-size:12px">(${escapeHtml(t.providerUrl)})</span>` : ''}</td>
288
440
  <td>
289
441
  <span class="badge" style="background:${t.protocol==='openai'?'#0c4a6e':'#581c87'};color:${t.protocol==='openai'?'#7dd3fc':'#e9d5ff'}">
290
- ${t.protocol}
442
+ ${t.protocol || '-'}
291
443
  </span>
292
444
  </td>
293
445
  <td><code>${escapeHtml(t.defaultModel) || '-'}</code></td>
@@ -323,36 +475,62 @@ function openModal(id = null) {
323
475
  document.getElementById('proxy-auth').value = p.requireAuth ? 'true' : 'false';
324
476
  document.getElementById('proxy-auth-token').value = p.authToken || '';
325
477
  document.getElementById('auth-token-group').style.display = p.requireAuth ? 'block' : 'none';
326
- document.getElementById('target-url').value = t.providerUrl || '';
327
- document.getElementById('target-protocol').value = t.protocol || 'openai';
328
478
  document.getElementById('target-key').value = t.apiKey || '';
329
- if ((!Array.isArray(t.models) || t.models.length === 0)) {
330
- const legacyModels = loadModels(id);
331
- if (legacyModels.length > 0) {
332
- p.target = p.target || {};
333
- p.target.models = legacyModels;
479
+
480
+ // 自动注册供应商到全局列表
481
+ if (t.providerUrl && !findProviderByUrl(t.providerUrl)) {
482
+ addProvider(getProviderDisplayName(t.providerUrl), t.providerUrl);
483
+ }
484
+
485
+ // 迁移旧模型数据:从按代理ID存储 → 按供应商URL存储
486
+ if (t.providerUrl) {
487
+ const existingByProvider = loadModelsByProvider(t.providerUrl);
488
+ if (existingByProvider.length === 0) {
489
+ // 优先用服务端的模型列表
490
+ let modelsToMigrate = Array.isArray(t.models) ? t.models.filter(Boolean) : [];
491
+ // 兼容旧版 localStorage(按代理ID存储)
492
+ if (modelsToMigrate.length === 0) {
493
+ const legacyKey = `protocol-proxy-models-${id}`;
494
+ try {
495
+ const legacy = JSON.parse(localStorage.getItem(legacyKey));
496
+ if (Array.isArray(legacy)) modelsToMigrate = legacy;
497
+ } catch {}
498
+ }
499
+ if (modelsToMigrate.length > 0) {
500
+ saveModelsByProvider(t.providerUrl, modelsToMigrate);
501
+ }
334
502
  }
335
503
  }
504
+
505
+ selectProvider(t.providerUrl || '');
336
506
  selectModel(t.defaultModel || '');
337
507
  } else {
338
508
  document.getElementById('proxy-id').value = '';
339
509
  document.getElementById('auth-token-group').style.display = 'none';
340
- const models = loadModels(null);
341
- selectModel(models[0] || '');
510
+ selectProvider('');
511
+ selectModel('');
342
512
  }
343
513
 
514
+ updateModelAddState();
344
515
  document.getElementById('modal').classList.add('active');
345
516
  }
346
517
 
347
518
  function closeModal() {
348
519
  document.getElementById('modal').classList.remove('active');
349
520
  document.getElementById('model-dropdown').classList.remove('open');
521
+ document.getElementById('provider-dropdown').classList.remove('open');
350
522
  editingId = null;
351
523
  }
352
524
 
353
525
  async function handleSubmit(e) {
354
526
  e.preventDefault();
355
527
 
528
+ const providerUrl = getSelectedProviderUrl();
529
+ if (!providerUrl) {
530
+ showToast('请选择供应商地址', true);
531
+ return;
532
+ }
533
+
356
534
  const port = parseInt(document.getElementById('proxy-port').value);
357
535
 
358
536
  // 前端端口冲突校验
@@ -363,8 +541,8 @@ async function handleSubmit(e) {
363
541
  }
364
542
 
365
543
  const target = {
366
- providerUrl: document.getElementById('target-url').value.trim(),
367
- protocol: document.getElementById('target-protocol').value,
544
+ providerUrl,
545
+ protocol: detectProtocol(providerUrl),
368
546
  defaultModel: document.getElementById('target-model').value.trim() || undefined,
369
547
  models: getSelectedModels(),
370
548
  apiKey: document.getElementById('target-key').value.trim(),
@@ -395,15 +573,6 @@ async function handleSubmit(e) {
395
573
  return;
396
574
  }
397
575
 
398
- // 新建代理后,将临时模型列表迁移到真实 ID 下
399
- if (!editingId && result.id) {
400
- const tempModels = loadModels(null);
401
- if (tempModels.length > 0) {
402
- saveModels(result.id, tempModels);
403
- }
404
- localStorage.removeItem(getModelKey(null));
405
- }
406
-
407
576
  closeModal();
408
577
  await loadProxies();
409
578
  } catch (err) {
@@ -437,7 +606,6 @@ async function deleteProxy(id) {
437
606
  if (!ok) return;
438
607
  try {
439
608
  await fetch(`/api/proxies/${id}`, { method: 'DELETE' });
440
- localStorage.removeItem(getModelKey(id));
441
609
  await loadProxies();
442
610
  } catch (err) {
443
611
  showToast('删除失败: ' + err.message, true);
@@ -449,7 +617,6 @@ async function editProxy(id) {
449
617
  const res = await fetch(`/api/proxies/${id}`);
450
618
  if (!res.ok) throw new Error('加载失败');
451
619
  const full = await res.json();
452
- // 用完整数据替换列表中的掩码数据
453
620
  const idx = proxies.findIndex(p => p.id === id);
454
621
  if (idx !== -1) proxies[idx] = { ...proxies[idx], ...full };
455
622
  openModal(id);
package/public/index.html CHANGED
@@ -74,14 +74,25 @@
74
74
  <div class="form-row">
75
75
  <div class="form-group">
76
76
  <label>供应商地址</label>
77
- <input type="url" id="target-url" required placeholder="https://api.openai.com">
77
+ <input type="hidden" id="target-url">
78
+ <div class="model-dropdown" id="provider-dropdown">
79
+ <div class="model-dropdown-trigger" id="provider-dropdown-trigger">
80
+ <span id="provider-dropdown-value">选择供应商...</span>
81
+ <span class="model-dropdown-arrow">&#9662;</span>
82
+ </div>
83
+ <div class="model-dropdown-menu" id="provider-dropdown-menu">
84
+ <div class="model-dropdown-options" id="provider-dropdown-options"></div>
85
+ <div class="model-add-section">
86
+ <input type="text" class="model-add-input" id="provider-add-name" placeholder="供应商名称">
87
+ <input type="url" class="model-add-input" id="provider-add-url" placeholder="https://api.example.com">
88
+ <button type="button" class="btn btn-primary btn-sm" id="provider-add-btn">添加</button>
89
+ </div>
90
+ </div>
91
+ </div>
78
92
  </div>
79
93
  <div class="form-group">
80
94
  <label>目标协议</label>
81
- <select id="target-protocol">
82
- <option value="openai">OpenAI</option>
83
- <option value="anthropic">Anthropic</option>
84
- </select>
95
+ <input type="text" id="target-protocol" readonly placeholder="自动识别" style="background:#1e293b;color:#94a3b8;cursor:not-allowed">
85
96
  </div>
86
97
  </div>
87
98
  <div class="form-row">
@@ -95,7 +106,7 @@
95
106
  </div>
96
107
  <div class="model-dropdown-menu" id="model-dropdown-menu">
97
108
  <div class="model-dropdown-options" id="model-dropdown-options"></div>
98
- <div class="model-add-section">
109
+ <div class="model-add-section" id="model-add-section">
99
110
  <input type="text" class="model-add-input" id="model-add-input" placeholder="输入模型名称">
100
111
  <button type="button" class="btn btn-primary btn-sm" id="model-add-btn">添加</button>
101
112
  </div>