mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
feat(AI): add improved descriptions
This commit is contained in:
1 parent
e07eb41168
commit
97f17e4fa3
40 files changed
+3677
-593
No files matched your search
@@ -0,0 +1,6 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Print" ADD COLUMN "aiDescriptionAppliedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "aiDescriptionCheckedHash" TEXT,
|
||||
ADD COLUMN "aiDescriptionOriginal" TEXT,
|
||||
ADD COLUMN "aiDescriptionSuggestion" JSONB;
|
||||
|
||||
@@ -133,48 +133,55 @@ 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)
|
||||
categorySource CategorySource?
|
||||
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?
|
||||
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
|
||||
aiRejectedCategoryIds String[] @default([])
|
||||
// Improved descriptions: a suggested rewrite awaiting review, { notes, model, createdAt }.
|
||||
aiDescriptionSuggestion Json?
|
||||
// The description before an AI rewrite was applied, so it can be reverted; null once edited by hand.
|
||||
aiDescriptionOriginal String?
|
||||
aiDescriptionAppliedAt DateTime?
|
||||
// Of the description and the options the AI last looked at, so a run skips models with nothing new.
|
||||
aiDescriptionCheckedHash String?
|
||||
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])
|
||||
|
||||
@@ -25,6 +25,7 @@ import adminRoutes from "./routes/admin";
|
||||
import dashboardRoutes from "./routes/dashboard";
|
||||
import activityRoutes from "./routes/activity";
|
||||
import aiCategorizationRoutes from "./routes/aiCategorization";
|
||||
import aiDescriptionRoutes from "./routes/aiDescriptions";
|
||||
|
||||
export function createApp(): Express {
|
||||
const app = express();
|
||||
@@ -47,6 +48,7 @@ export function createApp(): Express {
|
||||
app.use("/api", settingsRoutes);
|
||||
app.use("/api", printsRoutes);
|
||||
app.use("/api", aiCategorizationRoutes);
|
||||
app.use("/api", aiDescriptionRoutes);
|
||||
app.use("/api", authorsRoutes);
|
||||
app.use("/api", platesRoutes);
|
||||
app.use("/api", previewImagesRoutes);
|
||||
|
||||
@@ -126,6 +126,15 @@ export type PrintOut = {
|
||||
category_name: string | null;
|
||||
category_source: "manual" | "rule" | "ai" | "legacy" | null;
|
||||
category_kind: "category" | "folder" | null;
|
||||
/** Improved descriptions on the detail page only, while the feature is on: a rewrite to review, and the
|
||||
* description from before one was applied, to compare or revert to. */
|
||||
ai_description_suggestion: { notes: string; created_at: string } | null;
|
||||
/** The description is an AI rewrite (accepted, or applied in auto mode) not since edited or reverted. Sent
|
||||
* whether or not the feature is on, so the model page can always say so. */
|
||||
description_by_ai: boolean;
|
||||
/** A suggested description awaits review; sent on every model, so a card's menu can wait for it too. */
|
||||
ai_description_pending: boolean;
|
||||
ai_description_original: string | null;
|
||||
ai_suggestion: {
|
||||
category_id: string;
|
||||
category_path: string;
|
||||
@@ -241,6 +250,7 @@ export function toPrintOut(
|
||||
previewImages?: PreviewImage[],
|
||||
category?: Category | null,
|
||||
aiSuggestion?: PrintOut["ai_suggestion"],
|
||||
aiDescription?: Pick<PrintOut, "ai_description_suggestion" | "ai_description_original">,
|
||||
): PrintOut {
|
||||
const sortedPlates = plates.toSorted((a, b) => a.position - b.position);
|
||||
const plateOuts = sortedPlates.map((p) => toPlateOut(print.id, p));
|
||||
@@ -299,6 +309,11 @@ export function toPrintOut(
|
||||
category_source: print.categorySource ? (print.categorySource.toLowerCase() as PrintOut["category_source"]) : null,
|
||||
category_kind: category ? (category.kind as PrintOut["category_kind"]) : null,
|
||||
ai_suggestion: aiSuggestion ?? null,
|
||||
ai_description_suggestion: aiDescription?.ai_description_suggestion ?? null,
|
||||
// The original is kept exactly while the description is the AI's.
|
||||
description_by_ai: print.aiDescriptionOriginal !== null,
|
||||
ai_description_pending: print.aiDescriptionSuggestion !== null,
|
||||
ai_description_original: aiDescription?.ai_description_original ?? null,
|
||||
created_at: print.createdAt.toISOString(),
|
||||
storage_path: storageParentDir(sortedPlates),
|
||||
plates: plateOuts,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Router, type NextFunction, type Request, type Response } from "express";
|
||||
import { z } from "zod";
|
||||
import { requireAuth } from "../auth";
|
||||
import { HttpError } from "../utils/fileUtils";
|
||||
import { asyncHandler } from "../utils/asyncHandler";
|
||||
import { parseBody } from "../utils/validate";
|
||||
import {
|
||||
acceptAiDescription,
|
||||
aiDescriptionsEnabled,
|
||||
cancelAiDescriptionRun,
|
||||
currentAiDescriptionRun,
|
||||
getAiDescriptionSettings,
|
||||
listAiDescriptionsApplied,
|
||||
listAiDescriptionSuggestions,
|
||||
queueAiDescription,
|
||||
rejectAiDescription,
|
||||
revertAiDescription,
|
||||
startAiDescriptionRun,
|
||||
type AiDescriptionRun,
|
||||
} from "../services/aiDescriptionService";
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
function runOut(run: AiDescriptionRun | null) {
|
||||
if (!run) return null;
|
||||
return {
|
||||
running: run.running,
|
||||
started_at: run.startedAt,
|
||||
finished_at: run.finishedAt,
|
||||
total: run.total,
|
||||
done: run.done,
|
||||
suggested: run.suggested,
|
||||
applied: run.applied,
|
||||
unchanged: run.unchanged,
|
||||
skipped: run.skipped,
|
||||
failed: run.failed,
|
||||
cancelled: run.cancelled,
|
||||
last_error: run.lastError,
|
||||
};
|
||||
}
|
||||
|
||||
function page(req: Request): { limit: number; offset: number } {
|
||||
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);
|
||||
return { limit, offset };
|
||||
}
|
||||
|
||||
router.get(
|
||||
"/ai-descriptions/status",
|
||||
asyncHandler(async (_req, res) => {
|
||||
res.json({ enabled: aiDescriptionsEnabled(await getAiDescriptionSettings()) });
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/ai-descriptions/suggestions",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { limit, offset } = page(req);
|
||||
res.json(await listAiDescriptionSuggestions(req.userId!, limit, offset));
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/ai-descriptions/applied",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { limit, offset } = page(req);
|
||||
res.json(await listAiDescriptionsApplied(req.userId!, limit, offset));
|
||||
}),
|
||||
);
|
||||
|
||||
for (const [action, handle] of [
|
||||
["accept", acceptAiDescription],
|
||||
["reject", rejectAiDescription],
|
||||
["revert", revertAiDescription],
|
||||
] as const) {
|
||||
router.post(
|
||||
`/print/:id/ai-description/${action}`,
|
||||
asyncHandler(async (req, res) => {
|
||||
await handle(req.userId!, req.params.id);
|
||||
res.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Any user, for a model of their own: answers at once, and the outcome arrives as a notification.
|
||||
router.post(
|
||||
"/print/:id/ai-description/improve",
|
||||
asyncHandler(async (req, res) => {
|
||||
await queueAiDescription(req.userId!, req.params.id);
|
||||
res.status(202).json({ queued: true });
|
||||
}),
|
||||
);
|
||||
|
||||
const bulkSchema = z.object({ action: z.enum(["accept", "reject"]), print_ids: z.array(z.string()).max(500) });
|
||||
router.post(
|
||||
"/ai-descriptions/suggestions/bulk",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(bulkSchema, req.body);
|
||||
let done = 0;
|
||||
let failed = 0;
|
||||
for (const printId of new Set(body.print_ids)) {
|
||||
try {
|
||||
if (body.action === "accept") await acceptAiDescription(req.userId!, printId);
|
||||
else await rejectAiDescription(req.userId!, printId);
|
||||
done++;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
res.json({ done, failed });
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/ai-descriptions/run",
|
||||
asyncHandler(async (req, res) => {
|
||||
res.json({ run: runOut(currentAiDescriptionRun(req.userId!)) });
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/ai-descriptions/run",
|
||||
asyncHandler(async (req, res) => {
|
||||
if (!aiDescriptionsEnabled(await getAiDescriptionSettings())) {
|
||||
throw new HttpError(503, "Improved descriptions are off", "ai_disabled");
|
||||
}
|
||||
const run = startAiDescriptionRun(req.userId!);
|
||||
if (!run) throw new HttpError(409, "Improving descriptions is already running", "running");
|
||||
res.status(202).json({ run: runOut(run) });
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/ai-descriptions/run/cancel",
|
||||
asyncHandler(async (req, res) => {
|
||||
res.json({ run: runOut(cancelAiDescriptionRun(req.userId!)) });
|
||||
}),
|
||||
);
|
||||
|
||||
// The same small error shape as the AI categorization routes, so callers can branch on a stable code.
|
||||
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;
|
||||
@@ -30,6 +30,7 @@ import { createLog } from "../services/auditLog";
|
||||
import { recordActivity } from "../services/activityService";
|
||||
import { notifyUpload } from "../services/modelNotifications";
|
||||
import { fillSourceGaps } from "../services/fillGapsService";
|
||||
import { forgetAiDescription } from "../services/aiDescriptionService";
|
||||
import { isNormalizable3mf, normalize3mfStatus, normalized3mfFor } from "../services/normalized3mfCache";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
@@ -583,6 +584,7 @@ router.post(
|
||||
const plates = await prisma.plate.findMany({ where: { printId: print.id }, orderBy: { position: "asc" } });
|
||||
await relocatePrint(updated, plates);
|
||||
if (body.notes !== undefined) {
|
||||
if (body.notes !== (print.notes ?? "")) await forgetAiDescription(print.id);
|
||||
await pruneDescriptionImages(print.id);
|
||||
// Images pasted in by URL are fetched in the background: slow hosts mustn't hold up the save.
|
||||
void localizeDescriptionImages(print.id);
|
||||
|
||||
@@ -25,7 +25,10 @@ import {
|
||||
type SmtpSettings,
|
||||
getAiCategorizationPrefs,
|
||||
setAiCategorizationPrefs,
|
||||
getAiDescriptionPrefs,
|
||||
setAiDescriptionPrefs,
|
||||
type AiCategorizationPrefs,
|
||||
type AiDescriptionPrefs,
|
||||
} from "../services/settingsService";
|
||||
import { getDatabaseInfo, testAndSwitchDatabase } from "../services/databaseSettingsService";
|
||||
import {
|
||||
@@ -67,7 +70,8 @@ import { getUserAuthorPreviewEnabled, setUserAuthorPreviewEnabled } from "../ser
|
||||
import { checkForUpdates } from "../services/versionService";
|
||||
import { getWeatherLocation, setWeatherLocation, weatherLocationSchema } from "../services/weatherLocationService";
|
||||
import { dropPreviewsAffectedBySimplification } from "../services/modelPreviewCache";
|
||||
import { listAiProviderModels, testAiProvider } from "../services/aiCategorizationService";
|
||||
import { aiCategorizationRunActive, listAiProviderModels, testAiProvider } from "../services/aiCategorizationService";
|
||||
import { aiDescriptionRunActive } from "../services/aiDescriptionService";
|
||||
import {
|
||||
createAiConnection,
|
||||
DEFAULT_AI_PROVIDER,
|
||||
@@ -618,6 +622,9 @@ router.patch(
|
||||
requireAdmin,
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(aiCategorizationPrefsSchema, req.body);
|
||||
if (body.enabled === false && aiCategorizationRunActive()) {
|
||||
throw new HttpError(409, "AI categorization is running. Stop the run before turning it off.", "run_active");
|
||||
}
|
||||
if (body.ai_connection_id) await getAiConnection(body.ai_connection_id);
|
||||
const prefs = await setAiCategorizationPrefs({
|
||||
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
|
||||
@@ -631,6 +638,88 @@ router.patch(
|
||||
}),
|
||||
);
|
||||
|
||||
function aiDescriptionPrefsOut(prefs: AiDescriptionPrefs) {
|
||||
return {
|
||||
enabled: prefs.enabled,
|
||||
mode: prefs.mode,
|
||||
on_import: prefs.onImport,
|
||||
send_image: prefs.sendImage,
|
||||
ai_connection_id: prefs.aiConnectionId,
|
||||
actions: {
|
||||
remove_self_promotion: prefs.actions.removeSelfPromotion,
|
||||
remove_unrelated: prefs.actions.removeUnrelated,
|
||||
translate: prefs.actions.translate,
|
||||
generate_missing: prefs.actions.generateMissing,
|
||||
fix_spelling: prefs.actions.fixSpelling,
|
||||
improve_formatting: prefs.actions.improveFormatting,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
router.get(
|
||||
"/settings/ai-descriptions",
|
||||
requireAdmin,
|
||||
asyncHandler(async (_req, res) => {
|
||||
res.json(aiDescriptionPrefsOut(await getAiDescriptionPrefs()));
|
||||
}),
|
||||
);
|
||||
|
||||
const aiDescriptionPrefsSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
mode: z.enum(["suggest", "auto"]).optional(),
|
||||
on_import: z.boolean().optional(),
|
||||
send_image: z.boolean().optional(),
|
||||
ai_connection_id: z.string().nullable().optional(),
|
||||
actions: z
|
||||
.object({
|
||||
remove_self_promotion: z.boolean(),
|
||||
remove_unrelated: z.boolean(),
|
||||
translate: z.boolean(),
|
||||
generate_missing: z.boolean(),
|
||||
fix_spelling: z.boolean(),
|
||||
improve_formatting: z.boolean(),
|
||||
})
|
||||
.partial()
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
router.patch(
|
||||
"/settings/ai-descriptions",
|
||||
requireAdmin,
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(aiDescriptionPrefsSchema, req.body);
|
||||
if (body.enabled === false && aiDescriptionRunActive()) {
|
||||
throw new HttpError(409, "Improved descriptions is running. Stop the run before turning it off.", "run_active");
|
||||
}
|
||||
if (body.ai_connection_id) await getAiConnection(body.ai_connection_id);
|
||||
const actions = body.actions;
|
||||
const prefs = await setAiDescriptionPrefs({
|
||||
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
|
||||
...(body.mode !== undefined ? { mode: body.mode } : {}),
|
||||
...(body.on_import !== undefined ? { onImport: body.on_import } : {}),
|
||||
...(body.send_image !== undefined ? { sendImage: body.send_image } : {}),
|
||||
...(body.ai_connection_id !== undefined ? { aiConnectionId: body.ai_connection_id } : {}),
|
||||
...(actions
|
||||
? {
|
||||
actions: {
|
||||
...(actions.remove_self_promotion !== undefined
|
||||
? { removeSelfPromotion: actions.remove_self_promotion }
|
||||
: {}),
|
||||
...(actions.remove_unrelated !== undefined ? { removeUnrelated: actions.remove_unrelated } : {}),
|
||||
...(actions.translate !== undefined ? { translate: actions.translate } : {}),
|
||||
...(actions.generate_missing !== undefined ? { generateMissing: actions.generate_missing } : {}),
|
||||
...(actions.fix_spelling !== undefined ? { fixSpelling: actions.fix_spelling } : {}),
|
||||
...(actions.improve_formatting !== undefined ? { improveFormatting: actions.improve_formatting } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
res.json(aiDescriptionPrefsOut(prefs));
|
||||
}),
|
||||
);
|
||||
|
||||
function aiConnectionOut(connection: AiConnection) {
|
||||
return {
|
||||
id: connection.id,
|
||||
@@ -747,9 +836,15 @@ router.delete(
|
||||
// With no model left every feature switches off, so adding one later doesn't quietly start them again.
|
||||
if (!(await listAiConnections()).length) {
|
||||
await setAiCategorizationPrefs({ enabled: false, aiConnectionId: null });
|
||||
} else if ((await getAiCategorizationPrefs()).aiConnectionId === req.params.id) {
|
||||
await setAiDescriptionPrefs({ enabled: false, aiConnectionId: null });
|
||||
} else {
|
||||
// A feature set to it goes back to the default.
|
||||
await setAiCategorizationPrefs({ aiConnectionId: null });
|
||||
if ((await getAiCategorizationPrefs()).aiConnectionId === req.params.id) {
|
||||
await setAiCategorizationPrefs({ aiConnectionId: null });
|
||||
}
|
||||
if ((await getAiDescriptionPrefs()).aiConnectionId === req.params.id) {
|
||||
await setAiDescriptionPrefs({ aiConnectionId: null });
|
||||
}
|
||||
}
|
||||
res.status(204).end();
|
||||
}),
|
||||
|
||||
@@ -1,48 +1,31 @@
|
||||
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 { createLogger } from "../logger";
|
||||
import { previewImagePath } from "./previewImageService";
|
||||
import { createLog } from "./auditLog";
|
||||
import { createNotification } from "./notificationService";
|
||||
import { setPrintCategory } from "./printCategoryService";
|
||||
import { DEFAULT_AI_PROVIDER, resolveAiConnection, type AiProvider } from "./aiConnectionService";
|
||||
import { resolveAiProvider, type ResolvedAiProvider } from "./aiConnectionService";
|
||||
import {
|
||||
askProvider,
|
||||
limited,
|
||||
noSecretError,
|
||||
previewPart,
|
||||
setAiRetryDelayForTest,
|
||||
type ProviderContent,
|
||||
type ProviderSettings,
|
||||
} from "./aiProviderClient";
|
||||
import { getAiCategorizationPrefs, type AiCategorizationPrefs } from "./settingsService";
|
||||
|
||||
/** Categorization's own preferences with the AI it uses; base URL and model are null while there's no AI. */
|
||||
export type AiCategorizationSettings = AiCategorizationPrefs &
|
||||
Omit<AiProvider, "baseUrl" | "model"> & { baseUrl: string | null; model: string | null; connectionId: string | null };
|
||||
export type AiCategorizationSettings = AiCategorizationPrefs & ResolvedAiProvider;
|
||||
|
||||
/** What one request to a provider needs, saved or only typed into the settings form. */
|
||||
type RequestSettings = Omit<AiProvider, "baseUrl" | "model" | "concurrency"> & {
|
||||
baseUrl: string | null;
|
||||
model: string | null;
|
||||
sendImage: boolean;
|
||||
};
|
||||
type RequestSettings = ProviderSettings & { sendImage: boolean };
|
||||
|
||||
export async function getAiCategorizationSettings(): Promise<AiCategorizationSettings> {
|
||||
const prefs = await getAiCategorizationPrefs();
|
||||
const connection = await resolveAiConnection(prefs.aiConnectionId);
|
||||
return {
|
||||
...prefs,
|
||||
...DEFAULT_AI_PROVIDER,
|
||||
baseUrl: null,
|
||||
model: null,
|
||||
...(connection
|
||||
? {
|
||||
baseUrl: connection.baseUrl,
|
||||
apiKey: connection.apiKey,
|
||||
model: connection.model,
|
||||
timeoutMs: connection.timeoutMs,
|
||||
concurrency: connection.concurrency,
|
||||
thinking: connection.thinking,
|
||||
}
|
||||
: {}),
|
||||
connectionId: connection?.id ?? null,
|
||||
};
|
||||
return { ...prefs, ...(await resolveAiProvider(prefs.aiConnectionId)) };
|
||||
}
|
||||
|
||||
/** Suggestions are only shown while categorization is on; switched off, they stay stored for when it's back. */
|
||||
@@ -69,39 +52,8 @@ type Candidate = { id: string; path: string };
|
||||
type ProviderResult = { categoryId: string | null; confidence: number; reason: string };
|
||||
export type CategorizationOutcome = "applied" | "suggested" | "no_match" | "unchanged";
|
||||
|
||||
// Per AI: each provider has its own limit, and a slow one shouldn't hold up another.
|
||||
const slots = new Map<string, { active: number; 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>(key: string, limit: number, task: () => Promise<T>): Promise<T> {
|
||||
let slot = slots.get(key);
|
||||
if (!slot) {
|
||||
slot = { active: 0, waiters: [] };
|
||||
slots.set(key, slot);
|
||||
}
|
||||
// A waiter can be woken while another request takes the slot first, so it retries acquisition.
|
||||
if (slot.active >= limit) {
|
||||
await new Promise<void>((resolve) => slot.waiters.push(resolve));
|
||||
return limited(key, limit, task);
|
||||
}
|
||||
slot.active++;
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
slot.active--;
|
||||
slot.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");
|
||||
}
|
||||
/** Kept for the tests that already use it; the delay is shared by every AI feature. */
|
||||
export const setAiCategorizationRetryDelayForTest = setAiRetryDelayForTest;
|
||||
|
||||
function plainText(value: string | null): string {
|
||||
return (value ?? "")
|
||||
@@ -141,56 +93,6 @@ async function candidateCategories(userId: string, rejected: string[]): Promise<
|
||||
.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: { timeoutMs: number }): 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Providers that ignore response_format (Xiaomi MiMo) wrap the object in prose or markdown, e.g.
|
||||
* `**{...}**`; the outermost braces are tried when the whole text doesn't parse. undefined when
|
||||
* neither does.
|
||||
*/
|
||||
function parseAnswerJson(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
const start = text.indexOf("{");
|
||||
const end = text.lastIndexOf("}");
|
||||
if (start === -1 || end <= start) return undefined;
|
||||
try {
|
||||
return JSON.parse(text.slice(start, end + 1));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path.toLowerCase().replace(/\s+/g, " ").trim();
|
||||
}
|
||||
@@ -218,9 +120,6 @@ function candidateByLabel(candidates: LabelledCandidate[], answer: string): Labe
|
||||
return loose.length === 1 ? loose[0] : undefined;
|
||||
}
|
||||
|
||||
// Connections (base URL and model) that answered 400 to reasoning_effort, so later requests leave it out.
|
||||
const reasoningEffortRejected = new Set<string>();
|
||||
|
||||
async function requestProvider(
|
||||
print: Print,
|
||||
categories: Candidate[],
|
||||
@@ -231,7 +130,7 @@ async function requestProvider(
|
||||
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 } })[] = [
|
||||
const content: ProviderContent = [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
@@ -272,97 +171,17 @@ async function requestProvider(
|
||||
required: ["reason", "category", "confidence"],
|
||||
};
|
||||
|
||||
const requestLog = log.child({ print: print.id, model: settings.model });
|
||||
const connection = `${settings.baseUrl}|${settings.model}`;
|
||||
let skipThinking = !settings.thinking && !reasoningEffortRejected.has(connection);
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
const last = attempt === attempts - 1;
|
||||
const started = Date.now();
|
||||
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,
|
||||
// A 300-character reason plus the JSON around it needs room; 300 tokens cut answers off before the
|
||||
// closing brace, and reasoning models spend part of the budget before answering.
|
||||
max_tokens: 1024,
|
||||
response_format: { type: "json_schema", json_schema: { name: "categorization", strict: true, schema } },
|
||||
// A reasoning model (qwen3) on a CPU spends minutes thinking; this one choice rarely needs it.
|
||||
...(skipThinking ? { reasoning_effort: "none" } : {}),
|
||||
messages: [{ role: "user", content }],
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
// Strict providers reject the field outright, e.g. OpenAI for a model that doesn't reason: ask again
|
||||
// without it, which doesn't count as an attempt. Only when the error names it, so another bad request
|
||||
// isn't taken for proof that the connection can't skip thinking.
|
||||
if (response.status === 400 && skipThinking && /reasoning/i.test(await response.clone().text())) {
|
||||
skipThinking = false;
|
||||
reasoningEffortRejected.add(connection);
|
||||
requestLog.warn("provider rejected reasoning_effort, asking again without it");
|
||||
attempt--;
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
requestLog.warn("provider request failed, retrying", {
|
||||
status: response.status,
|
||||
attempt: attempt + 1,
|
||||
attempts,
|
||||
delay_ms: delay,
|
||||
});
|
||||
await retryDelay(delay);
|
||||
continue;
|
||||
}
|
||||
const body = (await response.json()) as {
|
||||
choices?: { message?: { content?: unknown }; finish_reason?: unknown }[];
|
||||
usage?: { completion_tokens?: unknown; completion_tokens_details?: { reasoning_tokens?: unknown } };
|
||||
};
|
||||
const raw = body.choices?.[0]?.message?.content;
|
||||
// What an admin needs to tell a cut-off answer (a reasoning model spending the token budget) from a bad one.
|
||||
const answer = {
|
||||
ms: Date.now() - started,
|
||||
finish_reason: body.choices?.[0]?.finish_reason,
|
||||
completion_tokens: body.usage?.completion_tokens,
|
||||
reasoning_tokens: body.usage?.completion_tokens_details?.reasoning_tokens,
|
||||
answer_chars: typeof raw === "string" ? raw.length : undefined,
|
||||
};
|
||||
const unusable = (message: string) => {
|
||||
requestLog.warn("provider answer unusable", {
|
||||
problem: message,
|
||||
...answer,
|
||||
answer_end: typeof raw === "string" ? raw.slice(-120) : undefined,
|
||||
});
|
||||
return new Error(message);
|
||||
};
|
||||
if (typeof raw !== "string") throw unusable("AI provider returned no classification");
|
||||
const unfenced = raw
|
||||
.trim()
|
||||
.replace(/^```(?:json)?\s*/i, "")
|
||||
.replace(/\s*```$/, "");
|
||||
const json = parseAnswerJson(unfenced);
|
||||
if (json === undefined) {
|
||||
// JSON.parse only says where it stopped, which tells an admin nothing about the provider.
|
||||
if (body.choices?.[0]?.finish_reason === "length")
|
||||
throw unusable("AI provider cut the answer off at its token limit");
|
||||
throw unusable("AI provider returned an answer that is not valid JSON");
|
||||
}
|
||||
return askProvider({
|
||||
settings,
|
||||
content,
|
||||
schemaName: "categorization",
|
||||
schema,
|
||||
// A 300-character reason plus the JSON around it needs room; 300 tokens cut answers off before the closing brace.
|
||||
maxTokens: 1024,
|
||||
attempts,
|
||||
log: log.child({ print: print.id, model: settings.model }),
|
||||
noAnswer: "no classification",
|
||||
read: (json, unusable, answer) => {
|
||||
const parsed = z
|
||||
.object({
|
||||
category: z.string().nullable(),
|
||||
@@ -377,26 +196,14 @@ async function requestProvider(
|
||||
if (confidence < 0 || confidence > 1) throw unusable("AI provider returned invalid classification");
|
||||
const chosen = parsed.data.category === null ? null : candidateByLabel(candidates, parsed.data.category);
|
||||
if (chosen === undefined) throw unusable("AI provider returned a category outside the candidate list");
|
||||
requestLog.debug("provider answered", { ...answer, category: parsed.data.category, confidence });
|
||||
log.debug("provider answered", { print: print.id, ...answer, category: parsed.data.category, confidence });
|
||||
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));
|
||||
requestLog.warn("provider unreachable, retrying", {
|
||||
error: noSecretError(readableNetworkError(error, settings)),
|
||||
attempt: attempt + 1,
|
||||
attempts,
|
||||
});
|
||||
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 {
|
||||
@@ -537,7 +344,11 @@ export async function queueAiCategorization(userId: string, printId: string, opt
|
||||
const settings = await getAiCategorizationSettings();
|
||||
if (!aiCategorizationEnabled(settings)) throw new HttpError(503, "AI categorization is disabled", "ai_disabled");
|
||||
const categorize = { force: options.force, folders: true };
|
||||
await loadCategorizable(userId, printId, categorize);
|
||||
const { print } = await loadCategorizable(userId, printId, categorize);
|
||||
// One at a time: a new suggestion would replace the one awaiting review unseen.
|
||||
if (print.aiSuggestion !== null) {
|
||||
throw new HttpError(409, "Accept or reject the suggested category first", "suggestion_pending");
|
||||
}
|
||||
if (queuedPrints.has(printId)) return;
|
||||
queuedPrints.add(printId);
|
||||
void categorizePrint(userId, printId, { ...categorize, source: "request" })
|
||||
@@ -652,6 +463,11 @@ export type AiCategorizationRun = {
|
||||
|
||||
const runs = new Map<string, AiCategorizationRun>();
|
||||
|
||||
/** Anyone's run, so the feature isn't switched off under it. */
|
||||
export function aiCategorizationRunActive(): boolean {
|
||||
return [...runs.values()].some((run) => run.running);
|
||||
}
|
||||
|
||||
export function currentAiCategorizationRun(userId: string): AiCategorizationRun | null {
|
||||
return runs.get(userId) ?? null;
|
||||
}
|
||||
@@ -863,7 +679,7 @@ export async function listAiProviderModels(provider: {
|
||||
|
||||
/** Asks a provider to categorize a calibration cube, so a connection can be checked before it's saved. */
|
||||
export async function testAiProvider(
|
||||
provider: Omit<AiProvider, "baseUrl" | "model" | "concurrency"> & { baseUrl: string | null; model: string | null },
|
||||
provider: ProviderSettings,
|
||||
): Promise<{ ok: true; latencyMs: number; model: string } | { ok: false; error: string }> {
|
||||
if (!provider.baseUrl || !provider.model) return { ok: false, error: "Set the API base URL and model name first" };
|
||||
const start = Date.now();
|
||||
|
||||
@@ -49,6 +49,27 @@ export async function resolveAiConnection(id: string | null | undefined): Promis
|
||||
return prisma.aiConnection.findFirst({ where: { isDefault: true } });
|
||||
}
|
||||
|
||||
/** What a feature set to `id` talks to; base URL and model are null while there's no AI at all. */
|
||||
export type ResolvedAiProvider = Omit<AiProvider, "baseUrl" | "model"> & {
|
||||
baseUrl: string | null;
|
||||
model: string | null;
|
||||
connectionId: string | null;
|
||||
};
|
||||
|
||||
export async function resolveAiProvider(id: string | null | undefined): Promise<ResolvedAiProvider> {
|
||||
const connection = await resolveAiConnection(id);
|
||||
if (!connection) return { ...DEFAULT_AI_PROVIDER, baseUrl: null, model: null, connectionId: null };
|
||||
return {
|
||||
baseUrl: connection.baseUrl,
|
||||
apiKey: connection.apiKey,
|
||||
model: connection.model,
|
||||
timeoutMs: connection.timeoutMs,
|
||||
concurrency: connection.concurrency,
|
||||
thinking: connection.thinking,
|
||||
connectionId: connection.id,
|
||||
};
|
||||
}
|
||||
|
||||
/** The first AI becomes the default, so a feature always has one to use. */
|
||||
export async function createAiConnection(input: AiConnectionInput): Promise<AiConnection> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
|
||||
@@ -0,0 +1,597 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { Prisma, type Print } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "../db";
|
||||
import { HttpError } from "../utils/fileUtils";
|
||||
import { createLogger } from "../logger";
|
||||
import { createLog } from "./auditLog";
|
||||
import { createNotification } from "./notificationService";
|
||||
import { resolveAiProvider, type ResolvedAiProvider } from "./aiConnectionService";
|
||||
import { askProvider, limited, noSecretError, previewPart, type ProviderContent } from "./aiProviderClient";
|
||||
import { getAiDescriptionPrefs, type AiDescriptionActions, type AiDescriptionPrefs } from "./settingsService";
|
||||
|
||||
const log = createLogger("ai-descriptions");
|
||||
|
||||
export type AiDescriptionSettings = AiDescriptionPrefs & ResolvedAiProvider;
|
||||
|
||||
export async function getAiDescriptionSettings(): Promise<AiDescriptionSettings> {
|
||||
const prefs = await getAiDescriptionPrefs();
|
||||
return { ...prefs, ...(await resolveAiProvider(prefs.aiConnectionId)) };
|
||||
}
|
||||
|
||||
export function aiDescriptionsEnabled(settings: AiDescriptionSettings): boolean {
|
||||
return settings.enabled && Boolean(settings.baseUrl && settings.model);
|
||||
}
|
||||
|
||||
const suggestionSchema = z.object({ notes: z.string(), model: z.string(), createdAt: z.string() });
|
||||
type Suggestion = z.infer<typeof suggestionSchema>;
|
||||
|
||||
export type DescriptionOutcome = "suggested" | "applied" | "unchanged" | "skipped";
|
||||
|
||||
// Past this a description is left alone: a rewrite of it would outgrow what most models answer in one go.
|
||||
const MAX_DESCRIPTION_CHARS = 12_000;
|
||||
// A whole description comes back, which on a CPU takes minutes, so the answer streams and is waited on piece by
|
||||
// piece: as long as the model keeps writing, however slowly. Reading a long description before the first piece
|
||||
// gets at least FIRST_PIECE_MS, or the model's own timeout if that's longer.
|
||||
const FIRST_PIECE_MS = 120_000;
|
||||
const BETWEEN_PIECES_MS = 60_000;
|
||||
|
||||
const RULES: Record<Exclude<keyof AiDescriptionActions, "generateMissing">, string> = {
|
||||
removeSelfPromotion:
|
||||
"Remove self-promotion: links or calls to visit the author's profile, shop, social media, Patreon or Ko-fi, " +
|
||||
"and links or mentions meant to advertise their other models.",
|
||||
removeUnrelated:
|
||||
"Remove text unrelated to the model itself, such as requests for likes, boosts, ratings, follows, comments, " +
|
||||
"makes or donations.",
|
||||
translate: "If the description is not in English, translate it to English.",
|
||||
fixSpelling: "Fix obvious spelling and grammar mistakes without rewording anything else.",
|
||||
improveFormatting:
|
||||
"Improve the formatting: clear paragraphs, and Markdown lists or headings where they make it easier to read.",
|
||||
};
|
||||
|
||||
/** What the AI looked at: the description and what it was asked to do. A run skips a model while this is unchanged. */
|
||||
function checkedHash(notes: string | null, settings: AiDescriptionPrefs): string {
|
||||
const generate = settings.actions.generateMissing && settings.sendImage;
|
||||
return createHash("sha256")
|
||||
.update(JSON.stringify([notes ?? "", settings.actions, generate]))
|
||||
.digest("hex")
|
||||
.slice(0, 32);
|
||||
}
|
||||
|
||||
/** Every image a description shows, as Markdown or HTML, by address. */
|
||||
function imagesIn(notes: string): Set<string> {
|
||||
const found = new Set<string>();
|
||||
for (const match of notes.matchAll(/!\[[^\]]*\]\(\s*<?([^)\s>]+)/g)) found.add(match[1]);
|
||||
for (const match of notes.matchAll(/<img\b[^>]*\bsrc=["']([^"']+)["']/gi)) found.add(match[1]);
|
||||
return found;
|
||||
}
|
||||
|
||||
// Around the description in the prompt, so the model sees where it starts and ends. Named rather than symbols like
|
||||
// "<<<", which a small model copied into its answer as if they were part of the text.
|
||||
const DESCRIPTION_START = "--- DESCRIPTION START ---";
|
||||
const DESCRIPTION_END = "--- DESCRIPTION END ---";
|
||||
|
||||
/** The answer without a start or end marker the model echoed back anyway, the old "<<<"/">>>" ones included. */
|
||||
function withoutMarkers(answer: string): string {
|
||||
return answer
|
||||
.trim()
|
||||
.replace(/^(?:-{3} DESCRIPTION START -{3}|<<<)[ \t]*\n?/i, "")
|
||||
.replace(/\n?[ \t]*(?:-{3} DESCRIPTION END -{3}|>>>)$/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
const same = (a: string, b: string) => a.replace(/\s+/g, " ").trim() === b.replace(/\s+/g, " ").trim();
|
||||
|
||||
/** Why a model was left alone without asking the AI, for the owner who asked about that one. */
|
||||
type SkipReason = "no_description" | "too_long" | "nothing_selected" | "no_image" | "checked";
|
||||
type Rewrite = { notes: string } | "unchanged" | { skipped: SkipReason };
|
||||
|
||||
async function requestRewrite(print: Print, settings: AiDescriptionSettings, attempts = 3): Promise<Rewrite> {
|
||||
const notes = print.notes?.trim() ?? "";
|
||||
const generating = !notes;
|
||||
if (generating && !(settings.actions.generateMissing && settings.sendImage)) return { skipped: "no_description" };
|
||||
if (notes.length > MAX_DESCRIPTION_CHARS) return { skipped: "too_long" };
|
||||
const rules = (Object.keys(RULES) as (keyof typeof RULES)[]).filter((key) => settings.actions[key]);
|
||||
if (!generating && !rules.length) return { skipped: "nothing_selected" };
|
||||
const image = await previewPart(print.id, settings.sendImage);
|
||||
if (generating && !image) return { skipped: "no_image" };
|
||||
|
||||
const content: ProviderContent = [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
"You edit the description of a 3D-printable model's listing. The description is Markdown.",
|
||||
...(generating
|
||||
? [
|
||||
"The model has no description. Write a short, factual one (two to four sentences) saying what the " +
|
||||
"printed object is and what it is for, from the title, the tags and the image. Don't invent print " +
|
||||
"settings, sizes or materials.",
|
||||
]
|
||||
: [
|
||||
"Make only these changes:",
|
||||
...rules.map((key) => `- ${RULES[key]}`),
|
||||
// The removals above are about promotion and noise; these say outright what they must never take.
|
||||
"Always keep, even when a change above would touch it:",
|
||||
"- Print instructions: printer and slicer settings, layer height, infill, walls, supports, " +
|
||||
"orientation, filament or material, temperatures, and any other advice on printing it.",
|
||||
"- Every image, whether Markdown (), an HTML <img> tag or an image inside a link, exactly " +
|
||||
"as written and in the same place in the text.",
|
||||
"- Instructions and steps for assembling, finishing or using the model, in the same order, with the " +
|
||||
"images between them.",
|
||||
"- Sizes, part lists, hardware needed, licence and credits, and any HTML markup as it is.",
|
||||
"Never invent facts. Return the whole description, not only the parts you changed, and without " +
|
||||
`the ${DESCRIPTION_START} and ${DESCRIPTION_END} lines around it.`,
|
||||
"If none of these changes apply, set changed to false.",
|
||||
]),
|
||||
"",
|
||||
"The model:",
|
||||
`Title: ${print.title ?? print.name}`,
|
||||
`Tags: ${print.tags.join(", ") || "none"}`,
|
||||
...(generating ? [] : [DESCRIPTION_START, notes, DESCRIPTION_END]),
|
||||
].join("\n"),
|
||||
},
|
||||
];
|
||||
if (image) content.push(image);
|
||||
const schema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: { changed: { type: "boolean" }, description: { type: "string" } },
|
||||
required: ["changed", "description"],
|
||||
};
|
||||
const before = imagesIn(notes);
|
||||
return askProvider<Rewrite>({
|
||||
settings,
|
||||
content,
|
||||
schemaName: "description",
|
||||
schema,
|
||||
// The whole description comes back, so the budget grows with it; about 3 characters make a token.
|
||||
maxTokens: Math.min(8192, Math.ceil(notes.length / 2.5) + 768),
|
||||
attempts,
|
||||
log: log.child({ print: print.id, model: settings.model }),
|
||||
noAnswer: "no description",
|
||||
stream: { firstChunkMs: Math.max(settings.timeoutMs, FIRST_PIECE_MS), idleMs: BETWEEN_PIECES_MS },
|
||||
read: (json, unusable) => {
|
||||
const parsed = z.object({ changed: z.boolean(), description: z.string() }).safeParse(json);
|
||||
if (!parsed.success) throw unusable("AI provider returned an invalid description");
|
||||
const rewritten = withoutMarkers(parsed.data.description);
|
||||
if (!parsed.data.changed && !generating) return "unchanged";
|
||||
if (!rewritten) {
|
||||
if (generating) throw unusable("AI provider returned an empty description");
|
||||
return "unchanged";
|
||||
}
|
||||
if (!generating && same(rewritten, notes)) return "unchanged";
|
||||
// Images are the model's own pictures; a rewrite that loses one isn't offered.
|
||||
const after = imagesIn(rewritten);
|
||||
if ([...before].some((url) => !after.has(url)))
|
||||
throw unusable("AI provider dropped an image from the description");
|
||||
return { notes: rewritten };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Applies a rewrite, keeping the first original so it can be reverted. Null when the description changed meanwhile. */
|
||||
async function applyRewrite(print: Print, notes: string, settings: AiDescriptionPrefs): Promise<boolean> {
|
||||
const { count } = await prisma.print.updateMany({
|
||||
where: { id: print.id, notes: print.notes },
|
||||
data: {
|
||||
notes,
|
||||
aiDescriptionOriginal: print.aiDescriptionOriginal ?? print.notes ?? "",
|
||||
aiDescriptionAppliedAt: new Date(),
|
||||
aiDescriptionSuggestion: Prisma.DbNull,
|
||||
aiDescriptionCheckedHash: checkedHash(notes, settings),
|
||||
},
|
||||
});
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/** What the model page shows of Improved descriptions for one model; nothing while the feature is off. */
|
||||
export async function aiDescriptionOut(print: Pick<Print, "aiDescriptionSuggestion" | "aiDescriptionOriginal">) {
|
||||
if (!aiDescriptionsEnabled(await getAiDescriptionSettings())) return undefined;
|
||||
const suggestion = suggestionSchema.safeParse(print.aiDescriptionSuggestion);
|
||||
return {
|
||||
ai_description_suggestion: suggestion.success
|
||||
? { notes: suggestion.data.notes, created_at: suggestion.data.createdAt }
|
||||
: null,
|
||||
ai_description_original: print.aiDescriptionOriginal,
|
||||
};
|
||||
}
|
||||
|
||||
/** Errors a run expects on some models: one deleted meanwhile. */
|
||||
function isExpectedSkip(error: unknown): boolean {
|
||||
return error instanceof HttpError && error.status === 404;
|
||||
}
|
||||
|
||||
/** `source` only labels the log line. `force` ignores that nothing changed since the last pass. */
|
||||
export async function improvePrintDescription(
|
||||
userId: string,
|
||||
printId: string,
|
||||
options: { source?: "import" | "run"; force?: boolean } = {},
|
||||
): Promise<DescriptionOutcome> {
|
||||
return (await improveWithReason(userId, printId, options)).outcome;
|
||||
}
|
||||
|
||||
async function improveWithReason(
|
||||
userId: string,
|
||||
printId: string,
|
||||
options: { source?: "import" | "run" | "request"; force?: boolean },
|
||||
): Promise<{ outcome: DescriptionOutcome; reason?: SkipReason }> {
|
||||
const fields = { print: printId, source: options.source ?? "run" };
|
||||
const started = Date.now();
|
||||
try {
|
||||
const result = await improveOne(userId, printId, options.force ?? false);
|
||||
log.info("description checked", { ...fields, ...result, ms: Date.now() - started });
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (isExpectedSkip(error)) log.info("description skipped", { ...fields, reason: (error as Error).message });
|
||||
else log.warn("description failed", { ...fields, error: noSecretError(error), ms: Date.now() - started });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function improveOne(
|
||||
userId: string,
|
||||
printId: string,
|
||||
force: boolean,
|
||||
): Promise<{ outcome: DescriptionOutcome; reason?: SkipReason }> {
|
||||
const settings = await getAiDescriptionSettings();
|
||||
if (!aiDescriptionsEnabled(settings)) throw new HttpError(503, "Improved descriptions are off", "ai_disabled");
|
||||
const print = await prisma.print.findFirst({ where: { id: printId, userId } });
|
||||
if (!print) throw new HttpError(404, "Print not found");
|
||||
const hash = checkedHash(print.notes, settings);
|
||||
if (!force && print.aiDescriptionCheckedHash === hash) return { outcome: "skipped", reason: "checked" };
|
||||
|
||||
const rewrite = await limited(settings.connectionId ?? "", settings.concurrency, () =>
|
||||
requestRewrite(print, settings),
|
||||
);
|
||||
if (rewrite === "unchanged") {
|
||||
await prisma.print.updateMany({
|
||||
where: { id: print.id, notes: print.notes },
|
||||
data: { aiDescriptionCheckedHash: hash, aiDescriptionSuggestion: Prisma.DbNull },
|
||||
});
|
||||
return { outcome: "unchanged" };
|
||||
}
|
||||
if ("skipped" in rewrite) return { outcome: "skipped", reason: rewrite.skipped };
|
||||
const { notes } = rewrite;
|
||||
if (settings.mode === "auto") {
|
||||
return { outcome: (await applyRewrite(print, notes, settings)) ? "applied" : "unchanged" };
|
||||
}
|
||||
const suggestion: Suggestion = { notes, model: settings.model!, createdAt: new Date().toISOString() };
|
||||
const { count } = await prisma.print.updateMany({
|
||||
where: { id: print.id, notes: print.notes },
|
||||
data: { aiDescriptionSuggestion: suggestion as unknown as Prisma.InputJsonValue, aiDescriptionCheckedHash: hash },
|
||||
});
|
||||
return { outcome: count ? "suggested" : "unchanged" };
|
||||
}
|
||||
|
||||
// Models waiting on the provider for their owner's request, so a second click doesn't ask twice.
|
||||
const queuedPrints = new Set<string>();
|
||||
|
||||
/**
|
||||
* Improves one model's description in the background, for its owner who asked, and tells them the outcome as a
|
||||
* notification: a whole description takes minutes on a slow model. It doesn't skip a model with nothing new: they
|
||||
* asked about this one. What they can act on (the feature off, a missing model) is thrown before anything is queued.
|
||||
*/
|
||||
export async function queueAiDescription(userId: string, printId: string): Promise<void> {
|
||||
if (!aiDescriptionsEnabled(await getAiDescriptionSettings())) {
|
||||
throw new HttpError(503, "Improved descriptions are off", "ai_disabled");
|
||||
}
|
||||
const print = await ownPrint(userId, printId);
|
||||
// One at a time: a new suggestion would replace the one awaiting review unseen.
|
||||
if (print.aiDescriptionSuggestion !== null) {
|
||||
throw new HttpError(409, "Accept or reject the suggested description first", "suggestion_pending");
|
||||
}
|
||||
if (queuedPrints.has(printId)) return;
|
||||
queuedPrints.add(printId);
|
||||
void improveWithReason(userId, printId, { source: "request", force: true })
|
||||
.then(
|
||||
(result) => notifyOutcome(userId, printId, result),
|
||||
(error) => notifyFailure(userId, printId, error),
|
||||
)
|
||||
.catch((error) => log.warn("could not notify the description outcome", { print: printId, error }))
|
||||
.finally(() => queuedPrints.delete(printId));
|
||||
}
|
||||
|
||||
const SKIPPED: Record<SkipReason, string> = {
|
||||
no_description:
|
||||
"It has no description, and writing one needs the preview image to be sent. An admin can turn that on.",
|
||||
no_image: "It has no description, and no preview image to write one from.",
|
||||
too_long: "Its description is too long to rewrite in one go.",
|
||||
nothing_selected: "No changes are turned on for Improved descriptions. An admin can choose them.",
|
||||
checked: "Nothing changed since its last check.",
|
||||
};
|
||||
|
||||
async function notifyOutcome(
|
||||
userId: string,
|
||||
printId: string,
|
||||
{ outcome, reason }: { outcome: DescriptionOutcome; reason?: SkipReason },
|
||||
): Promise<void> {
|
||||
const print = await prisma.print.findFirst({ where: { id: printId, userId }, select: { name: true, title: true } });
|
||||
if (!print) return;
|
||||
const name = print.title || print.name;
|
||||
const [title, body] =
|
||||
outcome === "suggested"
|
||||
? [`AI suggests a better description for "${name}"`, "Open the model to compare it and accept or reject it."]
|
||||
: outcome === "applied"
|
||||
? [`AI improved the description of "${name}"`, "The original is kept, so you can revert it on the model page."]
|
||||
: outcome === "unchanged"
|
||||
? [`The description of "${name}" needs no changes`, null]
|
||||
: [`Couldn't improve the description of "${name}"`, reason ? SKIPPED[reason] : null];
|
||||
await createNotification(userId, { type: "ai", title, body, internalPath: `/models/${printId}` });
|
||||
}
|
||||
|
||||
async function notifyFailure(userId: string, printId: string, error: unknown): Promise<void> {
|
||||
const print = await prisma.print.findFirst({ where: { id: printId, userId }, select: { name: true, title: true } });
|
||||
if (!print) return;
|
||||
await createNotification(userId, {
|
||||
type: "ai",
|
||||
title: `Couldn't improve the description of "${print.title || print.name}"`,
|
||||
body: noSecretError(error).message,
|
||||
internalPath: `/models/${printId}`,
|
||||
});
|
||||
}
|
||||
|
||||
/** Import creation never waits on a provider. */
|
||||
export function enqueueAiDescription(userId: string, printId: string): void {
|
||||
void getAiDescriptionSettings().then(
|
||||
(settings) => {
|
||||
if (!aiDescriptionsEnabled(settings) || !settings.onImport) return;
|
||||
// improvePrintDescription logs its own outcome and failure.
|
||||
return improvePrintDescription(userId, printId, { source: "import" }).catch(() => undefined);
|
||||
},
|
||||
(error) => log.warn("could not read the settings for an imported model", { print: printId, error }),
|
||||
);
|
||||
}
|
||||
|
||||
async function ownPrint(userId: string, printId: string): Promise<Print> {
|
||||
const print = await prisma.print.findFirst({ where: { id: printId, userId } });
|
||||
if (!print) throw new HttpError(404, "Print not found");
|
||||
return print;
|
||||
}
|
||||
|
||||
export async function acceptAiDescription(userId: string, printId: string): Promise<void> {
|
||||
const print = await ownPrint(userId, printId);
|
||||
const suggestion = suggestionSchema.safeParse(print.aiDescriptionSuggestion);
|
||||
if (!suggestion.success) throw new HttpError(404, "No suggested description found");
|
||||
if (!(await applyRewrite(print, suggestion.data.notes, await getAiDescriptionPrefs()))) {
|
||||
throw new HttpError(409, "The description changed meanwhile", "stale");
|
||||
}
|
||||
}
|
||||
|
||||
/** The suggestion goes; the model is skipped by later runs until its description or the options change. */
|
||||
export async function rejectAiDescription(userId: string, printId: string): Promise<void> {
|
||||
const print = await ownPrint(userId, printId);
|
||||
if (!suggestionSchema.safeParse(print.aiDescriptionSuggestion).success) {
|
||||
throw new HttpError(404, "No suggested description found");
|
||||
}
|
||||
await prisma.print.update({ where: { id: print.id }, data: { aiDescriptionSuggestion: Prisma.DbNull } });
|
||||
}
|
||||
|
||||
/** Puts back the description from before the AI changed it, and keeps later runs from changing it again. */
|
||||
export async function revertAiDescription(userId: string, printId: string): Promise<void> {
|
||||
const print = await ownPrint(userId, printId);
|
||||
if (print.aiDescriptionOriginal === null) throw new HttpError(404, "No original description to restore");
|
||||
const original = print.aiDescriptionOriginal || null;
|
||||
await prisma.print.update({
|
||||
where: { id: print.id },
|
||||
data: {
|
||||
notes: original,
|
||||
aiDescriptionOriginal: null,
|
||||
aiDescriptionAppliedAt: null,
|
||||
aiDescriptionSuggestion: Prisma.DbNull,
|
||||
aiDescriptionCheckedHash: checkedHash(original, await getAiDescriptionPrefs()),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** A description edited by hand makes a pending suggestion stale, and the kept original no longer the one to revert to. */
|
||||
export async function forgetAiDescription(printId: string): Promise<void> {
|
||||
await prisma.print.update({
|
||||
where: { id: printId },
|
||||
data: { aiDescriptionSuggestion: Prisma.DbNull, aiDescriptionOriginal: null, aiDescriptionAppliedAt: null },
|
||||
});
|
||||
}
|
||||
|
||||
export type DescriptionSuggestionOut = {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string | null;
|
||||
original: string;
|
||||
suggested: string;
|
||||
model: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export async function listAiDescriptionSuggestions(userId: string, limit: number, offset: number) {
|
||||
const where = { userId, aiDescriptionSuggestion: { 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, name: true, title: true, notes: true, aiDescriptionSuggestion: true },
|
||||
}),
|
||||
]);
|
||||
const items: DescriptionSuggestionOut[] = rows.flatMap((row) => {
|
||||
const suggestion = suggestionSchema.safeParse(row.aiDescriptionSuggestion);
|
||||
if (!suggestion.success) return [];
|
||||
return [
|
||||
{
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
title: row.title,
|
||||
original: row.notes ?? "",
|
||||
suggested: suggestion.data.notes,
|
||||
model: suggestion.data.model,
|
||||
created_at: suggestion.data.createdAt,
|
||||
},
|
||||
];
|
||||
});
|
||||
return { total, items };
|
||||
}
|
||||
|
||||
export async function listAiDescriptionsApplied(userId: string, limit: number, offset: number) {
|
||||
const where = { userId, aiDescriptionOriginal: { not: null } };
|
||||
const [total, rows] = await Promise.all([
|
||||
prisma.print.count({ where }),
|
||||
prisma.print.findMany({
|
||||
where,
|
||||
orderBy: { aiDescriptionAppliedAt: "desc" },
|
||||
skip: offset,
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
title: true,
|
||||
notes: true,
|
||||
aiDescriptionOriginal: true,
|
||||
aiDescriptionAppliedAt: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
total,
|
||||
items: rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
title: row.title,
|
||||
original: row.aiDescriptionOriginal ?? "",
|
||||
current: row.notes ?? "",
|
||||
applied_at: row.aiDescriptionAppliedAt?.toISOString() ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export type AiDescriptionRun = {
|
||||
running: boolean;
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
total: number;
|
||||
done: number;
|
||||
suggested: number;
|
||||
applied: number;
|
||||
/** The AI found nothing to change. */
|
||||
unchanged: number;
|
||||
/** Nothing new since the last pass, or nothing the options could do. */
|
||||
skipped: number;
|
||||
failed: number;
|
||||
cancelled: boolean;
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
const runs = new Map<string, AiDescriptionRun>();
|
||||
|
||||
/** Anyone's run, so the feature isn't switched off under it. */
|
||||
export function aiDescriptionRunActive(): boolean {
|
||||
return [...runs.values()].some((run) => run.running);
|
||||
}
|
||||
|
||||
export function currentAiDescriptionRun(userId: string): AiDescriptionRun | null {
|
||||
return runs.get(userId) ?? null;
|
||||
}
|
||||
|
||||
/** Always every model the user owns; the ones with nothing new are skipped without asking the AI. */
|
||||
export function startAiDescriptionRun(userId: string): AiDescriptionRun | null {
|
||||
if (runs.get(userId)?.running) return null;
|
||||
const run: AiDescriptionRun = {
|
||||
running: true,
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: null,
|
||||
total: 0,
|
||||
done: 0,
|
||||
suggested: 0,
|
||||
applied: 0,
|
||||
unchanged: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
cancelled: false,
|
||||
lastError: null,
|
||||
};
|
||||
runs.set(userId, run);
|
||||
log.info("run started", { user: userId });
|
||||
void runAiDescriptions(userId, run)
|
||||
.catch((error) => {
|
||||
run.failed++;
|
||||
run.lastError = noSecretError(error).message;
|
||||
log.error("run crashed", { user: userId, error: noSecretError(error) });
|
||||
})
|
||||
.finally(() => {
|
||||
run.running = false;
|
||||
run.finishedAt = new Date().toISOString();
|
||||
const summary = { user: userId, ...run, lastError: run.lastError ?? undefined };
|
||||
if (run.failed) log.warn("run finished with failures", summary);
|
||||
else log.info("run finished", summary);
|
||||
void createLog({ userId, action: "ai_description_run", details: { ...run } });
|
||||
void notifyRunFinished(userId, run).catch((error) =>
|
||||
log.warn("could not notify the description run", { user: userId, error }),
|
||||
);
|
||||
});
|
||||
return run;
|
||||
}
|
||||
|
||||
async function runAiDescriptions(userId: string, run: AiDescriptionRun): Promise<void> {
|
||||
const prints = await prisma.print.findMany({
|
||||
where: { userId },
|
||||
select: { id: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
run.total = prints.length;
|
||||
let consecutiveFailures = 0;
|
||||
for (const print of prints) {
|
||||
if (run.cancelled) break;
|
||||
let stop = false;
|
||||
try {
|
||||
const outcome = await improvePrintDescription(userId, print.id, { source: "run" });
|
||||
consecutiveFailures = 0;
|
||||
run[outcome]++;
|
||||
} catch (error) {
|
||||
if (isExpectedSkip(error)) {
|
||||
run.skipped++;
|
||||
} else if (error instanceof HttpError && error.code === "ai_disabled") {
|
||||
run.lastError = noSecretError(error).message;
|
||||
stop = true;
|
||||
} else {
|
||||
run.failed++;
|
||||
consecutiveFailures++;
|
||||
run.lastError = noSecretError(error).message;
|
||||
if (consecutiveFailures >= 5) {
|
||||
run.lastError = "AI provider is failing repeatedly; run stopped after 5 consecutive failures";
|
||||
stop = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
run.done++;
|
||||
if (stop) break;
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelAiDescriptionRun(userId: string): AiDescriptionRun | null {
|
||||
const run = runs.get(userId);
|
||||
if (!run) return null;
|
||||
run.cancelled = true;
|
||||
return run;
|
||||
}
|
||||
|
||||
function models(count: number, one: string, many: string): string {
|
||||
return `${count} ${count === 1 ? one : many}`;
|
||||
}
|
||||
|
||||
async function notifyRunFinished(userId: string, run: AiDescriptionRun): Promise<void> {
|
||||
if (!run.total) return;
|
||||
// Only what happened: "0 descriptions improved" tells the owner nothing.
|
||||
const parts = [
|
||||
run.applied && models(run.applied, "description was improved", "descriptions were improved"),
|
||||
run.suggested && models(run.suggested, "model got a suggested description", "models got a suggested description"),
|
||||
run.unchanged && models(run.unchanged, "description needed no changes", "descriptions needed no changes"),
|
||||
run.failed && models(run.failed, "description couldn't be checked", "descriptions couldn't be checked"),
|
||||
].filter(Boolean);
|
||||
const lastError = run.failed && run.lastError ? ` Last error: ${run.lastError}` : "";
|
||||
await createNotification(userId, {
|
||||
type: "ai",
|
||||
title: run.cancelled
|
||||
? `Improving descriptions stopped after ${run.done} of ${run.total} models`
|
||||
: `Improving descriptions finished ${run.total} ${run.total === 1 ? "model" : "models"}`,
|
||||
body: parts.length ? `${parts.join(". ")}.${lastError}` : null,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import fs from "node:fs/promises";
|
||||
import sharp from "sharp";
|
||||
import { prisma } from "../db";
|
||||
import type { Logger } from "../logger";
|
||||
import { previewImagePath } from "./previewImageService";
|
||||
import type { AiProvider } from "./aiConnectionService";
|
||||
|
||||
/** What one request to a provider needs, saved or only typed into the settings form. */
|
||||
export type ProviderSettings = Omit<AiProvider, "baseUrl" | "model" | "concurrency"> & {
|
||||
baseUrl: string | null;
|
||||
model: string | null;
|
||||
};
|
||||
|
||||
export type ProviderContent = ({ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } })[];
|
||||
|
||||
// Per AI: each provider has its own limit, and a slow one shouldn't hold up another, whichever feature asks.
|
||||
const slots = new Map<string, { active: number; 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 setAiRetryDelayForTest(delay?: (milliseconds: number) => Promise<void>): void {
|
||||
retryDelay = delay ?? ((milliseconds) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
|
||||
}
|
||||
|
||||
export async function limited<T>(key: string, limit: number, task: () => Promise<T>): Promise<T> {
|
||||
let slot = slots.get(key);
|
||||
if (!slot) {
|
||||
slot = { active: 0, waiters: [] };
|
||||
slots.set(key, slot);
|
||||
}
|
||||
// A waiter can be woken while another request takes the slot first, so it retries acquisition.
|
||||
if (slot.active >= limit) {
|
||||
await new Promise<void>((resolve) => slot.waiters.push(resolve));
|
||||
return limited(key, limit, task);
|
||||
}
|
||||
slot.active++;
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
slot.active--;
|
||||
slot.waiters.shift()?.();
|
||||
}
|
||||
}
|
||||
|
||||
export 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");
|
||||
}
|
||||
|
||||
/** What a failed fetch means to an admin; fetch itself only says "fetch failed" or "aborted". */
|
||||
function readableNetworkError(error: unknown, waited: { timeoutMs: number; stalled?: boolean }): unknown {
|
||||
if (error instanceof Error && error.name === "AbortError")
|
||||
return new Error(
|
||||
waited.stalled
|
||||
? `The AI provider stopped answering for ${Math.round(waited.timeoutMs / 1000)} s`
|
||||
: `The AI provider did not answer within ${Math.round(waited.timeoutMs / 1000)} s`,
|
||||
);
|
||||
if (error instanceof TypeError) return new Error("Could not reach the AI provider. Check the API base URL.");
|
||||
return error;
|
||||
}
|
||||
|
||||
/** The model's first preview image, small enough for a provider, when sending it is turned on. */
|
||||
export 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`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Providers that ignore response_format (Xiaomi MiMo) wrap the object in prose or markdown, e.g.
|
||||
* `**{...}**`; the outermost braces are tried when the whole text doesn't parse. undefined when
|
||||
* neither does.
|
||||
*/
|
||||
function parseAnswerJson(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
const start = text.indexOf("{");
|
||||
const end = text.lastIndexOf("}");
|
||||
if (start === -1 || end <= start) return undefined;
|
||||
try {
|
||||
return JSON.parse(text.slice(start, end + 1));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ChatBody = {
|
||||
choices?: { message?: { content?: unknown }; finish_reason?: unknown }[];
|
||||
usage?: { completion_tokens?: unknown; completion_tokens_details?: { reasoning_tokens?: unknown } };
|
||||
};
|
||||
|
||||
/**
|
||||
* Puts a streamed answer back together as the one-piece answer would have been. Every piece, thinking included,
|
||||
* tells `onPiece` the provider is still at it.
|
||||
*/
|
||||
async function readStream(response: Response, onPiece: () => void): Promise<ChatBody> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) return {};
|
||||
const decoder = new TextDecoder();
|
||||
let pending = "";
|
||||
let content = "";
|
||||
let finishReason: unknown;
|
||||
let usage: ChatBody["usage"];
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
onPiece();
|
||||
pending += decoder.decode(value, { stream: true });
|
||||
const lines = pending.split("\n");
|
||||
pending = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
const data = line.trim().replace(/^data:\s*/, "");
|
||||
if (!data || data === "[DONE]" || !line.trim().startsWith("data:")) continue;
|
||||
try {
|
||||
const chunk = JSON.parse(data) as {
|
||||
choices?: { delta?: { content?: unknown }; finish_reason?: unknown }[];
|
||||
usage?: ChatBody["usage"];
|
||||
};
|
||||
const piece = chunk.choices?.[0]?.delta?.content;
|
||||
if (typeof piece === "string") content += piece;
|
||||
if (chunk.choices?.[0]?.finish_reason) finishReason = chunk.choices[0].finish_reason;
|
||||
if (chunk.usage) usage = chunk.usage;
|
||||
} catch {
|
||||
// A keep-alive or comment line: nothing to add.
|
||||
}
|
||||
}
|
||||
}
|
||||
return { choices: [{ message: { content }, finish_reason: finishReason }], usage };
|
||||
}
|
||||
|
||||
// Connections (base URL and model) that answered 400 to reasoning_effort, so later requests leave it out.
|
||||
const reasoningEffortRejected = new Set<string>();
|
||||
|
||||
/** Throws the given message as the answer's problem, logging what an admin needs to tell why. */
|
||||
export type Unusable = (message: string) => Error;
|
||||
|
||||
/**
|
||||
* Asks a provider for one JSON answer in `schema`, retrying what a retry can fix, and hands the parsed object to
|
||||
* `read`, which returns what the feature needs or throws `unusable(...)` for an answer it can't use.
|
||||
*/
|
||||
export async function askProvider<T>(options: {
|
||||
settings: ProviderSettings;
|
||||
content: ProviderContent;
|
||||
schemaName: string;
|
||||
schema: object;
|
||||
maxTokens: number;
|
||||
attempts?: number;
|
||||
log: Logger;
|
||||
/** What a missing answer is called in the error, e.g. "no classification". */
|
||||
noAnswer: string;
|
||||
/**
|
||||
* For long answers: the provider streams it, and the wait is for each piece rather than the whole. It gets
|
||||
* `firstChunkMs` to start (reading a long prompt on a CPU takes a while), then gives up only after `idleMs`
|
||||
* without a new piece, however long the answer takes in all. A timed-out stream isn't asked again: the abandoned
|
||||
* one would still be running ahead of it.
|
||||
*/
|
||||
stream?: { firstChunkMs: number; idleMs: number };
|
||||
read: (json: unknown, unusable: Unusable, answer: Record<string, unknown>) => T;
|
||||
}): Promise<T> {
|
||||
const { settings, content, schemaName, schema, maxTokens, attempts = 3, log: requestLog, noAnswer, read } = options;
|
||||
const { stream } = options;
|
||||
const connection = `${settings.baseUrl}|${settings.model}`;
|
||||
let skipThinking = !settings.thinking && !reasoningEffortRejected.has(connection);
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
const last = attempt === attempts - 1;
|
||||
const started = Date.now();
|
||||
const controller = new AbortController();
|
||||
// Streaming, the wait starts over with every piece; otherwise it's one wait for the whole answer.
|
||||
const waited = { timeoutMs: stream ? stream.firstChunkMs : settings.timeoutMs, stalled: false };
|
||||
let timeout = setTimeout(() => controller.abort(), waited.timeoutMs);
|
||||
const stillAnswering = () => {
|
||||
if (!stream) return;
|
||||
clearTimeout(timeout);
|
||||
waited.timeoutMs = stream.idleMs;
|
||||
waited.stalled = true;
|
||||
timeout = setTimeout(() => controller.abort(), stream.idleMs);
|
||||
};
|
||||
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,
|
||||
// Room for the answer and the JSON around it; reasoning models also spend part of it before answering.
|
||||
max_tokens: maxTokens,
|
||||
...(stream ? { stream: true, stream_options: { include_usage: true } } : {}),
|
||||
response_format: { type: "json_schema", json_schema: { name: schemaName, strict: true, schema } },
|
||||
// A reasoning model (qwen3) on a CPU spends minutes thinking; these tasks rarely need it.
|
||||
...(skipThinking ? { reasoning_effort: "none" } : {}),
|
||||
messages: [{ role: "user", content }],
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
// Strict providers reject the field outright, e.g. OpenAI for a model that doesn't reason: ask again
|
||||
// without it, which doesn't count as an attempt. Only when the error names it, so another bad request
|
||||
// isn't taken for proof that the connection can't skip thinking.
|
||||
if (response.status === 400 && skipThinking && /reasoning/i.test(await response.clone().text())) {
|
||||
skipThinking = false;
|
||||
reasoningEffortRejected.add(connection);
|
||||
requestLog.warn("provider rejected reasoning_effort, asking again without it");
|
||||
attempt--;
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
requestLog.warn("provider request failed, retrying", {
|
||||
status: response.status,
|
||||
attempt: attempt + 1,
|
||||
attempts,
|
||||
delay_ms: delay,
|
||||
});
|
||||
await retryDelay(delay);
|
||||
continue;
|
||||
}
|
||||
// A provider that ignores `stream` answers in one piece, which reads the usual way.
|
||||
const body: ChatBody = (response.headers.get("content-type") ?? "").includes("text/event-stream")
|
||||
? await readStream(response, stillAnswering)
|
||||
: ((await response.json()) as ChatBody);
|
||||
const raw = body.choices?.[0]?.message?.content;
|
||||
// What an admin needs to tell a cut-off answer (a reasoning model spending the token budget) from a bad one.
|
||||
const answer = {
|
||||
ms: Date.now() - started,
|
||||
finish_reason: body.choices?.[0]?.finish_reason,
|
||||
completion_tokens: body.usage?.completion_tokens,
|
||||
reasoning_tokens: body.usage?.completion_tokens_details?.reasoning_tokens,
|
||||
answer_chars: typeof raw === "string" ? raw.length : undefined,
|
||||
};
|
||||
const unusable: Unusable = (message) => {
|
||||
requestLog.warn("provider answer unusable", {
|
||||
problem: message,
|
||||
...answer,
|
||||
answer_end: typeof raw === "string" ? raw.slice(-120) : undefined,
|
||||
});
|
||||
return new Error(message);
|
||||
};
|
||||
if (typeof raw !== "string") throw unusable(`AI provider returned ${noAnswer}`);
|
||||
const unfenced = raw
|
||||
.trim()
|
||||
.replace(/^```(?:json)?\s*/i, "")
|
||||
.replace(/\s*```$/, "");
|
||||
const json = parseAnswerJson(unfenced);
|
||||
if (json === undefined) {
|
||||
// JSON.parse only says where it stopped, which tells an admin nothing about the provider.
|
||||
if (body.choices?.[0]?.finish_reason === "length")
|
||||
throw unusable("AI provider cut the answer off at its token limit");
|
||||
throw unusable("AI provider returned an answer that is not valid JSON");
|
||||
}
|
||||
return read(json, unusable, answer);
|
||||
} catch (error) {
|
||||
const timedOut = error instanceof Error && error.name === "AbortError";
|
||||
const retryable = error instanceof TypeError || (timedOut && !stream);
|
||||
if (!retryable || last) throw noSecretError(readableNetworkError(error, waited));
|
||||
requestLog.warn("provider unreachable, retrying", {
|
||||
error: noSecretError(readableNetworkError(error, waited)),
|
||||
attempt: attempt + 1,
|
||||
attempts,
|
||||
});
|
||||
await retryDelay(attempt === 0 ? 1000 : 3000);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
throw new Error("AI provider request failed");
|
||||
}
|
||||
@@ -24,7 +24,8 @@ export type LogAction =
|
||||
| "collection_item_removed"
|
||||
| "collection_sync_enabled"
|
||||
| "collection_sync_disabled"
|
||||
| "ai_categorization_run";
|
||||
| "ai_categorization_run"
|
||||
| "ai_description_run";
|
||||
|
||||
/** Fire-and-forget: a logging failure must never break the action it records. */
|
||||
export async function createLog(params: {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { getPreviewMode } from "./settingsService";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { CategorySource, Plate, Print } from "@prisma/client";
|
||||
import { enqueueAiCategorization } from "./aiCategorizationService";
|
||||
import { enqueueAiDescription } from "./aiDescriptionService";
|
||||
|
||||
const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".webp", ".bmp"]);
|
||||
|
||||
@@ -219,6 +220,7 @@ 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);
|
||||
enqueueAiDescription(userId, print.id);
|
||||
return { print, plates };
|
||||
} catch (err) {
|
||||
await discardPrint(print.id);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { prisma } from "../db";
|
||||
import { HttpError } from "../utils/fileUtils";
|
||||
import { toPrintOut, type PrintOut } from "../dto";
|
||||
import { aiCategoryPaths, aiSuggestionOut, aiSuggestionsShown } from "./aiCategorizationService";
|
||||
import { aiDescriptionOut } from "./aiDescriptionService";
|
||||
import type { Author, Category, Plate, PreviewImage, Print, PrintFile } from "@prisma/client";
|
||||
|
||||
export type FullPrint = {
|
||||
@@ -44,6 +45,7 @@ export async function printOutById(userId: string, printId: string): Promise<Pri
|
||||
full.previewImages,
|
||||
full.print.category,
|
||||
suggestionsShown ? await aiSuggestionOut(userId, full.print.aiSuggestion, categoryPaths) : null,
|
||||
await aiDescriptionOut(full.print),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -268,6 +268,82 @@ export async function setAiCategorizationPrefs(patch: Partial<AiCategorizationPr
|
||||
return next;
|
||||
}
|
||||
|
||||
/** What Improved descriptions may change. Generating needs the image, so it only applies when that's sent. */
|
||||
export type AiDescriptionActions = {
|
||||
removeSelfPromotion: boolean;
|
||||
removeUnrelated: boolean;
|
||||
translate: boolean;
|
||||
generateMissing: boolean;
|
||||
fixSpelling: boolean;
|
||||
improveFormatting: boolean;
|
||||
};
|
||||
|
||||
export type AiDescriptionPrefs = {
|
||||
enabled: boolean;
|
||||
/** Auto applies a rewrite straight away; the original is kept either way, so it can be reverted. */
|
||||
mode: AiCategorizationMode;
|
||||
onImport: boolean;
|
||||
sendImage: boolean;
|
||||
aiConnectionId: string | null;
|
||||
actions: AiDescriptionActions;
|
||||
};
|
||||
|
||||
const AI_DESCRIPTIONS_KEY = "ai_descriptions";
|
||||
export const DEFAULT_AI_DESCRIPTION_PREFS: AiDescriptionPrefs = {
|
||||
enabled: false,
|
||||
mode: "suggest",
|
||||
onImport: true,
|
||||
sendImage: false,
|
||||
aiConnectionId: null,
|
||||
actions: {
|
||||
removeSelfPromotion: true,
|
||||
removeUnrelated: true,
|
||||
translate: true,
|
||||
generateMissing: false,
|
||||
fixSpelling: true,
|
||||
improveFormatting: true,
|
||||
},
|
||||
};
|
||||
|
||||
function validAiDescriptionPrefs(value: unknown): Partial<AiDescriptionPrefs> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||
const raw = value as Record<string, unknown>;
|
||||
const actions = raw.actions && typeof raw.actions === "object" ? (raw.actions as Record<string, unknown>) : {};
|
||||
return {
|
||||
...(typeof raw.enabled === "boolean" ? { enabled: raw.enabled } : {}),
|
||||
...(raw.mode === "suggest" || raw.mode === "auto" ? { mode: raw.mode } : {}),
|
||||
...(typeof raw.onImport === "boolean" ? { onImport: raw.onImport } : {}),
|
||||
...(typeof raw.sendImage === "boolean" ? { sendImage: raw.sendImage } : {}),
|
||||
...(typeof raw.aiConnectionId === "string" || raw.aiConnectionId === null
|
||||
? { aiConnectionId: raw.aiConnectionId || null }
|
||||
: {}),
|
||||
actions: Object.fromEntries(
|
||||
Object.entries(DEFAULT_AI_DESCRIPTION_PREFS.actions).map(([key, fallback]) => [
|
||||
key,
|
||||
typeof actions[key] === "boolean" ? actions[key] : fallback,
|
||||
]),
|
||||
) as AiDescriptionActions,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAiDescriptionPrefs(): Promise<AiDescriptionPrefs> {
|
||||
const row = await prisma.setting.findUnique({ where: { key: AI_DESCRIPTIONS_KEY } });
|
||||
return { ...DEFAULT_AI_DESCRIPTION_PREFS, ...validAiDescriptionPrefs(row?.value) };
|
||||
}
|
||||
|
||||
export async function setAiDescriptionPrefs(
|
||||
patch: Partial<Omit<AiDescriptionPrefs, "actions">> & { actions?: Partial<AiDescriptionActions> },
|
||||
): Promise<AiDescriptionPrefs> {
|
||||
const current = await getAiDescriptionPrefs();
|
||||
const next = { ...current, ...patch, actions: { ...current.actions, ...patch.actions } };
|
||||
await prisma.setting.upsert({
|
||||
where: { key: AI_DESCRIPTIONS_KEY },
|
||||
create: { key: AI_DESCRIPTIONS_KEY, value: next as unknown as Prisma.InputJsonValue },
|
||||
update: { value: next as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
const AUTH_TOKEN_TTL_KEY = "auth_token_ttl_seconds";
|
||||
const DEFAULT_AUTH_TOKEN_TTL_SECONDS = 43200;
|
||||
|
||||
|
||||
@@ -785,6 +785,10 @@ describe("AI categorization", () => {
|
||||
body: "fits the target",
|
||||
internalPath: `/models/${inFolder.id}`,
|
||||
});
|
||||
// Another one waits until this one is accepted or rejected.
|
||||
const again = await recategorize(inFolder.id);
|
||||
expect(again.status).toBe(409);
|
||||
expect(again.body.code).toBe("suggestion_pending");
|
||||
// Accepting takes the model out of its folder.
|
||||
const accepted = await request(app).post(`/api/print/${inFolder.id}/ai-suggestion/accept`).set(auth(owner.token));
|
||||
expect(accepted.body.print).toMatchObject({ category_id: target.id, category_kind: "category" });
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
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_PREFS,
|
||||
DEFAULT_AI_DESCRIPTION_PREFS,
|
||||
setAiCategorizationPrefs,
|
||||
setAiDescriptionPrefs,
|
||||
type AiDescriptionPrefs,
|
||||
} from "../src/services/settingsService";
|
||||
import { createAiConnection } from "../src/services/aiConnectionService";
|
||||
import { setAiRetryDelayForTest } from "../src/services/aiProviderClient";
|
||||
|
||||
const app = createApp();
|
||||
const stamp = Date.now();
|
||||
let adminToken: string;
|
||||
let adminId: string;
|
||||
let userNumber = 0;
|
||||
const createdUsers: string[] = [];
|
||||
let provider: http.Server | null = null;
|
||||
|
||||
type Reply = { changed: boolean; description: string } | { status: number; text?: string };
|
||||
type Handler = (prompt: string) => Reply | Promise<Reply>;
|
||||
const prompts: string[] = [];
|
||||
// How the mock answers a streamed request: in pieces this far apart, or in one piece as if it ignored `stream`.
|
||||
const streaming = { gapMs: 0, ignore: false };
|
||||
|
||||
function auth(token: string) {
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
async function startProvider(handler: Handler): Promise<string> {
|
||||
prompts.length = 0;
|
||||
await new Promise<void>((resolve) => {
|
||||
provider = http.createServer(async (req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString("utf8")) as {
|
||||
messages: { content: { type: string; text?: string }[] }[];
|
||||
stream?: boolean;
|
||||
};
|
||||
const prompt = body.messages[0].content.find((part) => part.type === "text")?.text ?? "";
|
||||
prompts.push(prompt);
|
||||
const reply = await handler(prompt);
|
||||
if ("status" in reply) {
|
||||
res.writeHead(reply.status, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ error: reply.text ?? "failed" }));
|
||||
return;
|
||||
}
|
||||
const text = JSON.stringify(reply);
|
||||
if (!body.stream || streaming.ignore) {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ choices: [{ message: { content: text }, finish_reason: "stop" }] }));
|
||||
return;
|
||||
}
|
||||
// Server-sent events, as OpenAI-style providers stream: the answer in three pieces, then the finish.
|
||||
res.writeHead(200, { "content-type": "text/event-stream" });
|
||||
const third = Math.ceil(text.length / 3);
|
||||
for (let start = 0; start < text.length; start += third) {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ choices: [{ delta: { content: text.slice(start, start + third) } }] })}\n\n`,
|
||||
);
|
||||
if (streaming.gapMs) await new Promise((wait) => setTimeout(wait, streaming.gapMs));
|
||||
}
|
||||
res.write(`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: "stop" }] })}\n\n`);
|
||||
res.end("data: [DONE]\n\n");
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
/** One model, the default, and the feature on with it. */
|
||||
async function enable(baseUrl: string, patch: Partial<AiDescriptionPrefs> = {}) {
|
||||
await prisma.aiConnection.deleteMany();
|
||||
await createAiConnection({ baseUrl, apiKey: null, model: "local", timeoutMs: 2000, concurrency: 1, thinking: true });
|
||||
await setAiDescriptionPrefs({ ...DEFAULT_AI_DESCRIPTION_PREFS, enabled: true, onImport: false, ...patch });
|
||||
}
|
||||
|
||||
async function newUser() {
|
||||
const email = `ai-descriptions-${stamp}-${userNumber++}@example.com`;
|
||||
const registered = await request(app)
|
||||
.post("/api/register")
|
||||
.send({ displayName: `AI descriptions ${userNumber}`, email, password: "password123" });
|
||||
createdUsers.push(registered.body.user.id);
|
||||
return { id: registered.body.user.id as string, token: registered.body.token as string };
|
||||
}
|
||||
|
||||
async function printFor(ownerId: string, notes: string | null, name = `Model ${stamp}-${userNumber}-${Math.random()}`) {
|
||||
return prisma.print.create({ data: { userId: ownerId, name, nameNormalized: name.toLowerCase(), notes } });
|
||||
}
|
||||
|
||||
/** The description suggested for a model, if any. */
|
||||
async function suggestedFor(id: string): Promise<string | undefined> {
|
||||
const suggestion = (await prisma.print.findUnique({ where: { id } }))?.aiDescriptionSuggestion as {
|
||||
notes?: string;
|
||||
} | null;
|
||||
return suggestion?.notes;
|
||||
}
|
||||
|
||||
async function runAll(token: string) {
|
||||
const started = await request(app).post("/api/ai-descriptions/run").set(auth(token)).send({});
|
||||
expect(started.status).toBe(202);
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const state = await request(app).get("/api/ai-descriptions/run").set(auth(token));
|
||||
if (!state.body.run?.running) return state.body.run;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
throw new Error("run did not finish");
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const email = `ai-descriptions-${stamp}@example.com`;
|
||||
const registered = await request(app)
|
||||
.post("/api/register")
|
||||
.send({ displayName: "AI descriptions", email, password: "password123" });
|
||||
adminId = registered.body.user.id;
|
||||
await prisma.user.update({ where: { id: adminId }, data: { role: "ADMIN" } });
|
||||
adminToken = (await request(app).post("/api/login").send({ email, password: "password123" })).body.token;
|
||||
setAiRetryDelayForTest(async () => undefined);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
streaming.gapMs = 0;
|
||||
streaming.ignore = false;
|
||||
await stopProvider();
|
||||
await prisma.aiConnection.deleteMany();
|
||||
await setAiDescriptionPrefs(DEFAULT_AI_DESCRIPTION_PREFS);
|
||||
await setAiCategorizationPrefs(DEFAULT_AI_CATEGORIZATION_PREFS);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
setAiRetryDelayForTest();
|
||||
await prisma.print.deleteMany({ where: { userId: { in: [adminId, ...createdUsers] } } });
|
||||
await prisma.user.deleteMany({ where: { id: { in: [adminId, ...createdUsers] } } });
|
||||
});
|
||||
|
||||
describe("Improved descriptions", () => {
|
||||
it("asks only for the changes that are ticked", async () => {
|
||||
const baseUrl = await startProvider(() => ({ changed: false, description: "" }));
|
||||
await enable(baseUrl, {
|
||||
actions: { ...DEFAULT_AI_DESCRIPTION_PREFS.actions, removeSelfPromotion: false, translate: false },
|
||||
});
|
||||
const owner = await newUser();
|
||||
await printFor(owner.id, "Hallo, bitte folgt mir auf Patreon.");
|
||||
await runAll(owner.token);
|
||||
expect(prompts).toHaveLength(1);
|
||||
expect(prompts[0]).toContain("requests for likes, boosts");
|
||||
expect(prompts[0]).toContain("spelling");
|
||||
expect(prompts[0]).not.toContain("self-promotion");
|
||||
expect(prompts[0]).not.toContain("translate");
|
||||
expect(prompts[0]).toContain("Hallo, bitte folgt mir auf Patreon.");
|
||||
// Whatever is ticked, print instructions, images and steps are named as things to keep.
|
||||
expect(prompts[0]).toContain("Print instructions: printer and slicer settings");
|
||||
expect(prompts[0]).toContain("an HTML <img> tag");
|
||||
expect(prompts[0]).toContain("Instructions and steps for assembling");
|
||||
});
|
||||
|
||||
it("suggests a rewrite, applies it on accept, and reverts to the original", async () => {
|
||||
const baseUrl = await startProvider(() => ({ changed: true, description: "A tidy clip for 10 mm cables." }));
|
||||
await enable(baseUrl);
|
||||
const owner = await newUser();
|
||||
const model = await printFor(owner.id, "A tidy clip for 10mm cables. Follow me for more!!");
|
||||
|
||||
const run = await runAll(owner.token);
|
||||
expect(run).toMatchObject({ total: 1, done: 1, suggested: 1, failed: 0 });
|
||||
const listed = await request(app).get("/api/ai-descriptions/suggestions").set(auth(owner.token));
|
||||
expect(listed.body).toMatchObject({
|
||||
total: 1,
|
||||
items: [
|
||||
{
|
||||
id: model.id,
|
||||
original: "A tidy clip for 10mm cables. Follow me for more!!",
|
||||
suggested: "A tidy clip for 10 mm cables.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await request(app).post(`/api/print/${model.id}/ai-description/accept`).set(auth(owner.token));
|
||||
expect((await prisma.print.findUnique({ where: { id: model.id } }))?.notes).toBe("A tidy clip for 10 mm cables.");
|
||||
const applied = await request(app).get("/api/ai-descriptions/applied").set(auth(owner.token));
|
||||
expect(applied.body.items).toMatchObject([
|
||||
{
|
||||
id: model.id,
|
||||
original: "A tidy clip for 10mm cables. Follow me for more!!",
|
||||
current: "A tidy clip for 10 mm cables.",
|
||||
},
|
||||
]);
|
||||
|
||||
await request(app).post(`/api/print/${model.id}/ai-description/revert`).set(auth(owner.token));
|
||||
expect((await prisma.print.findUnique({ where: { id: model.id } }))?.notes).toBe(
|
||||
"A tidy clip for 10mm cables. Follow me for more!!",
|
||||
);
|
||||
// A reverted description isn't rewritten again by the next run.
|
||||
await runAll(owner.token);
|
||||
expect(prompts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips a rejected model on later runs until the options change", async () => {
|
||||
const baseUrl = await startProvider(() => ({ changed: true, description: "Better." }));
|
||||
await enable(baseUrl);
|
||||
const owner = await newUser();
|
||||
const model = await printFor(owner.id, "Worse.");
|
||||
await runAll(owner.token);
|
||||
await request(app).post(`/api/print/${model.id}/ai-description/reject`).set(auth(owner.token));
|
||||
|
||||
const again = await runAll(owner.token);
|
||||
expect(again).toMatchObject({ skipped: 1, suggested: 0 });
|
||||
expect(prompts).toHaveLength(1);
|
||||
|
||||
await setAiDescriptionPrefs({ actions: { improveFormatting: false } });
|
||||
const changed = await runAll(owner.token);
|
||||
expect(changed).toMatchObject({ suggested: 1 });
|
||||
expect(prompts).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("waits on a streamed answer as long as pieces keep coming, past the model's timeout", async () => {
|
||||
const baseUrl = await startProvider(() => ({ changed: true, description: "Slowly rewritten." }));
|
||||
await enable(baseUrl);
|
||||
await prisma.aiConnection.updateMany({ data: { timeoutMs: 100 } });
|
||||
streaming.gapMs = 150;
|
||||
const owner = await newUser();
|
||||
await printFor(owner.id, "Slow.");
|
||||
// Three pieces 150 ms apart take well over the 100 ms timeout, yet each one keeps the wait going.
|
||||
expect(await runAll(owner.token)).toMatchObject({ suggested: 1, failed: 0 });
|
||||
});
|
||||
|
||||
it("reads an answer in one piece from a provider that ignores streaming", async () => {
|
||||
const baseUrl = await startProvider(() => ({ changed: true, description: "One piece." }));
|
||||
await enable(baseUrl);
|
||||
streaming.ignore = true;
|
||||
const owner = await newUser();
|
||||
await printFor(owner.id, "Two pieces.");
|
||||
expect(await runAll(owner.token)).toMatchObject({ suggested: 1 });
|
||||
});
|
||||
|
||||
it("won't be switched off while a run is going", async () => {
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const baseUrl = await startProvider(async () => {
|
||||
await held;
|
||||
return { changed: false, description: "" };
|
||||
});
|
||||
await enable(baseUrl);
|
||||
const owner = await newUser();
|
||||
await printFor(owner.id, "Busy.");
|
||||
expect((await request(app).post("/api/ai-descriptions/run").set(auth(owner.token)).send({})).status).toBe(202);
|
||||
|
||||
const refused = await request(app)
|
||||
.patch("/api/settings/ai-descriptions")
|
||||
.set(auth(adminToken))
|
||||
.send({ enabled: false });
|
||||
expect(refused.status).toBe(409);
|
||||
expect(refused.body.error ?? refused.body.detail).toContain("Stop the run");
|
||||
release();
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const state = await request(app).get("/api/ai-descriptions/run").set(auth(owner.token));
|
||||
if (!state.body.run?.running) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
const allowed = await request(app)
|
||||
.patch("/api/settings/ai-descriptions")
|
||||
.set(auth(adminToken))
|
||||
.send({ enabled: false });
|
||||
expect(allowed.status).toBe(200);
|
||||
});
|
||||
|
||||
it("drops the description markers a model copies back into its answer", async () => {
|
||||
const replies = [
|
||||
"<<<\nNo more typos here.\n>>>",
|
||||
"--- DESCRIPTION START ---\nClean text.\n--- DESCRIPTION END ---",
|
||||
"<<<\nSame text.",
|
||||
];
|
||||
const baseUrl = await startProvider(() => ({ changed: true, description: replies.shift()! }));
|
||||
await enable(baseUrl);
|
||||
const owner = await newUser();
|
||||
const first = await printFor(owner.id, "No mroe typos here.");
|
||||
const second = await printFor(owner.id, "Clean txet.");
|
||||
const third = await printFor(owner.id, "Same text.");
|
||||
const run = await runAll(owner.token);
|
||||
expect(prompts[0]).toContain("--- DESCRIPTION START ---\nNo mroe typos here.\n--- DESCRIPTION END ---");
|
||||
expect(prompts[0]).not.toContain("<<<");
|
||||
expect(await suggestedFor(first.id)).toBe("No more typos here.");
|
||||
expect(await suggestedFor(second.id)).toBe("Clean text.");
|
||||
// Once the marker is gone it's the description it was, so nothing is suggested.
|
||||
expect(await suggestedFor(third.id)).toBeUndefined();
|
||||
expect(run).toMatchObject({ suggested: 2, unchanged: 1 });
|
||||
});
|
||||
|
||||
it("lets any owner improve one model in the background, told by a notification", async () => {
|
||||
const baseUrl = await startProvider(() => ({ changed: true, description: "A neat cable clip." }));
|
||||
await enable(baseUrl);
|
||||
const owner = await newUser();
|
||||
const model = await printFor(owner.id, "A neat cabel clip.", "Cable clip");
|
||||
// Already checked with nothing new: a run would skip it, but asking about it does not.
|
||||
await prisma.print.update({ where: { id: model.id }, data: { aiDescriptionCheckedHash: "x" } });
|
||||
|
||||
const queued = await request(app).post(`/api/print/${model.id}/ai-description/improve`).set(auth(owner.token));
|
||||
expect(queued.status).toBe(202);
|
||||
const notified = async () =>
|
||||
prisma.notification.findMany({ where: { userId: owner.id, type: "ai" }, orderBy: { createdAt: "asc" } });
|
||||
for (let i = 0; i < 200 && !(await notified()).length; i++) await new Promise((wait) => setTimeout(wait, 20));
|
||||
expect((await notified())[0]).toMatchObject({
|
||||
title: 'AI suggests a better description for "Cable clip"',
|
||||
internalPath: `/models/${model.id}`,
|
||||
});
|
||||
|
||||
// Another one waits until this one is accepted or rejected, and every model says it's pending.
|
||||
const again = await request(app).post(`/api/print/${model.id}/ai-description/improve`).set(auth(owner.token));
|
||||
expect(again.status).toBe(409);
|
||||
expect(again.body.code).toBe("suggestion_pending");
|
||||
expect((await request(app).get(`/api/print/${model.id}`).set(auth(owner.token))).body.ai_description_pending).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// The owner reviews it on the model page; they can't open the admin's Manage.
|
||||
const detail = await request(app).get(`/api/print/${model.id}`).set(auth(owner.token));
|
||||
expect(detail.body).toMatchObject({
|
||||
ai_description_suggestion: { notes: "A neat cable clip." },
|
||||
ai_description_original: null,
|
||||
});
|
||||
await request(app).post(`/api/print/${model.id}/ai-description/accept`).set(auth(owner.token));
|
||||
const accepted = await request(app).get(`/api/print/${model.id}`).set(auth(owner.token));
|
||||
expect(accepted.body).toMatchObject({
|
||||
notes: "A neat cable clip.",
|
||||
ai_description_suggestion: null,
|
||||
ai_description_original: "A neat cabel clip.",
|
||||
description_by_ai: true,
|
||||
});
|
||||
// Still said with the feature off, though the original to compare with isn't sent then.
|
||||
await setAiDescriptionPrefs({ enabled: false });
|
||||
expect((await request(app).get(`/api/print/${model.id}`).set(auth(owner.token))).body).toMatchObject({
|
||||
description_by_ai: true,
|
||||
ai_description_original: null,
|
||||
});
|
||||
await setAiDescriptionPrefs({ enabled: true });
|
||||
await request(app).post(`/api/print/${model.id}/meta`).set(auth(owner.token)).send({ notes: "Mine again." });
|
||||
expect((await request(app).get(`/api/print/${model.id}`).set(auth(owner.token))).body.description_by_ai).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("says why it couldn't improve one, and refuses while the feature is off", async () => {
|
||||
const baseUrl = await startProvider(() => ({ changed: true, description: "Never asked." }));
|
||||
await enable(baseUrl);
|
||||
const owner = await newUser();
|
||||
const model = await printFor(owner.id, null, "Blank model");
|
||||
await request(app).post(`/api/print/${model.id}/ai-description/improve`).set(auth(owner.token));
|
||||
let notification = null;
|
||||
for (let i = 0; i < 200 && !notification; i++) {
|
||||
notification = await prisma.notification.findFirst({ where: { userId: owner.id, type: "ai" } });
|
||||
if (!notification) await new Promise((wait) => setTimeout(wait, 20));
|
||||
}
|
||||
expect(notification).toMatchObject({
|
||||
title: 'Couldn\'t improve the description of "Blank model"',
|
||||
body: expect.stringContaining("needs the preview image"),
|
||||
});
|
||||
expect(prompts).toHaveLength(0);
|
||||
|
||||
await setAiDescriptionPrefs({ enabled: false });
|
||||
const refused = await request(app).post(`/api/print/${model.id}/ai-description/improve`).set(auth(owner.token));
|
||||
expect(refused.status).toBe(503);
|
||||
expect((await request(app).get(`/api/print/${model.id}`).set(auth(owner.token))).body).toMatchObject({
|
||||
ai_description_suggestion: null,
|
||||
ai_description_original: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies straight away in auto mode and keeps the original", async () => {
|
||||
const baseUrl = await startProvider(() => ({ changed: true, description: "In English now." }));
|
||||
await enable(baseUrl, { mode: "auto" });
|
||||
const owner = await newUser();
|
||||
const model = await printFor(owner.id, "Jetzt auf Deutsch.");
|
||||
expect(await runAll(owner.token)).toMatchObject({ applied: 1 });
|
||||
const saved = await prisma.print.findUnique({ where: { id: model.id } });
|
||||
expect(saved).toMatchObject({ notes: "In English now.", aiDescriptionOriginal: "Jetzt auf Deutsch." });
|
||||
});
|
||||
|
||||
it("doesn't offer a rewrite that drops an image", async () => {
|
||||
const baseUrl = await startProvider(() => ({ changed: true, description: "Just text." }));
|
||||
await enable(baseUrl);
|
||||
const owner = await newUser();
|
||||
const model = await printFor(owner.id, "Look:  Nice.");
|
||||
const run = await runAll(owner.token);
|
||||
expect(run).toMatchObject({ failed: 1, suggested: 0 });
|
||||
expect(run.last_error).toContain("dropped an image");
|
||||
expect((await prisma.print.findUnique({ where: { id: model.id } }))?.aiDescriptionSuggestion).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves models without a description alone unless it may write one from the image", async () => {
|
||||
const baseUrl = await startProvider(() => ({ changed: true, description: "Made up." }));
|
||||
await enable(baseUrl, { actions: { ...DEFAULT_AI_DESCRIPTION_PREFS.actions, generateMissing: true } });
|
||||
const owner = await newUser();
|
||||
await printFor(owner.id, null);
|
||||
// Generating needs the image, which isn't sent.
|
||||
expect(await runAll(owner.token)).toMatchObject({ skipped: 1 });
|
||||
expect(prompts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("drops a pending suggestion and the kept original when the description is edited by hand", async () => {
|
||||
const baseUrl = await startProvider(() => ({ changed: true, description: "Rewritten." }));
|
||||
await enable(baseUrl);
|
||||
const owner = await newUser();
|
||||
const model = await printFor(owner.id, "Original.");
|
||||
await runAll(owner.token);
|
||||
const edited = await request(app)
|
||||
.post(`/api/print/${model.id}/meta`)
|
||||
.set(auth(owner.token))
|
||||
.send({ notes: "My own words." });
|
||||
expect(edited.status).toBe(200);
|
||||
const saved = await prisma.print.findUnique({ where: { id: model.id } });
|
||||
expect(saved).toMatchObject({ notes: "My own words.", aiDescriptionSuggestion: null, aiDescriptionOriginal: null });
|
||||
});
|
||||
|
||||
it("reports off, refuses runs, and switches off when the last model is deleted", async () => {
|
||||
const baseUrl = await startProvider(() => ({ changed: false, description: "" }));
|
||||
await enable(baseUrl);
|
||||
const owner = await newUser();
|
||||
expect((await request(app).get("/api/ai-descriptions/status").set(auth(owner.token))).body.enabled).toBe(true);
|
||||
|
||||
const settings = await request(app).get("/api/settings/ai-descriptions").set(auth(adminToken));
|
||||
expect(settings.body).toMatchObject({ enabled: true, mode: "suggest", actions: { generate_missing: false } });
|
||||
const connections = await request(app).get("/api/settings/ai-connections").set(auth(adminToken));
|
||||
await request(app).delete(`/api/settings/ai-connections/${connections.body[0].id}`).set(auth(adminToken));
|
||||
|
||||
expect((await request(app).get("/api/settings/ai-descriptions").set(auth(adminToken))).body.enabled).toBe(false);
|
||||
expect((await request(app).get("/api/ai-descriptions/status").set(auth(owner.token))).body.enabled).toBe(false);
|
||||
const refused = await request(app).post("/api/ai-descriptions/run").set(auth(owner.token)).send({});
|
||||
expect(refused.status).toBe(503);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { authHeaders } from "../utils/auth";
|
||||
import { apiBase, readErrorMessage, UnauthorizedError } from "./client";
|
||||
import { invalidateAiCategorizationStatus } from "./aiCategorization";
|
||||
import { invalidateAiDescriptionStatus } from "./aiDescriptions";
|
||||
|
||||
export type AiConnection = {
|
||||
id: string;
|
||||
@@ -38,12 +39,13 @@ async function request<T>(path: string, method = "GET", payload?: unknown): Prom
|
||||
}
|
||||
|
||||
/** The instance's AIs. Adding, deleting or changing the default can turn AI features on or off, so the cached
|
||||
* status is dropped after each. */
|
||||
* statuses are dropped after each. */
|
||||
export const aiConnectionsApi = {
|
||||
list: (): Promise<AiConnection[]> => request("/settings/ai-connections"),
|
||||
create: async (input: AiConnectionInput): Promise<AiConnection> => {
|
||||
const created = await request<AiConnection>("/settings/ai-connections", "POST", input);
|
||||
invalidateAiCategorizationStatus();
|
||||
invalidateAiDescriptionStatus();
|
||||
return created;
|
||||
},
|
||||
update: (id: string, input: Partial<AiConnectionInput>): Promise<AiConnection> =>
|
||||
@@ -51,10 +53,12 @@ export const aiConnectionsApi = {
|
||||
remove: async (id: string): Promise<void> => {
|
||||
await request<void>(`/settings/ai-connections/${encodeURIComponent(id)}`, "DELETE");
|
||||
invalidateAiCategorizationStatus();
|
||||
invalidateAiDescriptionStatus();
|
||||
},
|
||||
makeDefault: async (id: string): Promise<AiConnection> => {
|
||||
const made = await request<AiConnection>(`/settings/ai-connections/${encodeURIComponent(id)}/default`, "POST");
|
||||
invalidateAiCategorizationStatus();
|
||||
invalidateAiDescriptionStatus();
|
||||
return made;
|
||||
},
|
||||
/** `id` fills in what the form leaves out from that saved AI, such as its key. */
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { authHeaders } from "../utils/auth";
|
||||
import { apiBase, readErrorMessage, UnauthorizedError } from "./client";
|
||||
|
||||
export type AiDescriptionActions = {
|
||||
remove_self_promotion: boolean;
|
||||
remove_unrelated: boolean;
|
||||
translate: boolean;
|
||||
/** Only applies while the first preview image is sent. */
|
||||
generate_missing: boolean;
|
||||
fix_spelling: boolean;
|
||||
improve_formatting: boolean;
|
||||
};
|
||||
|
||||
export type AiDescriptionSettings = {
|
||||
enabled: boolean;
|
||||
/** Auto applies a rewrite at once; the original is kept either way, so it can be reverted. */
|
||||
mode: "suggest" | "auto";
|
||||
on_import: boolean;
|
||||
send_image: boolean;
|
||||
/** Null uses the default AI. */
|
||||
ai_connection_id: string | null;
|
||||
actions: AiDescriptionActions;
|
||||
};
|
||||
|
||||
export type AiDescriptionSettingsInput = Partial<Omit<AiDescriptionSettings, "actions">> & {
|
||||
actions?: Partial<AiDescriptionActions>;
|
||||
};
|
||||
|
||||
export type AiDescriptionSuggestion = {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string | null;
|
||||
original: string;
|
||||
suggested: string;
|
||||
model: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type AiDescriptionApplied = {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string | null;
|
||||
original: string;
|
||||
current: string;
|
||||
applied_at: string | null;
|
||||
};
|
||||
|
||||
export type AiDescriptionRun = {
|
||||
running: boolean;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
total: number;
|
||||
done: number;
|
||||
suggested: number;
|
||||
applied: number;
|
||||
unchanged: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
cancelled: boolean;
|
||||
last_error: string | null;
|
||||
};
|
||||
|
||||
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 message = `Request failed (${res.status})`;
|
||||
try {
|
||||
const body: unknown = await res.clone().json();
|
||||
if (body && typeof body === "object" && typeof (body as { error?: unknown }).error === "string") {
|
||||
message = (body as { error: string }).error;
|
||||
} else {
|
||||
message = await readErrorMessage(res, message);
|
||||
}
|
||||
} catch {
|
||||
// Not JSON: the status says enough.
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
const page = (limit: number, offset: number) => `limit=${limit}&offset=${offset}`;
|
||||
|
||||
let statusRequest: Promise<{ enabled: boolean }> | null = null;
|
||||
|
||||
export function invalidateAiDescriptionStatus(): void {
|
||||
statusRequest = null;
|
||||
}
|
||||
|
||||
/** Fetched once per session, as every model card's menu asks; changing the settings drops it. */
|
||||
function cachedStatus(): Promise<{ enabled: boolean }> {
|
||||
statusRequest ??= request<{ enabled: boolean }>("/ai-descriptions/status").catch((error: unknown) => {
|
||||
statusRequest = null;
|
||||
throw error;
|
||||
});
|
||||
return statusRequest;
|
||||
}
|
||||
|
||||
export const aiDescriptionsApi = {
|
||||
getSettings: (): Promise<AiDescriptionSettings> => request("/settings/ai-descriptions"),
|
||||
updateSettings: async (input: AiDescriptionSettingsInput): Promise<AiDescriptionSettings> => {
|
||||
const saved = await request<AiDescriptionSettings>("/settings/ai-descriptions", "PATCH", input);
|
||||
statusRequest = null;
|
||||
return saved;
|
||||
},
|
||||
/** Queues one model; the outcome arrives as a notification. */
|
||||
improve: (printId: string): Promise<{ queued: true }> =>
|
||||
request(`/print/${encodeURIComponent(printId)}/ai-description/improve`, "POST", {}),
|
||||
suggestions: (limit = 50, offset = 0): Promise<{ total: number; items: AiDescriptionSuggestion[] }> =>
|
||||
request(`/ai-descriptions/suggestions?${page(limit, offset)}`),
|
||||
applied: (limit = 50, offset = 0): Promise<{ total: number; items: AiDescriptionApplied[] }> =>
|
||||
request(`/ai-descriptions/applied?${page(limit, offset)}`),
|
||||
accept: (printId: string): Promise<{ ok: true }> =>
|
||||
request(`/print/${encodeURIComponent(printId)}/ai-description/accept`, "POST"),
|
||||
reject: (printId: string): Promise<{ ok: true }> =>
|
||||
request(`/print/${encodeURIComponent(printId)}/ai-description/reject`, "POST"),
|
||||
revert: (printId: string): Promise<{ ok: true }> =>
|
||||
request(`/print/${encodeURIComponent(printId)}/ai-description/revert`, "POST"),
|
||||
bulk: (action: "accept" | "reject", printIds: string[]): Promise<{ done: number; failed: number }> =>
|
||||
request("/ai-descriptions/suggestions/bulk", "POST", { action, print_ids: printIds }),
|
||||
runState: (): Promise<{ run: AiDescriptionRun | null }> => request("/ai-descriptions/run"),
|
||||
startRun: (): Promise<{ run: AiDescriptionRun }> => request("/ai-descriptions/run", "POST", {}),
|
||||
cancelRun: (): Promise<{ run: AiDescriptionRun | null }> => request("/ai-descriptions/run/cancel", "POST", {}),
|
||||
status: cachedStatus,
|
||||
};
|
||||
@@ -64,6 +64,14 @@ export type Print = {
|
||||
category_name?: string | null;
|
||||
category_source?: "manual" | "rule" | "ai" | "legacy" | null;
|
||||
category_kind?: "category" | "folder" | null;
|
||||
/** Improved descriptions, on the detail page while the feature is on: a rewrite to review, and the description
|
||||
* from before one was applied. */
|
||||
ai_description_suggestion?: { notes: string; created_at: string } | null;
|
||||
ai_description_original?: string | null;
|
||||
/** The description is an AI rewrite, not since edited or reverted; sent whether or not the feature is on. */
|
||||
description_by_ai?: boolean;
|
||||
/** A suggested description awaits review; on every model, unlike the suggestion itself. */
|
||||
ai_description_pending?: boolean;
|
||||
ai_suggestion?: {
|
||||
category_id: string;
|
||||
category_path: string;
|
||||
|
||||
@@ -1,59 +1,64 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type React from "react";
|
||||
import { aiCategorizationApi, type AiRun, type AiRunScope } from "../../api/aiCategorization";
|
||||
import { aiDescriptionsApi, type AiDescriptionRun } from "../../api/aiDescriptions";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
|
||||
type AiRunContextValue = {
|
||||
/** The latest run, finished or not; null before the first one. */
|
||||
run: AiRun | null;
|
||||
/** One library run of an AI feature: the latest one, finished or not, and how to start or stop it. */
|
||||
export type TrackedRun<R, S> = {
|
||||
/** Null before the first run. */
|
||||
run: R | null;
|
||||
running: boolean;
|
||||
startRun: (scope: AiRunScope) => Promise<void>;
|
||||
startRun: (scope: S) => Promise<void>;
|
||||
cancelRun: () => Promise<void>;
|
||||
};
|
||||
|
||||
type AiRunContextValue = {
|
||||
categorization: TrackedRun<AiRun, AiRunScope>;
|
||||
descriptions: TrackedRun<AiDescriptionRun, void>;
|
||||
};
|
||||
|
||||
const AiRunContext = createContext<AiRunContextValue | null>(null);
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
|
||||
/** A library AI categorization run, followed app-wide so its progress bar outlives the dialog that started it. */
|
||||
export function AiRunProvider({
|
||||
onUnauthorized,
|
||||
onRunFinished,
|
||||
children,
|
||||
}: {
|
||||
onUnauthorized?: () => void;
|
||||
/** Models may have changed category, and the run's summary notification has arrived. */
|
||||
onRunFinished?: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [run, setRun] = useState<AiRun | null>(null);
|
||||
type RunApi<R, S> = {
|
||||
/** Whether the feature is on; there's no run to pick up when it isn't. */
|
||||
enabled: () => Promise<boolean>;
|
||||
state: () => Promise<R | null>;
|
||||
start: (scope: S) => Promise<R>;
|
||||
cancel: () => Promise<R | null>;
|
||||
};
|
||||
|
||||
/** Picks up a run already going after a reload, follows it while it runs, and says when it ends. */
|
||||
function useTrackedRun<R extends { running: boolean }, S>(
|
||||
api: RunApi<R, S>,
|
||||
onUnauthorized: (() => void) | undefined,
|
||||
onFinished: React.RefObject<(() => void) | undefined>,
|
||||
): TrackedRun<R, S> {
|
||||
const [run, setRun] = useState<R | null>(null);
|
||||
const running = Boolean(run?.running);
|
||||
const onRunFinishedRef = useRef(onRunFinished);
|
||||
useEffect(() => {
|
||||
onRunFinishedRef.current = onRunFinished;
|
||||
}, [onRunFinished]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setRun((await aiCategorizationApi.runState()).run);
|
||||
setRun(await api.state());
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
}
|
||||
}, [onUnauthorized]);
|
||||
}, [api, onUnauthorized]);
|
||||
|
||||
// Picks up a run already going after a reload; with AI off there can't be one.
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void aiCategorizationApi
|
||||
.status()
|
||||
.then((status) => {
|
||||
if (active && status.enabled) void refresh();
|
||||
void api
|
||||
.enabled()
|
||||
.then((enabled) => {
|
||||
if (active && enabled) void refresh();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [refresh]);
|
||||
}, [api, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
@@ -63,23 +68,63 @@ export function AiRunProvider({
|
||||
|
||||
const wasRunning = useRef(false);
|
||||
useEffect(() => {
|
||||
if (wasRunning.current && !running) onRunFinishedRef.current?.();
|
||||
if (wasRunning.current && !running) onFinished.current?.();
|
||||
wasRunning.current = running;
|
||||
}, [running]);
|
||||
}, [running, onFinished]);
|
||||
|
||||
const startRun = useCallback(async (scope: AiRunScope) => {
|
||||
setRun((await aiCategorizationApi.startRun(scope)).run);
|
||||
}, []);
|
||||
const cancelRun = useCallback(async () => {
|
||||
setRun((await aiCategorizationApi.cancelRun()).run);
|
||||
}, []);
|
||||
const startRun = useCallback(async (scope: S) => setRun(await api.start(scope)), [api]);
|
||||
const cancelRun = useCallback(async () => setRun(await api.cancel()), [api]);
|
||||
return useMemo(() => ({ run, running, startRun, cancelRun }), [run, running, startRun, cancelRun]);
|
||||
}
|
||||
|
||||
const value = useMemo(() => ({ run, running, startRun, cancelRun }), [run, running, startRun, cancelRun]);
|
||||
// Module-level, so the hooks above see the same object on every render.
|
||||
const categorizationRuns: RunApi<AiRun, AiRunScope> = {
|
||||
enabled: async () => (await aiCategorizationApi.status()).enabled,
|
||||
state: async () => (await aiCategorizationApi.runState()).run,
|
||||
start: async (scope) => (await aiCategorizationApi.startRun(scope)).run,
|
||||
cancel: async () => (await aiCategorizationApi.cancelRun()).run,
|
||||
};
|
||||
const descriptionRuns: RunApi<AiDescriptionRun, void> = {
|
||||
enabled: async () => (await aiDescriptionsApi.status()).enabled,
|
||||
state: async () => (await aiDescriptionsApi.runState()).run,
|
||||
start: async () => (await aiDescriptionsApi.startRun()).run,
|
||||
cancel: async () => (await aiDescriptionsApi.cancelRun()).run,
|
||||
};
|
||||
|
||||
/** The library runs of the AI features, followed app-wide so their progress bars outlive the dialog that started
|
||||
* them. */
|
||||
export function AiRunProvider({
|
||||
onUnauthorized,
|
||||
onRunFinished,
|
||||
children,
|
||||
}: {
|
||||
onUnauthorized?: () => void;
|
||||
/** Models may have changed, and the run's summary notification has arrived. */
|
||||
onRunFinished?: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const onRunFinishedRef = useRef(onRunFinished);
|
||||
useEffect(() => {
|
||||
onRunFinishedRef.current = onRunFinished;
|
||||
}, [onRunFinished]);
|
||||
|
||||
const categorization = useTrackedRun(categorizationRuns, onUnauthorized, onRunFinishedRef);
|
||||
const descriptions = useTrackedRun(descriptionRuns, onUnauthorized, onRunFinishedRef);
|
||||
const value = useMemo(() => ({ categorization, descriptions }), [categorization, descriptions]);
|
||||
return <AiRunContext.Provider value={value}>{children}</AiRunContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAiRun(): AiRunContextValue {
|
||||
function useAiRuns(): AiRunContextValue {
|
||||
const value = useContext(AiRunContext);
|
||||
if (!value) throw new Error("useAiRun must be used inside AiRunProvider");
|
||||
return value;
|
||||
}
|
||||
|
||||
/** The AI categorization run. */
|
||||
export function useAiRun(): TrackedRun<AiRun, AiRunScope> {
|
||||
return useAiRuns().categorization;
|
||||
}
|
||||
|
||||
export function useAiDescriptionRun(): TrackedRun<AiDescriptionRun, void> {
|
||||
return useAiRuns().descriptions;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import Box from "@mui/material/Box";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
@@ -8,41 +7,77 @@ import LinearProgress from "@mui/material/LinearProgress";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useAiRun } from "./AiRunContext";
|
||||
import CategorizationAnimation from "../../pages/AiPage/CategorizationAnimation";
|
||||
import DescriptionAnimation from "../../pages/AiPage/DescriptionAnimation";
|
||||
import { useAiDescriptionRun, useAiRun } from "./AiRunContext";
|
||||
|
||||
/** Shown while a library AI categorization runs; the outcome arrives as a notification once it's done. */
|
||||
export default function AiRunProgressBar() {
|
||||
// Long enough to read one, short enough that a run of a few minutes shows several.
|
||||
const PHRASE_MS = 3500;
|
||||
const MINI_TILE = 40;
|
||||
|
||||
/** Shown while an AI feature's library run goes; the outcome arrives as a notification once it's done. */
|
||||
export default function AiRunProgressBar({ kind }: { kind: "categorization" | "descriptions" }) {
|
||||
const { t } = useTranslation("app");
|
||||
const { run, running, cancelRun } = useAiRun();
|
||||
const categorization = useAiRun();
|
||||
const descriptions = useAiDescriptionRun();
|
||||
const { run, running, cancelRun } = kind === "categorization" ? categorization : descriptions;
|
||||
const strings = kind === "categorization" ? "aiRunProgress" : "aiRunProgress.descriptions";
|
||||
const Animation = kind === "categorization" ? CategorizationAnimation : DescriptionAnimation;
|
||||
const [stopping, setStopping] = useState(false);
|
||||
if (!run || !running) return null;
|
||||
const [phrase, setPhrase] = useState(0);
|
||||
|
||||
// A light touch on a long wait: the title changes every few seconds while the run goes.
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
const timer = window.setInterval(() => setPhrase((current) => current + 1), PHRASE_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [running]);
|
||||
|
||||
if (!run || !running) return null;
|
||||
const phrases = t(`${strings}.phrases`, { returnObjects: true }) as string[];
|
||||
const { done, total } = run;
|
||||
const title = run.cancelled
|
||||
? t("aiRunProgress.stopping")
|
||||
: total > 0
|
||||
? t(`${strings}.label`, { phrase: phrases[phrase % phrases.length], done, total })
|
||||
: t(`${strings}.starting`);
|
||||
|
||||
return (
|
||||
<Box sx={{ bgcolor: "background.paper", borderTop: "1px solid", borderColor: "divider", px: 2, py: 0.75 }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ maxWidth: 480, mx: "auto" }}>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ maxWidth: 520, mx: "auto" }}>
|
||||
{/* The feature's own tile, small: which run this is at a glance. */}
|
||||
<Box
|
||||
aria-hidden
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
width: MINI_TILE,
|
||||
height: MINI_TILE,
|
||||
borderRadius: "11px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
bgcolor: "#ffffff",
|
||||
boxShadow: "inset 0 0 0 1px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Animation size={MINI_TILE - 6} paused={run.cancelled} />
|
||||
</Box>
|
||||
<Stack spacing={0.5} sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Stack direction="row" spacing={0.75} justifyContent="center" alignItems="center">
|
||||
<AutoAwesomeIcon fontSize="small" sx={{ opacity: 0.7 }} />
|
||||
<Typography variant="caption" textAlign="center">
|
||||
{run.cancelled
|
||||
? t("aiRunProgress.stopping")
|
||||
: total > 0
|
||||
? t("aiRunProgress.label", { done, total })
|
||||
: t("aiRunProgress.starting")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="caption" noWrap sx={{ display: "block" }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant={total > 0 ? "determinate" : "indeterminate"}
|
||||
value={total > 0 ? Math.min(100, (done / total) * 100) : 0}
|
||||
sx={{ borderRadius: 1, height: 6 }}
|
||||
/>
|
||||
</Stack>
|
||||
<Tooltip title={t("aiRunProgress.stop")}>
|
||||
<Tooltip title={t(`${strings}.stop`)}>
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={t("aiRunProgress.stop")}
|
||||
aria-label={t(`${strings}.stop`)}
|
||||
disabled={stopping || run.cancelled}
|
||||
onClick={() => {
|
||||
setStopping(true);
|
||||
|
||||
@@ -201,7 +201,8 @@ function AppLayoutShell({
|
||||
</Box>
|
||||
{/* Both can run at once, so they stack here rather than each pinning itself to the bottom. */}
|
||||
<Box sx={{ position: "fixed", left: 0, right: 0, bottom: 0, zIndex: (theme) => theme.zIndex.snackbar }}>
|
||||
<AiRunProgressBar />
|
||||
<AiRunProgressBar kind="categorization" />
|
||||
<AiRunProgressBar kind="descriptions" />
|
||||
<ImportProgressBar />
|
||||
</Box>
|
||||
<BackToTopButton />
|
||||
|
||||
@@ -6,7 +6,7 @@ import Tooltip from "@mui/material/Tooltip";
|
||||
import Zoom from "@mui/material/Zoom";
|
||||
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
|
||||
import { useImportJob } from "./ImportJobContext";
|
||||
import { useAiRun } from "./AiRunContext";
|
||||
import { useAiDescriptionRun, useAiRun } from "./AiRunContext";
|
||||
|
||||
const SCROLL_SHOW_THRESHOLD = 200;
|
||||
|
||||
@@ -21,6 +21,7 @@ export default function BackToTopButton() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const { activeJob } = useImportJob();
|
||||
const { running: aiRunning } = useAiRun();
|
||||
const { running: descriptionsRunning } = useAiDescriptionRun();
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setVisible(window.scrollY > SCROLL_SHOW_THRESHOLD);
|
||||
@@ -38,7 +39,7 @@ export default function BackToTopButton() {
|
||||
sx={{
|
||||
position: "fixed",
|
||||
right: 24,
|
||||
bottom: 24 + (activeJob ? 64 : 0) + (aiRunning ? 64 : 0),
|
||||
bottom: 24 + (activeJob ? 64 : 0) + (aiRunning ? 64 : 0) + (descriptionsRunning ? 64 : 0),
|
||||
zIndex: (theme) => theme.zIndex.fab,
|
||||
transition: (theme) => theme.transitions.create("bottom"),
|
||||
}}
|
||||
|
||||
@@ -310,9 +310,38 @@
|
||||
},
|
||||
"aiRunProgress": {
|
||||
"starting": "Starting AI categorization…",
|
||||
"label": "Categorizing with AI… {{done}} of {{total}}",
|
||||
"label": "{{phrase}} {{done}} of {{total}}",
|
||||
"phrases": [
|
||||
"Thingporting models…",
|
||||
"Finding every model a home…",
|
||||
"Sorting the shelf…",
|
||||
"Labelling the drawers…",
|
||||
"Asking the AI where this one goes…",
|
||||
"Untangling the filament spool…",
|
||||
"Matching parts to their bins…",
|
||||
"Stacking models in tidy piles…",
|
||||
"Playing Tetris with your library…",
|
||||
"Telling Benchy it belongs with the boats…"
|
||||
],
|
||||
"stopping": "Stopping after the current model…",
|
||||
"stop": "Stop categorizing"
|
||||
"stop": "Stop categorizing",
|
||||
"descriptions": {
|
||||
"starting": "Starting to improve descriptions…",
|
||||
"label": "{{phrase}} {{done}} of {{total}}",
|
||||
"phrases": [
|
||||
"Thingporting descriptions…",
|
||||
"Polishing prose…",
|
||||
"Removing supports from the text…",
|
||||
"Sanding down rough paragraphs…",
|
||||
"Shooing away self-promotion…",
|
||||
"Fixing typos with a tiny hex key…",
|
||||
"Translating the untranslatable…",
|
||||
"Re-slicing run-on sentences…",
|
||||
"Levelling the bed of every paragraph…",
|
||||
"Trimming the stringing between words…"
|
||||
],
|
||||
"stop": "Stop improving descriptions"
|
||||
}
|
||||
},
|
||||
"importProgress": {
|
||||
"starting": "Starting import…",
|
||||
@@ -662,26 +691,72 @@
|
||||
"turnOff": "Turn off",
|
||||
"openSettings": "Settings",
|
||||
"settingsAria": "{{feature}} settings",
|
||||
"toggleAria": "Turn {{feature}} on or off"
|
||||
"toggleAria": "Turn {{feature}} on or off",
|
||||
"runningLock": "Running now. Stop the run from the bar at the bottom before turning it off.",
|
||||
"settings": "Settings",
|
||||
"manage": "Manage",
|
||||
"descriptions": "Improved descriptions",
|
||||
"descriptionsDescription": "Cleans up model descriptions with AI, on import or in a run: drops self-promotion, translates and tidies them, for you to review or applied at once.",
|
||||
"privacy": "With a third-party API, model titles, descriptions and tags leave this server. Images are sent only when enabled."
|
||||
},
|
||||
"aiCategorization": {
|
||||
"heading": "AI Categorization",
|
||||
"privacy": "With a third-party API, model titles, descriptions and tags leave this server. Images are sent only when enabled.",
|
||||
"useAi": "Use AI",
|
||||
"defaultAi": "Default AI ({{model}})",
|
||||
"noAi": "Add a model under Models to use categorization.",
|
||||
"noAi": "Add a model under Models to use this feature.",
|
||||
"mode": "Mode",
|
||||
"suggest": "Suggest for review",
|
||||
"auto": "Apply automatically",
|
||||
"threshold": "Auto-apply confidence threshold",
|
||||
"onImport": "Categorize new imports",
|
||||
"sendImage": "Send the first preview image",
|
||||
"settings": "Settings",
|
||||
"manage": "Manage",
|
||||
"save": "Save settings",
|
||||
"saved": "AI categorization settings saved.",
|
||||
"failed": "Failed to load or save AI categorization settings."
|
||||
},
|
||||
"aiDescriptions": {
|
||||
"failed": "Failed to load or save Improved descriptions settings.",
|
||||
"saved": "Improved descriptions settings saved.",
|
||||
"autoHint": "The original description is kept, so an applied one can be reverted from Manage.",
|
||||
"onImport": "Improve descriptions for every new import",
|
||||
"whatAiDoes": "What AI does",
|
||||
"needsImage": "(needs the preview image to be sent)",
|
||||
"actions": {
|
||||
"remove_self_promotion": "Removes self-promotion (links to author pages or other models)",
|
||||
"remove_unrelated": "Removes other unrelated info (e.g. boost requests)",
|
||||
"translate": "Translates descriptions in other languages to English",
|
||||
"generate_missing": "Tries to write a description for models without one",
|
||||
"fix_spelling": "Fixes obvious spelling mistakes",
|
||||
"improve_formatting": "Improves formatting"
|
||||
},
|
||||
"runTitle": "Improve descriptions",
|
||||
"runHint": "Checks every model's description. Models with nothing new since their last check are skipped.",
|
||||
"start": "Improve descriptions",
|
||||
"runInProgress": "Improving descriptions. Progress shows at the bottom of the page.",
|
||||
"suggestionsTitle": "Suggested descriptions ({{count}})",
|
||||
"noSuggestions": "No suggested descriptions.",
|
||||
"firstPage": "Showing {{shown}} of {{total}}.",
|
||||
"selectAll": "Select all",
|
||||
"deselectAll": "Deselect all",
|
||||
"acceptSelected": "Accept selected",
|
||||
"rejectSelected": "Reject selected",
|
||||
"compare": "Compare",
|
||||
"accept": "Accept",
|
||||
"reject": "Reject",
|
||||
"acceptDone_one": "Description updated.",
|
||||
"acceptDone_other": "{{count}} descriptions updated.",
|
||||
"rejectDone_one": "Suggestion rejected.",
|
||||
"rejectDone_other": "{{count}} suggestions rejected.",
|
||||
"appliedTitle": "Changed by AI ({{count}})",
|
||||
"revert": "Revert",
|
||||
"revertTitle": "Restore the original description?",
|
||||
"revertMessage": "{{name}} gets back the description it had before the AI changed it. Later runs leave it as it is.",
|
||||
"revertDone": "Original description restored.",
|
||||
"original": "Original",
|
||||
"suggested": "Suggested",
|
||||
"current": "Now",
|
||||
"empty": "No description."
|
||||
},
|
||||
"session": {
|
||||
"heading": "Session Length",
|
||||
"subtitle": "Control how long a signed-in session stays valid for the whole instance.",
|
||||
@@ -1698,6 +1773,7 @@
|
||||
"uploadedDirectly": "Uploaded directly to Thingport"
|
||||
},
|
||||
"aiCategorization": {
|
||||
"suggestionPending": "Accept or reject the suggestion first",
|
||||
"section": "AI Categorization",
|
||||
"recategorize": "Recategorize with AI",
|
||||
"categorize": "Categorize with AI",
|
||||
@@ -1737,6 +1813,20 @@
|
||||
"confirmManual": "{{name}} has a category you chose manually. Replace it with an AI result?",
|
||||
"failed": "AI recategorization failed."
|
||||
},
|
||||
"aiDescriptions": {
|
||||
"improve": "Improve description with AI",
|
||||
"queued": "Improving the description with AI. This can take a few minutes; you'll get a notification when it's done.",
|
||||
"failed": "Couldn't start improving the description.",
|
||||
"suggestionTitle": "AI suggests a better description",
|
||||
"suggestionHint": "Compare it with the current one, then keep it or not.",
|
||||
"chipAria": "Description by AI: compare with the original or revert",
|
||||
"compareOriginal": "Compare with the original",
|
||||
"revertOriginal": "Revert to the original",
|
||||
"accepted": "Description updated.",
|
||||
"rejected": "Suggestion rejected.",
|
||||
"revertMessage": "This model gets back the description it had before the AI changed it.",
|
||||
"suggestionPending": "Accept or reject the suggestion first"
|
||||
},
|
||||
"detail": {
|
||||
"subtitle": "Model",
|
||||
"preview3d": "3D Preview",
|
||||
|
||||
@@ -310,9 +310,38 @@
|
||||
},
|
||||
"aiRunProgress": {
|
||||
"starting": "Pradedamas DI kategorizavimas…",
|
||||
"label": "Kategorizuojama naudojant DI… {{done}} iš {{total}}",
|
||||
"label": "{{phrase}} {{done}} iš {{total}}",
|
||||
"phrases": [
|
||||
"Thingportuojami modeliai…",
|
||||
"Kiekvienam modeliui ieškomi namai…",
|
||||
"Tvarkoma lentyna…",
|
||||
"Žymimi stalčiai…",
|
||||
"Klausiama DI, kur dėti šį…",
|
||||
"Narpliojama gijos ritė…",
|
||||
"Detalės dedamos į savo dėžutes…",
|
||||
"Modeliai sukraunami į tvarkingas krūveles…",
|
||||
"Su jūsų biblioteka žaidžiamas tetris…",
|
||||
"Benchy aiškinama, kad jo vieta tarp laivų…"
|
||||
],
|
||||
"stopping": "Sustabdoma po dabartinio modelio…",
|
||||
"stop": "Stabdyti kategorizavimą"
|
||||
"stop": "Stabdyti kategorizavimą",
|
||||
"descriptions": {
|
||||
"starting": "Pradedama tobulinti aprašymus…",
|
||||
"label": "{{phrase}} {{done}} iš {{total}}",
|
||||
"phrases": [
|
||||
"Thingportuojami aprašymai…",
|
||||
"Šlifuojamas tekstas…",
|
||||
"Nuimamos atramos nuo teksto…",
|
||||
"Nušlifuojamos šiurkščios pastraipos…",
|
||||
"Vejama lauk savireklama…",
|
||||
"Klaidos taisomos mažu šešiakampiu…",
|
||||
"Verčiama tai, kas neišverčiama…",
|
||||
"Iš naujo pjaustomi per ilgi sakiniai…",
|
||||
"Lyginamas kiekvienos pastraipos pagrindas…",
|
||||
"Karpomi siūlai tarp žodžių…"
|
||||
],
|
||||
"stop": "Stabdyti aprašymų tobulinimą"
|
||||
}
|
||||
},
|
||||
"importProgress": {
|
||||
"starting": "Pradedamas importas…",
|
||||
@@ -666,26 +695,76 @@
|
||||
"turnOff": "Išjungti",
|
||||
"openSettings": "Nustatymai",
|
||||
"settingsAria": "{{feature}} nustatymai",
|
||||
"toggleAria": "Įjungti arba išjungti: {{feature}}"
|
||||
"toggleAria": "Įjungti arba išjungti: {{feature}}",
|
||||
"runningLock": "Dabar vykdoma. Prieš išjungdami sustabdykite vykdymą apatinėje juostoje.",
|
||||
"settings": "Nustatymai",
|
||||
"manage": "Valdymas",
|
||||
"descriptions": "Patobulinti aprašymai",
|
||||
"descriptionsDescription": "Naudodamas DI sutvarko modelių aprašymus importuojant arba paleidus: pašalina savireklamą, išverčia ir sutvarko juos, kad peržiūrėtumėte arba iš karto pritaiko.",
|
||||
"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į."
|
||||
},
|
||||
"aiCategorization": {
|
||||
"heading": "DI kategorizavimas",
|
||||
"privacy": "Naudojant trečiosios šalies API, modelių pavadinimai, aprašymai ir žymos perduodami už šio serverio ribų. Paveikslėliai siunčiami tik įjungus šią parinktį.",
|
||||
"useAi": "Naudoti DI",
|
||||
"defaultAi": "Numatytasis DI ({{model}})",
|
||||
"noAi": "Pridėkite modelį skiltyje „Modeliai“, kad galėtumėte naudoti kategorizavimą.",
|
||||
"noAi": "Pridėkite modelį skiltyje „Modeliai“, kad galėtumėte naudoti šią funkciją.",
|
||||
"mode": "Režimas",
|
||||
"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į",
|
||||
"settings": "Nustatymai",
|
||||
"manage": "Valdymas",
|
||||
"save": "Išsaugoti nustatymus",
|
||||
"saved": "DI kategorizavimo nustatymai išsaugoti.",
|
||||
"failed": "Nepavyko įkelti arba išsaugoti DI kategorizavimo nustatymų."
|
||||
},
|
||||
"aiDescriptions": {
|
||||
"failed": "Nepavyko įkelti arba išsaugoti patobulintų aprašymų nustatymų.",
|
||||
"saved": "Patobulintų aprašymų nustatymai išsaugoti.",
|
||||
"autoHint": "Pradinis aprašymas išsaugomas, todėl pritaikytą galima atšaukti skiltyje „Valdymas“.",
|
||||
"onImport": "Tobulinti kiekvieno naujo importo aprašymą",
|
||||
"whatAiDoes": "Ką daro DI",
|
||||
"needsImage": "(reikia siųsti peržiūros paveikslėlį)",
|
||||
"actions": {
|
||||
"remove_self_promotion": "Pašalina savireklamą (nuorodas į autoriaus puslapius ar kitus modelius)",
|
||||
"remove_unrelated": "Pašalina kitą nesusijusią informaciją (pvz., prašymus paremti)",
|
||||
"translate": "Išverčia kitų kalbų aprašymus į anglų kalbą",
|
||||
"generate_missing": "Bando parašyti aprašymą modeliams, kurie jo neturi",
|
||||
"fix_spelling": "Ištaiso akivaizdžias rašybos klaidas",
|
||||
"improve_formatting": "Pagerina formatavimą"
|
||||
},
|
||||
"runTitle": "Tobulinti aprašymus",
|
||||
"runHint": "Patikrina kiekvieno modelio aprašymą. Modeliai, kuriuose nuo paskutinės patikros niekas nepasikeitė, praleidžiami.",
|
||||
"start": "Tobulinti aprašymus",
|
||||
"runInProgress": "Aprašymai tobulinami. Eiga rodoma puslapio apačioje.",
|
||||
"suggestionsTitle": "Siūlomi aprašymai ({{count}})",
|
||||
"noSuggestions": "Siūlomų aprašymų nėra.",
|
||||
"firstPage": "Rodoma {{shown}} iš {{total}}.",
|
||||
"selectAll": "Pažymėti visus",
|
||||
"deselectAll": "Panaikinti žymėjimą",
|
||||
"acceptSelected": "Priimti pasirinktus",
|
||||
"rejectSelected": "Atmesti pasirinktus",
|
||||
"compare": "Palyginti",
|
||||
"accept": "Priimti",
|
||||
"reject": "Atmesti",
|
||||
"acceptDone_one": "Aprašymas atnaujintas.",
|
||||
"acceptDone_few": "Atnaujinti {{count}} aprašymai.",
|
||||
"acceptDone_many": "Atnaujinta {{count}} aprašymo.",
|
||||
"acceptDone_other": "Atnaujinta {{count}} aprašymų.",
|
||||
"rejectDone_one": "Pasiūlymas atmestas.",
|
||||
"rejectDone_few": "Atmesti {{count}} pasiūlymai.",
|
||||
"rejectDone_many": "Atmesta {{count}} pasiūlymo.",
|
||||
"rejectDone_other": "Atmesta {{count}} pasiūlymų.",
|
||||
"appliedTitle": "Pakeisti DI ({{count}})",
|
||||
"revert": "Atkurti",
|
||||
"revertTitle": "Atkurti pradinį aprašymą?",
|
||||
"revertMessage": "{{name}} atgaus aprašymą, kurį turėjo prieš DI pakeitimą. Vėlesni paleidimai jo nebekeis.",
|
||||
"revertDone": "Pradinis aprašymas atkurtas.",
|
||||
"original": "Pradinis",
|
||||
"suggested": "Siūlomas",
|
||||
"current": "Dabar",
|
||||
"empty": "Aprašymo nėra."
|
||||
},
|
||||
"session": {
|
||||
"heading": "Sesijos trukmė",
|
||||
"subtitle": "Valdykite, kiek laiko galioja prisijungimo sesija visai sistemai.",
|
||||
@@ -1727,6 +1806,7 @@
|
||||
"uploadedDirectly": "Įkelta tiesiai į Thingport"
|
||||
},
|
||||
"aiCategorization": {
|
||||
"suggestionPending": "Pirmiausia priimkite arba atmeskite pasiūlymą",
|
||||
"section": "DI kategorizavimas",
|
||||
"recategorize": "Kategorizuoti iš naujo naudojant DI",
|
||||
"categorize": "Kategorizuoti naudojant DI",
|
||||
@@ -1766,6 +1846,20 @@
|
||||
"confirmManual": "{{name}} turi jūsų rankiniu būdu pasirinktą kategoriją. Pakeisti ją DI rezultatu?",
|
||||
"failed": "DI kategorizuoti nepavyko."
|
||||
},
|
||||
"aiDescriptions": {
|
||||
"improve": "Patobulinti aprašymą naudojant DI",
|
||||
"queued": "Aprašymas tobulinamas naudojant DI. Tai gali užtrukti kelias minutes; kai bus baigta, gausite pranešimą.",
|
||||
"failed": "Nepavyko pradėti tobulinti aprašymo.",
|
||||
"suggestionTitle": "DI siūlo geresnį aprašymą",
|
||||
"suggestionHint": "Palyginkite jį su dabartiniu ir nuspręskite, ar jį palikti.",
|
||||
"chipAria": "Aprašymas sukurtas DI: palyginti su pradiniu arba atkurti",
|
||||
"compareOriginal": "Palyginti su pradiniu",
|
||||
"revertOriginal": "Atkurti pradinį",
|
||||
"accepted": "Aprašymas atnaujintas.",
|
||||
"rejected": "Pasiūlymas atmestas.",
|
||||
"revertMessage": "Šis modelis atgaus aprašymą, kurį turėjo prieš DI pakeitimą.",
|
||||
"suggestionPending": "Pirmiausia priimkite arba atmeskite pasiūlymą"
|
||||
},
|
||||
"detail": {
|
||||
"subtitle": "Modelis",
|
||||
"preview3d": "3D peržiūra",
|
||||
|
||||
@@ -17,31 +17,17 @@ import { UnauthorizedError } from "../../api/client";
|
||||
import { settingsApi, type AiCategorizationSettings } from "../../api/settings";
|
||||
import type { AiConnection } from "../../api/aiConnections";
|
||||
import SectionedDialog from "../../components/SectionedDialog";
|
||||
import HelpTip from "../../components/HelpTip";
|
||||
import type { FeatureSection } from "./FeatureTile";
|
||||
import AiCategorizationPanel from "../ModelsPage/AiCategorizationPanel";
|
||||
import { AiLogo, hostOf } from "./aiLogos";
|
||||
|
||||
export type AiCategorizationSection = "settings" | "manage";
|
||||
|
||||
/** An option in the "Use AI" list: the model's logo and what to call it. */
|
||||
function AiOption({ connection, label }: { connection: AiConnection | undefined; label: string }) {
|
||||
return (
|
||||
<Stack direction="row" spacing={1.25} alignItems="center">
|
||||
{connection ? (
|
||||
<AiLogo model={connection.model} baseUrl={connection.base_url} size={18} />
|
||||
) : (
|
||||
<span style={{ width: 18 }} />
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
import UseAiSelect from "./UseAiSelect";
|
||||
|
||||
type Props = {
|
||||
settings: AiCategorizationSettings;
|
||||
/** Switched on with a model to use; Manage can only be opened then, as nothing can run otherwise. */
|
||||
active: boolean;
|
||||
/** Where it opens, e.g. from the tile's menu. */
|
||||
initialSection?: AiCategorizationSection;
|
||||
initialSection?: FeatureSection;
|
||||
connections: AiConnection[];
|
||||
onClose: () => void;
|
||||
onSaved: (settings: AiCategorizationSettings) => void;
|
||||
@@ -62,7 +48,7 @@ export default function AiCategorizationDialog({
|
||||
onUnauthorized,
|
||||
}: Props) {
|
||||
const { t } = useTranslation("app");
|
||||
const [section, setSection] = useState<AiCategorizationSection>(initialSection);
|
||||
const [section, setSection] = useState<FeatureSection>(initialSection);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [draft, setDraft] = useState<Draft>({
|
||||
mode: settings.mode,
|
||||
@@ -94,14 +80,6 @@ export default function AiCategorizationDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const defaultConnection = connections.find((connection) => connection.is_default);
|
||||
const defaultOption = (
|
||||
<AiOption
|
||||
connection={defaultConnection}
|
||||
label={t("adminSettings.aiCategorization.defaultAi", { model: defaultConnection?.model ?? "—" })}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<SectionedDialog
|
||||
open
|
||||
@@ -110,12 +88,12 @@ export default function AiCategorizationDialog({
|
||||
sections={[
|
||||
{
|
||||
id: "settings" as const,
|
||||
label: t("adminSettings.aiCategorization.settings"),
|
||||
label: t("adminSettings.aiFeatures.settings"),
|
||||
icon: <TuneIcon fontSize="small" />,
|
||||
},
|
||||
{
|
||||
id: "manage" as const,
|
||||
label: t("adminSettings.aiCategorization.manage"),
|
||||
label: t("adminSettings.aiFeatures.manage"),
|
||||
icon: <AutoAwesomeOutlinedIcon fontSize="small" />,
|
||||
disabled: !active,
|
||||
},
|
||||
@@ -128,44 +106,13 @@ export default function AiCategorizationDialog({
|
||||
<AiCategorizationPanel onUnauthorized={onUnauthorized} onRunStarted={onClose} />
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
<Alert severity="info">{t("adminSettings.aiCategorization.privacy")}</Alert>
|
||||
{!connections.length && <Alert severity="warning">{t("adminSettings.aiCategorization.noAi")}</Alert>}
|
||||
<FormControl fullWidth>
|
||||
{/* Empty means the default AI, which still shows text, so the label stays up on the border. */}
|
||||
<InputLabel id="ai-connection-label" shrink>
|
||||
{t("adminSettings.aiCategorization.useAi")}
|
||||
</InputLabel>
|
||||
<Select
|
||||
labelId="ai-connection-label"
|
||||
label={t("adminSettings.aiCategorization.useAi")}
|
||||
// A saved choice that's gone (deleted meanwhile) reads as the default, as the backend treats it.
|
||||
value={
|
||||
draft.ai_connection_id && connections.some((connection) => connection.id === draft.ai_connection_id)
|
||||
? draft.ai_connection_id
|
||||
: ""
|
||||
}
|
||||
displayEmpty
|
||||
notched
|
||||
// The chosen option as it looks in the list, logo included; empty is the default AI.
|
||||
renderValue={(value) => {
|
||||
const chosen = connections.find((connection) => connection.id === value);
|
||||
return chosen ? (
|
||||
<AiOption connection={chosen} label={`${chosen.model} · ${hostOf(chosen.base_url)}`} />
|
||||
) : (
|
||||
defaultOption
|
||||
);
|
||||
}}
|
||||
onChange={(event) => update("ai_connection_id", event.target.value || null)}
|
||||
disabled={saving || !connections.length}
|
||||
>
|
||||
<MenuItem value="">{defaultOption}</MenuItem>
|
||||
{connections.map((connection) => (
|
||||
<MenuItem key={connection.id} value={connection.id}>
|
||||
<AiOption connection={connection} label={`${connection.model} · ${hostOf(connection.base_url)}`} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<UseAiSelect
|
||||
value={draft.ai_connection_id}
|
||||
connections={connections}
|
||||
onChange={(id) => update("ai_connection_id", id)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="ai-mode-label">{t("adminSettings.aiCategorization.mode")}</InputLabel>
|
||||
<Select
|
||||
@@ -207,7 +154,12 @@ export default function AiCategorizationDialog({
|
||||
disabled={saving}
|
||||
/>
|
||||
}
|
||||
label={t("adminSettings.aiCategorization.sendImage")}
|
||||
label={
|
||||
<>
|
||||
{t("adminSettings.aiCategorization.sendImage")}
|
||||
<HelpTip text={t("adminSettings.aiFeatures.privacy")} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AutoAwesomeOutlinedIcon from "@mui/icons-material/AutoAwesomeOutlined";
|
||||
import TuneIcon from "@mui/icons-material/Tune";
|
||||
import Alert from "@mui/material/Alert";
|
||||
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 FormControl from "@mui/material/FormControl";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import FormGroup from "@mui/material/FormGroup";
|
||||
import InputLabel from "@mui/material/InputLabel";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Select from "@mui/material/Select";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Switch from "@mui/material/Switch";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import { aiDescriptionsApi, type AiDescriptionActions, type AiDescriptionSettings } from "../../api/aiDescriptions";
|
||||
import type { AiConnection } from "../../api/aiConnections";
|
||||
import HelpTip from "../../components/HelpTip";
|
||||
import SectionedDialog from "../../components/SectionedDialog";
|
||||
import AiDescriptionsPanel from "./AiDescriptionsPanel";
|
||||
import type { FeatureSection } from "./FeatureTile";
|
||||
import UseAiSelect from "./UseAiSelect";
|
||||
|
||||
type Props = {
|
||||
settings: AiDescriptionSettings;
|
||||
/** Switched on with a model to use; Manage can only be opened then, as nothing can run otherwise. */
|
||||
active: boolean;
|
||||
initialSection?: FeatureSection;
|
||||
connections: AiConnection[];
|
||||
onClose: () => void;
|
||||
onSaved: (settings: AiDescriptionSettings) => void;
|
||||
onUnauthorized?: () => void;
|
||||
};
|
||||
|
||||
type Draft = Omit<AiDescriptionSettings, "enabled">;
|
||||
|
||||
// In the order the settings list them.
|
||||
const ACTIONS: (keyof AiDescriptionActions)[] = [
|
||||
"remove_self_promotion",
|
||||
"remove_unrelated",
|
||||
"translate",
|
||||
"generate_missing",
|
||||
"fix_spelling",
|
||||
"improve_formatting",
|
||||
];
|
||||
|
||||
/** Improved descriptions' settings, and its library run with the rewrites to review. Turning it on and off is the
|
||||
* switch on its tile. */
|
||||
export default function AiDescriptionsDialog({
|
||||
settings,
|
||||
active,
|
||||
initialSection = "settings",
|
||||
connections,
|
||||
onClose,
|
||||
onSaved,
|
||||
onUnauthorized,
|
||||
}: Props) {
|
||||
const { t } = useTranslation("app");
|
||||
const [section, setSection] = useState<FeatureSection>(initialSection);
|
||||
const [draft, setDraft] = useState<Draft>({
|
||||
mode: settings.mode,
|
||||
on_import: settings.on_import,
|
||||
send_image: settings.send_image,
|
||||
ai_connection_id: settings.ai_connection_id,
|
||||
actions: settings.actions,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const update = <K extends keyof Draft>(key: K, value: Draft[K]) => {
|
||||
setSaved(false);
|
||||
setDraft((current) => ({ ...current, [key]: value }));
|
||||
};
|
||||
const setAction = (key: keyof AiDescriptionActions, value: boolean) =>
|
||||
update("actions", { ...draft.actions, [key]: value });
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
try {
|
||||
onSaved(await aiDescriptionsApi.updateSettings(draft));
|
||||
setSaved(true);
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else setError(err instanceof Error ? err.message : t("adminSettings.aiDescriptions.failed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionedDialog
|
||||
open
|
||||
onClose={saving ? () => undefined : onClose}
|
||||
title={t("adminSettings.aiFeatures.descriptions")}
|
||||
sections={[
|
||||
{ id: "settings" as const, label: t("adminSettings.aiFeatures.settings"), icon: <TuneIcon fontSize="small" /> },
|
||||
{
|
||||
id: "manage" as const,
|
||||
label: t("adminSettings.aiFeatures.manage"),
|
||||
icon: <AutoAwesomeOutlinedIcon fontSize="small" />,
|
||||
disabled: !active,
|
||||
},
|
||||
]}
|
||||
section={active ? section : "settings"}
|
||||
onSectionChange={setSection}
|
||||
size="large"
|
||||
>
|
||||
{section === "manage" && active ? (
|
||||
<AiDescriptionsPanel onUnauthorized={onUnauthorized} onRunStarted={onClose} />
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
{!connections.length && <Alert severity="warning">{t("adminSettings.aiCategorization.noAi")}</Alert>}
|
||||
<UseAiSelect
|
||||
value={draft.ai_connection_id}
|
||||
connections={connections}
|
||||
onChange={(id) => update("ai_connection_id", id)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="ai-descriptions-mode-label">{t("adminSettings.aiCategorization.mode")}</InputLabel>
|
||||
<Select
|
||||
labelId="ai-descriptions-mode-label"
|
||||
label={t("adminSettings.aiCategorization.mode")}
|
||||
value={draft.mode}
|
||||
onChange={(event) => update("mode", event.target.value as Draft["mode"])}
|
||||
disabled={saving}
|
||||
>
|
||||
<MenuItem value="suggest">{t("adminSettings.aiCategorization.suggest")}</MenuItem>
|
||||
<MenuItem value="auto">{t("adminSettings.aiCategorization.auto")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{draft.mode === "auto" && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: -1 }}>
|
||||
{t("adminSettings.aiDescriptions.autoHint")}
|
||||
</Typography>
|
||||
)}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={draft.on_import}
|
||||
onChange={(event) => update("on_import", event.target.checked)}
|
||||
disabled={saving}
|
||||
/>
|
||||
}
|
||||
label={t("adminSettings.aiDescriptions.onImport")}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={draft.send_image}
|
||||
onChange={(event) => update("send_image", event.target.checked)}
|
||||
disabled={saving}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<>
|
||||
{t("adminSettings.aiCategorization.sendImage")}
|
||||
<HelpTip text={t("adminSettings.aiFeatures.privacy")} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight={600} sx={{ mb: 0.5 }}>
|
||||
{t("adminSettings.aiDescriptions.whatAiDoes")}
|
||||
</Typography>
|
||||
<FormGroup>
|
||||
{ACTIONS.map((key) => {
|
||||
// Writing one needs the picture to go by.
|
||||
const needsImage = key === "generate_missing" && !draft.send_image;
|
||||
return (
|
||||
<FormControlLabel
|
||||
key={key}
|
||||
control={
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={draft.actions[key] && !needsImage}
|
||||
onChange={(event) => setAction(key, event.target.checked)}
|
||||
disabled={saving || needsImage}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<>
|
||||
{t(`adminSettings.aiDescriptions.actions.${key}`)}
|
||||
{needsImage && (
|
||||
<Typography component="span" variant="caption" color="text.secondary" sx={{ ml: 0.75 }}>
|
||||
{t("adminSettings.aiDescriptions.needsImage")}
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</FormGroup>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => void save()}
|
||||
disabled={saving}
|
||||
startIcon={saving ? <CircularProgress size={14} color="inherit" /> : undefined}
|
||||
>
|
||||
{t("adminSettings.aiCategorization.save")}
|
||||
</Button>
|
||||
</Stack>
|
||||
{saved && <Alert severity="success">{t("adminSettings.aiDescriptions.saved")}</Alert>}
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
</Stack>
|
||||
)}
|
||||
</SectionedDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import { useCallback, useEffect, 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 CompareArrowsIcon from "@mui/icons-material/CompareArrows";
|
||||
import UndoIcon from "@mui/icons-material/Undo";
|
||||
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 Divider from "@mui/material/Divider";
|
||||
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 { aiDescriptionsApi, type AiDescriptionApplied, type AiDescriptionSuggestion } from "../../api/aiDescriptions";
|
||||
import { useConfirm } from "../../components/ConfirmProvider";
|
||||
import { useAiDescriptionRun } from "../../components/Layout/AiRunContext";
|
||||
import { ListRowSkeletons } from "../../components/ListSkeleton";
|
||||
import { useToast } from "../../components/ToastProvider";
|
||||
import DescriptionCompareDialog from "./DescriptionCompareDialog";
|
||||
|
||||
type Props = {
|
||||
onUnauthorized?: () => void;
|
||||
/** Closes the dialog once a run starts: its progress shows at the bottom of the app. */
|
||||
onRunStarted?: () => void;
|
||||
};
|
||||
|
||||
const PAGE = 50;
|
||||
|
||||
/** The start of a description as one line, for a list row. */
|
||||
function snippet(markdown: string): string {
|
||||
const text = markdown
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||
.replace(/[#*_`>]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return text.length > 160 ? `${text.slice(0, 160)}…` : text;
|
||||
}
|
||||
|
||||
type Comparing =
|
||||
{ kind: "suggestion"; item: AiDescriptionSuggestion } | { kind: "applied"; item: AiDescriptionApplied };
|
||||
|
||||
/** Improved descriptions' library run above the suggestions to review and the rewrites already applied. */
|
||||
export default function AiDescriptionsPanel({ onUnauthorized, onRunStarted }: Props) {
|
||||
const { t } = useTranslation(["app", "common"]);
|
||||
const showToast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const { running, startRun } = useAiDescriptionRun();
|
||||
const [suggestions, setSuggestions] = useState<{ total: number; items: AiDescriptionSuggestion[] } | null>(null);
|
||||
const [applied, setApplied] = useState<{ total: number; items: AiDescriptionApplied[] } | null>(null);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [comparing, setComparing] = useState<Comparing | null>(null);
|
||||
|
||||
const fail = useCallback(
|
||||
(err: unknown) => {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else showToast({ message: err instanceof Error ? err.message : String(err), severity: "error" });
|
||||
},
|
||||
[onUnauthorized, showToast],
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [pending, done] = await Promise.all([
|
||||
aiDescriptionsApi.suggestions(PAGE, 0),
|
||||
aiDescriptionsApi.applied(PAGE, 0),
|
||||
]);
|
||||
setSuggestions(pending);
|
||||
setApplied(done);
|
||||
setSelected([]);
|
||||
}, []);
|
||||
|
||||
// Again whenever a run stops, since a run adds suggestions.
|
||||
useEffect(() => {
|
||||
if (running) return;
|
||||
void load().catch(fail);
|
||||
}, [running, load, fail]);
|
||||
|
||||
const act = async (work: () => Promise<unknown>, done: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await work();
|
||||
await load();
|
||||
setComparing(null);
|
||||
showToast({ message: done });
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const accept = (ids: string[]) =>
|
||||
act(
|
||||
() => (ids.length === 1 ? aiDescriptionsApi.accept(ids[0]) : aiDescriptionsApi.bulk("accept", ids)),
|
||||
t("adminSettings.aiDescriptions.acceptDone", { count: ids.length }),
|
||||
);
|
||||
const reject = (ids: string[]) =>
|
||||
act(
|
||||
() => (ids.length === 1 ? aiDescriptionsApi.reject(ids[0]) : aiDescriptionsApi.bulk("reject", ids)),
|
||||
t("adminSettings.aiDescriptions.rejectDone", { count: ids.length }),
|
||||
);
|
||||
const revert = async (item: AiDescriptionApplied) => {
|
||||
const ok = await confirm({
|
||||
title: t("adminSettings.aiDescriptions.revertTitle"),
|
||||
message: t("adminSettings.aiDescriptions.revertMessage", { name: item.title || item.name }),
|
||||
confirmLabel: t("adminSettings.aiDescriptions.revert"),
|
||||
});
|
||||
if (ok) await act(() => aiDescriptionsApi.revert(item.id), t("adminSettings.aiDescriptions.revertDone"));
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await startRun();
|
||||
onRunStarted?.();
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const items = suggestions?.items ?? [];
|
||||
const toggle = (id: string) =>
|
||||
setSelected((ids) => (ids.includes(id) ? ids.filter((other) => other !== id) : [...ids, id]));
|
||||
const rowSx = { gap: 1, flexWrap: { xs: "wrap", sm: "nowrap" } } as const;
|
||||
const actionsSx = {
|
||||
flexShrink: 0,
|
||||
pt: 0.5,
|
||||
// On a phone the actions take their own line instead of squeezing the text.
|
||||
flexBasis: { xs: "100%", sm: "auto" },
|
||||
justifyContent: "flex-end",
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack spacing={1.5} sx={{ mb: 2.5 }}>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{t("adminSettings.aiDescriptions.runTitle")}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("adminSettings.aiDescriptions.runHint")}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={busy && !suggestions ? <CircularProgress size={16} /> : <AutoAwesomeIcon />}
|
||||
onClick={() => void start()}
|
||||
disabled={busy || running}
|
||||
>
|
||||
{t("adminSettings.aiDescriptions.start")}
|
||||
</Button>
|
||||
{running && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t("adminSettings.aiDescriptions.runInProgress")}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<Typography variant="body2" fontWeight={600} sx={{ mb: 1 }}>
|
||||
{t("adminSettings.aiDescriptions.suggestionsTitle", { count: suggestions?.total ?? 0 })}
|
||||
</Typography>
|
||||
{!suggestions ? (
|
||||
<ListRowSkeletons count={3} lead="rounded" leadSize={24} twoLines trailing />
|
||||
) : items.length ? (
|
||||
<>
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 1 }} flexWrap="wrap" useFlexGap>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setSelected(selected.length === items.length ? [] : items.map((item) => item.id))}
|
||||
>
|
||||
{selected.length === items.length
|
||||
? t("adminSettings.aiDescriptions.deselectAll")
|
||||
: t("adminSettings.aiDescriptions.selectAll")}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<CheckIcon />}
|
||||
onClick={() => void accept(selected)}
|
||||
disabled={!selected.length || busy}
|
||||
>
|
||||
{t("adminSettings.aiDescriptions.acceptSelected")}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<CloseIcon />}
|
||||
onClick={() => void reject(selected)}
|
||||
disabled={!selected.length || busy}
|
||||
>
|
||||
{t("adminSettings.aiDescriptions.rejectSelected")}
|
||||
</Button>
|
||||
</Stack>
|
||||
<List dense>
|
||||
{items.map((item) => (
|
||||
<ListItem key={item.id} divider alignItems="flex-start" sx={rowSx}>
|
||||
<Checkbox checked={selected.includes(item.id)} onChange={() => toggle(item.id)} />
|
||||
<ListItemText
|
||||
sx={{ flex: "1 1 0", minWidth: 0 }}
|
||||
primary={item.title || item.name}
|
||||
secondary={snippet(item.suggested)}
|
||||
/>
|
||||
<Stack direction="row" spacing={0.5} sx={actionsSx}>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<CompareArrowsIcon />}
|
||||
onClick={() => setComparing({ kind: "suggestion", item })}
|
||||
>
|
||||
{t("adminSettings.aiDescriptions.compare")}
|
||||
</Button>
|
||||
<Button size="small" onClick={() => void accept([item.id])} disabled={busy}>
|
||||
{t("adminSettings.aiDescriptions.accept")}
|
||||
</Button>
|
||||
<Button size="small" color="error" onClick={() => void reject([item.id])} disabled={busy}>
|
||||
{t("adminSettings.aiDescriptions.reject")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
{suggestions.total > items.length && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t("adminSettings.aiDescriptions.firstPage", { shown: items.length, total: suggestions.total })}
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("adminSettings.aiDescriptions.noSuggestions")}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{Boolean(applied?.items.length) && (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="body2" fontWeight={600} sx={{ mb: 1 }}>
|
||||
{t("adminSettings.aiDescriptions.appliedTitle", { count: applied?.total ?? 0 })}
|
||||
</Typography>
|
||||
<List dense>
|
||||
{applied?.items.map((item) => (
|
||||
<ListItem key={item.id} divider alignItems="flex-start" sx={rowSx}>
|
||||
<ListItemText
|
||||
sx={{ flex: "1 1 0", minWidth: 0 }}
|
||||
primary={item.title || item.name}
|
||||
secondary={snippet(item.current)}
|
||||
/>
|
||||
<Stack direction="row" spacing={0.5} sx={actionsSx}>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<CompareArrowsIcon />}
|
||||
onClick={() => setComparing({ kind: "applied", item })}
|
||||
>
|
||||
{t("adminSettings.aiDescriptions.compare")}
|
||||
</Button>
|
||||
<Button size="small" startIcon={<UndoIcon />} onClick={() => void revert(item)} disabled={busy}>
|
||||
{t("adminSettings.aiDescriptions.revert")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{comparing?.kind === "suggestion" && (
|
||||
<DescriptionCompareDialog
|
||||
title={comparing.item.title || comparing.item.name}
|
||||
before={comparing.item.original}
|
||||
after={comparing.item.suggested}
|
||||
afterLabel={t("adminSettings.aiDescriptions.suggested")}
|
||||
onClose={() => setComparing(null)}
|
||||
actions={
|
||||
<>
|
||||
<Button color="error" onClick={() => void reject([comparing.item.id])} disabled={busy}>
|
||||
{t("adminSettings.aiDescriptions.reject")}
|
||||
</Button>
|
||||
<Button variant="contained" onClick={() => void accept([comparing.item.id])} disabled={busy}>
|
||||
{t("adminSettings.aiDescriptions.accept")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{comparing?.kind === "applied" && (
|
||||
<DescriptionCompareDialog
|
||||
title={comparing.item.title || comparing.item.name}
|
||||
before={comparing.item.original}
|
||||
after={comparing.item.current}
|
||||
afterLabel={t("adminSettings.aiDescriptions.current")}
|
||||
onClose={() => setComparing(null)}
|
||||
actions={
|
||||
<Button startIcon={<UndoIcon />} onClick={() => void revert(comparing.item)} disabled={busy}>
|
||||
{t("adminSettings.aiDescriptions.revert")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,227 +1,181 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AutoAwesomeOutlinedIcon from "@mui/icons-material/AutoAwesomeOutlined";
|
||||
import PowerSettingsNewIcon from "@mui/icons-material/PowerSettingsNew";
|
||||
import TuneIcon from "@mui/icons-material/Tune";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Box from "@mui/material/Box";
|
||||
import ButtonBase from "@mui/material/ButtonBase";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Switch from "@mui/material/Switch";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import { settingsApi, type AiCategorizationSettings } from "../../api/settings";
|
||||
import { settingsApi } from "../../api/settings";
|
||||
import { aiDescriptionsApi } from "../../api/aiDescriptions";
|
||||
import type { AiConnection } from "../../api/aiConnections";
|
||||
import SectionHeader from "../../components/SectionHeader";
|
||||
import { menuPosition, TileMenuHeader } from "../../components/Layout/ConfigurationDialog/AppTiles";
|
||||
import AiCategorizationDialog, { type AiCategorizationSection } from "./AiCategorizationDialog";
|
||||
import { useAiDescriptionRun, useAiRun } from "../../components/Layout/AiRunContext";
|
||||
import AiCategorizationDialog from "./AiCategorizationDialog";
|
||||
import AiDescriptionsDialog from "./AiDescriptionsDialog";
|
||||
import CategorizationAnimation from "./CategorizationAnimation";
|
||||
import { AiLogo } from "./aiLogos";
|
||||
import DescriptionAnimation from "./DescriptionAnimation";
|
||||
import FeatureTile, { FEATURE_TILE, type FeatureSection } from "./FeatureTile";
|
||||
|
||||
/** `connections` is null while the models are loading. */
|
||||
type Props = { connections: AiConnection[] | null; onUnauthorized?: () => void };
|
||||
|
||||
const TILE = 112;
|
||||
const tileShadow = "inset 0 0 0 1px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04)";
|
||||
type FeatureSettings = { enabled: boolean; ai_connection_id: string | null };
|
||||
|
||||
/** What the AIs are used for, each as a tile: click it for its settings, switch it on or off underneath. */
|
||||
export default function AiFeaturesSection({ connections, onUnauthorized }: Props) {
|
||||
const { t } = useTranslation("app");
|
||||
const [settings, setSettings] = useState<AiCategorizationSettings | null>(null);
|
||||
// The section the dialog is open on, or null when closed.
|
||||
const [open, setOpen] = useState<AiCategorizationSection | null>(null);
|
||||
const [menu, setMenu] = useState<{ top: number; left: number } | null>(null);
|
||||
/**
|
||||
* One feature's settings: loaded, switched on and off, and read again when models come or go (deleting the last
|
||||
* one switches every feature off on the server).
|
||||
*/
|
||||
function useFeature<T extends FeatureSettings>(
|
||||
load: () => Promise<T>,
|
||||
update: (input: { enabled: boolean }) => Promise<T>,
|
||||
modelCount: number | undefined,
|
||||
onUnauthorized: (() => void) | undefined,
|
||||
failed: string,
|
||||
) {
|
||||
const [settings, setSettings] = useState<T | null>(null);
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fail = useCallback(
|
||||
(err: unknown) => {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else setError(err instanceof Error ? err.message : failed);
|
||||
},
|
||||
[onUnauthorized, failed],
|
||||
);
|
||||
|
||||
// Read again when models come or go: deleting the last one switches the features off on the server.
|
||||
const modelCount = connections?.length;
|
||||
useEffect(() => {
|
||||
if (modelCount === undefined) return;
|
||||
let active = true;
|
||||
void settingsApi
|
||||
.getAiCategorization()
|
||||
void load()
|
||||
.then((loaded) => {
|
||||
if (active) setSettings(loaded);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else if (active) setError(err instanceof Error ? err.message : t("adminSettings.aiCategorization.failed"));
|
||||
if (active) fail(err);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [modelCount, onUnauthorized, t]);
|
||||
}, [load, modelCount, fail]);
|
||||
|
||||
const toggle = async (enabled: boolean) => {
|
||||
setSwitching(true);
|
||||
setError(null);
|
||||
try {
|
||||
setSettings(await settingsApi.updateAiCategorization({ enabled }));
|
||||
setSettings(await update({ enabled }));
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else setError(err instanceof Error ? err.message : t("adminSettings.aiCategorization.failed"));
|
||||
fail(err);
|
||||
} finally {
|
||||
setSwitching(false);
|
||||
}
|
||||
};
|
||||
return { settings, setSettings, switching, error, setError, toggle };
|
||||
}
|
||||
|
||||
// The AI it would use: the one it's set to while that exists, otherwise the default, as the backend picks.
|
||||
const model =
|
||||
/** What the AIs are used for, each as a tile: click it for its settings, switch it on or off underneath. */
|
||||
export default function AiFeaturesSection({ connections, onUnauthorized }: Props) {
|
||||
const { t } = useTranslation("app");
|
||||
const modelCount = connections?.length;
|
||||
const { running: categorizing } = useAiRun();
|
||||
const { running: improving } = useAiDescriptionRun();
|
||||
const categorization = useFeature(
|
||||
settingsApi.getAiCategorization,
|
||||
settingsApi.updateAiCategorization,
|
||||
modelCount,
|
||||
onUnauthorized,
|
||||
t("adminSettings.aiCategorization.failed"),
|
||||
);
|
||||
const descriptions = useFeature(
|
||||
aiDescriptionsApi.getSettings,
|
||||
aiDescriptionsApi.updateSettings,
|
||||
modelCount,
|
||||
onUnauthorized,
|
||||
t("adminSettings.aiDescriptions.failed"),
|
||||
);
|
||||
// Which dialog is open, and on which section.
|
||||
const [open, setOpen] = useState<{ feature: "categorization" | "descriptions"; section: FeatureSection } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const hasModel = Boolean(connections?.length);
|
||||
// The AI a feature would use: the one it's set to while that exists, otherwise the default, as the backend picks.
|
||||
const modelFor = (settings: FeatureSettings | null) =>
|
||||
connections?.find((connection) => connection.id === settings?.ai_connection_id) ??
|
||||
connections?.find((connection) => connection.is_default);
|
||||
const hasModel = Boolean(connections?.length);
|
||||
// Without a model nothing can run, whatever was saved, and there's nothing to switch on until one is added.
|
||||
const enabled = Boolean(settings?.enabled) && hasModel;
|
||||
const label = t("adminSettings.aiFeatures.categorization");
|
||||
const categorizationOn = Boolean(categorization.settings?.enabled) && hasModel;
|
||||
const descriptionsOn = Boolean(descriptions.settings?.enabled) && hasModel;
|
||||
const loading = connections === null || (!categorization.settings && !categorization.error);
|
||||
const errors = [
|
||||
{ key: "categorization", ...categorization },
|
||||
{ key: "descriptions", ...descriptions },
|
||||
].filter((feature) => feature.error);
|
||||
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<SectionHeader title={t("adminSettings.aiFeatures.heading")} subtitle={t("adminSettings.aiFeatures.subtitle")} />
|
||||
{(settings === null || connections === null) && !error ? (
|
||||
{loading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
<Box>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: `repeat(auto-fill, ${TILE + 64}px)`, gap: 2 }}>
|
||||
<Stack alignItems="center" spacing={0.75}>
|
||||
<Tooltip title={t("adminSettings.aiFeatures.openSettings")} placement="top">
|
||||
<ButtonBase
|
||||
onClick={() => setOpen("settings")}
|
||||
onContextMenu={(event) => setMenu(menuPosition(event))}
|
||||
disabled={!settings}
|
||||
aria-label={t("adminSettings.aiFeatures.settingsAria", { feature: label })}
|
||||
sx={{
|
||||
position: "relative",
|
||||
width: TILE,
|
||||
height: TILE,
|
||||
borderRadius: "22px",
|
||||
bgcolor: "#ffffff",
|
||||
boxShadow: tileShadow,
|
||||
filter: enabled ? "none" : "grayscale(1)",
|
||||
opacity: enabled ? 1 : 0.55,
|
||||
transition: "filter 200ms, opacity 200ms, transform 150ms",
|
||||
"&:hover": { transform: "translateY(-1px)" },
|
||||
"&.Mui-focusVisible": { outline: 2, outlineColor: "primary.main", outlineOffset: 2 },
|
||||
}}
|
||||
>
|
||||
<CategorizationAnimation size={84} paused={!enabled} />
|
||||
{model && (
|
||||
// A bubble over the corner, so it reads as which model the feature uses rather than part of it.
|
||||
<Tooltip title={model.model} placement="bottom">
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
right: -8,
|
||||
bottom: -8,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
bgcolor: "#ffffff",
|
||||
boxShadow: "0 0 0 1px rgba(0, 0, 0, 0.08), 0 2px 6px rgba(0, 0, 0, 0.18)",
|
||||
}}
|
||||
>
|
||||
<AiLogo model={model.model} baseUrl={model.base_url} size={18} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ pt: 0.5 }}>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{label}
|
||||
</Typography>
|
||||
{hasModel && (
|
||||
<Switch
|
||||
size="small"
|
||||
checked={enabled}
|
||||
onChange={(event) => void toggle(event.target.checked)}
|
||||
disabled={!settings || switching}
|
||||
slotProps={{
|
||||
input: { "aria-label": t("adminSettings.aiFeatures.toggleAria", { feature: label }) },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: `repeat(auto-fill, ${FEATURE_TILE + 104}px)`, gap: 2 }}>
|
||||
<FeatureTile
|
||||
label={t("adminSettings.aiFeatures.categorization")}
|
||||
description={t("adminSettings.aiFeatures.categorizationDescription")}
|
||||
Animation={CategorizationAnimation}
|
||||
enabled={categorizationOn}
|
||||
hasModel={hasModel}
|
||||
model={modelFor(categorization.settings)}
|
||||
ready={Boolean(categorization.settings)}
|
||||
switching={categorization.switching}
|
||||
running={categorizing}
|
||||
onToggle={(enabled) => void categorization.toggle(enabled)}
|
||||
onOpen={(section) => setOpen({ feature: "categorization", section })}
|
||||
/>
|
||||
<FeatureTile
|
||||
label={t("adminSettings.aiFeatures.descriptions")}
|
||||
description={t("adminSettings.aiFeatures.descriptionsDescription")}
|
||||
Animation={DescriptionAnimation}
|
||||
enabled={descriptionsOn}
|
||||
hasModel={hasModel}
|
||||
model={modelFor(descriptions.settings)}
|
||||
ready={Boolean(descriptions.settings)}
|
||||
switching={descriptions.switching}
|
||||
running={improving}
|
||||
onToggle={(enabled) => void descriptions.toggle(enabled)}
|
||||
onOpen={(section) => setOpen({ feature: "descriptions", section })}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1.5 }}>
|
||||
{t("adminSettings.aiFeatures.hint")}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{error && (
|
||||
<Alert severity="error" onClose={() => setError(null)}>
|
||||
{error}
|
||||
{errors.map((feature) => (
|
||||
<Alert key={feature.key} severity="error" onClose={() => feature.setError(null)}>
|
||||
{feature.error}
|
||||
</Alert>
|
||||
)}
|
||||
<Menu
|
||||
open={Boolean(menu)}
|
||||
onClose={() => setMenu(null)}
|
||||
anchorReference="anchorPosition"
|
||||
anchorPosition={menu ?? undefined}
|
||||
slotProps={{ paper: { sx: { minWidth: 240, maxWidth: 300 } } }}
|
||||
>
|
||||
<TileMenuHeader
|
||||
logo={<AutoAwesomeOutlinedIcon fontSize="small" sx={{ color: "text.secondary" }} />}
|
||||
name={label}
|
||||
status={t("adminSettings.aiFeatures.categorizationDescription")}
|
||||
/>
|
||||
<MenuItem
|
||||
disabled={!hasModel || !settings || switching}
|
||||
onClick={() => {
|
||||
setMenu(null);
|
||||
void toggle(!enabled);
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<PowerSettingsNewIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>
|
||||
{enabled ? t("adminSettings.aiFeatures.turnOff") : t("adminSettings.aiFeatures.turnOn")}
|
||||
</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
disabled={!settings}
|
||||
onClick={() => {
|
||||
setMenu(null);
|
||||
setOpen("settings");
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<TuneIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("adminSettings.aiCategorization.settings")}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
disabled={!settings || !enabled}
|
||||
onClick={() => {
|
||||
setMenu(null);
|
||||
setOpen("manage");
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<AutoAwesomeOutlinedIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("adminSettings.aiCategorization.manage")}</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
{open && settings && (
|
||||
))}
|
||||
{open?.feature === "categorization" && categorization.settings && (
|
||||
<AiCategorizationDialog
|
||||
settings={settings}
|
||||
active={enabled}
|
||||
settings={categorization.settings}
|
||||
active={categorizationOn}
|
||||
connections={connections ?? []}
|
||||
initialSection={open}
|
||||
initialSection={open.section}
|
||||
onClose={() => setOpen(null)}
|
||||
onSaved={setSettings}
|
||||
onSaved={categorization.setSettings}
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
)}
|
||||
{open?.feature === "descriptions" && descriptions.settings && (
|
||||
<AiDescriptionsDialog
|
||||
settings={descriptions.settings}
|
||||
active={descriptionsOn}
|
||||
connections={connections ?? []}
|
||||
initialSection={open.section}
|
||||
onClose={() => setOpen(null)}
|
||||
onSaved={descriptions.setSettings}
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import { mix } from "./pseudoRandom";
|
||||
|
||||
// A conveyor of rows, one color each. Every row holds a square that belongs in the row below, which keeps a gap
|
||||
// for it. Each step that square drops into its gap, then the rows move up one: the top row leaves and a new one
|
||||
@@ -19,15 +20,6 @@ function move(ms: number): string {
|
||||
return `transform ${ms}ms cubic-bezier(0.4, 0, 0.2, 1)`;
|
||||
}
|
||||
|
||||
function mix(value: number): number {
|
||||
let hash = Math.imul(value + 1, 0x9e3779b1);
|
||||
hash ^= hash >>> 16;
|
||||
hash = Math.imul(hash, 0x85ebca6b);
|
||||
hash ^= hash >>> 13;
|
||||
hash = Math.imul(hash, 0xc2b2ae35);
|
||||
return (hash ^ (hash >>> 16)) >>> 0;
|
||||
}
|
||||
|
||||
const misfitColumn = (conveyorRow: number) => (mix(conveyorRow) >>> 3) % COLUMNS;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import { mix } from "./pseudoRandom";
|
||||
|
||||
// Lines of text drawn as skeleton bars. Each step a line or two turn red and fold away, the rest move up into the
|
||||
// space, and as many new lines grow in at the bottom; then other lines, picked at random, go the same way.
|
||||
const LINES = 6;
|
||||
// As parts of the size, so the same picture works in the feature tile and in the small one on the progress bar.
|
||||
const LINE_HEIGHT = 1 / 12;
|
||||
const LINE_GAP = 1 / 17;
|
||||
|
||||
const HOLD_MS = 900;
|
||||
const MARK_MS = 500;
|
||||
const REMOVE_MS = 450;
|
||||
const ADD_MS = 450;
|
||||
|
||||
type LineState = "shown" | "marked" | "removed" | "added";
|
||||
type Line = { id: number; state: LineState };
|
||||
type Phase = "hold" | "mark" | "remove" | "add";
|
||||
|
||||
/** A line's length, fixed per line, between half and nearly the full width. */
|
||||
const widthOf = (id: number) => 48 + (mix(id) % 46);
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
return typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
|
||||
}
|
||||
|
||||
const firstLines = (): Line[] => Array.from({ length: LINES }, (_line, id) => ({ id, state: "shown" }));
|
||||
|
||||
/** Text cleaned up line by line, looping; still while the feature is off or motion is reduced. */
|
||||
export default function DescriptionAnimation({ size, paused }: { size: number; paused?: boolean }) {
|
||||
const [lines, setLines] = useState<Line[]>(firstLines);
|
||||
const [phase, setPhase] = useState<Phase>("hold");
|
||||
const [step, setStep] = useState(0);
|
||||
const still = paused || prefersReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
if (still) return;
|
||||
const timers: Record<Phase, [number, () => void]> = {
|
||||
hold: [
|
||||
HOLD_MS,
|
||||
() => {
|
||||
// One or two lines, never the same ones twice running: the picks come from the step.
|
||||
const count = 1 + (mix(step) % 2);
|
||||
const picked = new Set<number>();
|
||||
for (let n = 0; picked.size < count && n < 8; n++) picked.add(mix(step * 7 + n) % LINES);
|
||||
setLines((current) =>
|
||||
current.map((line, index) => (picked.has(index) ? { ...line, state: "marked" } : line)),
|
||||
);
|
||||
setPhase("mark");
|
||||
},
|
||||
],
|
||||
mark: [
|
||||
MARK_MS,
|
||||
() => {
|
||||
setLines((current) =>
|
||||
current.map((line) => (line.state === "marked" ? { ...line, state: "removed" } : line)),
|
||||
);
|
||||
setPhase("remove");
|
||||
},
|
||||
],
|
||||
remove: [
|
||||
REMOVE_MS,
|
||||
() => {
|
||||
setLines((current) => {
|
||||
const kept = current.filter((line) => line.state !== "removed");
|
||||
const next = Math.max(...current.map((line) => line.id)) + 1;
|
||||
const added = Array.from({ length: LINES - kept.length }, (_line, n): Line => ({
|
||||
id: next + n,
|
||||
state: "added",
|
||||
}));
|
||||
return [...kept, ...added];
|
||||
});
|
||||
setPhase("add");
|
||||
},
|
||||
],
|
||||
add: [
|
||||
// A moment for the added lines to mount folded, so growing them in animates.
|
||||
30,
|
||||
() => {
|
||||
setLines((current) => current.map((line) => (line.state === "added" ? { ...line, state: "shown" } : line)));
|
||||
setStep((current) => current + 1);
|
||||
setPhase("hold");
|
||||
},
|
||||
],
|
||||
};
|
||||
const [after, next] = timers[phase];
|
||||
const timer = window.setTimeout(next, after);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [phase, step, still]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: size,
|
||||
height: size,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
px: `${size / 10}px`,
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{lines.map((line) => {
|
||||
const folded = line.state === "removed" || line.state === "added";
|
||||
return (
|
||||
<Box
|
||||
key={line.id}
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
width: `${widthOf(line.id)}%`,
|
||||
height: folded ? 0 : size * LINE_HEIGHT,
|
||||
mb: folded ? 0 : `${size * LINE_GAP}px`,
|
||||
borderRadius: `${Math.max(2, size / 21)}px`,
|
||||
opacity: folded ? 0 : 1,
|
||||
bgcolor: line.state === "marked" || line.state === "removed" ? "#ef4444" : "#e2e8f0",
|
||||
transition: `height ${ADD_MS - 50}ms ease, margin ${ADD_MS - 50}ms ease, opacity ${REMOVE_MS - 50}ms ease, background-color 250ms ease`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
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 Typography from "@mui/material/Typography";
|
||||
import MarkdownDescription from "../../components/MarkdownDescription";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
before: string;
|
||||
after: string;
|
||||
/** What the right side is: a suggestion, or what's there now after an applied rewrite. */
|
||||
afterLabel: string;
|
||||
onClose: () => void;
|
||||
/** Buttons for what can be done with it, beside Close. */
|
||||
actions?: React.ReactNode;
|
||||
};
|
||||
|
||||
function Column({ label, markdown }: { label: string; markdown: string }) {
|
||||
const { t } = useTranslation("app");
|
||||
return (
|
||||
<Box sx={{ minWidth: 0, display: "flex", flexDirection: "column" }}>
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
{label}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: "auto",
|
||||
p: 2,
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: "10px",
|
||||
maxHeight: { xs: "none", md: "60vh" },
|
||||
}}
|
||||
>
|
||||
{markdown.trim() ? (
|
||||
<MarkdownDescription markdown={markdown} />
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("adminSettings.aiDescriptions.empty")}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** A description before and after the AI, side by side (stacked on a phone). */
|
||||
export default function DescriptionCompareDialog({ title, before, after, afterLabel, onClose, actions }: Props) {
|
||||
const { t } = useTranslation(["app", "common"]);
|
||||
return (
|
||||
<Dialog open onClose={onClose} fullWidth maxWidth="lg">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||
<Column label={t("adminSettings.aiDescriptions.original")} markdown={before} />
|
||||
<Column label={afterLabel} markdown={after} />
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
{actions}
|
||||
<Button onClick={onClose}>{t("common:close")}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AutoAwesomeOutlinedIcon from "@mui/icons-material/AutoAwesomeOutlined";
|
||||
import PowerSettingsNewIcon from "@mui/icons-material/PowerSettingsNew";
|
||||
import TuneIcon from "@mui/icons-material/Tune";
|
||||
import Box from "@mui/material/Box";
|
||||
import ButtonBase from "@mui/material/ButtonBase";
|
||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Switch from "@mui/material/Switch";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import type { AiConnection } from "../../api/aiConnections";
|
||||
import { menuPosition, TileMenuHeader } from "../../components/Layout/ConfigurationDialog/AppTiles";
|
||||
import { AiLogo } from "./aiLogos";
|
||||
|
||||
/** Where a feature's dialog opens. */
|
||||
export type FeatureSection = "settings" | "manage";
|
||||
|
||||
export const FEATURE_TILE = 112;
|
||||
const tileShadow = "inset 0 0 0 1px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04)";
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
/** A sentence on what the feature does, in its menu's header. */
|
||||
description: string;
|
||||
/** Drawn in the tile; paused while the feature is off. */
|
||||
Animation: React.ComponentType<{ size: number; paused?: boolean }>;
|
||||
/** Switched on and with a model to use. */
|
||||
enabled: boolean;
|
||||
/** Without one the switch is hidden: there's nothing to turn on. */
|
||||
hasModel: boolean;
|
||||
/** The model it uses, shown as a bubble on the corner. */
|
||||
model: AiConnection | undefined;
|
||||
/** False until its settings have loaded. */
|
||||
ready: boolean;
|
||||
switching: boolean;
|
||||
/** Its library run is going: it can't be switched off under it. */
|
||||
running: boolean;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
onOpen: (section: FeatureSection) => void;
|
||||
};
|
||||
|
||||
/** An AI feature as a tile: click it for its settings, switch it underneath, right-click for the rest. */
|
||||
export default function FeatureTile({
|
||||
label,
|
||||
description,
|
||||
Animation,
|
||||
enabled,
|
||||
hasModel,
|
||||
model,
|
||||
ready,
|
||||
switching,
|
||||
running,
|
||||
onToggle,
|
||||
onOpen,
|
||||
}: Props) {
|
||||
const { t } = useTranslation("app");
|
||||
const [menu, setMenu] = useState<{ top: number; left: number } | null>(null);
|
||||
const locked = running && enabled;
|
||||
const open = (section: FeatureSection) => {
|
||||
setMenu(null);
|
||||
onOpen(section);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack alignItems="center" spacing={0.75}>
|
||||
<Tooltip title={t("adminSettings.aiFeatures.openSettings")} placement="top">
|
||||
<ButtonBase
|
||||
onClick={() => onOpen("settings")}
|
||||
onContextMenu={(event) => setMenu(menuPosition(event))}
|
||||
disabled={!ready}
|
||||
aria-label={t("adminSettings.aiFeatures.settingsAria", { feature: label })}
|
||||
sx={{
|
||||
position: "relative",
|
||||
width: FEATURE_TILE,
|
||||
height: FEATURE_TILE,
|
||||
borderRadius: "22px",
|
||||
bgcolor: "#ffffff",
|
||||
boxShadow: tileShadow,
|
||||
filter: enabled ? "none" : "grayscale(1)",
|
||||
opacity: enabled ? 1 : 0.55,
|
||||
transition: "filter 200ms, opacity 200ms, transform 150ms",
|
||||
"&:hover": { transform: "translateY(-1px)" },
|
||||
"&.Mui-focusVisible": { outline: 2, outlineColor: "primary.main", outlineOffset: 2 },
|
||||
}}
|
||||
>
|
||||
<Animation size={84} paused={!enabled} />
|
||||
{model && (
|
||||
// A bubble over the corner, so it reads as which model the feature uses rather than part of it.
|
||||
<Tooltip title={model.model} placement="bottom">
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
right: -8,
|
||||
bottom: -8,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
bgcolor: "#ffffff",
|
||||
boxShadow: "0 0 0 1px rgba(0, 0, 0, 0.08), 0 2px 6px rgba(0, 0, 0, 0.18)",
|
||||
}}
|
||||
>
|
||||
<AiLogo model={model.model} baseUrl={model.base_url} size={18} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ButtonBase>
|
||||
</Tooltip>
|
||||
{/* Centred under the tile, the label wrapping to a second line when it must. */}
|
||||
<Stack direction="row" alignItems="center" justifyContent="center" spacing={0.5} sx={{ pt: 0.5, width: "100%" }}>
|
||||
<Typography variant="body2" fontWeight={600} textAlign="center" sx={{ lineHeight: 1.3, minWidth: 0 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
{hasModel && (
|
||||
<Tooltip title={locked ? t("adminSettings.aiFeatures.runningLock") : ""}>
|
||||
<span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={enabled}
|
||||
onChange={(event) => onToggle(event.target.checked)}
|
||||
disabled={!ready || switching || locked}
|
||||
slotProps={{ input: { "aria-label": t("adminSettings.aiFeatures.toggleAria", { feature: label }) } }}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Menu
|
||||
open={Boolean(menu)}
|
||||
onClose={() => setMenu(null)}
|
||||
anchorReference="anchorPosition"
|
||||
anchorPosition={menu ?? undefined}
|
||||
slotProps={{ paper: { sx: { minWidth: 240, maxWidth: 300 } } }}
|
||||
>
|
||||
<TileMenuHeader
|
||||
logo={<AutoAwesomeOutlinedIcon fontSize="small" sx={{ color: "text.secondary" }} />}
|
||||
name={label}
|
||||
status={description}
|
||||
/>
|
||||
<MenuItem
|
||||
disabled={!hasModel || !ready || switching || locked}
|
||||
onClick={() => {
|
||||
setMenu(null);
|
||||
onToggle(!enabled);
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<PowerSettingsNewIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>
|
||||
{enabled ? t("adminSettings.aiFeatures.turnOff") : t("adminSettings.aiFeatures.turnOn")}
|
||||
</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem disabled={!ready} onClick={() => open("settings")}>
|
||||
<ListItemIcon>
|
||||
<TuneIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("adminSettings.aiFeatures.settings")}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem disabled={!ready || !enabled} onClick={() => open("manage")}>
|
||||
<ListItemIcon>
|
||||
<AutoAwesomeOutlinedIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("adminSettings.aiFeatures.manage")}</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FormControl from "@mui/material/FormControl";
|
||||
import InputLabel from "@mui/material/InputLabel";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Select from "@mui/material/Select";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import type { AiConnection } from "../../api/aiConnections";
|
||||
import { AiLogo, hostOf } from "./aiLogos";
|
||||
|
||||
/** An option in the list: the model's logo and what to call it. */
|
||||
function AiOption({ connection, label }: { connection: AiConnection | undefined; label: string }) {
|
||||
return (
|
||||
<Stack direction="row" spacing={1.25} alignItems="center">
|
||||
{connection ? (
|
||||
<AiLogo model={connection.model} baseUrl={connection.base_url} size={18} />
|
||||
) : (
|
||||
<span style={{ width: 18 }} />
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** A model as it shows in the list. */
|
||||
function ModelOption({ connection }: { connection: AiConnection }) {
|
||||
return <AiOption connection={connection} label={`${connection.model} · ${hostOf(connection.base_url)}`} />;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
/** Null is the default AI. */
|
||||
value: string | null;
|
||||
connections: AiConnection[];
|
||||
onChange: (id: string | null) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
/** Which model a feature uses: the default one, or one picked out. */
|
||||
export default function UseAiSelect({ value, connections, onChange, disabled }: Props) {
|
||||
const { t } = useTranslation("app");
|
||||
const labelId = useId();
|
||||
const defaultConnection = connections.find((connection) => connection.is_default);
|
||||
const defaultOption = (
|
||||
<AiOption
|
||||
connection={defaultConnection}
|
||||
label={t("adminSettings.aiCategorization.defaultAi", { model: defaultConnection?.model ?? "—" })}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<FormControl fullWidth>
|
||||
{/* Empty means the default AI, which still shows text, so the label stays up on the border. */}
|
||||
<InputLabel id={labelId} shrink>
|
||||
{t("adminSettings.aiCategorization.useAi")}
|
||||
</InputLabel>
|
||||
<Select
|
||||
labelId={labelId}
|
||||
label={t("adminSettings.aiCategorization.useAi")}
|
||||
// A saved choice that's gone (deleted meanwhile) reads as the default, as the backend treats it.
|
||||
value={value && connections.some((connection) => connection.id === value) ? value : ""}
|
||||
displayEmpty
|
||||
notched
|
||||
// The chosen option as it looks in the list, logo included; empty is the default AI.
|
||||
renderValue={(chosen) => {
|
||||
const connection = connections.find((candidate) => candidate.id === chosen);
|
||||
return connection ? <ModelOption connection={connection} /> : defaultOption;
|
||||
}}
|
||||
onChange={(event) => onChange(event.target.value || null)}
|
||||
disabled={disabled || !connections.length}
|
||||
>
|
||||
<MenuItem value="">{defaultOption}</MenuItem>
|
||||
{connections.map((connection) => (
|
||||
<MenuItem key={connection.id} value={connection.id}>
|
||||
<ModelOption connection={connection} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** A well-spread number from an integer: the same input always gives the same output, so an animation can look
|
||||
* random yet repeat exactly where it needs to. */
|
||||
export function mix(value: number): number {
|
||||
let hash = Math.imul(value + 1, 0x9e3779b1);
|
||||
hash ^= hash >>> 16;
|
||||
hash = Math.imul(hash, 0x85ebca6b);
|
||||
hash ^= hash >>> 13;
|
||||
hash = Math.imul(hash, 0xc2b2ae35);
|
||||
return (hash ^ (hash >>> 16)) >>> 0;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AutoFixNormalIcon from "@mui/icons-material/AutoFixNormal";
|
||||
import CompareArrowsIcon from "@mui/icons-material/CompareArrows";
|
||||
import UndoIcon from "@mui/icons-material/Undo";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Chip from "@mui/material/Chip";
|
||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import { aiDescriptionsApi } from "../../api/aiDescriptions";
|
||||
import { type Print, printsApi } from "../../api/prints";
|
||||
import { useConfirm } from "../../components/ConfirmProvider";
|
||||
import { useToast } from "../../components/ToastProvider";
|
||||
import DescriptionCompareDialog from "../AiPage/DescriptionCompareDialog";
|
||||
|
||||
type Props = {
|
||||
print: Print;
|
||||
onUpdated: (print: Print) => void;
|
||||
onUnauthorized?: () => void;
|
||||
};
|
||||
|
||||
/** Accepting, rejecting and reverting a model's AI description, then showing the model as it is after. */
|
||||
function useDescriptionActions({ print, onUpdated, onUnauthorized }: Props) {
|
||||
const { t } = useTranslation(["models", "app"]);
|
||||
const confirm = useConfirm();
|
||||
const showToast = useToast();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [comparing, setComparing] = useState(false);
|
||||
|
||||
const act = async (work: () => Promise<unknown>, done: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await work();
|
||||
onUpdated(await printsApi.get(print.id));
|
||||
setComparing(false);
|
||||
showToast({ message: done });
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else showToast({ message: err instanceof Error ? err.message : String(err), severity: "error" });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return {
|
||||
busy,
|
||||
comparing,
|
||||
setComparing,
|
||||
accept: () => act(() => aiDescriptionsApi.accept(print.id), t("models:aiDescriptions.accepted")),
|
||||
reject: () => act(() => aiDescriptionsApi.reject(print.id), t("models:aiDescriptions.rejected")),
|
||||
revert: async () => {
|
||||
const ok = await confirm({
|
||||
title: t("app:adminSettings.aiDescriptions.revertTitle"),
|
||||
message: t("models:aiDescriptions.revertMessage"),
|
||||
confirmLabel: t("app:adminSettings.aiDescriptions.revert"),
|
||||
});
|
||||
if (ok) await act(() => aiDescriptionsApi.revert(print.id), t("app:adminSettings.aiDescriptions.revertDone"));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* "by AI" beside the Description heading, as beside an AI category. While the feature is on, it opens Compare and
|
||||
* Revert; switched off, the original isn't sent, so it only says where the text came from.
|
||||
*/
|
||||
export function AiDescriptionChip(props: Props) {
|
||||
const { print } = props;
|
||||
const { t } = useTranslation(["models", "app"]);
|
||||
const actions = useDescriptionActions(props);
|
||||
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
||||
if (!print.description_by_ai) return null;
|
||||
const original = print.ai_description_original;
|
||||
const label = t("models:aiCategorization.byAi");
|
||||
if (original == null) {
|
||||
return <Chip size="small" label={label} color="secondary" variant="outlined" />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Chip
|
||||
size="small"
|
||||
label={label}
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
onClick={(event) => setAnchor(event.currentTarget)}
|
||||
aria-haspopup="menu"
|
||||
aria-label={t("models:aiDescriptions.chipAria")}
|
||||
/>
|
||||
<Menu anchorEl={anchor} open={Boolean(anchor)} onClose={() => setAnchor(null)}>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setAnchor(null);
|
||||
actions.setComparing(true);
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<CompareArrowsIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("models:aiDescriptions.compareOriginal")}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
disabled={actions.busy}
|
||||
onClick={() => {
|
||||
setAnchor(null);
|
||||
void actions.revert();
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<UndoIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("models:aiDescriptions.revertOriginal")}</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
{actions.comparing && (
|
||||
<DescriptionCompareDialog
|
||||
title={print.title || print.name}
|
||||
before={original}
|
||||
after={print.notes ?? ""}
|
||||
afterLabel={t("app:adminSettings.aiDescriptions.current")}
|
||||
onClose={() => actions.setComparing(false)}
|
||||
actions={
|
||||
<Button startIcon={<UndoIcon />} onClick={() => void actions.revert()} disabled={actions.busy}>
|
||||
{t("app:adminSettings.aiDescriptions.revert")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A suggested rewrite under the description, to compare and accept or reject. Its owner reviews it here, as Manage
|
||||
* is the admin's.
|
||||
*/
|
||||
export default function AiDescriptionReview(props: Props) {
|
||||
const { print } = props;
|
||||
const { t } = useTranslation(["models", "app"]);
|
||||
const actions = useDescriptionActions(props);
|
||||
const suggestion = print.ai_description_suggestion;
|
||||
if (!suggestion) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
p: 1.5,
|
||||
border: 1,
|
||||
borderColor: "info.main",
|
||||
borderRadius: "10px",
|
||||
bgcolor: (theme) => (theme.palette.mode === "dark" ? "rgba(41, 182, 246, 0.08)" : "rgba(2, 136, 209, 0.05)"),
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="flex-start">
|
||||
<AutoFixNormalIcon fontSize="small" color="info" sx={{ mt: 0.25 }} />
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{t("models:aiDescriptions.suggestionTitle")}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t("models:aiDescriptions.suggestionHint")}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1 }} flexWrap="wrap" useFlexGap>
|
||||
<Button size="small" startIcon={<CompareArrowsIcon />} onClick={() => actions.setComparing(true)}>
|
||||
{t("app:adminSettings.aiDescriptions.compare")}
|
||||
</Button>
|
||||
<Button size="small" variant="contained" onClick={() => void actions.accept()} disabled={actions.busy}>
|
||||
{t("app:adminSettings.aiDescriptions.accept")}
|
||||
</Button>
|
||||
<Button size="small" color="error" onClick={() => void actions.reject()} disabled={actions.busy}>
|
||||
{t("app:adminSettings.aiDescriptions.reject")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
{actions.comparing && (
|
||||
<DescriptionCompareDialog
|
||||
title={print.title || print.name}
|
||||
before={print.notes ?? ""}
|
||||
after={suggestion.notes}
|
||||
afterLabel={t("app:adminSettings.aiDescriptions.suggested")}
|
||||
onClose={() => actions.setComparing(false)}
|
||||
actions={
|
||||
<>
|
||||
<Button color="error" onClick={() => void actions.reject()} disabled={actions.busy}>
|
||||
{t("app:adminSettings.aiDescriptions.reject")}
|
||||
</Button>
|
||||
<Button variant="contained" onClick={() => void actions.accept()} disabled={actions.busy}>
|
||||
{t("app:adminSettings.aiDescriptions.accept")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -18,11 +18,13 @@ 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 AutoFixNormalIcon from "@mui/icons-material/AutoFixNormal";
|
||||
import LaunchIcon from "@mui/icons-material/Launch";
|
||||
import type { SxProps, Theme } from "@mui/material/styles";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import { type Print, PrintApiError, printsApi } from "../../api/prints";
|
||||
import { aiCategorizationApi } from "../../api/aiCategorization";
|
||||
import { aiDescriptionsApi } from "../../api/aiDescriptions";
|
||||
import { useToast } from "../../components/ToastProvider";
|
||||
import type { AuthUser } from "../../api/auth";
|
||||
import { collectionsApi } from "../../api/collections";
|
||||
@@ -69,7 +71,9 @@ export default function ModelActionsMenu({
|
||||
const confirmDialog = useConfirm();
|
||||
const showToast = useToast();
|
||||
const [aiEnabled, setAiEnabled] = useState(false);
|
||||
const [descriptionsEnabled, setDescriptionsEnabled] = useState(false);
|
||||
const [recategorizing, setRecategorizing] = useState(false);
|
||||
const [improving, setImproving] = useState(false);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void aiCategorizationApi
|
||||
@@ -80,6 +84,12 @@ export default function ModelActionsMenu({
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
});
|
||||
void aiDescriptionsApi
|
||||
.status()
|
||||
.then((status) => {
|
||||
if (active) setDescriptionsEnabled(status.enabled);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
@@ -200,6 +210,26 @@ export default function ModelActionsMenu({
|
||||
}
|
||||
};
|
||||
|
||||
// A description streams for as long as the model keeps writing; on a CPU that's minutes.
|
||||
const queueDescription = useAiOutcomeWatch(print.id, onUpdated, 60 * 60_000);
|
||||
const improveDescription = async () => {
|
||||
closeMenu();
|
||||
setImproving(true);
|
||||
try {
|
||||
await queueDescription(() => aiDescriptionsApi.improve(print.id).then(() => undefined));
|
||||
showToast({ message: t("models:aiDescriptions.queued") });
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else
|
||||
showToast({
|
||||
message: err instanceof Error ? err.message : t("models:aiDescriptions.failed"),
|
||||
severity: "error",
|
||||
});
|
||||
} finally {
|
||||
setImproving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const providerInfo = importProviderInfo(print.source_provider);
|
||||
const { slicerOption, targets: slicerTargets, normalizedTargets } = useOpenInSlicer(print);
|
||||
const openInSlicerHref = slicerTargets.length === 1 ? slicerTargets[0].href : undefined;
|
||||
@@ -271,15 +301,31 @@ export default function ModelActionsMenu({
|
||||
<ListItemText sx={{ color: "error.main" }}>{t("common:delete")}</ListItemText>
|
||||
</MenuItem>
|
||||
{aiEnabled && (
|
||||
<MenuItem onClick={() => void recategorize()} disabled={recategorizing}>
|
||||
// One suggestion at a time: the next waits until this one is accepted or rejected.
|
||||
<MenuItem onClick={() => void recategorize()} disabled={recategorizing || Boolean(print.ai_suggestion)}>
|
||||
<ListItemIcon>
|
||||
{recategorizing ? <CircularProgress size={18} /> : <AutoAwesomeIcon fontSize="small" />}
|
||||
</ListItemIcon>
|
||||
<ListItemText>
|
||||
<ListItemText secondary={print.ai_suggestion ? t("models:aiCategorization.suggestionPending") : undefined}>
|
||||
{t(uncategorized ? "models:aiCategorization.categorize" : "models:aiCategorization.recategorize")}
|
||||
</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
{descriptionsEnabled && (
|
||||
<MenuItem
|
||||
onClick={() => void improveDescription()}
|
||||
disabled={improving || Boolean(print.ai_description_pending)}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{improving ? <CircularProgress size={18} /> : <AutoFixNormalIcon fontSize="small" />}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
secondary={print.ai_description_pending ? t("models:aiDescriptions.suggestionPending") : undefined}
|
||||
>
|
||||
{t("models:aiDescriptions.improve")}
|
||||
</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
<Divider />
|
||||
{/* One target: a plain link to it. Several: this menu hands over to SlicerFileMenu,
|
||||
anchored on the same trigger, to pick which. */}
|
||||
|
||||
@@ -28,6 +28,7 @@ import ModelActionsMenu from "./ModelActionsMenu";
|
||||
import FavoriteButton from "./FavoriteButton";
|
||||
import ModelSidePanel from "./ModelSidePanel";
|
||||
import MarkdownDescription from "../../components/MarkdownDescription";
|
||||
import AiDescriptionReview, { AiDescriptionChip } from "./AiDescriptionReview";
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
|
||||
type Props = {
|
||||
@@ -324,13 +325,16 @@ export default function ModelDetailPage({ theme, onSelectCategory, onUnauthorize
|
||||
borderColor: dividerBorderColor,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
fontWeight={700}
|
||||
sx={{ mb: 1, color: (muiTheme) => muiTheme.thingport.headingText }}
|
||||
>
|
||||
{t("models:detail.description")}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
fontWeight={700}
|
||||
sx={{ color: (muiTheme) => muiTheme.thingport.headingText }}
|
||||
>
|
||||
{t("models:detail.description")}
|
||||
</Typography>
|
||||
<AiDescriptionChip print={print} onUpdated={setPrint} onUnauthorized={onUnauthorized} />
|
||||
</Stack>
|
||||
{print.notes?.trim() ? (
|
||||
<MarkdownDescription markdown={print.notes} />
|
||||
) : (
|
||||
@@ -338,6 +342,7 @@ export default function ModelDetailPage({ theme, onSelectCategory, onUnauthorize
|
||||
{t("models:detail.noDescription")}
|
||||
</Typography>
|
||||
)}
|
||||
<AiDescriptionReview print={print} onUpdated={setPrint} onUnauthorized={onUnauthorized} />
|
||||
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
|
||||
@@ -4,7 +4,8 @@ import { type Print, printsApi } from "../../api/prints";
|
||||
import { useNotifications } from "../../components/Layout/NotificationsContext";
|
||||
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
// Past the longest a provider can take: three attempts at the 300 s timeout settings allow.
|
||||
// Past the longest a category can take: three attempts at the 300 s timeout settings allow. A description streams
|
||||
// for as long as the model keeps writing, so its caller waits longer.
|
||||
const GIVE_UP_MS = 16 * 60_000;
|
||||
const RECENT = 10;
|
||||
|
||||
@@ -13,7 +14,7 @@ const RECENT = 10;
|
||||
* model, so the page that asked shows the outcome. The bell's own poll is slow and only counts. Notifications from
|
||||
* before the request are noted first, so no clock comparison decides which one is new.
|
||||
*/
|
||||
export function useAiOutcomeWatch(printId: string, onUpdated?: (print: Print) => void) {
|
||||
export function useAiOutcomeWatch(printId: string, onUpdated?: (print: Print) => void, giveUpMs = GIVE_UP_MS) {
|
||||
const { refresh } = useNotifications();
|
||||
const stopRef = useRef<(() => void) | null>(null);
|
||||
useEffect(() => () => stopRef.current?.(), []);
|
||||
@@ -34,7 +35,7 @@ export function useAiOutcomeWatch(printId: string, onUpdated?: (print: Print) =>
|
||||
};
|
||||
stopRef.current = stop;
|
||||
const path = `/models/${printId}`;
|
||||
const giveUpAt = Date.now() + GIVE_UP_MS;
|
||||
const giveUpAt = Date.now() + giveUpMs;
|
||||
const check = async () => {
|
||||
try {
|
||||
const { items } = await notificationsApi.list({ limit: RECENT });
|
||||
@@ -52,6 +53,6 @@ export function useAiOutcomeWatch(printId: string, onUpdated?: (print: Print) =>
|
||||
};
|
||||
timer = window.setTimeout(() => void check(), POLL_INTERVAL_MS);
|
||||
},
|
||||
[printId, onUpdated, refresh],
|
||||
[printId, onUpdated, refresh, giveUpMs],
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user