Compare commits

...

5 Commits

Author SHA1 Message Date
Luis Pater
d422606f99 feat(app.js, styles.css): add main flag for Gemini CLI auth files, update styles and filtering logic 2025-11-13 09:22:14 +08:00
Supra4E8C
8b07159c35 refactor(app.js): improve API key handling and display logic
- Enhanced the masking and display of API keys to ensure proper handling of null or undefined values.
- Updated the HTML rendering logic to use normalized and escaped key values for better security and consistency.
- Improved the handling of key arguments in button actions to prevent potential issues with special characters.
- Refactored the file name handling in the UI to ensure proper escaping and display of file names.
2025-11-12 12:04:58 +08:00
Supra4E8C
5b1be05eb9 feat(index.html): add Vertex AI credential import UI
- Implemented the UI for importing Vertex AI credentials, including file selection and input fields for location and JSON key.
- Enhanced internationalization support with appropriate labels and hints in both English and Chinese.
- Removed the previous implementation of the Vertex AI credential import section to streamline the code.
2025-11-11 18:28:56 +08:00
Supra4E8C
a4fd672458 feat(app.js, i18n, index.html, styles): implement Vertex AI credential import feature
- Added functionality for importing Vertex AI credentials, including file selection and upload handling in the CLIProxyManager.
- Updated UI components in index.html to support the new Vertex AI credential import feature.
- Enhanced internationalization strings to provide appropriate labels and messages for the Vertex AI import functionality in both English and Chinese.
- Introduced new styles for the Vertex AI credential import section to ensure a consistent user experience.
2025-11-11 18:19:35 +08:00
Supra4E8C
6f1c7b168d feat(app.js, i18n, index.html): add request logging and WebSocket authentication settings
- Introduced toggles for enabling request logging and WebSocket authentication in the UI.
- Implemented methods to load and update the respective settings in the CLIProxyManager.
- Updated internationalization strings to support new features in both English and Chinese.
2025-11-11 12:32:56 +08:00
4 changed files with 508 additions and 34 deletions

349
app.js
View File

@@ -31,6 +31,13 @@ class CLIProxyManager {
this.currentAuthFileFilter = 'all';
this.cachedAuthFiles = [];
// Vertex AI credential import state
this.vertexImportState = {
file: null,
loading: false,
result: null
};
// 主题管理
this.currentTheme = 'light';
@@ -515,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));
@@ -537,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');
@@ -602,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');
@@ -1540,6 +1573,18 @@ 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']) {
@@ -1567,6 +1612,8 @@ class CLIProxyManager {
this.loadRetrySettings(),
this.loadQuotaSettings(),
this.loadUsageStatisticsSettings(),
this.loadRequestLogSetting(),
this.loadWsAuthSetting(),
this.loadApiKeys(),
this.loadGeminiKeys(),
this.loadCodexKeys(),
@@ -1707,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 {
@@ -1725,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 {
@@ -2319,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})">
@@ -2334,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
@@ -2681,7 +2803,8 @@ 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, '&quot;');
const apiKeyJson = JSON.stringify(rawKey || '').replace(/"/g, '&quot;');
@@ -2690,7 +2813,7 @@ class CLIProxyManager {
<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>
<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">
@@ -2957,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, '&quot;');
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)}
@@ -2981,7 +3106,7 @@ class CLIProxyManager {
<button class="btn btn-secondary" onclick="manager.editCodexKey(${index}, ${JSON.stringify(config).replace(/"/g, '&quot;')})">
<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>
@@ -3189,14 +3314,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, '&quot;');
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)}
@@ -3213,7 +3340,7 @@ class CLIProxyManager {
<button class="btn btn-secondary" onclick="manager.editClaudeKey(${index}, ${JSON.stringify(config).replace(/"/g, '&quot;')})">
<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>
@@ -3459,6 +3586,7 @@ class CLIProxyManager {
totalFailure += keyStats.failure;
});
const deleteArg = JSON.stringify(name).replace(/"/g, '&quot;');
return `
<div class="provider-item">
<div class="item-content">
@@ -3481,7 +3609,7 @@ class CLIProxyManager {
<button class="btn btn-secondary" onclick="manager.editOpenAIProvider(${index}, ${JSON.stringify(item).replace(/"/g, '&quot;')})">
<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>
@@ -3717,7 +3845,22 @@ class CLIProxyManager {
// 渲染认证文件列表
async renderAuthFiles(files, keyStats = null) {
const container = document.getElementById('auth-files-list');
const visibleFiles = Array.isArray(files) ? files.filter(file => file.disabled !== true) : [];
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 (visibleFiles.length === 0) {
@@ -3752,14 +3895,16 @@ class CLIProxyManager {
this.updateFilterButtons(existingTypes);
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 = [];
@@ -3826,6 +3971,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;
@@ -3835,15 +3983,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);
// 生成操作按钮 HTMLruntime-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>
@@ -3856,9 +4009,9 @@ class CLIProxyManager {
</div>`;
return `
<div class="file-item" data-file-type="${fileType}" data-file-name="${this.escapeHtml(file.name)}" ${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>
@@ -3904,6 +4057,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' }
];
@@ -4145,6 +4299,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();
@@ -4216,11 +4500,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>

52
i18n.js
View File

@@ -113,6 +113,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 密钥管理',
@@ -214,7 +217,7 @@ const i18n = {
// 认证文件管理
'auth_files.title': '认证文件管理',
'auth_files.title_section': '认证文件',
'auth_files.description': '这里管理 QwenGemini 的认证配置文件。上传 JSON 格式的认证文件以启用相应的 AI 服务。',
'auth_files.description': '这里集中管理 CLI Proxy 支持的所有 JSON 认证文件(如 QwenGemini、Vertex 等),上传后即可在运行时启用相应的 AI 服务。',
'auth_files.upload_button': '上传文件',
'auth_files.delete_all_button': '删除全部',
'auth_files.empty_title': '暂无认证文件',
@@ -243,6 +246,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',
@@ -252,8 +256,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
@@ -407,6 +429,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密钥删除成功',
@@ -576,6 +600,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',
@@ -677,7 +704,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',
@@ -706,6 +733,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',
@@ -715,8 +743,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',
@@ -869,6 +915,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',

View File

@@ -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>

View File

@@ -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提供商统计徽章样式 ===== */
/* 统计信息容器 */