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:
parent
8b261f9ec2
commit
597527fc04
4 changed files with 189 additions and 10 deletions
|
|
@ -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 }>;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Reference in a new issue