mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-22 14:42:29 -05:00
feat: categorize models with an OpenAI-compatible AI provider (#11)
* feat: categorize models with an OpenAI-compatible AI provider Models can now be sorted into the user's categories by any OpenAI-compatible chat endpoint (llama.cpp, Ollama, vLLM, OpenAI, OpenRouter...), configured once by an admin under settings: mode off/suggest/auto, confidence threshold, categorize on import, concurrency, optional preview image. Every print now records where its category came from (manual, site rule, AI, or legacy for rows that predate this). AI never changes a manual choice: runs and the import hook skip them, and a single-model request needs force. Folders are never touched. Category changes go through one setPrintCategory path, so the name check and file relocation stay shared, and AI writes are a compare-and-set against what was read before the provider call, so an edit made meanwhile wins. Users get per-model recategorize, accept/reject of pending suggestions (a rejected category is not offered again), and a background "recategorize all" with scope flags, progress and cancel that stops after repeated provider failures. * feat: AI categorization settings, suggestions and bulk runs in the UI Admin settings get an AI categorization section with a connection test and a privacy note. The model menu offers "Recategorize with AI" (asking before it overrides a manual category), the side panel shows a pending suggestion with accept/reject and marks AI-set categories, and the models page has "Recategorize all with AI" with scope, progress and cancel, plus a pending suggestions list with bulk accept/reject. * fix: keep suggestion actions from overlapping the reason MUI's secondaryAction is absolutely positioned over a fixed padding, so the two buttons covered a long reason. They now sit in the row, and wrap under the text on narrow screens. * fix: put the clear API key action inside the key field A full-width outlined button under the field read as an empty input in an error state. It is now a small action at the end of the field, and the helper text says the key goes away on save once it's clicked. * refactor: update AI categorization admin part * refactor: move ai categorization buttons to categories manager modal * fix: multiple smaller problems with AI categorization --------- Co-authored-by: Tautvydas Derzinskas <tautvydasuk@gmail.com>
This commit is contained in:
1 parent
840733dd22
commit
708a93822a
39 files changed
+3432
-277
No files matched your search
@@ -0,0 +1,10 @@
|
||||
-- Preserve the distinction between an existing category and a category chosen after AI categorization.
|
||||
CREATE TYPE "CategorySource" AS ENUM ('MANUAL', 'RULE', 'AI', 'LEGACY');
|
||||
|
||||
ALTER TABLE "Print"
|
||||
ADD COLUMN "categorySource" "CategorySource",
|
||||
ADD COLUMN "aiSuggestion" JSONB,
|
||||
ADD COLUMN "aiRejectedCategoryIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
-- Existing assignments predate provenance tracking, so their origin cannot be inferred safely.
|
||||
UPDATE "Print" SET "categorySource" = 'LEGACY' WHERE "categoryId" IS NOT NULL;
|
||||
@@ -12,6 +12,13 @@ enum Role {
|
||||
MEMBER
|
||||
}
|
||||
|
||||
enum CategorySource {
|
||||
MANUAL
|
||||
RULE
|
||||
AI
|
||||
LEGACY
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
@@ -108,43 +115,48 @@ model Category {
|
||||
}
|
||||
|
||||
model Print {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
nameNormalized String
|
||||
title String?
|
||||
notes String?
|
||||
creator String?
|
||||
tags String[] @default([])
|
||||
viewCount Int @default(0)
|
||||
printCount Int @default(0)
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
nameNormalized String
|
||||
title String?
|
||||
notes String?
|
||||
creator String?
|
||||
tags String[] @default([])
|
||||
viewCount Int @default(0)
|
||||
printCount Int @default(0)
|
||||
// Back the Favourites and Browsing History pseudo-collections.
|
||||
favoritedAt DateTime?
|
||||
lastViewedAt DateTime?
|
||||
favoritedAt DateTime?
|
||||
lastViewedAt DateTime?
|
||||
// Dedupes re-imports of the same design. Null for uploads and unidentifiable sources.
|
||||
sourceProvider String?
|
||||
sourceExternalId String?
|
||||
sourceProvider String?
|
||||
sourceExternalId String?
|
||||
// Gaps (see services/sourceGaps.ts) the source had nothing for at the last fill.
|
||||
unfillableGaps String[] @default([])
|
||||
categoryId String?
|
||||
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||
authorId String?
|
||||
author Author? @relation(fields: [authorId], references: [id], onDelete: SetNull)
|
||||
sourcePath String?
|
||||
plates Plate[]
|
||||
previewImages PreviewImage[]
|
||||
descriptionImages DescriptionImage[]
|
||||
files PrintFile[] @relation("PrintFiles")
|
||||
preparedFileId String? @unique
|
||||
preparedFile PrintFile? @relation("PreparedFile", fields: [preparedFileId], references: [id], onDelete: SetNull)
|
||||
preparedMetadata Json?
|
||||
collectionItems CollectionItem[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
unfillableGaps String[] @default([])
|
||||
categoryId String?
|
||||
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||
categorySource CategorySource?
|
||||
// A provider result awaiting the owner's review: { categoryId, confidence, reason, model, createdAt }.
|
||||
aiSuggestion Json?
|
||||
// A rejected category is never offered to the provider for this model again.
|
||||
aiRejectedCategoryIds String[] @default([])
|
||||
authorId String?
|
||||
author Author? @relation(fields: [authorId], references: [id], onDelete: SetNull)
|
||||
sourcePath String?
|
||||
plates Plate[]
|
||||
previewImages PreviewImage[]
|
||||
descriptionImages DescriptionImage[]
|
||||
files PrintFile[] @relation("PrintFiles")
|
||||
preparedFileId String? @unique
|
||||
preparedFile PrintFile? @relation("PreparedFile", fields: [preparedFileId], references: [id], onDelete: SetNull)
|
||||
preparedMetadata Json?
|
||||
collectionItems CollectionItem[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
// Generated column (name A, tags B, notes C, creator D) created with its GIN index in a
|
||||
// hand-written migration, since Prisma can't express a generated tsvector.
|
||||
searchVector Unsupported("tsvector")?
|
||||
searchVector Unsupported("tsvector")?
|
||||
|
||||
@@unique([userId, categoryId, nameNormalized])
|
||||
@@unique([userId, sourceProvider, sourceExternalId])
|
||||
|
||||
@@ -23,6 +23,7 @@ import notificationsRoutes from "./routes/notifications";
|
||||
import adminRoutes from "./routes/admin";
|
||||
import dashboardRoutes from "./routes/dashboard";
|
||||
import activityRoutes from "./routes/activity";
|
||||
import aiCategorizationRoutes from "./routes/aiCategorization";
|
||||
|
||||
export function createApp(): Express {
|
||||
const app = express();
|
||||
@@ -44,6 +45,7 @@ export function createApp(): Express {
|
||||
app.use("/api", authRoutes);
|
||||
app.use("/api", settingsRoutes);
|
||||
app.use("/api", printsRoutes);
|
||||
app.use("/api", aiCategorizationRoutes);
|
||||
app.use("/api", authorsRoutes);
|
||||
app.use("/api", platesRoutes);
|
||||
app.use("/api", previewImagesRoutes);
|
||||
|
||||
@@ -124,6 +124,15 @@ export type PrintOut = {
|
||||
category_id: string | null;
|
||||
// Only populated by the detail fetch; list endpoints skip the join.
|
||||
category_name: string | null;
|
||||
category_source: "manual" | "rule" | "ai" | "legacy" | null;
|
||||
ai_suggestion: {
|
||||
category_id: string;
|
||||
category_path: string;
|
||||
confidence: number;
|
||||
reason: string;
|
||||
model: string;
|
||||
created_at: string;
|
||||
} | null;
|
||||
created_at: string;
|
||||
storage_path: string | null;
|
||||
plates: PlateOut[];
|
||||
@@ -230,6 +239,7 @@ export function toPrintOut(
|
||||
author?: Author | null,
|
||||
previewImages?: PreviewImage[],
|
||||
category?: Category | null,
|
||||
aiSuggestion?: PrintOut["ai_suggestion"],
|
||||
): PrintOut {
|
||||
const sortedPlates = plates.toSorted((a, b) => a.position - b.position);
|
||||
const plateOuts = sortedPlates.map((p) => toPlateOut(print.id, p));
|
||||
@@ -285,6 +295,8 @@ export function toPrintOut(
|
||||
tags: print.tags,
|
||||
category_id: print.categoryId,
|
||||
category_name: category?.name ?? null,
|
||||
category_source: print.categorySource ? (print.categorySource.toLowerCase() as PrintOut["category_source"]) : null,
|
||||
ai_suggestion: aiSuggestion ?? null,
|
||||
created_at: print.createdAt.toISOString(),
|
||||
storage_path: storageParentDir(sortedPlates),
|
||||
plates: plateOuts,
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Router, type NextFunction, type Request, type Response } from "express";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { requireAuth } from "../auth";
|
||||
import { prisma } from "../db";
|
||||
import { HttpError } from "../utils/fileUtils";
|
||||
import { asyncHandler } from "../utils/asyncHandler";
|
||||
import { parseBody } from "../utils/validate";
|
||||
import { printOutById, printOutsByIds } from "../services/printLoader";
|
||||
import {
|
||||
acceptAiSuggestion,
|
||||
aiCategorizationCounts,
|
||||
cancelAiCategorizationRun,
|
||||
categorizePrint,
|
||||
currentAiCategorizationRun,
|
||||
rejectAiSuggestion,
|
||||
startAiCategorizationRun,
|
||||
type AiCategorizationRun,
|
||||
} from "../services/aiCategorizationService";
|
||||
import { aiCategorizationEnabled, getAiCategorizationSettings } from "../services/settingsService";
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
function runOut(run: AiCategorizationRun | null) {
|
||||
if (!run) return null;
|
||||
return {
|
||||
running: run.running,
|
||||
started_at: run.startedAt,
|
||||
finished_at: run.finishedAt,
|
||||
total: run.total,
|
||||
done: run.done,
|
||||
applied: run.applied,
|
||||
suggested: run.suggested,
|
||||
no_match: run.noMatch,
|
||||
failed: run.failed,
|
||||
cancelled: run.cancelled,
|
||||
last_error: run.lastError,
|
||||
};
|
||||
}
|
||||
|
||||
function aiFailure(error: unknown): never {
|
||||
if (error instanceof HttpError) throw error;
|
||||
throw new HttpError(502, "AI provider failed", "ai_failed");
|
||||
}
|
||||
|
||||
router.get(
|
||||
"/ai-categorization/status",
|
||||
asyncHandler(async (_req, res) => {
|
||||
const settings = await getAiCategorizationSettings();
|
||||
res.json({ enabled: aiCategorizationEnabled(settings), mode: settings.mode });
|
||||
}),
|
||||
);
|
||||
|
||||
const recategorizeSchema = z.object({ force: z.boolean().optional() });
|
||||
router.post(
|
||||
"/print/:id/recategorize",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(recategorizeSchema, req.body);
|
||||
let outcome;
|
||||
try {
|
||||
outcome = await categorizePrint(req.userId!, req.params.id, body);
|
||||
} catch (error) {
|
||||
aiFailure(error);
|
||||
}
|
||||
res.json({ print: await printOutById(req.userId!, req.params.id), outcome });
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/print/:id/ai-suggestion/accept",
|
||||
asyncHandler(async (req, res) => {
|
||||
await acceptAiSuggestion(req.userId!, req.params.id);
|
||||
res.json({ print: await printOutById(req.userId!, req.params.id) });
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/print/:id/ai-suggestion/reject",
|
||||
asyncHandler(async (req, res) => {
|
||||
await rejectAiSuggestion(req.userId!, req.params.id);
|
||||
res.json({ print: await printOutById(req.userId!, req.params.id) });
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/ai-categorization/suggestions",
|
||||
asyncHandler(async (req, res) => {
|
||||
const limit = Math.min(100, Math.max(1, Number.parseInt(String(req.query.limit ?? "50"), 10) || 50));
|
||||
const offset = Math.max(0, Number.parseInt(String(req.query.offset ?? "0"), 10) || 0);
|
||||
const where = { userId: req.userId!, aiSuggestion: { not: Prisma.DbNull } };
|
||||
const [total, rows] = await Promise.all([
|
||||
prisma.print.count({ where }),
|
||||
prisma.print.findMany({ where, orderBy: { updatedAt: "desc" }, skip: offset, take: limit, select: { id: true } }),
|
||||
]);
|
||||
const outs = await printOutsByIds(
|
||||
req.userId!,
|
||||
rows.map((row) => row.id),
|
||||
);
|
||||
res.json({
|
||||
total,
|
||||
items: rows.flatMap((row) => {
|
||||
const item = outs.get(row.id);
|
||||
return item ? [item] : [];
|
||||
}),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const bulkSchema = z.object({ action: z.enum(["accept", "reject"]), print_ids: z.array(z.string()).max(500) });
|
||||
router.post(
|
||||
"/ai-categorization/suggestions/bulk",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(bulkSchema, req.body);
|
||||
let accepted = 0;
|
||||
let rejected = 0;
|
||||
let failed = 0;
|
||||
for (const printId of new Set(body.print_ids)) {
|
||||
try {
|
||||
if (body.action === "accept") {
|
||||
await acceptAiSuggestion(req.userId!, printId);
|
||||
accepted++;
|
||||
} else {
|
||||
await rejectAiSuggestion(req.userId!, printId);
|
||||
rejected++;
|
||||
}
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
res.json({ accepted, rejected, failed });
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/ai-categorization/run",
|
||||
asyncHandler(async (req, res) => {
|
||||
res.json({
|
||||
counts: await aiCategorizationCounts(req.userId!),
|
||||
run: runOut(currentAiCategorizationRun(req.userId!)),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const runSchema = z.object({ include_ai: z.boolean(), include_rule: z.boolean(), include_legacy: z.boolean() });
|
||||
router.post(
|
||||
"/ai-categorization/run",
|
||||
asyncHandler(async (req, res) => {
|
||||
const settings = await getAiCategorizationSettings();
|
||||
if (!aiCategorizationEnabled(settings)) throw new HttpError(503, "AI categorization is disabled", "ai_disabled");
|
||||
const body = parseBody(runSchema, req.body);
|
||||
const run = startAiCategorizationRun(req.userId!, {
|
||||
includeAi: body.include_ai,
|
||||
includeRule: body.include_rule,
|
||||
includeLegacy: body.include_legacy,
|
||||
});
|
||||
if (!run) throw new HttpError(409, "AI categorization run is already active", "running");
|
||||
res.status(202).json({ run: runOut(run) });
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/ai-categorization/run/cancel",
|
||||
asyncHandler(async (req, res) => {
|
||||
res.json({ run: runOut(cancelAiCategorizationRun(req.userId!)) });
|
||||
}),
|
||||
);
|
||||
|
||||
// These client actions have a small, documented error shape separate from the older API's
|
||||
// `detail` convention, so callers can branch on a stable code without parsing text.
|
||||
router.use((error: unknown, _req: Request, res: Response, next: NextFunction) => {
|
||||
if (error instanceof HttpError && error.code) {
|
||||
res.status(error.status).json({ error: error.message, code: error.code });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -244,7 +244,7 @@ router.delete(
|
||||
const prints = await prisma.print.findMany({ where: { categoryId: category.id, userId: req.userId } });
|
||||
for (const print of prints) {
|
||||
const nextName = await availableModelName(req.userId!, print.name, null, print.id);
|
||||
const data: Prisma.PrintUpdateInput = { category: { disconnect: true } };
|
||||
const data: Prisma.PrintUpdateInput = { category: { disconnect: true }, categorySource: null };
|
||||
if (nextName !== print.name) {
|
||||
data.name = nextName;
|
||||
data.nameNormalized = nextName.trim().toLowerCase();
|
||||
|
||||
@@ -11,6 +11,7 @@ import { asyncHandler } from "../utils/asyncHandler";
|
||||
import { modelUpload } from "../uploadMiddleware";
|
||||
import { createPrint, deletePlateFiles, resolvePlateFilePath, type NewPlateInput } from "../services/printCreation";
|
||||
import { plateThumbPath, relocatePrint, relocatePrintsForToken, uniqueModelName } from "../services/printService";
|
||||
import { setPrintCategory } from "../services/printCategoryService";
|
||||
import { previewImagePath, deleteAllPreviewImages } from "../services/previewImageService";
|
||||
import {
|
||||
deleteAllDescriptionImages,
|
||||
@@ -124,6 +125,7 @@ router.post(
|
||||
notes: body.notes || null,
|
||||
tags,
|
||||
categoryId: body.category_id || null,
|
||||
categorySource: body.category_id ? ("MANUAL" as const) : null,
|
||||
};
|
||||
|
||||
const printsOut = [];
|
||||
@@ -552,27 +554,21 @@ router.post(
|
||||
"/print/:id/category",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(categoryUpdateSchema, req.body);
|
||||
const print = await prisma.print.findFirst({ where: { id: req.params.id, userId: req.userId } });
|
||||
if (!print) throw new HttpError(404, "Print not found");
|
||||
const categoryId = body.category_id || null;
|
||||
const data: Prisma.PrintUpdateInput = {};
|
||||
if (categoryId) {
|
||||
const category = await prisma.category.findFirst({ where: { id: categoryId, userId: req.userId } });
|
||||
if (!category) throw new HttpError(400, "Category not found");
|
||||
await uniqueModelName(req.userId!, print.name, category.id, print.id);
|
||||
data.category = { connect: { id: category.id } };
|
||||
} else {
|
||||
await uniqueModelName(req.userId!, print.name, null, print.id);
|
||||
data.category = { disconnect: true };
|
||||
}
|
||||
const updated = await prisma.print.update({ where: { id: print.id }, data });
|
||||
const plates = await prisma.plate.findMany({ where: { printId: print.id }, orderBy: { position: "asc" } });
|
||||
await relocatePrint(updated, plates);
|
||||
res.json({ print: await printOutById(req.userId!, print.id) });
|
||||
const updated = await setPrintCategory(
|
||||
req.userId!,
|
||||
req.params.id,
|
||||
body.category_id || null,
|
||||
body.category_id ? "MANUAL" : null,
|
||||
{
|
||||
clearSuggestion: true,
|
||||
},
|
||||
);
|
||||
if (!updated) throw new HttpError(409, "Category changed concurrently");
|
||||
res.json({ print: await printOutById(req.userId!, updated.id) });
|
||||
void createLog({
|
||||
userId: req.userId!,
|
||||
action: "model_edited",
|
||||
targetId: print.id,
|
||||
targetId: updated.id,
|
||||
details: { field: "category", name: updated.name },
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
setSmtpSettings,
|
||||
setThingiverseAccessToken,
|
||||
type SmtpSettings,
|
||||
getAiCategorizationSettings,
|
||||
setAiCategorizationSettings,
|
||||
} from "../services/settingsService";
|
||||
import { getDatabaseInfo, testAndSwitchDatabase } from "../services/databaseSettingsService";
|
||||
import {
|
||||
@@ -49,6 +51,7 @@ import { THEME_SELECTIONS, getUserTheme, setUserTheme } from "../services/themeP
|
||||
import { getUserAuthorPreviewEnabled, setUserAuthorPreviewEnabled } from "../services/authorPreviewPreferenceService";
|
||||
import { checkForUpdates } from "../services/versionService";
|
||||
import { dropPreviewsAffectedBySimplification } from "../services/modelPreviewCache";
|
||||
import { testAiCategorizationProvider } from "../services/aiCategorizationService";
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
@@ -460,4 +463,95 @@ router.patch(
|
||||
}),
|
||||
);
|
||||
|
||||
function aiCategorizationSettingsOut(settings: Awaited<ReturnType<typeof getAiCategorizationSettings>>) {
|
||||
return {
|
||||
mode: settings.mode,
|
||||
base_url: settings.baseUrl,
|
||||
model: settings.model,
|
||||
has_api_key: Boolean(settings.apiKey),
|
||||
threshold: settings.threshold,
|
||||
on_import: settings.onImport,
|
||||
concurrency: settings.concurrency,
|
||||
send_image: settings.sendImage,
|
||||
timeout_ms: settings.timeoutMs,
|
||||
};
|
||||
}
|
||||
|
||||
router.get(
|
||||
"/settings/ai-categorization",
|
||||
requireAdmin,
|
||||
asyncHandler(async (_req, res) => {
|
||||
res.json(aiCategorizationSettingsOut(await getAiCategorizationSettings()));
|
||||
}),
|
||||
);
|
||||
|
||||
const aiCategorizationSettingsSchema = z
|
||||
.object({
|
||||
mode: z.enum(["off", "suggest", "auto"]).optional(),
|
||||
base_url: z.string().trim().nullable().optional(),
|
||||
api_key: z.string().nullable().optional(),
|
||||
model: z.string().trim().nullable().optional(),
|
||||
threshold: z.number().min(0).max(1).optional(),
|
||||
on_import: z.boolean().optional(),
|
||||
concurrency: z.number().int().min(1).max(4).optional(),
|
||||
send_image: z.boolean().optional(),
|
||||
timeout_ms: z.number().int().min(1).max(300000).optional(),
|
||||
})
|
||||
.strict();
|
||||
router.patch(
|
||||
"/settings/ai-categorization",
|
||||
requireAdmin,
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(aiCategorizationSettingsSchema, req.body);
|
||||
const connection = {
|
||||
...(body.base_url !== undefined ? { baseUrl: body.base_url?.replace(/\/+$/, "") || null } : {}),
|
||||
...(body.api_key !== undefined ? { apiKey: body.api_key } : {}),
|
||||
...(body.model !== undefined ? { model: body.model || null } : {}),
|
||||
...(body.timeout_ms !== undefined ? { timeoutMs: body.timeout_ms } : {}),
|
||||
};
|
||||
// A changed connection is only saved once it answers; clearing the URL or model disconnects without a test.
|
||||
if (Object.keys(connection).length) {
|
||||
const next = { ...(await getAiCategorizationSettings()), ...connection };
|
||||
if (next.baseUrl && next.model) {
|
||||
const result = await testAiCategorizationProvider(connection);
|
||||
if (!result.ok)
|
||||
throw new HttpError(422, `Could not connect to the AI provider: ${result.error}`, "ai_connection_failed");
|
||||
}
|
||||
}
|
||||
const settings = await setAiCategorizationSettings({
|
||||
...connection,
|
||||
...(body.mode !== undefined ? { mode: body.mode } : {}),
|
||||
...(body.threshold !== undefined ? { threshold: body.threshold } : {}),
|
||||
...(body.on_import !== undefined ? { onImport: body.on_import } : {}),
|
||||
...(body.concurrency !== undefined ? { concurrency: body.concurrency } : {}),
|
||||
...(body.send_image !== undefined ? { sendImage: body.send_image } : {}),
|
||||
});
|
||||
res.json(aiCategorizationSettingsOut(settings));
|
||||
}),
|
||||
);
|
||||
|
||||
// Omitted fields use the saved value; api_key null tests without a key.
|
||||
const aiCategorizationTestSchema = z
|
||||
.object({
|
||||
base_url: z.string().trim().min(1).optional(),
|
||||
model: z.string().trim().min(1).optional(),
|
||||
api_key: z.string().nullable().optional(),
|
||||
timeout_ms: z.number().int().min(1).max(300000).optional(),
|
||||
})
|
||||
.strict();
|
||||
router.post(
|
||||
"/settings/ai-categorization/test",
|
||||
requireAdmin,
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(aiCategorizationTestSchema, req.body ?? {});
|
||||
const result = await testAiCategorizationProvider({
|
||||
...(body.base_url !== undefined ? { baseUrl: body.base_url.replace(/\/+$/, "") } : {}),
|
||||
...(body.model !== undefined ? { model: body.model } : {}),
|
||||
...(body.api_key !== undefined ? { apiKey: body.api_key || null } : {}),
|
||||
...(body.timeout_ms !== undefined ? { timeoutMs: body.timeout_ms } : {}),
|
||||
});
|
||||
res.json(result.ok ? { ok: true, latency_ms: result.latencyMs, model: result.model } : result);
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,547 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { Prisma, type CategorySource, type Print } from "@prisma/client";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "../db";
|
||||
import { HttpError } from "../utils/fileUtils";
|
||||
import { previewImagePath } from "./previewImageService";
|
||||
import { createLog } from "./auditLog";
|
||||
import { setPrintCategory } from "./printCategoryService";
|
||||
import { aiCategorizationEnabled, getAiCategorizationSettings, type AiCategorizationSettings } from "./settingsService";
|
||||
|
||||
const suggestionSchema = z.object({
|
||||
categoryId: z.string(),
|
||||
confidence: z.number().finite().min(0).max(1),
|
||||
reason: z.string().max(300),
|
||||
model: z.string(),
|
||||
createdAt: z.string(),
|
||||
});
|
||||
|
||||
type Suggestion = z.infer<typeof suggestionSchema>;
|
||||
type Candidate = { id: string; path: string };
|
||||
type ProviderResult = { categoryId: string | null; confidence: number; reason: string };
|
||||
export type CategorizationOutcome = "applied" | "suggested" | "no_match" | "unchanged";
|
||||
|
||||
let active = 0;
|
||||
const waiters: (() => void)[] = [];
|
||||
let retryDelay = (milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
|
||||
|
||||
/** Keeps timeout and retry tests fast without changing production backoff behavior. */
|
||||
export function setAiCategorizationRetryDelayForTest(delay?: (milliseconds: number) => Promise<void>): void {
|
||||
retryDelay = delay ?? ((milliseconds) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
|
||||
}
|
||||
|
||||
async function limited<T>(limit: number, task: () => Promise<T>): Promise<T> {
|
||||
// A waiter can be woken while another request takes the slot first, so it retries acquisition.
|
||||
if (active >= limit) {
|
||||
await new Promise<void>((resolve) => waiters.push(resolve));
|
||||
return limited(limit, task);
|
||||
}
|
||||
active++;
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
active--;
|
||||
waiters.shift()?.();
|
||||
}
|
||||
}
|
||||
|
||||
function noSecretError(error: unknown): Error {
|
||||
if (error instanceof Error) return new Error(error.message.replace(/Bearer\s+\S+/gi, "Bearer [redacted]"));
|
||||
return new Error("AI provider request failed");
|
||||
}
|
||||
|
||||
function plainText(value: string | null): string {
|
||||
return (value ?? "")
|
||||
.replace(/!?(\[[^\]]*\])\([^)]*\)/g, "$1")
|
||||
.replace(/^[\t ]{0,3}(?:#{1,6}\s+|[-+*]\s+|\d+[.)]\s+)/gm, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 2000);
|
||||
}
|
||||
|
||||
export async function aiCategoryPaths(userId: string): Promise<Map<string, string>> {
|
||||
const categories = await prisma.category.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ position: "asc" }, { name: "asc" }],
|
||||
});
|
||||
const byId = new Map(categories.map((category) => [category.id, category]));
|
||||
const paths = new Map<string, string>();
|
||||
for (const category of categories) {
|
||||
if (category.kind !== "category") continue;
|
||||
const names: string[] = [];
|
||||
let current: typeof category | undefined = category;
|
||||
const seen = new Set<string>();
|
||||
while (current && !seen.has(current.id)) {
|
||||
seen.add(current.id);
|
||||
names.unshift(current.name);
|
||||
current = current.parentId ? byId.get(current.parentId) : undefined;
|
||||
}
|
||||
paths.set(category.id, names.join(" > "));
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
async function candidateCategories(userId: string, rejected: string[]): Promise<Candidate[]> {
|
||||
const rejectedSet = new Set(rejected);
|
||||
return [...(await aiCategoryPaths(userId))]
|
||||
.filter(([id]) => !rejectedSet.has(id))
|
||||
.map(([id, path]) => ({ id, path }));
|
||||
}
|
||||
|
||||
async function previewPart(
|
||||
printId: string,
|
||||
enabled: boolean,
|
||||
): Promise<{ type: "image_url"; image_url: { url: string } } | null> {
|
||||
if (!enabled) return null;
|
||||
const image = await prisma.previewImage.findFirst({ where: { printId }, orderBy: { position: "asc" } });
|
||||
if (!image) return null;
|
||||
try {
|
||||
const data = await sharp(await fs.readFile(previewImagePath(image.id)))
|
||||
.resize(512, 512, { fit: "inside" })
|
||||
.jpeg()
|
||||
.toBuffer();
|
||||
return { type: "image_url", image_url: { url: `data:image/jpeg;base64,${data.toString("base64")}` } };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function chatUrl(baseUrl: string): string {
|
||||
return `${baseUrl.replace(/\/+$/, "")}/chat/completions`;
|
||||
}
|
||||
|
||||
/** What a failed fetch means to an admin; fetch itself only says "fetch failed" or "aborted". */
|
||||
function readableNetworkError(error: unknown, settings: AiCategorizationSettings): unknown {
|
||||
if (error instanceof Error && error.name === "AbortError")
|
||||
return new Error(`The AI provider did not answer within ${Math.round(settings.timeoutMs / 1000)} s`);
|
||||
if (error instanceof TypeError) return new Error("Could not reach the AI provider. Check the API base URL.");
|
||||
return error;
|
||||
}
|
||||
|
||||
async function requestProvider(
|
||||
print: Print,
|
||||
candidates: Candidate[],
|
||||
settings: AiCategorizationSettings,
|
||||
attempts = 3,
|
||||
): Promise<ProviderResult> {
|
||||
const image = await previewPart(print.id, settings.sendImage);
|
||||
// A model that already has a category says so; on its own the model often misses an obvious fit.
|
||||
const current = print.categoryId ? candidates.find((candidate) => candidate.id === print.categoryId) : undefined;
|
||||
const content: ({ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } })[] = [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
// "Classify this 3D-print model" pulled answers toward 3D printer categories; ask about the object instead.
|
||||
"Pick the candidate category that best fits this model. It is a file for a physical object that gets 3D printed, " +
|
||||
"so choose by what the printed object is or is used for, not by the fact that it is 3D printed. " +
|
||||
"Categories about 3D printers fit only parts and accessories for a 3D printer itself.",
|
||||
"Give your reason first and name the category you pick in it, then give that candidate's number. " +
|
||||
"If the model has a current category that still fits, pick it again. Use null only if no candidate fits.",
|
||||
`Title: ${print.title ?? print.name}`,
|
||||
`Tags: ${print.tags.join(", ") || "none"}`,
|
||||
`Creator: ${print.creator ?? "unknown"}`,
|
||||
`Source: ${print.sourceProvider ?? "unknown"}`,
|
||||
...(current ? [`Current category: ${current.path}`] : []),
|
||||
`Description: ${plainText(print.notes) || "none"}`,
|
||||
"Candidates:",
|
||||
// Numbered rather than by id: small models misread near-identical ids and return a neighbour's.
|
||||
...candidates.map((candidate, index) => `${index + 1}: ${candidate.path}`),
|
||||
].join("\n"),
|
||||
},
|
||||
];
|
||||
if (image) content.push(image);
|
||||
const schema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
// Fields are generated in this order: the reason comes first so the answer follows it rather than being
|
||||
// justified afterwards.
|
||||
properties: {
|
||||
reason: { type: "string", maxLength: 300 },
|
||||
category: { anyOf: [{ type: "integer", enum: candidates.map((_, index) => index + 1) }, { type: "null" }] },
|
||||
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||
},
|
||||
required: ["reason", "category", "confidence"],
|
||||
};
|
||||
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
const last = attempt === attempts - 1;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), settings.timeoutMs);
|
||||
try {
|
||||
const response = await fetch(chatUrl(settings.baseUrl!), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(settings.apiKey ? { authorization: `Bearer ${settings.apiKey}` } : {}),
|
||||
},
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
model: settings.model,
|
||||
temperature: 0,
|
||||
max_tokens: 300,
|
||||
response_format: { type: "json_schema", json_schema: { name: "categorization", strict: true, schema } },
|
||||
messages: [{ role: "user", content }],
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const retryable = response.status === 429 || response.status >= 500;
|
||||
if (!retryable || last) throw new Error(`AI provider returned HTTP ${response.status}`);
|
||||
const retryAfterHeader = response.headers.get("retry-after");
|
||||
const retryAfter = retryAfterHeader === null ? Number.NaN : Number(retryAfterHeader);
|
||||
const delay =
|
||||
Number.isFinite(retryAfter) && retryAfter > 0
|
||||
? Math.min(30000, retryAfter * 1000)
|
||||
: attempt === 0
|
||||
? 1000
|
||||
: 3000;
|
||||
await retryDelay(delay);
|
||||
continue;
|
||||
}
|
||||
const body = (await response.json()) as { choices?: { message?: { content?: unknown } }[] };
|
||||
const raw = body.choices?.[0]?.message?.content;
|
||||
if (typeof raw !== "string") throw new Error("AI provider returned no classification");
|
||||
const unfenced = raw
|
||||
.trim()
|
||||
.replace(/^```(?:json)?\s*/i, "")
|
||||
.replace(/\s*```$/, "");
|
||||
const parsed = z
|
||||
.object({
|
||||
// Providers without a grammar sometimes quote the number.
|
||||
category: z.union([z.number().int(), z.string().regex(/^\d+$/).transform(Number)]).nullable(),
|
||||
confidence: z.number().finite(),
|
||||
reason: z.string(),
|
||||
})
|
||||
.safeParse(JSON.parse(unfenced));
|
||||
if (!parsed.success) throw new Error("AI provider returned invalid classification");
|
||||
// Providers that ignore the schema's range (Ollama) can answer a whole percentage such as 85.
|
||||
const { confidence: stated } = parsed.data;
|
||||
const confidence = Number.isInteger(stated) && stated > 1 && stated <= 100 ? stated / 100 : stated;
|
||||
if (confidence < 0 || confidence > 1) throw new Error("AI provider returned invalid classification");
|
||||
const chosen = parsed.data.category === null ? null : candidates[parsed.data.category - 1];
|
||||
if (chosen === undefined) throw new Error("AI provider returned a category outside the candidate list");
|
||||
return {
|
||||
categoryId: chosen?.id ?? null,
|
||||
confidence,
|
||||
reason: parsed.data.reason.slice(0, 300),
|
||||
};
|
||||
} catch (error) {
|
||||
const retryable = error instanceof TypeError || (error instanceof Error && error.name === "AbortError");
|
||||
if (!retryable || last) throw noSecretError(readableNetworkError(error, settings));
|
||||
await retryDelay(attempt === 0 ? 1000 : 3000);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
throw new Error("AI provider request failed");
|
||||
}
|
||||
|
||||
function isFolder(print: Print & { category?: { kind: string } | null }): boolean {
|
||||
return print.category?.kind === "folder";
|
||||
}
|
||||
|
||||
async function clearSuggestion(
|
||||
printId: string,
|
||||
expected?: { categoryId: string | null; categorySource: CategorySource | null },
|
||||
) {
|
||||
const where: Prisma.PrintWhereInput = { id: printId };
|
||||
if (expected) Object.assign(where, expected);
|
||||
await prisma.print.updateMany({ where, data: { aiSuggestion: Prisma.DbNull } });
|
||||
}
|
||||
|
||||
export async function categorizePrint(
|
||||
userId: string,
|
||||
printId: string,
|
||||
options: { force?: boolean } = {},
|
||||
): Promise<CategorizationOutcome> {
|
||||
const settings = await getAiCategorizationSettings();
|
||||
if (!aiCategorizationEnabled(settings)) throw new HttpError(503, "AI categorization is disabled", "ai_disabled");
|
||||
const print = await prisma.print.findFirst({ where: { id: printId, userId }, include: { category: true } });
|
||||
if (!print) throw new HttpError(404, "Print not found");
|
||||
if (isFolder(print)) throw new HttpError(409, "Models in folders are not AI-categorized", "folder");
|
||||
if (print.categorySource === "MANUAL" && !options.force) {
|
||||
throw new HttpError(409, "This category was set manually", "manual_category");
|
||||
}
|
||||
const candidates = await candidateCategories(userId, print.aiRejectedCategoryIds);
|
||||
if (!candidates.length) {
|
||||
await clearSuggestion(print.id);
|
||||
return "no_match";
|
||||
}
|
||||
|
||||
const answer = await limited(settings.concurrency, () => requestProvider(print, candidates, settings));
|
||||
const expected = { categoryId: print.categoryId, categorySource: print.categorySource };
|
||||
if (answer.categoryId === null) {
|
||||
await clearSuggestion(print.id, expected);
|
||||
return "no_match";
|
||||
}
|
||||
if (answer.categoryId === print.categoryId) {
|
||||
await clearSuggestion(print.id, expected);
|
||||
return "unchanged";
|
||||
}
|
||||
const suggestion: Suggestion = {
|
||||
categoryId: answer.categoryId,
|
||||
confidence: answer.confidence,
|
||||
reason: answer.reason,
|
||||
model: settings.model!,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
if (settings.mode === "auto" && answer.confidence >= settings.threshold) {
|
||||
try {
|
||||
const applied = await setPrintCategory(userId, print.id, answer.categoryId, "AI", {
|
||||
expected,
|
||||
clearSuggestion: true,
|
||||
});
|
||||
if (applied) return "applied";
|
||||
return "unchanged";
|
||||
} catch (error) {
|
||||
// A colliding model name is reviewable, unlike a broken provider request.
|
||||
if (!(error instanceof HttpError) || error.status !== 409) throw error;
|
||||
}
|
||||
}
|
||||
const where: Prisma.PrintWhereInput = { id: print.id, categoryId: expected.categoryId };
|
||||
if (!options.force) {
|
||||
// SQL's `!=` excludes NULL, but uncategorized prints intentionally have no source.
|
||||
where.OR = [{ categorySource: null }, { categorySource: { not: "MANUAL" } }];
|
||||
}
|
||||
const stored = await prisma.print.updateMany({
|
||||
where,
|
||||
data: { aiSuggestion: suggestion as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
return stored.count ? "suggested" : "unchanged";
|
||||
}
|
||||
|
||||
/** Import creation deliberately never waits on a provider. */
|
||||
export function enqueueAiCategorization(userId: string, printId: string): void {
|
||||
void getAiCategorizationSettings()
|
||||
.then((settings) => {
|
||||
if (!aiCategorizationEnabled(settings) || !settings.onImport) return;
|
||||
return categorizePrint(userId, printId);
|
||||
})
|
||||
.catch((error) => console.warn("[ai-categorize]", printId, noSecretError(error).message));
|
||||
}
|
||||
|
||||
export async function acceptAiSuggestion(userId: string, printId: string): Promise<void> {
|
||||
const print = await prisma.print.findFirst({ where: { id: printId, userId } });
|
||||
if (!print) throw new HttpError(404, "Print not found");
|
||||
const suggestion = suggestionSchema.safeParse(print.aiSuggestion);
|
||||
if (!suggestion.success) throw new HttpError(404, "No AI suggestion found");
|
||||
const category = await prisma.category.findFirst({ where: { id: suggestion.data.categoryId, userId } });
|
||||
if (!category) throw new HttpError(409, "Suggested category no longer exists", "stale");
|
||||
await setPrintCategory(userId, print.id, category.id, "AI", { clearSuggestion: true });
|
||||
}
|
||||
|
||||
export async function rejectAiSuggestion(userId: string, printId: string): Promise<void> {
|
||||
const print = await prisma.print.findFirst({ where: { id: printId, userId } });
|
||||
if (!print) throw new HttpError(404, "Print not found");
|
||||
const suggestion = suggestionSchema.safeParse(print.aiSuggestion);
|
||||
if (!suggestion.success) throw new HttpError(404, "No AI suggestion found");
|
||||
await prisma.print.update({
|
||||
where: { id: print.id },
|
||||
data: {
|
||||
aiSuggestion: Prisma.DbNull,
|
||||
aiRejectedCategoryIds: [...new Set([...print.aiRejectedCategoryIds, suggestion.data.categoryId])],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function aiSuggestionOut(
|
||||
userId: string,
|
||||
value: unknown,
|
||||
categoryPaths?: Map<string, string>,
|
||||
): Promise<{
|
||||
category_id: string;
|
||||
category_path: string;
|
||||
confidence: number;
|
||||
reason: string;
|
||||
model: string;
|
||||
created_at: string;
|
||||
} | null> {
|
||||
const suggestion = suggestionSchema.safeParse(value);
|
||||
if (!suggestion.success) return null;
|
||||
const path = (categoryPaths ?? (await aiCategoryPaths(userId))).get(suggestion.data.categoryId);
|
||||
if (!path) return null;
|
||||
return {
|
||||
category_id: suggestion.data.categoryId,
|
||||
category_path: path,
|
||||
confidence: suggestion.data.confidence,
|
||||
reason: suggestion.data.reason,
|
||||
model: suggestion.data.model,
|
||||
created_at: suggestion.data.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export type AiCategorizationRun = {
|
||||
running: boolean;
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
total: number;
|
||||
done: number;
|
||||
applied: number;
|
||||
suggested: number;
|
||||
noMatch: number;
|
||||
failed: number;
|
||||
cancelled: boolean;
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
const runs = new Map<string, AiCategorizationRun>();
|
||||
|
||||
export function currentAiCategorizationRun(userId: string): AiCategorizationRun | null {
|
||||
return runs.get(userId) ?? null;
|
||||
}
|
||||
|
||||
export async function aiCategorizationCounts(userId: string) {
|
||||
const [uncategorized, folder, grouped] = await Promise.all([
|
||||
prisma.print.count({ where: { userId, categoryId: null } }),
|
||||
prisma.print.count({ where: { userId, category: { is: { kind: "folder" } } } }),
|
||||
prisma.print.groupBy({
|
||||
by: ["categorySource"],
|
||||
where: { userId, categoryId: { not: null }, category: { is: { kind: "category" } } },
|
||||
_count: { categorySource: true },
|
||||
}),
|
||||
]);
|
||||
const counts = { uncategorized, ai: 0, rule: 0, legacy: 0, manual: 0, folder };
|
||||
for (const row of grouped) {
|
||||
if (row.categorySource === "AI") counts.ai = row._count.categorySource;
|
||||
else if (row.categorySource === "RULE") counts.rule = row._count.categorySource;
|
||||
else if (row.categorySource === "LEGACY") counts.legacy = row._count.categorySource;
|
||||
else if (row.categorySource === "MANUAL") counts.manual = row._count.categorySource;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
export function startAiCategorizationRun(
|
||||
userId: string,
|
||||
scope: { includeAi: boolean; includeRule: boolean; includeLegacy: boolean },
|
||||
): AiCategorizationRun | null {
|
||||
const running = runs.get(userId);
|
||||
if (running?.running) return null;
|
||||
const run: AiCategorizationRun = {
|
||||
running: true,
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: null,
|
||||
total: 0,
|
||||
done: 0,
|
||||
applied: 0,
|
||||
suggested: 0,
|
||||
noMatch: 0,
|
||||
failed: 0,
|
||||
cancelled: false,
|
||||
lastError: null,
|
||||
};
|
||||
runs.set(userId, run);
|
||||
void runAiCategorization(userId, run, scope)
|
||||
.catch((error) => {
|
||||
run.failed++;
|
||||
run.lastError = noSecretError(error).message;
|
||||
})
|
||||
.finally(() => {
|
||||
run.running = false;
|
||||
run.finishedAt = new Date().toISOString();
|
||||
void createLog({ userId, action: "ai_categorization_run", details: { ...run } });
|
||||
});
|
||||
return run;
|
||||
}
|
||||
|
||||
async function runAiCategorization(
|
||||
userId: string,
|
||||
run: AiCategorizationRun,
|
||||
scope: { includeAi: boolean; includeRule: boolean; includeLegacy: boolean },
|
||||
): Promise<void> {
|
||||
const prints = await prisma.print.findMany({
|
||||
where: { userId },
|
||||
include: { category: { select: { kind: true } } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
const selected = prints.filter((print) => {
|
||||
if (print.category?.kind === "folder" || print.categorySource === "MANUAL") return false;
|
||||
if (!print.categoryId) return true;
|
||||
return (
|
||||
(print.categorySource === "AI" && scope.includeAi) ||
|
||||
(print.categorySource === "RULE" && scope.includeRule) ||
|
||||
(print.categorySource === "LEGACY" && scope.includeLegacy)
|
||||
);
|
||||
});
|
||||
run.total = selected.length;
|
||||
let consecutiveProviderFailures = 0;
|
||||
for (const print of selected) {
|
||||
if (run.cancelled) break;
|
||||
let stop = false;
|
||||
try {
|
||||
const outcome = await categorizePrint(userId, print.id);
|
||||
consecutiveProviderFailures = 0;
|
||||
if (outcome === "applied") run.applied++;
|
||||
else if (outcome === "suggested") run.suggested++;
|
||||
else if (outcome === "no_match") run.noMatch++;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof HttpError &&
|
||||
error.status === 409 &&
|
||||
(error.code === "manual_category" || error.code === "folder")
|
||||
) {
|
||||
consecutiveProviderFailures = 0;
|
||||
} else if (error instanceof HttpError && error.status === 503 && error.code === "ai_disabled") {
|
||||
run.lastError = noSecretError(error).message;
|
||||
stop = true;
|
||||
} else {
|
||||
run.failed++;
|
||||
consecutiveProviderFailures++;
|
||||
if (consecutiveProviderFailures >= 5) {
|
||||
run.lastError = "AI provider is failing repeatedly; run stopped after 5 consecutive failures";
|
||||
stop = true;
|
||||
} else {
|
||||
run.lastError = noSecretError(error).message;
|
||||
}
|
||||
}
|
||||
}
|
||||
run.done++;
|
||||
if (stop) break;
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelAiCategorizationRun(userId: string): AiCategorizationRun | null {
|
||||
const run = runs.get(userId);
|
||||
if (!run) return null;
|
||||
run.cancelled = true;
|
||||
return run;
|
||||
}
|
||||
|
||||
/** Tests the given connection values, falling back to the saved ones, so a connection can be checked before saving. */
|
||||
export async function testAiCategorizationProvider(
|
||||
overrides: Partial<Pick<AiCategorizationSettings, "baseUrl" | "model" | "apiKey" | "timeoutMs">> = {},
|
||||
): Promise<{ ok: true; latencyMs: number; model: string } | { ok: false; error: string }> {
|
||||
const settings = { ...(await getAiCategorizationSettings()), ...overrides };
|
||||
// The connection is tested on its own, so categorization may still be off.
|
||||
if (!settings.baseUrl || !settings.model) return { ok: false, error: "Set the API base URL and model name first" };
|
||||
const start = Date.now();
|
||||
try {
|
||||
const fake = {
|
||||
id: "test-print",
|
||||
userId: "test-user",
|
||||
name: "Calibration cube",
|
||||
nameNormalized: "calibration cube",
|
||||
title: null,
|
||||
notes: null,
|
||||
creator: null,
|
||||
tags: [],
|
||||
categoryId: null,
|
||||
categorySource: null,
|
||||
aiRejectedCategoryIds: [],
|
||||
sourceProvider: null,
|
||||
} as unknown as Print;
|
||||
await requestProvider(
|
||||
fake,
|
||||
[
|
||||
{ id: "a", path: "Calibration" },
|
||||
{ id: "b", path: "Other" },
|
||||
],
|
||||
settings,
|
||||
// A test answers the admin who is waiting on it; one attempt says whether the connection works.
|
||||
1,
|
||||
);
|
||||
return { ok: true, latencyMs: Date.now() - start, model: settings.model! };
|
||||
} catch (error) {
|
||||
const message = noSecretError(error).message;
|
||||
return { ok: false, error: settings.apiKey ? message.replaceAll(settings.apiKey, "[redacted]") : message };
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,8 @@ export type LogAction =
|
||||
| "collection_item_added"
|
||||
| "collection_item_removed"
|
||||
| "collection_sync_enabled"
|
||||
| "collection_sync_disabled";
|
||||
| "collection_sync_disabled"
|
||||
| "ai_categorization_run";
|
||||
|
||||
/** Fire-and-forget: a logging failure must never break the action it records. */
|
||||
export async function createLog(params: {
|
||||
|
||||
@@ -220,7 +220,10 @@ export async function consumeFolder(dir = CONSUME_DIR): Promise<ConsumeSummary |
|
||||
const filename = sanitizeFilename(path.basename(item.rel));
|
||||
const { print } = await createPrint(
|
||||
userId,
|
||||
{ categoryId: await categoryFor(foldersOf(item.rel)) },
|
||||
{
|
||||
categoryId: await categoryFor(foldersOf(item.rel)),
|
||||
categorySource: "MANUAL",
|
||||
},
|
||||
path.parse(filename).name,
|
||||
[{ filename, mime: guessMimeFromPath(filename), copyFromPath: item.path }],
|
||||
);
|
||||
@@ -250,7 +253,11 @@ export async function consumeFolder(dir = CONSUME_DIR): Promise<ConsumeSummary |
|
||||
});
|
||||
const { print, plates: created } = await createPrint(
|
||||
userId,
|
||||
{ title: name, categoryId: await categoryFor(folders.slice(0, -1)) },
|
||||
{
|
||||
title: name,
|
||||
categoryId: await categoryFor(folders.slice(0, -1)),
|
||||
categorySource: "MANUAL",
|
||||
},
|
||||
name,
|
||||
inputs,
|
||||
);
|
||||
|
||||
@@ -103,7 +103,10 @@ async function fillEmptyMetadata(
|
||||
}
|
||||
if (gaps.has("category")) {
|
||||
const categoryId = await resolveCategoryIdByCategory(userId, meta.categorySite, meta.siteCategoryIds);
|
||||
if (categoryId) updates.category = { connect: { id: categoryId } };
|
||||
if (categoryId) {
|
||||
updates.category = { connect: { id: categoryId } };
|
||||
updates.categorySource = "RULE";
|
||||
}
|
||||
}
|
||||
if (Object.keys(updates).length) await prisma.print.update({ where: { id: print.id }, data: updates });
|
||||
}
|
||||
|
||||
@@ -792,6 +792,7 @@ async function importThingiverseThing(
|
||||
notes: body.notes ?? meta.description ?? null,
|
||||
tags: body.tags && body.tags.length ? body.tags : (meta.tags ?? []),
|
||||
categoryId,
|
||||
categorySource: body.category_id ? "MANUAL" : categoryId ? "RULE" : null,
|
||||
creator: meta.creator ?? null,
|
||||
authorId: author?.id ?? null,
|
||||
sourceProvider: source.provider,
|
||||
@@ -899,6 +900,7 @@ async function importPrintablesModel(
|
||||
notes: body.notes ?? meta.description ?? null,
|
||||
tags: body.tags && body.tags.length ? body.tags : (meta.tags ?? []),
|
||||
categoryId,
|
||||
categorySource: body.category_id ? "MANUAL" : categoryId ? "RULE" : null,
|
||||
creator: meta.creator ?? null,
|
||||
authorId: author?.id ?? null,
|
||||
sourceProvider: source.provider,
|
||||
@@ -978,6 +980,7 @@ async function importNewOrExisting(userId: string, url: string, body: ImportRequ
|
||||
notes: body.notes ?? meta.description ?? null,
|
||||
tags: body.tags && body.tags.length ? body.tags : meta.tags,
|
||||
categoryId,
|
||||
categorySource: body.category_id ? "MANUAL" : categoryId ? "RULE" : null,
|
||||
creator: meta.creator ?? null,
|
||||
authorId: author?.id ?? null,
|
||||
sourceProvider: source?.provider ?? null,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Prisma, type CategorySource, type Print } from "@prisma/client";
|
||||
import { prisma } from "../db";
|
||||
import { HttpError } from "../utils/fileUtils";
|
||||
import { relocatePrint, uniqueModelName } from "./printService";
|
||||
|
||||
export type CategoryUpdateExpectation = {
|
||||
categoryId: string | null;
|
||||
categorySource: CategorySource | null;
|
||||
};
|
||||
|
||||
type SetPrintCategoryOptions = {
|
||||
expected?: CategoryUpdateExpectation;
|
||||
clearSuggestion?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Changes the category through one path so validation and managed-file relocation cannot diverge
|
||||
* between the manual route, AI application and accepting a suggestion. `expected` is an optimistic
|
||||
* lock used after a slow provider call: a more recent manual decision always wins.
|
||||
*/
|
||||
export async function setPrintCategory(
|
||||
userId: string,
|
||||
printId: string,
|
||||
categoryId: string | null,
|
||||
source: CategorySource | null,
|
||||
options: SetPrintCategoryOptions = {},
|
||||
): Promise<Print | null> {
|
||||
if ((categoryId === null) !== (source === null)) {
|
||||
throw new Error("A category source is required exactly when a category is set");
|
||||
}
|
||||
|
||||
const print = await prisma.print.findFirst({ where: { id: printId, userId } });
|
||||
if (!print) throw new HttpError(404, "Print not found");
|
||||
if (
|
||||
options.expected &&
|
||||
(print.categoryId !== options.expected.categoryId || print.categorySource !== options.expected.categorySource)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (categoryId) {
|
||||
const category = await prisma.category.findFirst({ where: { id: categoryId, userId } });
|
||||
if (!category) throw new HttpError(400, "Category not found");
|
||||
await uniqueModelName(userId, print.name, category.id, print.id);
|
||||
} else {
|
||||
await uniqueModelName(userId, print.name, null, print.id);
|
||||
}
|
||||
|
||||
const where = options.expected
|
||||
? {
|
||||
id: print.id,
|
||||
categoryId: options.expected.categoryId,
|
||||
categorySource: options.expected.categorySource,
|
||||
}
|
||||
: { id: print.id };
|
||||
const updated = await prisma.print.updateMany({
|
||||
where,
|
||||
data: {
|
||||
categoryId,
|
||||
categorySource: source,
|
||||
...(options.clearSuggestion ? { aiSuggestion: Prisma.DbNull } : {}),
|
||||
},
|
||||
});
|
||||
if (!updated.count) return null;
|
||||
|
||||
const next = await prisma.print.findUniqueOrThrow({ where: { id: print.id } });
|
||||
const plates = await prisma.plate.findMany({ where: { printId: print.id }, orderBy: { position: "asc" } });
|
||||
await relocatePrint(next, plates);
|
||||
return next;
|
||||
}
|
||||
@@ -20,7 +20,8 @@ import { generateModelPreviewGlb } from "./modelPreviewCache";
|
||||
import { deleteNormalized3mf } from "./normalized3mfCache";
|
||||
import { getPreviewMode } from "./settingsService";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { Plate, Print } from "@prisma/client";
|
||||
import type { CategorySource, Plate, Print } from "@prisma/client";
|
||||
import { enqueueAiCategorization } from "./aiCategorizationService";
|
||||
|
||||
const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".webp", ".bmp"]);
|
||||
|
||||
@@ -41,6 +42,7 @@ export type PrintMetaInput = {
|
||||
notes?: string | null;
|
||||
tags?: string[];
|
||||
categoryId?: string | null;
|
||||
categorySource?: CategorySource | null;
|
||||
creator?: string | null;
|
||||
authorId?: string | null;
|
||||
/** Backs de-duplication of future imports of the same source model. */
|
||||
@@ -180,6 +182,7 @@ export async function createPrint(
|
||||
creator: meta.creator?.trim() || null,
|
||||
tags: normalizeTags(meta.tags || []),
|
||||
categoryId: meta.categoryId ?? null,
|
||||
categorySource: meta.categoryId ? (meta.categorySource ?? "MANUAL") : null,
|
||||
authorId: meta.authorId ?? null,
|
||||
sourceProvider: meta.sourceProvider ?? null,
|
||||
sourceExternalId: meta.sourceExternalId ?? null,
|
||||
@@ -209,6 +212,9 @@ export async function createPrint(
|
||||
}
|
||||
}
|
||||
|
||||
// Background classification is intentionally detached: importing a model must not wait for,
|
||||
// or fail because of, a third-party AI provider.
|
||||
if (!print.categoryId) void enqueueAiCategorization(userId, print.id);
|
||||
return { print, plates };
|
||||
} catch (err) {
|
||||
await discardPrint(print.id);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { prisma } from "../db";
|
||||
import { HttpError } from "../utils/fileUtils";
|
||||
import { toPrintOut, type PrintOut } from "../dto";
|
||||
import { aiCategoryPaths, aiSuggestionOut } from "./aiCategorizationService";
|
||||
import type { Author, Category, Plate, PreviewImage, Print, PrintFile } from "@prisma/client";
|
||||
|
||||
export type FullPrint = {
|
||||
@@ -29,7 +30,7 @@ export async function loadFullPrint(userId: string, printId: string): Promise<Fu
|
||||
}
|
||||
|
||||
export async function printOutById(userId: string, printId: string): Promise<PrintOut> {
|
||||
const full = await loadFullPrint(userId, printId);
|
||||
const [full, categoryPaths] = await Promise.all([loadFullPrint(userId, printId), aiCategoryPaths(userId)]);
|
||||
return toPrintOut(
|
||||
full.print,
|
||||
full.plates,
|
||||
@@ -38,6 +39,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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,11 +57,12 @@ 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] = await Promise.all([
|
||||
const [prints, plates, files, previewImages, categoryPaths] = 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),
|
||||
]);
|
||||
const platesByPrint = groupByPrintId(plates);
|
||||
const filesByPrint = groupByPrintId(files);
|
||||
@@ -78,6 +81,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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -157,6 +157,78 @@ export async function setSmtpSettings(patch: Partial<SmtpSettings>): Promise<Smt
|
||||
return next;
|
||||
}
|
||||
|
||||
export type AiCategorizationMode = "off" | "suggest" | "auto";
|
||||
export type AiCategorizationSettings = {
|
||||
mode: AiCategorizationMode;
|
||||
baseUrl: string | null;
|
||||
apiKey: string | null;
|
||||
model: string | null;
|
||||
threshold: number;
|
||||
onImport: boolean;
|
||||
concurrency: number;
|
||||
sendImage: boolean;
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
const AI_CATEGORIZATION_KEY = "ai_categorization";
|
||||
export const DEFAULT_AI_CATEGORIZATION_SETTINGS: AiCategorizationSettings = {
|
||||
mode: "off",
|
||||
baseUrl: null,
|
||||
apiKey: null,
|
||||
model: null,
|
||||
threshold: 0.8,
|
||||
onImport: true,
|
||||
concurrency: 1,
|
||||
sendImage: false,
|
||||
timeoutMs: 60000,
|
||||
};
|
||||
|
||||
function validAiSettings(value: unknown): Partial<AiCategorizationSettings> {
|
||||
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 } : {}),
|
||||
...(typeof raw.baseUrl === "string" || raw.baseUrl === null ? { baseUrl: raw.baseUrl?.trim() || null } : {}),
|
||||
...(typeof raw.apiKey === "string" || raw.apiKey === null ? { apiKey: raw.apiKey?.trim() || null } : {}),
|
||||
...(typeof raw.model === "string" || raw.model === null ? { model: raw.model?.trim() || null } : {}),
|
||||
...(typeof raw.threshold === "number" && raw.threshold >= 0 && raw.threshold <= 1
|
||||
? { threshold: raw.threshold }
|
||||
: {}),
|
||||
...(typeof raw.onImport === "boolean" ? { onImport: raw.onImport } : {}),
|
||||
...(typeof raw.concurrency === "number" &&
|
||||
Number.isInteger(raw.concurrency) &&
|
||||
raw.concurrency >= 1 &&
|
||||
raw.concurrency <= 4
|
||||
? { concurrency: raw.concurrency }
|
||||
: {}),
|
||||
...(typeof raw.sendImage === "boolean" ? { sendImage: raw.sendImage } : {}),
|
||||
...(typeof raw.timeoutMs === "number" && Number.isInteger(raw.timeoutMs) && raw.timeoutMs > 0
|
||||
? { timeoutMs: raw.timeoutMs }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAiCategorizationSettings(): Promise<AiCategorizationSettings> {
|
||||
const row = await prisma.setting.findUnique({ where: { key: AI_CATEGORIZATION_KEY } });
|
||||
return { ...DEFAULT_AI_CATEGORIZATION_SETTINGS, ...validAiSettings(row?.value) };
|
||||
}
|
||||
|
||||
export async function setAiCategorizationSettings(
|
||||
patch: Partial<AiCategorizationSettings>,
|
||||
): Promise<AiCategorizationSettings> {
|
||||
const next = { ...(await getAiCategorizationSettings()), ...patch };
|
||||
await prisma.setting.upsert({
|
||||
where: { key: AI_CATEGORIZATION_KEY },
|
||||
create: { key: AI_CATEGORIZATION_KEY, value: next as unknown as Prisma.InputJsonValue },
|
||||
update: { value: next as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
export function aiCategorizationEnabled(settings: AiCategorizationSettings): boolean {
|
||||
return settings.mode !== "off" && Boolean(settings.baseUrl && settings.model);
|
||||
}
|
||||
|
||||
const AUTH_TOKEN_TTL_KEY = "auth_token_ttl_seconds";
|
||||
const DEFAULT_AUTH_TOKEN_TTL_SECONDS = 43200;
|
||||
|
||||
|
||||
@@ -169,6 +169,7 @@ export async function extractZipEntriesToPrints(
|
||||
notes: options.notes ?? null,
|
||||
tags: options.tags ?? [],
|
||||
categoryId: targetCategoryId,
|
||||
categorySource: targetCategoryId ? "MANUAL" : null,
|
||||
creator: options.creator ?? null,
|
||||
authorId: options.authorId ?? null,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,751 @@
|
||||
import http from "node:http";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import request from "supertest";
|
||||
import { createApp } from "../src/app";
|
||||
import { prisma } from "../src/db";
|
||||
import { DEFAULT_AI_CATEGORIZATION_SETTINGS, setAiCategorizationSettings } from "../src/services/settingsService";
|
||||
import { aiCategoryPaths, setAiCategorizationRetryDelayForTest } from "../src/services/aiCategorizationService";
|
||||
|
||||
const app = createApp();
|
||||
const stamp = Date.now();
|
||||
const STL = "solid test\nendsolid test";
|
||||
let token: string;
|
||||
let userId: string;
|
||||
let userNumber = 0;
|
||||
let printNumber = 0;
|
||||
let categoryNumber = 0;
|
||||
const createdPrints: string[] = [];
|
||||
const createdCategories: string[] = [];
|
||||
const createdUsers: string[] = [];
|
||||
let provider: http.Server | null = null;
|
||||
let providerRequests = 0;
|
||||
let providerInFlight = 0;
|
||||
let providerMaxInFlight = 0;
|
||||
|
||||
type ProviderAnswer = { category_id: string | null; confidence: number; reason: string };
|
||||
type ProviderReply =
|
||||
| ProviderAnswer
|
||||
| { status: number; headers?: Record<string, string>; content?: ProviderAnswer | string; never?: boolean };
|
||||
type ProviderHandler = (body: Record<string, unknown>) => ProviderReply | Promise<ProviderReply>;
|
||||
|
||||
type User = { id: string; token: string };
|
||||
|
||||
function auth(value = token) {
|
||||
return { Authorization: `Bearer ${value}` };
|
||||
}
|
||||
|
||||
function isAnswer(reply: ProviderReply): reply is ProviderAnswer {
|
||||
return "category_id" in reply;
|
||||
}
|
||||
|
||||
function promptText(body: Record<string, unknown>): string {
|
||||
return (body.messages as { content: { text: string }[] }[])[0].content[0].text;
|
||||
}
|
||||
|
||||
// Handlers answer with real category ids for readability; the prompt numbers its candidates, so
|
||||
// translate an id into its number there. An id the prompt doesn't list becomes an out-of-range number.
|
||||
async function candidateNumber(body: Record<string, unknown>, categoryId: string): Promise<number> {
|
||||
const row = await prisma.category.findUnique({ where: { id: categoryId } });
|
||||
const path = row ? (await aiCategoryPaths(row.userId)).get(categoryId) : undefined;
|
||||
const line = promptText(body)
|
||||
.split("\n")
|
||||
.find((text) => path !== undefined && text.replace(/^\d+: /, "") === path);
|
||||
return line ? Number(line.split(":")[0]) : 9999;
|
||||
}
|
||||
|
||||
async function toProviderJson(body: Record<string, unknown>, answer: ProviderAnswer) {
|
||||
const { category_id: categoryId, ...rest } = answer;
|
||||
return { category: categoryId === null ? null : await candidateNumber(body, categoryId), ...rest };
|
||||
}
|
||||
|
||||
async function toProviderText(body: Record<string, unknown>, text: string): Promise<string> {
|
||||
let out = text.replace(/"category_id":null/g, '"category":null');
|
||||
for (const [match, categoryId] of text.matchAll(/"category_id":"([^"]+)"/g)) {
|
||||
out = out.replace(match, `"category":${await candidateNumber(body, categoryId)}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function startProvider(handler: ProviderHandler) {
|
||||
providerRequests = 0;
|
||||
providerInFlight = 0;
|
||||
providerMaxInFlight = 0;
|
||||
await new Promise<void>((resolve) => {
|
||||
provider = http.createServer(async (req, res) => {
|
||||
providerRequests++;
|
||||
providerInFlight++;
|
||||
providerMaxInFlight = Math.max(providerMaxInFlight, providerInFlight);
|
||||
const chunks: Buffer[] = [];
|
||||
try {
|
||||
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, unknown>;
|
||||
const reply = await handler(body);
|
||||
if (!isAnswer(reply) && reply.never) return;
|
||||
const status = isAnswer(reply) ? 200 : reply.status;
|
||||
const headers = isAnswer(reply) ? {} : reply.headers || {};
|
||||
const content = isAnswer(reply)
|
||||
? reply
|
||||
: (reply.content ?? { category_id: null, confidence: 1, reason: "none" });
|
||||
res.writeHead(status, { "content-type": "application/json", ...headers });
|
||||
const text =
|
||||
typeof content === "string"
|
||||
? await toProviderText(body, content)
|
||||
: JSON.stringify(await toProviderJson(body, content));
|
||||
res.end(JSON.stringify({ choices: [{ message: { content: text } }] }));
|
||||
} catch (error) {
|
||||
res.writeHead(500, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ error: error instanceof Error ? error.message : "provider failed" }));
|
||||
} finally {
|
||||
providerInFlight--;
|
||||
}
|
||||
});
|
||||
provider.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = provider.address();
|
||||
if (!address || typeof address === "string") throw new Error("Provider did not bind a TCP port");
|
||||
return `http://127.0.0.1:${address.port}/v1`;
|
||||
}
|
||||
|
||||
async function stopProvider() {
|
||||
if (!provider) return;
|
||||
provider.closeAllConnections?.();
|
||||
await new Promise<void>((resolve) => provider!.close(() => resolve()));
|
||||
provider = null;
|
||||
}
|
||||
|
||||
async function enable(baseUrl: string, patch: Partial<typeof DEFAULT_AI_CATEGORIZATION_SETTINGS> = {}) {
|
||||
await setAiCategorizationSettings({
|
||||
mode: "suggest",
|
||||
baseUrl,
|
||||
apiKey: null,
|
||||
model: "local",
|
||||
threshold: 0.8,
|
||||
onImport: false,
|
||||
concurrency: 1,
|
||||
sendImage: false,
|
||||
timeoutMs: 1000,
|
||||
...patch,
|
||||
});
|
||||
}
|
||||
|
||||
async function categoryFor(ownerId: string, name: string, kind = "category") {
|
||||
// Unique, so a test's answer maps to exactly one numbered candidate in the prompt.
|
||||
const row = await prisma.category.create({ data: { userId: ownerId, name: `${name} ${categoryNumber++}`, kind } });
|
||||
createdCategories.push(row.id);
|
||||
return row;
|
||||
}
|
||||
|
||||
async function category(name: string, kind = "category") {
|
||||
return categoryFor(userId, name, kind);
|
||||
}
|
||||
|
||||
async function printFor(
|
||||
ownerId: string,
|
||||
categoryId: string | null = null,
|
||||
categorySource: "MANUAL" | "RULE" | "AI" | "LEGACY" | null = null,
|
||||
patch: { name?: string; title?: string; notes?: string } = {},
|
||||
) {
|
||||
const name = patch.name ?? `Model ${printNumber++}`;
|
||||
const row = await prisma.print.create({
|
||||
data: {
|
||||
userId: ownerId,
|
||||
name,
|
||||
nameNormalized: name.toLowerCase(),
|
||||
title: patch.title ?? null,
|
||||
notes: patch.notes ?? null,
|
||||
categoryId,
|
||||
categorySource,
|
||||
},
|
||||
});
|
||||
createdPrints.push(row.id);
|
||||
return row;
|
||||
}
|
||||
|
||||
async function print(
|
||||
categoryId: string | null = null,
|
||||
categorySource: "MANUAL" | "RULE" | "AI" | "LEGACY" | null = null,
|
||||
patch: { name?: string; title?: string; notes?: string } = {},
|
||||
) {
|
||||
return printFor(userId, categoryId, categorySource, patch);
|
||||
}
|
||||
|
||||
async function newUser(): Promise<User> {
|
||||
const email = `ai-categorization-${stamp}-${userNumber++}@example.com`;
|
||||
const registered = await request(app)
|
||||
.post("/api/register")
|
||||
.send({ displayName: `AI categorization ${userNumber}`, email, password: "password123" });
|
||||
createdUsers.push(registered.body.user.id);
|
||||
return { id: registered.body.user.id, token: registered.body.token };
|
||||
}
|
||||
|
||||
async function waitForRun(value: string) {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const response = await request(app).get("/api/ai-categorization/run").set(auth(value));
|
||||
if (!response.body.run?.running) return response;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
throw new Error("AI categorization run did not finish");
|
||||
}
|
||||
|
||||
async function waitFor(check: () => boolean | Promise<boolean>, message: string) {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
if (await check()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const email = `ai-categorization-${stamp}@example.com`;
|
||||
const registered = await request(app)
|
||||
.post("/api/register")
|
||||
.send({ displayName: "AI categorization", email, password: "password123" });
|
||||
token = registered.body.token;
|
||||
userId = registered.body.user.id;
|
||||
await prisma.user.update({ where: { id: userId }, data: { role: "ADMIN" } });
|
||||
token = (await request(app).post("/api/login").send({ email, password: "password123" })).body.token;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
setAiCategorizationRetryDelayForTest();
|
||||
await stopProvider();
|
||||
await setAiCategorizationSettings(DEFAULT_AI_CATEGORIZATION_SETTINGS);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.print.deleteMany({ where: { id: { in: createdPrints } } });
|
||||
await prisma.category.deleteMany({ where: { id: { in: createdCategories } } });
|
||||
await prisma.user.deleteMany({ where: { id: { in: [userId, ...createdUsers] } } });
|
||||
});
|
||||
|
||||
describe("AI categorization", () => {
|
||||
it("keeps API keys write-only and exposes legacy category provenance", async () => {
|
||||
const baseUrl = await startProvider(() => ({ category_id: null, confidence: 1, reason: "none" }));
|
||||
const patched = await request(app)
|
||||
.patch("/api/settings/ai-categorization")
|
||||
.set(auth())
|
||||
.send({ mode: "auto", base_url: baseUrl, model: "local-model", api_key: "private-key" });
|
||||
expect(patched.status).toBe(200);
|
||||
expect(patched.body).toMatchObject({ mode: "auto", has_api_key: true });
|
||||
expect(JSON.stringify(patched.body)).not.toContain("private-key");
|
||||
const read = await request(app).get("/api/settings/ai-categorization").set(auth());
|
||||
expect(read.body).toMatchObject({ has_api_key: true, base_url: baseUrl, model: "local-model" });
|
||||
expect(JSON.stringify(read.body)).not.toContain("private-key");
|
||||
|
||||
const legacyCategory = await category("Legacy");
|
||||
const legacy = await print(legacyCategory.id, "LEGACY");
|
||||
const detail = await request(app).get(`/api/print/${legacy.id}`).set(auth());
|
||||
expect(detail.body.category_source).toBe("legacy");
|
||||
});
|
||||
|
||||
it("saves a connection only after it answers, and tests unsaved values", async () => {
|
||||
await setAiCategorizationSettings(DEFAULT_AI_CATEGORIZATION_SETTINGS);
|
||||
const deadUrl = await startProvider(() => ({ category_id: null, confidence: 1, reason: "none" }));
|
||||
await stopProvider();
|
||||
const tested = await request(app)
|
||||
.post("/api/settings/ai-categorization/test")
|
||||
.set(auth())
|
||||
.send({ base_url: deadUrl, model: "local-model", timeout_ms: 1000 });
|
||||
expect(tested.body).toMatchObject({ ok: false, error: expect.stringContaining("Could not reach the AI provider") });
|
||||
const refused = await request(app)
|
||||
.patch("/api/settings/ai-categorization")
|
||||
.set(auth())
|
||||
.send({ base_url: deadUrl, model: "local-model", timeout_ms: 1000 });
|
||||
expect(refused.status).toBe(422);
|
||||
expect(refused.body.code).toBe("ai_connection_failed");
|
||||
const unchanged = await request(app).get("/api/settings/ai-categorization").set(auth());
|
||||
expect(unchanged.body.base_url).toBeNull();
|
||||
|
||||
// A test makes one attempt rather than retrying a failing provider.
|
||||
const failingUrl = await startProvider(() => ({ status: 500 }));
|
||||
const failing = await request(app)
|
||||
.post("/api/settings/ai-categorization/test")
|
||||
.set(auth())
|
||||
.send({ base_url: failingUrl, model: "local-model" });
|
||||
expect(failing.body).toMatchObject({ ok: false, error: "AI provider returned HTTP 500" });
|
||||
expect(providerRequests).toBe(1);
|
||||
await stopProvider();
|
||||
|
||||
const liveUrl = await startProvider(() => ({ category_id: null, confidence: 1, reason: "none" }));
|
||||
const draft = await request(app)
|
||||
.post("/api/settings/ai-categorization/test")
|
||||
.set(auth())
|
||||
.send({ base_url: liveUrl, model: "local-model" });
|
||||
expect(draft.body).toMatchObject({ ok: true, model: "local-model" });
|
||||
const saved = await request(app)
|
||||
.patch("/api/settings/ai-categorization")
|
||||
.set(auth())
|
||||
.send({ base_url: liveUrl, model: "local-model" });
|
||||
expect(saved.status).toBe(200);
|
||||
expect(saved.body.base_url).toBe(liveUrl);
|
||||
|
||||
await stopProvider();
|
||||
const cleared = await request(app)
|
||||
.patch("/api/settings/ai-categorization")
|
||||
.set(auth())
|
||||
.send({ base_url: null, model: null });
|
||||
expect(cleared.status).toBe(200);
|
||||
expect(cleared.body).toMatchObject({ base_url: null, model: null });
|
||||
});
|
||||
|
||||
it("waits before retrying 429 responses both without and with Retry-After", async () => {
|
||||
const target = await category("Retry target");
|
||||
const withoutHeader = await startProvider(() =>
|
||||
providerRequests === 1 ? { status: 429 } : { category_id: target.id, confidence: 1, reason: "retry worked" },
|
||||
);
|
||||
await enable(withoutHeader, { mode: "auto" });
|
||||
const first = await print();
|
||||
const started = Date.now();
|
||||
const retried = await request(app).post(`/api/print/${first.id}/recategorize`).set(auth()).send({});
|
||||
expect(retried.status).toBe(200);
|
||||
expect(Date.now() - started).toBeGreaterThanOrEqual(900);
|
||||
expect(providerRequests).toBe(2);
|
||||
|
||||
await stopProvider();
|
||||
const withHeader = await startProvider(() =>
|
||||
providerRequests === 1
|
||||
? { status: 429, headers: { "retry-after": "1" } }
|
||||
: { category_id: target.id, confidence: 1, reason: "retry worked" },
|
||||
);
|
||||
await enable(withHeader, { mode: "auto" });
|
||||
const second = await print();
|
||||
const withRetryAfterStarted = Date.now();
|
||||
const retryAfter = await request(app).post(`/api/print/${second.id}/recategorize`).set(auth()).send({});
|
||||
expect(retryAfter.status).toBe(200);
|
||||
expect(Date.now() - withRetryAfterStarted).toBeGreaterThanOrEqual(900);
|
||||
expect(providerRequests).toBe(2);
|
||||
});
|
||||
|
||||
it("maps timeouts and repeated 500s to ai_failed, but does not retry a 400", async () => {
|
||||
setAiCategorizationRetryDelayForTest(async () => undefined);
|
||||
const target = await category("Failure target");
|
||||
const timeoutUrl = await startProvider(() => ({ status: 200, never: true }));
|
||||
await enable(timeoutUrl, { timeoutMs: 20 });
|
||||
const timedOut = await print();
|
||||
const timeout = await request(app).post(`/api/print/${timedOut.id}/recategorize`).set(auth()).send({});
|
||||
expect(timeout.status).toBe(502);
|
||||
expect(timeout.body.code).toBe("ai_failed");
|
||||
expect(providerRequests).toBe(3);
|
||||
|
||||
await stopProvider();
|
||||
const serverErrorUrl = await startProvider(() => ({ status: 500 }));
|
||||
await enable(serverErrorUrl);
|
||||
const serverError = await print();
|
||||
const failed = await request(app).post(`/api/print/${serverError.id}/recategorize`).set(auth()).send({});
|
||||
expect(failed.status).toBe(502);
|
||||
expect(failed.body.code).toBe("ai_failed");
|
||||
expect(providerRequests).toBe(3);
|
||||
|
||||
await stopProvider();
|
||||
const badRequestUrl = await startProvider(() => ({ status: 400 }));
|
||||
await enable(badRequestUrl);
|
||||
const badRequest = await print();
|
||||
const notRetried = await request(app).post(`/api/print/${badRequest.id}/recategorize`).set(auth()).send({});
|
||||
expect(notRetried.status).toBe(502);
|
||||
expect(notRetried.body.code).toBe("ai_failed");
|
||||
expect(providerRequests).toBe(1);
|
||||
expect(target.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it("accepts fenced answers, truncates reasons, and rejects invalid classifications", async () => {
|
||||
const target = await category("Output target");
|
||||
const longReason = "r".repeat(301);
|
||||
const replies: ProviderReply[] = [
|
||||
{ category_id: null, confidence: 1, reason: "none" },
|
||||
{
|
||||
status: 200,
|
||||
content: `\`\`\`json\n${JSON.stringify({ category_id: target.id, confidence: 50, reason: longReason })}\n\`\`\``,
|
||||
},
|
||||
{ status: 200, content: '{"category_id":null,"confidence":NaN,"reason":"bad"}' },
|
||||
{ category_id: target.id, confidence: 1.1, reason: "bad" },
|
||||
{ category_id: "not-a-candidate", confidence: 1, reason: "bad" },
|
||||
];
|
||||
let schema: Record<string, unknown> | null = null;
|
||||
const baseUrl = await startProvider((body) => {
|
||||
schema = body.response_format as Record<string, unknown>;
|
||||
return replies.shift()!;
|
||||
});
|
||||
await enable(baseUrl, { mode: "suggest" });
|
||||
|
||||
const noMatch = await request(app)
|
||||
.post(`/api/print/${(await print()).id}/recategorize`)
|
||||
.set(auth())
|
||||
.send({});
|
||||
expect(noMatch.body.outcome).toBe("no_match");
|
||||
|
||||
const fenced = await request(app)
|
||||
.post(`/api/print/${(await print()).id}/recategorize`)
|
||||
.set(auth())
|
||||
.send({});
|
||||
expect(fenced.status).toBe(200);
|
||||
expect(fenced.body).toMatchObject({ outcome: "suggested", print: { ai_suggestion: { category_id: target.id } } });
|
||||
expect(fenced.body.print.ai_suggestion.reason).toHaveLength(300);
|
||||
// Answered as a whole percentage, which some providers do despite the 0-1 range.
|
||||
expect(fenced.body.print.ai_suggestion.confidence).toBe(0.5);
|
||||
expect(JSON.stringify(schema)).toContain('"maxLength":300');
|
||||
// Candidates go out as numbers; a near-identical id is easy for a small model to misread.
|
||||
expect(JSON.stringify(schema)).not.toContain(target.id);
|
||||
expect(JSON.stringify(schema)).toContain('"type":"integer"');
|
||||
// The reason is generated before the answer.
|
||||
const properties = (schema as unknown as { json_schema: { schema: { properties: object } } }).json_schema.schema
|
||||
.properties;
|
||||
expect(Object.keys(properties)[0]).toBe("reason");
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const invalid = await request(app)
|
||||
.post(`/api/print/${(await print()).id}/recategorize`)
|
||||
.set(auth())
|
||||
.send({});
|
||||
expect(invalid.status).toBe(502);
|
||||
expect(invalid.body.code).toBe("ai_failed");
|
||||
}
|
||||
});
|
||||
|
||||
it("tells the provider a model's current category", async () => {
|
||||
const current = await category("Current hint");
|
||||
const prompts: string[] = [];
|
||||
const baseUrl = await startProvider((body) => {
|
||||
prompts.push(promptText(body));
|
||||
return { category_id: null, confidence: 1, reason: "none" };
|
||||
});
|
||||
await enable(baseUrl);
|
||||
await request(app)
|
||||
.post(`/api/print/${(await print(current.id, "LEGACY")).id}/recategorize`)
|
||||
.set(auth())
|
||||
.send({});
|
||||
await request(app)
|
||||
.post(`/api/print/${(await print()).id}/recategorize`)
|
||||
.set(auth())
|
||||
.send({});
|
||||
expect(prompts[0]).toContain(`Current category: ${current.name}`);
|
||||
expect(prompts[1]).not.toContain("Current category:");
|
||||
});
|
||||
|
||||
it("preserves hyphens in plain-text prompt descriptions", async () => {
|
||||
const target = await category("Hyphen target");
|
||||
let prompt = "";
|
||||
const baseUrl = await startProvider((body) => {
|
||||
const messages = body.messages as { content: { text: string }[] }[];
|
||||
prompt = messages[0].content[0].text;
|
||||
return { category_id: target.id, confidence: 1, reason: "fits" };
|
||||
});
|
||||
await enable(baseUrl, { mode: "auto" });
|
||||
const model = await print(null, null, { notes: "- heading\n3-in-1 tool" });
|
||||
const result = await request(app).post(`/api/print/${model.id}/recategorize`).set(auth()).send({});
|
||||
expect(result.status).toBe(200);
|
||||
expect(prompt).toContain("3-in-1 tool");
|
||||
});
|
||||
|
||||
it("never changes MANUAL categories unless explicitly forced and clears a pending suggestion manually", async () => {
|
||||
const target = await category("Target");
|
||||
const manual = await category("Manual");
|
||||
const baseUrl = await startProvider(() => ({ category_id: target.id, confidence: 0.99, reason: "matches" }));
|
||||
await enable(baseUrl, { mode: "auto" });
|
||||
const model = await print(manual.id, "MANUAL");
|
||||
const denied = await request(app).post(`/api/print/${model.id}/recategorize`).set(auth()).send({});
|
||||
expect(denied.status).toBe(409);
|
||||
expect(denied.body.code).toBe("manual_category");
|
||||
const forced = await request(app).post(`/api/print/${model.id}/recategorize`).set(auth()).send({ force: true });
|
||||
expect(forced.status).toBe(200);
|
||||
expect(forced.body).toMatchObject({ outcome: "applied", print: { category_id: target.id, category_source: "ai" } });
|
||||
|
||||
await enable(baseUrl, { mode: "suggest" });
|
||||
const suggested = await print();
|
||||
await request(app).post(`/api/print/${suggested.id}/recategorize`).set(auth()).send({});
|
||||
const cleared = await request(app)
|
||||
.post(`/api/print/${suggested.id}/category`)
|
||||
.set(auth())
|
||||
.send({ category_id: manual.id });
|
||||
expect(cleared.status).toBe(200);
|
||||
expect(cleared.body.print.ai_suggestion).toBeNull();
|
||||
});
|
||||
|
||||
it("suggests below threshold, supports bulk actions, and handles deleted suggestion categories", async () => {
|
||||
const first = await category("First");
|
||||
const second = await category("Second");
|
||||
const staleCategory = await category("Stale");
|
||||
const received: unknown[] = [];
|
||||
const baseUrl = await startProvider((body) => {
|
||||
received.push(body);
|
||||
const categoryId = received.length === 1 ? first.id : received.length === 4 ? staleCategory.id : second.id;
|
||||
return { category_id: categoryId, confidence: 0.3, reason: "review" };
|
||||
});
|
||||
await enable(baseUrl, { mode: "auto" });
|
||||
const model = await print();
|
||||
const suggested = await request(app).post(`/api/print/${model.id}/recategorize`).set(auth()).send({});
|
||||
expect(suggested.body).toMatchObject({ outcome: "suggested", print: { ai_suggestion: { category_id: first.id } } });
|
||||
const bulk = await request(app)
|
||||
.post("/api/ai-categorization/suggestions/bulk")
|
||||
.set(auth())
|
||||
.send({ action: "reject", print_ids: [model.id] });
|
||||
expect(bulk.body).toMatchObject({ accepted: 0, rejected: 1, failed: 0 });
|
||||
const repeated = await request(app).post(`/api/print/${model.id}/recategorize`).set(auth()).send({});
|
||||
expect(repeated.body).toMatchObject({ outcome: "suggested", print: { ai_suggestion: { category_id: second.id } } });
|
||||
expect(promptText(received[1] as Record<string, unknown>)).not.toContain(first.name);
|
||||
|
||||
const accepted = await print();
|
||||
await request(app).post(`/api/print/${accepted.id}/recategorize`).set(auth()).send({});
|
||||
const acceptedBulk = await request(app)
|
||||
.post("/api/ai-categorization/suggestions/bulk")
|
||||
.set(auth())
|
||||
.send({ action: "accept", print_ids: [accepted.id] });
|
||||
expect(acceptedBulk.body).toMatchObject({ accepted: 1, rejected: 0, failed: 0 });
|
||||
|
||||
const stale = await print();
|
||||
await request(app).post(`/api/print/${stale.id}/recategorize`).set(auth()).send({});
|
||||
await prisma.category.delete({ where: { id: staleCategory.id } });
|
||||
const staleAccept = await request(app).post(`/api/print/${stale.id}/ai-suggestion/accept`).set(auth()).send({});
|
||||
expect(staleAccept.status).toBe(409);
|
||||
expect(staleAccept.body.code).toBe("stale");
|
||||
});
|
||||
|
||||
it("discards an in-flight answer when a user categorizes manually", async () => {
|
||||
const target = await category("AI target");
|
||||
const manual = await category("Manual winner");
|
||||
let release!: () => void;
|
||||
let arrived!: () => void;
|
||||
const held = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const providerStarted = new Promise<void>((resolve) => {
|
||||
arrived = resolve;
|
||||
});
|
||||
const baseUrl = await startProvider(async () => {
|
||||
arrived();
|
||||
await held;
|
||||
return { category_id: target.id, confidence: 1, reason: "late" };
|
||||
});
|
||||
await enable(baseUrl, { mode: "auto", timeoutMs: 5000 });
|
||||
const model = await print();
|
||||
const pending = request(app)
|
||||
.post(`/api/print/${model.id}/recategorize`)
|
||||
.set(auth())
|
||||
.send({})
|
||||
.then((response) => response);
|
||||
await providerStarted;
|
||||
const chosen = await request(app)
|
||||
.post(`/api/print/${model.id}/category`)
|
||||
.set(auth())
|
||||
.send({ category_id: manual.id });
|
||||
expect(chosen.status).toBe(200);
|
||||
release();
|
||||
const completed = await pending;
|
||||
expect(completed.body.outcome).toBe("unchanged");
|
||||
expect(completed.body.print).toMatchObject({ category_id: manual.id, category_source: "manual" });
|
||||
});
|
||||
|
||||
it("does not block imports when a provider is down and skips prints created with a category", async () => {
|
||||
setAiCategorizationRetryDelayForTest(async () => undefined);
|
||||
const assigned = await category("Assigned on upload");
|
||||
const downUrl = await startProvider(() => ({ status: 500 }));
|
||||
await enable(downUrl, { onImport: true });
|
||||
const manualUpload = await request(app)
|
||||
.post("/api/upload")
|
||||
.set(auth())
|
||||
.field("category_id", assigned.id)
|
||||
.attach("files", Buffer.from(STL), "manual.stl");
|
||||
expect(manualUpload.status).toBe(200);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(providerRequests).toBe(0);
|
||||
|
||||
const uncategorizedUpload = await request(app)
|
||||
.post("/api/upload")
|
||||
.set(auth())
|
||||
.attach("files", Buffer.from(STL), "uncategorized.stl");
|
||||
expect(uncategorizedUpload.status).toBe(200);
|
||||
await waitFor(() => providerRequests === 3, "import hook did not retry its failed provider call");
|
||||
});
|
||||
|
||||
it("isolates candidate categories and suggestion lists by user", async () => {
|
||||
const owner = await newUser();
|
||||
const other = await newUser();
|
||||
const ownCategory = await categoryFor(owner.id, "Owned category");
|
||||
const ownFolder = await categoryFor(owner.id, "Owned folder", "folder");
|
||||
const otherCategory = await categoryFor(other.id, "Other category");
|
||||
const bodies: Record<string, unknown>[] = [];
|
||||
const baseUrl = await startProvider((body) => {
|
||||
bodies.push(body);
|
||||
return {
|
||||
category_id: promptText(body).includes(ownCategory.name) ? ownCategory.id : otherCategory.id,
|
||||
confidence: 0.2,
|
||||
reason: "review",
|
||||
};
|
||||
});
|
||||
await enable(baseUrl);
|
||||
const ownPrint = await printFor(owner.id);
|
||||
const otherPrint = await printFor(other.id);
|
||||
await request(app).post(`/api/print/${ownPrint.id}/recategorize`).set(auth(owner.token)).send({});
|
||||
await request(app).post(`/api/print/${otherPrint.id}/recategorize`).set(auth(other.token)).send({});
|
||||
expect(promptText(bodies[0])).toContain(ownCategory.name);
|
||||
expect(promptText(bodies[0])).not.toContain(ownFolder.name);
|
||||
expect(promptText(bodies[0])).not.toContain(otherCategory.name);
|
||||
|
||||
const suggestions = await request(app).get("/api/ai-categorization/suggestions").set(auth(owner.token));
|
||||
expect(suggestions.body.total).toBe(1);
|
||||
expect(suggestions.body.items.map((item: { id: string }) => item.id)).toEqual([ownPrint.id]);
|
||||
});
|
||||
|
||||
it("honors run scope, excludes MANUAL and folders, rejects duplicate starts, and cancels", async () => {
|
||||
const runner = await newUser();
|
||||
const target = await categoryFor(runner.id, "Run target");
|
||||
const folder = await categoryFor(runner.id, "Run folder", "folder");
|
||||
await printFor(runner.id);
|
||||
await printFor(runner.id, target.id, "AI");
|
||||
await printFor(runner.id, target.id, "RULE");
|
||||
await printFor(runner.id, target.id, "LEGACY");
|
||||
await printFor(runner.id, target.id, "MANUAL");
|
||||
await printFor(runner.id, folder.id, "AI");
|
||||
const baseUrl = await startProvider(() => ({ category_id: null, confidence: 1, reason: "none" }));
|
||||
await enable(baseUrl);
|
||||
|
||||
const counts = await request(app).get("/api/ai-categorization/run").set(auth(runner.token));
|
||||
expect(counts.body.counts).toMatchObject({ uncategorized: 1, ai: 1, rule: 1, legacy: 1, manual: 1, folder: 1 });
|
||||
const onlyUncategorized = await request(app)
|
||||
.post("/api/ai-categorization/run")
|
||||
.set(auth(runner.token))
|
||||
.send({ include_ai: false, include_rule: false, include_legacy: false });
|
||||
expect(onlyUncategorized.status).toBe(202);
|
||||
const firstRun = await waitForRun(runner.token);
|
||||
expect(firstRun.body.run).toMatchObject({ total: 1, done: 1, failed: 0 });
|
||||
expect(providerRequests).toBe(1);
|
||||
|
||||
let release!: () => void;
|
||||
let arrived!: () => void;
|
||||
const held = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const arrivedAtProvider = new Promise<void>((resolve) => {
|
||||
arrived = resolve;
|
||||
});
|
||||
await stopProvider();
|
||||
const heldUrl = await startProvider(async () => {
|
||||
arrived();
|
||||
await held;
|
||||
return { category_id: null, confidence: 1, reason: "none" };
|
||||
});
|
||||
await enable(heldUrl);
|
||||
const started = await request(app)
|
||||
.post("/api/ai-categorization/run")
|
||||
.set(auth(runner.token))
|
||||
.send({ include_ai: true, include_rule: true, include_legacy: true });
|
||||
expect(started.status).toBe(202);
|
||||
await arrivedAtProvider;
|
||||
const duplicate = await request(app)
|
||||
.post("/api/ai-categorization/run")
|
||||
.set(auth(runner.token))
|
||||
.send({ include_ai: true, include_rule: true, include_legacy: true });
|
||||
expect(duplicate.status).toBe(409);
|
||||
expect(duplicate.body.code).toBe("running");
|
||||
const cancelled = await request(app).post("/api/ai-categorization/run/cancel").set(auth(runner.token)).send({});
|
||||
expect(cancelled.body.run.cancelled).toBe(true);
|
||||
release();
|
||||
const stopped = await waitForRun(runner.token);
|
||||
expect(stopped.body.run).toMatchObject({ total: 4, done: 1, cancelled: true, failed: 0 });
|
||||
});
|
||||
|
||||
it("counts manual and folder conflicts raised mid-run as completed work", async () => {
|
||||
const runner = await newUser();
|
||||
const target = await categoryFor(runner.id, "Conflict target");
|
||||
const folder = await categoryFor(runner.id, "Conflict folder", "folder");
|
||||
const first = await printFor(runner.id);
|
||||
const changedToManual = await printFor(runner.id);
|
||||
const changedToFolder = await printFor(runner.id);
|
||||
let release!: () => void;
|
||||
let arrived!: () => void;
|
||||
const held = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const arrivedAtProvider = new Promise<void>((resolve) => {
|
||||
arrived = resolve;
|
||||
});
|
||||
const baseUrl = await startProvider(async () => {
|
||||
arrived();
|
||||
await held;
|
||||
return { category_id: null, confidence: 1, reason: "none" };
|
||||
});
|
||||
await enable(baseUrl);
|
||||
await request(app)
|
||||
.post("/api/ai-categorization/run")
|
||||
.set(auth(runner.token))
|
||||
.send({ include_ai: false, include_rule: false, include_legacy: false });
|
||||
await arrivedAtProvider;
|
||||
await prisma.print.update({
|
||||
where: { id: changedToManual.id },
|
||||
data: { categoryId: target.id, categorySource: "MANUAL" },
|
||||
});
|
||||
await prisma.print.update({
|
||||
where: { id: changedToFolder.id },
|
||||
data: { categoryId: folder.id, categorySource: "AI" },
|
||||
});
|
||||
release();
|
||||
const stopped = await waitForRun(runner.token);
|
||||
expect(first.id).toBeTruthy();
|
||||
expect(stopped.body.run).toMatchObject({ total: 3, done: 3, failed: 0, last_error: null });
|
||||
expect(providerRequests).toBe(1);
|
||||
});
|
||||
|
||||
it("stops a run after five consecutive provider failures", async () => {
|
||||
setAiCategorizationRetryDelayForTest(async () => undefined);
|
||||
const runner = await newUser();
|
||||
await categoryFor(runner.id, "Failure target");
|
||||
for (let i = 0; i < 6; i++) await printFor(runner.id);
|
||||
const baseUrl = await startProvider(() => ({ status: 500 }));
|
||||
await enable(baseUrl);
|
||||
const started = await request(app)
|
||||
.post("/api/ai-categorization/run")
|
||||
.set(auth(runner.token))
|
||||
.send({ include_ai: false, include_rule: false, include_legacy: false });
|
||||
expect(started.status).toBe(202);
|
||||
const stopped = await waitForRun(runner.token);
|
||||
expect(stopped.body.run).toMatchObject({ total: 6, done: 5, failed: 5 });
|
||||
expect(stopped.body.run.last_error).toContain("5 consecutive failures");
|
||||
expect(providerRequests).toBe(15);
|
||||
});
|
||||
|
||||
it("stops a run when AI is disabled while it is in progress", async () => {
|
||||
const runner = await newUser();
|
||||
await categoryFor(runner.id, "Disable target");
|
||||
await printFor(runner.id);
|
||||
await printFor(runner.id);
|
||||
let release!: () => void;
|
||||
let arrived!: () => void;
|
||||
const held = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const arrivedAtProvider = new Promise<void>((resolve) => {
|
||||
arrived = resolve;
|
||||
});
|
||||
const baseUrl = await startProvider(async () => {
|
||||
arrived();
|
||||
await held;
|
||||
return { category_id: null, confidence: 1, reason: "none" };
|
||||
});
|
||||
await enable(baseUrl);
|
||||
await request(app)
|
||||
.post("/api/ai-categorization/run")
|
||||
.set(auth(runner.token))
|
||||
.send({ include_ai: false, include_rule: false, include_legacy: false });
|
||||
await arrivedAtProvider;
|
||||
await setAiCategorizationSettings(DEFAULT_AI_CATEGORIZATION_SETTINGS);
|
||||
release();
|
||||
const stopped = await waitForRun(runner.token);
|
||||
expect(stopped.body.run).toMatchObject({ total: 2, done: 2, failed: 0 });
|
||||
expect(stopped.body.run.last_error).toContain("disabled");
|
||||
});
|
||||
|
||||
it("uses the global concurrency limit for simultaneous recategorization", async () => {
|
||||
const runner = await newUser();
|
||||
const target = await categoryFor(runner.id, "Concurrent target");
|
||||
const baseUrl = await startProvider(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
return { category_id: target.id, confidence: 1, reason: "fits" };
|
||||
});
|
||||
await enable(baseUrl, { mode: "auto", concurrency: 1 });
|
||||
const models = await Promise.all([printFor(runner.id), printFor(runner.id), printFor(runner.id)]);
|
||||
const responses = await Promise.all(
|
||||
models.map((model) => request(app).post(`/api/print/${model.id}/recategorize`).set(auth(runner.token)).send({})),
|
||||
);
|
||||
expect(responses.map((response) => response.status)).toEqual([200, 200, 200]);
|
||||
expect(providerMaxInFlight).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@ import ConnectionsPage from "./pages/ConnectionsPage";
|
||||
import CaptchaPage from "./pages/CaptchaPage";
|
||||
import ImportQueuePage from "./pages/ImportQueuePage";
|
||||
import RenderingPage from "./pages/RenderingPage";
|
||||
import AiPage from "./pages/AiPage";
|
||||
import { healthApi, type HealthInfo } from "./api/health";
|
||||
import { authApi, type AuthUser } from "./api/auth";
|
||||
import { settingsApi, type PreviewMode } from "./api/settings";
|
||||
@@ -227,6 +228,10 @@ function AppShell({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin-ai"
|
||||
element={isAdmin ? <AiPage onUnauthorized={onUnauthorized} /> : <Navigate to="/" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/admin-users"
|
||||
element={isAdmin ? <UsersPage onUnauthorized={onUnauthorized} /> : <Navigate to="/" replace />}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { authHeaders } from "../utils/auth";
|
||||
import { apiBase, readErrorMessage, UnauthorizedError } from "./client";
|
||||
import type { Print } from "./prints";
|
||||
|
||||
export type AiCategorizationMode = "off" | "suggest" | "auto";
|
||||
|
||||
export type AiCategorizationStatus = { enabled: boolean; mode: AiCategorizationMode };
|
||||
export type AiRunCounts = {
|
||||
uncategorized: number;
|
||||
ai: number;
|
||||
rule: number;
|
||||
legacy: number;
|
||||
manual: number;
|
||||
folder: number;
|
||||
};
|
||||
export type AiRun = {
|
||||
running: boolean;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
total: number;
|
||||
done: number;
|
||||
applied: number;
|
||||
suggested: number;
|
||||
no_match: number;
|
||||
failed: number;
|
||||
cancelled: boolean;
|
||||
last_error: string | null;
|
||||
};
|
||||
export type AiRunState = { counts: AiRunCounts; run: AiRun | null };
|
||||
export type AiRunScope = { include_ai: boolean; include_rule: boolean; include_legacy: boolean };
|
||||
export type AiSuggestions = { total: number; items: Print[] };
|
||||
export type AiBulkResult = { accepted: number; rejected: number; failed: number };
|
||||
export type AiRecategorizeOutcome = "applied" | "suggested" | "no_match" | "unchanged";
|
||||
export type AiRecategorizeResult = { print: Print; outcome: AiRecategorizeOutcome };
|
||||
|
||||
export class AiCategorizationError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
readonly code: string | null,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AiCategorizationError";
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, method = "GET", payload?: unknown): Promise<T> {
|
||||
const res = await fetch(`${apiBase()}${path}`, {
|
||||
method,
|
||||
headers: authHeaders(payload === undefined ? undefined : { "Content-Type": "application/json" }),
|
||||
...(payload === undefined ? {} : { body: JSON.stringify(payload) }),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
if (!res.ok) {
|
||||
let code: string | null = null;
|
||||
let message: string;
|
||||
try {
|
||||
const body: unknown = await res.json();
|
||||
if (typeof body === "object" && body !== null) {
|
||||
const record = body as Record<string, unknown>;
|
||||
if (typeof record.code === "string") code = record.code;
|
||||
message =
|
||||
typeof record.error === "string"
|
||||
? record.error
|
||||
: await readErrorMessage(res, `Request failed (${res.status})`);
|
||||
} else {
|
||||
message = `Request failed (${res.status})`;
|
||||
}
|
||||
} catch {
|
||||
message = `Request failed (${res.status})`;
|
||||
}
|
||||
throw new AiCategorizationError(message, res.status, code);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
let statusRequest: Promise<AiCategorizationStatus> | null = null;
|
||||
|
||||
/** The status is fetched once per session; saving the admin settings drops it so menus follow. */
|
||||
export function invalidateAiCategorizationStatus(): void {
|
||||
statusRequest = null;
|
||||
}
|
||||
|
||||
export const aiCategorizationApi = {
|
||||
status: (): Promise<AiCategorizationStatus> => {
|
||||
if (!statusRequest) {
|
||||
statusRequest = request<AiCategorizationStatus>("/ai-categorization/status").catch((error: unknown) => {
|
||||
statusRequest = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return statusRequest;
|
||||
},
|
||||
suggestions: (limit = 50, offset = 0): Promise<AiSuggestions> =>
|
||||
request(`/ai-categorization/suggestions?limit=${limit}&offset=${offset}`),
|
||||
accept: (printId: string): Promise<{ print: Print }> =>
|
||||
request(`/print/${encodeURIComponent(printId)}/ai-suggestion/accept`, "POST"),
|
||||
reject: (printId: string): Promise<{ print: Print }> =>
|
||||
request(`/print/${encodeURIComponent(printId)}/ai-suggestion/reject`, "POST"),
|
||||
bulk: (action: "accept" | "reject", printIds: string[]): Promise<AiBulkResult> =>
|
||||
request("/ai-categorization/suggestions/bulk", "POST", { action, print_ids: printIds }),
|
||||
runState: (): Promise<AiRunState> => request("/ai-categorization/run"),
|
||||
startRun: (scope: AiRunScope): Promise<{ run: AiRun }> => request("/ai-categorization/run", "POST", scope),
|
||||
cancelRun: (): Promise<{ run: AiRun }> => request("/ai-categorization/run/cancel", "POST"),
|
||||
};
|
||||
@@ -62,6 +62,15 @@ export type Print = {
|
||||
tags: string[];
|
||||
category_id?: string | null;
|
||||
category_name?: string | null;
|
||||
category_source?: "manual" | "rule" | "ai" | "legacy" | null;
|
||||
ai_suggestion?: {
|
||||
category_id: string;
|
||||
category_path: string;
|
||||
confidence: number;
|
||||
reason: string;
|
||||
model: string;
|
||||
created_at: string;
|
||||
} | null;
|
||||
created_at: string;
|
||||
storage_path?: string | null;
|
||||
plates: Plate[]; // ordered by position, length >= 1
|
||||
@@ -106,6 +115,17 @@ export type FillGapsResult = {
|
||||
|
||||
export type PrintSortMode = "newest" | "popular" | "downloads";
|
||||
|
||||
export class PrintApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
readonly code: string | null,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "PrintApiError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Filters combine with AND. */
|
||||
export type DownloadZipFilter = {
|
||||
print_ids?: string[];
|
||||
@@ -369,6 +389,33 @@ export const printsApi = {
|
||||
},
|
||||
|
||||
/** Fills the model's empty details and images from its source; never overwrites an edit. */
|
||||
recategorize: async (
|
||||
id: string,
|
||||
force = false,
|
||||
): Promise<{ print: Print; outcome: "applied" | "suggested" | "no_match" | "unchanged" }> => {
|
||||
const res = await fetch(`${apiBase()}/print/${id}/recategorize`, {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ force }),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
if (!res.ok) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
const record = typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
|
||||
throw new PrintApiError(
|
||||
typeof record.error === "string" ? record.error : `AI recategorization failed (${res.status})`,
|
||||
res.status,
|
||||
typeof record.code === "string" ? record.code : null,
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
|
||||
fillGaps: async (id: string): Promise<FillGapsResult> => {
|
||||
const res = await fetch(`${apiBase()}/print/${id}/fill-gaps`, { method: "POST", headers: authHeaders() });
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { authHeaders } from "../utils/auth";
|
||||
import { apiBase, assertOk, readErrorMessage, UnauthorizedError } from "./client";
|
||||
import type { ThemeSelection } from "../constants/settingsOptions";
|
||||
import { invalidateAiCategorizationStatus } from "./aiCategorization";
|
||||
|
||||
export type StorageSettings = {
|
||||
template: string;
|
||||
@@ -48,6 +49,22 @@ export type AuthSettings = {
|
||||
token_ttl_seconds: number;
|
||||
};
|
||||
|
||||
export type AiCategorizationSettings = {
|
||||
mode: "off" | "suggest" | "auto";
|
||||
base_url: string | null;
|
||||
model: string | null;
|
||||
has_api_key: boolean;
|
||||
threshold: number;
|
||||
on_import: boolean;
|
||||
concurrency: number;
|
||||
send_image: boolean;
|
||||
timeout_ms: number;
|
||||
};
|
||||
|
||||
export type AiCategorizationSettingsInput = Partial<Omit<AiCategorizationSettings, "has_api_key">> & {
|
||||
api_key?: string | null;
|
||||
};
|
||||
|
||||
export type SmtpSettings = {
|
||||
host: string | null;
|
||||
port: number;
|
||||
@@ -91,6 +108,39 @@ export type VersionCheck = {
|
||||
export const FRONTEND_GIT_SHA: string | null = (import.meta.env.VITE_GIT_SHA as string | undefined) || null;
|
||||
|
||||
export const settingsApi = {
|
||||
getAiCategorization: async (): Promise<AiCategorizationSettings> => {
|
||||
const res = await fetch(`${apiBase()}/settings/ai-categorization`, { headers: authHeaders() });
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
assertOk(res, "Failed to load AI categorization settings");
|
||||
return res.json();
|
||||
},
|
||||
|
||||
updateAiCategorization: async (payload: AiCategorizationSettingsInput): Promise<AiCategorizationSettings> => {
|
||||
const res = await fetch(`${apiBase()}/settings/ai-categorization`, {
|
||||
method: "PATCH",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to update AI categorization settings"));
|
||||
invalidateAiCategorizationStatus();
|
||||
return res.json();
|
||||
},
|
||||
|
||||
// Omitted fields use the saved value, so a connection can be tested before saving.
|
||||
testAiCategorization: async (
|
||||
payload: Pick<AiCategorizationSettingsInput, "base_url" | "model" | "api_key" | "timeout_ms"> = {},
|
||||
): Promise<{ ok: boolean; latency_ms?: number; model?: string; error?: string }> => {
|
||||
const res = await fetch(`${apiBase()}/settings/ai-categorization/test`, {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to test AI connection"));
|
||||
return res.json();
|
||||
},
|
||||
|
||||
getVersionCheck: async (): Promise<VersionCheck> => {
|
||||
const res = await fetch(`${apiBase()}/settings/version-check`, { headers: authHeaders() });
|
||||
assertOk(res, "Failed to check for updates");
|
||||
|
||||
@@ -102,6 +102,9 @@ function useRouteChrome() {
|
||||
} else if (path.startsWith("/admin-rendering")) {
|
||||
title = t("adminSettings.rendering.heading");
|
||||
onBack = () => navigate("/admin");
|
||||
} else if (path.startsWith("/admin-ai")) {
|
||||
title = t("adminSettings.ai.heading");
|
||||
onBack = () => navigate("/admin");
|
||||
} else if (path.startsWith("/admin-users")) {
|
||||
title = t("adminSettings.users.heading");
|
||||
onBack = () => navigate("/admin");
|
||||
|
||||
@@ -14,6 +14,12 @@ const selectedBg = (theme: Theme) =>
|
||||
theme.palette.mode === "dark" ? "rgba(255, 255, 255, 0.08)" : "rgba(0, 0, 0, 0.06)";
|
||||
const hoverBg = (theme: Theme) => (theme.palette.mode === "dark" ? "rgba(255, 255, 255, 0.04)" : "rgba(0, 0, 0, 0.03)");
|
||||
|
||||
// "large" fits wider section names on one line and longer content, e.g. tree editors and lists.
|
||||
const SIZES = {
|
||||
default: { width: 620, height: 520, navWidth: 170 },
|
||||
large: { width: 920, height: 680, navWidth: 210 },
|
||||
};
|
||||
|
||||
type Props<T extends string> = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -21,6 +27,7 @@ type Props<T extends string> = {
|
||||
sections: DialogSection<T>[];
|
||||
section: T;
|
||||
onSectionChange: (section: T) => void;
|
||||
size?: keyof typeof SIZES;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -33,9 +40,11 @@ export default function SectionedDialog<T extends string>({
|
||||
sections,
|
||||
section,
|
||||
onSectionChange,
|
||||
size = "default",
|
||||
children,
|
||||
}: Props<T>) {
|
||||
const { t } = useTranslation("common");
|
||||
const { width, height, navWidth } = SIZES[size];
|
||||
|
||||
const sectionButton = ({ id, label, icon }: DialogSection<T>, compact: boolean) => {
|
||||
const chosen = id === section;
|
||||
@@ -56,6 +65,8 @@ export default function SectionedDialog<T extends string>({
|
||||
borderRadius: "8px",
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 500,
|
||||
// Phone tabs share the width, so only the side menu keeps labels on one line.
|
||||
whiteSpace: compact ? "normal" : "nowrap",
|
||||
color: chosen ? (muiTheme) => muiTheme.thingport.headingText : "text.secondary",
|
||||
bgcolor: chosen ? selectedBg : "transparent",
|
||||
transition: "background-color 150ms",
|
||||
@@ -76,9 +87,9 @@ export default function SectionedDialog<T extends string>({
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
width: 620,
|
||||
width,
|
||||
maxWidth: "calc(100% - 32px)",
|
||||
height: { xs: "75vh", md: 520 },
|
||||
height: { xs: "75vh", md: height },
|
||||
maxHeight: "calc(100% - 32px)",
|
||||
m: 2,
|
||||
display: "flex",
|
||||
@@ -135,7 +146,7 @@ export default function SectionedDialog<T extends string>({
|
||||
display: { xs: "none", md: "flex" },
|
||||
flexDirection: "column",
|
||||
gap: 0.25,
|
||||
width: 170,
|
||||
width: navWidth,
|
||||
flexShrink: 0,
|
||||
p: 1.25,
|
||||
borderRight: 1,
|
||||
|
||||
@@ -518,6 +518,45 @@
|
||||
"cleared": "Access Token removed.",
|
||||
"failed": "Failed to save Thingiverse settings."
|
||||
},
|
||||
"ai": {
|
||||
"heading": "AI",
|
||||
"connection": {
|
||||
"heading": "Connect AI",
|
||||
"subtitle": "An OpenAI-compatible service that Thingport's AI features use.",
|
||||
"baseUrl": "API base URL",
|
||||
"model": "Model name",
|
||||
"apiKey": "API key",
|
||||
"keySet": "A key is saved. Type a new one to replace it.",
|
||||
"clearKey": "Clear API key",
|
||||
"keyWillClear": "The key will be removed when you save",
|
||||
"concurrency": "Concurrent requests (1-4)",
|
||||
"timeout": "Request timeout (ms)",
|
||||
"writeOnly": "The saved API key is never shown. Leave the field blank to keep it.",
|
||||
"test": "Test connection",
|
||||
"testing": "Testing…",
|
||||
"testSuccess": "Connected to {{model}} in {{latency}} ms.",
|
||||
"testFailed": "Connection test failed.",
|
||||
"save": "Save connection",
|
||||
"saving": "Checking connection…",
|
||||
"saved": "AI connection saved.",
|
||||
"failed": "Failed to load or save the AI connection."
|
||||
}
|
||||
},
|
||||
"aiCategorization": {
|
||||
"heading": "AI model categorization",
|
||||
"subtitle": "Sort models into their owner's categories with the connected AI.",
|
||||
"privacy": "With a third-party API, model titles, descriptions and tags leave this server. Images are sent only when enabled.",
|
||||
"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": {
|
||||
"heading": "Session Length",
|
||||
"subtitle": "Control how long a signed-in session stays valid for the whole instance.",
|
||||
@@ -1514,6 +1553,47 @@
|
||||
"importedFrom": "Imported from {{provider}}",
|
||||
"uploadedDirectly": "Uploaded directly to Thingport"
|
||||
},
|
||||
"aiCategorization": {
|
||||
"folderSkipped": "Models in folders are left as they are: folders are your own tree.",
|
||||
"section": "AI Categorization",
|
||||
"recategorize": "Recategorize with AI",
|
||||
"runAll": "Recategorize all with AI",
|
||||
"runHint": "Sorts uncategorized models, and can re-check ones that already have a category.",
|
||||
"runTitle": "Recategorize models with AI",
|
||||
"countUncategorized": "Uncategorized models are always included: {{count}}",
|
||||
"includeAi": "Include models last categorized by AI: {{count}}",
|
||||
"includeRule": "Include models categorized by source rules: {{count}}",
|
||||
"includeLegacy": "Include older categorized models: {{count}}",
|
||||
"skipped": "{{manual}} manual models and {{folder}} folder models are always skipped.",
|
||||
"progress": "Processed {{done}} of {{total}}, {{applied}} applied, {{suggested}} suggested, {{failed}} failed.",
|
||||
"finished": "Finished: {{applied}} applied, {{suggested}} suggested, {{failed}} failed.",
|
||||
"cancel": "Cancel run",
|
||||
"start": "Start",
|
||||
"suggestionsTitle": "Pending suggestions ({{count}})",
|
||||
"selectAll": "Select all",
|
||||
"deselectAll": "Deselect all",
|
||||
"acceptSelected": "Accept selected",
|
||||
"rejectSelected": "Reject selected",
|
||||
"accept": "Accept",
|
||||
"reject": "Reject",
|
||||
"acceptDone": "Accepted {{count}} suggestion(s).",
|
||||
"rejectDone": "Rejected {{count}} suggestion(s).",
|
||||
"firstPage": "Showing {{shown}} of {{total}} suggestions.",
|
||||
"loadMore": "Load more",
|
||||
"noSuggestions": "No pending suggestions.",
|
||||
"suggestionLabel": "AI suggestion",
|
||||
"byAi": "by AI",
|
||||
"accepted": "AI suggestion accepted.",
|
||||
"rejected": "AI suggestion rejected.",
|
||||
"confirmManual": "{{name}} has a category you chose manually. Replace it with an AI result?",
|
||||
"failed": "AI recategorization failed.",
|
||||
"outcome": {
|
||||
"applied": "AI category applied.",
|
||||
"suggested": "AI suggestion added for review.",
|
||||
"no_match": "AI couldn't find a suitable category.",
|
||||
"unchanged": "The category was left unchanged."
|
||||
}
|
||||
},
|
||||
"detail": {
|
||||
"subtitle": "Model",
|
||||
"preview3d": "3D Preview",
|
||||
@@ -1837,6 +1917,9 @@
|
||||
"addSubcategory": "Add subfolder",
|
||||
"noCategories": "No folders yet. Add one, or upload a folder of models."
|
||||
}
|
||||
},
|
||||
"manager": {
|
||||
"title": "Manage categories"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -520,6 +520,45 @@
|
||||
"cleared": "Prieigos raktas pašalintas.",
|
||||
"failed": "Nepavyko išsaugoti Thingiverse nustatymų."
|
||||
},
|
||||
"ai": {
|
||||
"heading": "DI",
|
||||
"connection": {
|
||||
"heading": "DI prijungimas",
|
||||
"subtitle": "Su OpenAI suderinama paslauga, kurią naudoja Thingport DI funkcijos.",
|
||||
"baseUrl": "API bazinis URL",
|
||||
"model": "Modelio pavadinimas",
|
||||
"apiKey": "API raktas",
|
||||
"keySet": "Raktas išsaugotas. Įveskite naują, kad jį pakeistumėte.",
|
||||
"clearKey": "Pašalinti API raktą",
|
||||
"keyWillClear": "Raktas bus pašalintas išsaugojus",
|
||||
"concurrency": "Lygiagrečios užklausos (1-4)",
|
||||
"timeout": "Užklausos skirtasis laikas (ms)",
|
||||
"writeOnly": "Išsaugotas API raktas niekada nerodomas. Palikite lauką tuščią, kad jį išsaugotumėte.",
|
||||
"test": "Tikrinti ryšį",
|
||||
"testing": "Tikrinama…",
|
||||
"testSuccess": "Prisijungta prie {{model}} per {{latency}} ms.",
|
||||
"testFailed": "Ryšio patikra nepavyko.",
|
||||
"save": "Išsaugoti prijungimą",
|
||||
"saving": "Tikrinamas ryšys…",
|
||||
"saved": "DI prijungimas išsaugotas.",
|
||||
"failed": "Nepavyko įkelti arba išsaugoti DI prijungimo."
|
||||
}
|
||||
},
|
||||
"aiCategorization": {
|
||||
"heading": "DI modelių kategorizavimas",
|
||||
"subtitle": "Rūšiuokite modelius į jų savininko kategorijas naudodami prijungtą DI.",
|
||||
"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į.",
|
||||
"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": {
|
||||
"heading": "Sesijos trukmė",
|
||||
"subtitle": "Valdykite, kiek laiko galioja prisijungimo sesija visai sistemai.",
|
||||
@@ -1541,6 +1580,47 @@
|
||||
"importedFrom": "Importuota iš {{provider}}",
|
||||
"uploadedDirectly": "Įkelta tiesiai į Thingport"
|
||||
},
|
||||
"aiCategorization": {
|
||||
"folderSkipped": "Modeliai aplankuose paliekami kaip yra: aplankai yra jūsų pačių medis.",
|
||||
"section": "DI kategorizavimas",
|
||||
"recategorize": "Kategorizuoti iš naujo naudojant DI",
|
||||
"runAll": "Kategorizuoti visus iš naujo naudojant DI",
|
||||
"runHint": "Surūšiuoja nekategorizuotus modelius ir gali iš naujo patikrinti jau kategorizuotus.",
|
||||
"runTitle": "Kategorizuoti modelius naudojant DI",
|
||||
"countUncategorized": "Nekategorizuoti modeliai visada įtraukiami: {{count}}",
|
||||
"includeAi": "Įtraukti DI anksčiau kategorizuotus modelius: {{count}}",
|
||||
"includeRule": "Įtraukti pagal šaltinio taisykles kategorizuotus modelius: {{count}}",
|
||||
"includeLegacy": "Įtraukti seniau kategorizuotus modelius: {{count}}",
|
||||
"skipped": "{{manual}} rankiniu būdu kategorizuoti modeliai ir {{folder}} aplankų modeliai praleidžiami.",
|
||||
"progress": "Apdorota {{done}} iš {{total}}, pritaikyta {{applied}}, pasiūlyta {{suggested}}, nepavyko {{failed}}.",
|
||||
"finished": "Baigta: pritaikyta {{applied}}, pasiūlyta {{suggested}}, nepavyko {{failed}}.",
|
||||
"cancel": "Atšaukti vykdymą",
|
||||
"start": "Pradėti",
|
||||
"suggestionsTitle": "Laukiantys pasiūlymai ({{count}})",
|
||||
"selectAll": "Pažymėti visus",
|
||||
"deselectAll": "Panaikinti žymėjimą",
|
||||
"acceptSelected": "Priimti pasirinktus",
|
||||
"rejectSelected": "Atmesti pasirinktus",
|
||||
"accept": "Priimti",
|
||||
"reject": "Atmesti",
|
||||
"acceptDone": "Priimta pasiūlymų: {{count}}.",
|
||||
"rejectDone": "Atmesta pasiūlymų: {{count}}.",
|
||||
"firstPage": "Rodoma {{shown}} iš {{total}} pasiūlymų.",
|
||||
"loadMore": "Įkelti daugiau",
|
||||
"noSuggestions": "Laukiančių pasiūlymų nėra.",
|
||||
"suggestionLabel": "DI pasiūlymas",
|
||||
"byAi": "DI",
|
||||
"accepted": "DI pasiūlymas priimtas.",
|
||||
"rejected": "DI pasiūlymas atmestas.",
|
||||
"confirmManual": "{{name}} turi jūsų rankiniu būdu pasirinktą kategoriją. Pakeisti ją DI rezultatu?",
|
||||
"failed": "DI kategorizuoti nepavyko.",
|
||||
"outcome": {
|
||||
"applied": "DI kategorija pritaikyta.",
|
||||
"suggested": "Pridėtas DI pasiūlymas peržiūrai.",
|
||||
"no_match": "DI nerado tinkamos kategorijos.",
|
||||
"unchanged": "Kategorija nepakeista."
|
||||
}
|
||||
},
|
||||
"detail": {
|
||||
"subtitle": "Modelis",
|
||||
"preview3d": "3D peržiūra",
|
||||
@@ -1873,6 +1953,9 @@
|
||||
"addSubcategory": "Pridėti poaplankį",
|
||||
"noCategories": "Kol kas nėra aplankų. Pridėkite arba įkelkite modelių aplanką."
|
||||
}
|
||||
},
|
||||
"manager": {
|
||||
"title": "Kategorijų tvarkymas"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import CableIcon from "@mui/icons-material/Cable";
|
||||
import PublicIcon from "@mui/icons-material/Public";
|
||||
import VerifiedUserIcon from "@mui/icons-material/VerifiedUser";
|
||||
import ViewInArIcon from "@mui/icons-material/ViewInAr";
|
||||
import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
|
||||
import QueueIcon from "@mui/icons-material/Queue";
|
||||
import UpdateCheckSection from "./UpdateCheckSection";
|
||||
import { adminApi, type StorageUsage } from "../../api/admin";
|
||||
@@ -36,6 +37,7 @@ type Section = {
|
||||
const SECTIONS: Section[] = [
|
||||
{ path: "/admin-settings", icon: <SettingsIcon fontSize="small" />, labelKey: "common:settings" },
|
||||
{ path: "/admin-rendering", icon: <ViewInArIcon fontSize="small" />, labelKey: "adminSettings.rendering.heading" },
|
||||
{ path: "/admin-ai", icon: <AutoAwesomeIcon fontSize="small" />, labelKey: "adminSettings.ai.heading" },
|
||||
{ path: "/admin-users", icon: <PeopleIcon fontSize="small" />, labelKey: "adminSettings.users.heading" },
|
||||
{ path: "/admin-queue", icon: <QueueIcon fontSize="small" />, labelKey: "adminSettings.importQueue.heading" },
|
||||
{ path: "/admin-logs", icon: <HistoryIcon fontSize="small" />, labelKey: "adminSettings.logs.heading" },
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useEffect, 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 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";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import { settingsApi, type AiCategorizationSettings } from "../../api/settings";
|
||||
import SectionHeader from "../../components/SectionHeader";
|
||||
|
||||
type Props = { onUnauthorized?: () => void };
|
||||
type Draft = Pick<AiCategorizationSettings, "mode" | "threshold" | "on_import" | "send_image">;
|
||||
const defaults: Draft = { mode: "off", threshold: 0.8, on_import: true, send_image: false };
|
||||
|
||||
const pick = (settings: AiCategorizationSettings): Draft => ({
|
||||
mode: settings.mode,
|
||||
threshold: settings.threshold,
|
||||
on_import: settings.on_import,
|
||||
send_image: settings.send_image,
|
||||
});
|
||||
|
||||
export default function AiCategorizationSection({ onUnauthorized }: Props) {
|
||||
const { t } = useTranslation("app");
|
||||
const [draft, setDraft] = useState<Draft>(defaults);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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"));
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else setError(err instanceof Error ? err.message : t("adminSettings.aiCategorization.failed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
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}>
|
||||
<Alert severity="info">{t("adminSettings.aiCategorization.privacy")}</Alert>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="ai-mode-label">{t("adminSettings.aiCategorization.mode")}</InputLabel>
|
||||
<Select
|
||||
labelId="ai-mode-label"
|
||||
label={t("adminSettings.aiCategorization.mode")}
|
||||
value={draft.mode}
|
||||
onChange={(event) => update("mode", event.target.value as Draft["mode"])}
|
||||
disabled={loading || 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}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={draft.on_import}
|
||||
onChange={(event) => update("on_import", event.target.checked)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
}
|
||||
label={t("adminSettings.aiCategorization.onImport")}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={draft.send_image}
|
||||
onChange={(event) => update("send_image", event.target.checked)}
|
||||
disabled={loading || 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { useEffect, 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 InputAdornment from "@mui/material/InputAdornment";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import { settingsApi, type AiCategorizationSettings } from "../../api/settings";
|
||||
import SectionHeader from "../../components/SectionHeader";
|
||||
|
||||
type Props = { onUnauthorized?: () => void };
|
||||
type Draft = Pick<AiCategorizationSettings, "concurrency" | "timeout_ms"> & {
|
||||
base_url: string;
|
||||
model: string;
|
||||
api_key: string;
|
||||
};
|
||||
const defaults: Draft = { base_url: "", model: "", api_key: "", concurrency: 1, timeout_ms: 60000 };
|
||||
|
||||
const pick = (settings: AiCategorizationSettings): Draft => ({
|
||||
base_url: settings.base_url ?? "",
|
||||
model: settings.model ?? "",
|
||||
api_key: "",
|
||||
concurrency: settings.concurrency,
|
||||
timeout_ms: settings.timeout_ms,
|
||||
});
|
||||
|
||||
export default function AiConnectionSection({ onUnauthorized }: Props) {
|
||||
const { t } = useTranslation("app");
|
||||
const [draft, setDraft] = useState<Draft>(defaults);
|
||||
const [hasApiKey, setHasApiKey] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [clearKey, setClearKey] = 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));
|
||||
setHasApiKey(settings.has_api_key);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else if (active) setError(err instanceof Error ? err.message : t("adminSettings.ai.connection.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({
|
||||
base_url: draft.base_url.trim() || null,
|
||||
model: draft.model.trim() || null,
|
||||
concurrency: Number(draft.concurrency),
|
||||
timeout_ms: Number(draft.timeout_ms),
|
||||
...(clearKey ? { api_key: null } : draft.api_key ? { api_key: draft.api_key } : {}),
|
||||
});
|
||||
setDraft(pick(saved));
|
||||
setHasApiKey(saved.has_api_key);
|
||||
setClearKey(false);
|
||||
setStatus(t("adminSettings.ai.connection.saved"));
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else setError(err instanceof Error ? err.message : t("adminSettings.ai.connection.failed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const testConnection = async () => {
|
||||
setTesting(true);
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await settingsApi.testAiCategorization({
|
||||
base_url: draft.base_url.trim(),
|
||||
model: draft.model.trim(),
|
||||
timeout_ms: Number(draft.timeout_ms),
|
||||
// A blank key field tests with the saved key, unless it is about to be cleared.
|
||||
...(clearKey ? { api_key: null } : draft.api_key ? { api_key: draft.api_key } : {}),
|
||||
});
|
||||
if (result.ok)
|
||||
setStatus(t("adminSettings.ai.connection.testSuccess", { model: result.model, latency: result.latency_ms }));
|
||||
else setError(result.error || t("adminSettings.ai.connection.testFailed"));
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else setError(err instanceof Error ? err.message : t("adminSettings.ai.connection.testFailed"));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const canTest = Boolean(draft.base_url.trim() && draft.model.trim());
|
||||
const showSavedKey = hasApiKey && !clearKey && !draft.api_key;
|
||||
// Saving tests the connection too, so either request locks the whole form.
|
||||
const busy = loading || saving || testing;
|
||||
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<SectionHeader
|
||||
title={t("adminSettings.ai.connection.heading")}
|
||||
subtitle={t("adminSettings.ai.connection.subtitle")}
|
||||
/>
|
||||
<Paper variant="outlined" sx={{ p: 2.5 }}>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label={t("adminSettings.ai.connection.baseUrl")}
|
||||
value={draft.base_url}
|
||||
onChange={(event) => update("base_url", event.target.value)}
|
||||
disabled={busy}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label={t("adminSettings.ai.connection.model")}
|
||||
value={draft.model}
|
||||
onChange={(event) => update("model", event.target.value)}
|
||||
disabled={busy}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
type="password"
|
||||
label={t("adminSettings.ai.connection.apiKey")}
|
||||
value={draft.api_key}
|
||||
onChange={(event) => {
|
||||
update("api_key", event.target.value);
|
||||
setClearKey(false);
|
||||
}}
|
||||
helperText={
|
||||
clearKey
|
||||
? t("adminSettings.ai.connection.keyWillClear")
|
||||
: hasApiKey
|
||||
? t("adminSettings.ai.connection.keySet")
|
||||
: undefined
|
||||
}
|
||||
disabled={busy}
|
||||
autoComplete="new-password"
|
||||
fullWidth
|
||||
placeholder={showSavedKey ? "••••••••••••••••" : undefined}
|
||||
slotProps={{
|
||||
inputLabel: showSavedKey ? { shrink: true } : undefined,
|
||||
input: {
|
||||
endAdornment:
|
||||
hasApiKey && !clearKey ? (
|
||||
<InputAdornment position="end">
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => {
|
||||
setClearKey(true);
|
||||
update("api_key", "");
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("adminSettings.ai.connection.clearKey")}
|
||||
</Button>
|
||||
</InputAdornment>
|
||||
) : undefined,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
type="number"
|
||||
label={t("adminSettings.ai.connection.concurrency")}
|
||||
value={draft.concurrency}
|
||||
inputProps={{ min: 1, max: 4, step: 1 }}
|
||||
onChange={(event) => update("concurrency", Number(event.target.value))}
|
||||
disabled={busy}
|
||||
/>
|
||||
<TextField
|
||||
type="number"
|
||||
label={t("adminSettings.ai.connection.timeout")}
|
||||
value={draft.timeout_ms}
|
||||
inputProps={{ min: 1000, step: 1000 }}
|
||||
onChange={(event) => update("timeout_ms", Number(event.target.value))}
|
||||
disabled={busy}
|
||||
/>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => void save()}
|
||||
disabled={busy}
|
||||
startIcon={saving ? <CircularProgress size={14} color="inherit" /> : undefined}
|
||||
>
|
||||
{saving ? t("adminSettings.ai.connection.saving") : t("adminSettings.ai.connection.save")}
|
||||
</Button>
|
||||
{canTest && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => void testConnection()}
|
||||
disabled={busy}
|
||||
startIcon={testing ? <CircularProgress size={14} color="inherit" /> : undefined}
|
||||
>
|
||||
{testing ? t("adminSettings.ai.connection.testing") : t("adminSettings.ai.connection.test")}
|
||||
</Button>
|
||||
)}
|
||||
{loading && <CircularProgress size={14} />}
|
||||
</Stack>
|
||||
{status && <Alert severity="success">{status}</Alert>}
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t("adminSettings.ai.connection.writeOnly")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import Divider from "@mui/material/Divider";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import AiConnectionSection from "./AiConnectionSection";
|
||||
import AiCategorizationSection from "./AiCategorizationSection";
|
||||
|
||||
type Props = {
|
||||
onUnauthorized?: () => void;
|
||||
};
|
||||
|
||||
export default function AiPage({ onUnauthorized }: Props) {
|
||||
return (
|
||||
<Stack spacing={4} divider={<Divider />}>
|
||||
<AiConnectionSection onUnauthorized={onUnauthorized} />
|
||||
<AiCategorizationSection onUnauthorized={onUnauthorized} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
@@ -17,10 +17,13 @@ import PlaylistAddIcon from "@mui/icons-material/PlaylistAdd";
|
||||
import PlaylistRemoveIcon from "@mui/icons-material/PlaylistRemove";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
|
||||
import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
|
||||
import LaunchIcon from "@mui/icons-material/Launch";
|
||||
import type { SxProps, Theme } from "@mui/material/styles";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import { type Print, printsApi } from "../../api/prints";
|
||||
import { type Print, PrintApiError, printsApi } from "../../api/prints";
|
||||
import { aiCategorizationApi } from "../../api/aiCategorization";
|
||||
import { useToast } from "../../components/ToastProvider";
|
||||
import type { AuthUser } from "../../api/auth";
|
||||
import { collectionsApi } from "../../api/collections";
|
||||
import { useConfirm } from "../../components/ConfirmProvider";
|
||||
@@ -63,6 +66,23 @@ export default function ModelActionsMenu({
|
||||
}: Props) {
|
||||
const { t } = useTranslation(["models", "common"]);
|
||||
const confirmDialog = useConfirm();
|
||||
const showToast = useToast();
|
||||
const [aiEnabled, setAiEnabled] = useState(false);
|
||||
const [recategorizing, setRecategorizing] = useState(false);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void aiCategorizationApi
|
||||
.status()
|
||||
.then((status) => {
|
||||
if (active) setAiEnabled(status.enabled);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [onUnauthorized]);
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
@@ -151,6 +171,34 @@ export default function ModelActionsMenu({
|
||||
}
|
||||
};
|
||||
|
||||
const recategorize = async (force = false) => {
|
||||
closeMenu();
|
||||
setRecategorizing(true);
|
||||
try {
|
||||
const result = await printsApi.recategorize(print.id, force);
|
||||
onUpdated?.(result.print);
|
||||
showToast({ message: t(`models:aiCategorization.outcome.${result.outcome}`) });
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else if (err instanceof PrintApiError && err.code === "manual_category" && !force) {
|
||||
const confirmed = await confirmDialog({
|
||||
message: t("models:aiCategorization.confirmManual", { name: print.title || print.name }),
|
||||
confirmLabel: t("models:aiCategorization.recategorize"),
|
||||
});
|
||||
if (confirmed) await recategorize(true);
|
||||
} else if (err instanceof PrintApiError && err.code === "folder") {
|
||||
showToast({ message: t("models:aiCategorization.folderSkipped"), severity: "warning" });
|
||||
} else {
|
||||
showToast({
|
||||
message: err instanceof Error ? err.message : t("models:aiCategorization.failed"),
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setRecategorizing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const providerInfo = importProviderInfo(print.source_provider);
|
||||
const { slicerOption, targets: slicerTargets, normalizedTargets } = useOpenInSlicer(print);
|
||||
const openInSlicerHref = slicerTargets.length === 1 ? slicerTargets[0].href : undefined;
|
||||
@@ -215,6 +263,14 @@ export default function ModelActionsMenu({
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("common:edit")}</ListItemText>
|
||||
</MenuItem>
|
||||
{aiEnabled && (
|
||||
<MenuItem onClick={() => void recategorize()} disabled={recategorizing}>
|
||||
<ListItemIcon>
|
||||
{recategorizing ? <CircularProgress size={18} /> : <AutoAwesomeIcon fontSize="small" />}
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("models:aiCategorization.recategorize")}</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={handleDelete}>
|
||||
<ListItemIcon>
|
||||
<DeleteIcon fontSize="small" color="error" />
|
||||
|
||||
@@ -7,6 +7,7 @@ import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import Button from "@mui/material/Button";
|
||||
import Chip from "@mui/material/Chip";
|
||||
import ButtonBase from "@mui/material/ButtonBase";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
import StorageIcon from "@mui/icons-material/Storage";
|
||||
@@ -16,6 +17,9 @@ import DownloadIcon from "@mui/icons-material/Download";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import PrintIcon from "@mui/icons-material/Print";
|
||||
import type { Print } from "../../api/prints";
|
||||
import { aiCategorizationApi } from "../../api/aiCategorization";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import { useToast } from "../../components/ToastProvider";
|
||||
import type { AuthUser } from "../../api/auth";
|
||||
import { useGravatarUrl } from "../../hooks/useGravatarUrl";
|
||||
import { dividerBorderColor } from "../../theme";
|
||||
@@ -43,6 +47,8 @@ type Props = {
|
||||
export default function ModelSidePanel({ print, onSelectCategory, onUnauthorized, onUpdated, viewer }: Props) {
|
||||
const { t } = useTranslation(["models", "common"]);
|
||||
const navigate = useNavigate();
|
||||
const showToast = useToast();
|
||||
const [suggestionBusy, setSuggestionBusy] = useState(false);
|
||||
const viewerAvatarUrl = useGravatarUrl(viewer?.email, 56);
|
||||
const {
|
||||
pickerOpen,
|
||||
@@ -161,10 +167,79 @@ export default function ModelSidePanel({ print, onSelectCategory, onUnauthorized
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{print.category_name}
|
||||
</Typography>
|
||||
{print.category_source === "ai" && (
|
||||
<Chip size="small" label={t("models:aiCategorization.byAi")} color="secondary" variant="outlined" />
|
||||
)}
|
||||
</ButtonBase>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{print.ai_suggestion && (
|
||||
<Paper variant="outlined" sx={{ p: 1.5, bgcolor: "action.hover" }}>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t("models:aiCategorization.suggestionLabel")}
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{print.ai_suggestion.category_path} · {Math.round(print.ai_suggestion.confidence * 100)}%
|
||||
</Typography>
|
||||
{print.ai_suggestion.reason && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{print.ai_suggestion.reason}
|
||||
</Typography>
|
||||
)}
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
disabled={suggestionBusy}
|
||||
onClick={() =>
|
||||
void (async () => {
|
||||
setSuggestionBusy(true);
|
||||
try {
|
||||
const result = await aiCategorizationApi.accept(print.id);
|
||||
onUpdated?.(result.print);
|
||||
showToast({ message: t("models:aiCategorization.accepted") });
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else
|
||||
showToast({ message: err instanceof Error ? err.message : String(err), severity: "error" });
|
||||
} finally {
|
||||
setSuggestionBusy(false);
|
||||
}
|
||||
})()
|
||||
}
|
||||
>
|
||||
{t("models:aiCategorization.accept")}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color="inherit"
|
||||
disabled={suggestionBusy}
|
||||
onClick={() =>
|
||||
void (async () => {
|
||||
setSuggestionBusy(true);
|
||||
try {
|
||||
const result = await aiCategorizationApi.reject(print.id);
|
||||
onUpdated?.(result.print);
|
||||
showToast({ message: t("models:aiCategorization.rejected") });
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else
|
||||
showToast({ message: err instanceof Error ? err.message : String(err), severity: "error" });
|
||||
} finally {
|
||||
setSuggestionBusy(false);
|
||||
}
|
||||
})()
|
||||
}
|
||||
>
|
||||
{t("models:aiCategorization.reject")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{typeof print.total_size === "number" && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 0.5 }}>
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
|
||||
import CheckIcon from "@mui/icons-material/Check";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Checkbox from "@mui/material/Checkbox";
|
||||
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 Divider from "@mui/material/Divider";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import LinearProgress from "@mui/material/LinearProgress";
|
||||
import List from "@mui/material/List";
|
||||
import ListItem from "@mui/material/ListItem";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import { aiCategorizationApi, type AiRun, type AiRunCounts, type AiRunScope } from "../../api/aiCategorization";
|
||||
import type { Print } from "../../api/prints";
|
||||
import { useToast } from "../../components/ToastProvider";
|
||||
|
||||
type Props = {
|
||||
onUnauthorized?: () => void;
|
||||
onPrintUpdated?: (print: Print) => void;
|
||||
/** Many models may have changed category: after a run, or a bulk accept or reject. */
|
||||
onLibraryChanged?: () => void;
|
||||
};
|
||||
const initialCounts: AiRunCounts = { uncategorized: 0, ai: 0, rule: 0, legacy: 0, manual: 0, folder: 0 };
|
||||
const initialScope: AiRunScope = { include_ai: false, include_rule: false, include_legacy: false };
|
||||
|
||||
/** The AI section of the models manager: a library run above the pending suggestions. */
|
||||
export default function AiCategorizationPanel({ onUnauthorized, onPrintUpdated, onLibraryChanged }: Props) {
|
||||
const { t } = useTranslation(["models", "common"]);
|
||||
const showToast = useToast();
|
||||
const [runOpen, setRunOpen] = useState(false);
|
||||
const [counts, setCounts] = useState(initialCounts);
|
||||
const [run, setRun] = useState<AiRun | null>(null);
|
||||
const [scope, setScope] = useState(initialScope);
|
||||
const [items, setItems] = useState<Print[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [suggestionsLoading, setSuggestionsLoading] = useState(true);
|
||||
|
||||
const loadRun = async () => {
|
||||
const state = await aiCategorizationApi.runState();
|
||||
setCounts(state.counts);
|
||||
setRun(state.run);
|
||||
};
|
||||
const loadSuggestions = async (offset = 0, append = false) => {
|
||||
const result = await aiCategorizationApi.suggestions(50, offset);
|
||||
setItems((current) => (append ? [...current, ...result.items] : result.items));
|
||||
setTotal(result.total);
|
||||
if (!append) setSelected([]);
|
||||
};
|
||||
|
||||
// Reloaded when the run dialog closes, since a run adds suggestions.
|
||||
useEffect(() => {
|
||||
if (runOpen) return;
|
||||
let active = true;
|
||||
setSuggestionsLoading(true);
|
||||
void aiCategorizationApi
|
||||
.suggestions(50, 0)
|
||||
.then((result) => {
|
||||
if (!active) return;
|
||||
setItems(result.items);
|
||||
setTotal(result.total);
|
||||
setSelected([]);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else if (active) showToast({ message: err instanceof Error ? err.message : String(err), severity: "error" });
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setSuggestionsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [runOpen, onUnauthorized, showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runOpen) return;
|
||||
let active = true;
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const state = await aiCategorizationApi.runState();
|
||||
if (active) {
|
||||
setCounts(state.counts);
|
||||
setRun(state.run);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
}
|
||||
};
|
||||
void refresh();
|
||||
const timer = window.setInterval(() => void refresh(), 2000);
|
||||
return () => {
|
||||
active = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [runOpen, onUnauthorized]);
|
||||
|
||||
// A run that finishes while the dialog is open has changed the library behind it.
|
||||
const wasRunning = useRef(false);
|
||||
useEffect(() => {
|
||||
if (wasRunning.current && !run?.running) onLibraryChanged?.();
|
||||
wasRunning.current = Boolean(run?.running);
|
||||
}, [run?.running, onLibraryChanged]);
|
||||
|
||||
// Closed mid-run, it shows what the run has done so far.
|
||||
const closeRun = () => {
|
||||
if (run?.running) onLibraryChanged?.();
|
||||
setRunOpen(false);
|
||||
};
|
||||
|
||||
const openRun = async () => {
|
||||
setRunOpen(true);
|
||||
setBusy(true);
|
||||
try {
|
||||
await loadRun();
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else showToast({ message: err instanceof Error ? err.message : String(err), severity: "error" });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await aiCategorizationApi.startRun(scope);
|
||||
setRun(result.run);
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else showToast({ message: err instanceof Error ? err.message : String(err), severity: "error" });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const cancel = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await aiCategorizationApi.cancelRun();
|
||||
setRun(result.run);
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else showToast({ message: err instanceof Error ? err.message : String(err), severity: "error" });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const actOnItems = async (action: "accept" | "reject", ids: string[]) => {
|
||||
if (!ids.length) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
if (ids.length === 1) {
|
||||
const result =
|
||||
action === "accept" ? await aiCategorizationApi.accept(ids[0]) : await aiCategorizationApi.reject(ids[0]);
|
||||
onPrintUpdated?.(result.print);
|
||||
} else {
|
||||
await aiCategorizationApi.bulk(action, ids);
|
||||
onLibraryChanged?.();
|
||||
}
|
||||
await loadSuggestions();
|
||||
showToast({ message: t(`models:aiCategorization.${action}Done`, { count: ids.length }) });
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else showToast({ message: err instanceof Error ? err.message : String(err), severity: "error" });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleScope = (key: keyof AiRunScope) => setScope((current) => ({ ...current, [key]: !current[key] }));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack spacing={1} alignItems="flex-start" sx={{ mb: 2.5 }}>
|
||||
<Button size="small" variant="outlined" startIcon={<AutoAwesomeIcon />} onClick={() => void openRun()}>
|
||||
{t("models:aiCategorization.runAll")}
|
||||
</Button>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t("models:aiCategorization.runHint")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Dialog open={runOpen} onClose={closeRun} fullWidth maxWidth="sm">
|
||||
<DialogTitle>{t("models:aiCategorization.runTitle")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={1.5} sx={{ pt: 1 }}>
|
||||
<Typography variant="body2">
|
||||
{t("models:aiCategorization.countUncategorized", { count: counts.uncategorized })}
|
||||
</Typography>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={scope.include_ai} onChange={() => toggleScope("include_ai")} />}
|
||||
label={t("models:aiCategorization.includeAi", { count: counts.ai })}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={scope.include_rule} onChange={() => toggleScope("include_rule")} />}
|
||||
label={t("models:aiCategorization.includeRule", { count: counts.rule })}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={scope.include_legacy} onChange={() => toggleScope("include_legacy")} />}
|
||||
label={t("models:aiCategorization.includeLegacy", { count: counts.legacy })}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t("models:aiCategorization.skipped", { manual: counts.manual, folder: counts.folder })}
|
||||
</Typography>
|
||||
{run?.running && (
|
||||
<>
|
||||
<Typography variant="body2">
|
||||
{t("models:aiCategorization.progress", {
|
||||
done: run.done,
|
||||
total: run.total,
|
||||
applied: run.applied,
|
||||
suggested: run.suggested,
|
||||
failed: run.failed,
|
||||
})}
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant={run.total ? "determinate" : "indeterminate"}
|
||||
value={run.total ? (run.done / run.total) * 100 : 0}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{run && !run.running && run.finished_at && (
|
||||
<Typography variant="body2">
|
||||
{t("models:aiCategorization.finished", {
|
||||
applied: run.applied,
|
||||
suggested: run.suggested,
|
||||
failed: run.failed,
|
||||
})}
|
||||
</Typography>
|
||||
)}
|
||||
{run?.last_error && (
|
||||
<Typography variant="caption" color="error">
|
||||
{run.last_error}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={closeRun}>{t("common:close")}</Button>
|
||||
{run?.running ? (
|
||||
<Button color="error" onClick={() => void cancel()} disabled={busy}>
|
||||
{t("models:aiCategorization.cancel")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => void start()}
|
||||
disabled={
|
||||
busy ||
|
||||
counts.uncategorized +
|
||||
(scope.include_ai ? counts.ai : 0) +
|
||||
(scope.include_rule ? counts.rule : 0) +
|
||||
(scope.include_legacy ? counts.legacy : 0) ===
|
||||
0
|
||||
}
|
||||
>
|
||||
{busy ? <CircularProgress size={18} /> : t("models:aiCategorization.start")}
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<Typography variant="body2" fontWeight={600} sx={{ mb: 1 }}>
|
||||
{t("models:aiCategorization.suggestionsTitle", { count: total })}
|
||||
</Typography>
|
||||
<Box>
|
||||
{suggestionsLoading && !items.length ? (
|
||||
<CircularProgress size={22} />
|
||||
) : items.length ? (
|
||||
<>
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setSelected(selected.length === items.length ? [] : items.map((item) => item.id))}
|
||||
>
|
||||
{selected.length === items.length
|
||||
? t("models:aiCategorization.deselectAll")
|
||||
: t("models:aiCategorization.selectAll")}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<CheckIcon />}
|
||||
onClick={() => void actOnItems("accept", selected)}
|
||||
disabled={!selected.length || busy}
|
||||
>
|
||||
{t("models:aiCategorization.acceptSelected")}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<CloseIcon />}
|
||||
onClick={() => void actOnItems("reject", selected)}
|
||||
disabled={!selected.length || busy}
|
||||
>
|
||||
{t("models:aiCategorization.rejectSelected")}
|
||||
</Button>
|
||||
</Stack>
|
||||
<List dense>
|
||||
{items.map((item) => (
|
||||
// The actions sit in the row rather than in secondaryAction: that one is absolutely
|
||||
// positioned over a fixed padding, and two buttons overlap a long reason.
|
||||
<ListItem
|
||||
key={item.id}
|
||||
divider
|
||||
alignItems="flex-start"
|
||||
sx={{ gap: 1, flexWrap: { xs: "wrap", sm: "nowrap" } }}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected.includes(item.id)}
|
||||
onChange={() =>
|
||||
setSelected((ids) =>
|
||||
ids.includes(item.id) ? ids.filter((id) => id !== item.id) : [...ids, item.id],
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ListItemText
|
||||
sx={{ flex: "1 1 0", minWidth: 0 }}
|
||||
primary={item.title || item.name}
|
||||
secondary={
|
||||
item.ai_suggestion && (
|
||||
<>
|
||||
<Typography component="span" variant="body2" fontWeight={600} sx={{ display: "block" }}>
|
||||
{`${item.ai_suggestion.category_path} · ${Math.round(item.ai_suggestion.confidence * 100)}%`}
|
||||
</Typography>
|
||||
{item.ai_suggestion.reason}
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={0.5}
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
pt: 0.5,
|
||||
// On a phone the actions take their own line instead of squeezing the reason.
|
||||
flexBasis: { xs: "100%", sm: "auto" },
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
<Button size="small" onClick={() => void actOnItems("accept", [item.id])} disabled={busy}>
|
||||
{t("models:aiCategorization.accept")}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => void actOnItems("reject", [item.id])}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("models:aiCategorization.reject")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
{total > items.length && (
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t("models:aiCategorization.firstPage", { shown: items.length, total })}
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void (async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await loadSuggestions(items.length, true);
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else
|
||||
showToast({ message: err instanceof Error ? err.message : String(err), severity: "error" });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
})()
|
||||
}
|
||||
>
|
||||
{t("models:aiCategorization.loadMore")}
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("models:aiCategorization.noSuggestions")}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Theme } from "@mui/material/styles";
|
||||
import Paper from "@mui/material/Paper";
|
||||
@@ -13,11 +13,10 @@ import Collapse from "@mui/material/Collapse";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
import type { Category, CategoryKind, CategoryMetaInput } from "../../api/categories";
|
||||
import type { Category, CategoryKind } from "../../api/categories";
|
||||
import { translateCategoryDisplay } from "../../utils/translateCategoryDisplay";
|
||||
import { dividerBorderColor } from "../../theme";
|
||||
import { ancestorPath, buildCategoryTree } from "../../utils/categoryTree";
|
||||
import CategoryManagerModal from "./CategoryManagerModal";
|
||||
|
||||
type Props = {
|
||||
/** Which box this is; `categories` holds only that kind. */
|
||||
@@ -26,12 +25,8 @@ type Props = {
|
||||
loading: boolean;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
onCreate: (name: string, parentId: string | null) => Promise<void>;
|
||||
onRename: (id: string, name: string) => Promise<void>;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
onReorder: (categoryIds: string[]) => Promise<void>;
|
||||
onMove: (id: string, parentId: string | null, position: number) => Promise<void>;
|
||||
onUpdateMeta: (id: string, meta: CategoryMetaInput) => Promise<void>;
|
||||
/** The cog: opens the models manager on this kind. */
|
||||
onManage: () => void;
|
||||
};
|
||||
|
||||
function rowSx(active: boolean) {
|
||||
@@ -62,21 +57,8 @@ function rowTextSx(active: boolean, extra?: object) {
|
||||
}
|
||||
|
||||
/** Any depth; a category selects the models at every level beneath it. Only the selected path is open. */
|
||||
export default function CategoriesPanel({
|
||||
kind,
|
||||
categories,
|
||||
loading,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onRename,
|
||||
onDelete,
|
||||
onReorder,
|
||||
onMove,
|
||||
onUpdateMeta,
|
||||
}: Props) {
|
||||
export default function CategoriesPanel({ kind, categories, loading, selectedId, onSelect, onManage }: Props) {
|
||||
const { t, i18n } = useTranslation(["models", "common"]);
|
||||
const [managerOpen, setManagerOpen] = useState(false);
|
||||
const displayName = (category: Category) => translateCategoryDisplay(category, i18n).name;
|
||||
|
||||
const untitledLabel = t("models:categories.untitled");
|
||||
@@ -132,71 +114,51 @@ export default function CategoriesPanel({
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
width: 260,
|
||||
flexShrink: 0,
|
||||
borderRadius: "12px",
|
||||
p: 1.5,
|
||||
alignSelf: "flex-start",
|
||||
bgcolor: "background.paper",
|
||||
borderColor: dividerBorderColor,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 0.5, pb: 1 }}>
|
||||
<Typography variant="subtitle1" fontWeight={700}>
|
||||
{t(`${labels}.title`)}
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
width: 260,
|
||||
flexShrink: 0,
|
||||
borderRadius: "12px",
|
||||
p: 1.5,
|
||||
alignSelf: "flex-start",
|
||||
bgcolor: "background.paper",
|
||||
borderColor: dividerBorderColor,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 0.5, pb: 1 }}>
|
||||
<Typography variant="subtitle1" fontWeight={700}>
|
||||
{t(`${labels}.title`)}
|
||||
</Typography>
|
||||
<Tooltip title={t(`${labels}.manageTooltip`) ?? ""}>
|
||||
<IconButton size="small" onClick={onManage} aria-label={t(`${labels}.manageTooltip`) ?? undefined}>
|
||||
<SettingsIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
|
||||
<List disablePadding>
|
||||
<ListItemButton onClick={() => onSelect(null)} sx={rowSx(selectedId === null)}>
|
||||
<ListItemText
|
||||
primary={t("models:categories.all")}
|
||||
primaryTypographyProps={rowTextSx(selectedId === null, { fontWeight: 600 })}
|
||||
/>
|
||||
</ListItemButton>
|
||||
|
||||
{loading && (
|
||||
<Stack alignItems="center" sx={{ py: 2 }}>
|
||||
<CircularProgress size={18} />
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{!loading && tree.roots.map((root) => renderCategory(root, 0))}
|
||||
|
||||
{!loading && !tree.roots.length && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ px: 1, py: 1 }}>
|
||||
{t(`${labels}.empty`)}
|
||||
</Typography>
|
||||
<Tooltip title={t(`${labels}.manageTooltip`) ?? ""}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setManagerOpen(true)}
|
||||
aria-label={t(`${labels}.manageTooltip`) ?? undefined}
|
||||
>
|
||||
<SettingsIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
|
||||
<List disablePadding>
|
||||
<ListItemButton onClick={() => onSelect(null)} sx={rowSx(selectedId === null)}>
|
||||
<ListItemText
|
||||
primary={t("models:categories.all")}
|
||||
primaryTypographyProps={rowTextSx(selectedId === null, { fontWeight: 600 })}
|
||||
/>
|
||||
</ListItemButton>
|
||||
|
||||
{loading && (
|
||||
<Stack alignItems="center" sx={{ py: 2 }}>
|
||||
<CircularProgress size={18} />
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{!loading && tree.roots.map((root) => renderCategory(root, 0))}
|
||||
|
||||
{!loading && !tree.roots.length && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ px: 1, py: 1 }}>
|
||||
{t(`${labels}.empty`)}
|
||||
</Typography>
|
||||
)}
|
||||
</List>
|
||||
</Paper>
|
||||
|
||||
{managerOpen && (
|
||||
<CategoryManagerModal
|
||||
kind={kind}
|
||||
categories={categories}
|
||||
onClose={() => setManagerOpen(false)}
|
||||
onCreate={onCreate}
|
||||
onRename={onRename}
|
||||
onDelete={onDelete}
|
||||
onReorder={onReorder}
|
||||
onMove={onMove}
|
||||
onUpdateMeta={onUpdateMeta}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</List>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
+95
-114
@@ -1,9 +1,5 @@
|
||||
import { Fragment, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
@@ -60,7 +56,6 @@ type Props = {
|
||||
/** Only the trees of this kind; it also picks the labels. */
|
||||
kind: CategoryKind;
|
||||
categories: Category[];
|
||||
onClose: () => void;
|
||||
onCreate: (name: string, parentId: string | null) => Promise<void>;
|
||||
onRename: (id: string, name: string) => Promise<void>;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
@@ -471,10 +466,10 @@ function AddRow({
|
||||
);
|
||||
}
|
||||
|
||||
export default function CategoryManagerModal({
|
||||
/** Add, rename, reorder and nest one kind's trees. Rendered inside the models manager dialog. */
|
||||
export default function CategoryManager({
|
||||
kind,
|
||||
categories,
|
||||
onClose,
|
||||
onCreate,
|
||||
onRename,
|
||||
onDelete,
|
||||
@@ -617,118 +612,104 @@ export default function CategoryManagerModal({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open onClose={onClose} fullWidth maxWidth="sm">
|
||||
<DialogTitle sx={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
{t(`${labels}.title`)}
|
||||
<IconButton size="small" onClick={onClose} aria-label={t("common:close") ?? undefined}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
// Folding the dragged subtree shifts the rows below, so keep re-measuring.
|
||||
measuring={{ droppable: { strategy: MeasuringStrategy.Always } }}
|
||||
onDragStart={handleDragStart}
|
||||
onDragMove={handleDragMove}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={resetDrag}
|
||||
>
|
||||
<SortableContext items={items.map((i) => i.id)} strategy={verticalListSortingStrategy}>
|
||||
<List disablePadding>
|
||||
{flat.map(({ category, depth }) => {
|
||||
const rootIndex = depth === 0 ? tree.roots.findIndex((r) => r.id === category.id) : -1;
|
||||
return (
|
||||
<Fragment key={category.id}>
|
||||
{rootIndex > 0 && <Box sx={{ height: 12 }} />}
|
||||
<TreeRow
|
||||
id={category.id}
|
||||
depth={category.id === activeId && projection ? projection.depth : depth}
|
||||
name={category.name || untitledLabel}
|
||||
busy={busyId === category.id}
|
||||
move={
|
||||
depth === 0
|
||||
? {
|
||||
canMoveUp: rootIndex > 0,
|
||||
canMoveDown: rootIndex < tree.roots.length - 1,
|
||||
onMoveUp: () => moveRoot(rootIndex, -1),
|
||||
onMoveDown: () => moveRoot(rootIndex, 1),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
metaTitle={category.meta_title}
|
||||
metaDescription={category.meta_description}
|
||||
hasDetails={hasMeta(category)}
|
||||
onOpenMeta={() => setMetaCategory(category)}
|
||||
onAddChild={() => setAddingUnderId(category.id)}
|
||||
addChildLabel={t(`${labels}.addSubcategory`)}
|
||||
onRename={(name) => onRename(category.id, name)}
|
||||
onDelete={() => handleDelete(category)}
|
||||
/>
|
||||
{addAfterId === category.id && addingUnderId && (
|
||||
<InlineAddRow
|
||||
depth={addingDepth}
|
||||
busy={busyId === `new-sub-${addingUnderId}`}
|
||||
placeholder={t(`${labels}.addSubcategory`)}
|
||||
onCancel={() => setAddingUnderId(null)}
|
||||
onAdd={async (name) => {
|
||||
setBusyId(`new-sub-${addingUnderId}`);
|
||||
try {
|
||||
await onCreate(name, addingUnderId);
|
||||
setAddingUnderId(null);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
<Box>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
// Folding the dragged subtree shifts the rows below, so keep re-measuring.
|
||||
measuring={{ droppable: { strategy: MeasuringStrategy.Always } }}
|
||||
onDragStart={handleDragStart}
|
||||
onDragMove={handleDragMove}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={resetDrag}
|
||||
>
|
||||
<SortableContext items={items.map((i) => i.id)} strategy={verticalListSortingStrategy}>
|
||||
<List disablePadding>
|
||||
{flat.map(({ category, depth }) => {
|
||||
const rootIndex = depth === 0 ? tree.roots.findIndex((r) => r.id === category.id) : -1;
|
||||
return (
|
||||
<Fragment key={category.id}>
|
||||
{rootIndex > 0 && <Box sx={{ height: 12 }} />}
|
||||
<TreeRow
|
||||
id={category.id}
|
||||
depth={category.id === activeId && projection ? projection.depth : depth}
|
||||
name={category.name || untitledLabel}
|
||||
busy={busyId === category.id}
|
||||
move={
|
||||
depth === 0
|
||||
? {
|
||||
canMoveUp: rootIndex > 0,
|
||||
canMoveDown: rootIndex < tree.roots.length - 1,
|
||||
onMoveUp: () => moveRoot(rootIndex, -1),
|
||||
onMoveDown: () => moveRoot(rootIndex, 1),
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
: undefined
|
||||
}
|
||||
metaTitle={category.meta_title}
|
||||
metaDescription={category.meta_description}
|
||||
hasDetails={hasMeta(category)}
|
||||
onOpenMeta={() => setMetaCategory(category)}
|
||||
onAddChild={() => setAddingUnderId(category.id)}
|
||||
addChildLabel={t(`${labels}.addSubcategory`)}
|
||||
onRename={(name) => onRename(category.id, name)}
|
||||
onDelete={() => handleDelete(category)}
|
||||
/>
|
||||
{addAfterId === category.id && addingUnderId && (
|
||||
<InlineAddRow
|
||||
depth={addingDepth}
|
||||
busy={busyId === `new-sub-${addingUnderId}`}
|
||||
placeholder={t(`${labels}.addSubcategory`)}
|
||||
onCancel={() => setAddingUnderId(null)}
|
||||
onAdd={async (name) => {
|
||||
setBusyId(`new-sub-${addingUnderId}`);
|
||||
try {
|
||||
await onCreate(name, addingUnderId);
|
||||
setAddingUnderId(null);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{!tree.roots.length && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
|
||||
{t(`${labels}.noCategories`)}
|
||||
</Typography>
|
||||
)}
|
||||
</List>
|
||||
</SortableContext>
|
||||
<DragOverlay>
|
||||
{activeCategory && (
|
||||
<Paper
|
||||
elevation={6}
|
||||
sx={{ display: "flex", alignItems: "center", px: 1, py: 0.75, cursor: "grabbing" }}
|
||||
>
|
||||
<DragIndicatorIcon fontSize="small" sx={{ mr: 0.75, color: "action.active" }} />
|
||||
<Typography variant="body2" noWrap>
|
||||
{activeCategory.name || untitledLabel}
|
||||
</Typography>
|
||||
</Paper>
|
||||
{!tree.roots.length && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
|
||||
{t(`${labels}.noCategories`)}
|
||||
</Typography>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</List>
|
||||
</SortableContext>
|
||||
<DragOverlay>
|
||||
{activeCategory && (
|
||||
<Paper elevation={6} sx={{ display: "flex", alignItems: "center", px: 1, py: 0.75, cursor: "grabbing" }}>
|
||||
<DragIndicatorIcon fontSize="small" sx={{ mr: 0.75, color: "action.active" }} />
|
||||
<Typography variant="body2" noWrap>
|
||||
{activeCategory.name || untitledLabel}
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
|
||||
<AddRow
|
||||
placeholder={t(`${labels}.addCategory`)}
|
||||
busy={busyId === "new-root"}
|
||||
onAdd={async (name) => {
|
||||
setBusyId("new-root");
|
||||
try {
|
||||
await onCreate(name, null);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>{t("common:close")}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<AddRow
|
||||
placeholder={t(`${labels}.addCategory`)}
|
||||
busy={busyId === "new-root"}
|
||||
onAdd={async (name) => {
|
||||
setBusyId("new-root");
|
||||
try {
|
||||
await onCreate(name, null);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{metaCategory && (
|
||||
<CategoryMetaDialog
|
||||
category={metaCategory}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AutoAwesomeOutlinedIcon from "@mui/icons-material/AutoAwesomeOutlined";
|
||||
import CategoryOutlinedIcon from "@mui/icons-material/CategoryOutlined";
|
||||
import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined";
|
||||
import type { Category, CategoryKind, CategoryMetaInput } from "../../api/categories";
|
||||
import type { Print } from "../../api/prints";
|
||||
import { aiCategorizationApi } from "../../api/aiCategorization";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import SectionedDialog from "../../components/SectionedDialog";
|
||||
import CategoryManager from "./CategoryManager";
|
||||
import AiCategorizationPanel from "./AiCategorizationPanel";
|
||||
|
||||
export type ModelsManagerSection = "categories" | "folders" | "ai";
|
||||
|
||||
type Props = {
|
||||
/** The section to open on, or null when closed. */
|
||||
open: ModelsManagerSection | null;
|
||||
onClose: () => void;
|
||||
/** Both kinds; each section shows its own. */
|
||||
categories: Category[];
|
||||
onCreate: (name: string, parentId: string | null, kind: CategoryKind) => Promise<void>;
|
||||
onRename: (id: string, name: string) => Promise<void>;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
onReorder: (categoryIds: string[]) => Promise<void>;
|
||||
onMove: (id: string, parentId: string | null, position: number) => Promise<void>;
|
||||
onUpdateMeta: (id: string, meta: CategoryMetaInput) => Promise<void>;
|
||||
onPrintUpdated?: (print: Print) => void;
|
||||
onLibraryChanged?: () => void;
|
||||
onUnauthorized?: () => void;
|
||||
};
|
||||
|
||||
/** Categories, folders and AI categorization in one place, opened from the side panel's cog. */
|
||||
export default function ModelsManagerDialog({
|
||||
open,
|
||||
onClose,
|
||||
categories,
|
||||
onCreate,
|
||||
onPrintUpdated,
|
||||
onLibraryChanged,
|
||||
onUnauthorized,
|
||||
...handlers
|
||||
}: Props) {
|
||||
const { t } = useTranslation(["models", "common"]);
|
||||
const [section, setSection] = useState<ModelsManagerSection>("categories");
|
||||
const [aiEnabled, setAiEnabled] = useState(false);
|
||||
|
||||
// Kept while closing, so the section doesn't switch during the fade.
|
||||
useEffect(() => {
|
||||
if (open) setSection(open);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let active = true;
|
||||
void aiCategorizationApi
|
||||
.status()
|
||||
.then((status) => {
|
||||
if (active) setAiEnabled(status.enabled);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [open, onUnauthorized]);
|
||||
|
||||
const byKind = useMemo(
|
||||
() => ({
|
||||
category: categories.filter((c) => c.kind === "category"),
|
||||
folder: categories.filter((c) => c.kind === "folder"),
|
||||
}),
|
||||
[categories],
|
||||
);
|
||||
|
||||
const sections = [
|
||||
{ id: "categories" as const, label: t("models:categories.title"), icon: <CategoryOutlinedIcon fontSize="small" /> },
|
||||
{ id: "folders" as const, label: t("models:folders.title"), icon: <FolderOutlinedIcon fontSize="small" /> },
|
||||
...(aiEnabled
|
||||
? [
|
||||
{
|
||||
id: "ai" as const,
|
||||
label: t("models:aiCategorization.section"),
|
||||
icon: <AutoAwesomeOutlinedIcon fontSize="small" />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const managerFor = (kind: CategoryKind) => (
|
||||
<CategoryManager
|
||||
key={kind}
|
||||
kind={kind}
|
||||
categories={byKind[kind]}
|
||||
onCreate={(name, parentId) => onCreate(name, parentId, kind)}
|
||||
{...handlers}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<SectionedDialog
|
||||
open={Boolean(open)}
|
||||
onClose={onClose}
|
||||
title={t("models:manager.title")}
|
||||
sections={sections}
|
||||
section={section}
|
||||
onSectionChange={setSection}
|
||||
size="large"
|
||||
>
|
||||
{section === "categories" && managerFor("category")}
|
||||
{section === "folders" && managerFor("folder")}
|
||||
{section === "ai" && (
|
||||
<AiCategorizationPanel
|
||||
onUnauthorized={onUnauthorized}
|
||||
onPrintUpdated={onPrintUpdated}
|
||||
onLibraryChanged={onLibraryChanged}
|
||||
/>
|
||||
)}
|
||||
</SectionedDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
@@ -7,7 +7,7 @@ import Typography from "@mui/material/Typography";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import { type Print, type PrintSortMode, printsApi } from "../../api/prints";
|
||||
import { type Category, type CategoryMetaInput, categoriesApi } from "../../api/categories";
|
||||
import { type Category, type CategoryKind, type CategoryMetaInput, categoriesApi } from "../../api/categories";
|
||||
import { type CategoriesView, type PreviewMode } from "../../api/settings";
|
||||
import type { AuthUser } from "../../api/auth";
|
||||
import { type ResolvedTheme } from "../../constants/settingsOptions";
|
||||
@@ -20,6 +20,7 @@ import CategoriesViewToggle from "./CategoriesViewToggle";
|
||||
import CategoryBanner from "./CategoryBanner";
|
||||
import ModelCard from "./ModelCard";
|
||||
import SortTabs from "./SortTabs";
|
||||
import ModelsManagerDialog, { type ModelsManagerSection } from "./ModelsManagerDialog";
|
||||
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
@@ -59,6 +60,10 @@ export default function ModelsPage({
|
||||
const [categoriesView, setCategoriesView] = useCategoriesView();
|
||||
const panelKind = categoriesView === "folders" ? "folder" : "category";
|
||||
const panelCategories = useMemo(() => categories.filter((c) => c.kind === panelKind), [categories, panelKind]);
|
||||
const [managerSection, setManagerSection] = useState<ModelsManagerSection | null>(null);
|
||||
// Bumped when AI categorization changes many models at once, to refetch the grid.
|
||||
const [libraryVersion, setLibraryVersion] = useState(0);
|
||||
const refreshLibrary = useCallback(() => setLibraryVersion((version) => version + 1), []);
|
||||
const sortModeParam = searchParams.get("orderBy");
|
||||
const sortMode: PrintSortMode =
|
||||
sortModeParam === "popular" || sortModeParam === "downloads" ? sortModeParam : "newest";
|
||||
@@ -161,7 +166,7 @@ export default function ModelsPage({
|
||||
}
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [categoryIdFilter, printsVersion, sortMode]);
|
||||
}, [categoryIdFilter, printsVersion, libraryVersion, sortMode]);
|
||||
|
||||
const loadMore = async () => {
|
||||
if (loadingMore || !hasMore) return;
|
||||
@@ -185,9 +190,9 @@ export default function ModelsPage({
|
||||
|
||||
const loadMoreSentinelRef = useInfiniteScroll(loadMore, hasMore, loading || loadingMore);
|
||||
|
||||
const createCategory = async (name: string, parentId: string | null) => {
|
||||
const createCategory = async (name: string, parentId: string | null, kind: CategoryKind) => {
|
||||
try {
|
||||
await categoriesApi.create(name, [], parentId || undefined, panelKind);
|
||||
await categoriesApi.create(name, [], parentId || undefined, kind);
|
||||
onCategoriesChanged();
|
||||
} catch (err) {
|
||||
handleError(err, t("models:errors.createCategoryFailed"));
|
||||
@@ -260,6 +265,20 @@ export default function ModelsPage({
|
||||
<CategoriesViewToggle value={categoriesView} onChange={switchCategoriesView} />
|
||||
<SortTabs value={sortMode} onChange={setSortMode} />
|
||||
</Box>
|
||||
<ModelsManagerDialog
|
||||
open={managerSection}
|
||||
onClose={() => setManagerSection(null)}
|
||||
categories={categories}
|
||||
onCreate={createCategory}
|
||||
onRename={renameCategory}
|
||||
onDelete={deleteCategory}
|
||||
onReorder={reorderCategories}
|
||||
onMove={moveCategory}
|
||||
onUpdateMeta={updateCategoryMeta}
|
||||
onPrintUpdated={(updated) => setItems((prev) => prev.map((item) => (item.id === updated.id ? updated : item)))}
|
||||
onLibraryChanged={refreshLibrary}
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
<Stack direction="row" spacing={2} alignItems="flex-start">
|
||||
<CategoriesPanel
|
||||
kind={panelKind}
|
||||
@@ -267,12 +286,7 @@ export default function ModelsPage({
|
||||
loading={categoriesLoading || categoriesView === null}
|
||||
selectedId={categoryId}
|
||||
onSelect={onSelectCategory}
|
||||
onCreate={createCategory}
|
||||
onRename={renameCategory}
|
||||
onDelete={deleteCategory}
|
||||
onReorder={reorderCategories}
|
||||
onMove={moveCategory}
|
||||
onUpdateMeta={updateCategoryMeta}
|
||||
onManage={() => setManagerSection(panelKind === "folder" ? "folders" : "categories")}
|
||||
/>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
{selectedCategory?.meta_title && (
|
||||
|
||||
@@ -77,7 +77,7 @@ test.describe("as a member", () => {
|
||||
await expect(models.getByRole("heading", { level: 3 })).toHaveText("0");
|
||||
await expect(page.getByText("Failed to load your dashboard.")).toHaveCount(0);
|
||||
|
||||
for (const path of ["/admin", "/admin-users", "/admin-settings"]) {
|
||||
for (const path of ["/admin", "/admin-users", "/admin-settings", "/admin-ai"]) {
|
||||
await page.goto(path);
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await expect(pageTitle(page)).toHaveText("Dashboard");
|
||||
|
||||
Reference in new issue
Block a user