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)
|
// 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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue