fix(ai): disable categorization when last model is deleted

When the last AI model is deleted, categorization is now forced
off server-side, preventing it from silently restarting when a
new model is added. The frontend distinguishes between loading
and no connections, hiding the feature toggle instead of
disabling it, and removes the unused "Add a model first" message.
This commit is contained in:
Tautvydas Derzinskas committed 2026-10-14 15:54:47 +02:00
1 parent e542cc4473
commit bfc3fa0d72
6 files changed
+36 -27

No files matched your search

+5 -2
View File
@@ -744,8 +744,11 @@ router.delete(
requireAdmin,
asyncHandler(async (req, res) => {
await deleteAiConnection(req.params.id);
// A feature set to it goes back to the default.
if ((await getAiCategorizationPrefs()).aiConnectionId === req.params.id) {
// With no model left every feature switches off, so adding one later doesn't quietly start them again.
if (!(await listAiConnections()).length) {
await setAiCategorizationPrefs({ enabled: false, aiConnectionId: null });
} else if ((await getAiCategorizationPrefs()).aiConnectionId === req.params.id) {
// A feature set to it goes back to the default.
await setAiCategorizationPrefs({ aiConnectionId: null });
}
res.status(204).end();
+4
View File
@@ -406,6 +406,10 @@ describe("AI categorization", () => {
const remaining = await request(app).get("/api/settings/ai-connections").set(auth());
expect(remaining.body).toMatchObject([{ id: third.id, is_default: true }]);
// Deleting the last model switches the features off.
await request(app).delete(`/api/settings/ai-connections/${third.id}`).set(auth());
expect((await request(app).get("/api/settings/ai-categorization").set(auth())).body.enabled).toBe(false);
const unknown = await request(app)
.patch("/api/settings/ai-categorization")
.set(auth())
+1 -2
View File
@@ -659,8 +659,7 @@
"categorization": "AI Categorization",
"openSettings": "Settings",
"settingsAria": "{{feature}} settings",
"toggleAria": "Turn {{feature}} on or off",
"needsModel": "Add a model first"
"toggleAria": "Turn {{feature}} on or off"
},
"aiCategorization": {
"heading": "AI Categorization",
+1 -2
View File
@@ -663,8 +663,7 @@
"categorization": "DI kategorizavimas",
"openSettings": "Nustatymai",
"settingsAria": "{{feature}} nustatymai",
"toggleAria": "Įjungti arba išjungti: {{feature}}",
"needsModel": "Pirmiausia pridėkite modelį"
"toggleAria": "Įjungti arba išjungti: {{feature}}"
},
"aiCategorization": {
"heading": "DI kategorizavimas",
+24 -20
View File
@@ -16,7 +16,8 @@ import AiCategorizationDialog from "./AiCategorizationDialog";
import CategorizationAnimation from "./CategorizationAnimation";
import { AiLogo } from "./aiLogos";
type Props = { connections: AiConnection[]; onUnauthorized?: () => void };
/** `connections` is null while the models are loading. */
type Props = { connections: AiConnection[] | null; onUnauthorized?: () => void };
const TILE = 112;
const tileShadow = "inset 0 0 0 1px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04)";
@@ -29,7 +30,10 @@ export default function AiFeaturesSection({ connections, onUnauthorized }: Props
const [switching, setSwitching] = useState(false);
const [error, setError] = useState<string | null>(null);
// Read again when models come or go: deleting the last one switches the features off on the server.
const modelCount = connections?.length;
useEffect(() => {
if (modelCount === undefined) return;
let active = true;
void settingsApi
.getAiCategorization()
@@ -43,7 +47,7 @@ export default function AiFeaturesSection({ connections, onUnauthorized }: Props
return () => {
active = false;
};
}, [onUnauthorized, t]);
}, [modelCount, onUnauthorized, t]);
const toggle = async (enabled: boolean) => {
setSwitching(true);
@@ -60,15 +64,17 @@ export default function AiFeaturesSection({ connections, onUnauthorized }: Props
// The AI it would use: the one it's set to while that exists, otherwise the default, as the backend picks.
const model =
connections.find((connection) => connection.id === settings?.ai_connection_id) ??
connections.find((connection) => connection.is_default);
const enabled = Boolean(settings?.enabled);
connections?.find((connection) => connection.id === settings?.ai_connection_id) ??
connections?.find((connection) => connection.is_default);
const hasModel = Boolean(connections?.length);
// Without a model nothing can run, whatever was saved, and there's nothing to switch on until one is added.
const enabled = Boolean(settings?.enabled) && hasModel;
const label = t("adminSettings.aiFeatures.categorization");
return (
<Stack spacing={3}>
<SectionHeader title={t("adminSettings.aiFeatures.heading")} subtitle={t("adminSettings.aiFeatures.subtitle")} />
{settings === null && !error ? (
{(settings === null || connections === null) && !error ? (
<CircularProgress size={20} />
) : (
<Box>
@@ -122,19 +128,17 @@ export default function AiFeaturesSection({ connections, onUnauthorized }: Props
<Typography variant="body2" fontWeight={600}>
{label}
</Typography>
<Tooltip title={connections.length ? "" : t("adminSettings.aiFeatures.needsModel")}>
<span>
<Switch
size="small"
checked={enabled}
onChange={(event) => void toggle(event.target.checked)}
disabled={!settings || switching || (!connections.length && !enabled)}
slotProps={{
input: { "aria-label": t("adminSettings.aiFeatures.toggleAria", { feature: label }) },
}}
/>
</span>
</Tooltip>
{hasModel && (
<Switch
size="small"
checked={enabled}
onChange={(event) => void toggle(event.target.checked)}
disabled={!settings || switching}
slotProps={{
input: { "aria-label": t("adminSettings.aiFeatures.toggleAria", { feature: label }) },
}}
/>
)}
</Stack>
</Stack>
</Box>
@@ -151,7 +155,7 @@ export default function AiFeaturesSection({ connections, onUnauthorized }: Props
{open && settings && (
<AiCategorizationDialog
settings={settings}
connections={connections}
connections={connections ?? []}
onClose={() => setOpen(false)}
onSaved={setSettings}
onUnauthorized={onUnauthorized}
+1 -1
View File
@@ -28,7 +28,7 @@ export default function AiPage({ onUnauthorized }: Props) {
return (
<Stack spacing={4} divider={<Divider />}>
<AiConnectionsSection connections={connections} onChanged={load} onUnauthorized={onUnauthorized} />
<AiFeaturesSection connections={connections ?? []} onUnauthorized={onUnauthorized} />
<AiFeaturesSection connections={connections} onUnauthorized={onUnauthorized} />
</Stack>
);
}