feat: AI features split, categorisation UX updates

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-14 15:43:23 +02:00
1 parent 56f173a20b
commit e542cc4473
13 files changed
+470 -103

No files matched your search

+4 -1
View File
@@ -585,6 +585,7 @@ router.patch(
function aiCategorizationPrefsOut(prefs: AiCategorizationPrefs) {
return {
enabled: prefs.enabled,
mode: prefs.mode,
threshold: prefs.threshold,
on_import: prefs.onImport,
@@ -603,7 +604,8 @@ router.get(
const aiCategorizationPrefsSchema = z
.object({
mode: z.enum(["off", "suggest", "auto"]).optional(),
enabled: z.boolean().optional(),
mode: z.enum(["suggest", "auto"]).optional(),
threshold: z.number().min(0).max(1).optional(),
on_import: z.boolean().optional(),
send_image: z.boolean().optional(),
@@ -618,6 +620,7 @@ router.patch(
const body = parseBody(aiCategorizationPrefsSchema, req.body);
if (body.ai_connection_id) await getAiConnection(body.ai_connection_id);
const prefs = await setAiCategorizationPrefs({
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
...(body.mode !== undefined ? { mode: body.mode } : {}),
...(body.threshold !== undefined ? { threshold: body.threshold } : {}),
...(body.on_import !== undefined ? { onImport: body.on_import } : {}),
@@ -45,8 +45,13 @@ export async function getAiCategorizationSettings(): Promise<AiCategorizationSet
};
}
/** Suggestions are only shown while categorization is on; switched off, they stay stored for when it's back. */
export async function aiSuggestionsShown(): Promise<boolean> {
return aiCategorizationEnabled(await getAiCategorizationSettings());
}
export function aiCategorizationEnabled(settings: AiCategorizationSettings): boolean {
return settings.mode !== "off" && Boolean(settings.baseUrl && settings.model);
return settings.enabled && Boolean(settings.baseUrl && settings.model);
}
const suggestionSchema = z.object({
+10 -5
View File
@@ -1,7 +1,7 @@
import { prisma } from "../db";
import { HttpError } from "../utils/fileUtils";
import { toPrintOut, type PrintOut } from "../dto";
import { aiCategoryPaths, aiSuggestionOut } from "./aiCategorizationService";
import { aiCategoryPaths, aiSuggestionOut, aiSuggestionsShown } from "./aiCategorizationService";
import type { Author, Category, Plate, PreviewImage, Print, PrintFile } from "@prisma/client";
export type FullPrint = {
@@ -30,7 +30,11 @@ export async function loadFullPrint(userId: string, printId: string): Promise<Fu
}
export async function printOutById(userId: string, printId: string): Promise<PrintOut> {
const [full, categoryPaths] = await Promise.all([loadFullPrint(userId, printId), aiCategoryPaths(userId)]);
const [full, categoryPaths, suggestionsShown] = await Promise.all([
loadFullPrint(userId, printId),
aiCategoryPaths(userId),
aiSuggestionsShown(),
]);
return toPrintOut(
full.print,
full.plates,
@@ -39,7 +43,7 @@ export async function printOutById(userId: string, printId: string): Promise<Pri
full.print.author,
full.previewImages,
full.print.category,
await aiSuggestionOut(userId, full.print.aiSuggestion, categoryPaths),
suggestionsShown ? await aiSuggestionOut(userId, full.print.aiSuggestion, categoryPaths) : null,
);
}
@@ -57,12 +61,13 @@ function groupByPrintId<T extends { printId: string }>(rows: T[]): Map<string, T
export async function printOutsByIds(userId: string, printIds: string[]): Promise<Map<string, PrintOut>> {
const out = new Map<string, PrintOut>();
if (!printIds.length) return out;
const [prints, plates, files, previewImages, categoryPaths] = await Promise.all([
const [prints, plates, files, previewImages, categoryPaths, suggestionsShown] = await Promise.all([
prisma.print.findMany({ where: { id: { in: printIds }, userId }, include: { author: true, category: true } }),
prisma.plate.findMany({ where: { printId: { in: printIds } }, orderBy: { position: "asc" } }),
prisma.printFile.findMany({ where: { printId: { in: printIds } } }),
prisma.previewImage.findMany({ where: { printId: { in: printIds } }, orderBy: { position: "asc" } }),
aiCategoryPaths(userId),
aiSuggestionsShown(),
]);
const platesByPrint = groupByPrintId(plates);
const filesByPrint = groupByPrintId(files);
@@ -81,7 +86,7 @@ export async function printOutsByIds(userId: string, printIds: string[]): Promis
previewsByPrint.get(print.id) || [],
// The search palette shows it under each model.
print.category,
await aiSuggestionOut(userId, print.aiSuggestion, categoryPaths),
suggestionsShown ? await aiSuggestionOut(userId, print.aiSuggestion, categoryPaths) : null,
),
);
}
+12 -3
View File
@@ -196,9 +196,11 @@ export async function setFlaresolverrUrl(value: string | null): Promise<string |
return url;
}
export type AiCategorizationMode = "off" | "suggest" | "auto";
export type AiCategorizationMode = "suggest" | "auto";
/** How categorization behaves; which AI it talks to is an AiConnection (aiConnectionService.ts). */
export type AiCategorizationPrefs = {
/** Off hides it everywhere: no runs, no categorizing on import, no suggestions shown. */
enabled: boolean;
mode: AiCategorizationMode;
threshold: number;
onImport: boolean;
@@ -209,7 +211,8 @@ export type AiCategorizationPrefs = {
const AI_CATEGORIZATION_KEY = "ai_categorization";
export const DEFAULT_AI_CATEGORIZATION_PREFS: AiCategorizationPrefs = {
mode: "off",
enabled: false,
mode: "suggest",
threshold: 0.8,
onImport: true,
sendImage: false,
@@ -232,7 +235,13 @@ function validAiPrefs(value: unknown): Partial<AiCategorizationPrefs> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const raw = value as Record<string, unknown>;
return {
...(raw.mode === "off" || raw.mode === "suggest" || raw.mode === "auto" ? { mode: raw.mode } : {}),
// Saved before the switch, "off" was a mode: it reads as switched off, and either other mode as on.
...(typeof raw.enabled === "boolean"
? { enabled: raw.enabled }
: raw.mode === "suggest" || raw.mode === "auto"
? { enabled: true }
: {}),
...(raw.mode === "suggest" || raw.mode === "auto" ? { mode: raw.mode } : {}),
...(typeof raw.threshold === "number" && raw.threshold >= 0 && raw.threshold <= 1
? { threshold: raw.threshold }
: {}),
+43 -3
View File
@@ -144,7 +144,7 @@ async function enable(baseUrl: string, patch: Partial<AiCategorizationPrefs & Ai
thinking: true,
...connection,
});
await setAiCategorizationPrefs({ mode, threshold, onImport, sendImage, aiConnectionId: null });
await setAiCategorizationPrefs({ enabled: true, mode, threshold, onImport, sendImage, aiConnectionId: null });
return created;
}
@@ -258,8 +258,12 @@ describe("AI categorization", () => {
const listed = await request(app).get("/api/settings/ai-connections").set(auth());
expect(listed.body).toHaveLength(1);
expect(JSON.stringify(listed.body)).not.toContain("private-key");
const prefs = await request(app).patch("/api/settings/ai-categorization").set(auth()).send({ mode: "auto" });
const prefs = await request(app)
.patch("/api/settings/ai-categorization")
.set(auth())
.send({ enabled: true, mode: "auto" });
expect(prefs.body).toEqual({
enabled: true,
mode: "auto",
threshold: 0.8,
on_import: true,
@@ -273,6 +277,42 @@ describe("AI categorization", () => {
expect(detail.body.category_source).toBe("legacy");
});
it("hides suggestions and refuses work while switched off, and keeps them for when it's back on", async () => {
const target = await category("Switch target");
const baseUrl = await startProvider(() => ({ category_id: target.id, confidence: 0.6, reason: "fits" }));
await enable(baseUrl);
const model = await print();
await request(app).post(`/api/print/${model.id}/recategorize`).set(auth()).send({});
const shown = await request(app).get(`/api/print/${model.id}`).set(auth());
expect(shown.body.ai_suggestion).toMatchObject({ category_id: target.id });
await request(app).patch("/api/settings/ai-categorization").set(auth()).send({ enabled: false });
expect((await request(app).get("/api/ai-categorization/status").set(auth())).body.enabled).toBe(false);
expect((await request(app).get(`/api/print/${model.id}`).set(auth())).body.ai_suggestion).toBeNull();
const refused = await request(app).post(`/api/print/${model.id}/recategorize`).set(auth()).send({});
expect(refused.status).toBe(503);
await request(app).patch("/api/settings/ai-categorization").set(auth()).send({ enabled: true });
expect((await request(app).get(`/api/print/${model.id}`).set(auth())).body.ai_suggestion).toMatchObject({
category_id: target.id,
});
});
it("reads settings saved when off was a mode", async () => {
for (const [mode, enabled] of [
["off", false],
["auto", true],
] as const) {
await prisma.setting.upsert({
where: { key: "ai_categorization" },
create: { key: "ai_categorization", value: { mode, threshold: 0.7 } },
update: { value: { mode, threshold: 0.7 } },
});
const read = await request(app).get("/api/settings/ai-categorization").set(auth());
expect(read.body).toMatchObject({ enabled, mode: mode === "off" ? "suggest" : mode, threshold: 0.7 });
}
});
it("adds an AI only after it answers, and tests unsaved values", async () => {
const deadUrl = await startProvider(() => ({ category_id: null, confidence: 1, reason: "none" }));
await stopProvider();
@@ -1170,7 +1210,7 @@ describe("AI categorization", () => {
await enable(baseUrl);
await request(app).post("/api/ai-categorization/run").set(auth(runner.token)).send(runScope());
await arrivedAtProvider;
await setAiCategorizationPrefs({ mode: "off" });
await setAiCategorizationPrefs({ enabled: false });
release();
const stopped = await waitForRun(runner.token);
expect(stopped.body.run).toMatchObject({ total: 2, done: 2, failed: 0 });
+1 -1
View File
@@ -2,7 +2,7 @@ import { authHeaders } from "../utils/auth";
import { apiBase, readErrorMessage, UnauthorizedError } from "./client";
import type { Print } from "./prints";
export type AiCategorizationMode = "off" | "suggest" | "auto";
export type AiCategorizationMode = "suggest" | "auto";
export type AiCategorizationStatus = { enabled: boolean; mode: AiCategorizationMode };
export type AiRunCounts = {
+3 -1
View File
@@ -60,7 +60,9 @@ export type AuthSettings = {
/** How categorization behaves; the AIs themselves are in api/aiConnections.ts. */
export type AiCategorizationSettings = {
mode: "off" | "suggest" | "auto";
/** Off hides categorization everywhere; the mode is kept for when it's back on. */
enabled: boolean;
mode: "suggest" | "auto";
threshold: number;
on_import: boolean;
send_image: boolean;
+14 -7
View File
@@ -610,8 +610,8 @@
"ai": {
"heading": "AI",
"connection": {
"heading": "AIs",
"subtitle": "OpenAI-compatible services Thingport's AI features use. A feature uses the default AI unless it's set to another.",
"heading": "Models",
"subtitle": "OpenAI-compatible services Thingport's AI features use.",
"gridHint": "Click an AI to make it the default. Double-click to edit or test it, or right-click for more.",
"add": "Add AI",
"addTitle": "Add AI",
@@ -652,22 +652,29 @@
"failed": "Failed to load or save the AI."
}
},
"aiFeatures": {
"heading": "Features",
"subtitle": "What Thingport uses the models for.",
"hint": "Click a feature for its settings.",
"categorization": "AI Categorization",
"openSettings": "Settings",
"settingsAria": "{{feature}} settings",
"toggleAria": "Turn {{feature}} on or off",
"needsModel": "Add a model first"
},
"aiCategorization": {
"heading": "AI model categorization",
"subtitle": "Sort models into their owner's categories with the connected AI.",
"heading": "AI Categorization",
"privacy": "With a third-party API, model titles, descriptions and tags leave this server. Images are sent only when enabled.",
"useAi": "Use AI",
"defaultAi": "Default AI ({{model}})",
"noAi": "Add an AI above to use categorization.",
"noAi": "Add a model under Models to use categorization.",
"mode": "Mode",
"off": "Off",
"suggest": "Suggest for review",
"auto": "Apply automatically",
"threshold": "Auto-apply confidence threshold",
"onImport": "Categorize new imports",
"sendImage": "Send the first preview image",
"save": "Save settings",
"saved": "AI categorization settings saved.",
"failed": "Failed to load or save AI categorization settings."
},
"session": {
+14 -7
View File
@@ -612,8 +612,8 @@
"ai": {
"heading": "DI",
"connection": {
"heading": "DI",
"subtitle": "Su OpenAI suderinamos paslaugos, kurias naudoja Thingport DI funkcijos. Funkcija naudoja numatytąjį DI, nebent nustatytas kitas.",
"heading": "Modeliai",
"subtitle": "Su OpenAI suderinamos paslaugos, kurias naudoja Thingport DI funkcijos.",
"gridHint": "Spustelėkite DI, kad jis taptų numatytuoju. Dukart spustelėkite, kad redaguotumėte ar patikrintumėte, arba spustelėkite dešiniuoju mygtuku, kad pamatytumėte daugiau.",
"add": "Pridėti DI",
"addTitle": "Pridėti DI",
@@ -656,22 +656,29 @@
"failed": "Nepavyko įkelti arba išsaugoti DI."
}
},
"aiFeatures": {
"heading": "Funkcijos",
"subtitle": "Kam Thingport naudoja modelius.",
"hint": "Spustelėkite funkciją, kad pamatytumėte jos nustatymus.",
"categorization": "DI kategorizavimas",
"openSettings": "Nustatymai",
"settingsAria": "{{feature}} nustatymai",
"toggleAria": "Įjungti arba išjungti: {{feature}}",
"needsModel": "Pirmiausia pridėkite modelį"
},
"aiCategorization": {
"heading": "DI modelių kategorizavimas",
"subtitle": "Rūšiuokite modelius į jų savininko kategorijas naudodami prijungtą DI.",
"heading": "DI kategorizavimas",
"privacy": "Naudojant trečiosios šalies API, modelių pavadinimai, aprašymai ir žymos perduodami už šio serverio ribų. Paveikslėliai siunčiami tik įjungus šią parinktį.",
"useAi": "Naudoti DI",
"defaultAi": "Numatytasis DI ({{model}})",
"noAi": "Pridėkite DI aukščiau, kad galėtumėte naudoti kategorizavimą.",
"noAi": "Pridėkite modelį skiltyje „Modeliai“, kad galėtumėte naudoti kategorizavimą.",
"mode": "Režimas",
"off": "Išjungta",
"suggest": "Siūlyti peržiūrai",
"auto": "Taikyti automatiškai",
"threshold": "Automatinio taikymo patikimumo riba",
"onImport": "Kategorizuoti naujus importus",
"sendImage": "Siųsti pirmą peržiūros paveikslėlį",
"save": "Išsaugoti nustatymus",
"saved": "DI kategorizavimo nustatymai išsaugoti.",
"failed": "Nepavyko įkelti arba išsaugoti DI kategorizavimo nustatymų."
},
"session": {
@@ -1,13 +1,16 @@
import { useEffect, useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import Alert from "@mui/material/Alert";
import Button from "@mui/material/Button";
import CircularProgress from "@mui/material/CircularProgress";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import FormControl from "@mui/material/FormControl";
import FormControlLabel from "@mui/material/FormControlLabel";
import InputLabel from "@mui/material/InputLabel";
import MenuItem from "@mui/material/MenuItem";
import Paper from "@mui/material/Paper";
import Select from "@mui/material/Select";
import Stack from "@mui/material/Stack";
import Switch from "@mui/material/Switch";
@@ -16,58 +19,39 @@ import { UnauthorizedError } from "../../api/client";
import { settingsApi, type AiCategorizationSettings } from "../../api/settings";
import type { AiConnection } from "../../api/aiConnections";
import { hostOf } from "./aiLogos";
import SectionHeader from "../../components/SectionHeader";
type Props = { connections: AiConnection[]; onUnauthorized?: () => void };
type Props = {
settings: AiCategorizationSettings;
connections: AiConnection[];
onClose: () => void;
onSaved: (settings: AiCategorizationSettings) => void;
onUnauthorized?: () => void;
};
type Draft = Pick<AiCategorizationSettings, "mode" | "threshold" | "on_import" | "send_image" | "ai_connection_id">;
const defaults: Draft = { mode: "off", threshold: 0.8, on_import: true, send_image: false, ai_connection_id: null };
const pick = (settings: AiCategorizationSettings): Draft => ({
mode: settings.mode,
threshold: settings.threshold,
on_import: settings.on_import,
send_image: settings.send_image,
ai_connection_id: settings.ai_connection_id,
});
export default function AiCategorizationSection({ connections, onUnauthorized }: Props) {
const { t } = useTranslation("app");
const [draft, setDraft] = useState<Draft>(defaults);
const [loading, setLoading] = useState(true);
/** How AI categorization behaves. Turning it on and off is the switch on its tile. */
export default function AiCategorizationDialog({ settings, connections, onClose, onSaved, onUnauthorized }: Props) {
const { t } = useTranslation(["app", "common"]);
const [draft, setDraft] = useState<Draft>({
mode: settings.mode,
threshold: settings.threshold,
on_import: settings.on_import,
send_image: settings.send_image,
ai_connection_id: settings.ai_connection_id,
});
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let active = true;
void settingsApi
.getAiCategorization()
.then((settings) => {
if (active) setDraft(pick(settings));
})
.catch((err: unknown) => {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else if (active) setError(err instanceof Error ? err.message : t("adminSettings.aiCategorization.failed"));
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [onUnauthorized, t]);
const update = <K extends keyof Draft>(key: K, value: Draft[K]) =>
setDraft((current) => ({ ...current, [key]: value }));
const save = async () => {
setSaving(true);
setStatus(null);
setError(null);
try {
const saved = await settingsApi.updateAiCategorization({ ...draft, threshold: Number(draft.threshold) });
setDraft(pick(saved));
setStatus(t("adminSettings.aiCategorization.saved"));
onSaved(await settingsApi.updateAiCategorization({ ...draft, threshold: Number(draft.threshold) }));
onClose();
} catch (err) {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setError(err instanceof Error ? err.message : t("adminSettings.aiCategorization.failed"));
@@ -77,17 +61,17 @@ export default function AiCategorizationSection({ connections, onUnauthorized }:
};
return (
<Stack spacing={3}>
<SectionHeader
title={t("adminSettings.aiCategorization.heading")}
subtitle={t("adminSettings.aiCategorization.subtitle")}
/>
<Paper variant="outlined" sx={{ p: 2.5 }}>
<Stack spacing={2}>
<Dialog open onClose={saving ? undefined : onClose} fullWidth maxWidth="sm">
<DialogTitle>{t("adminSettings.aiCategorization.heading")}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ pt: 1 }}>
<Alert severity="info">{t("adminSettings.aiCategorization.privacy")}</Alert>
{!connections.length && <Alert severity="warning">{t("adminSettings.aiCategorization.noAi")}</Alert>}
<FormControl fullWidth>
<InputLabel id="ai-connection-label">{t("adminSettings.aiCategorization.useAi")}</InputLabel>
{/* Empty means the default AI, which still shows text, so the label stays up on the border. */}
<InputLabel id="ai-connection-label" shrink>
{t("adminSettings.aiCategorization.useAi")}
</InputLabel>
<Select
labelId="ai-connection-label"
label={t("adminSettings.aiCategorization.useAi")}
@@ -98,8 +82,9 @@ export default function AiCategorizationSection({ connections, onUnauthorized }:
: ""
}
displayEmpty
notched
onChange={(event) => update("ai_connection_id", event.target.value || null)}
disabled={loading || saving || !connections.length}
disabled={saving || !connections.length}
>
<MenuItem value="">
{t("adminSettings.aiCategorization.defaultAi", {
@@ -120,27 +105,28 @@ export default function AiCategorizationSection({ connections, onUnauthorized }:
label={t("adminSettings.aiCategorization.mode")}
value={draft.mode}
onChange={(event) => update("mode", event.target.value as Draft["mode"])}
disabled={loading || saving}
disabled={saving}
>
<MenuItem value="off">{t("adminSettings.aiCategorization.off")}</MenuItem>
<MenuItem value="suggest">{t("adminSettings.aiCategorization.suggest")}</MenuItem>
<MenuItem value="auto">{t("adminSettings.aiCategorization.auto")}</MenuItem>
</Select>
</FormControl>
<TextField
type="number"
label={t("adminSettings.aiCategorization.threshold")}
value={draft.threshold}
inputProps={{ min: 0, max: 1, step: 0.05 }}
onChange={(event) => update("threshold", Number(event.target.value))}
disabled={loading || saving}
/>
{draft.mode === "auto" && (
<TextField
type="number"
label={t("adminSettings.aiCategorization.threshold")}
value={draft.threshold}
inputProps={{ min: 0, max: 1, step: 0.05 }}
onChange={(event) => update("threshold", Number(event.target.value))}
disabled={saving}
/>
)}
<FormControlLabel
control={
<Switch
checked={draft.on_import}
onChange={(event) => update("on_import", event.target.checked)}
disabled={loading || saving}
disabled={saving}
/>
}
label={t("adminSettings.aiCategorization.onImport")}
@@ -150,21 +136,27 @@ export default function AiCategorizationSection({ connections, onUnauthorized }:
<Switch
checked={draft.send_image}
onChange={(event) => update("send_image", event.target.checked)}
disabled={loading || saving}
disabled={saving}
/>
}
label={t("adminSettings.aiCategorization.sendImage")}
/>
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap" useFlexGap>
<Button variant="contained" onClick={() => void save()} disabled={loading || saving}>
{t("adminSettings.aiCategorization.save")}
</Button>
{(saving || loading) && <CircularProgress size={14} />}
</Stack>
{status && <Alert severity="success">{status}</Alert>}
{error && <Alert severity="error">{error}</Alert>}
</Stack>
</Paper>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={onClose} disabled={saving}>
{t("common:cancel")}
</Button>
<Button
variant="contained"
onClick={() => void save()}
disabled={saving}
startIcon={saving ? <CircularProgress size={14} color="inherit" /> : undefined}
>
{t("adminSettings.aiCategorization.save")}
</Button>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,162 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import ButtonBase from "@mui/material/ButtonBase";
import CircularProgress from "@mui/material/CircularProgress";
import Stack from "@mui/material/Stack";
import Switch from "@mui/material/Switch";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import { UnauthorizedError } from "../../api/client";
import { settingsApi, type AiCategorizationSettings } from "../../api/settings";
import type { AiConnection } from "../../api/aiConnections";
import SectionHeader from "../../components/SectionHeader";
import AiCategorizationDialog from "./AiCategorizationDialog";
import CategorizationAnimation from "./CategorizationAnimation";
import { AiLogo } from "./aiLogos";
type Props = { connections: AiConnection[]; 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)";
/** What the AIs are used for, each as a tile: click it for its settings, switch it on or off underneath. */
export default function AiFeaturesSection({ connections, onUnauthorized }: Props) {
const { t } = useTranslation("app");
const [settings, setSettings] = useState<AiCategorizationSettings | null>(null);
const [open, setOpen] = useState(false);
const [switching, setSwitching] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let active = true;
void settingsApi
.getAiCategorization()
.then((loaded) => {
if (active) setSettings(loaded);
})
.catch((err: unknown) => {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else if (active) setError(err instanceof Error ? err.message : t("adminSettings.aiCategorization.failed"));
});
return () => {
active = false;
};
}, [onUnauthorized, t]);
const toggle = async (enabled: boolean) => {
setSwitching(true);
setError(null);
try {
setSettings(await settingsApi.updateAiCategorization({ enabled }));
} catch (err) {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setError(err instanceof Error ? err.message : t("adminSettings.aiCategorization.failed"));
} finally {
setSwitching(false);
}
};
// 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);
const label = t("adminSettings.aiFeatures.categorization");
return (
<Stack spacing={3}>
<SectionHeader title={t("adminSettings.aiFeatures.heading")} subtitle={t("adminSettings.aiFeatures.subtitle")} />
{settings === null && !error ? (
<CircularProgress size={20} />
) : (
<Box>
<Box sx={{ display: "grid", gridTemplateColumns: `repeat(auto-fill, ${TILE + 64}px)`, gap: 2 }}>
<Stack alignItems="center" spacing={0.75}>
<Tooltip title={t("adminSettings.aiFeatures.openSettings")} placement="top">
<ButtonBase
onClick={() => setOpen(true)}
disabled={!settings}
aria-label={t("adminSettings.aiFeatures.settingsAria", { feature: label })}
sx={{
position: "relative",
width: TILE,
height: TILE,
borderRadius: "22px",
bgcolor: "#ffffff",
boxShadow: tileShadow,
filter: enabled ? "none" : "grayscale(1)",
opacity: enabled ? 1 : 0.55,
transition: "filter 200ms, opacity 200ms, transform 150ms",
"&:hover": { transform: "translateY(-1px)" },
"&.Mui-focusVisible": { outline: 2, outlineColor: "primary.main", outlineOffset: 2 },
}}
>
<CategorizationAnimation size={84} paused={!enabled} />
{model && (
// A bubble over the corner, so it reads as which model the feature uses rather than part of it.
<Tooltip title={model.model} placement="bottom">
<Box
sx={{
position: "absolute",
right: -8,
bottom: -8,
width: 32,
height: 32,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "#ffffff",
boxShadow: "0 0 0 1px rgba(0, 0, 0, 0.08), 0 2px 6px rgba(0, 0, 0, 0.18)",
}}
>
<AiLogo model={model.model} baseUrl={model.base_url} size={18} />
</Box>
</Tooltip>
)}
</ButtonBase>
</Tooltip>
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ pt: 0.5 }}>
<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>
</Stack>
</Stack>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1.5 }}>
{t("adminSettings.aiFeatures.hint")}
</Typography>
</Box>
)}
{error && (
<Alert severity="error" onClose={() => setError(null)}>
{error}
</Alert>
)}
{open && settings && (
<AiCategorizationDialog
settings={settings}
connections={connections}
onClose={() => setOpen(false)}
onSaved={setSettings}
onUnauthorized={onUnauthorized}
/>
)}
</Stack>
);
}
@@ -0,0 +1,135 @@
import { useEffect, useState } from "react";
import Box from "@mui/material/Box";
// A conveyor of rows, one color each. Every row holds a square that belongs in the row below, which keeps a gap
// for it. Each step that square drops into its gap, then the rows move up one: the top row leaves and a new one
// comes in at the bottom. The colors cycle, so after a step the picture is the start again, one color on.
const COLORS = ["#ef4444", "#22c55e", "#a855f7", "#eab308"];
const COLUMNS = 5;
const VISIBLE = 3;
// One step: still, then the drop, then the rows moving up.
const HOLD_MS = 700;
const DROP_MS = 550;
const SCROLL_MS = 450;
type Phase = "hold" | "drop" | "scroll";
function move(ms: number): string {
return `transform ${ms}ms cubic-bezier(0.4, 0, 0.2, 1)`;
}
function mix(value: number): number {
let hash = Math.imul(value + 1, 0x9e3779b1);
hash ^= hash >>> 16;
hash = Math.imul(hash, 0x85ebca6b);
hash ^= hash >>> 13;
hash = Math.imul(hash, 0xc2b2ae35);
return (hash ^ (hash >>> 16)) >>> 0;
}
const misfitColumn = (conveyorRow: number) => (mix(conveyorRow) >>> 3) % COLUMNS;
/**
* Where a row keeps its misfit and its gap, scattered but fixed per row: keyed by the row's place in the endless
* conveyor, not on screen, so a row keeps its layout as it moves up and the loop stays seamless. A misfit in the
* same column as the row above's moves over, so the drops don't look repeated.
*/
function layoutOf(conveyorRow: number): { misfit: number; gap: number } {
const picked = misfitColumn(conveyorRow);
const misfit = picked === misfitColumn(conveyorRow - 1) ? (picked + 2) % COLUMNS : picked;
return { misfit, gap: (misfit + 1 + ((mix(conveyorRow) >>> 11) % (COLUMNS - 1))) % COLUMNS };
}
function prefersReducedMotion(): boolean {
return typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
}
/** Squares sorted into rows by color, looping; still while the feature is off or motion is reduced. */
export default function CategorizationAnimation({ size, paused }: { size: number; paused?: boolean }) {
const [step, setStep] = useState(0);
const [phase, setPhase] = useState<Phase>("hold");
const still = paused || prefersReducedMotion();
useEffect(() => {
if (still) {
setPhase("hold");
return;
}
const next: Record<Phase, [Phase, number]> = {
hold: ["drop", HOLD_MS],
drop: ["scroll", DROP_MS],
scroll: ["hold", SCROLL_MS],
};
const [to, after] = next[phase];
const timer = window.setTimeout(() => {
if (to === "hold") setStep((current) => current + 1);
setPhase(to);
}, after);
return () => window.clearTimeout(timer);
}, [phase, still]);
const rowHeight = size / VISIBLE;
const block = Math.round(size * 0.15);
const gap = (size - 8 - COLUMNS * block) / (COLUMNS - 1);
const x = (column: number) => 4 + column * (block + gap);
const y = (row: number) => row * rowHeight + (rowHeight - block) / 2;
const color = (row: number) => COLORS[(step + row) % COLORS.length];
const layout = (row: number) => layoutOf(step + row);
// The top row's misfit drops into the gap of the row below, wherever that is.
const dropping = phase === "drop" || phase === "scroll";
const drop = `translate(${x(layout(1).gap) - x(layout(0).misfit)}px, ${rowHeight}px)`;
return (
<Box sx={{ position: "relative", width: size, height: size, overflow: "hidden" }} aria-hidden>
<Box
sx={{
position: "absolute",
inset: 0,
transform: phase === "scroll" ? `translateY(${-rowHeight}px)` : "none",
transition: phase === "scroll" ? move(SCROLL_MS - 50) : "none",
}}
>
{/* One row more than shows, ready to come in at the bottom. */}
{Array.from({ length: VISIBLE + 1 }, (_row, row) => (
<Box key={`${step}-${row}`}>
<Box
sx={{
position: "absolute",
left: 1,
right: 1,
top: row * rowHeight + 2,
height: rowHeight - 4,
borderRadius: "7px",
bgcolor: `${color(row)}26`,
}}
/>
{Array.from({ length: COLUMNS }, (_column, column) => {
// The top row's gap was filled by the step before; the others are still waiting.
if (column === layout(row).gap && row > 0) return null;
const misfit = column === layout(row).misfit;
return (
<Box
key={column}
sx={{
position: "absolute",
left: x(column),
top: y(row),
width: block,
height: block,
borderRadius: "3px",
bgcolor: misfit ? color(row + 1) : color(row),
boxShadow: misfit ? "0 1px 3px rgba(0, 0, 0, 0.25)" : "none",
zIndex: misfit ? 1 : 0,
transform: misfit && row === 0 && dropping ? drop : "none",
transition: misfit && row === 0 && phase === "drop" ? move(DROP_MS - 50) : "none",
}}
/>
);
})}
</Box>
))}
</Box>
</Box>
);
}
+3 -3
View File
@@ -4,14 +4,14 @@ import Stack from "@mui/material/Stack";
import { UnauthorizedError } from "../../api/client";
import { aiConnectionsApi, type AiConnection } from "../../api/aiConnections";
import AiConnectionsSection from "./AiConnectionsSection";
import AiCategorizationSection from "./AiCategorizationSection";
import AiFeaturesSection from "./AiFeaturesSection";
type Props = {
onUnauthorized?: () => void;
};
export default function AiPage({ onUnauthorized }: Props) {
// Shared: categorization picks from the same list the grid shows.
// Shared: a feature picks from the same models the grid shows.
const [connections, setConnections] = useState<AiConnection[] | null>(null);
const load = useCallback(() => {
@@ -28,7 +28,7 @@ export default function AiPage({ onUnauthorized }: Props) {
return (
<Stack spacing={4} divider={<Divider />}>
<AiConnectionsSection connections={connections} onChanged={load} onUnauthorized={onUnauthorized} />
<AiCategorizationSection connections={connections ?? []} onUnauthorized={onUnauthorized} />
<AiFeaturesSection connections={connections ?? []} onUnauthorized={onUnauthorized} />
</Stack>
);
}