feat(webui): add configuration presets for Claude CLI
- Add backend storage logic in `src/utils/claude-config.js` to save/load/delete presets - Add API endpoints (`GET`, `POST`, `DELETE`) for presets in `src/webui/index.js` - Update `public/views/settings.html` with new Presets UI card and modals - Update `public/js/components/claude-config.js` with auto-load logic and unsaved changes protection - Add translations (EN/ZH) for new UI elements in `public/js/store.js` - Add integration tests in `tests/frontend/test-frontend-settings.cjs` - Update compiled CSS Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,13 @@ window.Components.claudeConfig = () => ({
|
||||
restoring: false,
|
||||
gemini1mSuffix: false,
|
||||
|
||||
// Presets state
|
||||
presets: [],
|
||||
selectedPresetName: '',
|
||||
savingPreset: false,
|
||||
deletingPreset: false,
|
||||
pendingPresetName: '', // For unsaved changes confirmation
|
||||
|
||||
// Model fields that may contain Gemini model names
|
||||
geminiModelFields: [
|
||||
'ANTHROPIC_MODEL',
|
||||
@@ -25,12 +32,14 @@ window.Components.claudeConfig = () => ({
|
||||
// Only fetch config if this is the active sub-tab
|
||||
if (this.activeTab === 'claude') {
|
||||
this.fetchConfig();
|
||||
this.fetchPresets();
|
||||
}
|
||||
|
||||
// Watch local activeTab (from parent settings scope, skip initial trigger)
|
||||
this.$watch('activeTab', (tab, oldTab) => {
|
||||
if (tab === 'claude' && oldTab !== undefined) {
|
||||
this.fetchConfig();
|
||||
this.fetchPresets();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -171,5 +180,237 @@ window.Components.claudeConfig = () => ({
|
||||
} finally {
|
||||
this.restoring = false;
|
||||
}
|
||||
},
|
||||
|
||||
// ==========================================
|
||||
// Presets Management
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Fetch all saved presets from the server
|
||||
*/
|
||||
async fetchPresets() {
|
||||
const password = Alpine.store('global').webuiPassword;
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request('/api/claude/presets', {}, password);
|
||||
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
if (data.status === 'ok') {
|
||||
this.presets = data.presets || [];
|
||||
// Auto-select first preset if none selected
|
||||
if (this.presets.length > 0 && !this.selectedPresetName) {
|
||||
this.selectedPresetName = this.presets[0].name;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch presets:', e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load the selected preset into the form (does not save to Claude CLI)
|
||||
*/
|
||||
loadSelectedPreset() {
|
||||
const preset = this.presets.find(p => p.name === this.selectedPresetName);
|
||||
if (!preset) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Merge preset config into current config.env
|
||||
this.config.env = { ...this.config.env, ...preset.config };
|
||||
|
||||
// Update Gemini 1M toggle based on merged config (not just preset)
|
||||
this.gemini1mSuffix = this.detectGemini1mSuffix();
|
||||
|
||||
Alpine.store('global').showToast(
|
||||
Alpine.store('global').t('presetLoaded') || `Preset "${preset.name}" loaded. Click "Apply to Claude CLI" to save.`,
|
||||
'success'
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if current config matches any saved preset
|
||||
* @returns {boolean} True if current config matches a preset
|
||||
*/
|
||||
currentConfigMatchesPreset() {
|
||||
const relevantKeys = [
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'ANTHROPIC_MODEL',
|
||||
'CLAUDE_CODE_SUBAGENT_MODEL',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
'ENABLE_EXPERIMENTAL_MCP_CLI'
|
||||
];
|
||||
|
||||
for (const preset of this.presets) {
|
||||
let matches = true;
|
||||
for (const key of relevantKeys) {
|
||||
const currentVal = this.config.env[key] || '';
|
||||
const presetVal = preset.config[key] || '';
|
||||
if (currentVal !== presetVal) {
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matches) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle preset selection change - auto-load with unsaved changes warning
|
||||
* @param {string} newPresetName - The newly selected preset name
|
||||
*/
|
||||
async onPresetSelect(newPresetName) {
|
||||
if (!newPresetName || newPresetName === this.selectedPresetName) return;
|
||||
|
||||
// Check if current config has unsaved changes (doesn't match any preset)
|
||||
const hasUnsavedChanges = !this.currentConfigMatchesPreset();
|
||||
|
||||
if (hasUnsavedChanges) {
|
||||
// Store pending preset and show confirmation modal
|
||||
this.pendingPresetName = newPresetName;
|
||||
document.getElementById('unsaved_changes_modal').showModal();
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedPresetName = newPresetName;
|
||||
this.loadSelectedPreset();
|
||||
},
|
||||
|
||||
/**
|
||||
* Confirm loading preset despite unsaved changes
|
||||
*/
|
||||
confirmLoadPreset() {
|
||||
document.getElementById('unsaved_changes_modal').close();
|
||||
this.selectedPresetName = this.pendingPresetName;
|
||||
this.pendingPresetName = '';
|
||||
this.loadSelectedPreset();
|
||||
},
|
||||
|
||||
/**
|
||||
* Cancel loading preset - revert dropdown selection
|
||||
*/
|
||||
cancelLoadPreset() {
|
||||
document.getElementById('unsaved_changes_modal').close();
|
||||
// Revert the dropdown to current selection
|
||||
const select = document.querySelector('[aria-label="Select preset"]');
|
||||
if (select) select.value = this.selectedPresetName;
|
||||
this.pendingPresetName = '';
|
||||
},
|
||||
|
||||
/**
|
||||
* Save the current config as a new preset
|
||||
*/
|
||||
async saveCurrentAsPreset() {
|
||||
// Show the save preset modal
|
||||
document.getElementById('save_preset_modal').showModal();
|
||||
},
|
||||
|
||||
/**
|
||||
* Execute preset save after user enters name
|
||||
*/
|
||||
async executeSavePreset(name) {
|
||||
if (!name || !name.trim()) {
|
||||
Alpine.store('global').showToast('Preset name is required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.savingPreset = true;
|
||||
const password = Alpine.store('global').webuiPassword;
|
||||
|
||||
try {
|
||||
// Save only relevant env vars
|
||||
const relevantKeys = [
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'ANTHROPIC_MODEL',
|
||||
'CLAUDE_CODE_SUBAGENT_MODEL',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
'ENABLE_EXPERIMENTAL_MCP_CLI'
|
||||
];
|
||||
const presetConfig = {};
|
||||
relevantKeys.forEach(k => {
|
||||
if (this.config.env[k]) {
|
||||
presetConfig[k] = this.config.env[k];
|
||||
}
|
||||
});
|
||||
|
||||
const { response, newPassword } = await window.utils.request('/api/claude/presets', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name.trim(), config: presetConfig })
|
||||
}, password);
|
||||
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
if (data.status === 'ok') {
|
||||
this.presets = data.presets || [];
|
||||
this.selectedPresetName = name.trim();
|
||||
Alpine.store('global').showToast(
|
||||
Alpine.store('global').t('presetSaved') || `Preset "${name}" saved`,
|
||||
'success'
|
||||
);
|
||||
document.getElementById('save_preset_modal').close();
|
||||
} else {
|
||||
throw new Error(data.error || 'Save failed');
|
||||
}
|
||||
} catch (e) {
|
||||
Alpine.store('global').showToast('Failed to save preset: ' + e.message, 'error');
|
||||
} finally {
|
||||
this.savingPreset = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete the selected preset
|
||||
*/
|
||||
async deleteSelectedPreset() {
|
||||
if (!this.selectedPresetName) {
|
||||
Alpine.store('global').showToast('No preset selected', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm deletion
|
||||
if (!confirm(`Delete preset "${this.selectedPresetName}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.deletingPreset = true;
|
||||
const password = Alpine.store('global').webuiPassword;
|
||||
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request(
|
||||
`/api/claude/presets/${encodeURIComponent(this.selectedPresetName)}`,
|
||||
{ method: 'DELETE' },
|
||||
password
|
||||
);
|
||||
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
if (data.status === 'ok') {
|
||||
this.presets = data.presets || [];
|
||||
// Select first available preset or clear selection
|
||||
this.selectedPresetName = this.presets.length > 0 ? this.presets[0].name : '';
|
||||
Alpine.store('global').showToast(
|
||||
Alpine.store('global').t('presetDeleted') || 'Preset deleted',
|
||||
'success'
|
||||
);
|
||||
} else {
|
||||
throw new Error(data.error || 'Delete failed');
|
||||
}
|
||||
} catch (e) {
|
||||
Alpine.store('global').showToast('Failed to delete preset: ' + e.message, 'error');
|
||||
} finally {
|
||||
this.deletingPreset = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -131,6 +131,24 @@ document.addEventListener('alpine:init', () => {
|
||||
claudeSettingsAlertPrefix: "Settings below directly modify",
|
||||
claudeSettingsAlertSuffix: "Restart Claude CLI to apply.",
|
||||
applyToClaude: "Apply to Claude CLI",
|
||||
// Presets
|
||||
configPresets: "Configuration Presets",
|
||||
saveAsPreset: "Save as Preset",
|
||||
deletePreset: "Delete Preset",
|
||||
loadPreset: "Load preset into form",
|
||||
load: "Load",
|
||||
presetHint: "Select a preset to load it. Click \"Apply to Claude CLI\" to save changes.",
|
||||
presetLoaded: "Preset loaded. Click \"Apply to Claude CLI\" to save.",
|
||||
presetSaved: "Preset saved",
|
||||
presetDeleted: "Preset deleted",
|
||||
unsavedChangesTitle: "Unsaved Changes",
|
||||
unsavedChangesMessage: "Your current configuration doesn't match any saved preset. If you switch, your current unsaved settings will be lost.",
|
||||
loadAnyway: "Load Anyway",
|
||||
savePresetTitle: "Save Preset",
|
||||
savePresetDesc: "Save the current configuration as a reusable preset.",
|
||||
presetName: "Preset Name",
|
||||
presetNamePlaceholder: "e.g., My Work Setup",
|
||||
savePreset: "Save Preset",
|
||||
// Settings - Server
|
||||
port: "Port",
|
||||
uiVersion: "UI Version",
|
||||
@@ -389,6 +407,24 @@ document.addEventListener('alpine:init', () => {
|
||||
claudeSettingsAlertPrefix: "以下设置直接修改",
|
||||
claudeSettingsAlertSuffix: "重启 Claude CLI 生效。",
|
||||
applyToClaude: "应用到 Claude CLI",
|
||||
// Presets
|
||||
configPresets: "配置预设",
|
||||
saveAsPreset: "另存为预设",
|
||||
deletePreset: "删除预设",
|
||||
loadPreset: "加载预设到表单",
|
||||
load: "加载",
|
||||
presetHint: "选择预设以加载。点击“应用到 Claude CLI”以保存更改。",
|
||||
presetLoaded: "预设已加载。点击“应用到 Claude CLI”以保存。",
|
||||
presetSaved: "预设已保存",
|
||||
presetDeleted: "预设已删除",
|
||||
unsavedChangesTitle: "未保存的更改",
|
||||
unsavedChangesMessage: "当前配置与任何已保存的预设都不匹配。如果切换预设,当前未保存的设置将会丢失。",
|
||||
loadAnyway: "仍然加载",
|
||||
savePresetTitle: "保存预设",
|
||||
savePresetDesc: "将当前配置保存为可重复使用的预设。",
|
||||
presetName: "预设名称",
|
||||
presetNamePlaceholder: "例如:工作配置",
|
||||
savePreset: "保存预设",
|
||||
// Settings - Server
|
||||
port: "端口",
|
||||
uiVersion: "UI 版本",
|
||||
|
||||
Reference in New Issue
Block a user