feat: add AI Configuration settings tab + fix API routing order

This commit is contained in:
Ricel Leite 2026-02-19 01:08:52 -03:00
parent 15867ecf92
commit 9067e79d70
10 changed files with 939 additions and 522 deletions

View File

@ -7,11 +7,13 @@ from .issues import router as issues_router
from .webhooks import router as webhooks_router
from .reports import router as reports_router
from .gitea import router as gitea_router
from .settings import router as settings_router
api_router = APIRouter()
api_router.include_router(auth_router, prefix="/auth", tags=["Authentication"])
api_router.include_router(users_router, prefix="/users", tags=["Users"])
api_router.include_router(orgs_router, prefix="/organizations", tags=["Organizations"])
api_router.include_router(settings_router, prefix="/organizations", tags=["Settings"])
api_router.include_router(integrations_router, prefix="/integrations", tags=["Integrations"])
api_router.include_router(issues_router, prefix="/issues", tags=["Issues"])
api_router.include_router(webhooks_router, prefix="/webhooks", tags=["Webhooks"])

146
app/api/settings.py Normal file
View File

@ -0,0 +1,146 @@
"""Organization settings endpoints."""
from typing import Dict, Any, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
import httpx
from app.core.database import get_db
from app.models.organization import Organization, OrganizationMember
from app.api.deps import get_current_user, require_role
router = APIRouter()
class AIConfig(BaseModel):
provider: str = "openrouter"
apiKey: str = ""
model: str = "meta-llama/llama-3.3-70b-instruct"
autoAnalyze: bool = True
autoCreatePR: bool = True
confidenceThreshold: int = 70
class SettingsUpdate(BaseModel):
ai_config: Optional[AIConfig] = None
class TestLLMRequest(BaseModel):
provider: str
api_key: str
model: str
# In-memory storage for now (should be moved to database)
ORG_SETTINGS: Dict[int, Dict[str, Any]] = {}
@router.get("/{org_id}/settings")
async def get_settings(
org_id: int,
member: OrganizationMember = Depends(require_role("viewer")),
db: AsyncSession = Depends(get_db)
):
"""Get organization settings."""
settings = ORG_SETTINGS.get(org_id, {})
# Mask API key for security
if settings.get("ai_config", {}).get("apiKey"):
settings["ai_config"]["apiKey"] = "***configured***"
return settings
@router.put("/{org_id}/settings")
async def update_settings(
org_id: int,
settings: SettingsUpdate,
member: OrganizationMember = Depends(require_role("admin")),
db: AsyncSession = Depends(get_db)
):
"""Update organization settings."""
if org_id not in ORG_SETTINGS:
ORG_SETTINGS[org_id] = {}
if settings.ai_config:
ORG_SETTINGS[org_id]["ai_config"] = settings.ai_config.dict()
return {"message": "Settings updated", "settings": ORG_SETTINGS[org_id]}
@router.post("/{org_id}/test-llm")
async def test_llm_connection(
org_id: int,
request: TestLLMRequest,
member: OrganizationMember = Depends(require_role("admin")),
db: AsyncSession = Depends(get_db)
):
"""Test LLM API connection."""
# Build request based on provider
if request.provider == "openrouter":
url = "https://openrouter.ai/api/v1/chat/completions"
headers = {
"Authorization": f"Bearer {request.api_key}",
"Content-Type": "application/json",
"HTTP-Referer": "https://jira-fixer.startdata.com.br",
"X-Title": "JIRA AI Fixer"
}
payload = {
"model": request.model,
"messages": [{"role": "user", "content": "Say 'OK' if you can read this."}],
"max_tokens": 10
}
elif request.provider == "anthropic":
url = "https://api.anthropic.com/v1/messages"
headers = {
"x-api-key": request.api_key,
"Content-Type": "application/json",
"anthropic-version": "2023-06-01"
}
payload = {
"model": request.model,
"max_tokens": 10,
"messages": [{"role": "user", "content": "Say 'OK' if you can read this."}]
}
elif request.provider == "openai":
url = "https://api.openai.com/v1/chat/completions"
headers = {
"Authorization": f"Bearer {request.api_key}",
"Content-Type": "application/json"
}
payload = {
"model": request.model,
"messages": [{"role": "user", "content": "Say 'OK' if you can read this."}],
"max_tokens": 10
}
elif request.provider == "groq":
url = "https://api.groq.com/openai/v1/chat/completions"
headers = {
"Authorization": f"Bearer {request.api_key}",
"Content-Type": "application/json"
}
payload = {
"model": request.model,
"messages": [{"role": "user", "content": "Say 'OK' if you can read this."}],
"max_tokens": 10
}
elif request.provider == "google":
url = f"https://generativelanguage.googleapis.com/v1beta/models/{request.model}:generateContent?key={request.api_key}"
headers = {"Content-Type": "application/json"}
payload = {
"contents": [{"parts": [{"text": "Say 'OK' if you can read this."}]}],
"generationConfig": {"maxOutputTokens": 10}
}
else:
raise HTTPException(status_code=400, detail=f"Unsupported provider: {request.provider}")
try:
async with httpx.AsyncClient() as client:
response = await client.post(url, headers=headers, json=payload, timeout=15.0)
if response.status_code == 200:
return {"success": True, "message": "Connection successful"}
elif response.status_code == 401:
raise HTTPException(status_code=400, detail="Invalid API key")
elif response.status_code == 403:
raise HTTPException(status_code=400, detail="API key lacks permissions")
else:
error_detail = response.json().get("error", {}).get("message", response.text)
raise HTTPException(status_code=400, detail=f"API error: {error_detail}")
except httpx.TimeoutException:
raise HTTPException(status_code=400, detail="Connection timeout")
except httpx.ConnectError:
raise HTTPException(status_code=400, detail="Could not connect to API")

View File

@ -1,9 +1,9 @@
"""JIRA AI Fixer - Enterprise Issue Analysis Platform."""
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi import FastAPI, Request, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.responses import FileResponse
from starlette.middleware.base import BaseHTTPMiddleware
import os
@ -15,7 +15,6 @@ class HTTPSRedirectMiddleware(BaseHTTPMiddleware):
"""Force HTTPS in redirects when behind reverse proxy."""
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
# Fix Location header to use HTTPS if behind proxy
if response.status_code in (301, 302, 303, 307, 308):
location = response.headers.get("location", "")
if location.startswith("http://"):
@ -24,10 +23,8 @@ class HTTPSRedirectMiddleware(BaseHTTPMiddleware):
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
await init_db()
yield
# Shutdown
app = FastAPI(
title=settings.APP_NAME,
@ -39,10 +36,7 @@ app = FastAPI(
lifespan=lifespan
)
# Add HTTPS redirect middleware
app.add_middleware(HTTPSRedirectMiddleware)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
@ -51,10 +45,10 @@ app.add_middleware(
allow_headers=["*"],
)
# API routes
# FIRST: API routes (highest priority)
app.include_router(api_router, prefix="/api")
# Health check
# Health check (explicit, not in router)
@app.get("/api/health")
async def health():
return {
@ -63,36 +57,39 @@ async def health():
"version": settings.APP_VERSION
}
# Serve static frontend (will be mounted if exists)
# SECOND: Static files
FRONTEND_DIR = "/app/frontend"
ASSETS_DIR = f"{FRONTEND_DIR}/assets"
if os.path.exists(FRONTEND_DIR) and os.path.exists(ASSETS_DIR):
if os.path.exists(ASSETS_DIR):
app.mount("/assets", StaticFiles(directory=ASSETS_DIR), name="assets")
@app.get("/")
async def serve_frontend():
return FileResponse(f"{FRONTEND_DIR}/index.html")
# SPA fallback - MUST be last and NOT capture /api/*
@app.get("/{path:path}", include_in_schema=False)
async def serve_spa(path: str):
# Explicitly skip API routes
if path.startswith("api"):
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Not Found")
file_path = f"{FRONTEND_DIR}/{path}"
if os.path.exists(file_path) and os.path.isfile(file_path):
return FileResponse(file_path)
return FileResponse(f"{FRONTEND_DIR}/index.html")
# Fallback: serve basic info page when no frontend
# THIRD: Frontend routes (AFTER API)
@app.get("/")
async def root():
async def serve_root():
if os.path.exists(f"{FRONTEND_DIR}/index.html"):
return FileResponse(f"{FRONTEND_DIR}/index.html")
return {
"service": settings.APP_NAME,
"version": settings.APP_VERSION,
"docs": "/api/docs",
"health": "/api/health"
}
# LAST: SPA catch-all (exclude api/*)
@app.get("/{path:path}", include_in_schema=False)
async def serve_spa(path: str):
# NEVER capture API routes
if path.startswith("api"):
raise HTTPException(status_code=404, detail="API route not found")
# Try to serve static file
file_path = f"{FRONTEND_DIR}/{path}"
if os.path.exists(file_path) and os.path.isfile(file_path):
return FileResponse(file_path)
# Fallback to index.html for SPA routing
if os.path.exists(f"{FRONTEND_DIR}/index.html"):
return FileResponse(f"{FRONTEND_DIR}/index.html")
raise HTTPException(status_code=404, detail="Not found")

View File

@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JIRA AI Fixer</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<script type="module" crossorigin src="/assets/index-Be0hyHsH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-gQZrNcqD.css">
<script type="module" crossorigin src="/assets/index-BY2tGtHO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bw0JDVcx.css">
</head>
<body class="bg-gray-900 text-white">
<div id="root"></div>

View File

@ -1,25 +1,124 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useAuth } from '../context/AuthContext';
import api from '../services/api';
import { cn } from '../lib/utils';
import {
Settings as SettingsIcon, Building2, Bell, Shield, Key, Globe,
Mail, Save, Loader2, Plus, Trash2, Copy, Eye, EyeOff, Code2
Mail, Save, Loader2, Plus, Trash2, Copy, Eye, EyeOff, Code2,
Brain, Check, AlertCircle, RefreshCw
} from 'lucide-react';
const tabs = [
{ id: 'general', label: 'General', icon: Building2 },
{ id: 'ai', label: 'AI Configuration', icon: Brain },
{ id: 'notifications', label: 'Notifications', icon: Bell },
{ id: 'security', label: 'Security', icon: Shield },
{ id: 'api', label: 'API Keys', icon: Key },
{ id: 'webhooks', label: 'Webhooks', icon: Globe },
];
const LLM_PROVIDERS = [
{ id: 'openrouter', name: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1', models: [
{ id: 'meta-llama/llama-3.3-70b-instruct', name: 'Llama 3.3 70B (Free)' },
{ id: 'anthropic/claude-3.5-sonnet', name: 'Claude 3.5 Sonnet' },
{ id: 'openai/gpt-4o', name: 'GPT-4o' },
{ id: 'google/gemini-pro-1.5', name: 'Gemini Pro 1.5' },
]},
{ id: 'anthropic', name: 'Anthropic', baseUrl: 'https://api.anthropic.com/v1', models: [
{ id: 'claude-3-5-sonnet-20241022', name: 'Claude 3.5 Sonnet' },
{ id: 'claude-3-opus-20240229', name: 'Claude 3 Opus' },
{ id: 'claude-3-haiku-20240307', name: 'Claude 3 Haiku (Fast)' },
]},
{ id: 'openai', name: 'OpenAI', baseUrl: 'https://api.openai.com/v1', models: [
{ id: 'gpt-4o', name: 'GPT-4o' },
{ id: 'gpt-4o-mini', name: 'GPT-4o Mini (Fast)' },
{ id: 'gpt-4-turbo', name: 'GPT-4 Turbo' },
]},
{ id: 'google', name: 'Google AI', baseUrl: 'https://generativelanguage.googleapis.com/v1beta', models: [
{ id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro' },
{ id: 'gemini-1.5-flash', name: 'Gemini 1.5 Flash (Fast)' },
]},
{ id: 'groq', name: 'Groq', baseUrl: 'https://api.groq.com/openai/v1', models: [
{ id: 'llama-3.3-70b-versatile', name: 'Llama 3.3 70B' },
{ id: 'mixtral-8x7b-32768', name: 'Mixtral 8x7B' },
]},
];
export default function Settings() {
const { currentOrg } = useAuth();
const [activeTab, setActiveTab] = useState('general');
const [saving, setSaving] = useState(false);
const [showToken, setShowToken] = useState(false);
// AI Config state
const [aiConfig, setAiConfig] = useState({
provider: 'openrouter',
apiKey: '',
model: 'meta-llama/llama-3.3-70b-instruct',
autoAnalyze: true,
autoCreatePR: true,
confidenceThreshold: 70,
});
const [showApiKey, setShowApiKey] = useState(false);
const [testingConnection, setTestingConnection] = useState(false);
const [connectionStatus, setConnectionStatus] = useState(null);
const [loadingConfig, setLoadingConfig] = useState(true);
useEffect(() => {
if (currentOrg) {
loadAiConfig();
}
}, [currentOrg]);
const loadAiConfig = async () => {
try {
const response = await api.get(`/organizations/${currentOrg.id}/settings`);
if (response.data?.ai_config) {
setAiConfig(prev => ({ ...prev, ...response.data.ai_config }));
}
} catch (err) {
// Config not found, use defaults
} finally {
setLoadingConfig(false);
}
};
const handleSaveAiConfig = async () => {
setSaving(true);
try {
await api.put(`/organizations/${currentOrg.id}/settings`, {
ai_config: aiConfig
});
setConnectionStatus({ type: 'success', message: 'Configuration saved!' });
} catch (err) {
setConnectionStatus({ type: 'error', message: 'Failed to save configuration' });
} finally {
setSaving(false);
}
};
const testConnection = async () => {
setTestingConnection(true);
setConnectionStatus(null);
try {
const response = await api.post(`/organizations/${currentOrg.id}/test-llm`, {
provider: aiConfig.provider,
api_key: aiConfig.apiKey,
model: aiConfig.model,
});
setConnectionStatus({ type: 'success', message: 'Connection successful! API key is valid.' });
} catch (err) {
setConnectionStatus({
type: 'error',
message: err.response?.data?.detail || 'Connection failed. Check your API key.'
});
} finally {
setTestingConnection(false);
}
};
const selectedProvider = LLM_PROVIDERS.find(p => p.id === aiConfig.provider);
if (!currentOrg) return <div className="flex items-center justify-center h-full p-8"><p className="text-gray-500">Select an organization</p></div>;
const handleSave = async () => {
@ -76,15 +175,6 @@ export default function Settings() {
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Slug</label>
<input defaultValue={currentOrg.slug || ''} className="input font-mono" placeholder="my-org" />
</div>
<div>
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Default AI Model</label>
<select className="input">
<option>Claude 3.5 Sonnet (recommended)</option>
<option>GPT-4o</option>
<option>Llama 3.3 70B</option>
<option>Gemini Pro</option>
</select>
</div>
<div className="pt-3 border-t border-gray-800">
<button onClick={handleSave} disabled={saving} className="btn btn-primary">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
@ -95,6 +185,188 @@ export default function Settings() {
</div>
)}
{activeTab === 'ai' && (
<div className="space-y-6 animate-fade-in">
{/* AI Provider Configuration */}
<div className="card">
<div className="card-header">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Brain size={16} className="text-indigo-400" />
AI Provider Configuration
</h3>
</div>
<div className="card-body space-y-5">
{/* Provider Selection */}
<div>
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Provider</label>
<select
value={aiConfig.provider}
onChange={(e) => {
const provider = LLM_PROVIDERS.find(p => p.id === e.target.value);
setAiConfig({
...aiConfig,
provider: e.target.value,
model: provider?.models[0]?.id || ''
});
setConnectionStatus(null);
}}
className="input"
>
{LLM_PROVIDERS.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
{/* API Key */}
<div>
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">API Key</label>
<div className="relative">
<input
type={showApiKey ? "text" : "password"}
value={aiConfig.apiKey}
onChange={(e) => {
setAiConfig({ ...aiConfig, apiKey: e.target.value });
setConnectionStatus(null);
}}
className="input pr-20 font-mono"
placeholder={`Enter your ${selectedProvider?.name} API key`}
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="p-1 text-gray-500 hover:text-gray-300"
>
{showApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
</div>
<p className="text-xs text-gray-600 mt-1">
Get your API key from{' '}
<a
href={selectedProvider?.id === 'openrouter' ? 'https://openrouter.ai/keys' :
selectedProvider?.id === 'anthropic' ? 'https://console.anthropic.com/settings/keys' :
selectedProvider?.id === 'openai' ? 'https://platform.openai.com/api-keys' :
selectedProvider?.id === 'google' ? 'https://aistudio.google.com/app/apikey' :
'https://console.groq.com/keys'}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-400 hover:text-indigo-300"
>
{selectedProvider?.name} dashboard
</a>
</p>
</div>
{/* Model Selection */}
<div>
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">Model</label>
<select
value={aiConfig.model}
onChange={(e) => setAiConfig({ ...aiConfig, model: e.target.value })}
className="input"
>
{selectedProvider?.models.map(m => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
</div>
{/* Test Connection */}
<div className="flex items-center gap-3 pt-3 border-t border-gray-800">
<button
onClick={testConnection}
disabled={testingConnection || !aiConfig.apiKey}
className="btn btn-secondary"
>
{testingConnection ? (
<Loader2 size={14} className="animate-spin" />
) : (
<RefreshCw size={14} />
)}
Test Connection
</button>
{connectionStatus && (
<div className={cn(
"flex items-center gap-2 text-sm",
connectionStatus.type === 'success' ? "text-green-400" : "text-red-400"
)}>
{connectionStatus.type === 'success' ? <Check size={14} /> : <AlertCircle size={14} />}
{connectionStatus.message}
</div>
)}
</div>
</div>
</div>
{/* Analysis Settings */}
<div className="card">
<div className="card-header">
<h3 className="text-sm font-semibold">Analysis Settings</h3>
</div>
<div className="card-body space-y-4">
<div className="flex items-center justify-between py-2">
<div>
<p className="text-sm font-medium text-gray-200">Auto-analyze new issues</p>
<p className="text-xs text-gray-500">Automatically analyze issues when received</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={aiConfig.autoAnalyze}
onChange={(e) => setAiConfig({ ...aiConfig, autoAnalyze: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-gray-700 rounded-full peer peer-checked:bg-indigo-600 after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-4" />
</label>
</div>
<div className="flex items-center justify-between py-2">
<div>
<p className="text-sm font-medium text-gray-200">Auto-create Pull Requests</p>
<p className="text-xs text-gray-500">Create PRs automatically for high-confidence fixes</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={aiConfig.autoCreatePR}
onChange={(e) => setAiConfig({ ...aiConfig, autoCreatePR: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-gray-700 rounded-full peer peer-checked:bg-indigo-600 after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-4" />
</label>
</div>
<div>
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wide">
Confidence Threshold for Auto-PR ({aiConfig.confidenceThreshold}%)
</label>
<input
type="range"
min="50"
max="95"
step="5"
value={aiConfig.confidenceThreshold}
onChange={(e) => setAiConfig({ ...aiConfig, confidenceThreshold: parseInt(e.target.value) })}
className="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
<div className="flex justify-between text-xs text-gray-600 mt-1">
<span>50% (More PRs)</span>
<span>95% (Higher quality)</span>
</div>
</div>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end">
<button onClick={handleSaveAiConfig} disabled={saving} className="btn btn-primary">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
Save AI Configuration
</button>
</div>
</div>
)}
{activeTab === 'notifications' && (
<div className="card animate-fade-in">
<div className="card-header">
@ -214,11 +486,11 @@ export default function Settings() {
<div className="p-4 bg-gray-900/50 rounded-lg border border-gray-800/50 mb-4">
<h4 className="text-xs font-semibold text-gray-400 mb-2">Incoming Webhook URLs</h4>
<div className="space-y-2">
{['jira', 'servicenow', 'github', 'gitlab'].map(source => (
{['tickethub', 'jira', 'servicenow', 'github', 'gitlab', 'gitea'].map(source => (
<div key={source} className="flex items-center gap-2">
<span className="text-xs text-gray-500 w-20 capitalize">{source}:</span>
<span className="text-xs text-gray-500 w-24 capitalize">{source}:</span>
<code className="text-xs text-indigo-400 font-mono flex-1 truncate">
https://jira-fixer.startdata.com.br/api/webhooks/{source}
https://jira-fixer.startdata.com.br/api/webhooks/{currentOrg.id}/{source}
</code>
<button className="text-gray-500 hover:text-gray-300"><Copy size={12} /></button>
</div>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JIRA AI Fixer</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<script type="module" crossorigin src="/assets/index-Be0hyHsH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-gQZrNcqD.css">
<script type="module" crossorigin src="/assets/index-BY2tGtHO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bw0JDVcx.css">
</head>
<body class="bg-gray-900 text-white">
<div id="root"></div>