mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
feat: AI features split, categorisation UX updates
This commit is contained in:
1 parent
56f173a20b
commit
e542cc4473
13 files changed
+470
-103
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+63
-71
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user