mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
[CU-8689pphvt] Adds content ingestion for models (#1397)
* Adds service and webhook to ingest model content * Adds migration and adjustments to allow model ingestion * Adds reports page for mods * Adds example env vars * Fixes after merging with main * 5.0.139 * Adjusts moderator/models page * Fix images API endpoint not supporting postId properly * 5.0.143 * Adds a quick way for mods to update a flagged model while resolving * Prevents awaiting model ingestion * Allows sorting flagged models --------- Co-authored-by: Luis Rojas <lrojas94@gmail.com>
This commit is contained in:
committed by
GitHub
parent
783f46efd0
commit
4a4f50f09e
@@ -177,3 +177,7 @@ CF_INVISIBLE_TURNSTILE_SECRET=1x0000000000000000000000000000000AA
|
||||
|
||||
NEXT_PUBLIC_CF_MANAGED_TURNSTILE_SITEKEY=1x00000000000000000000AA
|
||||
CF_MANAGED_TURNSTILE_SECRET=1x0000000000000000000000000000000AA
|
||||
|
||||
# Model Ingestion
|
||||
CONTENT_SCAN_ENDPOINT=http://localhost:3000
|
||||
CONTENT_SCAN_CALLBACK_URL=http://localhost:3000
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ModelFlagStatus" AS ENUM ('Pending', 'Resolved');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Model" ADD COLUMN "scannedAt" TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ModelFlag" ADD COLUMN "minor" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "nsfw" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "poi" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "status" "ModelFlagStatus" NOT NULL DEFAULT 'Pending',
|
||||
ADD COLUMN "triggerWords" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ModelFlag" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ADD COLUMN "details" JSONB;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ModelFlag" DROP COLUMN "nameNsfw",
|
||||
ADD COLUMN "poiName" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ModelFlag_status_idx" ON "ModelFlag"("status");
|
||||
+18
-3
@@ -544,6 +544,7 @@ model Model {
|
||||
availability Availability @default(Public)
|
||||
nsfwLevel Int @default(0)
|
||||
lockedProperties String[] @default([])
|
||||
scannedAt DateTime?
|
||||
|
||||
// Licensing
|
||||
allowNoCredit Boolean @default(true)
|
||||
@@ -577,10 +578,24 @@ model Model {
|
||||
@@index([status, nsfw])
|
||||
}
|
||||
|
||||
enum ModelFlagStatus {
|
||||
Pending
|
||||
Resolved
|
||||
}
|
||||
|
||||
model ModelFlag {
|
||||
modelId Int @id
|
||||
model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)
|
||||
nameNsfw Boolean @default(false)
|
||||
modelId Int @id
|
||||
model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)
|
||||
poi Boolean @default(false)
|
||||
minor Boolean @default(false)
|
||||
nsfw Boolean @default(false)
|
||||
triggerWords Boolean @default(false)
|
||||
poiName Boolean @default(false)
|
||||
status ModelFlagStatus @default(Pending)
|
||||
details Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
model License {
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import { Anchor, Badge, Button, Chip, JsonInput, Modal, Paper, Stack, Text } from '@mantine/core';
|
||||
import { IconExternalLink } from '@tabler/icons-react';
|
||||
import {
|
||||
MantineReactTable,
|
||||
MRT_ColumnDef,
|
||||
MRT_PaginationState,
|
||||
MRT_SortingState,
|
||||
} from 'mantine-react-table';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { Collection } from '~/components/Collection/Collection';
|
||||
import { useDialogContext } from '~/components/Dialog/DialogProvider';
|
||||
import { dialogStore } from '~/components/Dialog/dialogStore';
|
||||
import { Form, InputCheckbox, InputRTE, InputText, useForm } from '~/libs/form';
|
||||
import { modelUpsertSchema } from '~/server/schema/model.schema';
|
||||
import { showErrorNotification } from '~/utils/notifications';
|
||||
import { trpc } from '~/utils/trpc';
|
||||
import { isNumber } from '~/utils/type-guards';
|
||||
|
||||
export function FlaggedModelsList() {
|
||||
const router = useRouter();
|
||||
const page = isNumber(router.query.page) ? Number(router.query.page) : 1;
|
||||
const [pagination, setPagination] = useState<MRT_PaginationState>({
|
||||
pageIndex: page - 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
const [sorting, setSorting] = useState<MRT_SortingState>([{ id: 'createdAt', desc: true }]);
|
||||
|
||||
const { data, isLoading, isFetching, isRefetching } = trpc.moderator.models.queryFlagged.useQuery(
|
||||
{ page: pagination.pageIndex + 1, limit: pagination.pageSize, sort: sorting }
|
||||
);
|
||||
const flaggedModels = data?.items ?? [];
|
||||
|
||||
const columns = useMemo<MRT_ColumnDef<(typeof flaggedModels)[number]>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'modelId',
|
||||
header: 'Model',
|
||||
accessorKey: 'model.name',
|
||||
enableColumnActions: false,
|
||||
enableSorting: false,
|
||||
Cell: ({ row: { original } }) => (
|
||||
<Link href={`/models/${original.modelId}?view=basic`} passHref>
|
||||
<Anchor target="_blank">
|
||||
<div className="flex flex-nowrap gap-1">
|
||||
<IconExternalLink className="shrink-0 grow-0" size={16} />
|
||||
<Text span inline>
|
||||
{original.model.name}
|
||||
</Text>
|
||||
</div>
|
||||
</Anchor>
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Review',
|
||||
accessorFn: (row) => row.model.id,
|
||||
enableColumnActions: false,
|
||||
enableSorting: false,
|
||||
Cell: ({ row: { original } }) => {
|
||||
const items = Object.entries(original)
|
||||
.filter(
|
||||
([key, value]) =>
|
||||
['poi', 'nsfw', 'minor', 'triggerWords', 'poiName'].includes(key) && !!value
|
||||
)
|
||||
.map(([key, value]) => ({ name: key, value }));
|
||||
|
||||
return (
|
||||
<Collection
|
||||
items={items}
|
||||
renderItem={(item) => <Badge color="yellow">{item.name}</Badge>}
|
||||
grouped
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Action',
|
||||
accessorKey: 'model.id',
|
||||
size: 100,
|
||||
enableColumnActions: false,
|
||||
enableSorting: false,
|
||||
mantineTableHeadCellProps: { align: 'right' },
|
||||
mantineTableBodyCellProps: { align: 'right' },
|
||||
Cell: ({ row: { original } }) => (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
dialogStore.trigger({
|
||||
component: DetailsModal,
|
||||
props: { model: original.model, details: original.details },
|
||||
})
|
||||
}
|
||||
compact
|
||||
>
|
||||
Resolve
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<MantineReactTable
|
||||
columns={columns}
|
||||
data={flaggedModels}
|
||||
rowCount={data?.totalItems ?? 0}
|
||||
maxMultiSortColCount={2}
|
||||
onPaginationChange={setPagination}
|
||||
onSortingChange={setSorting}
|
||||
enableStickyHeader
|
||||
enableSortingRemoval
|
||||
enableHiding={false}
|
||||
enableGlobalFilter={false}
|
||||
enableColumnFilters={false}
|
||||
mantineTableProps={{
|
||||
sx: { tableLayout: 'fixed' },
|
||||
}}
|
||||
mantineTableContainerProps={{ sx: { maxHeight: 450 } }}
|
||||
initialState={{
|
||||
density: 'xs',
|
||||
}}
|
||||
state={{
|
||||
isLoading: isLoading || isRefetching,
|
||||
showProgressBars: isFetching,
|
||||
sorting,
|
||||
pagination,
|
||||
}}
|
||||
renderTopToolbarCustomActions={({ table }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Text span>Filters: </Text>
|
||||
<Chip
|
||||
size="xs"
|
||||
variant="filled"
|
||||
onChange={(value) =>
|
||||
value
|
||||
? setSorting([
|
||||
{ id: 'poi', desc: true },
|
||||
{ id: 'nsfw', desc: true },
|
||||
])
|
||||
: table.resetSorting(true)
|
||||
}
|
||||
>
|
||||
High Priority
|
||||
</Chip>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const schema = modelUpsertSchema.pick({
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
poi: true,
|
||||
nsfw: true,
|
||||
minor: true,
|
||||
type: true,
|
||||
uploadType: true,
|
||||
status: true,
|
||||
});
|
||||
|
||||
function DetailsModal({ model, details }: { model: z.infer<typeof schema>; details: MixedObject }) {
|
||||
const context = useDialogContext();
|
||||
const queryUtils = trpc.useUtils();
|
||||
const form = useForm({ schema, defaultValues: { ...model }, shouldUnregister: false });
|
||||
const isDirty = form.formState.isDirty;
|
||||
|
||||
const upsertModelMutation = trpc.model.upsert.useMutation({
|
||||
onSuccess: async (result) => {
|
||||
await queryUtils.model.getById.invalidate({ id: result.id });
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification({
|
||||
title: 'Error saving model',
|
||||
error: new Error(error.message),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const resolveFlaggedModelMutation = trpc.moderator.models.resolveFlagged.useMutation({
|
||||
onSuccess: async () => {
|
||||
await queryUtils.moderator.models.queryFlagged.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification({
|
||||
title: 'Error resolving flagged model',
|
||||
error: new Error(error.message),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (data: z.infer<typeof schema>) => {
|
||||
if (!data.id) return;
|
||||
|
||||
try {
|
||||
if (isDirty) await upsertModelMutation.mutateAsync(data);
|
||||
|
||||
await resolveFlaggedModelMutation.mutateAsync({ id: data.id });
|
||||
|
||||
context.onClose();
|
||||
} catch {
|
||||
// Error is handled in the mutation
|
||||
}
|
||||
};
|
||||
|
||||
const [poi, nsfw] = form.watch(['poi', 'nsfw']);
|
||||
|
||||
return (
|
||||
<Modal {...context} title="Resolve Model" size="75%" centered>
|
||||
<div className="flex flex-nowrap gap-8">
|
||||
<Form form={form} onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<InputText name="name" label="Name" placeholder="Name" withAsterisk />
|
||||
|
||||
<InputRTE
|
||||
name="description"
|
||||
label="Description"
|
||||
description="Tell us what your model does"
|
||||
includeControls={[
|
||||
'heading',
|
||||
'formatting',
|
||||
'list',
|
||||
'link',
|
||||
'media',
|
||||
'mentions',
|
||||
'colors',
|
||||
]}
|
||||
editorSize="xl"
|
||||
placeholder="What does your model do? What's it for? What is your model good at? What should it be used for? What is your resource bad at? How should it not be used?"
|
||||
withAsterisk
|
||||
/>
|
||||
<Paper radius="md" p="xl" withBorder>
|
||||
<Stack spacing="xs">
|
||||
<Text size="md" weight={500}>
|
||||
This resource:
|
||||
</Text>
|
||||
<InputCheckbox
|
||||
name="poi"
|
||||
label="Depicts an actual person (Resource cannot be used on Civitai on-site Generator)"
|
||||
onChange={(e) => {
|
||||
form.setValue('nsfw', e.target.checked ? false : undefined);
|
||||
}}
|
||||
/>
|
||||
<InputCheckbox
|
||||
name="nsfw"
|
||||
label="Is intended to produce mature themes"
|
||||
disabled={poi}
|
||||
onChange={(event) =>
|
||||
event.target.checked ? form.setValue('minor', false) : null
|
||||
}
|
||||
/>
|
||||
<InputCheckbox
|
||||
name="minor"
|
||||
label="Cannot be used for NSFW generation"
|
||||
disabled={nsfw}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={resolveFlaggedModelMutation.isLoading || upsertModelMutation.isLoading}
|
||||
>
|
||||
Save & Resolve
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<div className="w-64 flex-none">
|
||||
<Text size="md" weight={600} className="mb-2">
|
||||
Scan Details
|
||||
</Text>
|
||||
<JsonInput value={JSON.stringify(details, null, 2)} minRows={4} formatOnBlur autosize />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Vendored
+3
@@ -181,6 +181,9 @@ export const serverSchema = z.object({
|
||||
CLOUDFLARE_TURNSTILE_SECRET: z.string().optional(),
|
||||
CF_INVISIBLE_TURNSTILE_SECRET: z.string().optional(),
|
||||
CF_MANAGED_TURNSTILE_SECRET: z.string().optional(),
|
||||
CONTENT_SCAN_ENDPOINT: z.string().optional(),
|
||||
CONTENT_SCAN_CALLBACK_URL: z.string().optional(),
|
||||
CONTENT_SCAN_MODEL: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,12 +3,12 @@ import { z } from 'zod';
|
||||
import { dbRead } from '~/server/db/client';
|
||||
import { dataProcessor } from '~/server/db/db-helpers';
|
||||
import { pgDbWrite } from '~/server/db/pgDb';
|
||||
import { ingestModel } from '~/server/services/model.service';
|
||||
import { WebhookEndpoint } from '~/server/utils/endpoint-helpers';
|
||||
import { hasNsfwWords } from '~/utils/metadata/audit';
|
||||
|
||||
const schema = z.object({
|
||||
concurrency: z.coerce.number().min(1).max(50).optional().default(15),
|
||||
batchSize: z.coerce.number().min(0).optional().default(500),
|
||||
concurrency: z.coerce.number().min(1).max(10).optional().default(1),
|
||||
batchSize: z.coerce.number().min(0).optional().default(100),
|
||||
start: z.coerce.number().min(0).optional().default(0),
|
||||
end: z.coerce.number().min(0).optional(),
|
||||
after: z.coerce.date().optional(),
|
||||
@@ -16,60 +16,62 @@ const schema = z.object({
|
||||
});
|
||||
|
||||
export default WebhookEndpoint(async (req, res) => {
|
||||
const params = schema.parse(req.query);
|
||||
let totalProcessed = 0;
|
||||
let totalTitleNsfw = 0;
|
||||
try {
|
||||
const params = schema.parse(req.query);
|
||||
let totalProcessed = 0;
|
||||
|
||||
await dataProcessor({
|
||||
params,
|
||||
runContext: res,
|
||||
rangeFetcher: async (context) => {
|
||||
if (params.after) {
|
||||
const results = await dbRead.$queryRaw<{ start: number; end: number }[]>`
|
||||
WITH dates AS (
|
||||
SELECT
|
||||
MIN("createdAt") as start,
|
||||
MAX("createdAt") as end
|
||||
FROM "Model" WHERE "createdAt" > ${params.after}
|
||||
)
|
||||
SELECT MIN(id) as start, MAX(id) as end
|
||||
FROM "Model" i
|
||||
JOIN dates d ON d.start = i."createdAt" OR d.end = i."createdAt";`;
|
||||
return results[0];
|
||||
}
|
||||
const [{ max }] = await dbRead.$queryRaw<{ max: number }[]>(
|
||||
Prisma.sql`SELECT MAX(id) "max" FROM "Model";`
|
||||
);
|
||||
return { ...context, end: max };
|
||||
},
|
||||
processor: async ({ start, end, cancelFns }) => {
|
||||
const modelsQuery = await pgDbWrite.cancellableQuery<{ id: number; name: string }>(Prisma.sql`
|
||||
SELECT id, name FROM "Model" WHERE id BETWEEN ${start} AND ${end}
|
||||
`);
|
||||
cancelFns.push(modelsQuery.cancel);
|
||||
|
||||
const models = await modelsQuery.result();
|
||||
|
||||
const toInsert = models
|
||||
.map(({ id, name }) => {
|
||||
return { id, titleNsfw: hasNsfwWords(name) };
|
||||
})
|
||||
.filter((x) => x.titleNsfw);
|
||||
totalProcessed += models.length;
|
||||
totalTitleNsfw += toInsert.length;
|
||||
|
||||
if (toInsert.length > 0) {
|
||||
const insertQuery = await pgDbWrite.cancellableQuery(Prisma.sql`
|
||||
INSERT INTO "ModelFlag" ("modelId", "titleNsfw")
|
||||
VALUES ${Prisma.raw(
|
||||
toInsert.map(({ id, titleNsfw }) => `(${id}, ${titleNsfw})`).join(', ')
|
||||
)}
|
||||
ON CONFLICT DO NOTHING;
|
||||
await dataProcessor({
|
||||
params,
|
||||
runContext: res,
|
||||
rangeFetcher: async (context) => {
|
||||
if (params.after) {
|
||||
const results = await dbRead.$queryRaw<{ start: number; end: number }[]>`
|
||||
WITH dates AS (
|
||||
SELECT
|
||||
MIN("createdAt") as start,
|
||||
MAX("createdAt") as end
|
||||
FROM "Model" WHERE "createdAt" > ${params.after}
|
||||
)
|
||||
SELECT MIN(id) as start, MAX(id) as end
|
||||
FROM "Model" i
|
||||
JOIN dates d ON d.start = i."createdAt" OR d.end = i."createdAt";`;
|
||||
return results[0];
|
||||
}
|
||||
const [{ max }] = await dbRead.$queryRaw<{ max: number }[]>(
|
||||
Prisma.sql`SELECT MAX(id) "max" FROM "Model";`
|
||||
);
|
||||
return { ...context, end: max };
|
||||
},
|
||||
processor: async ({ start, end, cancelFns }) => {
|
||||
const modelsQuery = await pgDbWrite.cancellableQuery<{
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
poi: boolean;
|
||||
nsfw: boolean;
|
||||
minor: boolean;
|
||||
}>(Prisma.sql`
|
||||
SELECT id, name, description, poi, nsfw, minor
|
||||
FROM "Model"
|
||||
WHERE id BETWEEN ${start} AND ${end}
|
||||
AND (status = 'Published'::"ModelStatus" OR status = 'Scheduled'::"ModelStatus")
|
||||
`);
|
||||
cancelFns.push(insertQuery.cancel);
|
||||
await insertQuery.result();
|
||||
}
|
||||
console.log(`Processed models ${start} - ${end}`, { totalProcessed, totalTitleNsfw });
|
||||
},
|
||||
});
|
||||
cancelFns.push(modelsQuery.cancel);
|
||||
|
||||
const models = await modelsQuery.result();
|
||||
if (!models.length) return;
|
||||
|
||||
const toIngest = models.map(ingestModel);
|
||||
await Promise.all(toIngest);
|
||||
totalProcessed += toIngest.length;
|
||||
|
||||
console.log(`Processed models ${start} - ${end}`, { totalProcessed });
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(200).json({ fiished: true, totalProcessed });
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
return res.status(500).json({ error: e.message, query: req.query });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { dbWrite } from '~/server/db/client';
|
||||
import { logToAxiom } from '~/server/logging/client';
|
||||
import { modelScanResultSchema } from '~/server/schema/model-flag.schema';
|
||||
import { upsertModelFlag } from '~/server/services/model-flag.service';
|
||||
import { WebhookEndpoint } from '~/server/utils/endpoint-helpers';
|
||||
|
||||
const logWebhook = (data: MixedObject) => {
|
||||
logToAxiom({ name: 'model-scan-result', type: 'error', ...data }, 'webhooks').catch(() => null);
|
||||
};
|
||||
|
||||
export default WebhookEndpoint(async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'POST') {
|
||||
logWebhook({ message: 'Wrong method', data: { method: req.method, input: req.body } });
|
||||
return res.status(405).json({ error: 'Method Not Allowed' });
|
||||
}
|
||||
|
||||
const result = modelScanResultSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
logWebhook({
|
||||
message: 'Could not parse body',
|
||||
data: { error: result.error.format(), input: req.body },
|
||||
});
|
||||
return res.status(400).json({ error: 'Invalid Request', details: result.error.format() });
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
if (data.status === 'failure') {
|
||||
logWebhook({
|
||||
message: 'Model scan failed',
|
||||
data: { input: req.body },
|
||||
});
|
||||
return res.status(500).json({ error: 'Could not scan model' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Check scan results and handle accordingly
|
||||
await dbWrite.model.update({
|
||||
where: { id: data.user_declared.content.id },
|
||||
data: { scannedAt: new Date() },
|
||||
});
|
||||
|
||||
await upsertModelFlag({
|
||||
modelId: data.user_declared.content.id,
|
||||
scanResult: {
|
||||
poi: data.flags.POI_flag,
|
||||
nsfw: data.flags.NSFW_flag,
|
||||
minor: data.flags.minor_flag,
|
||||
triggerWords: data.flags.triggerwords_flag,
|
||||
poiName: !!data.llm_interrogation.POIName?.length,
|
||||
},
|
||||
details: data.llm_interrogation,
|
||||
});
|
||||
|
||||
return res.status(200).json({ ok: true });
|
||||
} catch (error) {
|
||||
logWebhook({
|
||||
message: 'Unhandled exception',
|
||||
data: { error, input: req.body },
|
||||
});
|
||||
|
||||
return res.status(500).json({ error: 'Internal Server Error', details: error });
|
||||
}
|
||||
});
|
||||
@@ -100,7 +100,7 @@ import { useCurrentUser } from '~/hooks/useCurrentUser';
|
||||
import useIsClient from '~/hooks/useIsClient';
|
||||
import { openContext } from '~/providers/CustomModalsProvider';
|
||||
import { useFeatureFlags } from '~/providers/FeatureFlagsProvider';
|
||||
import { BaseModel, CAROUSEL_LIMIT } from '~/server/common/constants';
|
||||
import { CAROUSEL_LIMIT } from '~/server/common/constants';
|
||||
import { ImageSort, ModelType } from '~/server/common/enums';
|
||||
import { unpublishReasons } from '~/server/common/moderation-helpers';
|
||||
import { ModelMeta } from '~/server/schema/model.schema';
|
||||
@@ -113,7 +113,13 @@ import { formatDate, isFutureDate } from '~/utils/date-helpers';
|
||||
import { containerQuery } from '~/utils/mantine-css-helpers';
|
||||
import { showErrorNotification, showSuccessNotification } from '~/utils/notifications';
|
||||
import { abbreviateNumber } from '~/utils/number-helpers';
|
||||
import { getBaseModelEcosystemName, getDisplayName, removeTags, slugit, splitUppercase } from '~/utils/string-helpers';
|
||||
import {
|
||||
getBaseModelEcosystemName,
|
||||
getDisplayName,
|
||||
removeTags,
|
||||
slugit,
|
||||
splitUppercase,
|
||||
} from '~/utils/string-helpers';
|
||||
import { trpc } from '~/utils/trpc';
|
||||
import { isNumber } from '~/utils/type-guards';
|
||||
|
||||
@@ -203,6 +209,7 @@ export default function ModelDetailsV2({
|
||||
}
|
||||
);
|
||||
|
||||
const view = router.query.view;
|
||||
const rawVersionId = router.query.modelVersionId;
|
||||
const modelVersionId = Number(
|
||||
(Array.isArray(rawVersionId) ? rawVersionId[0] : rawVersionId) ?? model?.modelVersions[0]?.id
|
||||
@@ -227,7 +234,7 @@ export default function ModelDetailsV2({
|
||||
publishedVersions[0] ??
|
||||
null;
|
||||
const [selectedVersion, setSelectedVersion] = useState<ModelVersionDetail | null>(latestVersion);
|
||||
const selectedEcosystemName = getBaseModelEcosystemName(selectedVersion?.baseModel)
|
||||
const selectedEcosystemName = getBaseModelEcosystemName(selectedVersion?.baseModel);
|
||||
const tippedAmount = useBuzzTippingStore({ entityType: 'Model', entityId: model?.id ?? -1 });
|
||||
|
||||
const { canDownload: hasDownloadPermissions, canGenerate: hasGeneratePermissions } =
|
||||
@@ -508,12 +515,13 @@ export default function ModelDetailsV2({
|
||||
isFutureDate(selectedVersion.earlyAccessDeadline);
|
||||
const category = model.tagsOnModels.find(({ tag }) => !!tag.isCategory)?.tag;
|
||||
const tags = model.tagsOnModels.filter(({ tag }) => !tag.isCategory).map((tag) => tag.tag);
|
||||
const canLoadBelowTheFold = isClient && !loadingModel && !loadingImages;
|
||||
const basicView = view === 'basic' && isModerator;
|
||||
const canLoadBelowTheFold = isClient && !loadingModel && !loadingImages && !basicView;
|
||||
const unpublishedReason = model.meta?.unpublishedReason ?? 'other';
|
||||
const unpublishedMessage =
|
||||
unpublishedReason !== 'other'
|
||||
? unpublishReasons[unpublishedReason]?.notificationMessage
|
||||
: `Removal reason: ${model.meta?.customMessage}.` ?? '';
|
||||
: `Removal reason: ${model.meta?.customMessage}.`;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Modal,
|
||||
Pagination,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
@@ -21,6 +22,8 @@ import { TRPCClientErrorBase } from '@trpc/client';
|
||||
import { DefaultErrorShape } from '@trpc/server';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { Meta } from '~/components/Meta/Meta';
|
||||
import { FlaggedModelsList } from '~/components/Moderation/FlaggedModelsList';
|
||||
|
||||
import { unpublishReasons } from '~/server/common/moderation-helpers';
|
||||
import { allBrowsingLevelsFlag } from '~/shared/constants/browsingLevel.constants';
|
||||
@@ -35,24 +38,31 @@ type State = {
|
||||
page: number;
|
||||
opened: boolean;
|
||||
selectedModel: ModelGetAllPagedSimple['items'][number] | null;
|
||||
section: 'unpublished' | 'ai';
|
||||
};
|
||||
|
||||
export default function ModeratorModels() {
|
||||
const queryUtils = trpc.useContext();
|
||||
const queryUtils = trpc.useUtils();
|
||||
const [state, setState] = useState<State>({
|
||||
declineReason: 'Insufficient changes',
|
||||
page: 1,
|
||||
opened: false,
|
||||
selectedModel: null,
|
||||
section: 'unpublished',
|
||||
});
|
||||
|
||||
const { data, isLoading } = trpc.model.getAllPagedSimple.useQuery({
|
||||
needsReview: true,
|
||||
status: [ModelStatus.UnpublishedViolation, ModelStatus.Published],
|
||||
page: state.page,
|
||||
limit: 20,
|
||||
browsingLevel: allBrowsingLevelsFlag,
|
||||
});
|
||||
const viewingUnpublished = state.section === 'unpublished';
|
||||
|
||||
const { data, isLoading } = trpc.model.getAllPagedSimple.useQuery(
|
||||
{
|
||||
needsReview: true,
|
||||
status: [ModelStatus.UnpublishedViolation, ModelStatus.Published],
|
||||
page: state.page,
|
||||
limit: 20,
|
||||
browsingLevel: allBrowsingLevelsFlag,
|
||||
},
|
||||
{ enabled: viewingUnpublished }
|
||||
);
|
||||
|
||||
const { items, ...pagination } = data || {
|
||||
items: [],
|
||||
@@ -94,138 +104,177 @@ export default function ModeratorModels() {
|
||||
setState((s) => ({ ...s, ...partialState, opened: !s.opened }));
|
||||
|
||||
return (
|
||||
<Container size="sm">
|
||||
<Stack spacing={0} mb="xl">
|
||||
<Title order={1}>Models Needing Review</Title>
|
||||
<Text size="sm" color="dimmed">
|
||||
Unpublished models for violating ToS which their owners have requested a review
|
||||
</Text>
|
||||
</Stack>
|
||||
{isLoading ? (
|
||||
<Center p="xl">
|
||||
<Loader size="lg" />
|
||||
</Center>
|
||||
) : !!data?.items.length ? (
|
||||
<Stack>
|
||||
<List listStyleType="none" spacing="md">
|
||||
{data?.items.map((model) => {
|
||||
const hasVersion = !!model.modelVersion;
|
||||
const unpublishedAt =
|
||||
hasVersion && model.modelVersion?.meta && model.modelVersion?.meta?.unpublishedAt
|
||||
? new Date(model.modelVersion.meta.unpublishedAt)
|
||||
: model.meta && model.meta.unpublishedAt
|
||||
? new Date(model.meta.unpublishedAt)
|
||||
: null;
|
||||
const unpublishedReason =
|
||||
model.meta?.unpublishedReason ?? model.modelVersion?.meta?.unpublishedReason;
|
||||
const customMessage =
|
||||
model.meta?.customMessage ?? model.modelVersion?.meta?.customMessage;
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
key={model.id}
|
||||
sx={(theme) => ({
|
||||
padding: theme.spacing.sm,
|
||||
border: `1px solid ${
|
||||
theme.colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[2]
|
||||
}`,
|
||||
'& > *': { width: '100%' },
|
||||
})}
|
||||
>
|
||||
<Group position="apart" align="flex-start" noWrap>
|
||||
<Stack spacing={0}>
|
||||
<Group spacing={8} noWrap>
|
||||
{hasVersion ? (
|
||||
<Badge color="violet" radius="xl">
|
||||
Model Version
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge radius="xl">Model</Badge>
|
||||
)}
|
||||
<Link
|
||||
href={`/models/${model.id}/${slugit(model.name)}${
|
||||
model.modelVersion ? `?modelVersionId=${model.modelVersion.id}` : ''
|
||||
}`}
|
||||
passHref
|
||||
>
|
||||
<Anchor size="md" target="_blank" lineClamp={2}>
|
||||
{`${model.name}${
|
||||
model.modelVersion ? ` - ${model.modelVersion.name}` : ''
|
||||
}`}{' '}
|
||||
<IconExternalLink size={16} stroke={1.5} />
|
||||
</Anchor>
|
||||
</Link>
|
||||
</Group>
|
||||
{unpublishedAt && (
|
||||
<Text size="xs" color="dimmed">
|
||||
Unpublished at: {formatDate(unpublishedAt)}
|
||||
</Text>
|
||||
)}
|
||||
{unpublishedReason && (
|
||||
<Text size="sm">
|
||||
<Text weight={500} size="sm" span>
|
||||
Reason initially unpublished:
|
||||
</Text>{' '}
|
||||
{`${unpublishReasons[unpublishedReason].optionLabel}${
|
||||
customMessage ? ` - ${customMessage}` : ''
|
||||
}`}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
color="red"
|
||||
onClick={() => toggleModal({ selectedModel: model })}
|
||||
compact
|
||||
>
|
||||
Decline Request
|
||||
</Button>
|
||||
</Group>
|
||||
</List.Item>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
{pagination.totalPages > 1 && (
|
||||
<Group position="apart">
|
||||
<Text>Total {pagination.totalItems} items</Text>
|
||||
<Pagination
|
||||
page={state.page}
|
||||
onChange={(page) => setState((s) => ({ ...s, page }))}
|
||||
total={pagination.totalPages}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
<Modal opened={state.opened} onClose={() => toggleModal()} title="Decline Request">
|
||||
<Stack>
|
||||
<Textarea
|
||||
name="declineReason"
|
||||
description="Reason for declining request"
|
||||
minRows={2}
|
||||
placeholder="i.e.: Insufficient changes"
|
||||
value={state.declineReason}
|
||||
onChange={(e) => setState((s) => ({ ...s, declineReason: e.target.value }))}
|
||||
/>
|
||||
<Group position="right">
|
||||
<Button variant="default" onClick={() => toggleModal()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleDeclineRequest} loading={declineReviewMutation.isLoading}>
|
||||
Send
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
<>
|
||||
<Meta title="Moderator Models" deIndex />
|
||||
<Container size="sm">
|
||||
<Stack mb="xl">
|
||||
<Title order={1}>Models Needing Review</Title>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
data={[
|
||||
{ label: 'Unpublished', value: 'unpublished' },
|
||||
{ label: 'AI Scanned', value: 'ai' },
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setState((s) => ({
|
||||
...s,
|
||||
section: value as State['section'],
|
||||
page: 1,
|
||||
opened: false,
|
||||
selectedModel: null,
|
||||
}))
|
||||
}
|
||||
value={state.section}
|
||||
/>
|
||||
<Text size="sm" color="dimmed">
|
||||
Unpublished models for violating ToS which their owners have requested a review
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Paper p="xl" withBorder>
|
||||
<Center>
|
||||
<Text size="md" color="dimmed">
|
||||
There are no models that need review
|
||||
</Text>
|
||||
</Center>
|
||||
</Paper>
|
||||
)}
|
||||
</Container>
|
||||
{viewingUnpublished ? (
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<Center p="xl">
|
||||
<Loader size="lg" />
|
||||
</Center>
|
||||
) : !!data?.items.length ? (
|
||||
<Stack>
|
||||
<List listStyleType="none" spacing="md">
|
||||
{data?.items.map((model) => {
|
||||
const hasVersion = !!model.modelVersion;
|
||||
const unpublishedAt =
|
||||
hasVersion &&
|
||||
model.modelVersion?.meta &&
|
||||
model.modelVersion?.meta?.unpublishedAt
|
||||
? new Date(model.modelVersion.meta.unpublishedAt)
|
||||
: model.meta && model.meta.unpublishedAt
|
||||
? new Date(model.meta.unpublishedAt)
|
||||
: null;
|
||||
const unpublishedReason =
|
||||
model.meta?.unpublishedReason ?? model.modelVersion?.meta?.unpublishedReason;
|
||||
const customMessage =
|
||||
model.meta?.customMessage ?? model.modelVersion?.meta?.customMessage;
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
key={model.id}
|
||||
sx={(theme) => ({
|
||||
padding: theme.spacing.sm,
|
||||
border: `1px solid ${
|
||||
theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[4]
|
||||
: theme.colors.gray[2]
|
||||
}`,
|
||||
'& > *': { width: '100%' },
|
||||
})}
|
||||
>
|
||||
<Stack spacing={8}>
|
||||
<Group position="apart" align="flex-start" noWrap>
|
||||
{hasVersion ? (
|
||||
<Badge color="violet" radius="xl">
|
||||
Model Version
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge radius="xl">Model</Badge>
|
||||
)}
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
color="red"
|
||||
onClick={() => toggleModal({ selectedModel: model })}
|
||||
compact
|
||||
>
|
||||
Decline Request
|
||||
</Button>
|
||||
</Group>
|
||||
<Link
|
||||
href={`/models/${model.id}/${slugit(model.name)}${
|
||||
model.modelVersion ? `?modelVersionId=${model.modelVersion.id}` : ''
|
||||
}`}
|
||||
passHref
|
||||
>
|
||||
<Anchor size="md" target="_blank" lineClamp={1} inline>
|
||||
<div className="flex flex-nowrap gap-1">
|
||||
<IconExternalLink
|
||||
className="shrink-0 grow-0"
|
||||
size={16}
|
||||
stroke={1.5}
|
||||
/>
|
||||
{`${model.name}${
|
||||
model.modelVersion ? ` - ${model.modelVersion.name}` : ''
|
||||
}`}
|
||||
</div>
|
||||
</Anchor>
|
||||
</Link>
|
||||
{unpublishedAt && (
|
||||
<Text size="xs" color="dimmed">
|
||||
Unpublished at: {formatDate(unpublishedAt)}
|
||||
</Text>
|
||||
)}
|
||||
{unpublishedReason && (
|
||||
<Text size="sm">
|
||||
<Text weight={500} size="sm" span>
|
||||
Reason initially unpublished:
|
||||
</Text>{' '}
|
||||
{`${unpublishReasons[unpublishedReason].optionLabel}${
|
||||
customMessage ? ` - ${customMessage}` : ''
|
||||
}`}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</List.Item>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
{pagination.totalPages > 1 && (
|
||||
<Group position="apart">
|
||||
<Text>Total {pagination.totalItems} items</Text>
|
||||
<Pagination
|
||||
page={state.page}
|
||||
onChange={(page) => setState((s) => ({ ...s, page }))}
|
||||
total={pagination.totalPages}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
<Modal opened={state.opened} onClose={() => toggleModal()} title="Decline Request">
|
||||
<Stack>
|
||||
<Textarea
|
||||
name="declineReason"
|
||||
description="Reason for declining request"
|
||||
minRows={2}
|
||||
placeholder="i.e.: Insufficient changes"
|
||||
value={state.declineReason}
|
||||
onChange={(e) => setState((s) => ({ ...s, declineReason: e.target.value }))}
|
||||
/>
|
||||
<Group position="right">
|
||||
<Button variant="default" onClick={() => toggleModal()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDeclineRequest}
|
||||
loading={declineReviewMutation.isLoading}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
) : (
|
||||
<Paper p="xl" withBorder>
|
||||
<Center>
|
||||
<Text size="md" color="dimmed">
|
||||
There are no models that need review
|
||||
</Text>
|
||||
</Center>
|
||||
</Paper>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="lg:-mx-32">
|
||||
<FlaggedModelsList />
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,16 +4,22 @@ import {
|
||||
handleApproveTrainingData,
|
||||
handleDenyTrainingData,
|
||||
} from '~/server/controllers/training.controller';
|
||||
import { getByIdSchema, getByIdsSchema } from '~/server/schema/base.schema';
|
||||
import { getByIdSchema } from '~/server/schema/base.schema';
|
||||
import { getFlaggedModelsSchema } from '~/server/schema/model-flag.schema';
|
||||
import { queryModelVersionsSchema } from '~/server/schema/model-version.schema';
|
||||
import { getAllModelsSchema } from '~/server/schema/model.schema';
|
||||
import { getVersionById } from '~/server/services/model-version.service';
|
||||
import { createTrainingRequest } from '~/server/services/training.service';
|
||||
import { getFlaggedModels, resolveFlaggedModel } from '~/server/services/model-flag.service';
|
||||
import { moderatorProcedure, router } from '~/server/trpc';
|
||||
|
||||
export const modRouter = router({
|
||||
models: router({
|
||||
query: moderatorProcedure.input(getAllModelsSchema).query(getModelsPagedSimpleHandler),
|
||||
queryFlagged: moderatorProcedure
|
||||
.input(getFlaggedModelsSchema)
|
||||
.query(({ input }) => getFlaggedModels(input)),
|
||||
resolveFlagged: moderatorProcedure
|
||||
.input(getByIdSchema)
|
||||
.mutation(({ input, ctx }) => resolveFlaggedModel({ ...input, userId: ctx.user.id })),
|
||||
}),
|
||||
modelVersions: router({
|
||||
query: moderatorProcedure
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { z } from 'zod';
|
||||
import { getAllQuerySchema } from '~/server/schema/base.schema';
|
||||
|
||||
export type GetFlaggedModelsInput = z.infer<typeof getFlaggedModelsSchema>;
|
||||
export const getFlaggedModelsSchema = getAllQuerySchema.extend({
|
||||
filters: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
value: z.unknown(),
|
||||
})
|
||||
.array()
|
||||
.optional(),
|
||||
sort: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
desc: z.boolean(),
|
||||
})
|
||||
.array()
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type ModelScanResult = z.infer<typeof modelScanResultSchema>;
|
||||
export const modelScanResultSchema = z.object({
|
||||
status: z.enum(['success', 'failure']),
|
||||
user_declared: z.object({
|
||||
content: z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
POI: z.boolean(),
|
||||
NSFW: z.boolean(),
|
||||
minor: z.boolean(),
|
||||
triggerwords: z.string().array().nullish(),
|
||||
image_urls: z.string().array().nullish(),
|
||||
links: z.string().array().nullish(),
|
||||
}),
|
||||
}),
|
||||
llm_interrogation: z.object({
|
||||
POI: z.boolean(),
|
||||
POIName: z.string().array().nullish(),
|
||||
context: z.string().nullish(),
|
||||
NSFW: z.boolean(),
|
||||
minor: z.boolean(),
|
||||
triggerwords: z.string().array(),
|
||||
POIInfo: z
|
||||
.object({
|
||||
POIVerified: z.boolean(),
|
||||
reason: z.string(),
|
||||
})
|
||||
.nullish(),
|
||||
}),
|
||||
flags: z.object({
|
||||
POI_flag: z.boolean(),
|
||||
NSFW_flag: z.boolean(),
|
||||
minor_flag: z.boolean(),
|
||||
triggerwords_flag: z.boolean(),
|
||||
}),
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import { ModelSort } from '~/server/common/enums';
|
||||
import { UnpublishReason, unpublishReasons } from '~/server/common/moderation-helpers';
|
||||
import {
|
||||
baseQuerySchema,
|
||||
getAllQuerySchema,
|
||||
getByIdSchema,
|
||||
infiniteQuerySchema,
|
||||
paginationSchema,
|
||||
@@ -324,3 +325,13 @@ export const toggleCheckpointCoverageSchema = z.object({
|
||||
id: z.number(),
|
||||
versionId: z.number().nullish(),
|
||||
});
|
||||
|
||||
export type IngestModelInput = z.input<typeof ingestModelSchema>;
|
||||
export const ingestModelSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
description: z.coerce.string(),
|
||||
poi: z.coerce.boolean(),
|
||||
nsfw: z.coerce.boolean(),
|
||||
minor: z.coerce.boolean(),
|
||||
});
|
||||
|
||||
@@ -18,7 +18,6 @@ import { getCosmeticsForEntity } from '~/server/services/cosmetic.service';
|
||||
import { imagesForModelVersionsCache } from '~/server/redis/caches';
|
||||
import { ImagesForModelVersions } from '~/server/services/image.service';
|
||||
import { limitConcurrency, Task } from '~/server/utils/concurrency-helpers';
|
||||
import { removeEmpty } from '~/utils/object-helpers';
|
||||
|
||||
const RATING_BAYESIAN_M = 3.5;
|
||||
const RATING_BAYESIAN_C = 10;
|
||||
@@ -102,7 +101,6 @@ const onIndexSetup = async ({ indexName }: { indexName: string }) => {
|
||||
'lastVersionAtUnix',
|
||||
'versions.hashes',
|
||||
'versions.baseModel',
|
||||
'flags.nameNsfw',
|
||||
];
|
||||
|
||||
if (
|
||||
@@ -154,9 +152,6 @@ const modelSelect = {
|
||||
mode: true,
|
||||
checkpointType: true,
|
||||
availability: true,
|
||||
flags: {
|
||||
select: { nameNsfw: true },
|
||||
},
|
||||
// Joins:
|
||||
user: {
|
||||
select: userWithCosmeticsSelect,
|
||||
@@ -214,7 +209,6 @@ const transformData = async ({ models, cosmetics, images }: PullDataResult) => {
|
||||
.map((modelRecord) => {
|
||||
const { user, modelVersions, tagsOnModels, hashes, ...model } = modelRecord;
|
||||
const metrics = modelRecord.metrics[0] ?? {};
|
||||
const flags = removeEmpty(model.flags[0] ?? {});
|
||||
|
||||
const weightedRating =
|
||||
(metrics.rating * metrics.ratingCount + RATING_BAYESIAN_M * RATING_BAYESIAN_C) /
|
||||
@@ -281,7 +275,6 @@ const transformData = async ({ models, cosmetics, images }: PullDataResult) => {
|
||||
},
|
||||
canGenerate,
|
||||
cosmetic: cosmetics[model.id] ?? null,
|
||||
flags: Object.keys(flags).length > 0 ? flags : undefined,
|
||||
};
|
||||
})
|
||||
// Removes null models that have no versionIDs
|
||||
|
||||
@@ -1,13 +1,124 @@
|
||||
import { dbWrite } from '~/server/db/client';
|
||||
import { hasNsfwWords } from '~/utils/metadata/audit';
|
||||
import { ModelFlagStatus, Prisma } from '@prisma/client';
|
||||
import { dbRead, dbWrite } from '~/server/db/client';
|
||||
import { GetByIdInput } from '~/server/schema/base.schema';
|
||||
import { GetFlaggedModelsInput, ModelScanResult } from '~/server/schema/model-flag.schema';
|
||||
import { trackModActivity } from '~/server/services/moderator.service';
|
||||
import { getPagedData } from '~/server/utils/pagination-helpers';
|
||||
|
||||
export async function upsertModelFlag({ modelId, ...data }: { modelId: number; name?: string }) {
|
||||
const nameNsfw = hasNsfwWords(data.name);
|
||||
if (!nameNsfw) return;
|
||||
export async function upsertModelFlag({
|
||||
modelId,
|
||||
scanResult,
|
||||
details,
|
||||
}: {
|
||||
modelId: number;
|
||||
poiName?: string;
|
||||
scanResult?: {
|
||||
poi: boolean;
|
||||
nsfw: boolean;
|
||||
minor: boolean;
|
||||
triggerWords: boolean;
|
||||
poiName: boolean;
|
||||
};
|
||||
details?: MixedObject;
|
||||
}) {
|
||||
const isFlagged = scanResult && Object.values(scanResult).some((flag) => flag);
|
||||
if (!isFlagged) return null;
|
||||
|
||||
await dbWrite.$executeRaw`
|
||||
INSERT INTO "ModelFlag" ("modelId", "nameNsfw")
|
||||
VALUES (${modelId}, ${nameNsfw})
|
||||
ON CONFLICT ("modelId") DO UPDATE SET "nameNsfw" = EXCLUDED."nameNsfw";
|
||||
const [modelFlag] = await dbWrite.$queryRaw<
|
||||
{
|
||||
modelId: number;
|
||||
poi: boolean;
|
||||
nsfw: boolean;
|
||||
minor: boolean;
|
||||
triggerWords: boolean;
|
||||
poiName: string | null;
|
||||
status: ModelFlagStatus;
|
||||
}[]
|
||||
>`
|
||||
INSERT INTO "ModelFlag" ("modelId", "poi", "nsfw", "minor", "triggerWords", "poiName", "status", "details")
|
||||
VALUES (
|
||||
${modelId},
|
||||
${scanResult?.poi ?? false},
|
||||
${scanResult?.nsfw ?? false},
|
||||
${scanResult?.minor ?? false},
|
||||
${scanResult?.triggerWords ?? false},
|
||||
${scanResult?.poiName ?? false},
|
||||
${Prisma.sql`${ModelFlagStatus.Pending}::"ModelFlagStatus"`},
|
||||
${details ? Prisma.sql`${JSON.stringify(details)}::jsonb` : Prisma.JsonNull}
|
||||
)
|
||||
ON CONFLICT ("modelId") DO UPDATE
|
||||
SET "poi" = EXCLUDED."poi",
|
||||
"nsfw" = EXCLUDED."nsfw",
|
||||
"minor" = EXCLUDED."minor",
|
||||
"triggerWords" = EXCLUDED."triggerWords",
|
||||
"poiName" = EXCLUDED."poiName",
|
||||
"status" = EXCLUDED."status",
|
||||
"details" = EXCLUDED."details"
|
||||
RETURNING *;
|
||||
`;
|
||||
|
||||
return modelFlag;
|
||||
}
|
||||
|
||||
export function getFlaggedModels(input: GetFlaggedModelsInput) {
|
||||
return getPagedData(input, async ({ skip, take, sort = [], ...rest }) => {
|
||||
const [flaggedModels, count] = await dbRead.$transaction([
|
||||
dbRead.modelFlag.findMany({
|
||||
where: { status: ModelFlagStatus.Pending },
|
||||
take,
|
||||
skip,
|
||||
select: {
|
||||
modelId: true,
|
||||
poi: true,
|
||||
nsfw: true,
|
||||
triggerWords: true,
|
||||
minor: true,
|
||||
details: true,
|
||||
poiName: true,
|
||||
model: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
nsfw: true,
|
||||
poi: true,
|
||||
minor: true,
|
||||
// These are needed to comply with upsert schema
|
||||
status: true,
|
||||
uploadType: true,
|
||||
type: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
...sort.map(({ id, desc }) => ({ [id]: desc ? 'desc' : 'asc' })),
|
||||
{ createdAt: 'desc' },
|
||||
],
|
||||
}),
|
||||
dbRead.modelFlag.count({ where: { status: ModelFlagStatus.Pending } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: flaggedModels.map(({ details, ...model }) => {
|
||||
const parsedDetails = details as ModelScanResult['llm_interrogation'];
|
||||
|
||||
return {
|
||||
...model,
|
||||
details: parsedDetails,
|
||||
};
|
||||
}),
|
||||
count,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveFlaggedModel({ id, userId }: GetByIdInput & { userId: number }) {
|
||||
const updated = await dbWrite.modelFlag.update({
|
||||
where: { modelId: id },
|
||||
data: { status: ModelFlagStatus.Resolved },
|
||||
});
|
||||
|
||||
await trackModActivity(userId, { entityType: 'model', entityId: id, activity: 'moderateFlag' });
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -57,7 +57,8 @@ import {
|
||||
import { getBaseModelSet } from '~/shared/constants/generation.constants';
|
||||
import { maxDate } from '~/utils/date-helpers';
|
||||
import { isDefined } from '~/utils/type-guards';
|
||||
import { updateModelLastVersionAt } from './model.service';
|
||||
import { ingestModelById, updateModelLastVersionAt } from './model.service';
|
||||
import { logToAxiom } from '~/server/logging/client';
|
||||
|
||||
export const getModelVersionRunStrategies = async ({
|
||||
modelVersionId,
|
||||
@@ -253,6 +254,8 @@ export const upsertModelVersion = async ({
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
description: true,
|
||||
trainedWords: true,
|
||||
earlyAccessEndsAt: true,
|
||||
earlyAccessConfig: true,
|
||||
publishedAt: true,
|
||||
@@ -414,6 +417,11 @@ export const upsertModelVersion = async ({
|
||||
await bustMvCache(version.id);
|
||||
await dataForModelsCache.bust(version.modelId);
|
||||
|
||||
// Run it in the background to avoid blocking the request.
|
||||
ingestModelById({ id: version.modelId }).catch((error) =>
|
||||
logToAxiom({ type: 'error', name: 'model-ingestion', error, modelId: version.modelId })
|
||||
);
|
||||
|
||||
return version;
|
||||
}
|
||||
};
|
||||
@@ -683,6 +691,11 @@ export const publishModelVersionById = async ({
|
||||
images.map((image) => ({ id: image.id, action: SearchIndexUpdateQueueAction.Update }))
|
||||
);
|
||||
|
||||
// Run it in the background to avoid blocking the request.
|
||||
ingestModelById({ id: version.modelId }).catch((error) =>
|
||||
logToAxiom({ type: 'error', name: 'model-ingestion', error, modelId: version.modelId })
|
||||
);
|
||||
|
||||
return version;
|
||||
};
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ import { ModelVersionMeta } from '~/server/schema/model-version.schema';
|
||||
import {
|
||||
GetAllModelsOutput,
|
||||
GetModelVersionsSchema,
|
||||
IngestModelInput,
|
||||
ingestModelSchema,
|
||||
ModelGallerySettingsSchema,
|
||||
ModelInput,
|
||||
ModelMeta,
|
||||
@@ -94,7 +96,7 @@ import {
|
||||
SetAssociatedResourcesInput,
|
||||
SetModelsCategoryInput,
|
||||
} from './../schema/model.schema';
|
||||
import { upsertModelFlag } from '~/server/services/model-flag.service';
|
||||
import { isProd } from '~/env/other';
|
||||
|
||||
export const getModel = async <TSelect extends Prisma.ModelSelect>({
|
||||
id,
|
||||
@@ -1352,7 +1354,15 @@ export const upsertModel = async (
|
||||
} else {
|
||||
const beforeUpdate = await dbRead.model.findUnique({
|
||||
where: { id },
|
||||
select: { poi: true, userId: true, minor: true, gallerySettings: true },
|
||||
select: {
|
||||
name: true,
|
||||
description: true,
|
||||
poi: true,
|
||||
userId: true,
|
||||
minor: true,
|
||||
nsfw: true,
|
||||
gallerySettings: true,
|
||||
},
|
||||
});
|
||||
if (!beforeUpdate) return null;
|
||||
|
||||
@@ -1362,7 +1372,17 @@ export const upsertModel = async (
|
||||
const prevGallerySettings = beforeUpdate.gallerySettings as ModelGallerySettingsSchema;
|
||||
|
||||
const result = await dbWrite.model.update({
|
||||
select: { id: true, nsfwLevel: true, poi: true, minor: true, gallerySettings: true },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
nsfwLevel: true,
|
||||
poi: true,
|
||||
minor: true,
|
||||
nsfw: true,
|
||||
gallerySettings: true,
|
||||
status: true,
|
||||
},
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
@@ -1398,11 +1418,13 @@ export const upsertModel = async (
|
||||
},
|
||||
});
|
||||
await preventReplicationLag('model', id);
|
||||
await upsertModelFlag({ modelId: result.id, name: input.name });
|
||||
|
||||
// Check any changes that would require a search index update
|
||||
const poiChanged = result.poi !== beforeUpdate.poi;
|
||||
const minorChanged = result.minor !== beforeUpdate.minor;
|
||||
const nsfwChanged = result.nsfw !== beforeUpdate.nsfw;
|
||||
const nameChanged = input.name !== beforeUpdate.name;
|
||||
const descriptionChanged = input.description !== beforeUpdate.description;
|
||||
|
||||
// Update search index if listing changes
|
||||
if (tagsOnModels || poiChanged || minorChanged) {
|
||||
@@ -1415,6 +1437,18 @@ export const upsertModel = async (
|
||||
if (galleryBrowsingLevelChanged) await redis.del(`model:gallery-settings:${id}`);
|
||||
|
||||
await userContentOverviewCache.bust(userId);
|
||||
|
||||
// Ingest model if it's published and any of the following fields have changed:
|
||||
if (
|
||||
(result.status === 'Published' || result.status === 'Scheduled') &&
|
||||
(poiChanged || minorChanged || nsfwChanged || nameChanged || descriptionChanged)
|
||||
) {
|
||||
const parsedModel = ingestModelSchema.parse(result);
|
||||
// Run it in the background to prevent blocking the request
|
||||
ingestModel({ ...parsedModel }).catch((error) =>
|
||||
logToAxiom({ type: 'error', name: 'model-ingestion', error, modelId: parsedModel.id })
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
};
|
||||
@@ -1516,6 +1550,12 @@ export const publishModelById = async ({
|
||||
images.map((x) => ({ id: x.id, action: SearchIndexUpdateQueueAction.Update }))
|
||||
);
|
||||
|
||||
const parsedModel = ingestModelSchema.parse(model);
|
||||
// Run it in the background to prevent blocking the request
|
||||
ingestModel({ ...parsedModel }).catch((error) =>
|
||||
logToAxiom({ type: 'error', name: 'model-ingestion', error, modelId: parsedModel.id })
|
||||
);
|
||||
|
||||
return model;
|
||||
};
|
||||
|
||||
@@ -2256,3 +2296,67 @@ export async function copyGallerySettingsToAllModelsByUser({
|
||||
await Promise.all(modelIds.map((id) => redis.del(`model:gallery-settings:${id}`)));
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function ingestModelById({ id }: GetByIdInput) {
|
||||
const model = await dbRead.model.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, name: true, description: true, poi: true, nsfw: true, minor: true },
|
||||
});
|
||||
if (!model) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||
|
||||
const parsedModel = ingestModelSchema.parse(model);
|
||||
return await ingestModel({ ...parsedModel });
|
||||
}
|
||||
|
||||
export async function ingestModel(data: IngestModelInput) {
|
||||
if (!isProd || !env.CONTENT_SCAN_ENDPOINT) {
|
||||
console.log('Skipping model ingestion');
|
||||
await dbWrite.model.update({
|
||||
where: { id: data.id },
|
||||
data: { scannedAt: new Date() },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// get version data
|
||||
const db = await getDbWithoutLag('modelVersion');
|
||||
const versions = await db.modelVersion.findMany({
|
||||
where: { modelId: data.id, status: { in: [ModelStatus.Published, ModelStatus.Scheduled] } },
|
||||
select: { description: true, trainedWords: true },
|
||||
});
|
||||
|
||||
const versionDescriptions = versions.map((x) => x.description || null).filter(isDefined);
|
||||
const triggerWords = versions.flatMap((x) => x.trainedWords);
|
||||
|
||||
const payload = {
|
||||
callbackUrl:
|
||||
env.CONTENT_SCAN_CALLBACK_URL ??
|
||||
`${env.NEXTAUTH_URL}/api/webhooks/model-scan-result?token=${env.WEBHOOK_TOKEN}`,
|
||||
request: {
|
||||
llm_model: env.CONTENT_SCAN_MODEL ?? 'gpt-4o-mini',
|
||||
content: {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
content: [data.description, ...versionDescriptions].join('\n'),
|
||||
POI: data.poi,
|
||||
NSFW: data.nsfw,
|
||||
minor: data.minor,
|
||||
triggerwords: triggerWords,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(`${env.CONTENT_SCAN_ENDPOINT}/scan_model`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Failed to scan model. Service is unavailable.',
|
||||
});
|
||||
|
||||
if (response.status === 202) return true;
|
||||
else return false;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ type TagActivities = 'moderateTag' | 'disableTag' | 'addTag' | 'deleteTag';
|
||||
|
||||
type ModelModActivity = {
|
||||
entityType: 'model';
|
||||
activity: TagActivities | 'review';
|
||||
activity: TagActivities | 'review' | 'moderateFlag';
|
||||
};
|
||||
|
||||
type ModelVersionModActivity = {
|
||||
|
||||
Reference in New Issue
Block a user