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:
curo1305
2026-06-05 09:26:02 +02:00
co-authored by Claude Sonnet 4.6
parent ac2dded35b
commit 3b11b9a596
6 changed files with 457 additions and 29 deletions
+133 -16
View File
@@ -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"}