diff --git a/.gitignore b/.gitignore index 2570a77..0d86e6b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.aider* .env* !.env.vault .venv diff --git a/src/admin/web/manage.ts b/src/admin/web/manage.ts index 76e8308..296eb78 100644 --- a/src/admin/web/manage.ts +++ b/src/admin/web/manage.ts @@ -14,6 +14,7 @@ import { UserSchema, UserTokenCounts, } from "../../shared/users/schema"; +import { getLastNImages } from "../../shared/file-storage/image-history"; const router = Router(); @@ -221,6 +222,18 @@ router.post("/maintenance", (req, res) => { flash.message = `All users' token usage records reset.`; break; } + case "downloadImageMetadata": { + const data = JSON.stringify({ + exportedAt: new Date().toISOString(), + generations: getLastNImages() + }, null, 2); + res.setHeader( + "Content-Disposition", + `attachment; filename=image-metadata-${new Date().toISOString()}.json` + ); + res.setHeader("Content-Type", "application/json"); + return res.send(data); + } default: { throw new HttpError(400, "Invalid action"); } diff --git a/src/admin/web/views/admin_index.ejs b/src/admin/web/views/admin_index.ejs index 3dd52f1..96bd59a 100644 --- a/src/admin/web/views/admin_index.ejs +++ b/src/admin/web/views/admin_index.ejs @@ -50,6 +50,13 @@

<% } %> + <% if (imageGenerationEnabled) { %> +
+ Image Generation + + +
+ <% } %> diff --git a/src/admin/web/views/admin_list-users.ejs b/src/admin/web/views/admin_list-users.ejs index d259d2e..3526c44 100644 --- a/src/admin/web/views/admin_list-users.ejs +++ b/src/admin/web/views/admin_list-users.ejs @@ -6,7 +6,7 @@ <% } else { %> - +
diff --git a/src/info-page.ts b/src/info-page.ts index cb038ee..1bb9157 100644 --- a/src/info-page.ts +++ b/src/info-page.ts @@ -190,6 +190,7 @@ function buildRecentImageSection() { `; } html += ``; + html += `

View all recent images

` return html; } diff --git a/src/proxy/middleware/response/save-image.ts b/src/proxy/middleware/response/save-image.ts index efca911..cf2dd15 100644 --- a/src/proxy/middleware/response/save-image.ts +++ b/src/proxy/middleware/response/save-image.ts @@ -19,10 +19,9 @@ export const saveImage: ProxyResHandlerWithBody = async ( } if (body.data) { - const baseUrl = req.protocol + "://" + req.get("host"); const prompt = body.data[0].revised_prompt ?? req.body.prompt; const res = await mirrorGeneratedImage( - baseUrl, + req, prompt, body as OpenAIImageGenerationResult ); diff --git a/src/shared/api-schemas/openai.ts b/src/shared/api-schemas/openai.ts index db5e543..92195eb 100644 --- a/src/shared/api-schemas/openai.ts +++ b/src/shared/api-schemas/openai.ts @@ -52,7 +52,7 @@ export const OpenAIV1ChatCompletionSchema = z .number() .int() .nullish() - .default(OPENAI_OUTPUT_MAX) + .default(Math.min(OPENAI_OUTPUT_MAX, 4096)) .transform((v) => Math.min(v ?? OPENAI_OUTPUT_MAX, OPENAI_OUTPUT_MAX)), frequency_penalty: z.number().optional().default(0), presence_penalty: z.number().optional().default(0), diff --git a/src/shared/file-storage/image-history.ts b/src/shared/file-storage/image-history.ts index 54a3215..796ca72 100644 --- a/src/shared/file-storage/image-history.ts +++ b/src/shared/file-storage/image-history.ts @@ -1,15 +1,18 @@ -const IMAGE_HISTORY_SIZE = 30; +const IMAGE_HISTORY_SIZE = 10000; const imageHistory = new Array(IMAGE_HISTORY_SIZE); let index = 0; -type ImageHistory = { url: string; prompt: string }; +type ImageHistory = { url: string; prompt: string, token?: string }; export function addToImageHistory(image: ImageHistory) { + if (image.token?.length) { + image.token = `...${image.token.slice(-5)}`; + } imageHistory[index] = image; index = (index + 1) % IMAGE_HISTORY_SIZE; } -export function getLastNImages(n: number) { +export function getLastNImages(n: number = IMAGE_HISTORY_SIZE): ImageHistory[] { const result: ImageHistory[] = []; let currentIndex = (index - 1 + IMAGE_HISTORY_SIZE) % IMAGE_HISTORY_SIZE; diff --git a/src/shared/file-storage/mirror-generated-image.ts b/src/shared/file-storage/mirror-generated-image.ts index 94b265f..0c13443 100644 --- a/src/shared/file-storage/mirror-generated-image.ts +++ b/src/shared/file-storage/mirror-generated-image.ts @@ -1,4 +1,5 @@ import axios from "axios"; +import express from "express"; import { promises as fs } from "fs"; import path from "path"; import { v4 } from "uuid"; @@ -53,10 +54,11 @@ async function createThumbnail(filepath: string) { * Mutates the result object. */ export async function mirrorGeneratedImage( - host: string, + req: express.Request, prompt: string, result: OpenAIImageGenerationResult ): Promise { + const host = req.protocol + "://" + req.get("host"); for (const item of result.data) { let mirror: string; if (item.b64_json) { @@ -66,7 +68,7 @@ export async function mirrorGeneratedImage( } item.url = `${host}/user_content/${path.basename(mirror)}`; await createThumbnail(mirror); - addToImageHistory({ url: item.url, prompt }); + addToImageHistory({ url: item.url, prompt, token: req.user?.token ?? "" }); } return result; } diff --git a/src/shared/inject-locals.ts b/src/shared/inject-locals.ts index 15bb1a3..ec8e3a8 100644 --- a/src/shared/inject-locals.ts +++ b/src/shared/inject-locals.ts @@ -13,6 +13,9 @@ export const injectLocals: RequestHandler = (req, res, next) => { res.locals.nextQuotaRefresh = userStore.getNextQuotaRefresh(); res.locals.persistenceEnabled = config.gatekeeperStore !== "memory"; res.locals.usersEnabled = config.gatekeeper === "user_token"; + res.locals.imageGenerationEnabled = config.allowedModelFamilies.some( + (f) => ["dall-e", "azure-dall-e"].includes(f) + ); res.locals.showTokenCosts = config.showTokenCosts; res.locals.maxIps = config.maxIpsPerUser; diff --git a/src/shared/views/partials/shared_header.ejs b/src/shared/views/partials/shared_header.ejs index af3b469..2e5ebae 100644 --- a/src/shared/views/partials/shared_header.ejs +++ b/src/shared/views/partials/shared_header.ejs @@ -5,6 +5,12 @@ <%= title %> - + <%- include("partials/shared_flash", { flashData: flash }) %> diff --git a/src/shared/views/partials/shared_quota-info.ejs b/src/shared/views/partials/shared_quota-info.ejs index 38ad730..5580a67 100644 --- a/src/shared/views/partials/shared_quota-info.ejs +++ b/src/shared/views/partials/shared_quota-info.ejs @@ -1,5 +1,5 @@

Next refresh:

-
User
+
diff --git a/src/user/routes.ts b/src/user/routes.ts index 2009f31..28d273b 100644 --- a/src/user/routes.ts +++ b/src/user/routes.ts @@ -1,8 +1,10 @@ import express, { Router } from "express"; import { injectCsrfToken, checkCsrfToken } from "../shared/inject-csrf"; +import { browseImagesRouter } from "./web/browse-images"; import { selfServiceRouter } from "./web/self-service"; import { injectLocals } from "../shared/inject-locals"; import { withSession } from "../shared/with-session"; +import { config } from "../config"; const userRouter = Router(); @@ -13,7 +15,9 @@ userRouter.use( userRouter.use(withSession); userRouter.use(injectCsrfToken, checkCsrfToken); userRouter.use(injectLocals); - +if (config.showRecentImages) { + userRouter.use(browseImagesRouter); +} userRouter.use(selfServiceRouter); userRouter.use( diff --git a/src/user/web/browse-images.ts b/src/user/web/browse-images.ts new file mode 100644 index 0000000..c73ab75 --- /dev/null +++ b/src/user/web/browse-images.ts @@ -0,0 +1,54 @@ +import express, { Request, Response } from "express"; +import { getLastNImages } from "../../shared/file-storage/image-history"; +import { paginate } from "../../shared/utils"; +import { ipLimiter } from "../../proxy/rate-limit"; + +const IMAGES_PER_PAGE = 24; + +const metadataCacheTTL = 1000 * 60 * 3; +let metadataCache: unknown | null = null; +let metadataCacheValid = 0; + +const handleImageHistoryPage = (req: Request, res: Response) => { + const page = parseInt(req.query.page as string) || 1; + const allImages = getLastNImages().reverse(); + const { items, pageCount } = paginate(allImages, page, IMAGES_PER_PAGE); + + res.render("image_history", { + images: items, + pagination: { + currentPage: page, + totalPages: pageCount, + }, + }); +}; + +const handleMetadataRequest = (_req: Request, res: Response) => { + res.setHeader("Cache-Control", "public, max-age=180"); + res.setHeader("Content-Type", "application/json"); + res.setHeader( + "Content-Disposition", + `attachment; filename="image-metadata-${new Date().toISOString()}.json"` + ); + if (new Date().getTime() - metadataCacheValid < metadataCacheTTL) { + return res.status(200).json(metadataCache); + } + + const images = getLastNImages().map(({ prompt, url }) => ({ url, prompt })); + const metadata = { + exportedAt: new Date().toISOString(), + totalImages: images.length, + images, + }; + metadataCache = metadata; + metadataCacheValid = new Date().getTime(); + res.status(200).json(metadata); +}; + +export const browseImagesRouter = express.Router(); +browseImagesRouter.get("/image-history", handleImageHistoryPage); +browseImagesRouter.get( + "/image-history/metadata", + ipLimiter, + handleMetadataRequest +); diff --git a/src/user/web/views/image_history.ejs b/src/user/web/views/image_history.ejs new file mode 100644 index 0000000..f61c9db --- /dev/null +++ b/src/user/web/views/image_history.ejs @@ -0,0 +1,71 @@ +<%- include("partials/shared_header", { title: "Image History" }) %> +

Image History

+<% if (images && images.length > 0) { %> +
+ <% images.forEach(function(image) { %> +
+ <% const thumbUrl = image.url.replace(/\.png$/, "_t.jpg"); %> + + <%= image.prompt %> + +
+ <% }); %> +
+
+

Download JSON metadata for all images (data may be delayed)

+
+ <% if (pagination && pagination.totalPages > 1) { %> + + <% } %> +<% } else { %> +

No images found.

+<% } %> +<%- include("partials/user_footer") %> + +
Model Family