Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion cmd/desktop/frontend/src/i18n/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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'
]
};
};
10 changes: 9 additions & 1 deletion cmd/desktop/frontend/src/i18n/zh-CN.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ export default {
noEndpoints: '未配置端点。点击"添加端点"开始使用。',
copy: '复制',
copied: '已复制',
cloneSuffix: '(副本)',
cloned: '端点克隆成功',
cloneFailed: '端点克隆失败',
noEndpointAtIdx: '未找到端点',
noEndpointAtIdxWithIndex: '在索引处未找到端点',
invalidIndex: '无效索引',
functionUnavailable: '功能不可用',
current: '当前使用',
switchTo: '切换',
switchFailed: '切换失败',
Expand Down Expand Up @@ -94,6 +101,7 @@ export default {
remarkHelp: '可选:为此端点添加备注说明',
cancel: '取消',
save: '保存',
invalidFormat: '格式无效',
manageTokenPool: '管理 Token Pool',
close: '关闭',
changePort: '修改端口',
Expand Down Expand Up @@ -562,4 +570,4 @@ export default {
'小贴士:保存端点前先点击"测试"可以确保它们能正常工作',
'小贴士:您的 API 密钥和数据都存储在本地,安全可靠',
]
};
};
4 changes: 3 additions & 1 deletion cmd/desktop/frontend/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { initFilterDropdowns, clearAllFilters } from './modules/filters.js'
import { formatTokens } from './utils/format.js'
import {
showAddEndpointModal,
showAddEndpointModalWithPreset,
editEndpoint,
saveEndpoint,
openEndpointTokenPoolFromModal,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -279,4 +281,4 @@ window.closeHistoryModal = async () => {
window.deleteHistoryArchive = async () => {
const { deleteHistoryArchive } = await import('./modules/history.js');
deleteHistoryArchive();
};
};
3 changes: 3 additions & 0 deletions cmd/desktop/frontend/src/modules/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
105 changes: 104 additions & 1 deletion cmd/desktop/frontend/src/modules/endpoints.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -260,6 +273,7 @@ export async function renderEndpoints(endpoints) {
<span class="toggle-slider"></span>
</label>
<button class="btn-card btn-secondary" data-action="test" data-index="${index}">${t('endpoints.test')}</button>
<button class="btn-card btn-secondary" data-action="copy" data-index="${index}">${t('endpoints.copy')}</button>
<button class="btn-card btn-secondary" data-action="edit" data-index="${index}">${t('endpoints.edit')}</button>
<button class="btn-card btn-danger" data-action="delete" data-index="${index}">${t('endpoints.delete')}</button>
</div>
Expand All @@ -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);
Expand Down Expand Up @@ -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 = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em"><path d="M20 6L9 17l-5-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
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');
Expand Down Expand Up @@ -1569,6 +1663,7 @@ function renderCompactView(sortedEndpoints, container, currentEndpointName, isFi
<div class="compact-more-menu">
<button data-action="test" data-index="${index}">🧪 ${t('endpoints.test')}</button>
<button data-action="edit" data-index="${index}">✏️ ${t('endpoints.edit')}</button>
<button data-action="copy" data-index="${index}">📋 ${t('endpoints.copy')}</button>
<button data-action="delete" data-index="${index}" class="danger">🗑️ ${t('endpoints.delete')}</button>
</div>
</div>
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
}
}
}
21 changes: 20 additions & 1 deletion cmd/desktop/frontend/src/modules/modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle>';
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();
Expand Down Expand Up @@ -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');
}
}
}
16 changes: 15 additions & 1 deletion cmd/server/webui/api/endpoints.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,27 @@ 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 {
WriteError(w, http.StatusBadRequest, "Invalid request body")
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),
Expand Down Expand Up @@ -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, "/")
}
}
Loading
Loading