diff --git a/cmd/desktop/frontend/src/i18n/en.js b/cmd/desktop/frontend/src/i18n/en.js index 2a581c1a..fc925bec 100644 --- a/cmd/desktop/frontend/src/i18n/en.js +++ b/cmd/desktop/frontend/src/i18n/en.js @@ -28,6 +28,13 @@ export default { noEndpoints: 'No endpoints configured. Click "Add Endpoint" to get started.', copy: 'Copy', copied: 'Copied', + cloneSuffix: '(Copy)', + cloned: 'Endpoint cloned successfully', + cloneFailed: 'Failed to clone endpoint', + noEndpointAtIdx: 'No endpoint found', + noEndpointAtIdxWithIndex: 'No endpoint found at index', + invalidIndex: 'Invalid index', + functionUnavailable: 'Function not available', current: 'Current', switchTo: 'Switch', switchFailed: 'Switch Failed', @@ -94,6 +101,7 @@ export default { remarkHelp: 'Optional: Add a remark for this endpoint', cancel: 'Cancel', save: 'Save', + invalidFormat: 'Invalid format', manageTokenPool: 'Manage Token Pool', close: 'Close', changePort: 'Change Port', @@ -561,4 +569,4 @@ export default { 'Tip: Test endpoints before saving to ensure they work correctly', 'Tip: Your API keys and data are stored locally, safe and secure' ] -}; +}; \ No newline at end of file diff --git a/cmd/desktop/frontend/src/i18n/zh-CN.js b/cmd/desktop/frontend/src/i18n/zh-CN.js index ce1dbea2..d6cdae53 100644 --- a/cmd/desktop/frontend/src/i18n/zh-CN.js +++ b/cmd/desktop/frontend/src/i18n/zh-CN.js @@ -28,6 +28,13 @@ export default { noEndpoints: '未配置端点。点击"添加端点"开始使用。', copy: '复制', copied: '已复制', + cloneSuffix: '(副本)', + cloned: '端点克隆成功', + cloneFailed: '端点克隆失败', + noEndpointAtIdx: '未找到端点', + noEndpointAtIdxWithIndex: '在索引处未找到端点', + invalidIndex: '无效索引', + functionUnavailable: '功能不可用', current: '当前使用', switchTo: '切换', switchFailed: '切换失败', @@ -94,6 +101,7 @@ export default { remarkHelp: '可选:为此端点添加备注说明', cancel: '取消', save: '保存', + invalidFormat: '格式无效', manageTokenPool: '管理 Token Pool', close: '关闭', changePort: '修改端口', @@ -562,4 +570,4 @@ export default { '小贴士:保存端点前先点击"测试"可以确保它们能正常工作', '小贴士:您的 API 密钥和数据都存储在本地,安全可靠', ] -}; +}; \ No newline at end of file diff --git a/cmd/desktop/frontend/src/main.js b/cmd/desktop/frontend/src/main.js index 00077bb2..39e5c652 100644 --- a/cmd/desktop/frontend/src/main.js +++ b/cmd/desktop/frontend/src/main.js @@ -19,6 +19,7 @@ import { initFilterDropdowns, clearAllFilters } from './modules/filters.js' import { formatTokens } from './utils/format.js' import { showAddEndpointModal, + showAddEndpointModalWithPreset, editEndpoint, saveEndpoint, openEndpointTokenPoolFromModal, @@ -223,6 +224,7 @@ async function loadConfigAndRender() { // Expose functions to window for onclick handlers window.loadConfig = loadConfigAndRender; window.showAddEndpointModal = showAddEndpointModal; +window.showAddEndpointModalWithPreset = showAddEndpointModalWithPreset; window.editEndpoint = editEndpoint; window.saveEndpoint = saveEndpoint; window.openEndpointTokenPoolFromModal = openEndpointTokenPoolFromModal; @@ -279,4 +281,4 @@ window.closeHistoryModal = async () => { window.deleteHistoryArchive = async () => { const { deleteHistoryArchive } = await import('./modules/history.js'); deleteHistoryArchive(); -}; +}; \ No newline at end of file diff --git a/cmd/desktop/frontend/src/modules/config.js b/cmd/desktop/frontend/src/modules/config.js index 67e5864c..e1e13132 100644 --- a/cmd/desktop/frontend/src/modules/config.js +++ b/cmd/desktop/frontend/src/modules/config.js @@ -15,6 +15,9 @@ export async function loadConfig() { const configStr = await window.go.main.App.GetConfig(); const config = JSON.parse(configStr); + // 保存到全局变量,供克隆等功能使用 + window.config = config; + document.getElementById('proxyPort').textContent = config.port; document.getElementById('totalEndpoints').textContent = config.endpoints.length; diff --git a/cmd/desktop/frontend/src/modules/endpoints.js b/cmd/desktop/frontend/src/modules/endpoints.js index 2f76d433..403f1a3b 100644 --- a/cmd/desktop/frontend/src/modules/endpoints.js +++ b/cmd/desktop/frontend/src/modules/endpoints.js @@ -4,6 +4,19 @@ import { getEndpointStats } from './stats.js'; import { toggleEndpoint, testAllEndpointsZeroCost } from './config.js'; import { filterEndpoints, isFilterActive, updateFilterStats } from './filters.js'; +// 提取基础名称,移除副本后缀 +function extractBaseName(name) { + // 移除类似 "(Copy)", "(副本)", "(Copy) 1", "(副本) 1" 等后缀 + // 使用固定的模式匹配,避免在函数内部调用 t() + const copyPattern = /\(Copy\)(?:\s+\d+)?$/; + const chineseCopyPattern = /\(副本\)(?:\s+\d+)?$/; + + let baseName = name.replace(copyPattern, '').trim(); + baseName = baseName.replace(chineseCopyPattern, '').trim(); + + return baseName; +} + const ENDPOINT_TEST_STATUS_KEY = 'ccNexus_endpointTestStatus'; const ENDPOINT_VIEW_MODE_KEY = 'ccNexus_endpointViewMode'; @@ -260,6 +273,7 @@ export async function renderEndpoints(endpoints) { + @@ -281,6 +295,11 @@ export async function renderEndpoints(endpoints) { const idx = parseInt(testBtn.getAttribute('data-index')); window.testEndpoint(idx, testBtn); }); + const copyBtn = item.querySelector('[data-action="copy"]'); + copyBtn.addEventListener('click', () => { + const idx = parseInt(copyBtn.getAttribute('data-index')); + copyEndpointConfig(idx, copyBtn); + }); editBtn.addEventListener('click', () => { const idx = parseInt(editBtn.getAttribute('data-index')); window.editEndpoint(idx); @@ -1308,6 +1327,81 @@ export async function openTokenPoolModal(index, endpointName = '') { } } +// 克隆端点配置(创建副本) +function copyEndpointConfig(index, button) { + const allEndpoints = window.config?.endpoints || []; + + if (index < 0 || index >= allEndpoints.length) { + const errorMsg = `Invalid index ${index} for cloning endpoint. Total endpoints: ${allEndpoints.length} at ${new Date().toISOString()}`; + console.error(errorMsg); + if (typeof window.logError === 'function') { + window.logError(errorMsg); + } + showNotification(t('endpoints.cloneFailed') + ': ' + (t('endpoints.invalidIndex') || `Invalid index ${index}`), 'error'); + return; + } + + const endpoint = allEndpoints[index]; + + if (endpoint) { + const clonedEndpoint = { ...endpoint }; + + const baseName = extractBaseName(endpoint.name); + const copySuffix = '(Copy)'; + + let newName = `${baseName}${copySuffix}`; + let counter = 1; + while (allEndpoints.some(ep => ep.name === newName)) { + newName = `${baseName}${copySuffix} ${counter}`; + counter++; + } + clonedEndpoint.name = newName; + + if (clonedEndpoint.authMode === "token_pool") { + delete clonedEndpoint.apiKey; + } + + const originalHTML = button.innerHTML; + button.innerHTML = ''; + setTimeout(() => { button.innerHTML = originalHTML; }, 1000); + + showNotification(t('endpoints.cloned') || 'Endpoint cloned successfully', 'success'); + + window.clonedEndpointData = clonedEndpoint; + + if (typeof window.showAddEndpointModalWithPreset === 'function') { + try { + window.showAddEndpointModalWithPreset(clonedEndpoint); + } catch (error) { + const errorMsg = `Error calling showAddEndpointModalWithPreset at ${new Date().toISOString()}: ${error.message}\nStack: ${error.stack}`; + console.error(errorMsg); + try { + if (typeof window.logError === 'function') { + window.logError(errorMsg); + } + } catch (logErr) { + console.error('Failed to call logError:', logErr); + } + showNotification(t('endpoints.cloneFailed') + ': ' + error.message || `Failed to clone endpoint: ${error.message}`, 'error'); + } + } else { + const errorMsg = `showAddEndpointModalWithPreset function is not available at ${new Date().toISOString()}`; + console.error(errorMsg); + if (typeof window.logError === 'function') { + window.logError(errorMsg); + } + showNotification(t('endpoints.cloneFailed') + ': ' + t('endpoints.functionUnavailable') || 'Failed to clone endpoint: Function not available', 'error'); + } + } else { + const errorMsg = `Failed to clone endpoint: endpoint data not found at index ${index} at ${new Date().toISOString()}`; + console.error(errorMsg); + if (typeof window.logError === 'function') { + window.logError(errorMsg); + } + showNotification(t('endpoints.cloneFailed') + ': ' + (t('endpoints.noEndpointAtIdxWithIndex') || `No endpoint found at index ${index}`), 'error'); + } +} + export function toggleEndpointPanel() { const panel = document.getElementById('endpointPanel'); const icon = document.getElementById('endpointToggleIcon'); @@ -1569,6 +1663,7 @@ function renderCompactView(sortedEndpoints, container, currentEndpointName, isFi
+
@@ -1658,6 +1753,14 @@ function bindCompactItemEvents(item, index, enabled) { window.testEndpoint(idx, testBtn); }); + // 复制按钮 + const copyBtn = item.querySelector('[data-action="copy"]'); + copyBtn.addEventListener('click', () => { + closeAllDropdowns(); + const idx = parseInt(copyBtn.getAttribute('data-index')); + copyEndpointConfig(idx, copyBtn); +}); + // 编辑按钮 editBtn.addEventListener('click', () => { closeAllDropdowns(); @@ -1945,4 +2048,4 @@ export function updateEndpointStatsIncremental(endpointName, data) { const tooltip = `${t('endpoints.requests')}: ${data.requests} | ${t('endpoints.errors')}: ${data.errors}\n${t('statistics.in')}: ${formatTokens(data.inputTokens)} | ${t('statistics.out')}: ${formatTokens(data.outputTokens)}`; compactStats.title = tooltip; } -} +} \ No newline at end of file diff --git a/cmd/desktop/frontend/src/modules/modal.js b/cmd/desktop/frontend/src/modules/modal.js index 4c9017a3..d94e6f2d 100644 --- a/cmd/desktop/frontend/src/modules/modal.js +++ b/cmd/desktop/frontend/src/modules/modal.js @@ -198,6 +198,25 @@ export function showAddEndpointModal() { document.getElementById('endpointModal').classList.add('active'); } +// 使用预设数据打开添加端点模态框 +export function showAddEndpointModalWithPreset(presetData) { + currentEditIndex = -1; + document.getElementById('modalTitle').textContent = '➕ ' + t('modal.addEndpoint'); + document.getElementById('endpointName').value = presetData.name || ''; + document.getElementById('endpointUrl').value = presetData.apiUrl || ''; + document.getElementById('endpointKey').value = presetData.apiKey || ''; + document.getElementById('endpointKey').type = 'password'; + document.getElementById('eyeIcon').innerHTML = ''; + document.getElementById('endpointAuthMode').value = presetData.authMode || 'api_key'; + document.getElementById('endpointTransformer').value = presetData.transformer || 'claude'; + document.getElementById('endpointModel').value = presetData.model || ''; + document.getElementById('endpointRemark').value = presetData.remark || ''; + handleAuthModeChange(); + updateManageTokenPoolButton(); + handleTransformerChange(); + document.getElementById('endpointModal').classList.add('active'); +} + export async function editEndpoint(index) { currentEditIndex = index; const configStr = await window.go.main.App.GetConfig(); @@ -725,4 +744,4 @@ export function openArticle() { if (window.go?.main?.App) { window.go.main.App.OpenURL('https://mp.weixin.qq.com/s/ohtkyIMd5YC7So1q-gE0og'); } -} +} \ No newline at end of file diff --git a/cmd/server/webui/api/endpoints.go b/cmd/server/webui/api/endpoints.go index 430822a8..857510bd 100644 --- a/cmd/server/webui/api/endpoints.go +++ b/cmd/server/webui/api/endpoints.go @@ -119,6 +119,7 @@ func (h *Handler) createEndpoint(w http.ResponseWriter, r *http.Request) { Transformer string `json:"transformer"` Model string `json:"model"` Remark string `json:"remark"` + CloneFrom string `json:"cloneFrom"` // Clone from existing endpoint name } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -126,6 +127,19 @@ func (h *Handler) createEndpoint(w http.ResponseWriter, r *http.Request) { return } + // If cloning, get API key from source endpoint + if req.CloneFrom != "" && req.APIKey == "" { + endpoints, err := h.storage.GetEndpoints() + if err == nil { + for _, ep := range endpoints { + if ep.Name == req.CloneFrom { + req.APIKey = ep.APIKey + break + } + } + } + } + authMode := config.NormalizeAuthMode(req.AuthMode) normalizedEndpoint := config.Endpoint{ APIUrl: normalizeAPIUrl(req.APIUrl), @@ -525,4 +539,4 @@ func maskAPIKey(key string) string { // normalizeAPIUrl ensures the API URL has the correct format func normalizeAPIUrl(apiUrl string) string { return strings.TrimSuffix(apiUrl, "/") -} +} \ No newline at end of file diff --git a/cmd/server/webui/ui/js/components/endpoints.js b/cmd/server/webui/ui/js/components/endpoints.js index fc41f532..e65dffc1 100644 --- a/cmd/server/webui/ui/js/components/endpoints.js +++ b/cmd/server/webui/ui/js/components/endpoints.js @@ -150,6 +150,9 @@ class Endpoints { + @@ -189,6 +192,11 @@ class Endpoints { btn.addEventListener('click', () => this.showEditModal(btn.dataset.name)); }); + // Clone buttons + document.querySelectorAll('.clone-btn').forEach(btn => { + btn.addEventListener('click', () => this.cloneEndpoint(btn.dataset.name)); + }); + // Delete buttons document.querySelectorAll('.delete-btn').forEach(btn => { btn.addEventListener('click', () => this.deleteEndpoint(btn.dataset.name)); @@ -315,19 +323,29 @@ class Endpoints { } } - showEndpointModal(endpoint) { - const isEdit = !!endpoint; + showEndpointModal(endpoint, isClone = false) { + const isEdit = !!endpoint && !isClone; const modalContainer = document.getElementById('modal-container'); + // For clone mode: show masked value like edit mode + const apiKeyValue = endpoint ? '****' : ''; + const apiKeyPlaceholder = 'sk-...'; + const apiKeyHint = isEdit ? 'Leave as **** to keep existing key' : (isClone ? 'Leave as **** to keep existing key' : ''); + const cloneHiddenInput = isClone ? '' : ''; + const cloneFromValue = endpoint?.cloneFrom || ''; + const cloneFromInput = isClone && cloneFromValue ? `` : ''; + modalContainer.innerHTML = `