feat(ai): improve AI settings first-time setup UX
- Add setup modal that appears when enabling AI without configured provider - Modal allows selecting provider (Anthropic, OpenAI, DeepSeek, Ollama) - Enter API key/URL and enable AI in one smooth flow - Reorder backend to apply API keys before enabled check - Fix Ollama to strip 'ollama:' prefix from model names - Simplify backend error message for unconfigured providers
This commit is contained in:
parent
fe20b2c55b
commit
43d658556b
3 changed files with 1085 additions and 885 deletions
|
|
@ -72,6 +72,13 @@ export const AISettings: Component = () => {
|
|||
// Auto-fix acknowledgement state (not persisted - must acknowledge each session)
|
||||
const [autoFixAcknowledged, setAutoFixAcknowledged] = createSignal(false);
|
||||
|
||||
// First-time setup modal state
|
||||
const [showSetupModal, setShowSetupModal] = createSignal(false);
|
||||
const [setupProvider, setSetupProvider] = createSignal<'anthropic' | 'openai' | 'deepseek' | 'ollama'>('anthropic');
|
||||
const [setupApiKey, setSetupApiKey] = createSignal('');
|
||||
const [setupOllamaUrl, setSetupOllamaUrl] = createSignal('http://localhost:11434');
|
||||
const [setupSaving, setSetupSaving] = createSignal(false);
|
||||
|
||||
const [form, setForm] = createStore({
|
||||
enabled: false,
|
||||
provider: 'anthropic' as AIProvider, // Legacy - kept for compatibility
|
||||
|
|
@ -413,6 +420,7 @@ export const AISettings: Component = () => {
|
|||
// Legacy helper functions removed - multi-provider accordions handle all provider-specific UI
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
padding="none"
|
||||
class="overflow-hidden border border-gray-200 dark:border-gray-700"
|
||||
|
|
@ -441,10 +449,22 @@ export const AISettings: Component = () => {
|
|||
size="sm"
|
||||
class="flex-1"
|
||||
/>
|
||||
{/* Toggle with first-time setup flow */}
|
||||
{(() => {
|
||||
const s = settings();
|
||||
const hasConfiguredProvider = s && (s.anthropic_configured || s.openai_configured || s.deepseek_configured || s.ollama_configured);
|
||||
|
||||
return (
|
||||
<Toggle
|
||||
checked={form.enabled}
|
||||
onChange={async (event) => {
|
||||
const newValue = event.currentTarget.checked;
|
||||
// Show setup modal if trying to enable without a configured provider
|
||||
if (newValue && !hasConfiguredProvider) {
|
||||
event.currentTarget.checked = false;
|
||||
setShowSetupModal(true);
|
||||
return;
|
||||
}
|
||||
setForm('enabled', newValue);
|
||||
// Auto-save the enabled toggle immediately
|
||||
try {
|
||||
|
|
@ -467,6 +487,8 @@ export const AISettings: Component = () => {
|
|||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1278,6 +1300,192 @@ export const AISettings: Component = () => {
|
|||
</Show>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* First-time Setup Modal */}
|
||||
<Show when={showSetupModal()}>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-2xl max-w-md w-full mx-4 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div class="bg-gradient-to-r from-purple-600 to-pink-600 px-6 py-4">
|
||||
<h3 class="text-lg font-semibold text-white">Set Up AI Assistant</h3>
|
||||
<p class="text-purple-100 text-sm mt-1">Choose a provider to get started</p>
|
||||
</div>
|
||||
|
||||
{/* Provider Selection */}
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSetupProvider('anthropic')}
|
||||
class={`p-3 rounded-lg border-2 transition-all text-center ${setupProvider() === 'anthropic'
|
||||
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/30'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-purple-300'
|
||||
}`}
|
||||
>
|
||||
<div class="text-sm font-medium">Anthropic</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5">Claude</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSetupProvider('openai')}
|
||||
class={`p-3 rounded-lg border-2 transition-all text-center ${setupProvider() === 'openai'
|
||||
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/30'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-purple-300'
|
||||
}`}
|
||||
>
|
||||
<div class="text-sm font-medium">OpenAI</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5">ChatGPT</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSetupProvider('deepseek')}
|
||||
class={`p-3 rounded-lg border-2 transition-all text-center ${setupProvider() === 'deepseek'
|
||||
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/30'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-purple-300'
|
||||
}`}
|
||||
>
|
||||
<div class="text-sm font-medium">DeepSeek</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5">V3</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSetupProvider('ollama')}
|
||||
class={`p-3 rounded-lg border-2 transition-all text-center ${setupProvider() === 'ollama'
|
||||
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/30'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-purple-300'
|
||||
}`}
|
||||
>
|
||||
<div class="text-sm font-medium">Ollama</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5">Local</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* API Key / URL Input */}
|
||||
<Show when={setupProvider() === 'ollama'} fallback={
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
{setupProvider() === 'anthropic' ? 'Anthropic' : setupProvider() === 'openai' ? 'OpenAI' : 'DeepSeek'} API Key
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={setupApiKey()}
|
||||
onInput={(e) => setSetupApiKey(e.currentTarget.value)}
|
||||
placeholder={setupProvider() === 'anthropic' ? 'sk-ant-...' : 'sk-...'}
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1.5">
|
||||
<a
|
||||
href={setupProvider() === 'anthropic'
|
||||
? 'https://console.anthropic.com/settings/keys'
|
||||
: setupProvider() === 'openai'
|
||||
? 'https://platform.openai.com/api-keys'
|
||||
: 'https://platform.deepseek.com/api_keys'
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-purple-600 hover:underline"
|
||||
>
|
||||
Get your API key →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
}>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Ollama Server URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={setupOllamaUrl()}
|
||||
onInput={(e) => setSetupOllamaUrl(e.currentTarget.value)}
|
||||
placeholder="http://localhost:11434"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1.5">
|
||||
Ollama runs locally - no API key needed
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-800/50 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowSetupModal(false);
|
||||
setSetupApiKey('');
|
||||
}}
|
||||
class="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||
disabled={setupSaving()}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setSetupSaving(true);
|
||||
try {
|
||||
const payload: Record<string, unknown> = { enabled: true };
|
||||
|
||||
if (setupProvider() === 'anthropic') {
|
||||
if (!setupApiKey().trim()) {
|
||||
notificationStore.error('Please enter your Anthropic API key');
|
||||
return;
|
||||
}
|
||||
payload.anthropic_api_key = setupApiKey().trim();
|
||||
payload.model = 'anthropic:claude-sonnet-4-20250514';
|
||||
} else if (setupProvider() === 'openai') {
|
||||
if (!setupApiKey().trim()) {
|
||||
notificationStore.error('Please enter your OpenAI API key');
|
||||
return;
|
||||
}
|
||||
payload.openai_api_key = setupApiKey().trim();
|
||||
payload.model = 'openai:gpt-4o';
|
||||
} else if (setupProvider() === 'deepseek') {
|
||||
if (!setupApiKey().trim()) {
|
||||
notificationStore.error('Please enter your DeepSeek API key');
|
||||
return;
|
||||
}
|
||||
payload.deepseek_api_key = setupApiKey().trim();
|
||||
payload.model = 'deepseek:deepseek-chat';
|
||||
} else {
|
||||
if (!setupOllamaUrl().trim()) {
|
||||
notificationStore.error('Please enter your Ollama server URL');
|
||||
return;
|
||||
}
|
||||
payload.ollama_base_url = setupOllamaUrl().trim();
|
||||
payload.model = 'ollama:llama3.2:latest';
|
||||
}
|
||||
|
||||
const updated = await AIAPI.updateSettings(payload);
|
||||
setSettings(updated);
|
||||
setForm('enabled', true);
|
||||
resetForm(updated);
|
||||
setShowSetupModal(false);
|
||||
setSetupApiKey('');
|
||||
notificationStore.success('AI Assistant enabled! You can customize settings below.');
|
||||
// Load models after setup
|
||||
loadModels();
|
||||
} catch (error) {
|
||||
logger.error('[AISettings] Setup failed:', error);
|
||||
const message = error instanceof Error ? error.message : 'Setup failed';
|
||||
notificationStore.error(message);
|
||||
} finally {
|
||||
setSetupSaving(false);
|
||||
}
|
||||
}}
|
||||
class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 flex items-center gap-2"
|
||||
disabled={setupSaving() || (setupProvider() !== 'ollama' && !setupApiKey().trim()) || (setupProvider() === 'ollama' && !setupOllamaUrl().trim())}
|
||||
>
|
||||
{setupSaving() && <span class="h-4 w-4 border-2 border-white border-t-transparent rounded-full animate-spin" />}
|
||||
Enable AI
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -95,6 +95,10 @@ func (c *OllamaClient) Chat(ctx context.Context, req ChatRequest) (*ChatResponse
|
|||
|
||||
// Use provided model or fall back to client default
|
||||
model := req.Model
|
||||
// Strip "ollama:" prefix if present - callers may pass the full "provider:model" string
|
||||
if strings.HasPrefix(model, "ollama:") {
|
||||
model = strings.TrimPrefix(model, "ollama:")
|
||||
}
|
||||
if model == "" {
|
||||
model = c.model
|
||||
}
|
||||
|
|
|
|||
|
|
@ -359,28 +359,42 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
|
|||
settings.CustomContext = strings.TrimSpace(*req.CustomContext)
|
||||
}
|
||||
|
||||
// Handle multi-provider credentials FIRST - before enabled check
|
||||
// This allows the setup flow to send API key + enabled:true together
|
||||
// Clear flags take priority over setting new values
|
||||
if req.ClearAnthropicKey != nil && *req.ClearAnthropicKey {
|
||||
settings.AnthropicAPIKey = ""
|
||||
} else if req.AnthropicAPIKey != nil {
|
||||
settings.AnthropicAPIKey = strings.TrimSpace(*req.AnthropicAPIKey)
|
||||
}
|
||||
if req.ClearOpenAIKey != nil && *req.ClearOpenAIKey {
|
||||
settings.OpenAIAPIKey = ""
|
||||
} else if req.OpenAIAPIKey != nil {
|
||||
settings.OpenAIAPIKey = strings.TrimSpace(*req.OpenAIAPIKey)
|
||||
}
|
||||
if req.ClearDeepSeekKey != nil && *req.ClearDeepSeekKey {
|
||||
settings.DeepSeekAPIKey = ""
|
||||
} else if req.DeepSeekAPIKey != nil {
|
||||
settings.DeepSeekAPIKey = strings.TrimSpace(*req.DeepSeekAPIKey)
|
||||
}
|
||||
if req.ClearOllamaURL != nil && *req.ClearOllamaURL {
|
||||
settings.OllamaBaseURL = ""
|
||||
} else if req.OllamaBaseURL != nil {
|
||||
settings.OllamaBaseURL = strings.TrimSpace(*req.OllamaBaseURL)
|
||||
}
|
||||
if req.OpenAIBaseURL != nil {
|
||||
settings.OpenAIBaseURL = strings.TrimSpace(*req.OpenAIBaseURL)
|
||||
}
|
||||
|
||||
if req.Enabled != nil {
|
||||
// Only allow enabling if at least one provider is configured
|
||||
if *req.Enabled {
|
||||
configuredProviders := settings.GetConfiguredProviders()
|
||||
if len(configuredProviders) == 0 {
|
||||
// Fall back to legacy validation for backwards compatibility
|
||||
switch settings.Provider {
|
||||
case config.AIProviderAnthropic, config.AIProviderOpenAI, config.AIProviderDeepSeek:
|
||||
if settings.APIKey == "" {
|
||||
http.Error(w, "Cannot enable AI: configure at least one AI provider first", http.StatusBadRequest)
|
||||
// No providers configured - give a helpful error
|
||||
http.Error(w, "Please configure an AI provider (API key or Ollama URL) before enabling AI", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
case config.AIProviderOllama:
|
||||
// Ollama doesn't need API key, but needs base URL (or will use default)
|
||||
if settings.BaseURL == "" {
|
||||
settings.BaseURL = config.DefaultOllamaBaseURL
|
||||
}
|
||||
default:
|
||||
http.Error(w, "Cannot enable AI: configure at least one AI provider first", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
// If we have configured providers, we're good to enable
|
||||
}
|
||||
settings.Enabled = *req.Enabled
|
||||
|
|
@ -429,32 +443,6 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
|
|||
settings.AlertTriggeredAnalysis = *req.AlertTriggeredAnalysis
|
||||
}
|
||||
|
||||
// Handle multi-provider credentials
|
||||
// Clear flags take priority over setting new values
|
||||
if req.ClearAnthropicKey != nil && *req.ClearAnthropicKey {
|
||||
settings.AnthropicAPIKey = ""
|
||||
} else if req.AnthropicAPIKey != nil {
|
||||
settings.AnthropicAPIKey = strings.TrimSpace(*req.AnthropicAPIKey)
|
||||
}
|
||||
if req.ClearOpenAIKey != nil && *req.ClearOpenAIKey {
|
||||
settings.OpenAIAPIKey = ""
|
||||
} else if req.OpenAIAPIKey != nil {
|
||||
settings.OpenAIAPIKey = strings.TrimSpace(*req.OpenAIAPIKey)
|
||||
}
|
||||
if req.ClearDeepSeekKey != nil && *req.ClearDeepSeekKey {
|
||||
settings.DeepSeekAPIKey = ""
|
||||
} else if req.DeepSeekAPIKey != nil {
|
||||
settings.DeepSeekAPIKey = strings.TrimSpace(*req.DeepSeekAPIKey)
|
||||
}
|
||||
if req.ClearOllamaURL != nil && *req.ClearOllamaURL {
|
||||
settings.OllamaBaseURL = ""
|
||||
} else if req.OllamaBaseURL != nil {
|
||||
settings.OllamaBaseURL = strings.TrimSpace(*req.OllamaBaseURL)
|
||||
}
|
||||
if req.OpenAIBaseURL != nil {
|
||||
settings.OpenAIBaseURL = strings.TrimSpace(*req.OpenAIBaseURL)
|
||||
}
|
||||
|
||||
// Save settings
|
||||
if err := h.persistence.SaveAIConfig(*settings); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to save AI settings")
|
||||
|
|
|
|||
Loading…
Reference in a new issue