From 97f17e4fa35aa03db4ce8ac17b8419100efc333c Mon Sep 17 00:00:00 2001 From: Tautvydas Derzinskas Date: Wed, 14 Oct 2026 20:29:45 +0200 Subject: [PATCH] feat(AI): add improved descriptions --- .../migration.sql | 6 + backend/prisma/schema.prisma | 77 ++- backend/src/app.ts | 2 + backend/src/dto.ts | 15 + backend/src/routes/aiDescriptions.ts | 150 +++++ backend/src/routes/prints.ts | 2 + backend/src/routes/settings.ts | 101 ++- .../src/services/aiCategorizationService.ts | 266 ++------ backend/src/services/aiConnectionService.ts | 21 + backend/src/services/aiDescriptionService.ts | 597 ++++++++++++++++++ backend/src/services/aiProviderClient.ts | 295 +++++++++ backend/src/services/auditLog.ts | 3 +- backend/src/services/printCreation.ts | 2 + backend/src/services/printLoader.ts | 2 + backend/src/services/settingsService.ts | 76 +++ backend/tests/aiCategorization.test.ts | 4 + backend/tests/aiDescriptions.test.ts | 444 +++++++++++++ frontend/src/api/aiConnections.ts | 6 +- frontend/src/api/aiDescriptions.ts | 130 ++++ frontend/src/api/prints.ts | 8 + .../src/components/Layout/AiRunContext.tsx | 121 ++-- .../components/Layout/AiRunProgressBar.tsx | 75 ++- frontend/src/components/Layout/AppLayout.tsx | 3 +- .../src/components/Layout/BackToTopButton.tsx | 5 +- frontend/src/i18n/locales/en.json | 104 ++- frontend/src/i18n/locales/lt.json | 108 +++- .../pages/AiPage/AiCategorizationDialog.tsx | 86 +-- .../src/pages/AiPage/AiDescriptionsDialog.tsx | 219 +++++++ .../src/pages/AiPage/AiDescriptionsPanel.tsx | 306 +++++++++ .../src/pages/AiPage/AiFeaturesSection.tsx | 282 ++++----- .../pages/AiPage/CategorizationAnimation.tsx | 10 +- .../src/pages/AiPage/DescriptionAnimation.tsx | 125 ++++ .../pages/AiPage/DescriptionCompareDialog.tsx | 72 +++ frontend/src/pages/AiPage/FeatureTile.tsx | 177 ++++++ frontend/src/pages/AiPage/UseAiSelect.tsx | 79 +++ frontend/src/pages/AiPage/pseudoRandom.ts | 10 + .../ModelDetailPage/AiDescriptionReview.tsx | 203 ++++++ .../ModelDetailPage/ModelActionsMenu.tsx | 50 +- frontend/src/pages/ModelDetailPage/index.tsx | 19 +- .../ModelDetailPage/useAiOutcomeWatch.ts | 9 +- 40 files changed, 3677 insertions(+), 593 deletions(-) create mode 100644 backend/prisma/migrations/20261014150000_ai_descriptions/migration.sql create mode 100644 backend/src/routes/aiDescriptions.ts create mode 100644 backend/src/services/aiDescriptionService.ts create mode 100644 backend/src/services/aiProviderClient.ts create mode 100644 backend/tests/aiDescriptions.test.ts create mode 100644 frontend/src/api/aiDescriptions.ts create mode 100644 frontend/src/pages/AiPage/AiDescriptionsDialog.tsx create mode 100644 frontend/src/pages/AiPage/AiDescriptionsPanel.tsx create mode 100644 frontend/src/pages/AiPage/DescriptionAnimation.tsx create mode 100644 frontend/src/pages/AiPage/DescriptionCompareDialog.tsx create mode 100644 frontend/src/pages/AiPage/FeatureTile.tsx create mode 100644 frontend/src/pages/AiPage/UseAiSelect.tsx create mode 100644 frontend/src/pages/AiPage/pseudoRandom.ts create mode 100644 frontend/src/pages/ModelDetailPage/AiDescriptionReview.tsx diff --git a/backend/prisma/migrations/20261014150000_ai_descriptions/migration.sql b/backend/prisma/migrations/20261014150000_ai_descriptions/migration.sql new file mode 100644 index 0000000..cd9791a --- /dev/null +++ b/backend/prisma/migrations/20261014150000_ai_descriptions/migration.sql @@ -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; + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 54689d9..42268a4 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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]) diff --git a/backend/src/app.ts b/backend/src/app.ts index de22c21..f497020 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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); diff --git a/backend/src/dto.ts b/backend/src/dto.ts index bc9da5a..0b61180 100644 --- a/backend/src/dto.ts +++ b/backend/src/dto.ts @@ -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 { 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, diff --git a/backend/src/routes/aiDescriptions.ts b/backend/src/routes/aiDescriptions.ts new file mode 100644 index 0000000..4d299b5 --- /dev/null +++ b/backend/src/routes/aiDescriptions.ts @@ -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; diff --git a/backend/src/routes/prints.ts b/backend/src/routes/prints.ts index 7a3984b..aaea527 100644 --- a/backend/src/routes/prints.ts +++ b/backend/src/routes/prints.ts @@ -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); diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 163e629..1f19b00 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -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(); }), diff --git a/backend/src/services/aiCategorizationService.ts b/backend/src/services/aiCategorizationService.ts index ebc056b..7550573 100644 --- a/backend/src/services/aiCategorizationService.ts +++ b/backend/src/services/aiCategorizationService.ts @@ -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 & { 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 & { - baseUrl: string | null; - model: string | null; - sendImage: boolean; -}; +type RequestSettings = ProviderSettings & { sendImage: boolean }; export async function getAiCategorizationSettings(): Promise { 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 void)[] }>(); -let retryDelay = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds)); - -/** Keeps timeout and retry tests fast without changing production backoff behavior. */ -export function setAiCategorizationRetryDelayForTest(delay?: (milliseconds: number) => Promise): void { - retryDelay = delay ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))); -} - -async function limited(key: string, limit: number, task: () => Promise): Promise { - 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((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(); - 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(); +/** 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 & { 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(); diff --git a/backend/src/services/aiConnectionService.ts b/backend/src/services/aiConnectionService.ts index 5ae3d3b..206aa83 100644 --- a/backend/src/services/aiConnectionService.ts +++ b/backend/src/services/aiConnectionService.ts @@ -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 & { + baseUrl: string | null; + model: string | null; + connectionId: string | null; +}; + +export async function resolveAiProvider(id: string | null | undefined): Promise { + 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 { return prisma.$transaction(async (tx) => { diff --git a/backend/src/services/aiDescriptionService.ts b/backend/src/services/aiDescriptionService.ts new file mode 100644 index 0000000..f7a898b --- /dev/null +++ b/backend/src/services/aiDescriptionService.ts @@ -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 { + 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; + +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, 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 { + const found = new Set(); + for (const match of notes.matchAll(/!\[[^\]]*\]\(\s*]+)/g)) found.add(match[1]); + for (const match of notes.matchAll(/]*\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 { + 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 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({ + 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 { + 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) { + 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 { + 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(); + +/** + * 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 { + 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 = { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); + +/** 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 { + 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 { + 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, + }); +} diff --git a/backend/src/services/aiProviderClient.ts b/backend/src/services/aiProviderClient.ts new file mode 100644 index 0000000..113c9cd --- /dev/null +++ b/backend/src/services/aiProviderClient.ts @@ -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 & { + 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 void)[] }>(); +let retryDelay = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +/** Keeps timeout and retry tests fast without changing production backoff behavior. */ +export function setAiRetryDelayForTest(delay?: (milliseconds: number) => Promise): void { + retryDelay = delay ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))); +} + +export async function limited(key: string, limit: number, task: () => Promise): Promise { + 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((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 { + 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(); + +/** 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(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) => T; +}): Promise { + 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"); +} diff --git a/backend/src/services/auditLog.ts b/backend/src/services/auditLog.ts index 9a20ef2..6dfdb3d 100644 --- a/backend/src/services/auditLog.ts +++ b/backend/src/services/auditLog.ts @@ -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: { diff --git a/backend/src/services/printCreation.ts b/backend/src/services/printCreation.ts index e310def..60e6126 100644 --- a/backend/src/services/printCreation.ts +++ b/backend/src/services/printCreation.ts @@ -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); diff --git a/backend/src/services/printLoader.ts b/backend/src/services/printLoader.ts index dd6b3fd..3e2594d 100644 --- a/backend/src/services/printLoader.ts +++ b/backend/src/services/printLoader.ts @@ -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 { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const raw = value as Record; + const actions = raw.actions && typeof raw.actions === "object" ? (raw.actions as Record) : {}; + 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 { + 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> & { actions?: Partial }, +): Promise { + 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; diff --git a/backend/tests/aiCategorization.test.ts b/backend/tests/aiCategorization.test.ts index 4bceedc..46b0e19 100644 --- a/backend/tests/aiCategorization.test.ts +++ b/backend/tests/aiCategorization.test.ts @@ -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" }); diff --git a/backend/tests/aiDescriptions.test.ts b/backend/tests/aiDescriptions.test.ts new file mode 100644 index 0000000..259664d --- /dev/null +++ b/backend/tests/aiDescriptions.test.ts @@ -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; +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 { + prompts.length = 0; + await new Promise((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((resolve) => provider!.close(() => resolve())); + provider = null; +} + +/** One model, the default, and the feature on with it. */ +async function enable(baseUrl: string, patch: Partial = {}) { + 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 { + 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 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((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); + }); +}); diff --git a/frontend/src/api/aiConnections.ts b/frontend/src/api/aiConnections.ts index a1d9988..b7dce25 100644 --- a/frontend/src/api/aiConnections.ts +++ b/frontend/src/api/aiConnections.ts @@ -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(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 => request("/settings/ai-connections"), create: async (input: AiConnectionInput): Promise => { const created = await request("/settings/ai-connections", "POST", input); invalidateAiCategorizationStatus(); + invalidateAiDescriptionStatus(); return created; }, update: (id: string, input: Partial): Promise => @@ -51,10 +53,12 @@ export const aiConnectionsApi = { remove: async (id: string): Promise => { await request(`/settings/ai-connections/${encodeURIComponent(id)}`, "DELETE"); invalidateAiCategorizationStatus(); + invalidateAiDescriptionStatus(); }, makeDefault: async (id: string): Promise => { const made = await request(`/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. */ diff --git a/frontend/src/api/aiDescriptions.ts b/frontend/src/api/aiDescriptions.ts new file mode 100644 index 0000000..6822626 --- /dev/null +++ b/frontend/src/api/aiDescriptions.ts @@ -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> & { + actions?: Partial; +}; + +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(path: string, method = "GET", payload?: unknown): Promise { + 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; +} + +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 => request("/settings/ai-descriptions"), + updateSettings: async (input: AiDescriptionSettingsInput): Promise => { + const saved = await request("/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, +}; diff --git a/frontend/src/api/prints.ts b/frontend/src/api/prints.ts index 568e02a..42bf665 100644 --- a/frontend/src/api/prints.ts +++ b/frontend/src/api/prints.ts @@ -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; diff --git a/frontend/src/components/Layout/AiRunContext.tsx b/frontend/src/components/Layout/AiRunContext.tsx index 74ac6a2..9191891 100644 --- a/frontend/src/components/Layout/AiRunContext.tsx +++ b/frontend/src/components/Layout/AiRunContext.tsx @@ -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 = { + /** Null before the first run. */ + run: R | null; running: boolean; - startRun: (scope: AiRunScope) => Promise; + startRun: (scope: S) => Promise; cancelRun: () => Promise; }; +type AiRunContextValue = { + categorization: TrackedRun; + descriptions: TrackedRun; +}; + const AiRunContext = createContext(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(null); +type RunApi = { + /** Whether the feature is on; there's no run to pick up when it isn't. */ + enabled: () => Promise; + state: () => Promise; + start: (scope: S) => Promise; + cancel: () => Promise; +}; + +/** Picks up a run already going after a reload, follows it while it runs, and says when it ends. */ +function useTrackedRun( + api: RunApi, + onUnauthorized: (() => void) | undefined, + onFinished: React.RefObject<(() => void) | undefined>, +): TrackedRun { + const [run, setRun] = useState(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 = { + 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 = { + 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 {children}; } -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 { + return useAiRuns().categorization; +} + +export function useAiDescriptionRun(): TrackedRun { + return useAiRuns().descriptions; +} diff --git a/frontend/src/components/Layout/AiRunProgressBar.tsx b/frontend/src/components/Layout/AiRunProgressBar.tsx index 900293e..f6b8bac 100644 --- a/frontend/src/components/Layout/AiRunProgressBar.tsx +++ b/frontend/src/components/Layout/AiRunProgressBar.tsx @@ -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 ( - + + {/* The feature's own tile, small: which run this is at a glance. */} + + + - - - - {run.cancelled - ? t("aiRunProgress.stopping") - : total > 0 - ? t("aiRunProgress.label", { done, total }) - : t("aiRunProgress.starting")} - - + + {title} + 0 ? "determinate" : "indeterminate"} value={total > 0 ? Math.min(100, (done / total) * 100) : 0} sx={{ borderRadius: 1, height: 6 }} /> - + { setStopping(true); diff --git a/frontend/src/components/Layout/AppLayout.tsx b/frontend/src/components/Layout/AppLayout.tsx index 84bc3bd..e8efa8e 100644 --- a/frontend/src/components/Layout/AppLayout.tsx +++ b/frontend/src/components/Layout/AppLayout.tsx @@ -201,7 +201,8 @@ function AppLayoutShell({ {/* Both can run at once, so they stack here rather than each pinning itself to the bottom. */} theme.zIndex.snackbar }}> - + + diff --git a/frontend/src/components/Layout/BackToTopButton.tsx b/frontend/src/components/Layout/BackToTopButton.tsx index afac6f0..0a5c667 100644 --- a/frontend/src/components/Layout/BackToTopButton.tsx +++ b/frontend/src/components/Layout/BackToTopButton.tsx @@ -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"), }} diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 1afeb14..ecbcf51 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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", diff --git a/frontend/src/i18n/locales/lt.json b/frontend/src/i18n/locales/lt.json index 6c4fd16..69b9be5 100644 --- a/frontend/src/i18n/locales/lt.json +++ b/frontend/src/i18n/locales/lt.json @@ -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", diff --git a/frontend/src/pages/AiPage/AiCategorizationDialog.tsx b/frontend/src/pages/AiPage/AiCategorizationDialog.tsx index e749219..93920f2 100644 --- a/frontend/src/pages/AiPage/AiCategorizationDialog.tsx +++ b/frontend/src/pages/AiPage/AiCategorizationDialog.tsx @@ -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 ( - - {connection ? ( - - ) : ( - - )} - {label} - - ); -} +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(initialSection); + const [section, setSection] = useState(initialSection); const [saved, setSaved] = useState(false); const [draft, setDraft] = useState({ mode: settings.mode, @@ -94,14 +80,6 @@ export default function AiCategorizationDialog({ } }; - const defaultConnection = connections.find((connection) => connection.is_default); - const defaultOption = ( - - ); - return ( , }, { id: "manage" as const, - label: t("adminSettings.aiCategorization.manage"), + label: t("adminSettings.aiFeatures.manage"), icon: , disabled: !active, }, @@ -128,44 +106,13 @@ export default function AiCategorizationDialog({ ) : ( - {t("adminSettings.aiCategorization.privacy")} {!connections.length && {t("adminSettings.aiCategorization.noAi")}} - - {/* Empty means the default AI, which still shows text, so the label stays up on the border. */} - - {t("adminSettings.aiCategorization.useAi")} - - - + update("ai_connection_id", id)} + disabled={saving} + /> {t("adminSettings.aiCategorization.mode")} update("mode", event.target.value as Draft["mode"])} + disabled={saving} + > + {t("adminSettings.aiCategorization.suggest")} + {t("adminSettings.aiCategorization.auto")} + + + {draft.mode === "auto" && ( + + {t("adminSettings.aiDescriptions.autoHint")} + + )} + update("on_import", event.target.checked)} + disabled={saving} + /> + } + label={t("adminSettings.aiDescriptions.onImport")} + /> + update("send_image", event.target.checked)} + disabled={saving} + /> + } + label={ + <> + {t("adminSettings.aiCategorization.sendImage")} + + + } + /> + + + {t("adminSettings.aiDescriptions.whatAiDoes")} + + + {ACTIONS.map((key) => { + // Writing one needs the picture to go by. + const needsImage = key === "generate_missing" && !draft.send_image; + return ( + setAction(key, event.target.checked)} + disabled={saving || needsImage} + /> + } + label={ + <> + {t(`adminSettings.aiDescriptions.actions.${key}`)} + {needsImage && ( + + {t("adminSettings.aiDescriptions.needsImage")} + + )} + + } + /> + ); + })} + + + + + + {saved && {t("adminSettings.aiDescriptions.saved")}} + {error && {error}} + + )} + + ); +} diff --git a/frontend/src/pages/AiPage/AiDescriptionsPanel.tsx b/frontend/src/pages/AiPage/AiDescriptionsPanel.tsx new file mode 100644 index 0000000..55b4653 --- /dev/null +++ b/frontend/src/pages/AiPage/AiDescriptionsPanel.tsx @@ -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([]); + const [busy, setBusy] = useState(false); + const [comparing, setComparing] = useState(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, 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 ( + <> + + + {t("adminSettings.aiDescriptions.runTitle")} + + + {t("adminSettings.aiDescriptions.runHint")} + + + + {running && ( + + {t("adminSettings.aiDescriptions.runInProgress")} + + )} + + + + + + {t("adminSettings.aiDescriptions.suggestionsTitle", { count: suggestions?.total ?? 0 })} + + {!suggestions ? ( + + ) : items.length ? ( + <> + + + + + + + {items.map((item) => ( + + toggle(item.id)} /> + + + + + + + + ))} + + {suggestions.total > items.length && ( + + {t("adminSettings.aiDescriptions.firstPage", { shown: items.length, total: suggestions.total })} + + )} + + ) : ( + + {t("adminSettings.aiDescriptions.noSuggestions")} + + )} + + {Boolean(applied?.items.length) && ( + + + {t("adminSettings.aiDescriptions.appliedTitle", { count: applied?.total ?? 0 })} + + + {applied?.items.map((item) => ( + + + + + + + + ))} + + + )} + + {comparing?.kind === "suggestion" && ( + setComparing(null)} + actions={ + <> + + + + } + /> + )} + {comparing?.kind === "applied" && ( + setComparing(null)} + actions={ + + } + /> + )} + + ); +} diff --git a/frontend/src/pages/AiPage/AiFeaturesSection.tsx b/frontend/src/pages/AiPage/AiFeaturesSection.tsx index 38840da..8a5e006 100644 --- a/frontend/src/pages/AiPage/AiFeaturesSection.tsx +++ b/frontend/src/pages/AiPage/AiFeaturesSection.tsx @@ -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(null); - // The section the dialog is open on, or null when closed. - const [open, setOpen] = useState(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( + load: () => Promise, + update: (input: { enabled: boolean }) => Promise, + modelCount: number | undefined, + onUnauthorized: (() => void) | undefined, + failed: string, +) { + const [settings, setSettings] = useState(null); const [switching, setSwitching] = useState(false); const [error, setError] = useState(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 ( - {(settings === null || connections === null) && !error ? ( + {loading ? ( ) : ( - - - - 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 }, - }} - > - - {model && ( - // A bubble over the corner, so it reads as which model the feature uses rather than part of it. - - - - - - )} - - - - - {label} - - {hasModel && ( - void toggle(event.target.checked)} - disabled={!settings || switching} - slotProps={{ - input: { "aria-label": t("adminSettings.aiFeatures.toggleAria", { feature: label }) }, - }} - /> - )} - - + + void categorization.toggle(enabled)} + onOpen={(section) => setOpen({ feature: "categorization", section })} + /> + void descriptions.toggle(enabled)} + onOpen={(section) => setOpen({ feature: "descriptions", section })} + /> {t("adminSettings.aiFeatures.hint")} )} - {error && ( - setError(null)}> - {error} + {errors.map((feature) => ( + feature.setError(null)}> + {feature.error} - )} - setMenu(null)} - anchorReference="anchorPosition" - anchorPosition={menu ?? undefined} - slotProps={{ paper: { sx: { minWidth: 240, maxWidth: 300 } } }} - > - } - name={label} - status={t("adminSettings.aiFeatures.categorizationDescription")} - /> - { - setMenu(null); - void toggle(!enabled); - }} - > - - - - - {enabled ? t("adminSettings.aiFeatures.turnOff") : t("adminSettings.aiFeatures.turnOn")} - - - { - setMenu(null); - setOpen("settings"); - }} - > - - - - {t("adminSettings.aiCategorization.settings")} - - { - setMenu(null); - setOpen("manage"); - }} - > - - - - {t("adminSettings.aiCategorization.manage")} - - - {open && settings && ( + ))} + {open?.feature === "categorization" && categorization.settings && ( setOpen(null)} - onSaved={setSettings} + onSaved={categorization.setSettings} + onUnauthorized={onUnauthorized} + /> + )} + {open?.feature === "descriptions" && descriptions.settings && ( + setOpen(null)} + onSaved={descriptions.setSettings} onUnauthorized={onUnauthorized} /> )} diff --git a/frontend/src/pages/AiPage/CategorizationAnimation.tsx b/frontend/src/pages/AiPage/CategorizationAnimation.tsx index 756a9d2..3cda579 100644 --- a/frontend/src/pages/AiPage/CategorizationAnimation.tsx +++ b/frontend/src/pages/AiPage/CategorizationAnimation.tsx @@ -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; /** diff --git a/frontend/src/pages/AiPage/DescriptionAnimation.tsx b/frontend/src/pages/AiPage/DescriptionAnimation.tsx new file mode 100644 index 0000000..82db4b1 --- /dev/null +++ b/frontend/src/pages/AiPage/DescriptionAnimation.tsx @@ -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(firstLines); + const [phase, setPhase] = useState("hold"); + const [step, setStep] = useState(0); + const still = paused || prefersReducedMotion(); + + useEffect(() => { + if (still) return; + const timers: Record 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(); + 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 ( + + {lines.map((line) => { + const folded = line.state === "removed" || line.state === "added"; + return ( + + ); + })} + + ); +} diff --git a/frontend/src/pages/AiPage/DescriptionCompareDialog.tsx b/frontend/src/pages/AiPage/DescriptionCompareDialog.tsx new file mode 100644 index 0000000..f5bea9f --- /dev/null +++ b/frontend/src/pages/AiPage/DescriptionCompareDialog.tsx @@ -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 ( + + + {label} + + + {markdown.trim() ? ( + + ) : ( + + {t("adminSettings.aiDescriptions.empty")} + + )} + + + ); +} + +/** 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 ( + + {title} + + + + + + + + {actions} + + + + ); +} diff --git a/frontend/src/pages/AiPage/FeatureTile.tsx b/frontend/src/pages/AiPage/FeatureTile.tsx new file mode 100644 index 0000000..ce835ba --- /dev/null +++ b/frontend/src/pages/AiPage/FeatureTile.tsx @@ -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 ( + + + 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 }, + }} + > + + {model && ( + // A bubble over the corner, so it reads as which model the feature uses rather than part of it. + + + + + + )} + + + {/* Centred under the tile, the label wrapping to a second line when it must. */} + + + {label} + + {hasModel && ( + + + onToggle(event.target.checked)} + disabled={!ready || switching || locked} + slotProps={{ input: { "aria-label": t("adminSettings.aiFeatures.toggleAria", { feature: label }) } }} + /> + + + )} + + + setMenu(null)} + anchorReference="anchorPosition" + anchorPosition={menu ?? undefined} + slotProps={{ paper: { sx: { minWidth: 240, maxWidth: 300 } } }} + > + } + name={label} + status={description} + /> + { + setMenu(null); + onToggle(!enabled); + }} + > + + + + + {enabled ? t("adminSettings.aiFeatures.turnOff") : t("adminSettings.aiFeatures.turnOn")} + + + open("settings")}> + + + + {t("adminSettings.aiFeatures.settings")} + + open("manage")}> + + + + {t("adminSettings.aiFeatures.manage")} + + + + ); +} diff --git a/frontend/src/pages/AiPage/UseAiSelect.tsx b/frontend/src/pages/AiPage/UseAiSelect.tsx new file mode 100644 index 0000000..efc7629 --- /dev/null +++ b/frontend/src/pages/AiPage/UseAiSelect.tsx @@ -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 ( + + {connection ? ( + + ) : ( + + )} + {label} + + ); +} + +/** A model as it shows in the list. */ +function ModelOption({ connection }: { connection: AiConnection }) { + return ; +} + +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 = ( + + ); + return ( + + {/* Empty means the default AI, which still shows text, so the label stays up on the border. */} + + {t("adminSettings.aiCategorization.useAi")} + + + + ); +} diff --git a/frontend/src/pages/AiPage/pseudoRandom.ts b/frontend/src/pages/AiPage/pseudoRandom.ts new file mode 100644 index 0000000..fa7946e --- /dev/null +++ b/frontend/src/pages/AiPage/pseudoRandom.ts @@ -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; +} diff --git a/frontend/src/pages/ModelDetailPage/AiDescriptionReview.tsx b/frontend/src/pages/ModelDetailPage/AiDescriptionReview.tsx new file mode 100644 index 0000000..ea476a3 --- /dev/null +++ b/frontend/src/pages/ModelDetailPage/AiDescriptionReview.tsx @@ -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, 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(null); + if (!print.description_by_ai) return null; + const original = print.ai_description_original; + const label = t("models:aiCategorization.byAi"); + if (original == null) { + return ; + } + return ( + <> + setAnchor(event.currentTarget)} + aria-haspopup="menu" + aria-label={t("models:aiDescriptions.chipAria")} + /> + setAnchor(null)}> + { + setAnchor(null); + actions.setComparing(true); + }} + > + + + + {t("models:aiDescriptions.compareOriginal")} + + { + setAnchor(null); + void actions.revert(); + }} + > + + + + {t("models:aiDescriptions.revertOriginal")} + + + {actions.comparing && ( + actions.setComparing(false)} + actions={ + + } + /> + )} + + ); +} + +/** + * 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 ( + <> + (theme.palette.mode === "dark" ? "rgba(41, 182, 246, 0.08)" : "rgba(2, 136, 209, 0.05)"), + }} + > + + + + + {t("models:aiDescriptions.suggestionTitle")} + + + {t("models:aiDescriptions.suggestionHint")} + + + + + + + + + + {actions.comparing && ( + actions.setComparing(false)} + actions={ + <> + + + + } + /> + )} + + ); +} diff --git a/frontend/src/pages/ModelDetailPage/ModelActionsMenu.tsx b/frontend/src/pages/ModelDetailPage/ModelActionsMenu.tsx index 550eb2e..7980b67 100644 --- a/frontend/src/pages/ModelDetailPage/ModelActionsMenu.tsx +++ b/frontend/src/pages/ModelDetailPage/ModelActionsMenu.tsx @@ -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({ {t("common:delete")} {aiEnabled && ( - void recategorize()} disabled={recategorizing}> + // One suggestion at a time: the next waits until this one is accepted or rejected. + void recategorize()} disabled={recategorizing || Boolean(print.ai_suggestion)}> {recategorizing ? : } - + {t(uncategorized ? "models:aiCategorization.categorize" : "models:aiCategorization.recategorize")} )} + {descriptionsEnabled && ( + void improveDescription()} + disabled={improving || Boolean(print.ai_description_pending)} + > + + {improving ? : } + + + {t("models:aiDescriptions.improve")} + + + )} {/* One target: a plain link to it. Several: this menu hands over to SlicerFileMenu, anchored on the same trigger, to pick which. */} diff --git a/frontend/src/pages/ModelDetailPage/index.tsx b/frontend/src/pages/ModelDetailPage/index.tsx index 8129151..659972b 100644 --- a/frontend/src/pages/ModelDetailPage/index.tsx +++ b/frontend/src/pages/ModelDetailPage/index.tsx @@ -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, }} > - muiTheme.thingport.headingText }} - > - {t("models:detail.description")} - + + muiTheme.thingport.headingText }} + > + {t("models:detail.description")} + + + {print.notes?.trim() ? ( ) : ( @@ -338,6 +342,7 @@ export default function ModelDetailPage({ theme, onSelectCategory, onUnauthorize {t("models:detail.noDescription")} )} + 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], ); }