feat(AI): add improved descriptions

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-14 20:29:45 +02:00
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;
+42 -35
View File
@@ -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])
+2
View File
@@ -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);
+15
View File
@@ -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,
+150
View File
@@ -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;
+2
View File
@@ -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);
+98 -3
View File
@@ -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();
}),
+41 -225
View File
@@ -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,
});
}
+295
View File
@@ -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");
}
+2 -1
View File
@@ -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: {
+2
View File
@@ -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
View File
@@ -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),
);
}
+76
View File
@@ -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;
+4
View File
@@ -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" });
+444
View File
@@ -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: ![photo](/description-image/abc) 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);
});
});
+5 -1
View File
@@ -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. */
+130
View File
@@ -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,
};
+8
View File
@@ -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;
+83 -38
View File
@@ -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);
+2 -1
View File
@@ -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"),
}}
+97 -7
View File
@@ -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",
+101 -7
View File
@@ -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>
}
/>
)}
</>
);
}
+118 -164
View File
@@ -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>
);
}
+177
View File
@@ -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>
);
}
+79
View File
@@ -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>
);
}
+10
View File
@@ -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. */}
+12 -7
View File
@@ -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],
);
}