adds dall-e full history page and metadata downloader
This commit is contained in:
parent
37f17ded60
commit
7610369c6d
|
@ -1,3 +1,4 @@
|
|||
.aider*
|
||||
.env*
|
||||
!.env.vault
|
||||
.venv
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
|
|
@ -50,6 +50,13 @@
|
|||
</p>
|
||||
</fieldset>
|
||||
<% } %>
|
||||
<% if (imageGenerationEnabled) { %>
|
||||
<fieldset>
|
||||
<legend>Image Generation</legend>
|
||||
<button id="download-image-metadata" type="button" onclick="submitForm('downloadImageMetadata')">Download Image Metadata</button>
|
||||
<label for="download-image-metadata">Downloads a metadata file containing URL, prompt, and truncated user token for all cached images.</label>
|
||||
</fieldset>
|
||||
<% } %>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<% } else { %>
|
||||
<input type="checkbox" id="toggle-nicknames" onchange="toggleNicknames()" />
|
||||
<label for="toggle-nicknames">Show Nicknames</label>
|
||||
<table>
|
||||
<table class="striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
|
|
|
@ -190,6 +190,7 @@ function buildRecentImageSection() {
|
|||
</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
html += `<p style="clear: both; text-align: center;"><a href="/user/image-history">View all recent images</a></p>`
|
||||
|
||||
return html;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -1,15 +1,18 @@
|
|||
const IMAGE_HISTORY_SIZE = 30;
|
||||
const IMAGE_HISTORY_SIZE = 10000;
|
||||
const imageHistory = new Array<ImageHistory>(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;
|
||||
|
||||
|
|
|
@ -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<OpenAIImageGenerationResult> {
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -5,6 +5,12 @@
|
|||
<meta name="csrf-token" content="<%= csrfToken %>">
|
||||
<title><%= title %></title>
|
||||
<style>
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
background-color: #f0f0f0;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
background-color: #e0e6f6;
|
||||
}
|
||||
|
@ -69,9 +75,32 @@
|
|||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #222;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
a:link, a:visited {
|
||||
color: #bbe;
|
||||
}
|
||||
|
||||
a:link:hover, a:visited:hover {
|
||||
background-color: #446;
|
||||
}
|
||||
|
||||
table.striped tr:nth-child(even) {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
th.active {
|
||||
background-color: #446;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="font-family: sans-serif; background-color: #f0f0f0; padding: 1em;">
|
||||
<body>
|
||||
<%- include("partials/shared_flash", { flashData: flash }) %>
|
||||
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<p>Next refresh: <time><%- nextQuotaRefresh %></time></p>
|
||||
<table>
|
||||
<table class="striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Model Family</th>
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
);
|
|
@ -0,0 +1,71 @@
|
|||
<%- include("partials/shared_header", { title: "Image History" }) %>
|
||||
<h1>Image History</h1>
|
||||
<% if (images && images.length > 0) { %>
|
||||
<div class="image-history">
|
||||
<% images.forEach(function(image) { %>
|
||||
<div class="image-entry">
|
||||
<% const thumbUrl = image.url.replace(/\.png$/, "_t.jpg"); %>
|
||||
<a href="<%= image.url %>" target="_blank">
|
||||
<img src="<%= thumbUrl %>" alt="<%= image.prompt %>" title="<%= image.prompt %>" />
|
||||
</a>
|
||||
</div>
|
||||
<% }); %>
|
||||
</div>
|
||||
<div>
|
||||
<p><a href="/user/image-history/metadata">Download JSON metadata for all images</a> (data may be delayed)</p>
|
||||
</div>
|
||||
<% if (pagination && pagination.totalPages > 1) { %>
|
||||
<div class="pagination">
|
||||
<% const pageWindow = 5; %>
|
||||
<% const startPage = Math.max(1, pagination.currentPage - pageWindow); %>
|
||||
<% const endPage = Math.min(pagination.totalPages, pagination.currentPage + pageWindow); %>
|
||||
<% if (startPage > 1) { %>
|
||||
<a href="?page=1">1</a>
|
||||
<% if (startPage > 2) { %>...<% } %>
|
||||
<% } %>
|
||||
<% for (let i = startPage; i <= endPage; i++) { %>
|
||||
<a href="?page=<%= i %>" class="<%= i === pagination.currentPage ? 'active' : '' %>"><%= i %></a>
|
||||
<% } %>
|
||||
<% if (endPage < pagination.totalPages) { %>
|
||||
<% if (endPage < pagination.totalPages - 1) { %>...<% } %>
|
||||
<a href="?page=<%= pagination.totalPages %>"><%= pagination.totalPages %></a>
|
||||
<% } %>
|
||||
</div>
|
||||
<% } %>
|
||||
<% } else { %>
|
||||
<p>No images found.</p>
|
||||
<% } %>
|
||||
<%- include("partials/user_footer") %>
|
||||
|
||||
<style>
|
||||
.image-history {
|
||||
display: grid; grid-template-columns: repeat(6, 1fr); gap: 10px;
|
||||
}
|
||||
.image-entry {
|
||||
margin: 10px;
|
||||
}
|
||||
.pagination {
|
||||
text-align: center; margin-top: 20px;
|
||||
}
|
||||
a.active {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1200px) {
|
||||
.image-history {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 900px) {
|
||||
.image-history {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.image-history {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
Loading…
Reference in New Issue