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:
2
public/css/style.css
generated
2
public/css/style.css
generated
File diff suppressed because one or more lines are too long
@@ -12,6 +12,13 @@ window.Components.claudeConfig = () => ({
|
|||||||
restoring: false,
|
restoring: false,
|
||||||
gemini1mSuffix: false,
|
gemini1mSuffix: false,
|
||||||
|
|
||||||
|
// Presets state
|
||||||
|
presets: [],
|
||||||
|
selectedPresetName: '',
|
||||||
|
savingPreset: false,
|
||||||
|
deletingPreset: false,
|
||||||
|
pendingPresetName: '', // For unsaved changes confirmation
|
||||||
|
|
||||||
// Model fields that may contain Gemini model names
|
// Model fields that may contain Gemini model names
|
||||||
geminiModelFields: [
|
geminiModelFields: [
|
||||||
'ANTHROPIC_MODEL',
|
'ANTHROPIC_MODEL',
|
||||||
@@ -25,12 +32,14 @@ window.Components.claudeConfig = () => ({
|
|||||||
// Only fetch config if this is the active sub-tab
|
// Only fetch config if this is the active sub-tab
|
||||||
if (this.activeTab === 'claude') {
|
if (this.activeTab === 'claude') {
|
||||||
this.fetchConfig();
|
this.fetchConfig();
|
||||||
|
this.fetchPresets();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch local activeTab (from parent settings scope, skip initial trigger)
|
// Watch local activeTab (from parent settings scope, skip initial trigger)
|
||||||
this.$watch('activeTab', (tab, oldTab) => {
|
this.$watch('activeTab', (tab, oldTab) => {
|
||||||
if (tab === 'claude' && oldTab !== undefined) {
|
if (tab === 'claude' && oldTab !== undefined) {
|
||||||
this.fetchConfig();
|
this.fetchConfig();
|
||||||
|
this.fetchPresets();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -171,5 +180,237 @@ window.Components.claudeConfig = () => ({
|
|||||||
} finally {
|
} finally {
|
||||||
this.restoring = false;
|
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",
|
claudeSettingsAlertPrefix: "Settings below directly modify",
|
||||||
claudeSettingsAlertSuffix: "Restart Claude CLI to apply.",
|
claudeSettingsAlertSuffix: "Restart Claude CLI to apply.",
|
||||||
applyToClaude: "Apply to Claude CLI",
|
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
|
// Settings - Server
|
||||||
port: "Port",
|
port: "Port",
|
||||||
uiVersion: "UI Version",
|
uiVersion: "UI Version",
|
||||||
@@ -389,6 +407,24 @@ document.addEventListener('alpine:init', () => {
|
|||||||
claudeSettingsAlertPrefix: "以下设置直接修改",
|
claudeSettingsAlertPrefix: "以下设置直接修改",
|
||||||
claudeSettingsAlertSuffix: "重启 Claude CLI 生效。",
|
claudeSettingsAlertSuffix: "重启 Claude CLI 生效。",
|
||||||
applyToClaude: "应用到 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
|
// Settings - Server
|
||||||
port: "端口",
|
port: "端口",
|
||||||
uiVersion: "UI 版本",
|
uiVersion: "UI 版本",
|
||||||
|
|||||||
@@ -204,6 +204,52 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Configuration Presets -->
|
||||||
|
<div class="card bg-space-900/30 border border-neon-cyan/30 p-5">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-neon-cyan" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
||||||
|
</svg>
|
||||||
|
<label class="text-xs uppercase text-neon-cyan font-semibold" x-text="$store.global.t('configPresets') || 'Configuration Presets'">Configuration Presets</label>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-xs btn-ghost text-neon-cyan hover:bg-neon-cyan/10 gap-1"
|
||||||
|
@click="saveCurrentAsPreset()"
|
||||||
|
:disabled="savingPreset">
|
||||||
|
<svg x-show="!savingPreset" xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
<span x-show="!savingPreset" x-text="$store.global.t('saveAsPreset') || 'Save as Preset'">Save as Preset</span>
|
||||||
|
<span x-show="savingPreset" class="loading loading-spinner loading-xs"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select
|
||||||
|
class="select select-sm bg-space-800 border-space-border text-white flex-1 font-mono text-xs"
|
||||||
|
:disabled="presets.length === 0"
|
||||||
|
:value="selectedPresetName"
|
||||||
|
@change="onPresetSelect($event.target.value)"
|
||||||
|
aria-label="Select preset">
|
||||||
|
<option value="" disabled x-show="presets.length === 0">No presets available</option>
|
||||||
|
<template x-for="preset in presets" :key="preset.name">
|
||||||
|
<option :value="preset.name" x-text="preset.name" :selected="preset.name === selectedPresetName"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-sm btn-ghost text-red-400 hover:bg-red-500/10"
|
||||||
|
@click="deleteSelectedPreset()"
|
||||||
|
:disabled="!selectedPresetName || presets.length === 0 || deletingPreset"
|
||||||
|
:title="$store.global.t('deletePreset') || 'Delete preset'">
|
||||||
|
<span x-show="!deletingPreset">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span x-show="deletingPreset" class="loading loading-spinner loading-xs"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-[10px] text-gray-600 mt-2" x-text="$store.global.t('presetHint') || 'Select a preset to load it. Click \"Apply to Claude CLI\" to save changes.'">Select a preset to load it. Click "Apply to Claude CLI" to save changes.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Base URL -->
|
<!-- Base URL -->
|
||||||
<div class="card bg-space-900/30 border border-space-border/50 p-5">
|
<div class="card bg-space-900/30 border border-space-border/50 p-5">
|
||||||
<label class="label text-xs uppercase text-gray-500 font-semibold mb-2"
|
<label class="label text-xs uppercase text-gray-500 font-semibold mb-2"
|
||||||
@@ -570,6 +616,72 @@
|
|||||||
<button>close</button>
|
<button>close</button>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Unsaved Changes Confirmation Modal -->
|
||||||
|
<dialog id="unsaved_changes_modal" class="modal">
|
||||||
|
<div class="modal-box bg-space-900 border-2 border-yellow-500/50">
|
||||||
|
<h3 class="font-bold text-lg text-yellow-400 flex items-center gap-2">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
<span x-text="$store.global.t('unsavedChangesTitle') || 'Unsaved Changes'">Unsaved Changes</span>
|
||||||
|
</h3>
|
||||||
|
<p class="py-4 text-gray-300">
|
||||||
|
<span x-text="$store.global.t('unsavedChangesMessage') || 'Your current configuration doesn\'t match any saved preset.'">Your current configuration doesn't match any saved preset.</span>
|
||||||
|
<br><br>
|
||||||
|
<span class="text-yellow-400/80" x-text="'Load \"' + pendingPresetName + '\" and lose current changes?'"></span>
|
||||||
|
</p>
|
||||||
|
<div class="modal-action">
|
||||||
|
<button class="btn btn-ghost text-gray-400" @click="cancelLoadPreset()"
|
||||||
|
x-text="$store.global.t('cancel')">Cancel</button>
|
||||||
|
<button class="btn bg-yellow-500 hover:bg-yellow-600 border-none text-black" @click="confirmLoadPreset()">
|
||||||
|
<span x-text="$store.global.t('loadAnyway') || 'Load Anyway'">Load Anyway</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button @click="cancelLoadPreset()">close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Save Preset Modal -->
|
||||||
|
<dialog id="save_preset_modal" class="modal" x-data="{ presetName: '' }">
|
||||||
|
<div class="modal-box bg-space-900 border-2 border-neon-cyan/50">
|
||||||
|
<h3 class="font-bold text-lg text-neon-cyan flex items-center gap-2">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
||||||
|
</svg>
|
||||||
|
<span x-text="$store.global.t('savePresetTitle') || 'Save Preset'">Save Preset</span>
|
||||||
|
</h3>
|
||||||
|
<p class="py-2 text-gray-400 text-sm" x-text="$store.global.t('savePresetDesc') || 'Save the current configuration as a reusable preset.'">
|
||||||
|
Save the current configuration as a reusable preset.
|
||||||
|
</p>
|
||||||
|
<div class="form-control mt-4">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text text-gray-300" x-text="$store.global.t('presetName') || 'Preset Name'">Preset Name</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" x-model="presetName"
|
||||||
|
class="input input-sm input-bordered bg-space-800 border-space-border text-white w-full"
|
||||||
|
:placeholder="$store.global.t('presetNamePlaceholder') || 'e.g., My Work Setup'"
|
||||||
|
@keydown.enter="$root.executeSavePreset(presetName); presetName = ''"
|
||||||
|
x-init="$watch('$el.closest(\'dialog\').open', open => { if (open) { presetName = ''; $nextTick(() => $el.focus()) } })"
|
||||||
|
aria-label="Preset name">
|
||||||
|
</div>
|
||||||
|
<div class="modal-action">
|
||||||
|
<button class="btn btn-ghost text-gray-400" @click="presetName = ''; document.getElementById('save_preset_modal').close()"
|
||||||
|
x-text="$store.global.t('cancel')">Cancel</button>
|
||||||
|
<button class="btn bg-neon-cyan hover:bg-cyan-600 border-none text-black"
|
||||||
|
@click="$root.executeSavePreset(presetName); presetName = ''"
|
||||||
|
:disabled="!presetName.trim() || $root.savingPreset"
|
||||||
|
:class="{ 'loading': $root.savingPreset }">
|
||||||
|
<span x-show="!$root.savingPreset" x-text="$store.global.t('savePreset') || 'Save Preset'">Save Preset</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button @click="presetName = ''">close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab 3: Models Configuration -->
|
<!-- Tab 3: Models Configuration -->
|
||||||
|
|||||||
@@ -138,3 +138,139 @@ function deepMerge(target, source) {
|
|||||||
function isObject(item) {
|
function isObject(item) {
|
||||||
return (item && typeof item === 'object' && !Array.isArray(item));
|
return (item && typeof item === 'object' && !Array.isArray(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Claude CLI Presets
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default presets based on README examples
|
||||||
|
*/
|
||||||
|
const DEFAULT_PRESETS = [
|
||||||
|
{
|
||||||
|
name: 'Claude Thinking',
|
||||||
|
config: {
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'test',
|
||||||
|
ANTHROPIC_BASE_URL: 'http://localhost:8080',
|
||||||
|
ANTHROPIC_MODEL: 'claude-opus-4-5-thinking',
|
||||||
|
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-thinking',
|
||||||
|
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||||
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-2.5-flash-lite[1m]',
|
||||||
|
CLAUDE_CODE_SUBAGENT_MODEL: 'claude-sonnet-4-5-thinking',
|
||||||
|
ENABLE_EXPERIMENTAL_MCP_CLI: 'true'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Gemini 1M',
|
||||||
|
config: {
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'test',
|
||||||
|
ANTHROPIC_BASE_URL: 'http://localhost:8080',
|
||||||
|
ANTHROPIC_MODEL: 'gemini-3-pro-high[1m]',
|
||||||
|
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-3-pro-high[1m]',
|
||||||
|
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-3-flash[1m]',
|
||||||
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-2.5-flash-lite[1m]',
|
||||||
|
CLAUDE_CODE_SUBAGENT_MODEL: 'gemini-3-flash[1m]',
|
||||||
|
ENABLE_EXPERIMENTAL_MCP_CLI: 'true'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the path to the presets file
|
||||||
|
* @returns {string} Absolute path to claude-presets.json
|
||||||
|
*/
|
||||||
|
export function getPresetsPath() {
|
||||||
|
return path.join(os.homedir(), '.config', 'antigravity-proxy', 'claude-presets.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read all Claude CLI presets
|
||||||
|
* Creates the file with default presets if it doesn't exist.
|
||||||
|
* @returns {Promise<Array>} Array of preset objects
|
||||||
|
*/
|
||||||
|
export async function readPresets() {
|
||||||
|
const presetsPath = getPresetsPath();
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(presetsPath, 'utf8');
|
||||||
|
if (!content.trim()) return DEFAULT_PRESETS;
|
||||||
|
return JSON.parse(content);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
// Create with defaults
|
||||||
|
try {
|
||||||
|
await fs.mkdir(path.dirname(presetsPath), { recursive: true });
|
||||||
|
await fs.writeFile(presetsPath, JSON.stringify(DEFAULT_PRESETS, null, 2), 'utf8');
|
||||||
|
logger.info(`[ClaudePresets] Created presets file with defaults at ${presetsPath}`);
|
||||||
|
} catch (writeError) {
|
||||||
|
logger.warn(`[ClaudePresets] Could not create presets file: ${writeError.message}`);
|
||||||
|
}
|
||||||
|
return DEFAULT_PRESETS;
|
||||||
|
}
|
||||||
|
if (error instanceof SyntaxError) {
|
||||||
|
logger.error(`[ClaudePresets] Invalid JSON in presets at ${presetsPath}. Returning defaults.`);
|
||||||
|
return DEFAULT_PRESETS;
|
||||||
|
}
|
||||||
|
logger.error(`[ClaudePresets] Failed to read presets at ${presetsPath}:`, error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a preset (add or update)
|
||||||
|
* @param {string} name - Preset name
|
||||||
|
* @param {Object} config - Environment variables to save
|
||||||
|
* @returns {Promise<Array>} Updated array of presets
|
||||||
|
*/
|
||||||
|
export async function savePreset(name, config) {
|
||||||
|
const presetsPath = getPresetsPath();
|
||||||
|
let presets = await readPresets();
|
||||||
|
|
||||||
|
const existingIndex = presets.findIndex(p => p.name === name);
|
||||||
|
const newPreset = { name, config: { ...config } };
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
presets[existingIndex] = newPreset;
|
||||||
|
logger.info(`[ClaudePresets] Updated preset: ${name}`);
|
||||||
|
} else {
|
||||||
|
presets.push(newPreset);
|
||||||
|
logger.info(`[ClaudePresets] Created preset: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.mkdir(path.dirname(presetsPath), { recursive: true });
|
||||||
|
await fs.writeFile(presetsPath, JSON.stringify(presets, null, 2), 'utf8');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[ClaudePresets] Failed to save preset:`, error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return presets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a preset by name
|
||||||
|
* @param {string} name - Preset name to delete
|
||||||
|
* @returns {Promise<Array>} Updated array of presets
|
||||||
|
*/
|
||||||
|
export async function deletePreset(name) {
|
||||||
|
const presetsPath = getPresetsPath();
|
||||||
|
let presets = await readPresets();
|
||||||
|
|
||||||
|
const originalLength = presets.length;
|
||||||
|
presets = presets.filter(p => p.name !== name);
|
||||||
|
|
||||||
|
if (presets.length === originalLength) {
|
||||||
|
logger.warn(`[ClaudePresets] Preset not found: ${name}`);
|
||||||
|
return presets;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.writeFile(presetsPath, JSON.stringify(presets, null, 2), 'utf8');
|
||||||
|
logger.info(`[ClaudePresets] Deleted preset: ${name}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[ClaudePresets] Failed to delete preset:`, error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return presets;
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { fileURLToPath } from 'url';
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { getPublicConfig, saveConfig, config } from '../config.js';
|
import { getPublicConfig, saveConfig, config } from '../config.js';
|
||||||
import { DEFAULT_PORT, ACCOUNT_CONFIG_PATH } from '../constants.js';
|
import { DEFAULT_PORT, ACCOUNT_CONFIG_PATH } from '../constants.js';
|
||||||
import { readClaudeConfig, updateClaudeConfig, replaceClaudeConfig, getClaudeConfigPath } from '../utils/claude-config.js';
|
import { readClaudeConfig, updateClaudeConfig, replaceClaudeConfig, getClaudeConfigPath, readPresets, savePreset, deletePreset } from '../utils/claude-config.js';
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
import { getAuthorizationUrl, completeOAuthFlow, startCallbackServer } from '../auth/oauth.js';
|
import { getAuthorizationUrl, completeOAuthFlow, startCallbackServer } from '../auth/oauth.js';
|
||||||
import { loadAccounts, saveAccounts } from '../account-manager/storage.js';
|
import { loadAccounts, saveAccounts } from '../account-manager/storage.js';
|
||||||
@@ -484,6 +484,59 @@ export function mountWebUI(app, dirname, accountManager) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Claude CLI Presets API
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/claude/presets - Get all saved presets
|
||||||
|
*/
|
||||||
|
app.get('/api/claude/presets', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const presets = await readPresets();
|
||||||
|
res.json({ status: 'ok', presets });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ status: 'error', error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/claude/presets - Save a new preset
|
||||||
|
*/
|
||||||
|
app.post('/api/claude/presets', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, config: presetConfig } = req.body;
|
||||||
|
if (!name || typeof name !== 'string' || !name.trim()) {
|
||||||
|
return res.status(400).json({ status: 'error', error: 'Preset name is required' });
|
||||||
|
}
|
||||||
|
if (!presetConfig || typeof presetConfig !== 'object') {
|
||||||
|
return res.status(400).json({ status: 'error', error: 'Config object is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const presets = await savePreset(name.trim(), presetConfig);
|
||||||
|
res.json({ status: 'ok', presets, message: `Preset "${name}" saved` });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ status: 'error', error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/claude/presets/:name - Delete a preset
|
||||||
|
*/
|
||||||
|
app.delete('/api/claude/presets/:name', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.params;
|
||||||
|
if (!name) {
|
||||||
|
return res.status(400).json({ status: 'error', error: 'Preset name is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const presets = await deletePreset(name);
|
||||||
|
res.json({ status: 'ok', presets, message: `Preset "${name}" deleted` });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ status: 'error', error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/models/config - Update model configuration (hidden/pinned/alias)
|
* POST /api/models/config - Update model configuration (hidden/pinned/alias)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -313,6 +313,103 @@ const tests = [
|
|||||||
}
|
}
|
||||||
return 'app.js loads with all required components';
|
return 'app.js loads with all required components';
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== PRESETS API TESTS ====================
|
||||||
|
{
|
||||||
|
name: 'Presets API GET returns presets array',
|
||||||
|
async run() {
|
||||||
|
const res = await request('/api/claude/presets');
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error(`Expected 200, got ${res.status}`);
|
||||||
|
}
|
||||||
|
const data = JSON.parse(res.data);
|
||||||
|
if (data.status !== 'ok') {
|
||||||
|
throw new Error(`Expected status ok, got ${data.status}`);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(data.presets)) {
|
||||||
|
throw new Error('presets should be an array');
|
||||||
|
}
|
||||||
|
return `Presets API returns ${data.presets.length} preset(s)`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Presets API POST creates new preset',
|
||||||
|
async run() {
|
||||||
|
const testPreset = {
|
||||||
|
name: '__test_preset__',
|
||||||
|
config: {
|
||||||
|
ANTHROPIC_BASE_URL: 'http://localhost:8080',
|
||||||
|
ANTHROPIC_MODEL: 'test-model'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create preset
|
||||||
|
const postRes = await request('/api/claude/presets', {
|
||||||
|
method: 'POST',
|
||||||
|
body: testPreset
|
||||||
|
});
|
||||||
|
if (postRes.status !== 200) {
|
||||||
|
throw new Error(`POST failed with status ${postRes.status}`);
|
||||||
|
}
|
||||||
|
const postData = JSON.parse(postRes.data);
|
||||||
|
if (postData.status !== 'ok') {
|
||||||
|
throw new Error(`POST returned error: ${postData.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it exists
|
||||||
|
const getRes = await request('/api/claude/presets');
|
||||||
|
const getData = JSON.parse(getRes.data);
|
||||||
|
const found = getData.presets.find(p => p.name === '__test_preset__');
|
||||||
|
if (!found) {
|
||||||
|
throw new Error('Created preset not found in list');
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Preset created and verified';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Presets API DELETE removes preset',
|
||||||
|
async run() {
|
||||||
|
// Delete the test preset created above
|
||||||
|
const deleteRes = await request('/api/claude/presets/__test_preset__', {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (deleteRes.status !== 200) {
|
||||||
|
throw new Error(`DELETE failed with status ${deleteRes.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's gone
|
||||||
|
const getRes = await request('/api/claude/presets');
|
||||||
|
const getData = JSON.parse(getRes.data);
|
||||||
|
const found = getData.presets.find(p => p.name === '__test_preset__');
|
||||||
|
if (found) {
|
||||||
|
throw new Error('Deleted preset still exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Preset deleted and verified';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Settings view has presets UI elements',
|
||||||
|
async run() {
|
||||||
|
const res = await request('/views/settings.html');
|
||||||
|
const html = res.data;
|
||||||
|
|
||||||
|
const presetElements = [
|
||||||
|
'selectedPresetName', // Preset dropdown binding
|
||||||
|
'saveCurrentAsPreset', // Save button function
|
||||||
|
'deleteSelectedPreset', // Delete button function
|
||||||
|
'save_preset_modal', // Save modal
|
||||||
|
'configPresets' // Translation key for section title
|
||||||
|
];
|
||||||
|
|
||||||
|
const missing = presetElements.filter(el => !html.includes(el));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(`Missing preset UI elements: ${missing.join(', ')}`);
|
||||||
|
}
|
||||||
|
return 'All preset UI elements present';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user