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:
rcourtman 2025-12-15 18:59:19 +00:00
parent fe20b2c55b
commit 43d658556b
3 changed files with 1085 additions and 885 deletions

View file

@ -72,6 +72,13 @@ export const AISettings: Component = () => {
// Auto-fix acknowledgement state (not persisted - must acknowledge each session) // Auto-fix acknowledgement state (not persisted - must acknowledge each session)
const [autoFixAcknowledged, setAutoFixAcknowledged] = createSignal(false); 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({ const [form, setForm] = createStore({
enabled: false, enabled: false,
provider: 'anthropic' as AIProvider, // Legacy - kept for compatibility 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 // Legacy helper functions removed - multi-provider accordions handle all provider-specific UI
return ( return (
<>
<Card <Card
padding="none" padding="none"
class="overflow-hidden border border-gray-200 dark:border-gray-700" class="overflow-hidden border border-gray-200 dark:border-gray-700"
@ -441,10 +449,22 @@ export const AISettings: Component = () => {
size="sm" size="sm"
class="flex-1" 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 <Toggle
checked={form.enabled} checked={form.enabled}
onChange={async (event) => { onChange={async (event) => {
const newValue = event.currentTarget.checked; 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); setForm('enabled', newValue);
// Auto-save the enabled toggle immediately // Auto-save the enabled toggle immediately
try { try {
@ -467,6 +487,8 @@ export const AISettings: Component = () => {
</span> </span>
} }
/> />
);
})()}
</div> </div>
</div> </div>
@ -1278,6 +1300,192 @@ export const AISettings: Component = () => {
</Show> </Show>
</form> </form>
</Card> </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>
</>
); );
}; };

View file

@ -95,6 +95,10 @@ func (c *OllamaClient) Chat(ctx context.Context, req ChatRequest) (*ChatResponse
// Use provided model or fall back to client default // Use provided model or fall back to client default
model := req.Model 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 == "" { if model == "" {
model = c.model model = c.model
} }

View file

@ -359,28 +359,42 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
settings.CustomContext = strings.TrimSpace(*req.CustomContext) 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 { if req.Enabled != nil {
// Only allow enabling if at least one provider is configured // Only allow enabling if at least one provider is configured
if *req.Enabled { if *req.Enabled {
configuredProviders := settings.GetConfiguredProviders() configuredProviders := settings.GetConfiguredProviders()
if len(configuredProviders) == 0 { if len(configuredProviders) == 0 {
// Fall back to legacy validation for backwards compatibility // No providers configured - give a helpful error
switch settings.Provider { http.Error(w, "Please configure an AI provider (API key or Ollama URL) before enabling AI", http.StatusBadRequest)
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)
return 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 // If we have configured providers, we're good to enable
} }
settings.Enabled = *req.Enabled settings.Enabled = *req.Enabled
@ -429,32 +443,6 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
settings.AlertTriggeredAnalysis = *req.AlertTriggeredAnalysis 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 // Save settings
if err := h.persistence.SaveAIConfig(*settings); err != nil { if err := h.persistence.SaveAIConfig(*settings); err != nil {
log.Error().Err(err).Msg("Failed to save AI settings") log.Error().Err(err).Msg("Failed to save AI settings")