feat: Add per-provider test buttons and documentation links

- Add /api/ai/test/{provider} endpoint for testing individual providers
- Add 'Test' button to each provider accordion (visible when configured)
- Shows test result inline (success/error message)
- Update help links with direct URLs to API key pages:
  - Anthropic: console.anthropic.com/settings/keys
  - OpenAI: platform.openai.com/api-keys
  - DeepSeek: platform.deepseek.com/api_keys
  - Ollama: ollama.ai
This commit is contained in:
rcourtman 2025-12-11 18:11:31 +00:00
parent 8b261f9ec2
commit 597527fc04
4 changed files with 189 additions and 10 deletions

View file

@ -32,6 +32,13 @@ export class AIAPI {
}) as Promise<AITestResult>;
}
// Test a specific provider connection
static async testProvider(provider: string): Promise<{ success: boolean; message: string; provider: string }> {
return apiFetchJSON(`${this.baseUrl}/ai/test/${provider}`, {
method: 'POST',
}) as Promise<{ success: boolean; message: string; provider: string }>;
}
// Get available models from the AI provider
static async getModels(): Promise<{ models: { id: string; name: string; description?: string }[]; error?: string }> {
return apiFetchJSON(`${this.baseUrl}/ai/models`) as Promise<{ models: { id: string; name: string; description?: string }[]; error?: string }>;

View file

@ -66,6 +66,10 @@ export const AISettings: Component = () => {
// Accordion state for provider configuration sections
const [expandedProviders, setExpandedProviders] = createSignal<Set<AIProvider>>(new Set(['anthropic']));
// Per-provider test state
const [testingProvider, setTestingProvider] = createSignal<string | null>(null);
const [providerTestResult, setProviderTestResult] = createSignal<{ provider: string; success: boolean; message: string } | null>(null);
const [form, setForm] = createStore({
enabled: false,
provider: 'anthropic' as AIProvider, // Legacy - kept for compatibility
@ -316,6 +320,27 @@ export const AISettings: Component = () => {
}
};
const handleTestProvider = async (provider: string) => {
setTestingProvider(provider);
setProviderTestResult(null);
try {
const result = await AIAPI.testProvider(provider);
setProviderTestResult(result);
if (result.success) {
notificationStore.success(`${provider}: ${result.message}`);
} else {
notificationStore.error(`${provider}: ${result.message}`);
}
} catch (error) {
logger.error(`[AISettings] Test ${provider} failed:`, error);
const message = error instanceof Error ? error.message : 'Connection test failed';
setProviderTestResult({ provider, success: false, message });
notificationStore.error(`${provider}: ${message}`);
} finally {
setTestingProvider(null);
}
};
// OAuth handlers removed - OAuth is currently unavailable from Anthropic for third-party apps
// When OAuth becomes available, handlers can be added back to the Anthropic accordion section
@ -550,16 +575,35 @@ export const AISettings: Component = () => {
</svg>
</button>
<Show when={expandedProviders().has('anthropic')}>
<div class="px-3 py-3 bg-gray-50 dark:bg-gray-800/50 border-t border-gray-200 dark:border-gray-700">
<div class="px-3 py-3 bg-gray-50 dark:bg-gray-800/50 border-t border-gray-200 dark:border-gray-700 space-y-2">
<input
type="password"
value={form.anthropicApiKey}
onInput={(e) => setForm('anthropicApiKey', e.currentTarget.value)}
placeholder={settings()?.anthropic_configured ? '••••••••••• (configured)' : 'sk-ant-... (from console.anthropic.com)'}
placeholder={settings()?.anthropic_configured ? '••••••••••• (configured)' : 'sk-ant-...'}
class={controlClass()}
disabled={saving()}
/>
<p class="text-xs text-gray-500 mt-1">Get your API key from <a href="https://console.anthropic.com" target="_blank" class="text-purple-600 hover:underline">console.anthropic.com</a></p>
<div class="flex items-center justify-between">
<p class="text-xs text-gray-500">
<a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noopener" class="text-purple-600 hover:underline">Get API key </a>
</p>
<Show when={settings()?.anthropic_configured}>
<button
type="button"
onClick={() => handleTestProvider('anthropic')}
disabled={testingProvider() === 'anthropic'}
class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800 disabled:opacity-50"
>
{testingProvider() === 'anthropic' ? 'Testing...' : 'Test'}
</button>
</Show>
</div>
<Show when={providerTestResult()?.provider === 'anthropic'}>
<p class={`text-xs ${providerTestResult()?.success ? 'text-green-600' : 'text-red-600'}`}>
{providerTestResult()?.message}
</p>
</Show>
</div>
</Show>
</div>
@ -593,7 +637,7 @@ export const AISettings: Component = () => {
type="password"
value={form.openaiApiKey}
onInput={(e) => setForm('openaiApiKey', e.currentTarget.value)}
placeholder={settings()?.openai_configured ? '••••••••••• (configured)' : 'sk-... (from platform.openai.com)'}
placeholder={settings()?.openai_configured ? '••••••••••• (configured)' : 'sk-...'}
class={controlClass()}
disabled={saving()}
/>
@ -605,7 +649,26 @@ export const AISettings: Component = () => {
class={controlClass()}
disabled={saving()}
/>
<p class="text-xs text-gray-500">Get your API key from <a href="https://platform.openai.com" target="_blank" class="text-purple-600 hover:underline">platform.openai.com</a></p>
<div class="flex items-center justify-between">
<p class="text-xs text-gray-500">
<a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener" class="text-purple-600 hover:underline">Get API key </a>
</p>
<Show when={settings()?.openai_configured}>
<button
type="button"
onClick={() => handleTestProvider('openai')}
disabled={testingProvider() === 'openai'}
class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800 disabled:opacity-50"
>
{testingProvider() === 'openai' ? 'Testing...' : 'Test'}
</button>
</Show>
</div>
<Show when={providerTestResult()?.provider === 'openai'}>
<p class={`text-xs ${providerTestResult()?.success ? 'text-green-600' : 'text-red-600'}`}>
{providerTestResult()?.message}
</p>
</Show>
</div>
</Show>
</div>
@ -634,16 +697,35 @@ export const AISettings: Component = () => {
</svg>
</button>
<Show when={expandedProviders().has('deepseek')}>
<div class="px-3 py-3 bg-gray-50 dark:bg-gray-800/50 border-t border-gray-200 dark:border-gray-700">
<div class="px-3 py-3 bg-gray-50 dark:bg-gray-800/50 border-t border-gray-200 dark:border-gray-700 space-y-2">
<input
type="password"
value={form.deepseekApiKey}
onInput={(e) => setForm('deepseekApiKey', e.currentTarget.value)}
placeholder={settings()?.deepseek_configured ? '••••••••••• (configured)' : 'sk-... (from platform.deepseek.com)'}
placeholder={settings()?.deepseek_configured ? '••••••••••• (configured)' : 'sk-...'}
class={controlClass()}
disabled={saving()}
/>
<p class="text-xs text-gray-500 mt-1">Get your API key from <a href="https://platform.deepseek.com" target="_blank" class="text-purple-600 hover:underline">platform.deepseek.com</a></p>
<div class="flex items-center justify-between">
<p class="text-xs text-gray-500">
<a href="https://platform.deepseek.com/api_keys" target="_blank" rel="noopener" class="text-purple-600 hover:underline">Get API key </a>
</p>
<Show when={settings()?.deepseek_configured}>
<button
type="button"
onClick={() => handleTestProvider('deepseek')}
disabled={testingProvider() === 'deepseek'}
class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800 disabled:opacity-50"
>
{testingProvider() === 'deepseek' ? 'Testing...' : 'Test'}
</button>
</Show>
</div>
<Show when={providerTestResult()?.provider === 'deepseek'}>
<p class={`text-xs ${providerTestResult()?.success ? 'text-green-600' : 'text-red-600'}`}>
{providerTestResult()?.message}
</p>
</Show>
</div>
</Show>
</div>
@ -672,7 +754,7 @@ export const AISettings: Component = () => {
</svg>
</button>
<Show when={expandedProviders().has('ollama')}>
<div class="px-3 py-3 bg-gray-50 dark:bg-gray-800/50 border-t border-gray-200 dark:border-gray-700">
<div class="px-3 py-3 bg-gray-50 dark:bg-gray-800/50 border-t border-gray-200 dark:border-gray-700 space-y-2">
<input
type="url"
value={form.ollamaBaseUrl}
@ -681,7 +763,27 @@ export const AISettings: Component = () => {
class={controlClass()}
disabled={saving()}
/>
<p class="text-xs text-gray-500 mt-1">URL where your Ollama server is running. No API key needed - it's free!</p>
<div class="flex items-center justify-between">
<p class="text-xs text-gray-500">
<a href="https://ollama.ai" target="_blank" rel="noopener" class="text-purple-600 hover:underline">Learn about Ollama </a>
<span class="text-gray-400"> · Free & local</span>
</p>
<Show when={settings()?.ollama_configured}>
<button
type="button"
onClick={() => handleTestProvider('ollama')}
disabled={testingProvider() === 'ollama'}
class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800 disabled:opacity-50"
>
{testingProvider() === 'ollama' ? 'Testing...' : 'Test'}
</button>
</Show>
</div>
<Show when={providerTestResult()?.provider === 'ollama'}>
<p class={`text-xs ${providerTestResult()?.success ? 'text-green-600' : 'text-red-600'}`}>
{providerTestResult()?.message}
</p>
</Show>
</div>
</Show>
</div>

View file

@ -456,6 +456,75 @@ func (h *AISettingsHandler) HandleTestAIConnection(w http.ResponseWriter, r *htt
}
}
// HandleTestProvider tests a specific AI provider connection (POST /api/ai/test/:provider)
func (h *AISettingsHandler) HandleTestProvider(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Require admin authentication
if !CheckAuth(h.config, w, r) {
return
}
// Get provider from URL path (e.g., /api/ai/test/anthropic -> anthropic)
provider := strings.TrimPrefix(r.URL.Path, "/api/ai/test/")
if provider == "" || provider == r.URL.Path {
http.Error(w, `{"error":"Provider is required"}`, http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
var testResult struct {
Success bool `json:"success"`
Message string `json:"message"`
Provider string `json:"provider"`
}
testResult.Provider = provider
// Load config and create provider for testing
cfg := h.aiService.GetConfig()
if cfg == nil {
testResult.Success = false
testResult.Message = "AI not configured"
utils.WriteJSONResponse(w, testResult)
return
}
// Check if provider is configured
if !cfg.HasProvider(provider) {
testResult.Success = false
testResult.Message = "Provider not configured"
utils.WriteJSONResponse(w, testResult)
return
}
// Create provider and test connection
testProvider, err := providers.NewForProvider(cfg, provider, cfg.GetModel())
if err != nil {
testResult.Success = false
testResult.Message = fmt.Sprintf("Failed to create provider: %v", err)
utils.WriteJSONResponse(w, testResult)
return
}
err = testProvider.TestConnection(ctx)
if err != nil {
testResult.Success = false
testResult.Message = err.Error()
} else {
testResult.Success = true
testResult.Message = "Connection successful"
}
if err := utils.WriteJSONResponse(w, testResult); err != nil {
log.Error().Err(err).Msg("Failed to write provider test response")
}
}
// HandleListModels fetches available models from the configured AI provider (GET /api/ai/models)
func (h *AISettingsHandler) HandleListModels(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {

View file

@ -1076,6 +1076,7 @@ func (r *Router) setupRoutes() {
r.mux.HandleFunc("/api/settings/ai", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, r.aiSettingsHandler.HandleGetAISettings)))
r.mux.HandleFunc("/api/settings/ai/update", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.aiSettingsHandler.HandleUpdateAISettings)))
r.mux.HandleFunc("/api/ai/test", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.aiSettingsHandler.HandleTestAIConnection)))
r.mux.HandleFunc("/api/ai/test/{provider}", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.aiSettingsHandler.HandleTestProvider)))
r.mux.HandleFunc("/api/ai/models", RequireAuth(r.config, r.aiSettingsHandler.HandleListModels))
r.mux.HandleFunc("/api/ai/execute", RequireAuth(r.config, r.aiSettingsHandler.HandleExecute))
r.mux.HandleFunc("/api/ai/execute/stream", RequireAuth(r.config, r.aiSettingsHandler.HandleExecuteStream))