Files
Cli-Proxy-API-Management-Ce…/src/modules/ai-providers.js

1612 lines
67 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// AI 提供商配置相关方法模块
// 这些函数依赖于 CLIProxyManager 实例上的 makeRequest/getConfig/clearCache/showNotification 等能力,
// 以及 apiKeysModule 中的工具方法(如 applyHeadersToConfig/renderHeaderBadges
const getStatsBySource = (stats) => {
if (stats && typeof stats === 'object' && stats.bySource) {
return stats.bySource;
}
return stats || {};
};
const buildModelEndpoint = (baseUrl) => {
if (!baseUrl) return '';
const trimmed = String(baseUrl).trim().replace(/\/+$/g, '');
if (!trimmed) return '';
// 如果 base 已以 /v1 结尾,直接拼 /models否则拼 /v1/models避免丢失中间路径
if (trimmed.endsWith('/v1')) {
return `${trimmed}/models`;
}
return `${trimmed}/v1/models`;
};
const normalizeModelList = (payload) => {
const toModel = (entry) => {
if (typeof entry === 'string') {
return { name: entry };
}
if (!entry || typeof entry !== 'object') {
return null;
}
const name = entry.id || entry.name || entry.model || entry.value;
if (!name) return null;
const alias = entry.alias || entry.display_name || entry.displayName;
const description = entry.description || entry.note || entry.comment;
const model = { name: String(name) };
if (alias && alias !== name) {
model.alias = String(alias);
}
if (description) {
model.description = String(description);
}
return model;
};
if (Array.isArray(payload)) {
return payload.map(toModel).filter(Boolean);
}
if (payload && typeof payload === 'object') {
if (Array.isArray(payload.data)) {
return payload.data.map(toModel).filter(Boolean);
}
if (Array.isArray(payload.models)) {
return payload.models.map(toModel).filter(Boolean);
}
}
return [];
};
export async function loadGeminiKeys() {
try {
const config = await this.getConfig();
const keys = this.getGeminiKeysFromConfig(config);
await this.renderGeminiKeys(keys);
} catch (error) {
console.error('加载Gemini密钥失败:', error);
}
}
export function 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);
}
export async function 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') {
normalized = { ...item };
} else if (typeof item === 'string') {
const trimmed = item.trim();
if (trimmed) {
normalized = { 'api-key': trimmed };
}
}
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;
if (normalizedList.length === 0) {
container.innerHTML = `
<div class="empty-state">
<i class="fab fa-google"></i>
<h3>${i18n.t('ai_providers.gemini_empty_title')}</h3>
<p>${i18n.t('ai_providers.gemini_empty_desc')}</p>
</div>
`;
return;
}
if (!keyStats) {
keyStats = await this.getKeyStats();
}
const statsBySource = getStatsBySource(keyStats);
container.innerHTML = normalizedList.map((config, index) => {
const rawKey = config['api-key'] || '';
const masked = this.maskApiKey(rawKey || '');
const maskedDisplay = this.escapeHtml(masked);
const usageStats = (rawKey && (statsBySource[rawKey] || statsBySource[masked])) || { success: 0, failure: 0 };
const configJson = JSON.stringify(config).replace(/"/g, '&quot;');
const apiKeyJson = JSON.stringify(rawKey || '').replace(/"/g, '&quot;');
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-subtitle">${i18n.t('common.api_key')}: ${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">
<i class="fas fa-check-circle"></i> ${i18n.t('stats.success')}: ${usageStats.success}
</span>
<span class="stat-badge stat-failure">
<i class="fas fa-times-circle"></i> ${i18n.t('stats.failure')}: ${usageStats.failure}
</span>
</div>
</div>
<div class="item-actions">
<button class="btn btn-secondary" onclick="manager.editGeminiKey(${index}, ${configJson})">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-danger" onclick="manager.deleteGeminiKey(${apiKeyJson})">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`;
}).join('');
}
export function showAddGeminiKeyModal() {
const modal = document.getElementById('modal');
const modalBody = document.getElementById('modal-body');
modalBody.innerHTML = `
<h3>${i18n.t('ai_providers.gemini_add_modal_title')}</h3>
<div class="form-group">
<label>${i18n.t('ai_providers.gemini_add_modal_key_label')}</label>
<p class="form-hint">${i18n.t('ai_providers.gemini_add_modal_key_hint')}</p>
<div id="new-gemini-keys-wrapper" class="api-key-input-list"></div>
<button type="button" class="btn btn-secondary" onclick="manager.addGeminiKeyField('new-gemini-keys-wrapper')">${i18n.t('ai_providers.gemini_keys_add_btn')}</button>
</div>
<div class="form-group">
<label>${i18n.t('common.custom_headers_label')}</label>
<p class="form-hint">${i18n.t('common.custom_headers_hint')}</p>
<div id="new-gemini-headers-wrapper" class="header-input-list"></div>
<button type="button" class="btn btn-secondary" onclick="manager.addHeaderField('new-gemini-headers-wrapper')">${i18n.t('common.custom_headers_add')}</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.addGeminiKey()">${i18n.t('common.add')}</button>
</div>
`;
modal.style.display = 'block';
this.populateGeminiKeyFields('new-gemini-keys-wrapper');
this.populateHeaderFields('new-gemini-headers-wrapper');
}
export async function addGeminiKey() {
const entries = this.collectGeminiKeyFieldInputs('new-gemini-keys-wrapper');
const headers = this.collectHeaderInputs('new-gemini-headers-wrapper');
if (!entries.length) {
this.showNotification(i18n.t('notification.gemini_multi_input_required'), 'error');
return;
}
try {
const data = await this.makeRequest('/gemini-api-key');
let currentKeys = Array.isArray(data['gemini-api-key']) ? data['gemini-api-key'] : [];
const existingKeys = new Set(currentKeys.map(item => item && item['api-key']).filter(Boolean));
const batchSeen = new Set();
let successCount = 0;
let skippedCount = 0;
let failedCount = 0;
for (const entry of entries) {
const apiKey = entry['api-key'];
if (!apiKey) {
continue;
}
if (batchSeen.has(apiKey)) {
skippedCount++;
continue;
}
batchSeen.add(apiKey);
if (existingKeys.has(apiKey)) {
skippedCount++;
continue;
}
const newConfig = { 'api-key': apiKey };
const baseUrl = entry['base-url'];
if (baseUrl) {
newConfig['base-url'] = baseUrl;
} else {
delete newConfig['base-url'];
}
this.applyHeadersToConfig(newConfig, headers);
const nextKeys = [...currentKeys, newConfig];
try {
await this.makeRequest('/gemini-api-key', {
method: 'PUT',
body: JSON.stringify(nextKeys)
});
currentKeys = nextKeys;
existingKeys.add(apiKey);
successCount++;
} catch (error) {
failedCount++;
console.error('Gemini key add failed:', error);
}
}
this.clearCache(); // 清除缓存
this.closeModal();
this.loadGeminiKeys();
if (successCount === 1 && skippedCount === 0 && failedCount === 0) {
this.showNotification(i18n.t('notification.gemini_key_added'), 'success');
return;
}
const summaryTemplate = i18n.t('notification.gemini_multi_summary');
const summary = summaryTemplate
.replace('{success}', successCount)
.replace('{skipped}', skippedCount)
.replace('{failed}', failedCount);
const status = failedCount > 0 ? 'warning' : (successCount > 0 ? 'success' : 'info');
this.showNotification(summary, status);
} catch (error) {
this.showNotification(`${i18n.t('notification.gemini_multi_failed')}: ${error.message}`, 'error');
}
}
export function addGeminiKeyField(wrapperId, entry = {}, options = {}) {
const wrapper = document.getElementById(wrapperId);
if (!wrapper) return;
const row = document.createElement('div');
row.className = 'api-key-input-row';
const apiKeyValue = typeof entry?.['api-key'] === 'string' ? entry['api-key'] : '';
const baseUrlValue = typeof entry?.['base-url'] === 'string'
? entry['base-url']
: (typeof entry?.['base_url'] === 'string' ? entry['base_url'] : '');
const allowRemoval = options.allowRemoval !== false;
const removeButtonHtml = allowRemoval
? `<button type="button" class="btn btn-small btn-danger gemini-key-remove-btn"><i class="fas fa-trash"></i></button>`
: '';
row.innerHTML = `
<div class="input-group api-key-input-group">
<input type="text" class="api-key-value-input" placeholder="${i18n.t('ai_providers.gemini_add_modal_key_placeholder')}" value="${this.escapeHtml(apiKeyValue)}">
<input type="text" class="api-key-proxy-input" placeholder="${i18n.t('ai_providers.gemini_base_url_placeholder')}" value="${this.escapeHtml(baseUrlValue)}">
${removeButtonHtml}
</div>
`;
if (allowRemoval) {
const removeBtn = row.querySelector('.gemini-key-remove-btn');
if (removeBtn) {
removeBtn.addEventListener('click', () => {
wrapper.removeChild(row);
if (wrapper.childElementCount === 0) {
this.addGeminiKeyField(wrapperId, {}, options);
}
});
}
}
wrapper.appendChild(row);
}
export function populateGeminiKeyFields(wrapperId, entries = [], options = {}) {
const wrapper = document.getElementById(wrapperId);
if (!wrapper) return;
wrapper.innerHTML = '';
if (!Array.isArray(entries) || entries.length === 0) {
this.addGeminiKeyField(wrapperId, {}, options);
return;
}
entries.forEach(entry => this.addGeminiKeyField(wrapperId, entry, options));
}
export function collectGeminiKeyFieldInputs(wrapperId) {
const wrapper = document.getElementById(wrapperId);
if (!wrapper) return [];
const rows = Array.from(wrapper.querySelectorAll('.api-key-input-row'));
const entries = [];
rows.forEach(row => {
const keyInput = row.querySelector('.api-key-value-input');
const urlInput = row.querySelector('.api-key-proxy-input');
const apiKey = keyInput ? keyInput.value.trim() : '';
const baseUrl = urlInput ? urlInput.value.trim() : '';
if (apiKey) {
entries.push({ 'api-key': apiKey, 'base-url': baseUrl });
}
});
return entries;
}
export function editGeminiKey(index, config) {
const modal = document.getElementById('modal');
const modalBody = document.getElementById('modal-body');
this.currentGeminiEditConfig = config || {};
modalBody.innerHTML = `
<h3>${i18n.t('ai_providers.gemini_edit_modal_title')}</h3>
<div class="form-group">
<label>${i18n.t('ai_providers.gemini_edit_modal_key_label')}</label>
<div id="edit-gemini-keys-wrapper" class="api-key-input-list"></div>
</div>
<div class="form-group">
<label>${i18n.t('common.custom_headers_label')}</label>
<p class="form-hint">${i18n.t('common.custom_headers_hint')}</p>
<div id="edit-gemini-headers-wrapper" class="header-input-list"></div>
<button type="button" class="btn btn-secondary" onclick="manager.addHeaderField('edit-gemini-headers-wrapper')">${i18n.t('common.custom_headers_add')}</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.updateGeminiKey(${index})">${i18n.t('common.update')}</button>
</div>
`;
modal.style.display = 'block';
this.populateGeminiKeyFields('edit-gemini-keys-wrapper', [config], { allowRemoval: false });
this.populateHeaderFields('edit-gemini-headers-wrapper', config.headers || null);
}
export async function updateGeminiKey(index) {
const entries = this.collectGeminiKeyFieldInputs('edit-gemini-keys-wrapper');
if (!entries.length) {
this.showNotification(i18n.t('notification.please_enter') + ' ' + i18n.t('notification.gemini_api_key'), 'error');
return;
}
const entry = entries[0];
const newKey = entry['api-key'];
const baseUrl = entry['base-url'] || '';
const headers = this.collectHeaderInputs('edit-gemini-headers-wrapper');
if (!newKey) {
this.showNotification(i18n.t('notification.please_enter') + ' ' + i18n.t('notification.gemini_api_key'), 'error');
return;
}
try {
const existingConfig = (this.cachedGeminiKeys && this.cachedGeminiKeys[index]) || this.currentGeminiEditConfig || {};
const newConfig = { ...existingConfig, 'api-key': newKey };
if (baseUrl) {
newConfig['base-url'] = baseUrl;
} else {
delete newConfig['base-url'];
}
this.applyHeadersToConfig(newConfig, headers);
await this.makeRequest('/gemini-api-key', {
method: 'PATCH',
body: JSON.stringify({ index, value: newConfig })
});
this.clearCache(); // 清除缓存
this.closeModal();
this.loadGeminiKeys();
this.currentGeminiEditConfig = null;
this.showNotification(i18n.t('notification.gemini_key_updated'), 'success');
} catch (error) {
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
}
}
export async function deleteGeminiKey(apiKey) {
if (!confirm(i18n.t('ai_providers.gemini_delete_confirm'))) return;
try {
await this.makeRequest(`/gemini-api-key?api-key=${encodeURIComponent(apiKey)}`, { method: 'DELETE' });
this.clearCache(); // 清除缓存
this.loadGeminiKeys();
this.showNotification(i18n.t('notification.gemini_key_deleted'), 'success');
} catch (error) {
this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error');
}
}
// Codex providers
export async function loadCodexKeys() {
try {
const config = await this.getConfig();
const keys = Array.isArray(config['codex-api-key']) ? config['codex-api-key'] : [];
await this.renderCodexKeys(keys);
} catch (error) {
console.error('加载Codex密钥失败:', error);
}
}
export async function renderCodexKeys(keys, keyStats = null) {
const container = document.getElementById('codex-keys-list');
if (!container) {
return;
}
const list = Array.isArray(keys) ? keys : [];
if (list.length === 0) {
container.innerHTML = `
<div class="empty-state">
<i class="fas fa-code"></i>
<h3>${i18n.t('ai_providers.codex_empty_title')}</h3>
<p>${i18n.t('ai_providers.codex_empty_desc')}</p>
</div>
`;
return;
}
// 使用传入的keyStats如果没有则获取一次
if (!keyStats) {
keyStats = await this.getKeyStats();
}
const statsBySource = getStatsBySource(keyStats);
container.innerHTML = list.map((config, index) => {
const rawKey = config['api-key'] || '';
const masked = this.maskApiKey(rawKey || '');
const maskedDisplay = this.escapeHtml(masked);
const usageStats = (rawKey && (statsBySource[rawKey] || statsBySource[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')}: ${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)}
<div class="item-stats">
<span class="stat-badge stat-success">
<i class="fas fa-check-circle"></i> ${i18n.t('stats.success')}: ${usageStats.success}
</span>
<span class="stat-badge stat-failure">
<i class="fas fa-times-circle"></i> ${i18n.t('stats.failure')}: ${usageStats.failure}
</span>
</div>
</div>
<div class="item-actions">
<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(${deleteArg})">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`;
}).join('');
}
export function showAddCodexKeyModal() {
const modal = document.getElementById('modal');
const modalBody = document.getElementById('modal-body');
modalBody.innerHTML = `
<h3>${i18n.t('ai_providers.codex_add_modal_title')}</h3>
<div class="form-group">
<label for="new-codex-key">${i18n.t('ai_providers.codex_add_modal_key_label')}</label>
<input type="text" id="new-codex-key" placeholder="${i18n.t('ai_providers.codex_add_modal_key_placeholder')}">
</div>
<div class="form-group">
<label for="new-codex-url">${i18n.t('ai_providers.codex_add_modal_url_label')}</label>
<input type="text" id="new-codex-url" placeholder="${i18n.t('ai_providers.codex_add_modal_url_placeholder')}" required>
</div>
<div class="form-group">
<label for="new-codex-proxy">${i18n.t('ai_providers.codex_add_modal_proxy_label')}</label>
<input type="text" id="new-codex-proxy" placeholder="${i18n.t('ai_providers.codex_add_modal_proxy_placeholder')}">
</div>
<div class="form-group">
<label>${i18n.t('common.custom_headers_label')}</label>
<p class="form-hint">${i18n.t('common.custom_headers_hint')}</p>
<div id="new-codex-headers-wrapper" class="header-input-list"></div>
<button type="button" class="btn btn-secondary" onclick="manager.addHeaderField('new-codex-headers-wrapper')">${i18n.t('common.custom_headers_add')}</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.addCodexKey()">${i18n.t('common.add')}</button>
</div>
`;
modal.style.display = 'block';
this.populateHeaderFields('new-codex-headers-wrapper');
}
export async function addCodexKey() {
const apiKey = document.getElementById('new-codex-key').value.trim();
const baseUrl = document.getElementById('new-codex-url').value.trim();
const proxyUrl = document.getElementById('new-codex-proxy').value.trim();
const headers = this.collectHeaderInputs('new-codex-headers-wrapper');
if (!apiKey) {
this.showNotification(i18n.t('notification.field_required'), 'error');
return;
}
if (!baseUrl) {
this.showNotification(i18n.t('notification.codex_base_url_required'), 'error');
return;
}
try {
const data = await this.makeRequest('/codex-api-key');
const currentKeys = this.normalizeArrayResponse(data, 'codex-api-key').map(item => ({ ...item }));
const newConfig = this.buildCodexConfig(apiKey, baseUrl, proxyUrl, {}, headers);
currentKeys.push(newConfig);
await this.makeRequest('/codex-api-key', {
method: 'PUT',
body: JSON.stringify(currentKeys)
});
this.clearCache(); // 清除缓存
this.closeModal();
this.loadCodexKeys();
this.showNotification(i18n.t('notification.codex_config_added'), 'success');
} catch (error) {
this.showNotification(`${i18n.t('notification.add_failed')}: ${error.message}`, 'error');
}
}
export function editCodexKey(index, config) {
const modal = document.getElementById('modal');
const modalBody = document.getElementById('modal-body');
modalBody.innerHTML = `
<h3>${i18n.t('ai_providers.codex_edit_modal_title')}</h3>
<div class="form-group">
<label for="edit-codex-key">${i18n.t('ai_providers.codex_edit_modal_key_label')}</label>
<input type="text" id="edit-codex-key" value="${config['api-key']}">
</div>
<div class="form-group">
<label for="edit-codex-url">${i18n.t('ai_providers.codex_edit_modal_url_label')}</label>
<input type="text" id="edit-codex-url" value="${config['base-url'] || ''}" required>
</div>
<div class="form-group">
<label for="edit-codex-proxy">${i18n.t('ai_providers.codex_edit_modal_proxy_label')}</label>
<input type="text" id="edit-codex-proxy" value="${config['proxy-url'] || ''}">
</div>
<div class="form-group">
<label>${i18n.t('common.custom_headers_label')}</label>
<p class="form-hint">${i18n.t('common.custom_headers_hint')}</p>
<div id="edit-codex-headers-wrapper" class="header-input-list"></div>
<button type="button" class="btn btn-secondary" onclick="manager.addHeaderField('edit-codex-headers-wrapper')">${i18n.t('common.custom_headers_add')}</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.updateCodexKey(${index})">${i18n.t('common.update')}</button>
</div>
`;
modal.style.display = 'block';
this.populateHeaderFields('edit-codex-headers-wrapper', config.headers || null);
}
export async function updateCodexKey(index) {
const apiKey = document.getElementById('edit-codex-key').value.trim();
const baseUrl = document.getElementById('edit-codex-url').value.trim();
const proxyUrl = document.getElementById('edit-codex-proxy').value.trim();
const headers = this.collectHeaderInputs('edit-codex-headers-wrapper');
if (!apiKey) {
this.showNotification(i18n.t('notification.field_required'), 'error');
return;
}
if (!baseUrl) {
this.showNotification(i18n.t('notification.codex_base_url_required'), 'error');
return;
}
try {
const listResponse = await this.makeRequest('/codex-api-key');
const currentList = this.normalizeArrayResponse(listResponse, 'codex-api-key');
if (!Array.isArray(currentList) || index < 0 || index >= currentList.length) {
throw new Error('Invalid codex configuration index');
}
const original = currentList[index] ? { ...currentList[index] } : {};
const newConfig = this.buildCodexConfig(apiKey, baseUrl, proxyUrl, original, headers);
await this.makeRequest('/codex-api-key', {
method: 'PATCH',
body: JSON.stringify({ index, value: newConfig })
});
this.clearCache(); // 清除缓存
this.closeModal();
this.loadCodexKeys();
this.showNotification(i18n.t('notification.codex_config_updated'), 'success');
} catch (error) {
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
}
}
export async function deleteCodexKey(apiKey) {
if (!confirm(i18n.t('ai_providers.codex_delete_confirm'))) return;
try {
await this.makeRequest(`/codex-api-key?api-key=${encodeURIComponent(apiKey)}`, { method: 'DELETE' });
this.clearCache(); // 清除缓存
this.loadCodexKeys();
this.showNotification(i18n.t('notification.codex_config_deleted'), 'success');
} catch (error) {
this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error');
}
}
// Claude providers
export async function loadClaudeKeys() {
try {
const config = await this.getConfig();
const keys = Array.isArray(config['claude-api-key']) ? config['claude-api-key'] : [];
await this.renderClaudeKeys(keys);
} catch (error) {
console.error('加载Claude密钥失败:', error);
}
}
export async function renderClaudeKeys(keys, keyStats = null) {
const container = document.getElementById('claude-keys-list');
if (!container) {
return;
}
const list = Array.isArray(keys) ? keys : [];
if (list.length === 0) {
container.innerHTML = `
<div class="empty-state">
<i class="fas fa-brain"></i>
<h3>${i18n.t('ai_providers.claude_empty_title')}</h3>
<p>${i18n.t('ai_providers.claude_empty_desc')}</p>
</div>
`;
return;
}
// 使用传入的keyStats如果没有则获取一次
if (!keyStats) {
keyStats = await this.getKeyStats();
}
const statsBySource = getStatsBySource(keyStats);
container.innerHTML = list.map((config, index) => {
const rawKey = config['api-key'] || '';
const masked = this.maskApiKey(rawKey || '');
const maskedDisplay = this.escapeHtml(masked);
const usageStats = (rawKey && (statsBySource[rawKey] || statsBySource[masked])) || { success: 0, failure: 0 };
const deleteArg = JSON.stringify(rawKey).replace(/"/g, '&quot;');
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')}: ${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')}: ${usageStats.success}
</span>
<span class="stat-badge stat-failure">
<i class="fas fa-times-circle"></i> ${i18n.t('stats.failure')}: ${usageStats.failure}
</span>
</div>
</div>
<div class="item-actions">
<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(${deleteArg})">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`;
}).join('');
}
export function showAddClaudeKeyModal() {
const modal = document.getElementById('modal');
const modalBody = document.getElementById('modal-body');
modalBody.innerHTML = `
<h3>${i18n.t('ai_providers.claude_add_modal_title')}</h3>
<div class="form-group">
<label for="new-claude-key">${i18n.t('ai_providers.claude_add_modal_key_label')}</label>
<input type="text" id="new-claude-key" placeholder="${i18n.t('ai_providers.claude_add_modal_key_placeholder')}">
</div>
<div class="form-group">
<label for="new-claude-url">${i18n.t('ai_providers.claude_add_modal_url_label')}</label>
<input type="text" id="new-claude-url" placeholder="${i18n.t('ai_providers.claude_add_modal_url_placeholder')}">
</div>
<div class="form-group">
<label for="new-claude-proxy">${i18n.t('ai_providers.claude_add_modal_proxy_label')}</label>
<input type="text" id="new-claude-proxy" placeholder="${i18n.t('ai_providers.claude_add_modal_proxy_placeholder')}">
</div>
<div class="form-group">
<label>${i18n.t('common.custom_headers_label')}</label>
<p class="form-hint">${i18n.t('common.custom_headers_hint')}</p>
<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>
</div>
`;
modal.style.display = 'block';
this.populateHeaderFields('new-claude-headers-wrapper');
this.populateModelFields('new-claude-models-wrapper');
}
export async function addClaudeKey() {
const apiKey = document.getElementById('new-claude-key').value.trim();
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');
return;
}
try {
const data = await this.makeRequest('/claude-api-key');
const currentKeys = data['claude-api-key'] || [];
const newConfig = { 'api-key': apiKey };
if (baseUrl) {
newConfig['base-url'] = baseUrl;
}
if (proxyUrl) {
newConfig['proxy-url'] = proxyUrl;
}
this.applyHeadersToConfig(newConfig, headers);
if (models.length) {
newConfig.models = models;
}
currentKeys.push(newConfig);
await this.makeRequest('/claude-api-key', {
method: 'PUT',
body: JSON.stringify(currentKeys)
});
this.clearCache(); // 清除缓存
this.closeModal();
this.loadClaudeKeys();
this.showNotification(i18n.t('notification.claude_config_added'), 'success');
} catch (error) {
this.showNotification(`${i18n.t('notification.add_failed')}: ${error.message}`, 'error');
}
}
export function editClaudeKey(index, config) {
const modal = document.getElementById('modal');
const modalBody = document.getElementById('modal-body');
modalBody.innerHTML = `
<h3>${i18n.t('ai_providers.claude_edit_modal_title')}</h3>
<div class="form-group">
<label for="edit-claude-key">${i18n.t('ai_providers.claude_edit_modal_key_label')}</label>
<input type="text" id="edit-claude-key" value="${config['api-key']}">
</div>
<div class="form-group">
<label for="edit-claude-url">${i18n.t('ai_providers.claude_edit_modal_url_label')}</label>
<input type="text" id="edit-claude-url" value="${config['base-url'] || ''}">
</div>
<div class="form-group">
<label for="edit-claude-proxy">${i18n.t('ai_providers.claude_edit_modal_proxy_label')}</label>
<input type="text" id="edit-claude-proxy" value="${config['proxy-url'] || ''}">
</div>
<div class="form-group">
<label>${i18n.t('common.custom_headers_label')}</label>
<p class="form-hint">${i18n.t('common.custom_headers_hint')}</p>
<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>
</div>
`;
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 : []);
}
export async function updateClaudeKey(index) {
const apiKey = document.getElementById('edit-claude-key').value.trim();
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');
return;
}
try {
const newConfig = { 'api-key': apiKey };
if (baseUrl) {
newConfig['base-url'] = baseUrl;
}
if (proxyUrl) {
newConfig['proxy-url'] = proxyUrl;
}
this.applyHeadersToConfig(newConfig, headers);
if (models.length) {
newConfig.models = models;
}
await this.makeRequest('/claude-api-key', {
method: 'PATCH',
body: JSON.stringify({ index, value: newConfig })
});
this.clearCache(); // 清除缓存
this.closeModal();
this.loadClaudeKeys();
this.showNotification(i18n.t('notification.claude_config_updated'), 'success');
} catch (error) {
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
}
}
export async function deleteClaudeKey(apiKey) {
if (!confirm(i18n.t('ai_providers.claude_delete_confirm'))) return;
try {
await this.makeRequest(`/claude-api-key?api-key=${encodeURIComponent(apiKey)}`, { method: 'DELETE' });
this.clearCache(); // 清除缓存
this.loadClaudeKeys();
this.showNotification(i18n.t('notification.claude_config_deleted'), 'success');
} catch (error) {
this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error');
}
}
// OpenAI compatible providers
export async function loadOpenAIProviders() {
try {
const config = await this.getConfig();
const providers = Array.isArray(config['openai-compatibility']) ? config['openai-compatibility'] : [];
await this.renderOpenAIProviders(providers);
} catch (error) {
console.error('加载OpenAI提供商失败:', error);
}
}
export async function renderOpenAIProviders(providers, keyStats = null) {
const container = document.getElementById('openai-providers-list');
if (!container) {
return;
}
const list = Array.isArray(providers) ? providers : [];
if (list.length === 0) {
container.innerHTML = `
<div class="empty-state">
<i class="fas fa-plug"></i>
<h3>${i18n.t('ai_providers.openai_empty_title')}</h3>
<p>${i18n.t('ai_providers.openai_empty_desc')}</p>
</div>
`;
// 重置样式
container.style.maxHeight = '';
container.style.overflowY = '';
return;
}
// 根据提供商数量设置滚动条
if (list.length > 5) {
container.style.maxHeight = '400px';
container.style.overflowY = 'auto';
} else {
container.style.maxHeight = '';
container.style.overflowY = '';
}
// 使用传入的keyStats如果没有则获取一次
if (!keyStats) {
keyStats = await this.getKeyStats();
}
const statsBySource = getStatsBySource(keyStats);
container.innerHTML = list.map((provider, index) => {
const item = typeof provider === 'object' && provider !== null ? provider : {};
// 处理两种API密钥格式新的 api-key-entries 和旧的 api-keys
let apiKeyEntries = [];
if (Array.isArray(item['api-key-entries'])) {
// 新格式:{api-key: "...", proxy-url: "..."}
apiKeyEntries = item['api-key-entries'];
} else if (Array.isArray(item['api-keys'])) {
// 旧格式:简单的字符串数组
apiKeyEntries = item['api-keys'].map(key => ({ 'api-key': key }));
}
const models = Array.isArray(item.models) ? item.models : [];
const name = item.name || '';
const baseUrl = item['base-url'] || '';
let totalSuccess = 0;
let totalFailure = 0;
apiKeyEntries.forEach(entry => {
const key = entry && entry['api-key'] ? entry['api-key'] : '';
if (!key) return;
const masked = this.maskApiKey(key);
const usageStats = statsBySource[key] || statsBySource[masked] || { success: 0, failure: 0 };
totalSuccess += usageStats.success;
totalFailure += usageStats.failure;
});
const deleteArg = JSON.stringify(name).replace(/"/g, '&quot;');
return `
<div class="provider-item">
<div class="item-content">
<div class="item-title">${this.escapeHtml(name)}</div>
<div class="item-subtitle">${i18n.t('common.base_url')}: ${this.escapeHtml(baseUrl)}</div>
${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.renderModelBadges(models)}
<div class="item-stats">
<span class="stat-badge stat-success">
<i class="fas fa-check-circle"></i> ${i18n.t('stats.success')}: ${totalSuccess}
</span>
<span class="stat-badge stat-failure">
<i class="fas fa-times-circle"></i> ${i18n.t('stats.failure')}: ${totalFailure}
</span>
</div>
</div>
<div class="item-actions">
<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(${deleteArg})">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`;
}).join('');
}
const getOpenAIContext = (mode = 'new') => {
const isEdit = mode === 'edit';
return {
mode: isEdit ? 'edit' : 'new',
baseUrlInputId: isEdit ? 'edit-provider-url' : 'new-provider-url',
apiKeyWrapperId: isEdit ? 'edit-openai-keys-wrapper' : 'new-openai-keys-wrapper',
headerWrapperId: isEdit ? 'edit-openai-headers-wrapper' : 'new-openai-headers-wrapper',
modelWrapperId: isEdit ? 'edit-provider-models-wrapper' : 'new-provider-models-wrapper'
};
};
function ensureOpenAIModelDiscoveryCard(manager) {
let overlay = document.getElementById('openai-model-discovery');
if (overlay) {
return overlay;
}
overlay = document.createElement('div');
overlay.id = 'openai-model-discovery';
overlay.className = 'model-discovery-overlay';
overlay.innerHTML = `
<div class="model-discovery-card">
<div class="model-discovery-header">
<div class="model-discovery-title">
<h3>${i18n.t('ai_providers.openai_models_fetch_title')}</h3>
<p class="form-hint">${i18n.t('ai_providers.openai_models_fetch_hint')}</p>
</div>
<button type="button" class="btn btn-secondary" id="openai-model-discovery-back">${i18n.t('ai_providers.openai_models_fetch_back')}</button>
</div>
<div class="form-group">
<label>${i18n.t('ai_providers.openai_models_fetch_url_label')}</label>
<div class="input-group">
<input type="text" id="openai-model-discovery-url" readonly>
<button type="button" class="btn btn-secondary" id="openai-model-discovery-refresh">${i18n.t('ai_providers.openai_models_fetch_refresh')}</button>
</div>
</div>
<div id="openai-model-discovery-status" class="model-discovery-status"></div>
<div id="openai-model-discovery-list" class="model-discovery-list"></div>
<div class="modal-actions">
<button class="btn btn-secondary" id="openai-model-discovery-cancel">${i18n.t('common.cancel')}</button>
<button class="btn btn-primary" id="openai-model-discovery-apply">${i18n.t('ai_providers.openai_models_fetch_apply')}</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const bind = (id, handler) => {
const el = document.getElementById(id);
if (el) {
el.addEventListener('click', handler);
}
};
bind('openai-model-discovery-back', () => manager.closeOpenAIModelDiscovery());
bind('openai-model-discovery-cancel', () => manager.closeOpenAIModelDiscovery());
bind('openai-model-discovery-refresh', () => manager.refreshOpenAIModelDiscovery());
bind('openai-model-discovery-apply', () => manager.applyOpenAIModelDiscoverySelection());
return overlay;
}
export function setOpenAIModelDiscoveryStatus(message = '', type = 'info') {
const status = document.getElementById('openai-model-discovery-status');
if (!status) return;
status.textContent = message;
status.className = `model-discovery-status ${type}`;
}
export function renderOpenAIModelDiscoveryList(models = []) {
const list = document.getElementById('openai-model-discovery-list');
if (!list) return;
if (!models.length) {
list.innerHTML = `
<div class="model-discovery-empty">
<i class="fas fa-box-open"></i>
<span>${i18n.t('ai_providers.openai_models_fetch_empty')}</span>
</div>
`;
return;
}
list.innerHTML = models.map((model, index) => {
const name = this.escapeHtml(model.name || '');
const alias = model.alias ? `<span class="model-discovery-alias">${this.escapeHtml(model.alias)}</span>` : '';
const desc = model.description ? `<div class="model-discovery-desc">${this.escapeHtml(model.description)}</div>` : '';
return `
<label class="model-discovery-row">
<input type="checkbox" class="model-discovery-checkbox" data-model-index="${index}">
<div class="model-discovery-meta">
<div class="model-discovery-name">${name} ${alias}</div>
${desc}
</div>
</label>
`;
}).join('');
}
export function openOpenAIModelDiscovery(mode = 'new') {
const context = getOpenAIContext(mode);
const baseInput = document.getElementById(context.baseUrlInputId);
const baseUrl = baseInput ? baseInput.value.trim() : '';
if (!baseUrl) {
this.showNotification(i18n.t('ai_providers.openai_models_fetch_invalid_url'), 'error');
return;
}
const endpoint = buildModelEndpoint(baseUrl);
if (!endpoint) {
this.showNotification(i18n.t('ai_providers.openai_models_fetch_invalid_url'), 'error');
return;
}
const apiKeyEntries = this.collectApiKeyEntryInputs(context.apiKeyWrapperId);
const firstKey = Array.isArray(apiKeyEntries) ? apiKeyEntries.find(entry => entry && entry['api-key']) : null;
const headers = this.collectHeaderInputs(context.headerWrapperId) || {};
if (firstKey && !headers.Authorization && !headers.authorization) {
headers.Authorization = `Bearer ${firstKey['api-key']}`;
}
ensureOpenAIModelDiscoveryCard(this).classList.add('active');
this.openAIModelDiscoveryContext = {
...context,
endpoint,
headers,
discoveredModels: []
};
const urlInput = document.getElementById('openai-model-discovery-url');
if (urlInput) {
urlInput.value = endpoint;
}
this.renderOpenAIModelDiscoveryList([]);
this.setOpenAIModelDiscoveryStatus(i18n.t('ai_providers.openai_models_fetch_loading'), 'info');
this.refreshOpenAIModelDiscovery();
}
export async function refreshOpenAIModelDiscovery() {
const context = this.openAIModelDiscoveryContext;
if (!context || !context.endpoint) {
return;
}
this.setOpenAIModelDiscoveryStatus(i18n.t('ai_providers.openai_models_fetch_loading'), 'info');
const list = document.getElementById('openai-model-discovery-list');
if (list) {
list.innerHTML = '<div class="model-discovery-empty"><i class="fas fa-spinner fa-spin"></i></div>';
}
try {
const response = await fetch(context.endpoint, {
headers: context.headers || {}
});
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
let data;
try {
data = await response.json();
} catch (err) {
const text = await response.text();
throw new Error(text || err.message || 'Invalid JSON');
}
const models = normalizeModelList(data);
context.discoveredModels = models;
this.renderOpenAIModelDiscoveryList(models);
if (!models.length) {
this.setOpenAIModelDiscoveryStatus(i18n.t('ai_providers.openai_models_fetch_empty'), 'warning');
} else {
this.setOpenAIModelDiscoveryStatus('', 'info');
}
} catch (error) {
context.discoveredModels = [];
this.renderOpenAIModelDiscoveryList([]);
this.setOpenAIModelDiscoveryStatus(`${i18n.t('ai_providers.openai_models_fetch_error')}: ${error.message}`, 'error');
}
}
export function applyOpenAIModelDiscoverySelection() {
const context = this.openAIModelDiscoveryContext;
if (!context || !Array.isArray(context.discoveredModels) || !context.discoveredModels.length) {
this.closeOpenAIModelDiscovery();
return;
}
const list = document.getElementById('openai-model-discovery-list');
if (!list) {
this.closeOpenAIModelDiscovery();
return;
}
const selectedIndices = Array.from(list.querySelectorAll('.model-discovery-checkbox:checked'))
.map(input => Number.parseInt(input.getAttribute('data-model-index') || '-1', 10))
.filter(index => Number.isFinite(index) && index >= 0 && index < context.discoveredModels.length);
const selectedModels = selectedIndices.map(index => context.discoveredModels[index]);
if (!selectedModels.length) {
this.closeOpenAIModelDiscovery();
return;
}
const existing = this.collectModelInputs(context.modelWrapperId);
const mergedMap = new Map();
existing.forEach(model => {
if (model && model.name) {
mergedMap.set(model.name, { ...model });
}
});
let addedCount = 0;
selectedModels.forEach(model => {
const name = model && model.name;
if (!name) return;
if (!mergedMap.has(name)) {
mergedMap.set(name, { name, ...(model.alias ? { alias: model.alias } : {}) });
addedCount++;
}
});
this.populateModelFields(context.modelWrapperId, Array.from(mergedMap.values()));
this.closeOpenAIModelDiscovery();
if (addedCount > 0) {
const template = i18n.t('ai_providers.openai_models_fetch_added');
const message = template.replace('{count}', addedCount);
this.showNotification(message, 'success');
}
}
export function closeOpenAIModelDiscovery() {
const overlay = document.getElementById('openai-model-discovery');
if (overlay) {
overlay.classList.remove('active');
}
this.openAIModelDiscoveryContext = null;
}
export function showAddOpenAIProviderModal() {
const modal = document.getElementById('modal');
const modalBody = document.getElementById('modal-body');
modalBody.innerHTML = `
<h3>${i18n.t('ai_providers.openai_add_modal_title')}</h3>
<div class="form-group">
<label for="new-provider-name">${i18n.t('ai_providers.openai_add_modal_name_label')}</label>
<input type="text" id="new-provider-name" placeholder="${i18n.t('ai_providers.openai_add_modal_name_placeholder')}">
</div>
<div class="form-group">
<label for="new-provider-url">${i18n.t('ai_providers.openai_add_modal_url_label')}</label>
<input type="text" id="new-provider-url" placeholder="${i18n.t('ai_providers.openai_add_modal_url_placeholder')}">
</div>
<div class="form-group">
<label>${i18n.t('ai_providers.openai_add_modal_keys_label')}</label>
<p class="form-hint">${i18n.t('ai_providers.openai_keys_hint')}</p>
<div id="new-openai-keys-wrapper" class="api-key-input-list"></div>
<button type="button" class="btn btn-secondary" onclick="manager.addApiKeyEntryField('new-openai-keys-wrapper')">${i18n.t('ai_providers.openai_keys_add_btn')}</button>
</div>
<div class="form-group">
<label>${i18n.t('common.custom_headers_label')}</label>
<p class="form-hint">${i18n.t('common.custom_headers_hint')}</p>
<div id="new-openai-headers-wrapper" class="header-input-list"></div>
<button type="button" class="btn btn-secondary" onclick="manager.addHeaderField('new-openai-headers-wrapper')">${i18n.t('common.custom_headers_add')}</button>
</div>
<div class="form-group">
<label>${i18n.t('ai_providers.openai_add_modal_models_label')}</label>
<p class="form-hint">${i18n.t('ai_providers.openai_models_hint')}</p>
<div id="new-provider-models-wrapper" class="model-input-list"></div>
<div class="model-actions-inline">
<button type="button" class="btn btn-secondary" onclick="manager.addModelField('new-provider-models-wrapper')">${i18n.t('ai_providers.openai_models_add_btn')}</button>
<button type="button" class="btn btn-secondary" onclick="manager.openOpenAIModelDiscovery('new')">
<i class="fas fa-download"></i> ${i18n.t('ai_providers.openai_models_fetch_button')}
</button>
</div>
</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.addOpenAIProvider()">${i18n.t('common.add')}</button>
</div>
`;
modal.style.display = 'block';
this.populateModelFields('new-provider-models-wrapper', []);
this.populateHeaderFields('new-openai-headers-wrapper');
this.populateApiKeyEntryFields('new-openai-keys-wrapper');
}
export async function addOpenAIProvider() {
const name = document.getElementById('new-provider-name').value.trim();
const baseUrl = document.getElementById('new-provider-url').value.trim();
const apiKeyEntries = this.collectApiKeyEntryInputs('new-openai-keys-wrapper');
const models = this.collectModelInputs('new-provider-models-wrapper');
const headers = this.collectHeaderInputs('new-openai-headers-wrapper');
if (!this.validateOpenAIProviderInput(name, baseUrl, models)) {
return;
}
try {
const data = await this.makeRequest('/openai-compatibility');
const currentProviders = data['openai-compatibility'] || [];
const newProvider = {
name,
'base-url': baseUrl,
'api-key-entries': apiKeyEntries,
models
};
this.applyHeadersToConfig(newProvider, headers);
currentProviders.push(newProvider);
await this.makeRequest('/openai-compatibility', {
method: 'PUT',
body: JSON.stringify(currentProviders)
});
this.clearCache(); // 清除缓存
this.closeModal();
this.loadOpenAIProviders();
this.showNotification(i18n.t('notification.openai_provider_added'), 'success');
} catch (error) {
this.showNotification(`${i18n.t('notification.add_failed')}: ${error.message}`, 'error');
}
}
export function editOpenAIProvider(index, provider) {
const modal = document.getElementById('modal');
const modalBody = document.getElementById('modal-body');
// 处理两种API密钥格式新的 api-key-entries 和旧的 api-keys
let apiKeyEntries = [];
if (Array.isArray(provider?.['api-key-entries'])) {
// 新格式:{api-key: "...", proxy-url: "..."}
apiKeyEntries = provider['api-key-entries'];
} else if (Array.isArray(provider?.['api-keys'])) {
// 旧格式:简单的字符串数组
apiKeyEntries = provider['api-keys'].map(key => ({ 'api-key': key, 'proxy-url': '' }));
}
const models = Array.isArray(provider?.models) ? provider.models : [];
modalBody.innerHTML = `
<h3>${i18n.t('ai_providers.openai_edit_modal_title')}</h3>
<div class="form-group">
<label for="edit-provider-name">${i18n.t('ai_providers.openai_edit_modal_name_label')}</label>
<input type="text" id="edit-provider-name" value="${provider?.name ? this.escapeHtml(provider.name) : ''}">
</div>
<div class="form-group">
<label for="edit-provider-url">${i18n.t('ai_providers.openai_edit_modal_url_label')}</label>
<input type="text" id="edit-provider-url" value="${provider?.['base-url'] ? this.escapeHtml(provider['base-url']) : ''}">
</div>
<div class="form-group">
<label>${i18n.t('ai_providers.openai_edit_modal_keys_label')}</label>
<p class="form-hint">${i18n.t('ai_providers.openai_keys_hint')}</p>
<div id="edit-openai-keys-wrapper" class="api-key-input-list"></div>
<button type="button" class="btn btn-secondary" onclick="manager.addApiKeyEntryField('edit-openai-keys-wrapper')">${i18n.t('ai_providers.openai_keys_add_btn')}</button>
</div>
<div class="form-group">
<label>${i18n.t('common.custom_headers_label')}</label>
<p class="form-hint">${i18n.t('common.custom_headers_hint')}</p>
<div id="edit-openai-headers-wrapper" class="header-input-list"></div>
<button type="button" class="btn btn-secondary" onclick="manager.addHeaderField('edit-openai-headers-wrapper')">${i18n.t('common.custom_headers_add')}</button>
</div>
<div class="form-group">
<label>${i18n.t('ai_providers.openai_edit_modal_models_label')}</label>
<p class="form-hint">${i18n.t('ai_providers.openai_models_hint')}</p>
<div id="edit-provider-models-wrapper" class="model-input-list"></div>
<div class="model-actions-inline">
<button type="button" class="btn btn-secondary" onclick="manager.addModelField('edit-provider-models-wrapper')">${i18n.t('ai_providers.openai_models_add_btn')}</button>
<button type="button" class="btn btn-secondary" onclick="manager.openOpenAIModelDiscovery('edit')">
<i class="fas fa-download"></i> ${i18n.t('ai_providers.openai_models_fetch_button')}
</button>
</div>
</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.updateOpenAIProvider(${index})">${i18n.t('common.update')}</button>
</div>
`;
modal.style.display = 'block';
this.populateModelFields('edit-provider-models-wrapper', models);
this.populateHeaderFields('edit-openai-headers-wrapper', provider?.headers || null);
this.populateApiKeyEntryFields('edit-openai-keys-wrapper', apiKeyEntries);
}
export async function updateOpenAIProvider(index) {
const name = document.getElementById('edit-provider-name').value.trim();
const baseUrl = document.getElementById('edit-provider-url').value.trim();
const apiKeyEntries = this.collectApiKeyEntryInputs('edit-openai-keys-wrapper');
const models = this.collectModelInputs('edit-provider-models-wrapper');
const headers = this.collectHeaderInputs('edit-openai-headers-wrapper');
if (!this.validateOpenAIProviderInput(name, baseUrl, models)) {
return;
}
try {
const updatedProvider = {
name,
'base-url': baseUrl,
'api-key-entries': apiKeyEntries,
models
};
this.applyHeadersToConfig(updatedProvider, headers);
await this.makeRequest('/openai-compatibility', {
method: 'PATCH',
body: JSON.stringify({ index, value: updatedProvider })
});
this.clearCache(); // 清除缓存
this.closeModal();
this.loadOpenAIProviders();
this.showNotification(i18n.t('notification.openai_provider_updated'), 'success');
} catch (error) {
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
}
}
export async function deleteOpenAIProvider(name) {
if (!confirm(i18n.t('ai_providers.openai_delete_confirm'))) return;
try {
await this.makeRequest(`/openai-compatibility?name=${encodeURIComponent(name)}`, { method: 'DELETE' });
this.clearCache(); // 清除缓存
this.loadOpenAIProviders();
this.showNotification(i18n.t('notification.openai_provider_deleted'), 'success');
} catch (error) {
this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error');
}
}
export function addModelField(wrapperId, model = {}) {
const wrapper = document.getElementById(wrapperId);
if (!wrapper) return;
const row = document.createElement('div');
row.className = 'model-input-row';
row.innerHTML = `
<div class="input-group">
<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>
`;
const removeBtn = row.querySelector('.model-remove-btn');
if (removeBtn) {
removeBtn.addEventListener('click', () => {
wrapper.removeChild(row);
});
}
wrapper.appendChild(row);
}
export function populateModelFields(wrapperId, models = []) {
const wrapper = document.getElementById(wrapperId);
if (!wrapper) return;
wrapper.innerHTML = '';
if (!models.length) {
this.addModelField(wrapperId);
return;
}
models.forEach(model => this.addModelField(wrapperId, model));
}
export function collectModelInputs(wrapperId) {
const wrapper = document.getElementById(wrapperId);
if (!wrapper) return [];
const rows = Array.from(wrapper.querySelectorAll('.model-input-row'));
const models = [];
rows.forEach(row => {
const nameInput = row.querySelector('.model-name-input');
const aliasInput = row.querySelector('.model-alias-input');
const name = nameInput ? nameInput.value.trim() : '';
const alias = aliasInput ? aliasInput.value.trim() : '';
if (name) {
const model = { name };
if (alias) {
model.alias = alias;
}
models.push(model);
}
});
return models;
}
export function renderModelBadges(models) {
if (!models || models.length === 0) {
return '';
}
return `
<div class="provider-models">
${models.map(model => `
<span class="provider-model-tag">
<span class="model-name">${this.escapeHtml(model.name || '')}</span>
${model.alias ? `<span class="model-alias">${this.escapeHtml(model.alias)}</span>` : ''}
</span>
`).join('')}
</div>
`;
}
export function validateOpenAIProviderInput(name, baseUrl, models) {
if (!name || !baseUrl) {
this.showNotification(i18n.t('notification.openai_provider_required'), 'error');
return false;
}
const invalidModel = models.find(model => !model.name);
if (invalidModel) {
this.showNotification(i18n.t('notification.openai_model_name_required'), 'error');
return false;
}
return true;
}
export const aiProvidersModule = {
loadGeminiKeys,
getGeminiKeysFromConfig,
renderGeminiKeys,
showAddGeminiKeyModal,
addGeminiKey,
addGeminiKeyField,
populateGeminiKeyFields,
collectGeminiKeyFieldInputs,
editGeminiKey,
updateGeminiKey,
deleteGeminiKey,
loadCodexKeys,
renderCodexKeys,
showAddCodexKeyModal,
addCodexKey,
editCodexKey,
updateCodexKey,
deleteCodexKey,
loadClaudeKeys,
renderClaudeKeys,
showAddClaudeKeyModal,
addClaudeKey,
editClaudeKey,
updateClaudeKey,
deleteClaudeKey,
loadOpenAIProviders,
renderOpenAIProviders,
showAddOpenAIProviderModal,
addOpenAIProvider,
editOpenAIProvider,
updateOpenAIProvider,
deleteOpenAIProvider,
openOpenAIModelDiscovery,
refreshOpenAIModelDiscovery,
renderOpenAIModelDiscoveryList,
setOpenAIModelDiscoveryStatus,
applyOpenAIModelDiscoverySelection,
closeOpenAIModelDiscovery,
addModelField,
populateModelFields,
collectModelInputs,
renderModelBadges,
validateOpenAIProviderInput
};