From 7c2c544a572ebd9efe8ea2ec36ca42bb6a758a12 Mon Sep 17 00:00:00 2001 From: TheBlackSide243 <125499423+TheBlackSide243@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:45:11 +0200 Subject: [PATCH 1/3] feat: AI server status in the sidebar Co-Authored-By: Claude Opus 5.5 --- backend/app/api/ai_status.py | 63 ++++++++++++++++++++++++++ backend/app/api/router.py | 2 + backend/tests/test_ai_status.py | 62 +++++++++++++++++++++++++ frontend/components/ai-status-dot.tsx | 39 ++++++++++++++++ frontend/components/mobile-sidebar.tsx | 2 + frontend/components/sidebar.tsx | 4 ++ frontend/lib/hooks/query-keys.ts | 1 + frontend/lib/hooks/use-ai-status.ts | 28 ++++++++++++ frontend/messages/de/nav.json | 10 ++++ frontend/messages/en/nav.json | 10 ++++ frontend/messages/fr/nav.json | 10 ++++ frontend/messages/it/nav.json | 10 ++++ frontend/messages/ja/nav.json | 10 ++++ frontend/messages/ko/nav.json | 10 ++++ frontend/messages/zh-CN/nav.json | 10 ++++ frontend/messages/zh-TW/nav.json | 10 ++++ 16 files changed, 281 insertions(+) create mode 100644 backend/app/api/ai_status.py create mode 100644 backend/tests/test_ai_status.py create mode 100644 frontend/components/ai-status-dot.tsx create mode 100644 frontend/lib/hooks/use-ai-status.ts diff --git a/backend/app/api/ai_status.py b/backend/app/api/ai_status.py new file mode 100644 index 000000000..12224d4c4 --- /dev/null +++ b/backend/app/api/ai_status.py @@ -0,0 +1,63 @@ +import time +from typing import Annotated, Literal + +from fastapi import APIRouter, Depends +from pydantic import BaseModel + +from app.config import get_settings +from app.models.user import User +from app.services.ai_service import get_ai_service +from app.utils.auth import get_current_user + +router = APIRouter(prefix="/ai", tags=["AI"]) + +# The sidebar asks every minute from every open tab; one probe per window is plenty. +CACHE_SECONDS = 20 +_cache: dict[str, tuple[float, "AIStatusResponse"]] = {} + + +class AIStatusResponse(BaseModel): + status: Literal["online", "model_missing", "offline", "disabled"] + vision_model: str | None = None + text_model: str | None = None + missing_models: list[str] = [] + + +def _matches(model: str, available: list[str]) -> bool: + # Ollama lists "name:tag"; a configured name without a tag means ":latest". + candidates = {model, model if ":" in model else f"{model}:latest"} + return any(name in candidates for name in available) + + +def summarize(raw: dict) -> AIStatusResponse: + endpoints = raw.get("endpoints", []) + healthy = [ep for ep in endpoints if ep.get("status") == "healthy"] + if not healthy: + first = endpoints[0] if endpoints else {} + return AIStatusResponse( + status="offline", + vision_model=first.get("vision_model"), + text_model=first.get("text_model"), + ) + endpoint = healthy[0] + available = endpoint.get("available_models") or [] + wanted = [m for m in (endpoint.get("vision_model"), endpoint.get("text_model")) if m] + missing = sorted({m for m in wanted if not _matches(m, available)}) + return AIStatusResponse( + status="model_missing" if missing else "online", + vision_model=endpoint.get("vision_model"), + text_model=endpoint.get("text_model"), + missing_models=missing, + ) + + +@router.get("/status", response_model=AIStatusResponse) +async def ai_status(_: Annotated[User, Depends(get_current_user)]) -> AIStatusResponse: + if not get_settings().ai_enabled: + return AIStatusResponse(status="disabled") + cached = _cache.get("status") + if cached and time.monotonic() - cached[0] < CACHE_SECONDS: + return cached[1] + result = summarize(await get_ai_service().check_health()) + _cache["status"] = (time.monotonic(), result) + return result diff --git a/backend/app/api/router.py b/backend/app/api/router.py index ef3f9c331..7fa3355d4 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -2,6 +2,7 @@ from fastapi import APIRouter +from app.api.ai_status import router as ai_status_router from app.api.analytics import router as analytics_router from app.api.auth import router as auth_router from app.api.families import router as families_router @@ -32,3 +33,4 @@ api_router.include_router(notifications_router, prefix="/notifications", tags=["notifications"]) api_router.include_router(analytics_router) api_router.include_router(learning_router) +api_router.include_router(ai_status_router) diff --git a/backend/tests/test_ai_status.py b/backend/tests/test_ai_status.py new file mode 100644 index 000000000..c5cb465bb --- /dev/null +++ b/backend/tests/test_ai_status.py @@ -0,0 +1,62 @@ +from io import BytesIO +from unittest.mock import AsyncMock, patch + +import pytest +from httpx import AsyncClient +from PIL import Image + +from app.api import ai_status + + +def _jpeg() -> bytes: + buffer = BytesIO() + Image.new("RGB", (400, 600), (90, 90, 90)).save(buffer, format="JPEG") + return buffer.getvalue() + + +class TestAIStatus: + def test_summarize_reports_a_missing_model(self): + raw = { + "endpoints": [ + { + "status": "healthy", + "vision_model": "Ornith-1.5-9B", + "text_model": "hf.co/ornith-ai/Ornith-1.5-9B-GGUF:Q5_K_M", + "available_models": ["hf.co/ornith-ai/Ornith-1.5-9B-GGUF:Q5_K_M"], + } + ] + } + result = ai_status.summarize(raw) + assert result.status == "model_missing" + assert result.missing_models == ["Ornith-1.5-9B"] + + def test_summarize_online_and_offline(self): + online = { + "endpoints": [ + { + "status": "healthy", + "vision_model": "gemma3", + "text_model": "gemma3", + "available_models": ["gemma3:latest"], + } + ] + } + assert ai_status.summarize(online).status == "online" + offline = {"endpoints": [{"status": "unhealthy", "vision_model": "gemma3"}]} + assert ai_status.summarize(offline).status == "offline" + + @pytest.mark.asyncio + async def test_endpoint_needs_sign_in_and_answers(self, client: AsyncClient, auth_headers): + assert (await client.get("/api/v1/ai/status")).status_code == 401 + ai_status._cache.clear() + health = { + "status": "unhealthy", + "endpoints": [{"status": "unhealthy", "vision_model": "m", "text_model": "m"}], + } + with patch( + "app.services.ai_service.AIService.check_health", AsyncMock(return_value=health) + ): + response = await client.get("/api/v1/ai/status", headers=auth_headers) + ai_status._cache.clear() + assert response.status_code == 200 + assert response.json()["status"] in ("offline", "disabled") diff --git a/frontend/components/ai-status-dot.tsx b/frontend/components/ai-status-dot.tsx new file mode 100644 index 000000000..c07969264 --- /dev/null +++ b/frontend/components/ai-status-dot.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useAIStatus } from '@/lib/hooks/use-ai-status'; +import { cn } from '@/lib/utils'; + +const DOT: Record = { + online: 'bg-green-500', + model_missing: 'bg-amber-500', + offline: 'bg-red-500', + disabled: 'bg-muted-foreground', + unknown: 'bg-muted-foreground', +}; + +/** Green when the AI server answers with the configured models, amber when a model is missing, + * red when the server does not answer. */ +export function AIStatusDot({ className }: { className?: string }) { + const t = useTranslations('nav.aiStatus'); + const { data, isError } = useAIStatus(); + const state = isError ? 'unknown' : (data?.status ?? 'unknown'); + const detail = + state === 'model_missing' && data?.missing_models.length + ? t('missingDetail', { models: data.missing_models.join(', ') }) + : data?.vision_model + ? t('modelDetail', { model: data.vision_model }) + : t('unknownDetail'); + + return ( +
+ + {state === 'online' && ( + + )} + + + {t(state)} +
+ ); +} diff --git a/frontend/components/mobile-sidebar.tsx b/frontend/components/mobile-sidebar.tsx index 8b312c6a4..e1973bf7a 100644 --- a/frontend/components/mobile-sidebar.tsx +++ b/frontend/components/mobile-sidebar.tsx @@ -6,6 +6,7 @@ import { usePathname } from 'next/navigation'; import { X, Home, Shirt, Sparkles, Layers, LayoutGrid, History, BarChart3, Brain, Settings, Users, Bell, HeartHandshake } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useTranslations } from 'next-intl'; +import { AIStatusDot } from '@/components/ai-status-dot'; interface MobileSidebarProps { open: boolean; @@ -145,6 +146,7 @@ export function MobileSidebar({ open, onClose }: MobileSidebarProps) { + diff --git a/frontend/components/sidebar.tsx b/frontend/components/sidebar.tsx index 90faf6630..485941e0c 100644 --- a/frontend/components/sidebar.tsx +++ b/frontend/components/sidebar.tsx @@ -18,6 +18,7 @@ import { } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useTranslations } from 'next-intl'; +import { AIStatusDot } from '@/components/ai-status-dot'; export function Sidebar() { const pathname = usePathname(); @@ -108,6 +109,9 @@ export function Sidebar() { })} +
  • + +
  • diff --git a/frontend/lib/hooks/query-keys.ts b/frontend/lib/hooks/query-keys.ts index 5be59a1e4..50a58324b 100644 --- a/frontend/lib/hooks/query-keys.ts +++ b/frontend/lib/hooks/query-keys.ts @@ -23,6 +23,7 @@ export const queryKeys = { userProfile: ['user-profile'] as const, features: ['features'] as const, weather: ['weather'] as const, + aiStatus: ['ai-status'] as const, preferences: ['preferences'] as const, family: ['family'] as const, diff --git a/frontend/lib/hooks/use-ai-status.ts b/frontend/lib/hooks/use-ai-status.ts new file mode 100644 index 000000000..7f16e5236 --- /dev/null +++ b/frontend/lib/hooks/use-ai-status.ts @@ -0,0 +1,28 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { useSession } from 'next-auth/react'; +import { api } from '@/lib/api'; +import { useSetTokenIfAvailable } from '@/lib/hooks/use-session-token'; +import { queryKeys } from '@/lib/hooks/query-keys'; + +export interface AIStatus { + status: 'online' | 'model_missing' | 'offline' | 'disabled'; + vision_model: string | null; + text_model: string | null; + missing_models: string[]; +} + +export function useAIStatus() { + const { status } = useSession(); + useSetTokenIfAvailable(); + + return useQuery({ + queryKey: queryKeys.aiStatus, + queryFn: () => api.get('/ai/status'), + enabled: status === 'authenticated', + refetchInterval: 60_000, + staleTime: 30_000, + retry: false, + }); +} diff --git a/frontend/messages/de/nav.json b/frontend/messages/de/nav.json index 25f262a0f..2f8400f90 100644 --- a/frontend/messages/de/nav.json +++ b/frontend/messages/de/nav.json @@ -1,5 +1,15 @@ { "aiLearning": "KI-Lernprofil", + "aiStatus": { + "disabled": "KI ausgeschaltet", + "missingDetail": "Dem KI-Server fehlt: {models}", + "modelDetail": "Modell: {model}", + "model_missing": "KI-Modell fehlt", + "offline": "KI nicht erreichbar", + "online": "KI aktiv", + "unknown": "KI-Status unbekannt", + "unknownDetail": "KI-Server wird geprüft" + }, "analytics": "Statistiken", "brandAlt": "wardrowbe", "brandName": "wardrowbe", diff --git a/frontend/messages/en/nav.json b/frontend/messages/en/nav.json index b782fb593..5b0c2fe7c 100644 --- a/frontend/messages/en/nav.json +++ b/frontend/messages/en/nav.json @@ -1,5 +1,15 @@ { "aiLearning": "AI Learning", + "aiStatus": { + "disabled": "AI turned off", + "missingDetail": "The AI server does not have: {models}", + "modelDetail": "Model: {model}", + "model_missing": "AI model not found", + "offline": "AI offline", + "online": "AI online", + "unknown": "AI status unknown", + "unknownDetail": "Checking the AI server" + }, "analytics": "Analytics", "brandAlt": "Wardrowbe", "brandName": "wardrowbe", diff --git a/frontend/messages/fr/nav.json b/frontend/messages/fr/nav.json index 77e6eed89..3c0d3bda7 100644 --- a/frontend/messages/fr/nav.json +++ b/frontend/messages/fr/nav.json @@ -1,5 +1,15 @@ { "aiLearning": "Apprentissage IA", + "aiStatus": { + "disabled": "IA désactivée", + "missingDetail": "Le serveur IA n'a pas : {models}", + "modelDetail": "Modèle : {model}", + "model_missing": "Modèle IA introuvable", + "offline": "IA hors ligne", + "online": "IA active", + "unknown": "État de l'IA inconnu", + "unknownDetail": "Vérification du serveur IA" + }, "analytics": "Statistiques", "brandAlt": "wardrowbe", "brandName": "wardrowbe", diff --git a/frontend/messages/it/nav.json b/frontend/messages/it/nav.json index 5d36a064b..6c9ddd11d 100644 --- a/frontend/messages/it/nav.json +++ b/frontend/messages/it/nav.json @@ -1,5 +1,15 @@ { "aiLearning": "Apprendimento IA", + "aiStatus": { + "disabled": "IA disattivata", + "missingDetail": "Il server IA non ha: {models}", + "modelDetail": "Modello: {model}", + "model_missing": "Modello IA non trovato", + "offline": "IA spenta", + "online": "IA attiva", + "unknown": "Stato IA sconosciuto", + "unknownDetail": "Controllo il server IA" + }, "analytics": "Analisi", "brandAlt": "Wardrowbe", "brandName": "Wardrowbe", diff --git a/frontend/messages/ja/nav.json b/frontend/messages/ja/nav.json index 2d43369ba..b79115935 100644 --- a/frontend/messages/ja/nav.json +++ b/frontend/messages/ja/nav.json @@ -1,5 +1,15 @@ { "aiLearning": "AI学習", + "aiStatus": { + "disabled": "AIオフ", + "missingDetail": "AIサーバーにありません:{models}", + "modelDetail": "モデル:{model}", + "model_missing": "AIモデルが見つかりません", + "offline": "AIオフライン", + "online": "AI稼働中", + "unknown": "AIの状態不明", + "unknownDetail": "AIサーバーを確認中" + }, "analytics": "分析", "brandAlt": "wardrowbe", "brandName": "wardrowbe", diff --git a/frontend/messages/ko/nav.json b/frontend/messages/ko/nav.json index 9da105e96..4b5996475 100644 --- a/frontend/messages/ko/nav.json +++ b/frontend/messages/ko/nav.json @@ -1,5 +1,15 @@ { "aiLearning": "AI 학습", + "aiStatus": { + "disabled": "AI 꺼짐", + "missingDetail": "AI 서버에 없음: {models}", + "modelDetail": "모델: {model}", + "model_missing": "AI 모델 없음", + "offline": "AI 오프라인", + "online": "AI 작동 중", + "unknown": "AI 상태 알 수 없음", + "unknownDetail": "AI 서버 확인 중" + }, "analytics": "분석", "brandAlt": "wardrowbe", "brandName": "wardrowbe", diff --git a/frontend/messages/zh-CN/nav.json b/frontend/messages/zh-CN/nav.json index a4260ff65..1efe7a7fd 100644 --- a/frontend/messages/zh-CN/nav.json +++ b/frontend/messages/zh-CN/nav.json @@ -1,5 +1,15 @@ { "aiLearning": "AI 学习", + "aiStatus": { + "disabled": "AI 已关闭", + "missingDetail": "AI 服务器缺少:{models}", + "modelDetail": "模型:{model}", + "model_missing": "未找到 AI 模型", + "offline": "AI 离线", + "online": "AI 在线", + "unknown": "AI 状态未知", + "unknownDetail": "正在检查 AI 服务器" + }, "analytics": "数据分析", "brandAlt": "wardrowbe", "brandName": "wardrowbe", diff --git a/frontend/messages/zh-TW/nav.json b/frontend/messages/zh-TW/nav.json index 52bd9bf5e..cb9e11bc5 100644 --- a/frontend/messages/zh-TW/nav.json +++ b/frontend/messages/zh-TW/nav.json @@ -1,5 +1,15 @@ { "aiLearning": "AI 學習", + "aiStatus": { + "disabled": "AI 已關閉", + "missingDetail": "AI 伺服器缺少:{models}", + "modelDetail": "模型:{model}", + "model_missing": "找不到 AI 模型", + "offline": "AI 離線", + "online": "AI 運作中", + "unknown": "AI 狀態未知", + "unknownDetail": "正在檢查 AI 伺服器" + }, "analytics": "數據分析", "brandAlt": "wardrowbe", "brandName": "wardrowbe", From b4545f1e436960899b47c45fc624fa1262130bd7 Mon Sep 17 00:00:00 2001 From: TheBlackSide243 <125499423+TheBlackSide243@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:12:34 +0200 Subject: [PATCH 2/3] fix(ai-status): address review findings - A model setting that lists several models (a rotation) counts as present when any of them is. - The model of a switched-off capability is not reported missing. - The endpoint test enables AI, requires 'offline' and checks the probe ran. Co-Authored-By: Claude Opus 5.5 --- backend/app/api/ai_status.py | 25 ++++++++++++++++--- backend/tests/test_ai_status.py | 43 ++++++++++++++++++++++++++++++--- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/backend/app/api/ai_status.py b/backend/app/api/ai_status.py index 12224d4c4..9ceddc23c 100644 --- a/backend/app/api/ai_status.py +++ b/backend/app/api/ai_status.py @@ -24,9 +24,26 @@ class AIStatusResponse(BaseModel): def _matches(model: str, available: list[str]) -> bool: - # Ollama lists "name:tag"; a configured name without a tag means ":latest". - candidates = {model, model if ":" in model else f"{model}:latest"} - return any(name in candidates for name in available) + """A configured model setting is there when any of its models is: the setting can be a + comma-separated rotation, and Ollama lists "name:tag" (no tag means ":latest").""" + for choice in (part.strip() for part in model.split(",")): + if not choice: + continue + candidates = {choice, choice if ":" in choice else f"{choice}:latest"} + if any(name in candidates for name in available): + return True + return False + + +def _wanted(vision_model: str | None, text_model: str | None) -> list[str]: + """The models in use: a capability that is switched off cannot be missing.""" + settings = get_settings() + wanted = [] + if vision_model and settings.effective_ai_vision_enabled: + wanted.append(vision_model) + if text_model and settings.effective_ai_text_enabled: + wanted.append(text_model) + return wanted def summarize(raw: dict) -> AIStatusResponse: @@ -41,7 +58,7 @@ def summarize(raw: dict) -> AIStatusResponse: ) endpoint = healthy[0] available = endpoint.get("available_models") or [] - wanted = [m for m in (endpoint.get("vision_model"), endpoint.get("text_model")) if m] + wanted = _wanted(endpoint.get("vision_model"), endpoint.get("text_model")) missing = sorted({m for m in wanted if not _matches(m, available)}) return AIStatusResponse( status="model_missing" if missing else "online", diff --git a/backend/tests/test_ai_status.py b/backend/tests/test_ai_status.py index c5cb465bb..1feae20da 100644 --- a/backend/tests/test_ai_status.py +++ b/backend/tests/test_ai_status.py @@ -6,6 +6,7 @@ from PIL import Image from app.api import ai_status +from app.config import get_settings def _jpeg() -> bytes: @@ -53,10 +54,46 @@ async def test_endpoint_needs_sign_in_and_answers(self, client: AsyncClient, aut "status": "unhealthy", "endpoints": [{"status": "unhealthy", "vision_model": "m", "text_model": "m"}], } - with patch( - "app.services.ai_service.AIService.check_health", AsyncMock(return_value=health) + check = AsyncMock(return_value=health) + with ( + patch.object(type(get_settings()), "ai_enabled", property(lambda self: True)), + patch("app.services.ai_service.AIService.check_health", check), ): response = await client.get("/api/v1/ai/status", headers=auth_headers) ai_status._cache.clear() assert response.status_code == 200 - assert response.json()["status"] in ("offline", "disabled") + assert response.json()["status"] == "offline" + check.assert_awaited_once() + + def test_a_rotation_is_there_when_any_of_its_models_is(self): + health = { + "endpoints": [ + { + "status": "healthy", + "vision_model": "llava:7b, gemma3", + "text_model": "gemma3", + "available_models": ["gemma3:latest"], + } + ] + } + assert ai_status.summarize(health).status == "online" + + def test_a_switched_off_capability_cannot_be_missing(self): + health = { + "endpoints": [ + { + "status": "healthy", + "vision_model": "not-installed", + "text_model": "gemma3", + "available_models": ["gemma3:latest"], + } + ] + } + settings = type(get_settings()) + with ( + patch.object(settings, "effective_ai_vision_enabled", property(lambda self: False)), + patch.object(settings, "effective_ai_text_enabled", property(lambda self: True)), + ): + result = ai_status.summarize(health) + assert result.status == "online" + assert result.missing_models == [] From a796282aa1eb8026c86cc65c04c72c20295ec0c7 Mon Sep 17 00:00:00 2001 From: TheBlackSide243 <125499423+TheBlackSide243@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:27:16 +0200 Subject: [PATCH 3/3] feat: use the AI endpoints and models chosen in Settings Settings > AI lets each user pick their own endpoints and models; tagging, suggestions and pairings already honour them. This feature now does too, through services/user_ai.ai_service_for, with the server's AI_* settings as the fallback. Co-Authored-By: Claude Opus 5.5 --- backend/app/api/ai_status.py | 55 +++++++++++++++++++++++++++++---- backend/app/services/user_ai.py | 33 ++++++++++++++++++++ backend/tests/test_ai_status.py | 29 +++++++++++++++++ backend/tests/test_user_ai.py | 41 ++++++++++++++++++++++++ 4 files changed, 152 insertions(+), 6 deletions(-) create mode 100644 backend/app/services/user_ai.py create mode 100644 backend/tests/test_user_ai.py diff --git a/backend/app/api/ai_status.py b/backend/app/api/ai_status.py index 9ceddc23c..28ae2e363 100644 --- a/backend/app/api/ai_status.py +++ b/backend/app/api/ai_status.py @@ -1,17 +1,23 @@ +import logging import time from typing import Annotated, Literal +import httpx from fastapi import APIRouter, Depends from pydantic import BaseModel from app.config import get_settings +from app.database import DbSession from app.models.user import User -from app.services.ai_service import get_ai_service +from app.services.user_ai import ai_service_for from app.utils.auth import get_current_user +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/ai", tags=["AI"]) -# The sidebar asks every minute from every open tab; one probe per window is plenty. +# The sidebar asks every minute from every open tab; one probe per window is plenty. Keyed by +# user, because each user can point the app at their own models. CACHE_SECONDS = 20 _cache: dict[str, tuple[float, "AIStatusResponse"]] = {} @@ -21,6 +27,11 @@ class AIStatusResponse(BaseModel): vision_model: str | None = None text_model: str | None = None missing_models: list[str] = [] + # Ollama only: models in memory right now (GET /api/ps). A configured model that is not + # loaded is fine: Ollama loads it on the first request, which is just slower. + url: str | None = None + loaded_models: list[str] = [] + model_loaded: bool | None = None def _matches(model: str, available: list[str]) -> bool: @@ -65,16 +76,48 @@ def summarize(raw: dict) -> AIStatusResponse: vision_model=endpoint.get("vision_model"), text_model=endpoint.get("text_model"), missing_models=missing, + url=endpoint.get("url"), ) +async def loaded_models(base_url: str) -> list[str] | None: + """Models an Ollama server holds in memory, or None when the server is not Ollama (or + does not answer /api/ps). Read only: asking never loads anything.""" + root = base_url.rstrip("/").removesuffix("/v1") + try: + async with httpx.AsyncClient(timeout=3) as client: + response = await client.get(f"{root}/api/ps") + if response.status_code != 200: + return None + return [m.get("name") or m.get("model", "") for m in response.json().get("models", [])] + except (httpx.HTTPError, ValueError) as e: + logger.debug(f"Could not read loaded models from {root}: {e}") + return None + + +async def with_loaded_models(result: AIStatusResponse) -> AIStatusResponse: + if result.status != "online" or not result.url: + return result + loaded = await loaded_models(result.url) + if loaded is None: + return result + wanted = _wanted(result.vision_model, result.text_model) + result.loaded_models = loaded + result.model_loaded = bool(wanted) and all(_matches(m, loaded) for m in wanted) + return result + + @router.get("/status", response_model=AIStatusResponse) -async def ai_status(_: Annotated[User, Depends(get_current_user)]) -> AIStatusResponse: +async def ai_status( + db: DbSession, current_user: Annotated[User, Depends(get_current_user)] +) -> AIStatusResponse: if not get_settings().ai_enabled: return AIStatusResponse(status="disabled") - cached = _cache.get("status") + key = str(current_user.id) + cached = _cache.get(key) if cached and time.monotonic() - cached[0] < CACHE_SECONDS: return cached[1] - result = summarize(await get_ai_service().check_health()) - _cache["status"] = (time.monotonic(), result) + ai = await ai_service_for(db, current_user.id) + result = await with_loaded_models(summarize(await ai.check_health())) + _cache[key] = (time.monotonic(), result) return result diff --git a/backend/app/services/user_ai.py b/backend/app/services/user_ai.py new file mode 100644 index 000000000..b7b705aa5 --- /dev/null +++ b/backend/app/services/user_ai.py @@ -0,0 +1,33 @@ +"""The AI service as the user configured it. + +Settings > AI lets each user pick their own endpoints and models (stored in +UserPreference.ai_endpoints). Tagging, outfit suggestions and pairings already honour them; +every feature that talks to the model should, so it goes through here. The server's +AI_BASE_URL / AI_*_MODEL stay as the fallback after the user's endpoints, as in AIService. +""" + +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.preference import UserPreference +from app.services.ai_service import AIService, get_ai_service + + +async def user_ai_endpoints(db: AsyncSession, user_id: UUID) -> list[dict]: + endpoints = await db.scalar( + select(UserPreference.ai_endpoints).where(UserPreference.user_id == user_id) + ) + return [ep for ep in endpoints or [] if ep.get("enabled", True)] + + +async def ai_service_for(db: AsyncSession, user_id: UUID) -> AIService: + """AIService using the user's own endpoints first; the shared default when they set none. + + Raises AIDisabledError when internal AI is off, like get_ai_service. + """ + endpoints = await user_ai_endpoints(db, user_id) + if not endpoints: + return get_ai_service() + return AIService(endpoints=endpoints) diff --git a/backend/tests/test_ai_status.py b/backend/tests/test_ai_status.py index 1feae20da..a751ef5b8 100644 --- a/backend/tests/test_ai_status.py +++ b/backend/tests/test_ai_status.py @@ -97,3 +97,32 @@ def test_a_switched_off_capability_cannot_be_missing(self): result = ai_status.summarize(health) assert result.status == "online" assert result.missing_models == [] + + +class TestLoadedModels: + def _online(self): + return ai_status.AIStatusResponse( + status="online", + vision_model="qwen2.5vl:7b", + text_model="qwen2.5vl:7b", + url="http://ollama:11434/v1", + ) + + @pytest.mark.asyncio + async def test_reports_whether_the_model_is_in_memory(self): + with patch("app.api.ai_status.loaded_models", AsyncMock(return_value=["qwen2.5vl:7b"])): + assert (await ai_status.with_loaded_models(self._online())).model_loaded is True + with patch("app.api.ai_status.loaded_models", AsyncMock(return_value=[])): + result = await ai_status.with_loaded_models(self._online()) + # Reachable with the model installed but not loaded: still online, loads on first use. + assert result.status == "online" + assert result.model_loaded is False + + @pytest.mark.asyncio + async def test_a_server_without_api_ps_leaves_it_unknown(self): + with patch("app.api.ai_status.loaded_models", AsyncMock(return_value=None)): + assert (await ai_status.with_loaded_models(self._online())).model_loaded is None + + @pytest.mark.asyncio + async def test_an_unreachable_server_is_unknown_not_an_error(self): + assert await ai_status.loaded_models("http://127.0.0.1:9/v1") is None diff --git a/backend/tests/test_user_ai.py b/backend/tests/test_user_ai.py new file mode 100644 index 000000000..87b2d8e8f --- /dev/null +++ b/backend/tests/test_user_ai.py @@ -0,0 +1,41 @@ +"""Features that call the model use the endpoints and models the user picked in Settings.""" + +import pytest + +from app.models.preference import UserPreference +from app.services.ai_service import get_ai_service +from app.services.user_ai import ai_service_for + + +class TestUserModels: + @pytest.mark.asyncio + async def test_the_users_own_endpoint_comes_first(self, db_session, test_user): + db_session.add( + UserPreference( + user_id=test_user.id, + ai_endpoints=[ + { + "name": "mine", + "url": "http://ollama.local:11434/v1", + "vision_model": "qwen2.5vl:7b", + "text_model": "qwen3:8b", + "enabled": True, + }, + {"name": "off", "url": "http://off/v1", "enabled": False}, + ], + ) + ) + await db_session.commit() + + ai = await ai_service_for(db_session, test_user.id) + first = ai._endpoints[0] + assert (first.url, first.vision_model, first.text_model) == ( + "http://ollama.local:11434/v1", + "qwen2.5vl:7b", + "qwen3:8b", + ) + assert all(ep.name != "off" for ep in ai._endpoints) + + @pytest.mark.asyncio + async def test_without_own_endpoints_the_server_default_is_used(self, db_session, test_user): + assert await ai_service_for(db_session, test_user.id) is get_ai_service()