* feat: add i18n support with separate translation files - Extract translations from store.js to separate files for easier management - Add translation files for English (en.js), Indonesian (id.js), Turkish (tr.js), and Chinese (zh.js) - Load translations via window.translations object before Alpine store initialization - Add Bahasa Indonesia option to language selector * feat: translate remaining hardcoded UI strings - Update index.html to use t() for Menu and GitHub labels - Update views to translate Tier, Quota, Live, tier badges, and close button - Update components to use translated error messages and confirmation dialogs - Update utils to use translated validation and error messages - Update app-init.js to use translated OAuth success/error messages
421 lines
15 KiB
JavaScript
421 lines
15 KiB
JavaScript
/**
|
|
* Claude Config Component
|
|
* Registers itself to window.Components for Alpine.js to consume
|
|
*/
|
|
window.Components = window.Components || {};
|
|
|
|
window.Components.claudeConfig = () => ({
|
|
config: { env: {} },
|
|
configPath: '', // Dynamic path from backend
|
|
models: [],
|
|
loading: false,
|
|
restoring: false,
|
|
gemini1mSuffix: false,
|
|
|
|
// Presets state
|
|
presets: [],
|
|
selectedPresetName: '',
|
|
savingPreset: false,
|
|
deletingPreset: false,
|
|
pendingPresetName: '', // For unsaved changes confirmation
|
|
newPresetName: '', // For save preset modal input
|
|
|
|
// Model fields that may contain Gemini model names
|
|
geminiModelFields: [
|
|
'ANTHROPIC_MODEL',
|
|
'CLAUDE_CODE_SUBAGENT_MODEL',
|
|
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
|
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
|
'ANTHROPIC_DEFAULT_HAIKU_MODEL'
|
|
],
|
|
|
|
init() {
|
|
// 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();
|
|
}
|
|
});
|
|
|
|
this.$watch('$store.data.models', (val) => {
|
|
this.models = val || [];
|
|
});
|
|
this.models = Alpine.store('data').models || [];
|
|
},
|
|
|
|
/**
|
|
* Detect if any Gemini model has [1m] suffix
|
|
*/
|
|
detectGemini1mSuffix() {
|
|
for (const field of this.geminiModelFields) {
|
|
const val = this.config.env[field];
|
|
if (val && val.toLowerCase().includes('gemini') && val.includes('[1m]')) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
},
|
|
|
|
/**
|
|
* Toggle [1m] suffix for all Gemini models
|
|
*/
|
|
toggleGemini1mSuffix(enabled) {
|
|
for (const field of this.geminiModelFields) {
|
|
const val = this.config.env[field];
|
|
// Fix: Case-insensitive check for gemini
|
|
if (val && /gemini/i.test(val)) {
|
|
if (enabled && !val.includes('[1m]')) {
|
|
this.config.env[field] = val.trim() + '[1m]';
|
|
} else if (!enabled && val.includes('[1m]')) {
|
|
this.config.env[field] = val.replace(/\s*\[1m\]$/i, '').trim();
|
|
}
|
|
}
|
|
}
|
|
this.gemini1mSuffix = enabled;
|
|
},
|
|
|
|
/**
|
|
* Helper to select a model from the dropdown
|
|
* @param {string} field - The config.env field to update
|
|
* @param {string} modelId - The selected model ID
|
|
*/
|
|
selectModel(field, modelId) {
|
|
if (!this.config.env) this.config.env = {};
|
|
|
|
let finalModelId = modelId;
|
|
// If 1M mode is enabled and it's a Gemini model, append the suffix
|
|
if (this.gemini1mSuffix && modelId.toLowerCase().includes('gemini')) {
|
|
if (!finalModelId.includes('[1m]')) {
|
|
finalModelId = finalModelId.trim() + '[1m]';
|
|
}
|
|
}
|
|
|
|
this.config.env[field] = finalModelId;
|
|
},
|
|
|
|
async fetchConfig() {
|
|
const password = Alpine.store('global').webuiPassword;
|
|
try {
|
|
const { response, newPassword } = await window.utils.request('/api/claude/config', {}, password);
|
|
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
|
|
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
const data = await response.json();
|
|
this.config = data.config || {};
|
|
this.configPath = data.path || '~/.claude/settings.json'; // Save dynamic path
|
|
if (!this.config.env) this.config.env = {};
|
|
|
|
// Default MCP CLI to true if not set
|
|
if (this.config.env.ENABLE_EXPERIMENTAL_MCP_CLI === undefined) {
|
|
this.config.env.ENABLE_EXPERIMENTAL_MCP_CLI = 'true';
|
|
}
|
|
|
|
// Detect existing [1m] suffix state, default to true
|
|
const hasExistingSuffix = this.detectGemini1mSuffix();
|
|
const hasGeminiModels = this.geminiModelFields.some(f =>
|
|
this.config.env[f]?.toLowerCase().includes('gemini')
|
|
);
|
|
|
|
// Default to enabled: if no suffix found but Gemini models exist, apply suffix
|
|
if (!hasExistingSuffix && hasGeminiModels) {
|
|
this.toggleGemini1mSuffix(true);
|
|
} else {
|
|
this.gemini1mSuffix = hasExistingSuffix || !hasGeminiModels;
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to fetch Claude config:', e);
|
|
}
|
|
},
|
|
|
|
async saveClaudeConfig() {
|
|
this.loading = true;
|
|
const password = Alpine.store('global').webuiPassword;
|
|
try {
|
|
const { response, newPassword } = await window.utils.request('/api/claude/config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(this.config)
|
|
}, password);
|
|
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
|
|
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
Alpine.store('global').showToast(Alpine.store('global').t('claudeConfigSaved'), 'success');
|
|
} catch (e) {
|
|
Alpine.store('global').showToast(Alpine.store('global').t('saveConfigFailed') + ': ' + e.message, 'error');
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
restoreDefaultClaudeConfig() {
|
|
document.getElementById('restore_defaults_modal').showModal();
|
|
},
|
|
|
|
async executeRestore() {
|
|
this.restoring = true;
|
|
const password = Alpine.store('global').webuiPassword;
|
|
try {
|
|
const { response, newPassword } = await window.utils.request('/api/claude/config/restore', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' }
|
|
}, password);
|
|
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
|
|
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
Alpine.store('global').showToast(Alpine.store('global').t('claudeConfigRestored'), 'success');
|
|
|
|
// Close modal
|
|
document.getElementById('restore_defaults_modal').close();
|
|
|
|
// Reload the config to reflect the changes
|
|
await this.fetchConfig();
|
|
} catch (e) {
|
|
Alpine.store('global').showToast(Alpine.store('global').t('restoreConfigFailed') + ': ' + e.message, 'error');
|
|
} 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() {
|
|
// Clear the input and show the save preset modal
|
|
this.newPresetName = '';
|
|
document.getElementById('save_preset_modal').showModal();
|
|
},
|
|
|
|
/**
|
|
* Execute preset save after user enters name
|
|
*/
|
|
async executeSavePreset(name) {
|
|
if (!name || !name.trim()) {
|
|
Alpine.store('global').showToast(Alpine.store('global').t('presetNameRequired'), '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();
|
|
this.newPresetName = ''; // Clear the input
|
|
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 || Alpine.store('global').t('saveFailed'));
|
|
}
|
|
} catch (e) {
|
|
Alpine.store('global').showToast(Alpine.store('global').t('failedToSavePreset') + ': ' + e.message, 'error');
|
|
} finally {
|
|
this.savingPreset = false;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Delete the selected preset
|
|
*/
|
|
async deleteSelectedPreset() {
|
|
if (!this.selectedPresetName) {
|
|
Alpine.store('global').showToast(Alpine.store('global').t('noPresetSelected'), 'warning');
|
|
return;
|
|
}
|
|
|
|
// Confirm deletion
|
|
const confirmMsg = Alpine.store('global').t('deletePresetConfirm', { name: this.selectedPresetName });
|
|
if (!confirm(confirmMsg)) {
|
|
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 || Alpine.store('global').t('deleteFailed'));
|
|
}
|
|
} catch (e) {
|
|
Alpine.store('global').showToast(Alpine.store('global').t('failedToDeletePreset') + ': ' + e.message, 'error');
|
|
} finally {
|
|
this.deletingPreset = false;
|
|
}
|
|
}
|
|
});
|