feat: add AI Configuration settings tab + fix API routing order
This commit is contained in:
parent
15867ecf92
commit
9067e79d70
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
59
app/main.py
59
app/main.py
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue