mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-03 03:10:50 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5be40092f7 | ||
|
|
d422606f99 | ||
|
|
8b07159c35 | ||
|
|
5b1be05eb9 | ||
|
|
a4fd672458 | ||
|
|
6f1c7b168d | ||
|
|
1d7408cb25 |
594
app.js
594
app.js
@@ -27,6 +27,17 @@ class CLIProxyManager {
|
||||
// 日志时间戳(用于增量加载)
|
||||
this.latestLogTimestamp = null;
|
||||
|
||||
// Auth file filter state cache
|
||||
this.currentAuthFileFilter = 'all';
|
||||
this.cachedAuthFiles = [];
|
||||
|
||||
// Vertex AI credential import state
|
||||
this.vertexImportState = {
|
||||
file: null,
|
||||
loading: false,
|
||||
result: null
|
||||
};
|
||||
|
||||
// 主题管理
|
||||
this.currentTheme = 'light';
|
||||
|
||||
@@ -511,6 +522,8 @@ class CLIProxyManager {
|
||||
const switchProjectToggle = document.getElementById('switch-project-toggle');
|
||||
const switchPreviewToggle = document.getElementById('switch-preview-model-toggle');
|
||||
const usageStatisticsToggle = document.getElementById('usage-statistics-enabled-toggle');
|
||||
const requestLogToggle = document.getElementById('request-log-toggle');
|
||||
const wsAuthToggle = document.getElementById('ws-auth-toggle');
|
||||
|
||||
if (debugToggle) {
|
||||
debugToggle.addEventListener('change', (e) => this.updateDebug(e.target.checked));
|
||||
@@ -533,6 +546,12 @@ class CLIProxyManager {
|
||||
if (usageStatisticsToggle) {
|
||||
usageStatisticsToggle.addEventListener('change', (e) => this.updateUsageStatisticsEnabled(e.target.checked));
|
||||
}
|
||||
if (requestLogToggle) {
|
||||
requestLogToggle.addEventListener('change', (e) => this.updateRequestLog(e.target.checked));
|
||||
}
|
||||
if (wsAuthToggle) {
|
||||
wsAuthToggle.addEventListener('change', (e) => this.updateWsAuth(e.target.checked));
|
||||
}
|
||||
|
||||
// 日志记录设置
|
||||
const loggingToFileToggle = document.getElementById('logging-to-file-toggle');
|
||||
@@ -598,6 +617,24 @@ class CLIProxyManager {
|
||||
authFileInput.addEventListener('change', (e) => this.handleFileUpload(e));
|
||||
}
|
||||
|
||||
// Vertex AI credential import
|
||||
const vertexSelectFile = document.getElementById('vertex-select-file');
|
||||
const vertexFileInput = document.getElementById('vertex-file-input');
|
||||
const vertexImportBtn = document.getElementById('vertex-import-btn');
|
||||
|
||||
if (vertexSelectFile) {
|
||||
vertexSelectFile.addEventListener('click', () => this.openVertexFilePicker());
|
||||
}
|
||||
if (vertexFileInput) {
|
||||
vertexFileInput.addEventListener('change', (e) => this.handleVertexFileSelection(e));
|
||||
}
|
||||
if (vertexImportBtn) {
|
||||
vertexImportBtn.addEventListener('click', () => this.importVertexCredential());
|
||||
}
|
||||
this.updateVertexFileDisplay();
|
||||
this.updateVertexImportButtonState();
|
||||
this.renderVertexImportResult(this.vertexImportState.result);
|
||||
|
||||
// Codex OAuth
|
||||
const codexOauthBtn = document.getElementById('codex-oauth-btn');
|
||||
const codexOpenLink = document.getElementById('codex-open-link');
|
||||
@@ -1536,14 +1573,26 @@ class CLIProxyManager {
|
||||
// 显示或隐藏日志查看栏目
|
||||
this.toggleLogsNavItem(config['logging-to-file']);
|
||||
}
|
||||
if (config['request-log'] !== undefined) {
|
||||
const requestLogToggle = document.getElementById('request-log-toggle');
|
||||
if (requestLogToggle) {
|
||||
requestLogToggle.checked = config['request-log'];
|
||||
}
|
||||
}
|
||||
if (config['ws-auth'] !== undefined) {
|
||||
const wsAuthToggle = document.getElementById('ws-auth-toggle');
|
||||
if (wsAuthToggle) {
|
||||
wsAuthToggle.checked = config['ws-auth'];
|
||||
}
|
||||
}
|
||||
|
||||
// API 密钥
|
||||
if (config['api-keys']) {
|
||||
this.renderApiKeys(config['api-keys']);
|
||||
}
|
||||
|
||||
// Gemini 密钥
|
||||
await this.renderGeminiKeys(Array.isArray(config['generative-language-api-key']) ? config['generative-language-api-key'] : [], keyStats);
|
||||
// Gemini keys
|
||||
await this.renderGeminiKeys(this.getGeminiKeysFromConfig(config), keyStats);
|
||||
|
||||
// Codex 密钥
|
||||
await this.renderCodexKeys(Array.isArray(config['codex-api-key']) ? config['codex-api-key'] : [], keyStats);
|
||||
@@ -1563,6 +1612,8 @@ class CLIProxyManager {
|
||||
this.loadRetrySettings(),
|
||||
this.loadQuotaSettings(),
|
||||
this.loadUsageStatisticsSettings(),
|
||||
this.loadRequestLogSetting(),
|
||||
this.loadWsAuthSetting(),
|
||||
this.loadApiKeys(),
|
||||
this.loadGeminiKeys(),
|
||||
this.loadCodexKeys(),
|
||||
@@ -1703,6 +1754,36 @@ class CLIProxyManager {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载请求日志设置
|
||||
async loadRequestLogSetting() {
|
||||
try {
|
||||
const config = await this.getConfig();
|
||||
if (config['request-log'] !== undefined) {
|
||||
const requestLogToggle = document.getElementById('request-log-toggle');
|
||||
if (requestLogToggle) {
|
||||
requestLogToggle.checked = config['request-log'];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载请求日志设置失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载 WebSocket 鉴权设置
|
||||
async loadWsAuthSetting() {
|
||||
try {
|
||||
const config = await this.getConfig();
|
||||
if (config['ws-auth'] !== undefined) {
|
||||
const wsAuthToggle = document.getElementById('ws-auth-toggle');
|
||||
if (wsAuthToggle) {
|
||||
wsAuthToggle.checked = config['ws-auth'];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载 WebSocket 鉴权设置失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新使用统计设置
|
||||
async updateUsageStatisticsEnabled(enabled) {
|
||||
try {
|
||||
@@ -1721,6 +1802,42 @@ class CLIProxyManager {
|
||||
}
|
||||
}
|
||||
|
||||
// 更新请求日志设置
|
||||
async updateRequestLog(enabled) {
|
||||
try {
|
||||
await this.makeRequest('/request-log', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ value: enabled })
|
||||
});
|
||||
this.clearCache();
|
||||
this.showNotification(i18n.t('notification.request_log_updated'), 'success');
|
||||
} catch (error) {
|
||||
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
|
||||
const requestLogToggle = document.getElementById('request-log-toggle');
|
||||
if (requestLogToggle) {
|
||||
requestLogToggle.checked = !enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新 WebSocket 鉴权设置
|
||||
async updateWsAuth(enabled) {
|
||||
try {
|
||||
await this.makeRequest('/ws-auth', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ value: enabled })
|
||||
});
|
||||
this.clearCache();
|
||||
this.showNotification(i18n.t('notification.ws_auth_updated'), 'success');
|
||||
} catch (error) {
|
||||
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
|
||||
const wsAuthToggle = document.getElementById('ws-auth-toggle');
|
||||
if (wsAuthToggle) {
|
||||
wsAuthToggle.checked = !enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新日志记录到文件设置
|
||||
async updateLoggingToFile(enabled) {
|
||||
try {
|
||||
@@ -2315,14 +2432,18 @@ class CLIProxyManager {
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = keys.map((key, index) => `
|
||||
container.innerHTML = keys.map((key, index) => {
|
||||
const normalizedKey = typeof key === 'string' ? key : String(key ?? '');
|
||||
const maskedDisplay = this.escapeHtml(this.maskApiKey(normalizedKey));
|
||||
const keyArgument = JSON.stringify(normalizedKey).replace(/"/g, '"');
|
||||
return `
|
||||
<div class="key-item">
|
||||
<div class="item-content">
|
||||
<div class="item-title">${i18n.t('api_keys.item_title')} #${index + 1}</div>
|
||||
<div class="item-value">${this.maskApiKey(key)}</div>
|
||||
<div class="item-value">${maskedDisplay}</div>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<button class="btn btn-secondary" onclick="manager.editApiKey(${index}, '${key}')">
|
||||
<button class="btn btn-secondary" onclick="manager.editApiKey(${index}, ${keyArgument})">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button class="btn btn-danger" onclick="manager.deleteApiKey(${index})">
|
||||
@@ -2330,19 +2451,24 @@ class CLIProxyManager {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 遮蔽API密钥显示
|
||||
maskApiKey(key) {
|
||||
if (key.length > 8) {
|
||||
return key.substring(0, 4) + '...' + key.substring(key.length - 4);
|
||||
} else if (key.length > 4) {
|
||||
return key.substring(0, 2) + '...' + key.substring(key.length - 2);
|
||||
} else if (key.length > 2) {
|
||||
return key.substring(0, 1) + '...' + key.substring(key.length - 1);
|
||||
if (key === null || key === undefined) {
|
||||
return '';
|
||||
}
|
||||
return key;
|
||||
const normalizedKey = typeof key === 'string' ? key : String(key);
|
||||
if (normalizedKey.length > 8) {
|
||||
return normalizedKey.substring(0, 4) + '...' + normalizedKey.substring(normalizedKey.length - 4);
|
||||
} else if (normalizedKey.length > 4) {
|
||||
return normalizedKey.substring(0, 2) + '...' + normalizedKey.substring(normalizedKey.length - 2);
|
||||
} else if (normalizedKey.length > 2) {
|
||||
return normalizedKey.substring(0, 1) + '...' + normalizedKey.substring(normalizedKey.length - 1);
|
||||
}
|
||||
return normalizedKey;
|
||||
}
|
||||
|
||||
// HTML 转义,防止 XSS
|
||||
@@ -2594,35 +2720,67 @@ class CLIProxyManager {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载Gemini密钥
|
||||
// Load Gemini keys
|
||||
async loadGeminiKeys() {
|
||||
try {
|
||||
const config = await this.getConfig();
|
||||
let keys = Array.isArray(config['gemini-api-key']) ? config['gemini-api-key'] : [];
|
||||
if (keys.length === 0) {
|
||||
const legacyKeys = Array.isArray(config['generative-language-api-key']) ? config['generative-language-api-key'] : [];
|
||||
keys = legacyKeys.map(key => ({ 'api-key': key }));
|
||||
}
|
||||
const keys = this.getGeminiKeysFromConfig(config);
|
||||
await this.renderGeminiKeys(keys);
|
||||
} catch (error) {
|
||||
console.error('加载Gemini密钥失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染Gemini密钥列表
|
||||
// Extract Gemini keys from config with backward compatibility
|
||||
getGeminiKeysFromConfig(config) {
|
||||
if (!config) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const geminiKeys = Array.isArray(config['gemini-api-key']) ? config['gemini-api-key'] : [];
|
||||
if (geminiKeys.length > 0) {
|
||||
return geminiKeys;
|
||||
}
|
||||
|
||||
const legacyKeys = Array.isArray(config['generative-language-api-key']) ? config['generative-language-api-key'] : [];
|
||||
return legacyKeys
|
||||
.map(item => {
|
||||
if (item && typeof item === 'object') {
|
||||
return { ...item };
|
||||
}
|
||||
if (typeof item === 'string') {
|
||||
const trimmed = item.trim();
|
||||
if (trimmed) {
|
||||
return { 'api-key': trimmed };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// Render Gemini key list
|
||||
async renderGeminiKeys(keys, keyStats = null) {
|
||||
const container = document.getElementById('gemini-keys-list');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const normalizedList = (Array.isArray(keys) ? keys : []).map(item => {
|
||||
let normalized = null;
|
||||
if (item && typeof item === 'object') {
|
||||
return { ...item };
|
||||
normalized = { ...item };
|
||||
} else if (typeof item === 'string') {
|
||||
const trimmed = item.trim();
|
||||
if (trimmed) {
|
||||
normalized = { 'api-key': trimmed };
|
||||
}
|
||||
if (typeof item === 'string') {
|
||||
return { 'api-key': item };
|
||||
}
|
||||
return null;
|
||||
|
||||
if (normalized && !normalized['base-url'] && normalized['base_url']) {
|
||||
normalized['base-url'] = normalized['base_url'];
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}).filter(config => config && config['api-key']);
|
||||
this.cachedGeminiKeys = normalizedList;
|
||||
|
||||
@@ -2645,16 +2803,18 @@ class CLIProxyManager {
|
||||
|
||||
container.innerHTML = normalizedList.map((config, index) => {
|
||||
const rawKey = config['api-key'] || '';
|
||||
const masked = rawKey ? this.maskApiKey(rawKey) : '';
|
||||
const masked = this.maskApiKey(rawKey || '');
|
||||
const maskedDisplay = this.escapeHtml(masked);
|
||||
const keyStats = (rawKey && (stats[rawKey] || stats[masked])) || { success: 0, failure: 0 };
|
||||
const configJson = JSON.stringify(config).replace(/"/g, '"');
|
||||
const apiKeyJson = JSON.stringify(rawKey || '').replace(/"/g, '"');
|
||||
const baseUrl = config['base-url'] || config['base_url'] || '';
|
||||
return `
|
||||
<div class="key-item">
|
||||
<div class="item-content">
|
||||
<div class="item-title">${i18n.t('ai_providers.gemini_item_title')} #${index + 1}</div>
|
||||
<div class="item-value">${this.maskApiKey(rawKey || '')}</div>
|
||||
${config['base-url'] ? `<div class="item-subtitle">${i18n.t('common.base_url')}: ${this.escapeHtml(config['base-url'])}</div>` : ''}
|
||||
<div class="item-value">${maskedDisplay}</div>
|
||||
${baseUrl ? `<div class="item-subtitle">${i18n.t('common.base_url')}: ${this.escapeHtml(baseUrl)}</div>` : ''}
|
||||
${this.renderHeaderBadges(config.headers)}
|
||||
<div class="item-stats">
|
||||
<span class="stat-badge stat-success">
|
||||
@@ -2920,14 +3080,16 @@ class CLIProxyManager {
|
||||
const stats = keyStats;
|
||||
|
||||
container.innerHTML = list.map((config, index) => {
|
||||
const rawKey = config['api-key'];
|
||||
const masked = rawKey ? this.maskApiKey(rawKey) : '';
|
||||
const rawKey = config['api-key'] || '';
|
||||
const masked = this.maskApiKey(rawKey || '');
|
||||
const maskedDisplay = this.escapeHtml(masked);
|
||||
const keyStats = (rawKey && (stats[rawKey] || stats[masked])) || { success: 0, failure: 0 };
|
||||
const deleteArg = JSON.stringify(rawKey).replace(/"/g, '"');
|
||||
return `
|
||||
<div class="provider-item">
|
||||
<div class="item-content">
|
||||
<div class="item-title">${i18n.t('ai_providers.codex_item_title')} #${index + 1}</div>
|
||||
<div class="item-subtitle">${i18n.t('common.api_key')}: ${this.maskApiKey(config['api-key'])}</div>
|
||||
<div class="item-subtitle">${i18n.t('common.api_key')}: ${maskedDisplay}</div>
|
||||
${config['base-url'] ? `<div class="item-subtitle">${i18n.t('common.base_url')}: ${this.escapeHtml(config['base-url'])}</div>` : ''}
|
||||
${config['proxy-url'] ? `<div class="item-subtitle">${i18n.t('common.proxy_url')}: ${this.escapeHtml(config['proxy-url'])}</div>` : ''}
|
||||
${this.renderHeaderBadges(config.headers)}
|
||||
@@ -2944,7 +3106,7 @@ class CLIProxyManager {
|
||||
<button class="btn btn-secondary" onclick="manager.editCodexKey(${index}, ${JSON.stringify(config).replace(/"/g, '"')})">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button class="btn btn-danger" onclick="manager.deleteCodexKey('${config['api-key']}')">
|
||||
<button class="btn btn-danger" onclick="manager.deleteCodexKey(${deleteArg})">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -3152,17 +3314,26 @@ class CLIProxyManager {
|
||||
const stats = keyStats;
|
||||
|
||||
container.innerHTML = list.map((config, index) => {
|
||||
const rawKey = config['api-key'];
|
||||
const masked = rawKey ? this.maskApiKey(rawKey) : '';
|
||||
const rawKey = config['api-key'] || '';
|
||||
const masked = this.maskApiKey(rawKey || '');
|
||||
const maskedDisplay = this.escapeHtml(masked);
|
||||
const keyStats = (rawKey && (stats[rawKey] || stats[masked])) || { success: 0, failure: 0 };
|
||||
const deleteArg = JSON.stringify(rawKey).replace(/"/g, '"');
|
||||
const models = Array.isArray(config.models) ? config.models : [];
|
||||
const modelsCountHtml = models.length
|
||||
? `<div class="item-subtitle">${i18n.t('ai_providers.claude_models_count')}: ${models.length}</div>`
|
||||
: '';
|
||||
const modelsBadgesHtml = this.renderModelBadges(models);
|
||||
return `
|
||||
<div class="provider-item">
|
||||
<div class="item-content">
|
||||
<div class="item-title">${i18n.t('ai_providers.claude_item_title')} #${index + 1}</div>
|
||||
<div class="item-subtitle">${i18n.t('common.api_key')}: ${this.maskApiKey(config['api-key'])}</div>
|
||||
<div class="item-subtitle">${i18n.t('common.api_key')}: ${maskedDisplay}</div>
|
||||
${config['base-url'] ? `<div class="item-subtitle">${i18n.t('common.base_url')}: ${this.escapeHtml(config['base-url'])}</div>` : ''}
|
||||
${config['proxy-url'] ? `<div class="item-subtitle">${i18n.t('common.proxy_url')}: ${this.escapeHtml(config['proxy-url'])}</div>` : ''}
|
||||
${this.renderHeaderBadges(config.headers)}
|
||||
${modelsCountHtml}
|
||||
${modelsBadgesHtml}
|
||||
<div class="item-stats">
|
||||
<span class="stat-badge stat-success">
|
||||
<i class="fas fa-check-circle"></i> ${i18n.t('stats.success')}: ${keyStats.success}
|
||||
@@ -3176,7 +3347,7 @@ class CLIProxyManager {
|
||||
<button class="btn btn-secondary" onclick="manager.editClaudeKey(${index}, ${JSON.stringify(config).replace(/"/g, '"')})">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button class="btn btn-danger" onclick="manager.deleteClaudeKey('${config['api-key']}')">
|
||||
<button class="btn btn-danger" onclick="manager.deleteClaudeKey(${deleteArg})">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -3209,6 +3380,12 @@ class CLIProxyManager {
|
||||
<div id="new-claude-headers-wrapper" class="header-input-list"></div>
|
||||
<button type="button" class="btn btn-secondary" onclick="manager.addHeaderField('new-claude-headers-wrapper')">${i18n.t('common.custom_headers_add')}</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${i18n.t('ai_providers.claude_models_label')}</label>
|
||||
<p class="form-hint">${i18n.t('ai_providers.claude_models_hint')}</p>
|
||||
<div id="new-claude-models-wrapper" class="model-input-list"></div>
|
||||
<button type="button" class="btn btn-secondary" onclick="manager.addModelField('new-claude-models-wrapper')">${i18n.t('ai_providers.claude_models_add_btn')}</button>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.cancel')}</button>
|
||||
<button class="btn btn-primary" onclick="manager.addClaudeKey()">${i18n.t('common.add')}</button>
|
||||
@@ -3217,6 +3394,7 @@ class CLIProxyManager {
|
||||
|
||||
modal.style.display = 'block';
|
||||
this.populateHeaderFields('new-claude-headers-wrapper');
|
||||
this.populateModelFields('new-claude-models-wrapper');
|
||||
}
|
||||
|
||||
// 添加Claude密钥
|
||||
@@ -3225,6 +3403,7 @@ class CLIProxyManager {
|
||||
const baseUrl = document.getElementById('new-claude-url').value.trim();
|
||||
const proxyUrl = document.getElementById('new-claude-proxy').value.trim();
|
||||
const headers = this.collectHeaderInputs('new-claude-headers-wrapper');
|
||||
const models = this.collectModelInputs('new-claude-models-wrapper');
|
||||
|
||||
if (!apiKey) {
|
||||
this.showNotification(i18n.t('notification.field_required'), 'error');
|
||||
@@ -3243,6 +3422,9 @@ class CLIProxyManager {
|
||||
newConfig['proxy-url'] = proxyUrl;
|
||||
}
|
||||
this.applyHeadersToConfig(newConfig, headers);
|
||||
if (models.length) {
|
||||
newConfig.models = models;
|
||||
}
|
||||
|
||||
currentKeys.push(newConfig);
|
||||
|
||||
@@ -3285,6 +3467,12 @@ class CLIProxyManager {
|
||||
<div id="edit-claude-headers-wrapper" class="header-input-list"></div>
|
||||
<button type="button" class="btn btn-secondary" onclick="manager.addHeaderField('edit-claude-headers-wrapper')">${i18n.t('common.custom_headers_add')}</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${i18n.t('ai_providers.claude_models_label')}</label>
|
||||
<p class="form-hint">${i18n.t('ai_providers.claude_models_hint')}</p>
|
||||
<div id="edit-claude-models-wrapper" class="model-input-list"></div>
|
||||
<button type="button" class="btn btn-secondary" onclick="manager.addModelField('edit-claude-models-wrapper')">${i18n.t('ai_providers.claude_models_add_btn')}</button>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.cancel')}</button>
|
||||
<button class="btn btn-primary" onclick="manager.updateClaudeKey(${index})">${i18n.t('common.update')}</button>
|
||||
@@ -3293,6 +3481,7 @@ class CLIProxyManager {
|
||||
|
||||
modal.style.display = 'block';
|
||||
this.populateHeaderFields('edit-claude-headers-wrapper', config.headers || null);
|
||||
this.populateModelFields('edit-claude-models-wrapper', Array.isArray(config.models) ? config.models : []);
|
||||
}
|
||||
|
||||
// 更新Claude密钥
|
||||
@@ -3301,6 +3490,7 @@ class CLIProxyManager {
|
||||
const baseUrl = document.getElementById('edit-claude-url').value.trim();
|
||||
const proxyUrl = document.getElementById('edit-claude-proxy').value.trim();
|
||||
const headers = this.collectHeaderInputs('edit-claude-headers-wrapper');
|
||||
const models = this.collectModelInputs('edit-claude-models-wrapper');
|
||||
|
||||
if (!apiKey) {
|
||||
this.showNotification(i18n.t('notification.field_required'), 'error');
|
||||
@@ -3316,6 +3506,9 @@ class CLIProxyManager {
|
||||
newConfig['proxy-url'] = proxyUrl;
|
||||
}
|
||||
this.applyHeadersToConfig(newConfig, headers);
|
||||
if (models.length) {
|
||||
newConfig.models = models;
|
||||
}
|
||||
|
||||
await this.makeRequest('/claude-api-key', {
|
||||
method: 'PATCH',
|
||||
@@ -3422,6 +3615,7 @@ class CLIProxyManager {
|
||||
totalFailure += keyStats.failure;
|
||||
});
|
||||
|
||||
const deleteArg = JSON.stringify(name).replace(/"/g, '"');
|
||||
return `
|
||||
<div class="provider-item">
|
||||
<div class="item-content">
|
||||
@@ -3430,7 +3624,7 @@ class CLIProxyManager {
|
||||
${this.renderHeaderBadges(item.headers)}
|
||||
<div class="item-subtitle">${i18n.t('ai_providers.openai_keys_count')}: ${apiKeyEntries.length}</div>
|
||||
<div class="item-subtitle">${i18n.t('ai_providers.openai_models_count')}: ${models.length}</div>
|
||||
${this.renderOpenAIModelBadges(models)}
|
||||
${this.renderModelBadges(models)}
|
||||
<div class="item-stats">
|
||||
<span class="stat-badge stat-success">
|
||||
<i class="fas fa-check-circle"></i> ${i18n.t('stats.success')}: ${totalSuccess}
|
||||
@@ -3444,7 +3638,7 @@ class CLIProxyManager {
|
||||
<button class="btn btn-secondary" onclick="manager.editOpenAIProvider(${index}, ${JSON.stringify(item).replace(/"/g, '"')})">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button class="btn btn-danger" onclick="manager.deleteOpenAIProvider('${this.escapeHtml(name)}')">
|
||||
<button class="btn btn-danger" onclick="manager.deleteOpenAIProvider(${deleteArg})">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -3680,8 +3874,25 @@ class CLIProxyManager {
|
||||
// 渲染认证文件列表
|
||||
async renderAuthFiles(files, keyStats = null) {
|
||||
const container = document.getElementById('auth-files-list');
|
||||
const isRuntimeOnlyFile = (file) => {
|
||||
if (!file) return false;
|
||||
const runtimeValue = file.runtime_only;
|
||||
return runtimeValue === true || runtimeValue === 'true';
|
||||
};
|
||||
const shouldDisplayDisabledGeminiCli = (file) => {
|
||||
if (!file) return false;
|
||||
const provider = typeof file.provider === 'string' ? file.provider.toLowerCase() : '';
|
||||
const type = typeof file.type === 'string' ? file.type.toLowerCase() : '';
|
||||
const isGeminiCli = provider === 'gemini-cli' || type === 'gemini-cli';
|
||||
return isGeminiCli && !isRuntimeOnlyFile(file);
|
||||
};
|
||||
const visibleFiles = Array.isArray(files) ? files.filter(file => {
|
||||
if (!file) return false;
|
||||
return shouldDisplayDisabledGeminiCli(file) || file.disabled !== true;
|
||||
}) : [];
|
||||
this.cachedAuthFiles = visibleFiles.map(file => ({ ...file }));
|
||||
|
||||
if (files.length === 0) {
|
||||
if (visibleFiles.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-file-alt"></i>
|
||||
@@ -3689,6 +3900,9 @@ class CLIProxyManager {
|
||||
<p>${i18n.t('auth_files.empty_desc')}</p>
|
||||
</div>
|
||||
`;
|
||||
this.updateFilterButtons(new Set(['all']));
|
||||
this.bindAuthFileFilterEvents();
|
||||
this.applyAuthFileFilterState();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3700,7 +3914,7 @@ class CLIProxyManager {
|
||||
|
||||
// 收集所有文件类型(使用API返回的type字段)
|
||||
const existingTypes = new Set(['all']); // 'all' 总是存在
|
||||
files.forEach(file => {
|
||||
visibleFiles.forEach(file => {
|
||||
if (file.type) {
|
||||
existingTypes.add(file.type);
|
||||
}
|
||||
@@ -3709,15 +3923,17 @@ class CLIProxyManager {
|
||||
// 更新筛选按钮显示
|
||||
this.updateFilterButtons(existingTypes);
|
||||
|
||||
container.innerHTML = files.map(file => {
|
||||
container.innerHTML = visibleFiles.map(file => {
|
||||
const rawFileName = typeof file.name === 'string' ? file.name : '';
|
||||
const safeFileName = this.escapeHtml(rawFileName);
|
||||
// 认证文件的统计匹配逻辑:
|
||||
// 1. 首先尝试完整文件名匹配
|
||||
// 2. 如果没有匹配,尝试脱敏文件名匹配(去掉扩展名后的脱敏版本)
|
||||
let fileStats = stats[file.name] || { success: 0, failure: 0 };
|
||||
let fileStats = stats[rawFileName] || { success: 0, failure: 0 };
|
||||
|
||||
// 如果完整文件名没有统计,尝试基于文件名的脱敏版本匹配
|
||||
if (fileStats.success === 0 && fileStats.failure === 0) {
|
||||
const nameWithoutExt = file.name.replace(/\.[^/.]+$/, ""); // 去掉扩展名
|
||||
const nameWithoutExt = rawFileName.replace(/\.[^/.]+$/, ""); // 去掉扩展名
|
||||
|
||||
const possibleSources = [];
|
||||
|
||||
@@ -3784,6 +4000,9 @@ class CLIProxyManager {
|
||||
case 'iflow':
|
||||
typeDisplayKey = 'auth_files.type_iflow';
|
||||
break;
|
||||
case 'vertex':
|
||||
typeDisplayKey = 'auth_files.type_vertex';
|
||||
break;
|
||||
case 'empty':
|
||||
typeDisplayKey = 'auth_files.type_empty';
|
||||
break;
|
||||
@@ -3793,15 +4012,20 @@ class CLIProxyManager {
|
||||
}
|
||||
const typeBadge = `<span class="file-type-badge ${fileType}">${i18n.t(typeDisplayKey)}</span>`;
|
||||
|
||||
// 检查是否为 runtime-only 文件
|
||||
const isRuntimeOnly = file.runtime_only === true;
|
||||
// Determine whether the entry is runtime-only
|
||||
const isRuntimeOnly = isRuntimeOnlyFile(file);
|
||||
|
||||
// 生成操作按钮 HTML,runtime-only 文件显示虚拟标记
|
||||
const shouldShowMainFlag = shouldDisplayDisabledGeminiCli(file);
|
||||
const mainFlagButton = shouldShowMainFlag ? `
|
||||
<button class="btn-small btn-warning main-flag-btn" title="主" disabled>主</button>` : '';
|
||||
|
||||
// Build action buttons; runtime-only entries display placeholder badge
|
||||
const actionsHtml = isRuntimeOnly ? `
|
||||
<div class="item-actions">
|
||||
<span class="virtual-auth-badge">虚拟认证文件</span>
|
||||
</div>` : `
|
||||
<div class="item-actions" data-filename="${file.name}">
|
||||
<div class="item-actions" data-filename="${safeFileName}">
|
||||
${mainFlagButton}
|
||||
<button class="btn-small btn-info" data-action="showDetails" title="详细信息">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
</button>
|
||||
@@ -3814,9 +4038,9 @@ class CLIProxyManager {
|
||||
</div>`;
|
||||
|
||||
return `
|
||||
<div class="file-item" data-file-type="${fileType}" ${isRuntimeOnly ? 'data-runtime-only="true"' : ''}>
|
||||
<div class="file-item" data-file-type="${fileType}" data-file-name="${safeFileName}" ${isRuntimeOnly ? 'data-runtime-only="true"' : ''}>
|
||||
<div class="item-content">
|
||||
<div class="item-title">${typeBadge}${file.name}</div>
|
||||
<div class="item-title">${typeBadge}${safeFileName}</div>
|
||||
<div class="item-meta">
|
||||
<span class="item-subtitle">${i18n.t('auth_files.file_modified')}: ${new Date(file.modtime).toLocaleString(i18n.currentLanguage === 'zh-CN' ? 'zh-CN' : 'en-US')}</span>
|
||||
<span class="item-subtitle">${i18n.t('auth_files.file_size')}: ${this.formatFileSize(file.size)}</span>
|
||||
@@ -3842,6 +4066,9 @@ class CLIProxyManager {
|
||||
|
||||
// 绑定认证文件操作按钮事件(使用事件委托)
|
||||
this.bindAuthFileActionEvents();
|
||||
|
||||
// Reapply current filter state
|
||||
this.applyAuthFileFilterState();
|
||||
}
|
||||
|
||||
// 更新筛选按钮显示
|
||||
@@ -3859,6 +4086,7 @@ class CLIProxyManager {
|
||||
{ type: 'claude', labelKey: 'auth_files.filter_claude' },
|
||||
{ type: 'codex', labelKey: 'auth_files.filter_codex' },
|
||||
{ type: 'iflow', labelKey: 'auth_files.filter_iflow' },
|
||||
{ type: 'vertex', labelKey: 'auth_files.filter_vertex' },
|
||||
{ type: 'empty', labelKey: 'auth_files.filter_empty' }
|
||||
];
|
||||
|
||||
@@ -3924,6 +4152,7 @@ class CLIProxyManager {
|
||||
|
||||
// 获取筛选类型
|
||||
const filterType = clickedBtn.dataset.type;
|
||||
this.currentAuthFileFilter = filterType || 'all';
|
||||
|
||||
// 筛选文件
|
||||
const fileItems = document.querySelectorAll('.file-item');
|
||||
@@ -3975,6 +4204,65 @@ class CLIProxyManager {
|
||||
this.refreshFilterButtonTexts();
|
||||
}
|
||||
|
||||
// Apply current filter selection to the list
|
||||
applyAuthFileFilterState() {
|
||||
const filterContainer = document.querySelector('.auth-file-filter');
|
||||
if (!filterContainer) return;
|
||||
|
||||
const currentType = this.currentAuthFileFilter || 'all';
|
||||
const buttons = filterContainer.querySelectorAll('.filter-btn');
|
||||
if (buttons.length === 0) return;
|
||||
|
||||
let targetButton = null;
|
||||
buttons.forEach(btn => {
|
||||
if (btn.dataset.type === currentType) {
|
||||
targetButton = btn;
|
||||
}
|
||||
});
|
||||
|
||||
if (!targetButton) {
|
||||
targetButton = filterContainer.querySelector('.filter-btn[data-type="all"]') || buttons[0];
|
||||
if (targetButton) {
|
||||
this.currentAuthFileFilter = targetButton.dataset.type || 'all';
|
||||
}
|
||||
}
|
||||
|
||||
if (targetButton) {
|
||||
this.handleFilterClick(targetButton);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove deleted auth files from cache and DOM instantly
|
||||
removeAuthFileElements(filenames = []) {
|
||||
if (!Array.isArray(filenames) || filenames.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const removalSet = new Set(filenames);
|
||||
this.cachedAuthFiles = (this.cachedAuthFiles || []).filter(file => file && !removalSet.has(file.name));
|
||||
|
||||
const container = document.getElementById('auth-files-list');
|
||||
if (!container) return;
|
||||
|
||||
const fileItems = container.querySelectorAll('.file-item');
|
||||
fileItems.forEach(item => {
|
||||
const fileNameAttr = item.getAttribute('data-file-name');
|
||||
if (fileNameAttr && removalSet.has(fileNameAttr)) {
|
||||
item.remove();
|
||||
}
|
||||
});
|
||||
|
||||
if (!container.querySelector('.file-item')) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-file-alt"></i>
|
||||
<h3>${i18n.t('auth_files.empty_title')}</h3>
|
||||
<p>${i18n.t('auth_files.empty_desc')}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新筛选按钮文本(根据 data-i18n-text)
|
||||
refreshFilterButtonTexts() {
|
||||
document.querySelectorAll('.auth-file-filter .filter-btn[data-i18n-text]').forEach(btn => {
|
||||
@@ -4040,6 +4328,136 @@ class CLIProxyManager {
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// ===== Vertex AI Credential Import =====
|
||||
openVertexFilePicker() {
|
||||
const fileInput = document.getElementById('vertex-file-input');
|
||||
if (fileInput) {
|
||||
fileInput.click();
|
||||
}
|
||||
}
|
||||
|
||||
handleVertexFileSelection(event) {
|
||||
const fileInput = event?.target;
|
||||
const file = fileInput?.files?.[0] || null;
|
||||
|
||||
if (fileInput) {
|
||||
fileInput.value = '';
|
||||
}
|
||||
|
||||
if (file && !file.name.toLowerCase().endsWith('.json')) {
|
||||
this.showNotification(i18n.t('vertex_import.file_required'), 'error');
|
||||
this.vertexImportState.file = null;
|
||||
this.updateVertexFileDisplay();
|
||||
this.updateVertexImportButtonState();
|
||||
return;
|
||||
}
|
||||
|
||||
this.vertexImportState.file = file;
|
||||
this.updateVertexFileDisplay(file ? file.name : '');
|
||||
this.updateVertexImportButtonState();
|
||||
}
|
||||
|
||||
updateVertexFileDisplay(filename = '') {
|
||||
const displayInput = document.getElementById('vertex-file-display');
|
||||
if (!displayInput) return;
|
||||
displayInput.value = filename || '';
|
||||
}
|
||||
|
||||
updateVertexImportButtonState() {
|
||||
const importBtn = document.getElementById('vertex-import-btn');
|
||||
if (!importBtn) return;
|
||||
const disabled = !this.vertexImportState.file || this.vertexImportState.loading;
|
||||
importBtn.disabled = disabled;
|
||||
}
|
||||
|
||||
async importVertexCredential() {
|
||||
if (!this.vertexImportState.file) {
|
||||
this.showNotification(i18n.t('vertex_import.file_required'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const locationInput = document.getElementById('vertex-location');
|
||||
const location = locationInput ? locationInput.value.trim() : '';
|
||||
const formData = new FormData();
|
||||
formData.append('file', this.vertexImportState.file, this.vertexImportState.file.name);
|
||||
if (location) {
|
||||
formData.append('location', location);
|
||||
}
|
||||
|
||||
try {
|
||||
this.vertexImportState.loading = true;
|
||||
this.updateVertexImportButtonState();
|
||||
|
||||
const response = await fetch(`${this.apiUrl}/vertex/import`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.managementKey}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `HTTP ${response.status}`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMessage = errorData?.message || errorData?.error || errorMessage;
|
||||
} catch (parseError) {
|
||||
const text = await response.text();
|
||||
if (text) {
|
||||
errorMessage = text;
|
||||
}
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
this.showNotification(i18n.t('vertex_import.success'), 'success');
|
||||
this.renderVertexImportResult(result);
|
||||
this.vertexImportState.file = null;
|
||||
this.updateVertexFileDisplay();
|
||||
this.clearCache();
|
||||
await this.loadAuthFiles();
|
||||
} catch (error) {
|
||||
this.showNotification(`${i18n.t('notification.upload_failed')}: ${error.message}`, 'error');
|
||||
} finally {
|
||||
this.vertexImportState.loading = false;
|
||||
this.updateVertexImportButtonState();
|
||||
const fileInput = document.getElementById('vertex-file-input');
|
||||
if (fileInput) {
|
||||
fileInput.value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderVertexImportResult(result = null) {
|
||||
const container = document.getElementById('vertex-import-result');
|
||||
const projectEl = document.getElementById('vertex-result-project');
|
||||
const emailEl = document.getElementById('vertex-result-email');
|
||||
const locationEl = document.getElementById('vertex-result-location');
|
||||
const fileEl = document.getElementById('vertex-result-file');
|
||||
|
||||
if (!container || !projectEl || !emailEl || !locationEl || !fileEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
this.vertexImportState.result = null;
|
||||
container.style.display = 'none';
|
||||
projectEl.textContent = '-';
|
||||
emailEl.textContent = '-';
|
||||
locationEl.textContent = '-';
|
||||
fileEl.textContent = '-';
|
||||
return;
|
||||
}
|
||||
|
||||
this.vertexImportState.result = result;
|
||||
projectEl.textContent = result.project_id || '-';
|
||||
emailEl.textContent = result.email || '-';
|
||||
locationEl.textContent = result.location || 'us-central1';
|
||||
fileEl.textContent = result['auth-file'] || result.auth_file || '-';
|
||||
container.style.display = 'block';
|
||||
}
|
||||
|
||||
// 上传认证文件
|
||||
uploadAuthFile() {
|
||||
document.getElementById('auth-file-input').click();
|
||||
@@ -4111,11 +4529,12 @@ class CLIProxyManager {
|
||||
// 显示JSON模态框
|
||||
showJsonModal(filename, jsonContent) {
|
||||
// 创建模态框HTML
|
||||
const safeFilename = this.escapeHtml(filename || '');
|
||||
const modalHtml = `
|
||||
<div id="json-modal" class="json-modal">
|
||||
<div class="json-modal-content">
|
||||
<div class="json-modal-header">
|
||||
<h3><i class="fas fa-file-code"></i> ${filename}</h3>
|
||||
<h3><i class="fas fa-file-code"></i> ${safeFilename}</h3>
|
||||
<button class="json-modal-close" data-action="close">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
@@ -4234,23 +4653,86 @@ class CLIProxyManager {
|
||||
|
||||
try {
|
||||
await this.makeRequest(`/auth-files?name=${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||
this.removeAuthFileElements([filename]);
|
||||
this.clearCache(); // 清除缓存
|
||||
this.loadAuthFiles();
|
||||
await this.loadAuthFiles();
|
||||
this.showNotification(i18n.t('auth_files.delete_success'), 'success');
|
||||
} catch (error) {
|
||||
this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 删除所有认证文件
|
||||
// Delete auth files (respect current filter)
|
||||
async deleteAllAuthFiles() {
|
||||
if (!confirm(i18n.t('auth_files.delete_all_confirm'))) return;
|
||||
const filterType = (this.currentAuthFileFilter || 'all').toLowerCase();
|
||||
const isFiltered = filterType !== 'all';
|
||||
const typeLabel = this.generateDynamicTypeLabel(filterType);
|
||||
const confirmMessage = isFiltered
|
||||
? i18n.t('auth_files.delete_filtered_confirm').replace('{type}', typeLabel)
|
||||
: i18n.t('auth_files.delete_all_confirm');
|
||||
|
||||
if (!confirm(confirmMessage)) return;
|
||||
|
||||
try {
|
||||
if (!isFiltered) {
|
||||
const response = await this.makeRequest('/auth-files?all=true', { method: 'DELETE' });
|
||||
const currentNames = (this.cachedAuthFiles || []).map(file => file.name).filter(Boolean);
|
||||
if (currentNames.length > 0) {
|
||||
this.removeAuthFileElements(currentNames);
|
||||
}
|
||||
this.clearCache(); // 清除缓存
|
||||
this.loadAuthFiles();
|
||||
this.currentAuthFileFilter = 'all';
|
||||
await this.loadAuthFiles();
|
||||
this.showNotification(`${i18n.t('auth_files.delete_all_success')} ${response.deleted} ${i18n.t('auth_files.files_count')}`, 'success');
|
||||
return;
|
||||
}
|
||||
|
||||
const deletableFiles = (this.cachedAuthFiles || []).filter(file => {
|
||||
if (!file || file.runtime_only) return false;
|
||||
const fileType = (file.type || 'unknown').toLowerCase();
|
||||
return fileType === filterType;
|
||||
});
|
||||
|
||||
if (deletableFiles.length === 0) {
|
||||
this.showNotification(i18n.t('auth_files.delete_filtered_none').replace('{type}', typeLabel), 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
const deletedNames = [];
|
||||
|
||||
for (const file of deletableFiles) {
|
||||
try {
|
||||
await this.makeRequest(`/auth-files?name=${encodeURIComponent(file.name)}`, { method: 'DELETE' });
|
||||
success++;
|
||||
deletedNames.push(file.name);
|
||||
} catch (error) {
|
||||
console.error('删除认证文件失败:', file?.name, error);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedNames.length > 0) {
|
||||
this.removeAuthFileElements(deletedNames);
|
||||
}
|
||||
|
||||
this.clearCache(); // 清除缓存
|
||||
this.currentAuthFileFilter = 'all';
|
||||
await this.loadAuthFiles();
|
||||
|
||||
if (failed === 0) {
|
||||
const successMsg = i18n.t('auth_files.delete_filtered_success')
|
||||
.replace('{count}', success)
|
||||
.replace('{type}', typeLabel);
|
||||
this.showNotification(successMsg, 'success');
|
||||
} else {
|
||||
const warningMsg = i18n.t('auth_files.delete_filtered_partial')
|
||||
.replace('{success}', success)
|
||||
.replace('{failed}', failed)
|
||||
.replace('{type}', typeLabel);
|
||||
this.showNotification(warningMsg, 'warning');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error');
|
||||
}
|
||||
@@ -5435,8 +5917,8 @@ class CLIProxyManager {
|
||||
row.className = 'model-input-row';
|
||||
row.innerHTML = `
|
||||
<div class="input-group">
|
||||
<input type="text" class="model-name-input" placeholder="${i18n.t('ai_providers.openai_model_name_placeholder')}" value="${model.name ? this.escapeHtml(model.name) : ''}">
|
||||
<input type="text" class="model-alias-input" placeholder="${i18n.t('ai_providers.openai_model_alias_placeholder')}" value="${model.alias ? this.escapeHtml(model.alias) : ''}">
|
||||
<input type="text" class="model-name-input" placeholder="${i18n.t('common.model_name_placeholder')}" value="${model.name ? this.escapeHtml(model.name) : ''}">
|
||||
<input type="text" class="model-alias-input" placeholder="${i18n.t('common.model_alias_placeholder')}" value="${model.alias ? this.escapeHtml(model.alias) : ''}">
|
||||
<button type="button" class="btn btn-small btn-danger model-remove-btn"><i class="fas fa-trash"></i></button>
|
||||
</div>
|
||||
`;
|
||||
@@ -5489,7 +5971,7 @@ class CLIProxyManager {
|
||||
return models;
|
||||
}
|
||||
|
||||
renderOpenAIModelBadges(models) {
|
||||
renderModelBadges(models) {
|
||||
if (!models || models.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
72
i18n.js
72
i18n.js
@@ -46,6 +46,8 @@ const i18n = {
|
||||
'common.custom_headers_add': '添加请求头',
|
||||
'common.custom_headers_key_placeholder': 'Header 名称,例如 X-Custom-Header',
|
||||
'common.custom_headers_value_placeholder': 'Header 值',
|
||||
'common.model_name_placeholder': '模型名称,例如 claude-3-5-sonnet-20241022',
|
||||
'common.model_alias_placeholder': '模型别名 (可选)',
|
||||
|
||||
// 页面标题
|
||||
'title.main': 'CLI Proxy API Management Center',
|
||||
@@ -113,6 +115,9 @@ const i18n = {
|
||||
'basic_settings.usage_statistics_enable': '启用使用统计',
|
||||
'basic_settings.logging_title': '日志记录',
|
||||
'basic_settings.logging_to_file_enable': '启用日志记录到文件',
|
||||
'basic_settings.request_log_enable': '启用请求日志',
|
||||
'basic_settings.ws_auth_title': 'WebSocket 鉴权',
|
||||
'basic_settings.ws_auth_enable': '启用 /ws/* 鉴权',
|
||||
|
||||
// API 密钥管理
|
||||
'api_keys.title': 'API 密钥管理',
|
||||
@@ -181,6 +186,10 @@ const i18n = {
|
||||
'ai_providers.claude_edit_modal_url_label': 'Base URL (可选):',
|
||||
'ai_providers.claude_edit_modal_proxy_label': '代理 URL (可选):',
|
||||
'ai_providers.claude_delete_confirm': '确定要删除这个Claude配置吗?',
|
||||
'ai_providers.claude_models_label': '自定义模型 (可选):',
|
||||
'ai_providers.claude_models_hint': '为空表示使用全部模型;可填写 name[, alias] 以限制或重命名模型。',
|
||||
'ai_providers.claude_models_add_btn': '添加模型',
|
||||
'ai_providers.claude_models_count': '模型数量',
|
||||
|
||||
'ai_providers.openai_title': 'OpenAI 兼容提供商',
|
||||
'ai_providers.openai_add_button': '添加提供商',
|
||||
@@ -214,7 +223,7 @@ const i18n = {
|
||||
// 认证文件管理
|
||||
'auth_files.title': '认证文件管理',
|
||||
'auth_files.title_section': '认证文件',
|
||||
'auth_files.description': '这里管理 Qwen 和 Gemini 的认证配置文件。上传 JSON 格式的认证文件以启用相应的 AI 服务。',
|
||||
'auth_files.description': '这里集中管理 CLI Proxy 支持的所有 JSON 认证文件(如 Qwen、Gemini、Vertex 等),上传后即可在运行时启用相应的 AI 服务。',
|
||||
'auth_files.upload_button': '上传文件',
|
||||
'auth_files.delete_all_button': '删除全部',
|
||||
'auth_files.empty_title': '暂无认证文件',
|
||||
@@ -225,11 +234,15 @@ const i18n = {
|
||||
'auth_files.delete_button': '删除',
|
||||
'auth_files.delete_confirm': '确定要删除文件',
|
||||
'auth_files.delete_all_confirm': '确定要删除所有认证文件吗?此操作不可恢复!',
|
||||
'auth_files.delete_filtered_confirm': '确定要删除筛选出的 {type} 认证文件吗?此操作不可恢复!',
|
||||
'auth_files.upload_error_json': '只能上传JSON文件',
|
||||
'auth_files.upload_success': '文件上传成功',
|
||||
'auth_files.download_success': '文件下载成功',
|
||||
'auth_files.delete_success': '文件删除成功',
|
||||
'auth_files.delete_all_success': '成功删除',
|
||||
'auth_files.delete_filtered_success': '成功删除 {count} 个 {type} 认证文件',
|
||||
'auth_files.delete_filtered_partial': '{type} 认证文件删除完成,成功 {success} 个,失败 {failed} 个',
|
||||
'auth_files.delete_filtered_none': '当前筛选类型 ({type}) 下没有可删除的认证文件',
|
||||
'auth_files.files_count': '个文件',
|
||||
'auth_files.filter_all': '全部',
|
||||
'auth_files.filter_qwen': 'Qwen',
|
||||
@@ -239,6 +252,7 @@ const i18n = {
|
||||
'auth_files.filter_claude': 'Claude',
|
||||
'auth_files.filter_codex': 'Codex',
|
||||
'auth_files.filter_iflow': 'iFlow',
|
||||
'auth_files.filter_vertex': 'Vertex',
|
||||
'auth_files.filter_empty': '空文件',
|
||||
'auth_files.filter_unknown': '其他',
|
||||
'auth_files.type_qwen': 'Qwen',
|
||||
@@ -248,8 +262,26 @@ const i18n = {
|
||||
'auth_files.type_claude': 'Claude',
|
||||
'auth_files.type_codex': 'Codex',
|
||||
'auth_files.type_iflow': 'iFlow',
|
||||
'auth_files.type_vertex': 'Vertex',
|
||||
'auth_files.type_empty': '空文件',
|
||||
'auth_files.type_unknown': '其他',
|
||||
'vertex_import.title': 'Vertex AI 凭证导入',
|
||||
'vertex_import.description': '上传 Google 服务账号 JSON,使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。',
|
||||
'vertex_import.location_label': '目标区域 (可选)',
|
||||
'vertex_import.location_placeholder': 'us-central1',
|
||||
'vertex_import.location_hint': '留空表示使用默认区域 us-central1。',
|
||||
'vertex_import.file_label': '服务账号密钥 JSON',
|
||||
'vertex_import.file_hint': '仅支持 Google Cloud service account key JSON 文件,私钥会自动规范化。',
|
||||
'vertex_import.file_placeholder': '尚未选择文件',
|
||||
'vertex_import.choose_file': '选择文件',
|
||||
'vertex_import.import_button': '导入 Vertex 凭证',
|
||||
'vertex_import.file_required': '请先选择 .json 凭证文件',
|
||||
'vertex_import.success': 'Vertex 凭证导入成功',
|
||||
'vertex_import.result_title': '凭证已保存',
|
||||
'vertex_import.result_project': '项目 ID',
|
||||
'vertex_import.result_email': '服务账号',
|
||||
'vertex_import.result_location': '区域',
|
||||
'vertex_import.result_file': '存储文件',
|
||||
|
||||
|
||||
// Codex OAuth
|
||||
@@ -403,6 +435,8 @@ const i18n = {
|
||||
'notification.quota_switch_preview_updated': '预览模型切换设置已更新',
|
||||
'notification.usage_statistics_updated': '使用统计设置已更新',
|
||||
'notification.logging_to_file_updated': '日志记录设置已更新',
|
||||
'notification.request_log_updated': '请求日志设置已更新',
|
||||
'notification.ws_auth_updated': 'WebSocket 鉴权设置已更新',
|
||||
'notification.api_key_added': 'API密钥添加成功',
|
||||
'notification.api_key_updated': 'API密钥更新成功',
|
||||
'notification.api_key_deleted': 'API密钥删除成功',
|
||||
@@ -505,6 +539,8 @@ const i18n = {
|
||||
'common.custom_headers_add': 'Add Header',
|
||||
'common.custom_headers_key_placeholder': 'Header name, e.g. X-Custom-Header',
|
||||
'common.custom_headers_value_placeholder': 'Header value',
|
||||
'common.model_name_placeholder': 'Model name, e.g. claude-3-5-sonnet-20241022',
|
||||
'common.model_alias_placeholder': 'Model alias (optional)',
|
||||
|
||||
// Page titles
|
||||
'title.main': 'CLI Proxy API Management Center',
|
||||
@@ -572,6 +608,9 @@ const i18n = {
|
||||
'basic_settings.usage_statistics_enable': 'Enable usage statistics',
|
||||
'basic_settings.logging_title': 'Logging',
|
||||
'basic_settings.logging_to_file_enable': 'Enable logging to file',
|
||||
'basic_settings.request_log_enable': 'Enable request logging',
|
||||
'basic_settings.ws_auth_title': 'WebSocket Authentication',
|
||||
'basic_settings.ws_auth_enable': 'Require auth for /ws/*',
|
||||
|
||||
// API Keys management
|
||||
'api_keys.title': 'API Keys Management',
|
||||
@@ -640,6 +679,10 @@ const i18n = {
|
||||
'ai_providers.claude_edit_modal_url_label': 'Base URL (Optional):',
|
||||
'ai_providers.claude_edit_modal_proxy_label': 'Proxy URL (Optional):',
|
||||
'ai_providers.claude_delete_confirm': 'Are you sure you want to delete this Claude configuration?',
|
||||
'ai_providers.claude_models_label': 'Custom Models (Optional):',
|
||||
'ai_providers.claude_models_hint': 'Leave empty to allow all models, or add name[, alias] entries to limit/alias them.',
|
||||
'ai_providers.claude_models_add_btn': 'Add Model',
|
||||
'ai_providers.claude_models_count': 'Models Count',
|
||||
|
||||
'ai_providers.openai_title': 'OpenAI Compatible Providers',
|
||||
'ai_providers.openai_add_button': 'Add Provider',
|
||||
@@ -673,7 +716,7 @@ const i18n = {
|
||||
// Auth files management
|
||||
'auth_files.title': 'Auth Files Management',
|
||||
'auth_files.title_section': 'Auth Files',
|
||||
'auth_files.description': 'Here you can manage authentication configuration files for Qwen and Gemini. Upload JSON format authentication files to enable the corresponding AI services.',
|
||||
'auth_files.description': 'Manage all CLI Proxy JSON auth files here (e.g. Qwen, Gemini, Vertex). Uploading a credential immediately enables the corresponding AI integration.',
|
||||
'auth_files.upload_button': 'Upload File',
|
||||
'auth_files.delete_all_button': 'Delete All',
|
||||
'auth_files.empty_title': 'No Auth Files',
|
||||
@@ -684,11 +727,15 @@ const i18n = {
|
||||
'auth_files.delete_button': 'Delete',
|
||||
'auth_files.delete_confirm': 'Are you sure you want to delete file',
|
||||
'auth_files.delete_all_confirm': 'Are you sure you want to delete all auth files? This operation cannot be undone!',
|
||||
'auth_files.delete_filtered_confirm': 'Are you sure you want to delete all {type} auth files? This operation cannot be undone!',
|
||||
'auth_files.upload_error_json': 'Only JSON files are allowed',
|
||||
'auth_files.upload_success': 'File uploaded successfully',
|
||||
'auth_files.download_success': 'File downloaded successfully',
|
||||
'auth_files.delete_success': 'File deleted successfully',
|
||||
'auth_files.delete_all_success': 'Successfully deleted',
|
||||
'auth_files.delete_filtered_success': 'Deleted {count} {type} auth files successfully',
|
||||
'auth_files.delete_filtered_partial': '{type} auth files deletion finished: {success} succeeded, {failed} failed',
|
||||
'auth_files.delete_filtered_none': 'No deletable auth files under the current filter ({type})',
|
||||
'auth_files.files_count': 'files',
|
||||
'auth_files.filter_all': 'All',
|
||||
'auth_files.filter_qwen': 'Qwen',
|
||||
@@ -698,6 +745,7 @@ const i18n = {
|
||||
'auth_files.filter_claude': 'Claude',
|
||||
'auth_files.filter_codex': 'Codex',
|
||||
'auth_files.filter_iflow': 'iFlow',
|
||||
'auth_files.filter_vertex': 'Vertex',
|
||||
'auth_files.filter_empty': 'Empty',
|
||||
'auth_files.filter_unknown': 'Other',
|
||||
'auth_files.type_qwen': 'Qwen',
|
||||
@@ -707,8 +755,26 @@ const i18n = {
|
||||
'auth_files.type_claude': 'Claude',
|
||||
'auth_files.type_codex': 'Codex',
|
||||
'auth_files.type_iflow': 'iFlow',
|
||||
'auth_files.type_vertex': 'Vertex',
|
||||
'auth_files.type_empty': 'Empty',
|
||||
'auth_files.type_unknown': 'Other',
|
||||
'vertex_import.title': 'Vertex AI Credential Import',
|
||||
'vertex_import.description': 'Upload a Google service account JSON to store it as auth-dir/vertex-<project>.json using the same rules as the CLI vertex-import helper.',
|
||||
'vertex_import.location_label': 'Region (optional)',
|
||||
'vertex_import.location_placeholder': 'us-central1',
|
||||
'vertex_import.location_hint': 'Leave empty to use the default region us-central1.',
|
||||
'vertex_import.file_label': 'Service account key JSON',
|
||||
'vertex_import.file_hint': 'Only Google Cloud service account key JSON files are accepted.',
|
||||
'vertex_import.file_placeholder': 'No file selected',
|
||||
'vertex_import.choose_file': 'Choose File',
|
||||
'vertex_import.import_button': 'Import Vertex Credential',
|
||||
'vertex_import.file_required': 'Select a .json credential file first',
|
||||
'vertex_import.success': 'Vertex credential imported successfully',
|
||||
'vertex_import.result_title': 'Credential saved',
|
||||
'vertex_import.result_project': 'Project ID',
|
||||
'vertex_import.result_email': 'Service account',
|
||||
'vertex_import.result_location': 'Region',
|
||||
'vertex_import.result_file': 'Persisted file',
|
||||
|
||||
// Codex OAuth
|
||||
'auth_login.codex_oauth_title': 'Codex OAuth',
|
||||
@@ -861,6 +927,8 @@ const i18n = {
|
||||
'notification.quota_switch_preview_updated': 'Preview model switch settings updated',
|
||||
'notification.usage_statistics_updated': 'Usage statistics settings updated',
|
||||
'notification.logging_to_file_updated': 'Logging settings updated',
|
||||
'notification.request_log_updated': 'Request logging setting updated',
|
||||
'notification.ws_auth_updated': 'WebSocket authentication setting updated',
|
||||
'notification.api_key_added': 'API key added successfully',
|
||||
'notification.api_key_updated': 'API key updated successfully',
|
||||
'notification.api_key_deleted': 'API key deleted successfully',
|
||||
|
||||
72
index.html
72
index.html
@@ -316,6 +316,32 @@
|
||||
<span class="toggle-label"
|
||||
data-i18n="basic_settings.logging_to_file_enable">启用日志记录到文件</span>
|
||||
</div>
|
||||
<div class="toggle-group">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="request-log-toggle">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="toggle-label"
|
||||
data-i18n="basic_settings.request_log_enable">启用请求日志</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WebSocket 鉴权 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-shield-alt"></i> <span
|
||||
data-i18n="basic_settings.ws_auth_title">WebSocket 鉴权</span></h3>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="toggle-group">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="ws-auth-toggle">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="toggle-label"
|
||||
data-i18n="basic_settings.ws_auth_enable">启用 /ws/* 鉴权</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -402,6 +428,51 @@
|
||||
<div id="openai-providers-list" class="provider-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vertex AI Credential Import -->
|
||||
<div class="card" id="vertex-import-card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-cloud-upload-alt"></i> <span data-i18n="vertex_import.title">Vertex AI 凭证导入</span></h3>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<p class="form-hint" data-i18n="vertex_import.description">
|
||||
上传 Google 服务账号 JSON 并保存为 vertex-<project>.json。
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label for="vertex-location" data-i18n="vertex_import.location_label">区域 (可选)</label>
|
||||
<input type="text" id="vertex-location" data-i18n-placeholder="vertex_import.location_placeholder" value="us-central1">
|
||||
<p class="form-hint" data-i18n="vertex_import.location_hint">留空则使用默认 us-central1。</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="vertex_import.file_label">服务账号密钥 JSON</label>
|
||||
<div class="input-group">
|
||||
<input type="text" id="vertex-file-display" readonly data-i18n-placeholder="vertex_import.file_placeholder" placeholder="尚未选择文件">
|
||||
<input type="file" id="vertex-file-input" accept=".json" style="display: none;">
|
||||
<button type="button" id="vertex-select-file" class="btn btn-secondary">
|
||||
<i class="fas fa-file-upload"></i> <span data-i18n="vertex_import.choose_file">选择文件</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="form-hint" data-i18n="vertex_import.file_hint">仅支持 Google Cloud service account JSON。</p>
|
||||
</div>
|
||||
<div class="form-actions vertex-import-actions">
|
||||
<button type="button" id="vertex-import-btn" class="btn btn-primary" disabled>
|
||||
<i class="fas fa-cloud-upload-alt"></i> <span data-i18n="vertex_import.import_button">导入 Vertex 凭证</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="vertex-import-result" class="vertex-import-result" style="display: none;">
|
||||
<div class="vertex-import-result-header">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
<span data-i18n="vertex_import.result_title">凭证已保存</span>
|
||||
</div>
|
||||
<ul>
|
||||
<li><span data-i18n="vertex_import.result_project">项目 ID</span>: <code id="vertex-result-project">-</code></li>
|
||||
<li><span data-i18n="vertex_import.result_email">服务账号</span>: <code id="vertex-result-email">-</code></li>
|
||||
<li><span data-i18n="vertex_import.result_location">区域</span>: <code id="vertex-result-location">-</code></li>
|
||||
<li><span data-i18n="vertex_import.result_file">存储文件</span>: <code id="vertex-result-file">-</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 认证文件管理 -->
|
||||
@@ -432,6 +503,7 @@
|
||||
<button class="filter-btn" data-type="claude" data-i18n-text="auth_files.filter_claude">Claude</button>
|
||||
<button class="filter-btn" data-type="codex" data-i18n-text="auth_files.filter_codex">Codex</button>
|
||||
<button class="filter-btn" data-type="iflow" data-i18n-text="auth_files.filter_iflow">iFlow</button>
|
||||
<button class="filter-btn" data-type="vertex" data-i18n-text="auth_files.filter_vertex">Vertex</button>
|
||||
<button class="filter-btn" data-type="empty" data-i18n-text="auth_files.filter_empty">Empty</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
69
styles.css
69
styles.css
@@ -1248,6 +1248,21 @@ body {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-small.btn-warning {
|
||||
background: #ff9800;
|
||||
color: #fff;
|
||||
border-color: #ff9800;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.btn-small.btn-warning:disabled,
|
||||
.btn-small.btn-warning[disabled] {
|
||||
background: #fb8c00;
|
||||
border-color: #fb8c00;
|
||||
color: #fff;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.btn-small i {
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -3758,6 +3773,60 @@ input:checked+.slider:before {
|
||||
color: #f472b6;
|
||||
}
|
||||
|
||||
/* Vertex AI Credential Import */
|
||||
.vertex-import-actions {
|
||||
text-align: left;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.vertex-import-result {
|
||||
margin-top: 20px;
|
||||
border: 1px dashed var(--border-primary);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
background: var(--bg-quaternary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.vertex-import-result-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.vertex-import-result ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.vertex-import-result li {
|
||||
margin-bottom: 6px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.vertex-import-result code {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 6px;
|
||||
padding: 2px 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .vertex-import-result {
|
||||
border-color: rgba(96, 165, 250, 0.4);
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .vertex-import-result code {
|
||||
background: rgba(7, 11, 22, 0.8);
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
/* ===== AI提供商统计徽章样式 ===== */
|
||||
|
||||
/* 统计信息容器 */
|
||||
|
||||
Reference in New Issue
Block a user