protocol-proxy 1.0.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/README.md +94 -0
- package/config/proxies.json +3 -0
- package/lib/config-store.js +119 -0
- package/lib/converters/anthropic-to-openai.js +366 -0
- package/lib/converters/openai-to-anthropic.js +321 -0
- package/lib/converters/sse-helpers.js +51 -0
- package/lib/detector.js +38 -0
- package/lib/proxy-manager.js +79 -0
- package/lib/proxy-server.js +216 -0
- package/package.json +53 -0
- package/public/app.js +459 -0
- package/public/index.html +136 -0
- package/public/style.css +639 -0
- package/server.js +202 -0
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "protocol-proxy",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "OpenAI / Anthropic 协议转换透明代理",
|
|
5
|
+
"main": "server.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"protocol-proxy": "server.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"server.js",
|
|
11
|
+
"lib/",
|
|
12
|
+
"public/",
|
|
13
|
+
"config/",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"keywords": [
|
|
17
|
+
"proxy",
|
|
18
|
+
"openai",
|
|
19
|
+
"anthropic",
|
|
20
|
+
"api",
|
|
21
|
+
"protocol-converter"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"start": "node server.js",
|
|
25
|
+
"dev": "node --watch server.js",
|
|
26
|
+
"build": "pkg . --out-path=dist"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"cors": "^2.8.5",
|
|
30
|
+
"express": "^4.19.2"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"pkg": "^5.8.1"
|
|
34
|
+
},
|
|
35
|
+
"pkg": {
|
|
36
|
+
"scripts": [
|
|
37
|
+
"lib/**/*.js"
|
|
38
|
+
],
|
|
39
|
+
"assets": [
|
|
40
|
+
"public/**/*"
|
|
41
|
+
],
|
|
42
|
+
"targets": [
|
|
43
|
+
"node18-win-x64"
|
|
44
|
+
],
|
|
45
|
+
"outputPath": "dist",
|
|
46
|
+
"options": [
|
|
47
|
+
"--experimental-fetch"
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=20.0.0"
|
|
52
|
+
}
|
|
53
|
+
}
|
package/public/app.js
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
let proxies = [];
|
|
2
|
+
let editingId = null;
|
|
3
|
+
|
|
4
|
+
// ==================== Model 管理(按代理独立) ====================
|
|
5
|
+
function getLegacyModelKey(proxyId) {
|
|
6
|
+
return `protocol-proxy-models-${proxyId || '__new'}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function getModelKey(proxyId) {
|
|
10
|
+
return getLegacyModelKey(proxyId);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function loadLegacyModels(proxyId) {
|
|
14
|
+
const saved = localStorage.getItem(getLegacyModelKey(proxyId));
|
|
15
|
+
if (saved) {
|
|
16
|
+
try { return JSON.parse(saved); } catch { /* fall through */ }
|
|
17
|
+
}
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
|
|
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) {
|
|
32
|
+
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));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function addModel(proxyId, name) {
|
|
45
|
+
const models = loadModels(proxyId);
|
|
46
|
+
if (!models.includes(name)) {
|
|
47
|
+
models.push(name);
|
|
48
|
+
saveModels(proxyId, models);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function removeModel(proxyId, name) {
|
|
53
|
+
const models = loadModels(proxyId).filter(m => m !== name);
|
|
54
|
+
saveModels(proxyId, models);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getCurrentProxyId() {
|
|
58
|
+
return document.getElementById('modal').dataset.proxyId || null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function initModelDropdown() {
|
|
62
|
+
const trigger = document.getElementById('model-dropdown-trigger');
|
|
63
|
+
const dropdown = document.getElementById('model-dropdown');
|
|
64
|
+
const addInput = document.getElementById('model-add-input');
|
|
65
|
+
const addBtn = document.getElementById('model-add-btn');
|
|
66
|
+
|
|
67
|
+
trigger.addEventListener('click', (e) => {
|
|
68
|
+
e.stopPropagation();
|
|
69
|
+
dropdown.classList.toggle('open');
|
|
70
|
+
if (dropdown.classList.contains('open')) {
|
|
71
|
+
addInput.value = '';
|
|
72
|
+
addInput.focus();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
document.addEventListener('click', (e) => {
|
|
77
|
+
if (!dropdown.contains(e.target)) {
|
|
78
|
+
dropdown.classList.remove('open');
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
addBtn.addEventListener('click', () => {
|
|
83
|
+
const name = addInput.value.trim();
|
|
84
|
+
if (!name) return;
|
|
85
|
+
const proxyId = getCurrentProxyId();
|
|
86
|
+
addModel(proxyId, name);
|
|
87
|
+
selectModel(name);
|
|
88
|
+
renderModelOptions();
|
|
89
|
+
addInput.value = '';
|
|
90
|
+
addInput.focus();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
addInput.addEventListener('keydown', (e) => {
|
|
94
|
+
if (e.key === 'Enter') {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
addBtn.click();
|
|
97
|
+
}
|
|
98
|
+
if (e.key === 'Escape') {
|
|
99
|
+
dropdown.classList.remove('open');
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function renderModelOptions() {
|
|
105
|
+
const container = document.getElementById('model-dropdown-options');
|
|
106
|
+
const proxyId = getCurrentProxyId();
|
|
107
|
+
const models = loadModels(proxyId);
|
|
108
|
+
const current = document.getElementById('target-model').value;
|
|
109
|
+
|
|
110
|
+
container.innerHTML = models.map(m => `
|
|
111
|
+
<div class="model-option${m === current ? ' selected' : ''}" data-model="${escapeHtml(m)}">
|
|
112
|
+
<span class="model-option-name">${escapeHtml(m)}</span>
|
|
113
|
+
<button type="button" class="model-option-delete" data-delete="${escapeHtml(m)}" title="删除此模型">×</button>
|
|
114
|
+
</div>
|
|
115
|
+
`).join('');
|
|
116
|
+
|
|
117
|
+
container.querySelectorAll('.model-option').forEach(opt => {
|
|
118
|
+
opt.addEventListener('click', (e) => {
|
|
119
|
+
if (e.target.closest('.model-option-delete')) return;
|
|
120
|
+
selectModel(opt.dataset.model);
|
|
121
|
+
document.getElementById('model-dropdown').classList.remove('open');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
container.querySelectorAll('.model-option-delete').forEach(btn => {
|
|
126
|
+
btn.addEventListener('click', async (e) => {
|
|
127
|
+
e.stopPropagation();
|
|
128
|
+
const name = btn.dataset.delete;
|
|
129
|
+
const ok = await showConfirm(`确定要删除模型 <strong>${escapeHtml(name)}</strong> 吗?`);
|
|
130
|
+
if (!ok) return;
|
|
131
|
+
const pid = getCurrentProxyId();
|
|
132
|
+
removeModel(pid, name);
|
|
133
|
+
if (document.getElementById('target-model').value === name) {
|
|
134
|
+
selectModel('');
|
|
135
|
+
}
|
|
136
|
+
renderModelOptions();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function selectModel(value) {
|
|
142
|
+
document.getElementById('target-model').value = value || '';
|
|
143
|
+
document.getElementById('model-dropdown-value').textContent = value || '选择模型...';
|
|
144
|
+
renderModelOptions();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function getSelectedModels() {
|
|
148
|
+
const proxyId = getCurrentProxyId();
|
|
149
|
+
const models = [...loadModels(proxyId), ...loadLegacyModels(proxyId)];
|
|
150
|
+
const current = document.getElementById('target-model').value.trim();
|
|
151
|
+
if (current && !models.includes(current)) {
|
|
152
|
+
models.unshift(current);
|
|
153
|
+
}
|
|
154
|
+
return Array.from(new Set(models));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ==================== 初始化 ====================
|
|
158
|
+
function generateToken() {
|
|
159
|
+
const arr = new Uint8Array(24);
|
|
160
|
+
crypto.getRandomValues(arr);
|
|
161
|
+
return Array.from(arr, b => b.toString(16).padStart(2, '0')).join('');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function init() {
|
|
165
|
+
await loadProxies();
|
|
166
|
+
initModelDropdown();
|
|
167
|
+
document.getElementById('proxy-auth').addEventListener('change', (e) => {
|
|
168
|
+
const enabled = e.target.value === 'true';
|
|
169
|
+
document.getElementById('auth-token-group').style.display = enabled ? 'block' : 'none';
|
|
170
|
+
if (enabled && !document.getElementById('proxy-auth-token').value) {
|
|
171
|
+
document.getElementById('proxy-auth-token').value = generateToken();
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function loadProxies() {
|
|
177
|
+
try {
|
|
178
|
+
const res = await fetch('/api/proxies');
|
|
179
|
+
proxies = await res.json();
|
|
180
|
+
renderProxies();
|
|
181
|
+
updateStats();
|
|
182
|
+
} catch (err) {
|
|
183
|
+
console.error('加载代理失败:', err);
|
|
184
|
+
document.getElementById('proxy-list').innerHTML =
|
|
185
|
+
'<div class="empty">加载失败,请刷新重试</div>';
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function updateStats() {
|
|
190
|
+
document.getElementById('stat-total').textContent = proxies.length;
|
|
191
|
+
document.getElementById('stat-running').textContent =
|
|
192
|
+
proxies.filter(p => p.running).length;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ==================== 代理地址复制 ====================
|
|
196
|
+
function getProxyUrl(port) {
|
|
197
|
+
return `http://localhost:${port}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function copyProxyUrl(port, btn) {
|
|
201
|
+
const url = getProxyUrl(port);
|
|
202
|
+
navigator.clipboard.writeText(url).then(() => {
|
|
203
|
+
showToast('代理地址已复制');
|
|
204
|
+
const orig = btn.innerHTML;
|
|
205
|
+
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg> 已复制';
|
|
206
|
+
setTimeout(() => { btn.innerHTML = orig; }, 1500);
|
|
207
|
+
}).catch(() => {
|
|
208
|
+
showToast('复制失败,请手动复制', true);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function showConfirm(text) {
|
|
213
|
+
return new Promise(resolve => {
|
|
214
|
+
const modal = document.getElementById('confirm-modal');
|
|
215
|
+
document.getElementById('confirm-text').innerHTML = text;
|
|
216
|
+
modal.classList.add('active');
|
|
217
|
+
|
|
218
|
+
const okBtn = document.getElementById('confirm-ok');
|
|
219
|
+
const cancelBtn = document.getElementById('confirm-cancel');
|
|
220
|
+
|
|
221
|
+
function cleanup(result) {
|
|
222
|
+
modal.classList.remove('active');
|
|
223
|
+
okBtn.removeEventListener('click', onOk);
|
|
224
|
+
cancelBtn.removeEventListener('click', onCancel);
|
|
225
|
+
resolve(result);
|
|
226
|
+
}
|
|
227
|
+
function onOk() { cleanup(true); }
|
|
228
|
+
function onCancel() { cleanup(false); }
|
|
229
|
+
|
|
230
|
+
okBtn.addEventListener('click', onOk);
|
|
231
|
+
cancelBtn.addEventListener('click', onCancel);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function showToast(msg, isError) {
|
|
236
|
+
const existing = document.querySelector('.toast');
|
|
237
|
+
if (existing) existing.remove();
|
|
238
|
+
const toast = document.createElement('div');
|
|
239
|
+
toast.className = 'toast';
|
|
240
|
+
toast.textContent = msg;
|
|
241
|
+
if (isError) toast.style.background = '#ef4444';
|
|
242
|
+
document.body.appendChild(toast);
|
|
243
|
+
setTimeout(() => toast.remove(), 2000);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ==================== 渲染代理列表 ====================
|
|
247
|
+
function renderProxies() {
|
|
248
|
+
const container = document.getElementById('proxy-list');
|
|
249
|
+
if (proxies.length === 0) {
|
|
250
|
+
container.innerHTML = '<div class="empty">暂无代理配置,点击右上角创建</div>';
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
container.innerHTML = proxies.map(p => {
|
|
255
|
+
const t = p.target || {};
|
|
256
|
+
return `
|
|
257
|
+
<div class="proxy-item">
|
|
258
|
+
<div class="proxy-header">
|
|
259
|
+
<div class="proxy-title">
|
|
260
|
+
<h3>${escapeHtml(p.name)}</h3>
|
|
261
|
+
<span class="badge ${p.running ? 'badge-running' : 'badge-stopped'}">
|
|
262
|
+
${p.running ? '运行中' : '已停止'}
|
|
263
|
+
</span>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
<div class="proxy-meta">
|
|
267
|
+
<span>端口: <strong>${p.port}</strong></span>
|
|
268
|
+
<span>认证: ${p.requireAuth ? '已启用' : '未启用'}</span>
|
|
269
|
+
</div>
|
|
270
|
+
<div class="proxy-address">
|
|
271
|
+
<code>${escapeHtml(getProxyUrl(p.port))}</code>
|
|
272
|
+
<button class="copy-btn" onclick="copyProxyUrl(${p.port}, this)">
|
|
273
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
|
274
|
+
复制
|
|
275
|
+
</button>
|
|
276
|
+
</div>
|
|
277
|
+
<table class="target-table">
|
|
278
|
+
<thead>
|
|
279
|
+
<tr>
|
|
280
|
+
<th>供应商地址</th>
|
|
281
|
+
<th>协议</th>
|
|
282
|
+
<th>默认 Model</th>
|
|
283
|
+
</tr>
|
|
284
|
+
</thead>
|
|
285
|
+
<tbody>
|
|
286
|
+
<tr>
|
|
287
|
+
<td>${escapeHtml(t.providerUrl)}</td>
|
|
288
|
+
<td>
|
|
289
|
+
<span class="badge" style="background:${t.protocol==='openai'?'#0c4a6e':'#581c87'};color:${t.protocol==='openai'?'#7dd3fc':'#e9d5ff'}">
|
|
290
|
+
${t.protocol}
|
|
291
|
+
</span>
|
|
292
|
+
</td>
|
|
293
|
+
<td><code>${escapeHtml(t.defaultModel) || '-'}</code></td>
|
|
294
|
+
</tr>
|
|
295
|
+
</tbody>
|
|
296
|
+
</table>
|
|
297
|
+
<div class="proxy-actions">
|
|
298
|
+
${p.running
|
|
299
|
+
? `<button class="btn btn-danger" onclick="stopProxy('${p.id}')">停止</button>`
|
|
300
|
+
: `<button class="btn btn-success" onclick="startProxy('${p.id}')">启动</button>`
|
|
301
|
+
}
|
|
302
|
+
<button class="btn" onclick="editProxy('${p.id}')">编辑</button>
|
|
303
|
+
<button class="btn btn-danger" onclick="deleteProxy('${p.id}')">删除</button>
|
|
304
|
+
</div>
|
|
305
|
+
</div>
|
|
306
|
+
`}).join('');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ==================== 弹窗操作 ====================
|
|
310
|
+
function openModal(id = null) {
|
|
311
|
+
editingId = id;
|
|
312
|
+
document.getElementById('modal').dataset.proxyId = id || '';
|
|
313
|
+
document.getElementById('modal-title').textContent = id ? '编辑代理' : '新建代理';
|
|
314
|
+
document.getElementById('proxy-form').reset();
|
|
315
|
+
|
|
316
|
+
if (id) {
|
|
317
|
+
const p = proxies.find(x => x.id === id);
|
|
318
|
+
if (!p) return;
|
|
319
|
+
const t = p.target || {};
|
|
320
|
+
document.getElementById('proxy-id').value = p.id;
|
|
321
|
+
document.getElementById('proxy-name').value = p.name;
|
|
322
|
+
document.getElementById('proxy-port').value = p.port;
|
|
323
|
+
document.getElementById('proxy-auth').value = p.requireAuth ? 'true' : 'false';
|
|
324
|
+
document.getElementById('proxy-auth-token').value = p.authToken || '';
|
|
325
|
+
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
|
+
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;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
selectModel(t.defaultModel || '');
|
|
337
|
+
} else {
|
|
338
|
+
document.getElementById('proxy-id').value = '';
|
|
339
|
+
document.getElementById('auth-token-group').style.display = 'none';
|
|
340
|
+
const models = loadModels(null);
|
|
341
|
+
selectModel(models[0] || '');
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
document.getElementById('modal').classList.add('active');
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function closeModal() {
|
|
348
|
+
document.getElementById('modal').classList.remove('active');
|
|
349
|
+
document.getElementById('model-dropdown').classList.remove('open');
|
|
350
|
+
editingId = null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function handleSubmit(e) {
|
|
354
|
+
e.preventDefault();
|
|
355
|
+
|
|
356
|
+
const port = parseInt(document.getElementById('proxy-port').value);
|
|
357
|
+
|
|
358
|
+
// 前端端口冲突校验
|
|
359
|
+
const conflict = proxies.find(p => p.id !== editingId && p.port === port);
|
|
360
|
+
if (conflict) {
|
|
361
|
+
showToast(`端口 ${port} 已被代理「${conflict.name}」占用`, true);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const target = {
|
|
366
|
+
providerUrl: document.getElementById('target-url').value.trim(),
|
|
367
|
+
protocol: document.getElementById('target-protocol').value,
|
|
368
|
+
defaultModel: document.getElementById('target-model').value.trim() || undefined,
|
|
369
|
+
models: getSelectedModels(),
|
|
370
|
+
apiKey: document.getElementById('target-key').value.trim(),
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const payload = {
|
|
374
|
+
name: document.getElementById('proxy-name').value.trim(),
|
|
375
|
+
port,
|
|
376
|
+
requireAuth: document.getElementById('proxy-auth').value === 'true',
|
|
377
|
+
authToken: document.getElementById('proxy-auth-token').value.trim() || null,
|
|
378
|
+
target,
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
const url = editingId ? `/api/proxies/${editingId}` : '/api/proxies';
|
|
383
|
+
const method = editingId ? 'PUT' : 'POST';
|
|
384
|
+
const res = await fetch(url, {
|
|
385
|
+
method,
|
|
386
|
+
headers: { 'Content-Type': 'application/json' },
|
|
387
|
+
body: JSON.stringify(payload),
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
const result = await res.json();
|
|
391
|
+
|
|
392
|
+
if (!res.ok) {
|
|
393
|
+
showToast(result.error || '操作失败', true);
|
|
394
|
+
await loadProxies();
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
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
|
+
closeModal();
|
|
408
|
+
await loadProxies();
|
|
409
|
+
} catch (err) {
|
|
410
|
+
showToast('网络错误: ' + err.message, true);
|
|
411
|
+
await loadProxies();
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ==================== 代理操作 ====================
|
|
416
|
+
async function startProxy(id) {
|
|
417
|
+
try {
|
|
418
|
+
await fetch(`/api/proxies/${id}/start`, { method: 'POST' });
|
|
419
|
+
await loadProxies();
|
|
420
|
+
} catch (err) {
|
|
421
|
+
alert('启动失败: ' + err.message);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function stopProxy(id) {
|
|
426
|
+
try {
|
|
427
|
+
await fetch(`/api/proxies/${id}/stop`, { method: 'POST' });
|
|
428
|
+
await loadProxies();
|
|
429
|
+
} catch (err) {
|
|
430
|
+
alert('停止失败: ' + err.message);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async function deleteProxy(id) {
|
|
435
|
+
const p = proxies.find(x => x.id === id);
|
|
436
|
+
const ok = await showConfirm(`确定要删除代理配置 <strong>${escapeHtml(p?.name || '')}</strong> 吗?`);
|
|
437
|
+
if (!ok) return;
|
|
438
|
+
try {
|
|
439
|
+
await fetch(`/api/proxies/${id}`, { method: 'DELETE' });
|
|
440
|
+
localStorage.removeItem(getModelKey(id));
|
|
441
|
+
await loadProxies();
|
|
442
|
+
} catch (err) {
|
|
443
|
+
showToast('删除失败: ' + err.message, true);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function editProxy(id) {
|
|
448
|
+
openModal(id);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ==================== 工具函数 ====================
|
|
452
|
+
function escapeHtml(text) {
|
|
453
|
+
if (!text) return '';
|
|
454
|
+
const div = document.createElement('div');
|
|
455
|
+
div.textContent = text;
|
|
456
|
+
return div.innerHTML;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
init();
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Protocol Proxy - 协议转换代理管理</title>
|
|
7
|
+
<link rel="stylesheet" href="style.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div class="container">
|
|
11
|
+
<header>
|
|
12
|
+
<h1>Protocol Proxy</h1>
|
|
13
|
+
<p>OpenAI / Anthropic 协议转换透明代理</p>
|
|
14
|
+
</header>
|
|
15
|
+
|
|
16
|
+
<div class="stats" id="stats">
|
|
17
|
+
<div class="stat-item">
|
|
18
|
+
<span class="stat-value" id="stat-total">0</span>
|
|
19
|
+
<span class="stat-label">代理配置</span>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="stat-item">
|
|
22
|
+
<span class="stat-value" id="stat-running">0</span>
|
|
23
|
+
<span class="stat-label">运行中</span>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<section class="card">
|
|
28
|
+
<div class="card-header">
|
|
29
|
+
<h2>代理列表</h2>
|
|
30
|
+
<button class="btn btn-primary" onclick="openModal()">+ 新建代理</button>
|
|
31
|
+
</div>
|
|
32
|
+
<div id="proxy-list" class="proxy-list">
|
|
33
|
+
<div class="empty">加载中...</div>
|
|
34
|
+
</div>
|
|
35
|
+
</section>
|
|
36
|
+
|
|
37
|
+
<!-- 编辑/创建弹窗 -->
|
|
38
|
+
<div class="modal" id="modal">
|
|
39
|
+
<div class="modal-content">
|
|
40
|
+
<div class="modal-header">
|
|
41
|
+
<h3 id="modal-title">新建代理</h3>
|
|
42
|
+
<button class="btn-close" onclick="closeModal()">×</button>
|
|
43
|
+
</div>
|
|
44
|
+
<form id="proxy-form" onsubmit="handleSubmit(event)">
|
|
45
|
+
<input type="hidden" id="proxy-id">
|
|
46
|
+
|
|
47
|
+
<div class="form-group">
|
|
48
|
+
<label>代理名称</label>
|
|
49
|
+
<input type="text" id="proxy-name" required placeholder="例如:OpenAI 代理">
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<div class="form-row">
|
|
53
|
+
<div class="form-group">
|
|
54
|
+
<label>监听端口</label>
|
|
55
|
+
<input type="number" id="proxy-port" required placeholder="8080" min="1000" max="65535">
|
|
56
|
+
</div>
|
|
57
|
+
<div class="form-group">
|
|
58
|
+
<label>Agent 认证</label>
|
|
59
|
+
<select id="proxy-auth">
|
|
60
|
+
<option value="false">不启用</option>
|
|
61
|
+
<option value="true">启用</option>
|
|
62
|
+
</select>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div class="form-group" id="auth-token-group" style="display:none">
|
|
67
|
+
<label>认证 Token</label>
|
|
68
|
+
<input type="text" id="proxy-auth-token" placeholder="Bearer Token">
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<div class="target-section">
|
|
72
|
+
<h4>目标供应商配置</h4>
|
|
73
|
+
<div class="target-item">
|
|
74
|
+
<div class="form-row">
|
|
75
|
+
<div class="form-group">
|
|
76
|
+
<label>供应商地址</label>
|
|
77
|
+
<input type="url" id="target-url" required placeholder="https://api.openai.com">
|
|
78
|
+
</div>
|
|
79
|
+
<div class="form-group">
|
|
80
|
+
<label>目标协议</label>
|
|
81
|
+
<select id="target-protocol">
|
|
82
|
+
<option value="openai">OpenAI</option>
|
|
83
|
+
<option value="anthropic">Anthropic</option>
|
|
84
|
+
</select>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
<div class="form-row">
|
|
88
|
+
<div class="form-group">
|
|
89
|
+
<label>默认 Model(可选)</label>
|
|
90
|
+
<input type="hidden" id="target-model">
|
|
91
|
+
<div class="model-dropdown" id="model-dropdown">
|
|
92
|
+
<div class="model-dropdown-trigger" id="model-dropdown-trigger">
|
|
93
|
+
<span id="model-dropdown-value">选择模型...</span>
|
|
94
|
+
<span class="model-dropdown-arrow">▾</span>
|
|
95
|
+
</div>
|
|
96
|
+
<div class="model-dropdown-menu" id="model-dropdown-menu">
|
|
97
|
+
<div class="model-dropdown-options" id="model-dropdown-options"></div>
|
|
98
|
+
<div class="model-add-section">
|
|
99
|
+
<input type="text" class="model-add-input" id="model-add-input" placeholder="输入模型名称">
|
|
100
|
+
<button type="button" class="btn btn-primary btn-sm" id="model-add-btn">添加</button>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
<div class="form-group">
|
|
106
|
+
<label>API Key</label>
|
|
107
|
+
<input type="password" id="target-key" placeholder="sk-...">
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<div class="modal-footer">
|
|
114
|
+
<button type="button" class="btn" onclick="closeModal()">取消</button>
|
|
115
|
+
<button type="submit" class="btn btn-primary">保存</button>
|
|
116
|
+
</div>
|
|
117
|
+
</form>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<!-- 确认弹窗 -->
|
|
123
|
+
<div class="modal confirm-modal" id="confirm-modal">
|
|
124
|
+
<div class="confirm-box">
|
|
125
|
+
<div class="confirm-icon" id="confirm-icon">!</div>
|
|
126
|
+
<p class="confirm-text" id="confirm-text"></p>
|
|
127
|
+
<div class="confirm-actions">
|
|
128
|
+
<button class="btn" id="confirm-cancel">取消</button>
|
|
129
|
+
<button class="btn btn-danger" id="confirm-ok">删除</button>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<script src="app.js"></script>
|
|
135
|
+
</body>
|
|
136
|
+
</html>
|