feat(07-uat): admin AI panel UX enhancements + DocumentCard status indicators
- GET /api/admin/ai-config/models — fetch model list from provider's /models endpoint - POST /api/admin/ai-config/test-connection — replaced GET; accepts unsaved form values (api_key, base_url, model_name) so admins can test before saving - TestConnectionRequest Pydantic model with provider_id validation - SearchableModelSelect.vue — new reusable combobox; Teleport to body avoids overflow:hidden clipping; shows all models on open, filters only on typing; static "Enter manually" item always pinned at bottom; caches per provider - AdminAiConfigTab: model name input replaced with SearchableModelSelect; testingProvider ref + spinner added to Test Connection button - DocumentCard: processing spinner + "Classifying…", pending "Queued" badge, classification_failed badge; topics section only shown when status=ready - FileManagerView: onUnmounted imported (polling wiring pending next session) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
ac2dded35b
commit
3b11b9a596
+133
-16
@@ -152,6 +152,31 @@ class SystemAiConfigUpdate(BaseModel):
|
||||
return v
|
||||
|
||||
|
||||
class TestConnectionRequest(BaseModel):
|
||||
"""Request body for POST /api/admin/ai-config/test-connection.
|
||||
|
||||
Unsaved form values (api_key, base_url, model_name) override the DB row so
|
||||
admins can verify credentials before saving. All override fields are optional;
|
||||
omitting them falls back to whatever is stored in system_settings.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
provider_id: str
|
||||
api_key: Optional[str] = None # If non-empty, used instead of stored api_key_enc
|
||||
base_url: Optional[str] = None # If non-None, overrides DB base_url
|
||||
model_name: Optional[str] = None # If non-empty, overrides DB model_name
|
||||
|
||||
@field_validator("provider_id")
|
||||
@classmethod
|
||||
def provider_must_be_known(cls, v: str) -> str:
|
||||
if v not in PROVIDER_DEFAULTS:
|
||||
raise ValueError(
|
||||
f"Unknown provider_id {v!r}. Must be one of: {list(PROVIDER_DEFAULTS.keys())}"
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
class SystemTopicCreate(BaseModel):
|
||||
"""Request model for admin system topic creation (D-09)."""
|
||||
|
||||
@@ -628,32 +653,124 @@ async def create_system_topic(
|
||||
|
||||
# ── System AI Provider Configuration (D-08, D-15) ────────────────────────────
|
||||
|
||||
@router.get("/ai-config/test-connection")
|
||||
async def test_ai_connection(
|
||||
@router.get("/ai-config/models")
|
||||
async def get_ai_config_models(
|
||||
provider_id: str,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
"""Test connectivity for an AI provider configuration (D-08).
|
||||
"""Return the list of model IDs available from a provider's API (D-08).
|
||||
|
||||
Loads the system_settings row for the given provider_id (ignoring is_active
|
||||
so admins can test inactive configurations), builds a provider instance, and
|
||||
calls health_check().
|
||||
Calls the provider's standard GET /models endpoint using the stored
|
||||
config (base_url + api_key from system_settings). Always returns 200
|
||||
with {"models": [...]} — never 5xx on provider failure (returns empty list).
|
||||
|
||||
Returns {"ok": true/false, "provider_id": str} — never raises 5xx for
|
||||
provider-side failures (surfaces as ok=False so the UI shows a clear status).
|
||||
|
||||
Security: requires get_current_admin; provider_id sourced from query param,
|
||||
provider instance built only from DB row (never from request body).
|
||||
Security: requires get_current_admin; provider_id from query param only;
|
||||
decrypted api_key never appears in the response.
|
||||
"""
|
||||
import httpx # noqa: PLC0415 — local import keeps admin.py startup fast
|
||||
|
||||
config = await load_provider_config_by_id(session, provider_id)
|
||||
if config is None:
|
||||
return {"ok": False, "provider_id": provider_id, "reason": "not_configured"}
|
||||
|
||||
# Resolve base_url: prefer DB row, fall back to PROVIDER_DEFAULTS
|
||||
if config and config.base_url:
|
||||
base_url = config.base_url.rstrip("/")
|
||||
else:
|
||||
base_url = (PROVIDER_DEFAULTS.get(provider_id, {}).get("base_url") or "").rstrip("/")
|
||||
|
||||
if not base_url:
|
||||
return {"models": [], "provider_id": provider_id}
|
||||
|
||||
api_key = config.api_key if config else ""
|
||||
|
||||
# Build request headers — Anthropic uses x-api-key; all others use Bearer
|
||||
if provider_id == "anthropic":
|
||||
headers = {
|
||||
"x-api-key": api_key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
}
|
||||
models_url = "https://api.anthropic.com/v1/models"
|
||||
else:
|
||||
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
||||
models_url = f"{base_url}/models"
|
||||
|
||||
try:
|
||||
provider = get_provider(config)
|
||||
await provider.health_check()
|
||||
return {"ok": True, "provider_id": provider_id}
|
||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||
resp = await client.get(models_url, headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
# Standard OpenAI-compat shape: {"data": [{"id": "...", ...}, ...]}
|
||||
# Anthropic shape: {"data": [{"id": "...", ...}, ...]}
|
||||
# Ollama OpenAI-compat: same shape
|
||||
raw_list = data.get("data") or data.get("models") or []
|
||||
model_ids: list[str] = sorted(
|
||||
{
|
||||
item["id"] if isinstance(item, dict) else str(item)
|
||||
for item in raw_list
|
||||
if item
|
||||
}
|
||||
)
|
||||
return {"models": model_ids, "provider_id": provider_id}
|
||||
except Exception as exc:
|
||||
return {"models": [], "provider_id": provider_id, "error": str(exc)[:120]}
|
||||
|
||||
|
||||
@router.post("/ai-config/test-connection")
|
||||
async def test_ai_connection(
|
||||
body: TestConnectionRequest,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
"""Test connectivity for an AI provider, optionally with unsaved form values (D-08).
|
||||
|
||||
Loads the stored system_settings row for body.provider_id, then overlays any
|
||||
non-empty values from the request body so admins can verify credentials before
|
||||
saving them to the database.
|
||||
|
||||
Override priority (highest → lowest):
|
||||
1. body.api_key / base_url / model_name (unsaved form values)
|
||||
2. system_settings DB row (previously saved config)
|
||||
3. PROVIDER_DEFAULTS (built-in fallback)
|
||||
|
||||
Returns {"ok": true/false, "provider_id": str} — never raises 5xx for
|
||||
provider-side failures; surfaces as ok=False so the UI shows a clear status.
|
||||
|
||||
Security: requires get_current_admin; api_key from body is used only for the
|
||||
in-flight health_check() call and is never stored or logged.
|
||||
"""
|
||||
provider_id = body.provider_id
|
||||
stored = await load_provider_config_by_id(session, provider_id)
|
||||
defaults = PROVIDER_DEFAULTS.get(provider_id, {})
|
||||
|
||||
# Resolve effective values: body overrides DB, DB overrides PROVIDER_DEFAULTS
|
||||
effective_api_key = (
|
||||
body.api_key
|
||||
if body.api_key
|
||||
else (stored.api_key if stored else "")
|
||||
)
|
||||
effective_base_url = (
|
||||
body.base_url
|
||||
if body.base_url is not None
|
||||
else (stored.base_url if stored else defaults.get("base_url"))
|
||||
)
|
||||
effective_model = (
|
||||
body.model_name
|
||||
if body.model_name
|
||||
else (stored.model if stored else defaults.get("model", ""))
|
||||
)
|
||||
|
||||
effective_config = ProviderConfig(
|
||||
provider_id=provider_id,
|
||||
api_key=effective_api_key,
|
||||
base_url=effective_base_url,
|
||||
model=effective_model,
|
||||
)
|
||||
|
||||
try:
|
||||
provider = get_provider(effective_config)
|
||||
ok = await provider.health_check()
|
||||
return {"ok": ok, "provider_id": provider_id}
|
||||
except Exception:
|
||||
return {"ok": False, "provider_id": provider_id, "reason": "health_check_failed"}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user