forked from pinks/eris
Compare commits
58 Commits
Author | SHA1 | Date |
---|---|---|
lisq | 6ccf8a04d8 | |
lisq | 02e3c22b83 | |
lisq | 03b0c2bc89 | |
pinks | 5b6a1a3471 | |
pinks | ffd3d0dc8d | |
pinks | ec4a3c893e | |
pinks | e7d292df60 | |
pinks | fad14f685e | |
nameless | 2f62c17e32 | |
nameless | 602a63f2c9 | |
nameless | 8ced57c175 | |
nameless | d22848691c | |
nameless | af7b370321 | |
nameless | 5b61576315 | |
nameless | e270a3ab1f | |
nameless | 84ad11b709 | |
nameless | ac63e39373 | |
nameless | 73810d3204 | |
nameless | f4e7b5e1a7 | |
pinks | f678324889 | |
pinks | 6d6174ab48 | |
pinks | a3bada0da2 | |
pinks | 3a13d5c140 | |
pinks | dfa94e219d | |
pinks | a35f07b036 | |
pinks | f7b29dd150 | |
pinks | 4f2371fa8b | |
pinks | f020499f4d | |
pinks | 11d8a66c18 | |
pinks | 22d9c518be | |
pinks | 4ba6f494d2 | |
pinks | 2665fa1c02 | |
pinks | 083f6bc01c | |
Vargoshi | 2f059ceaff | |
Vargoshi | cd3b53018a | |
pinks | 5a241ff13c | |
pinks | 2a3674b0c4 | |
pinks | 843f6d9103 | |
nameless | 07fc0c71f2 | |
Vargoshi | d9f8966ad8 | |
Vargoshi | 5bb2a13f35 | |
pinks | 2258a484e1 | |
pinks | f0a570c1b9 | |
pinks | 0b85c99c92 | |
pinks | a251d5e965 | |
pinks | 6313c5d67e | |
pinks | 55c53ac565 | |
pinks | 477cb03ea2 | |
pinks | 0e88bc5053 | |
pinks | bf75cce20c | |
pinks | a8d36db20b | |
pinks | c0354ef679 | |
pinks | 99540d4035 | |
pinks | adebd34db8 | |
pinks | 01261b6f3a | |
pinks | 82fdd0f21c | |
pinks | 70a9b1180d | |
pinks | 7ebdf6eae4 |
|
@ -1,4 +1,4 @@
|
|||
.env
|
||||
app.db*
|
||||
*.db
|
||||
*.db-*
|
||||
deno.lock
|
||||
updateConfig.ts
|
||||
|
|
34
README.md
34
README.md
|
@ -1,5 +1,11 @@
|
|||
# Eris the Bot
|
||||
|
||||
[![Website](https://img.shields.io/website?url=https%3A%2F%2Feris.lisq.eu%2F)](https://eris.lisq.eu/)
|
||||
![Unique users](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Feris.lisq.eu%2Fapi%2Fstats&query=%24.userCount&label=unique%20users)
|
||||
![Generated images](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Feris.lisq.eu%2Fapi%2Fstats&query=%24.imageCount&label=images%20generated)
|
||||
![Processed steps](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Feris.lisq.eu%2Fapi%2Fstats&query=%24.stepCount&label=steps%20processed)
|
||||
![Painted pixels](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Feris.lisq.eu%2Fapi%2Fstats&query=%24.pixelCount&label=pixels%20painted)
|
||||
|
||||
Telegram bot for generating images from text.
|
||||
|
||||
## Requirements
|
||||
|
@ -13,21 +19,27 @@ You can put these in `.env` file or pass them as environment variables.
|
|||
|
||||
- `TG_BOT_TOKEN` - Telegram bot token. Get yours from [@BotFather](https://t.me/BotFather).
|
||||
Required.
|
||||
- `SD_API_URL` - URL to Stable Diffusion API. Only used on first run. Default:
|
||||
`http://127.0.0.1:7860/`
|
||||
- `TG_ADMIN_USERS` - Comma separated list of usernames of users that can use admin commands. Only
|
||||
used on first run. Optional.
|
||||
- `DENO_KV_PATH` - [Deno KV](https://deno.land/api?s=Deno.openKv&unstable) database file path. A
|
||||
temporary file is used by default.
|
||||
- `LOG_LEVEL` - [Log level](https://deno.land/std@0.201.0/log/mod.ts?s=LogLevels). Default: `INFO`.
|
||||
|
||||
## Running
|
||||
|
||||
- Start stable diffusion webui: `cd sd-webui`, `./webui.sh --api`
|
||||
- Start bot: `deno task start`
|
||||
1. Start Eris: `deno task start`
|
||||
2. Visit [Eris WebUI](http://localhost:5999/) and login via Telegram.
|
||||
3. Promote yourself to admin in the Eris WebUI.
|
||||
4. Start Stable Diffusion WebUI: `./webui.sh --api` (in SD WebUI directory)
|
||||
5. Add a new worker in the Eris WebUI.
|
||||
|
||||
## Codegen
|
||||
|
||||
The Stable Diffusion API in `sd/sdApi.ts` is auto-generated. To regenerate it, first start your SD
|
||||
WebUI with `--nowebui --api`, and then run:
|
||||
The Stable Diffusion API types are auto-generated. To regenerate them, first start your SD WebUI
|
||||
with `--nowebui --api`, and then run `deno task generate`
|
||||
|
||||
```sh
|
||||
deno run npm:openapi-typescript http://localhost:7861/openapi.json -o sd/sdApi.ts
|
||||
```
|
||||
## Project structure
|
||||
|
||||
- `/api` - Eris API served at `http://localhost:5999/api/`.
|
||||
- `/app` - Queue handling and other core processes.
|
||||
- `/bot` - Handling bot commands and other updates from Telegram API.
|
||||
- `/ui` - Eris WebUI frontend files served at `http://localhost:5999/`.
|
||||
- `/util` - Utility functions shared by other parts.
|
||||
|
|
|
@ -0,0 +1,128 @@
|
|||
import { info } from "std/log/mod.ts";
|
||||
import { Elysia, Static, t } from "elysia";
|
||||
import { Admin, adminSchema, adminStore } from "../app/adminStore.ts";
|
||||
import { getUser, withSessionAdmin, withSessionUser } from "./getUser.ts";
|
||||
import { TkvEntry } from "../utils/Tkv.ts";
|
||||
|
||||
const adminDataSchema = t.Intersect([adminSchema, t.Object({ tgUserId: t.Number() })]);
|
||||
|
||||
export type AdminData = Static<typeof adminDataSchema>;
|
||||
|
||||
function getAdminData(adminEntry: TkvEntry<["admins", number], Admin>): AdminData {
|
||||
return { tgUserId: adminEntry.key[1], ...adminEntry.value };
|
||||
}
|
||||
|
||||
export const adminsRoute = new Elysia()
|
||||
.get(
|
||||
"",
|
||||
async () => {
|
||||
const adminEntries = await Array.fromAsync(adminStore.list({ prefix: ["admins"] }));
|
||||
const admins = adminEntries.map(getAdminData);
|
||||
return admins;
|
||||
},
|
||||
{
|
||||
response: t.Array(adminDataSchema),
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"",
|
||||
async ({ query, body, set }) => {
|
||||
return withSessionAdmin({ query, set }, async (sessionUser, sessionAdminEntry) => {
|
||||
const newAdminUser = await getUser(body.tgUserId);
|
||||
const newAdminKey = ["admins", body.tgUserId] as const;
|
||||
const newAdminValue = { promotedBy: sessionAdminEntry.key[1] };
|
||||
const newAdminResult = await adminStore.atomicSet(newAdminKey, null, newAdminValue);
|
||||
if (!newAdminResult.ok) {
|
||||
set.status = 409;
|
||||
return "User is already an admin";
|
||||
}
|
||||
info(`User ${sessionUser.first_name} promoted user ${newAdminUser.first_name} to admin`);
|
||||
return getAdminData({ ...newAdminResult, key: newAdminKey, value: newAdminValue });
|
||||
});
|
||||
},
|
||||
{
|
||||
query: t.Object({ sessionId: t.String() }),
|
||||
body: t.Object({
|
||||
tgUserId: t.Number(),
|
||||
}),
|
||||
response: {
|
||||
200: adminDataSchema,
|
||||
401: t.Literal("Must be logged in"),
|
||||
403: t.Literal("Must be an admin"),
|
||||
409: t.Literal("User is already an admin"),
|
||||
},
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/promote_self",
|
||||
// if there are no admins, allow any user to promote themselves
|
||||
async ({ query, set }) => {
|
||||
return withSessionUser({ query, set }, async (sessionUser) => {
|
||||
const adminEntries = await Array.fromAsync(adminStore.list({ prefix: ["admins"] }));
|
||||
if (adminEntries.length !== 0) {
|
||||
set.status = 409;
|
||||
return "You are not allowed to promote yourself";
|
||||
}
|
||||
const newAdminKey = ["admins", sessionUser.id] as const;
|
||||
const newAdminValue = { promotedBy: null };
|
||||
const newAdminResult = await adminStore.set(newAdminKey, newAdminValue);
|
||||
info(`User ${sessionUser.first_name} promoted themselves to admin`);
|
||||
return getAdminData({ ...newAdminResult, key: newAdminKey, value: newAdminValue });
|
||||
});
|
||||
},
|
||||
{
|
||||
query: t.Object({ sessionId: t.String() }),
|
||||
response: {
|
||||
200: adminDataSchema,
|
||||
401: t.Literal("Must be logged in"),
|
||||
403: t.Literal("Must be an admin"),
|
||||
409: t.Literal("You are not allowed to promote yourself"),
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:adminId",
|
||||
async ({ params, set }) => {
|
||||
const adminEntry = await adminStore.get(["admins", Number(params.adminId)]);
|
||||
if (!adminEntry.versionstamp) {
|
||||
set.status = 404;
|
||||
return "Admin not found";
|
||||
}
|
||||
return getAdminData(adminEntry);
|
||||
},
|
||||
{
|
||||
params: t.Object({ adminId: t.String() }),
|
||||
response: {
|
||||
200: adminDataSchema,
|
||||
404: t.Literal("Admin not found"),
|
||||
},
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/:adminId",
|
||||
async ({ params, query, set }) => {
|
||||
return withSessionAdmin({ query, set }, async (sessionUser) => {
|
||||
const deletedAdminEntry = await adminStore.get(["admins", Number(params.adminId)]);
|
||||
if (!deletedAdminEntry.versionstamp) {
|
||||
set.status = 404;
|
||||
return "Admin not found";
|
||||
}
|
||||
const deletedAdminUser = await getUser(deletedAdminEntry.key[1]);
|
||||
await adminStore.delete(["admins", Number(params.adminId)]);
|
||||
info(
|
||||
`User ${sessionUser.first_name} demoted user ${deletedAdminUser.first_name} from admin`,
|
||||
);
|
||||
return null;
|
||||
});
|
||||
},
|
||||
{
|
||||
params: t.Object({ adminId: t.String() }),
|
||||
query: t.Object({ sessionId: t.String() }),
|
||||
response: {
|
||||
200: t.Null(),
|
||||
401: t.Literal("Must be logged in"),
|
||||
403: t.Literal("Must be an admin"),
|
||||
404: t.Literal("Admin not found"),
|
||||
},
|
||||
},
|
||||
);
|
|
@ -0,0 +1,14 @@
|
|||
import { Elysia, t } from "elysia";
|
||||
import { bot } from "../bot/mod.ts";
|
||||
|
||||
export const botRoute = new Elysia()
|
||||
.get(
|
||||
"",
|
||||
async () => {
|
||||
const username = bot.botInfo.username;
|
||||
return { username };
|
||||
},
|
||||
{
|
||||
response: t.Object({ username: t.String() }),
|
||||
},
|
||||
);
|
|
@ -0,0 +1,45 @@
|
|||
import { Chat } from "grammy_types";
|
||||
import { Admin, adminStore } from "../app/adminStore.ts";
|
||||
import { bot } from "../bot/mod.ts";
|
||||
import { sessions } from "./sessionsRoute.ts";
|
||||
import { TkvEntry } from "../utils/Tkv.ts";
|
||||
|
||||
export async function withSessionUser<O>(
|
||||
{ query, set }: { query: { sessionId: string }; set: { status?: string | number } },
|
||||
cb: (sessionUser: Chat.PrivateGetChat) => Promise<O>,
|
||||
) {
|
||||
const session = sessions.get(query.sessionId);
|
||||
if (!session?.userId) {
|
||||
set.status = 401;
|
||||
return "Must be logged in";
|
||||
}
|
||||
const user = await getUser(session.userId);
|
||||
return cb(user);
|
||||
}
|
||||
|
||||
export async function withSessionAdmin<O>(
|
||||
{ query, set }: { query: { sessionId: string }; set: { status?: string | number } },
|
||||
cb: (
|
||||
sessionUser: Chat.PrivateGetChat,
|
||||
sessionAdminEntry: TkvEntry<["admins", number], Admin>,
|
||||
) => Promise<O>,
|
||||
) {
|
||||
const session = sessions.get(query.sessionId);
|
||||
if (!session?.userId) {
|
||||
set.status = 401;
|
||||
return "Must be logged in";
|
||||
}
|
||||
const sessionUser = await getUser(session.userId);
|
||||
const sessionAdminEntry = await adminStore.get(["admins", sessionUser.id]);
|
||||
if (!sessionAdminEntry.versionstamp) {
|
||||
set.status = 403;
|
||||
return "Must be an admin";
|
||||
}
|
||||
return cb(sessionUser, sessionAdminEntry);
|
||||
}
|
||||
|
||||
export async function getUser(userId: number): Promise<Chat.PrivateGetChat> {
|
||||
const chat = await bot.api.getChat(userId);
|
||||
if (chat.type !== "private") throw new Error("Chat is not private");
|
||||
return chat;
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import { Elysia, t } from "elysia";
|
||||
import { generationQueue } from "../app/generationQueue.ts";
|
||||
|
||||
export const jobsRoute = new Elysia()
|
||||
.get(
|
||||
"",
|
||||
async () => {
|
||||
const allJobs = await generationQueue.getAllJobs();
|
||||
return allJobs.map((job) => ({
|
||||
id: job.id.join(":"),
|
||||
place: job.place,
|
||||
state: {
|
||||
from: {
|
||||
language_code: job.state.from.language_code ?? null,
|
||||
first_name: job.state.from.first_name,
|
||||
last_name: job.state.from.last_name ?? null,
|
||||
username: job.state.from.username ?? null,
|
||||
},
|
||||
progress: job.state.progress ?? null,
|
||||
workerInstanceKey: job.state.workerInstanceKey ?? null,
|
||||
},
|
||||
}));
|
||||
},
|
||||
{
|
||||
response: t.Array(t.Object({
|
||||
id: t.String(),
|
||||
place: t.Number(),
|
||||
state: t.Object({
|
||||
from: t.Object({
|
||||
language_code: t.Nullable(t.String()),
|
||||
first_name: t.String(),
|
||||
last_name: t.Nullable(t.String()),
|
||||
username: t.Nullable(t.String()),
|
||||
}),
|
||||
progress: t.Nullable(t.Number()),
|
||||
workerInstanceKey: t.Nullable(t.String()),
|
||||
}),
|
||||
})),
|
||||
},
|
||||
);
|
|
@ -0,0 +1,23 @@
|
|||
import { route } from "reroute";
|
||||
import { serveSpa } from "serve_spa";
|
||||
import { api } from "./serveApi.ts";
|
||||
import { fromFileUrl } from "std/path/mod.ts";
|
||||
|
||||
export async function serveUi() {
|
||||
const server = Deno.serve({ port: 5999 }, (request) =>
|
||||
route(request, {
|
||||
"/api/*": (request) => api.fetch(request),
|
||||
"/*": (request) =>
|
||||
serveSpa(request, {
|
||||
fsRoot: fromFileUrl(new URL("../ui/", import.meta.url)),
|
||||
indexFallback: true,
|
||||
importMapFile: "../deno.json",
|
||||
aliasMap: {
|
||||
"/utils/*": "../utils/",
|
||||
},
|
||||
log: (_request, response) => response.status >= 400,
|
||||
}),
|
||||
}));
|
||||
|
||||
await server.finished;
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
import { Elysia, t } from "elysia";
|
||||
import { info } from "std/log/mod.ts";
|
||||
import { defaultParamsSchema, getConfig, setConfig } from "../app/config.ts";
|
||||
import { withSessionAdmin } from "./getUser.ts";
|
||||
|
||||
export const paramsRoute = new Elysia()
|
||||
.get(
|
||||
"",
|
||||
async () => {
|
||||
const config = await getConfig();
|
||||
return config.defaultParams;
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: defaultParamsSchema,
|
||||
},
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"",
|
||||
async ({ query, body, set }) => {
|
||||
return withSessionAdmin({ query, set }, async (user) => {
|
||||
const config = await getConfig();
|
||||
info(`User ${user.first_name} updated default params: ${JSON.stringify(body)}`);
|
||||
const defaultParams = { ...config.defaultParams, ...body };
|
||||
await setConfig({ defaultParams });
|
||||
return config.defaultParams;
|
||||
});
|
||||
},
|
||||
{
|
||||
query: t.Object({ sessionId: t.String() }),
|
||||
body: defaultParamsSchema,
|
||||
response: {
|
||||
200: defaultParamsSchema,
|
||||
401: t.Literal("Must be logged in"),
|
||||
403: t.Literal("Must be an admin"),
|
||||
},
|
||||
},
|
||||
);
|
|
@ -0,0 +1,32 @@
|
|||
import { Elysia } from "elysia";
|
||||
import { swagger } from "elysia/swagger";
|
||||
import { adminsRoute } from "./adminsRoute.ts";
|
||||
import { botRoute } from "./botRoute.ts";
|
||||
import { jobsRoute } from "./jobsRoute.ts";
|
||||
import { paramsRoute } from "./paramsRoute.ts";
|
||||
import { sessionsRoute } from "./sessionsRoute.ts";
|
||||
import { statsRoute } from "./statsRoute.ts";
|
||||
import { usersRoute } from "./usersRoute.ts";
|
||||
import { workersRoute } from "./workersRoute.ts";
|
||||
|
||||
export const api = new Elysia()
|
||||
.use(
|
||||
swagger({
|
||||
path: "/docs",
|
||||
swaggerOptions: { url: "docs/json" } as never,
|
||||
documentation: {
|
||||
info: { title: "Eris API", version: "0.1" },
|
||||
servers: [{ url: "/api" }],
|
||||
},
|
||||
}),
|
||||
)
|
||||
.group("/admins", (api) => api.use(adminsRoute))
|
||||
.group("/bot", (api) => api.use(botRoute))
|
||||
.group("/jobs", (api) => api.use(jobsRoute))
|
||||
.group("/sessions", (api) => api.use(sessionsRoute))
|
||||
.group("/settings/params", (api) => api.use(paramsRoute))
|
||||
.group("/stats", (api) => api.use(statsRoute))
|
||||
.group("/users", (api) => api.use(usersRoute))
|
||||
.group("/workers", (api) => api.use(workersRoute));
|
||||
|
||||
export type Api = typeof api;
|
|
@ -0,0 +1,43 @@
|
|||
import { Elysia, NotFoundError, t } from "elysia";
|
||||
import { ulid } from "ulid";
|
||||
|
||||
export const sessions = new Map<string, Session>();
|
||||
|
||||
export interface Session {
|
||||
userId?: number | undefined;
|
||||
}
|
||||
|
||||
export const sessionsRoute = new Elysia()
|
||||
.post(
|
||||
"",
|
||||
async () => {
|
||||
const id = ulid();
|
||||
const session: Session = {};
|
||||
sessions.set(id, session);
|
||||
return { id, userId: session.userId ?? null };
|
||||
},
|
||||
{
|
||||
response: t.Object({
|
||||
id: t.String(),
|
||||
userId: t.Nullable(t.Number()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:sessionId",
|
||||
async ({ params }) => {
|
||||
const id = params.sessionId!;
|
||||
const session = sessions.get(id);
|
||||
if (!session) {
|
||||
throw new NotFoundError("Session not found");
|
||||
}
|
||||
return { id, userId: session.userId ?? null };
|
||||
},
|
||||
{
|
||||
params: t.Object({ sessionId: t.String() }),
|
||||
response: t.Object({
|
||||
id: t.String(),
|
||||
userId: t.Nullable(t.Number()),
|
||||
}),
|
||||
},
|
||||
);
|
|
@ -0,0 +1,134 @@
|
|||
import { Elysia, t } from "elysia";
|
||||
import { subMinutes } from "date-fns";
|
||||
import { dailyStatsSchema, getDailyStats } from "../app/dailyStatsStore.ts";
|
||||
import { generationStore } from "../app/generationStore.ts";
|
||||
import { globalStats } from "../app/globalStats.ts";
|
||||
import { getUserDailyStats, userDailyStatsSchema } from "../app/userDailyStatsStore.ts";
|
||||
import { getUserStats, userStatsSchema } from "../app/userStatsStore.ts";
|
||||
import { withSessionAdmin } from "./getUser.ts";
|
||||
|
||||
const STATS_INTERVAL_MIN = 3;
|
||||
|
||||
export const statsRoute = new Elysia()
|
||||
.get(
|
||||
"",
|
||||
async () => {
|
||||
const after = subMinutes(new Date(), STATS_INTERVAL_MIN);
|
||||
const generations = await generationStore.getAll({ after });
|
||||
|
||||
const imagesPerMinute = generations.length / STATS_INTERVAL_MIN;
|
||||
|
||||
const stepsPerMinute = generations
|
||||
.map((generation) => generation.value.info?.steps ?? 0)
|
||||
.reduce((sum, steps) => sum + steps, 0) / STATS_INTERVAL_MIN;
|
||||
|
||||
const pixelsPerMinute = generations
|
||||
.map((generation) =>
|
||||
(generation.value.info?.width ?? 0) * (generation.value.info?.height ?? 0)
|
||||
)
|
||||
.reduce((sum, pixels) => sum + pixels, 0) / STATS_INTERVAL_MIN;
|
||||
|
||||
const pixelStepsPerMinute = generations
|
||||
.map((generation) =>
|
||||
(generation.value.info?.width ?? 0) * (generation.value.info?.height ?? 0) *
|
||||
(generation.value.info?.steps ?? 0)
|
||||
)
|
||||
.reduce((sum, pixelSteps) => sum + pixelSteps, 0) / STATS_INTERVAL_MIN;
|
||||
|
||||
return {
|
||||
imageCount: globalStats.imageCount,
|
||||
stepCount: globalStats.stepCount,
|
||||
pixelCount: globalStats.pixelCount,
|
||||
pixelStepCount: globalStats.pixelStepCount,
|
||||
userCount: globalStats.userIds.length,
|
||||
imagesPerMinute,
|
||||
stepsPerMinute,
|
||||
pixelsPerMinute,
|
||||
pixelStepsPerMinute,
|
||||
};
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
imageCount: t.Number(),
|
||||
stepCount: t.Number(),
|
||||
pixelCount: t.Number(),
|
||||
pixelStepCount: t.Number(),
|
||||
userCount: t.Number(),
|
||||
imagesPerMinute: t.Number(),
|
||||
stepsPerMinute: t.Number(),
|
||||
pixelsPerMinute: t.Number(),
|
||||
pixelStepsPerMinute: t.Number(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/daily/:year/:month/:day",
|
||||
async ({ params }) => {
|
||||
return getDailyStats(params.year, params.month, params.day);
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
year: t.Number(),
|
||||
month: t.Number(),
|
||||
day: t.Number(),
|
||||
}),
|
||||
response: {
|
||||
200: dailyStatsSchema,
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/users/:userId",
|
||||
async ({ params }) => {
|
||||
const userId = params.userId;
|
||||
// deno-lint-ignore no-unused-vars
|
||||
const { tagCountMap, ...stats } = await getUserStats(userId);
|
||||
return stats;
|
||||
},
|
||||
{
|
||||
params: t.Object({ userId: t.Number() }),
|
||||
response: {
|
||||
200: t.Omit(userStatsSchema, ["tagCountMap"]),
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/users/:userId/tagcount",
|
||||
async ({ params, query, set }) => {
|
||||
return withSessionAdmin({ query, set }, async () => {
|
||||
const stats = await getUserStats(params.userId);
|
||||
return {
|
||||
tagCountMap: stats.tagCountMap,
|
||||
timestamp: stats.timestamp,
|
||||
};
|
||||
});
|
||||
},
|
||||
{
|
||||
params: t.Object({ userId: t.Number() }),
|
||||
query: t.Object({ sessionId: t.String() }),
|
||||
response: {
|
||||
200: t.Pick(userStatsSchema, ["tagCountMap", "timestamp"]),
|
||||
401: t.Literal("Must be logged in"),
|
||||
403: t.Literal("Must be an admin"),
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/users/:userId/daily/:year/:month/:day",
|
||||
async ({ params }) => {
|
||||
return getUserDailyStats(params.userId, params.year, params.month, params.day);
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
userId: t.Number(),
|
||||
year: t.Number(),
|
||||
month: t.Number(),
|
||||
day: t.Number(),
|
||||
}),
|
||||
response: {
|
||||
200: userDailyStatsSchema,
|
||||
},
|
||||
},
|
||||
);
|
|
@ -0,0 +1,55 @@
|
|||
import { Elysia, t } from "elysia";
|
||||
import { adminSchema, adminStore } from "../app/adminStore.ts";
|
||||
import { bot } from "../bot/mod.ts";
|
||||
import { getUser } from "./getUser.ts";
|
||||
|
||||
export const usersRoute = new Elysia()
|
||||
.get(
|
||||
"/:userId/photo",
|
||||
async ({ params }) => {
|
||||
const user = await getUser(Number(params.userId));
|
||||
if (!user.photo) {
|
||||
throw new Error("User has no photo");
|
||||
}
|
||||
const photoFile = await bot.api.getFile(user.photo.small_file_id);
|
||||
const photoData = await fetch(
|
||||
`https://api.telegram.org/file/bot${bot.token}/${photoFile.file_path}`,
|
||||
).then((resp) => {
|
||||
if (!resp.ok) {
|
||||
throw new Error("Failed to fetch photo");
|
||||
}
|
||||
return resp;
|
||||
}).then((resp) => resp.arrayBuffer());
|
||||
|
||||
return new Response(new File([photoData], "avatar.jpg", { type: "image/jpeg" }));
|
||||
},
|
||||
{
|
||||
params: t.Object({ userId: t.String() }),
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:userId",
|
||||
async ({ params }) => {
|
||||
const user = await getUser(Number(params.userId));
|
||||
const adminEntry = await adminStore.get(["admins", user.id]);
|
||||
return {
|
||||
id: user.id,
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name ?? null,
|
||||
username: user.username ?? null,
|
||||
bio: user.bio ?? null,
|
||||
admin: adminEntry.value ?? null,
|
||||
};
|
||||
},
|
||||
{
|
||||
params: t.Object({ userId: t.String() }),
|
||||
response: t.Object({
|
||||
id: t.Number(),
|
||||
first_name: t.String(),
|
||||
last_name: t.Nullable(t.String()),
|
||||
username: t.Nullable(t.String()),
|
||||
bio: t.Nullable(t.String()),
|
||||
admin: t.Nullable(adminSchema),
|
||||
}),
|
||||
},
|
||||
);
|
|
@ -0,0 +1,248 @@
|
|||
import { subMinutes } from "date-fns";
|
||||
import { Model } from "indexed_kv";
|
||||
import createOpenApiFetch from "openapi_fetch";
|
||||
import { info } from "std/log/mod.ts";
|
||||
import { Elysia, NotFoundError, Static, t } from "elysia";
|
||||
import { activeGenerationWorkers } from "../app/generationQueue.ts";
|
||||
import { generationStore } from "../app/generationStore.ts";
|
||||
import * as SdApi from "../app/sdApi.ts";
|
||||
import {
|
||||
WorkerInstance,
|
||||
workerInstanceSchema,
|
||||
workerInstanceStore,
|
||||
} from "../app/workerInstanceStore.ts";
|
||||
import { getAuthHeader } from "../utils/getAuthHeader.ts";
|
||||
import { omitUndef } from "../utils/omitUndef.ts";
|
||||
import { withSessionAdmin } from "./getUser.ts";
|
||||
|
||||
const workerResponseSchema = t.Intersect([
|
||||
t.Object({ id: t.String() }),
|
||||
t.Omit(workerInstanceSchema, ["sdUrl", "sdAuth"]),
|
||||
t.Object({
|
||||
isActive: t.Boolean(),
|
||||
imagesPerMinute: t.Number(),
|
||||
stepsPerMinute: t.Number(),
|
||||
pixelsPerMinute: t.Number(),
|
||||
pixelStepsPerMinute: t.Number(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type WorkerResponse = Static<typeof workerResponseSchema>;
|
||||
|
||||
const workerRequestSchema = t.Omit(workerInstanceSchema, ["lastOnlineTime", "lastError"]);
|
||||
|
||||
const STATS_INTERVAL_MIN = 10;
|
||||
|
||||
async function getWorkerResponse(workerInstance: Model<WorkerInstance>): Promise<WorkerResponse> {
|
||||
const after = subMinutes(new Date(), STATS_INTERVAL_MIN);
|
||||
|
||||
const generations = await generationStore.getBy("workerInstanceKey", {
|
||||
value: workerInstance.value.key,
|
||||
after: after,
|
||||
});
|
||||
|
||||
const imagesPerMinute = generations.length / STATS_INTERVAL_MIN;
|
||||
|
||||
const stepsPerMinute = generations
|
||||
.map((generation) => generation.value.info?.steps ?? 0)
|
||||
.reduce((sum, steps) => sum + steps, 0) / STATS_INTERVAL_MIN;
|
||||
|
||||
const pixelsPerMinute = generations
|
||||
.map((generation) => (generation.value.info?.width ?? 0) * (generation.value.info?.height ?? 0))
|
||||
.reduce((sum, pixels) => sum + pixels, 0) / STATS_INTERVAL_MIN;
|
||||
|
||||
const pixelStepsPerMinute = generations
|
||||
.map((generation) =>
|
||||
(generation.value.info?.width ?? 0) * (generation.value.info?.height ?? 0) *
|
||||
(generation.value.info?.steps ?? 0)
|
||||
)
|
||||
.reduce((sum, pixelSteps) => sum + pixelSteps, 0) / STATS_INTERVAL_MIN;
|
||||
|
||||
return omitUndef({
|
||||
id: workerInstance.id,
|
||||
key: workerInstance.value.key,
|
||||
name: workerInstance.value.name,
|
||||
lastError: workerInstance.value.lastError,
|
||||
lastOnlineTime: workerInstance.value.lastOnlineTime,
|
||||
isActive: activeGenerationWorkers.get(workerInstance.id)?.isProcessing ?? false,
|
||||
imagesPerMinute,
|
||||
stepsPerMinute,
|
||||
pixelsPerMinute,
|
||||
pixelStepsPerMinute,
|
||||
});
|
||||
}
|
||||
|
||||
export const workersRoute = new Elysia()
|
||||
.get(
|
||||
"",
|
||||
async () => {
|
||||
const workerInstances = await workerInstanceStore.getAll();
|
||||
const workers = await Promise.all(workerInstances.map(getWorkerResponse));
|
||||
return workers;
|
||||
},
|
||||
{
|
||||
response: t.Array(workerResponseSchema),
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"",
|
||||
async ({ query, body, set }) => {
|
||||
return withSessionAdmin({ query, set }, async (sessionUser) => {
|
||||
const workerInstance = await workerInstanceStore.create(body);
|
||||
info(`User ${sessionUser.first_name} created worker ${workerInstance.value.name}`);
|
||||
return await getWorkerResponse(workerInstance);
|
||||
});
|
||||
},
|
||||
{
|
||||
query: t.Object({ sessionId: t.String() }),
|
||||
body: workerRequestSchema,
|
||||
response: {
|
||||
200: workerResponseSchema,
|
||||
401: t.Literal("Must be logged in"),
|
||||
403: t.Literal("Must be an admin"),
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:workerId",
|
||||
async ({ params }) => {
|
||||
const workerInstance = await workerInstanceStore.getById(params.workerId);
|
||||
if (!workerInstance) {
|
||||
throw new NotFoundError("Worker not found");
|
||||
}
|
||||
return await getWorkerResponse(workerInstance);
|
||||
},
|
||||
{
|
||||
params: t.Object({ workerId: t.String() }),
|
||||
response: {
|
||||
200: workerResponseSchema,
|
||||
},
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/:workerId",
|
||||
async ({ params, query, body, set }) => {
|
||||
const workerInstance = await workerInstanceStore.getById(params.workerId);
|
||||
if (!workerInstance) {
|
||||
throw new NotFoundError("Worker not found");
|
||||
}
|
||||
return withSessionAdmin({ query, set }, async (sessionUser) => {
|
||||
info(
|
||||
`User ${sessionUser.first_name} updated worker ${workerInstance.value.name}: ${
|
||||
JSON.stringify(body)
|
||||
}`,
|
||||
);
|
||||
await workerInstance.update(body);
|
||||
return await getWorkerResponse(workerInstance);
|
||||
});
|
||||
},
|
||||
{
|
||||
params: t.Object({ workerId: t.String() }),
|
||||
query: t.Object({ sessionId: t.String() }),
|
||||
body: t.Partial(workerRequestSchema),
|
||||
response: {
|
||||
200: workerResponseSchema,
|
||||
401: t.Literal("Must be logged in"),
|
||||
403: t.Literal("Must be an admin"),
|
||||
},
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/:workerId",
|
||||
async ({ params, query, set }) => {
|
||||
const workerInstance = await workerInstanceStore.getById(params.workerId);
|
||||
if (!workerInstance) {
|
||||
throw new Error("Worker not found");
|
||||
}
|
||||
return withSessionAdmin({ query, set }, async (sessionUser) => {
|
||||
info(`User ${sessionUser.first_name} deleted worker ${workerInstance.value.name}`);
|
||||
await workerInstance.delete();
|
||||
return null;
|
||||
});
|
||||
},
|
||||
{
|
||||
params: t.Object({ workerId: t.String() }),
|
||||
query: t.Object({ sessionId: t.String() }),
|
||||
response: {
|
||||
200: t.Null(),
|
||||
401: t.Literal("Must be logged in"),
|
||||
403: t.Literal("Must be an admin"),
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:workerId/loras",
|
||||
async ({ params }) => {
|
||||
const workerInstance = await workerInstanceStore.getById(params.workerId);
|
||||
if (!workerInstance) {
|
||||
throw new NotFoundError("Worker not found");
|
||||
}
|
||||
const sdClient = createOpenApiFetch<SdApi.paths>({
|
||||
baseUrl: workerInstance.value.sdUrl,
|
||||
headers: getAuthHeader(workerInstance.value.sdAuth),
|
||||
});
|
||||
const lorasResponse = await sdClient.GET("/sdapi/v1/loras", {});
|
||||
if (lorasResponse.error) {
|
||||
throw new Error(
|
||||
`Loras request failed: ${lorasResponse["error"]}`,
|
||||
);
|
||||
}
|
||||
const loras = (lorasResponse.data as Lora[]).map((lora) => ({
|
||||
name: lora.name,
|
||||
alias: lora.alias ?? null,
|
||||
}));
|
||||
return loras;
|
||||
},
|
||||
{
|
||||
params: t.Object({ workerId: t.String() }),
|
||||
response: t.Array(
|
||||
t.Object({
|
||||
name: t.String(),
|
||||
alias: t.Nullable(t.String()),
|
||||
}),
|
||||
),
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:workerId/models",
|
||||
async ({ params }) => {
|
||||
const workerInstance = await workerInstanceStore.getById(params.workerId);
|
||||
if (!workerInstance) {
|
||||
throw new NotFoundError("Worker not found");
|
||||
}
|
||||
const sdClient = createOpenApiFetch<SdApi.paths>({
|
||||
baseUrl: workerInstance.value.sdUrl,
|
||||
headers: getAuthHeader(workerInstance.value.sdAuth),
|
||||
});
|
||||
const modelsResponse = await sdClient.GET("/sdapi/v1/sd-models", {});
|
||||
if (modelsResponse.error) {
|
||||
throw new Error(
|
||||
`Models request failed: ${modelsResponse["error"]}`,
|
||||
);
|
||||
}
|
||||
const models = modelsResponse.data.map((model) => ({
|
||||
title: model.title,
|
||||
modelName: model.model_name,
|
||||
hash: model.hash ?? null,
|
||||
sha256: model.sha256 ?? null,
|
||||
}));
|
||||
return models;
|
||||
},
|
||||
{
|
||||
params: t.Object({ workerId: t.String() }),
|
||||
response: t.Array(
|
||||
t.Object({
|
||||
title: t.String(),
|
||||
modelName: t.String(),
|
||||
hash: t.Nullable(t.String()),
|
||||
sha256: t.Nullable(t.String()),
|
||||
}),
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
export interface Lora {
|
||||
name: string;
|
||||
alias: string | null;
|
||||
metadata: object;
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
import { Static, t } from "elysia";
|
||||
import { db } from "./db.ts";
|
||||
import { Tkv } from "../utils/Tkv.ts";
|
||||
|
||||
export const adminSchema = t.Object({
|
||||
promotedBy: t.Nullable(t.Number()),
|
||||
});
|
||||
|
||||
export type Admin = Static<typeof adminSchema>;
|
||||
|
||||
export const adminStore = new Tkv<["admins", number], Admin>(db);
|
|
@ -1,53 +1,45 @@
|
|||
import * as SdApi from "../sd/sdApi.ts";
|
||||
import { Static, t } from "elysia";
|
||||
import { Tkv } from "../utils/Tkv.ts";
|
||||
import { db } from "./db.ts";
|
||||
|
||||
export interface ConfigData {
|
||||
adminUsernames: string[];
|
||||
pausedReason: string | null;
|
||||
maxUserJobs: number;
|
||||
maxJobs: number;
|
||||
defaultParams?: Partial<
|
||||
| SdApi.components["schemas"]["StableDiffusionProcessingTxt2Img"]
|
||||
| SdApi.components["schemas"]["StableDiffusionProcessingImg2Img"]
|
||||
>;
|
||||
sdInstances: SdInstanceData[];
|
||||
}
|
||||
export const defaultParamsSchema = t.Partial(t.Object({
|
||||
batch_size: t.Number(),
|
||||
n_iter: t.Number(),
|
||||
width: t.Number(),
|
||||
height: t.Number(),
|
||||
steps: t.Number(),
|
||||
cfg_scale: t.Number(),
|
||||
sampler_name: t.String(),
|
||||
negative_prompt: t.String(),
|
||||
}));
|
||||
|
||||
export interface SdInstanceData {
|
||||
id: string;
|
||||
name?: string;
|
||||
api: { url: string; auth?: string };
|
||||
maxResolution: number;
|
||||
}
|
||||
export type DefaultParams = Static<typeof defaultParamsSchema>;
|
||||
|
||||
const getDefaultConfig = (): ConfigData => ({
|
||||
adminUsernames: Deno.env.get("TG_ADMIN_USERS")?.split(",") ?? [],
|
||||
pausedReason: null,
|
||||
maxUserJobs: 3,
|
||||
maxJobs: 20,
|
||||
defaultParams: {
|
||||
batch_size: 1,
|
||||
n_iter: 1,
|
||||
width: 512,
|
||||
height: 768,
|
||||
steps: 30,
|
||||
cfg_scale: 10,
|
||||
negative_prompt: "boring_e621_fluffyrock_v4 boring_e621_v4",
|
||||
},
|
||||
sdInstances: [
|
||||
{
|
||||
id: "local",
|
||||
api: { url: Deno.env.get("SD_API_URL") ?? "http://127.0.0.1:7860/" },
|
||||
maxResolution: 1024 * 1024,
|
||||
},
|
||||
],
|
||||
export const configSchema = t.Object({
|
||||
pausedReason: t.Nullable(t.String()),
|
||||
maxUserJobs: t.Number(),
|
||||
maxJobs: t.Number(),
|
||||
defaultParams: defaultParamsSchema,
|
||||
});
|
||||
|
||||
export async function getConfig(): Promise<ConfigData> {
|
||||
const configEntry = await db.get<ConfigData>(["config"]);
|
||||
return configEntry.value ?? getDefaultConfig();
|
||||
export type Config = Static<typeof configSchema>;
|
||||
|
||||
export const configStore = new Tkv<["config"], Config>(db);
|
||||
|
||||
const defaultConfig: Config = {
|
||||
pausedReason: null,
|
||||
maxUserJobs: Infinity,
|
||||
maxJobs: Infinity,
|
||||
defaultParams: {},
|
||||
};
|
||||
|
||||
export async function getConfig(): Promise<Config> {
|
||||
const configEntry = await configStore.get(["config"]);
|
||||
return { ...defaultConfig, ...configEntry.value };
|
||||
}
|
||||
|
||||
export async function setConfig(config: ConfigData): Promise<void> {
|
||||
await db.set(["config"], config);
|
||||
export async function setConfig<K extends keyof Config>(newConfig: Pick<Config, K>): Promise<void> {
|
||||
const configEntry = await configStore.get(["config"]);
|
||||
const config = { ...defaultConfig, ...configEntry.value, ...newConfig };
|
||||
await configStore.atomicSet(["config"], configEntry.versionstamp, config);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,69 @@
|
|||
import { hoursToMilliseconds, isSameDay, minutesToMilliseconds } from "date-fns";
|
||||
import { UTCDateMini } from "date-fns/utc";
|
||||
import { Static, t } from "elysia";
|
||||
import { info } from "std/log/mod.ts";
|
||||
import { db } from "./db.ts";
|
||||
import { generationStore } from "./generationStore.ts";
|
||||
import { kvMemoize } from "../utils/kvMemoize.ts";
|
||||
|
||||
export const dailyStatsSchema = t.Object({
|
||||
userIds: t.Array(t.Number()),
|
||||
imageCount: t.Number(),
|
||||
stepCount: t.Number(),
|
||||
pixelCount: t.Number(),
|
||||
pixelStepCount: t.Number(),
|
||||
timestamp: t.Number(),
|
||||
});
|
||||
|
||||
export type DailyStats = Static<typeof dailyStatsSchema>;
|
||||
|
||||
export const getDailyStats = kvMemoize(
|
||||
db,
|
||||
["dailyStats"],
|
||||
async (year: number, month: number, day: number): Promise<DailyStats> => {
|
||||
const userIdSet = new Set<number>();
|
||||
let imageCount = 0;
|
||||
let stepCount = 0;
|
||||
let pixelCount = 0;
|
||||
let pixelStepCount = 0;
|
||||
|
||||
const after = new Date(Date.UTC(year, month - 1, day));
|
||||
const before = new Date(Date.UTC(year, month - 1, day + 1));
|
||||
|
||||
info(`Calculating daily stats for ${year}-${month}-${day}`);
|
||||
|
||||
for await (
|
||||
const generation of generationStore.listAll({ after, before })
|
||||
) {
|
||||
userIdSet.add(generation.value.from.id);
|
||||
imageCount++;
|
||||
stepCount += generation.value.info?.steps ?? 0;
|
||||
pixelCount += (generation.value.info?.width ?? 0) * (generation.value.info?.height ?? 0);
|
||||
pixelStepCount += (generation.value.info?.width ?? 0) *
|
||||
(generation.value.info?.height ?? 0) *
|
||||
(generation.value.info?.steps ?? 0);
|
||||
}
|
||||
|
||||
return {
|
||||
userIds: [...userIdSet],
|
||||
imageCount,
|
||||
stepCount,
|
||||
pixelCount,
|
||||
pixelStepCount,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
},
|
||||
{
|
||||
// expire in 1 minute if was calculated on the same day, otherwise 7-14 days.
|
||||
expireIn: (result, year, month, day) => {
|
||||
const requestDate = new UTCDateMini(year, month - 1, day);
|
||||
const calculatedDate = new UTCDateMini(result.timestamp);
|
||||
return isSameDay(requestDate, calculatedDate)
|
||||
? minutesToMilliseconds(1)
|
||||
: hoursToMilliseconds(24 * 7 + Math.random() * 24 * 7);
|
||||
},
|
||||
// should cache if the stats are non-zero
|
||||
shouldCache: (result) =>
|
||||
result.userIds.length > 0 || result.imageCount > 0 || result.pixelCount > 0,
|
||||
},
|
||||
);
|
|
@ -1,4 +1,4 @@
|
|||
import { KvFs } from "kvfs";
|
||||
|
||||
export const db = await Deno.openKv("./app.db");
|
||||
export const db = await Deno.openKv(Deno.env.get("DENO_KV_PATH"));
|
||||
export const fs = new KvFs(db);
|
||||
|
|
|
@ -2,22 +2,23 @@ import { promiseState } from "async";
|
|||
import { Chat, Message, User } from "grammy_types";
|
||||
import { JobData, Queue, Worker } from "kvmq";
|
||||
import createOpenApiClient from "openapi_fetch";
|
||||
import { delay } from "std/async";
|
||||
import { decode, encode } from "std/encoding/base64";
|
||||
import { getLogger } from "std/log";
|
||||
import { delay } from "std/async/delay.ts";
|
||||
import { decode, encode } from "std/encoding/base64.ts";
|
||||
import { debug, error, info } from "std/log/mod.ts";
|
||||
import { ulid } from "ulid";
|
||||
import { bot } from "../bot/mod.ts";
|
||||
import { SdError } from "../sd/SdError.ts";
|
||||
import { PngInfo } from "../sd/parsePngInfo.ts";
|
||||
import * as SdApi from "../sd/sdApi.ts";
|
||||
import { PngInfo } from "../bot/parsePngInfo.ts";
|
||||
import { formatOrdinal } from "../utils/formatOrdinal.ts";
|
||||
import { formatUserChat } from "../utils/formatUserChat.ts";
|
||||
import { getConfig, SdInstanceData } from "./config.ts";
|
||||
import { getAuthHeader } from "../utils/getAuthHeader.ts";
|
||||
import { omitUndef } from "../utils/omitUndef.ts";
|
||||
import { SdError } from "./SdError.ts";
|
||||
import { getConfig } from "./config.ts";
|
||||
import { db, fs } from "./db.ts";
|
||||
import { SdGenerationInfo } from "./generationStore.ts";
|
||||
import * as SdApi from "./sdApi.ts";
|
||||
import { uploadQueue } from "./uploadQueue.ts";
|
||||
|
||||
const logger = () => getLogger();
|
||||
import { workerInstanceStore } from "./workerInstanceStore.ts";
|
||||
|
||||
interface GenerationJob {
|
||||
task:
|
||||
|
@ -34,7 +35,7 @@ interface GenerationJob {
|
|||
chat: Chat;
|
||||
requestMessage: Message;
|
||||
replyMessage: Message;
|
||||
sdInstanceId?: string;
|
||||
workerInstanceKey?: string;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
|
@ -45,17 +46,17 @@ export const activeGenerationWorkers = new Map<string, Worker<GenerationJob>>();
|
|||
/**
|
||||
* Initializes queue workers for each SD instance when they become online.
|
||||
*/
|
||||
export async function processGenerationQueue() {
|
||||
export async function processGenerationQueue(): Promise<never> {
|
||||
while (true) {
|
||||
const config = await getConfig();
|
||||
|
||||
for (const sdInstance of config.sdInstances) {
|
||||
const activeWorker = activeGenerationWorkers.get(sdInstance.id);
|
||||
if (activeWorker?.isProcessing) continue;
|
||||
for await (const workerInstance of workerInstanceStore.listAll()) {
|
||||
const activeWorker = activeGenerationWorkers.get(workerInstance.id);
|
||||
if (activeWorker?.isProcessing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const workerSdClient = createOpenApiClient<SdApi.paths>({
|
||||
baseUrl: sdInstance.api.url,
|
||||
headers: { "Authorization": sdInstance.api.auth },
|
||||
baseUrl: workerInstance.value.sdUrl,
|
||||
headers: getAuthHeader(workerInstance.value.sdAuth),
|
||||
});
|
||||
|
||||
// check if worker is up
|
||||
|
@ -69,18 +70,23 @@ export async function processGenerationQueue() {
|
|||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
logger().warning(`Worker ${sdInstance.id} is down: ${error}`);
|
||||
const cleanedErrorMessage = error.message.replace(/url \([^)]+\)/, "");
|
||||
workerInstance.update({ lastError: { message: cleanedErrorMessage, time: Date.now() } })
|
||||
.catch(() => undefined);
|
||||
debug(`Worker ${workerInstance.value.key} is down: ${error}`);
|
||||
});
|
||||
|
||||
if (!activeWorkerStatus?.data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// create worker
|
||||
const newWorker = generationQueue.createWorker(async ({ state }, updateJob) => {
|
||||
await processGenerationJob(state, updateJob, sdInstance);
|
||||
await processGenerationJob(state, updateJob, workerInstance.id);
|
||||
});
|
||||
|
||||
newWorker.addEventListener("error", (e) => {
|
||||
logger().error(
|
||||
error(
|
||||
`Generation failed for ${formatUserChat(e.detail.job.state)}: ${e.detail.error}`,
|
||||
);
|
||||
bot.api.sendMessage(
|
||||
|
@ -94,13 +100,20 @@ export async function processGenerationQueue() {
|
|||
allow_sending_without_reply: true,
|
||||
},
|
||||
).catch(() => undefined);
|
||||
if (e.detail.error instanceof SdError) {
|
||||
newWorker.stopProcessing();
|
||||
}
|
||||
workerInstance.update({ lastError: { message: e.detail.error.message, time: Date.now() } })
|
||||
.catch(() => undefined);
|
||||
info(`Stopped worker ${workerInstance.value.key}`);
|
||||
});
|
||||
|
||||
newWorker.addEventListener("complete", () => {
|
||||
workerInstance.update({ lastOnlineTime: Date.now() }).catch(() => undefined);
|
||||
});
|
||||
|
||||
await workerInstance.update({ lastOnlineTime: Date.now() });
|
||||
newWorker.processJobs();
|
||||
activeGenerationWorkers.set(sdInstance.id, newWorker);
|
||||
logger().info(`Started worker ${sdInstance.id}`);
|
||||
activeGenerationWorkers.set(workerInstance.id, newWorker);
|
||||
info(`Started worker ${workerInstance.value.key}`);
|
||||
}
|
||||
await delay(60_000);
|
||||
}
|
||||
|
@ -112,39 +125,37 @@ export async function processGenerationQueue() {
|
|||
async function processGenerationJob(
|
||||
state: GenerationJob,
|
||||
updateJob: (job: Partial<JobData<GenerationJob>>) => Promise<void>,
|
||||
sdInstance: SdInstanceData,
|
||||
workerInstanceId: string,
|
||||
) {
|
||||
const startDate = new Date();
|
||||
const workerSdClient = createOpenApiClient<SdApi.paths>({
|
||||
baseUrl: sdInstance.api.url,
|
||||
headers: { "Authorization": sdInstance.api.auth },
|
||||
});
|
||||
state.sdInstanceId = sdInstance.id;
|
||||
logger().debug(`Generation started for ${formatUserChat(state)}`);
|
||||
await updateJob({ state: state });
|
||||
|
||||
// check if bot can post messages in this chat
|
||||
const chat = await bot.api.getChat(state.chat.id);
|
||||
if (
|
||||
(chat.type === "group" || chat.type === "supergroup") &&
|
||||
(!chat.permissions?.can_send_messages || !chat.permissions?.can_send_photos)
|
||||
) {
|
||||
throw new Error("Bot doesn't have permissions to send photos in this chat");
|
||||
const config = await getConfig();
|
||||
const workerInstance = await workerInstanceStore.getById(workerInstanceId);
|
||||
if (!workerInstance) {
|
||||
throw new Error(`Unknown workerInstanceId: ${workerInstanceId}`);
|
||||
}
|
||||
const workerSdClient = createOpenApiClient<SdApi.paths>({
|
||||
baseUrl: workerInstance.value.sdUrl,
|
||||
headers: getAuthHeader(workerInstance.value.sdAuth),
|
||||
});
|
||||
state.workerInstanceKey = workerInstance.value.key;
|
||||
state.progress = 0;
|
||||
debug(`Generation started for ${formatUserChat(state)}`);
|
||||
await updateJob({ state: state });
|
||||
|
||||
// edit the existing status message
|
||||
await bot.api.editMessageText(
|
||||
state.replyMessage.chat.id,
|
||||
state.replyMessage.message_id,
|
||||
`Generating your prompt now... 0% using ${sdInstance.name || sdInstance.id}`,
|
||||
`Generating your prompt now... 0% using ${
|
||||
workerInstance.value.name || workerInstance.value.key
|
||||
}`,
|
||||
{ maxAttempts: 1 },
|
||||
);
|
||||
).catch(() => undefined);
|
||||
|
||||
// reduce size if worker can't handle the resolution
|
||||
const config = await getConfig();
|
||||
const size = limitSize(
|
||||
{ ...config.defaultParams, ...state.task.params },
|
||||
sdInstance.maxResolution,
|
||||
omitUndef({ ...config.defaultParams, ...state.task.params }),
|
||||
1024 * 1024,
|
||||
);
|
||||
function limitSize(
|
||||
{ width, height }: { width?: number; height?: number },
|
||||
|
@ -164,18 +175,18 @@ async function processGenerationJob(
|
|||
// start generating the image
|
||||
const responsePromise = state.task.type === "txt2img"
|
||||
? workerSdClient.POST("/sdapi/v1/txt2img", {
|
||||
body: {
|
||||
body: omitUndef({
|
||||
...config.defaultParams,
|
||||
...state.task.params,
|
||||
...size,
|
||||
negative_prompt: state.task.params.negative_prompt
|
||||
? state.task.params.negative_prompt
|
||||
: config.defaultParams?.negative_prompt,
|
||||
},
|
||||
}),
|
||||
})
|
||||
: state.task.type === "img2img"
|
||||
? workerSdClient.POST("/sdapi/v1/img2img", {
|
||||
body: {
|
||||
body: omitUndef({
|
||||
...config.defaultParams,
|
||||
...state.task.params,
|
||||
...size,
|
||||
|
@ -191,7 +202,7 @@ async function processGenerationJob(
|
|||
).then((resp) => resp.arrayBuffer()),
|
||||
),
|
||||
],
|
||||
},
|
||||
}),
|
||||
})
|
||||
: undefined;
|
||||
|
||||
|
@ -199,7 +210,12 @@ async function processGenerationJob(
|
|||
throw new Error(`Unknown task type: ${state.task.type}`);
|
||||
}
|
||||
|
||||
// we await the promise only after it finishes
|
||||
// so we need to add catch callback to not crash the process before that
|
||||
responsePromise.catch(() => undefined);
|
||||
|
||||
// poll for progress while the generation request is pending
|
||||
|
||||
do {
|
||||
const progressResponse = await workerSdClient.GET("/sdapi/v1/progress", {
|
||||
params: {},
|
||||
|
@ -213,21 +229,20 @@ async function processGenerationJob(
|
|||
);
|
||||
}
|
||||
|
||||
if (progressResponse.data.progress > state.progress) {
|
||||
state.progress = progressResponse.data.progress;
|
||||
await updateJob({ state: state });
|
||||
|
||||
await bot.api.sendChatAction(state.chat.id, "upload_photo", { maxAttempts: 1 })
|
||||
.catch(() => undefined);
|
||||
await bot.api.editMessageText(
|
||||
state.replyMessage.chat.id,
|
||||
state.replyMessage.message_id,
|
||||
`Generating your prompt now... ${(progressResponse.data.progress * 100).toFixed(0)}% using ${
|
||||
sdInstance.name || sdInstance.id
|
||||
}`,
|
||||
`Generating your prompt now... ${
|
||||
(progressResponse.data.progress * 100).toFixed(0)
|
||||
}% using ${workerInstance.value.name || workerInstance.value.key}`,
|
||||
{ maxAttempts: 1 },
|
||||
).catch(() => undefined);
|
||||
}
|
||||
|
||||
await Promise.race([delay(3000), responsePromise]).catch(() => undefined);
|
||||
await Promise.race([delay(2_000), responsePromise]).catch(() => undefined);
|
||||
} while (await promiseState(responsePromise) === "pending");
|
||||
|
||||
// check response
|
||||
|
@ -257,48 +272,56 @@ async function processGenerationJob(
|
|||
from: state.from,
|
||||
requestMessage: state.requestMessage,
|
||||
replyMessage: state.replyMessage,
|
||||
sdInstanceId: sdInstance.id,
|
||||
workerInstanceKey: workerInstance.value.key,
|
||||
startDate,
|
||||
endDate: new Date(),
|
||||
imageKeys,
|
||||
info,
|
||||
}, { retryCount: 5, retryDelayMs: 10000 });
|
||||
|
||||
const uploadQueueSize = await uploadQueue.getAllJobs().then((jobs) =>
|
||||
jobs.filter((job) => job.status !== "processing").length
|
||||
);
|
||||
|
||||
// change status message to uploading images
|
||||
await bot.api.editMessageText(
|
||||
state.replyMessage.chat.id,
|
||||
state.replyMessage.message_id,
|
||||
`Uploading your images...`,
|
||||
uploadQueueSize > 10
|
||||
? `You are ${formatOrdinal(uploadQueueSize)} in upload queue.`
|
||||
: `Uploading your images...`,
|
||||
{ maxAttempts: 1 },
|
||||
).catch(() => undefined);
|
||||
|
||||
logger().debug(`Generation finished for ${formatUserChat(state)}`);
|
||||
// send upload photo action
|
||||
await bot.api.sendChatAction(state.chat.id, "upload_photo", { maxAttempts: 1 })
|
||||
.catch(() => undefined);
|
||||
|
||||
debug(`Generation finished for ${formatUserChat(state)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles queue updates and updates the status message.
|
||||
*/
|
||||
export async function updateGenerationQueue() {
|
||||
export async function updateGenerationQueue(): Promise<never> {
|
||||
while (true) {
|
||||
const jobs = await generationQueue.getAllJobs();
|
||||
let index = 0;
|
||||
for (const job of jobs) {
|
||||
if (job.lockUntil > new Date()) {
|
||||
// job is currently being processed, the worker will update its status message
|
||||
continue;
|
||||
|
||||
await Promise.all(jobs.map(async (job) => {
|
||||
if (job.status === "processing") {
|
||||
// if the job is processing, the worker will update its status message
|
||||
return;
|
||||
}
|
||||
if (!job.state.replyMessage) {
|
||||
// no status message, nothing to update
|
||||
continue;
|
||||
}
|
||||
index++;
|
||||
await bot.api.editMessageText(
|
||||
|
||||
return await bot.api.editMessageText(
|
||||
job.state.replyMessage.chat.id,
|
||||
job.state.replyMessage.message_id,
|
||||
`You are ${formatOrdinal(index)} in queue.`,
|
||||
`You are ${formatOrdinal(job.place)} in queue.`,
|
||||
{ maxAttempts: 1 },
|
||||
).catch(() => undefined);
|
||||
}
|
||||
await delay(3000);
|
||||
).catch(() => {});
|
||||
}));
|
||||
|
||||
// delay between updates based on the number of jobs
|
||||
await delay(Math.min(15_000, Math.max(3_000, jobs.length * 100)));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,10 +5,10 @@ import { db } from "./db.ts";
|
|||
export interface GenerationSchema {
|
||||
from: User;
|
||||
chat: Chat;
|
||||
sdInstanceId?: string;
|
||||
info?: SdGenerationInfo;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
sdInstanceId?: string | undefined; // TODO: change to workerInstanceKey
|
||||
info?: SdGenerationInfo | undefined;
|
||||
startDate?: Date | undefined;
|
||||
endDate?: Date | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -48,6 +48,7 @@ export interface SdGenerationInfo {
|
|||
type GenerationIndices = {
|
||||
fromId: number;
|
||||
chatId: number;
|
||||
workerInstanceKey: string;
|
||||
};
|
||||
|
||||
export const generationStore = new Store<GenerationSchema, GenerationIndices>(
|
||||
|
@ -57,6 +58,7 @@ export const generationStore = new Store<GenerationSchema, GenerationIndices>(
|
|||
indices: {
|
||||
fromId: { getValue: (item) => item.from.id },
|
||||
chatId: { getValue: (item) => item.chat.id },
|
||||
workerInstanceKey: { getValue: (item) => item.sdInstanceId ?? "" },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
import { addDays } from "date-fns";
|
||||
import { decodeTime } from "ulid";
|
||||
import { getDailyStats } from "./dailyStatsStore.ts";
|
||||
import { generationStore } from "./generationStore.ts";
|
||||
|
||||
export interface GlobalStats {
|
||||
userIds: number[];
|
||||
imageCount: number;
|
||||
stepCount: number;
|
||||
pixelCount: number;
|
||||
pixelStepCount: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export const globalStats: GlobalStats = await getGlobalStats();
|
||||
|
||||
async function getGlobalStats(): Promise<GlobalStats> {
|
||||
// find the year/month/day of the first generation
|
||||
const startDate = await generationStore.getAll({}, { limit: 1 })
|
||||
.then((generations) => generations[0]?.id)
|
||||
.then((generationId) => generationId ? new Date(decodeTime(generationId)) : new Date());
|
||||
|
||||
// iterate to today and sum up stats
|
||||
const userIdSet = new Set<number>();
|
||||
let imageCount = 0;
|
||||
let stepCount = 0;
|
||||
let pixelCount = 0;
|
||||
let pixelStepCount = 0;
|
||||
|
||||
const tomorrow = addDays(new Date(), 1);
|
||||
|
||||
for (
|
||||
let date = startDate;
|
||||
date < tomorrow;
|
||||
date = addDays(date, 1)
|
||||
) {
|
||||
const dailyStats = await getDailyStats(
|
||||
date.getUTCFullYear(),
|
||||
date.getUTCMonth() + 1,
|
||||
date.getUTCDate(),
|
||||
);
|
||||
for (const userId of dailyStats.userIds) userIdSet.add(userId);
|
||||
imageCount += dailyStats.imageCount;
|
||||
stepCount += dailyStats.stepCount;
|
||||
pixelCount += dailyStats.pixelCount;
|
||||
pixelStepCount += dailyStats.pixelStepCount;
|
||||
}
|
||||
|
||||
return {
|
||||
userIds: [...userIdSet],
|
||||
imageCount,
|
||||
stepCount,
|
||||
pixelCount,
|
||||
pixelStepCount,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
|
@ -3,21 +3,20 @@ import { InputFile, InputMediaBuilder } from "grammy";
|
|||
import { bold, fmt } from "grammy_parse_mode";
|
||||
import { Chat, Message, User } from "grammy_types";
|
||||
import { Queue } from "kvmq";
|
||||
import { format } from "std/fmt/duration";
|
||||
import { getLogger } from "std/log";
|
||||
import { format } from "std/fmt/duration.ts";
|
||||
import { debug, error } from "std/log/mod.ts";
|
||||
import { bot } from "../bot/mod.ts";
|
||||
import { formatUserChat } from "../utils/formatUserChat.ts";
|
||||
import { db, fs } from "./db.ts";
|
||||
import { generationStore, SdGenerationInfo } from "./generationStore.ts";
|
||||
|
||||
const logger = () => getLogger();
|
||||
import { globalStats } from "./globalStats.ts";
|
||||
|
||||
interface UploadJob {
|
||||
from: User;
|
||||
chat: Chat;
|
||||
requestMessage: Message;
|
||||
replyMessage: Message;
|
||||
sdInstanceId: string;
|
||||
workerInstanceKey?: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
imageKeys: Deno.KvKey[];
|
||||
|
@ -55,19 +54,23 @@ export async function processUploadQueue() {
|
|||
fmt`${bold("CFG scale:")} ${state.info.cfg_scale}, `,
|
||||
fmt`${bold("Seed:")} ${state.info.seed}, `,
|
||||
fmt`${bold("Size")}: ${state.info.width}x${state.info.height}, `,
|
||||
fmt`${bold("Worker")}: ${state.sdInstanceId}, `,
|
||||
state.workerInstanceKey ? fmt`${bold("Worker")}: ${state.workerInstanceKey}, ` : "",
|
||||
fmt`${bold("Time taken")}: ${format(jobDurationMs, { ignoreZero: true })}`,
|
||||
]
|
||||
: [],
|
||||
]);
|
||||
|
||||
// parse files from reply JSON
|
||||
let size = 0;
|
||||
const types = new Set<string>();
|
||||
const inputFiles = await Promise.all(
|
||||
state.imageKeys.map(async (fileKey, idx) => {
|
||||
const imageBuffer = await fs.get(fileKey).then((entry) => entry.value);
|
||||
if (!imageBuffer) throw new Error("File not found");
|
||||
const imageType = await fileTypeFromBuffer(imageBuffer);
|
||||
if (!imageType) throw new Error("Image has unknown type");
|
||||
size += imageBuffer.byteLength;
|
||||
types.add(imageType.ext);
|
||||
return InputMediaBuilder.photo(
|
||||
new InputFile(imageBuffer, `image${idx}.${imageType.ext}`),
|
||||
// if it can fit, add caption for first photo
|
||||
|
@ -89,7 +92,7 @@ export async function processUploadQueue() {
|
|||
// send caption in separate message if it couldn't fit
|
||||
if (caption.text.length > 1024 && caption.text.length <= 4096) {
|
||||
await bot.api.sendMessage(state.chat.id, caption.text, {
|
||||
reply_to_message_id: resultMessages[0].message_id,
|
||||
reply_to_message_id: resultMessages[0]!.message_id,
|
||||
allow_sending_without_reply: true,
|
||||
entities: caption.entities,
|
||||
maxWait: 60,
|
||||
|
@ -103,19 +106,36 @@ export async function processUploadQueue() {
|
|||
await generationStore.create({
|
||||
from: state.from,
|
||||
chat: state.chat,
|
||||
sdInstanceId: state.sdInstanceId,
|
||||
sdInstanceId: state.workerInstanceKey,
|
||||
startDate: state.startDate,
|
||||
endDate: new Date(),
|
||||
info: state.info,
|
||||
});
|
||||
|
||||
// update live stats
|
||||
{
|
||||
globalStats.imageCount++;
|
||||
globalStats.stepCount += state.info.steps;
|
||||
globalStats.pixelCount += state.info.width * state.info.height;
|
||||
globalStats.pixelStepCount += state.info.width * state.info.height * state.info.steps;
|
||||
const userIdSet = new Set(globalStats.userIds);
|
||||
userIdSet.add(state.from.id);
|
||||
globalStats.userIds = [...userIdSet];
|
||||
}
|
||||
|
||||
debug(
|
||||
`Uploaded ${state.imageKeys.length} ${[...types].join(",")} images (${
|
||||
Math.trunc(size / 1024)
|
||||
}kB) for ${formatUserChat(state)}`,
|
||||
);
|
||||
|
||||
// delete the status message
|
||||
await bot.api.deleteMessage(state.replyMessage.chat.id, state.replyMessage.message_id)
|
||||
.catch(() => undefined);
|
||||
}, { concurrency: 3 });
|
||||
}, { concurrency: 10 });
|
||||
|
||||
uploadWorker.addEventListener("error", (e) => {
|
||||
logger().error(`Upload failed for ${formatUserChat(e.detail.job.state)}: ${e.detail.error}`);
|
||||
error(`Upload failed for ${formatUserChat(e.detail.job.state)}: ${e.detail.error}`);
|
||||
bot.api.sendMessage(
|
||||
e.detail.job.state.requestMessage.chat.id,
|
||||
`Upload failed: ${e.detail.error}\n\n` +
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
import { Static, t } from "elysia";
|
||||
import { kvMemoize } from "../utils/kvMemoize.ts";
|
||||
import { db } from "./db.ts";
|
||||
import { generationStore } from "./generationStore.ts";
|
||||
import { hoursToMilliseconds, isSameDay, minutesToMilliseconds } from "date-fns";
|
||||
import { UTCDateMini } from "date-fns/utc";
|
||||
|
||||
export const userDailyStatsSchema = t.Object({
|
||||
imageCount: t.Number(),
|
||||
pixelCount: t.Number(),
|
||||
pixelStepCount: t.Number(),
|
||||
timestamp: t.Number(),
|
||||
});
|
||||
|
||||
export type UserDailyStats = Static<typeof userDailyStatsSchema>;
|
||||
|
||||
export const getUserDailyStats = kvMemoize(
|
||||
db,
|
||||
["userDailyStats"],
|
||||
async (userId: number, year: number, month: number, day: number): Promise<UserDailyStats> => {
|
||||
let imageCount = 0;
|
||||
let pixelCount = 0;
|
||||
let pixelStepCount = 0;
|
||||
|
||||
for await (
|
||||
const generation of generationStore.listBy("fromId", {
|
||||
after: new Date(Date.UTC(year, month - 1, day)),
|
||||
before: new Date(Date.UTC(year, month - 1, day + 1)),
|
||||
value: userId,
|
||||
})
|
||||
) {
|
||||
imageCount++;
|
||||
pixelCount += (generation.value.info?.width ?? 0) * (generation.value.info?.height ?? 0);
|
||||
pixelStepCount += (generation.value.info?.width ?? 0) *
|
||||
(generation.value.info?.height ?? 0) *
|
||||
(generation.value.info?.steps ?? 0);
|
||||
}
|
||||
|
||||
return {
|
||||
imageCount,
|
||||
pixelCount,
|
||||
pixelStepCount,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
},
|
||||
{
|
||||
// expire in 1 minute if was calculated on the same day, otherwise 7-14 days.
|
||||
expireIn: (result, year, month, day) => {
|
||||
const requestDate = new UTCDateMini(year, month - 1, day);
|
||||
const calculatedDate = new UTCDateMini(result.timestamp);
|
||||
return isSameDay(requestDate, calculatedDate)
|
||||
? minutesToMilliseconds(1)
|
||||
: hoursToMilliseconds(24 * 7 + Math.random() * 24 * 7);
|
||||
},
|
||||
// should cache if the stats are non-zero
|
||||
shouldCache: (result) =>
|
||||
result.imageCount > 0 || result.pixelCount > 0 || result.pixelStepCount > 0,
|
||||
},
|
||||
);
|
|
@ -0,0 +1,122 @@
|
|||
import { minutesToMilliseconds } from "date-fns";
|
||||
import { Store } from "indexed_kv";
|
||||
import { info } from "std/log/mod.ts";
|
||||
import { Static, t } from "elysia";
|
||||
import { db } from "./db.ts";
|
||||
import { generationStore } from "./generationStore.ts";
|
||||
import { kvMemoize } from "../utils/kvMemoize.ts";
|
||||
import { sortBy } from "std/collections/sort_by.ts";
|
||||
|
||||
export const userStatsSchema = t.Object({
|
||||
userId: t.Number(),
|
||||
imageCount: t.Number(),
|
||||
stepCount: t.Number(),
|
||||
pixelCount: t.Number(),
|
||||
pixelStepCount: t.Number(),
|
||||
tagCountMap: t.Record(t.String(), t.Number()),
|
||||
timestamp: t.Number(),
|
||||
});
|
||||
|
||||
export type UserStats = Static<typeof userStatsSchema>;
|
||||
|
||||
type UserStatsIndices = {
|
||||
userId: number;
|
||||
imageCount: number;
|
||||
pixelCount: number;
|
||||
};
|
||||
|
||||
const userStatsStore = new Store<UserStats, UserStatsIndices>(
|
||||
db,
|
||||
"userStats",
|
||||
{
|
||||
indices: {
|
||||
userId: { getValue: (item) => item.userId },
|
||||
imageCount: { getValue: (item) => item.imageCount },
|
||||
pixelCount: { getValue: (item) => item.pixelCount },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const getUserStats = kvMemoize(
|
||||
db,
|
||||
["userStats"],
|
||||
async (userId: number): Promise<UserStats> => {
|
||||
let imageCount = 0;
|
||||
let stepCount = 0;
|
||||
let pixelCount = 0;
|
||||
let pixelStepCount = 0;
|
||||
const tagCountMap = new Map<string, number>();
|
||||
|
||||
info(`Calculating user stats for ${userId}`);
|
||||
|
||||
for await (
|
||||
const generation of generationStore.listBy("fromId", { value: userId })
|
||||
) {
|
||||
imageCount++;
|
||||
stepCount += generation.value.info?.steps ?? 0;
|
||||
pixelCount += (generation.value.info?.width ?? 0) * (generation.value.info?.height ?? 0);
|
||||
pixelStepCount += (generation.value.info?.width ?? 0) *
|
||||
(generation.value.info?.height ?? 0) *
|
||||
(generation.value.info?.steps ?? 0);
|
||||
|
||||
const tags = generation.value.info?.prompt
|
||||
// split on punctuation and newlines
|
||||
.split(/[,;.]\s+|\n/)
|
||||
// remove `:weight` syntax
|
||||
.map((tag) => tag.replace(/:[\d\.]+/g, ""))
|
||||
// remove `(tag)` and `[tag]` syntax
|
||||
.map((tag) => tag.replace(/[()[\]]/g, " "))
|
||||
// collapse multiple whitespace to one
|
||||
.map((tag) => tag.replace(/\s+/g, " "))
|
||||
// trim whitespace
|
||||
.map((tag) => tag.trim())
|
||||
// remove empty tags
|
||||
.filter((tag) => tag.length > 0)
|
||||
// lowercase tags
|
||||
.map((tag) => tag.toLowerCase()) ??
|
||||
// default to empty array
|
||||
[];
|
||||
|
||||
for (const tag of tags) {
|
||||
const count = tagCountMap.get(tag) ?? 0;
|
||||
tagCountMap.set(tag, count + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const tagCountObj = Object.fromEntries(
|
||||
sortBy(
|
||||
Array.from(tagCountMap.entries()),
|
||||
([_tag, count]) => -count,
|
||||
).filter(([_tag, count]) => count >= 3),
|
||||
);
|
||||
|
||||
return {
|
||||
userId,
|
||||
imageCount,
|
||||
stepCount,
|
||||
pixelCount,
|
||||
pixelStepCount,
|
||||
tagCountMap: tagCountObj,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
},
|
||||
{
|
||||
// expire in random time between 5-10 minutes
|
||||
expireIn: () => minutesToMilliseconds(5 + Math.random() * 5),
|
||||
// override default set/get behavior to use userStatsStore
|
||||
override: {
|
||||
get: async (_key, [userId]) => {
|
||||
const items = await userStatsStore.getBy("userId", { value: userId }, { reverse: true });
|
||||
return items[0]?.value;
|
||||
},
|
||||
set: async (_key, [userId], value, options) => {
|
||||
// delete old stats
|
||||
for await (const item of userStatsStore.listBy("userId", { value: userId })) {
|
||||
await item.delete();
|
||||
}
|
||||
// set new stats
|
||||
await userStatsStore.create(value, options);
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
|
@ -0,0 +1,24 @@
|
|||
import { Store } from "indexed_kv";
|
||||
import { Static, t } from "elysia";
|
||||
import { db } from "./db.ts";
|
||||
|
||||
export const workerInstanceSchema = t.Object({
|
||||
key: t.String(),
|
||||
name: t.Nullable(t.String()),
|
||||
sdUrl: t.String(),
|
||||
sdAuth: t.Nullable(t.Object({
|
||||
user: t.String(),
|
||||
password: t.String(),
|
||||
})),
|
||||
lastOnlineTime: t.Optional(t.Number()),
|
||||
lastError: t.Optional(t.Object({
|
||||
message: t.String(),
|
||||
time: t.Number(),
|
||||
})),
|
||||
});
|
||||
|
||||
export type WorkerInstance = Static<typeof workerInstanceSchema>;
|
||||
|
||||
export const workerInstanceStore = new Store<WorkerInstance>(db, "workerInstances", {
|
||||
indices: {},
|
||||
});
|
|
@ -1,19 +1,20 @@
|
|||
import { CommandContext } from "grammy";
|
||||
import { bold, fmt, FormattedString } from "grammy_parse_mode";
|
||||
import { distinctBy } from "std/collections";
|
||||
import { getConfig } from "../app/config.ts";
|
||||
import { distinctBy } from "std/collections/distinct_by.ts";
|
||||
import { error, info } from "std/log/mod.ts";
|
||||
import { adminStore } from "../app/adminStore.ts";
|
||||
import { generationStore } from "../app/generationStore.ts";
|
||||
import { ErisContext, logger } from "./mod.ts";
|
||||
import { formatUserChat } from "../utils/formatUserChat.ts";
|
||||
import { ErisContext } from "./mod.ts";
|
||||
|
||||
export async function broadcastCommand(ctx: CommandContext<ErisContext>) {
|
||||
if (!ctx.from?.username) {
|
||||
return ctx.reply("I don't know who you are.");
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const adminEntry = await adminStore.get(["admins", ctx.from.id]);
|
||||
|
||||
if (!config.adminUsernames.includes(ctx.from.username)) {
|
||||
if (!adminEntry.versionstamp) {
|
||||
return ctx.reply("Only a bot admin can use this command.");
|
||||
}
|
||||
|
||||
|
@ -39,16 +40,17 @@ export async function broadcastCommand(ctx: CommandContext<ErisContext>) {
|
|||
|
||||
const replyMessage = await ctx.replyFmt(getMessage(), {
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
allow_sending_without_reply: true,
|
||||
});
|
||||
|
||||
// send message to each user
|
||||
for (const gen of gens) {
|
||||
try {
|
||||
await ctx.api.sendMessage(gen.value.from.id, text);
|
||||
logger().info(`Broadcasted to ${formatUserChat({ from: gen.value.from })}`);
|
||||
info(`Broadcasted to ${formatUserChat({ from: gen.value.from })}`);
|
||||
sentCount++;
|
||||
} catch (err) {
|
||||
logger().error(`Broadcasting to ${formatUserChat({ from: gen.value.from })} failed: ${err}`);
|
||||
error(`Broadcasting to ${formatUserChat({ from: gen.value.from })} failed: ${err}`);
|
||||
errors.push(fmt`${bold(formatUserChat({ from: gen.value.from }))} - ${err.message}\n`);
|
||||
}
|
||||
const fmtMessage = getMessage();
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { generationQueue } from "../app/generationQueue.ts";
|
||||
import { omitUndef } from "../utils/omitUndef.ts";
|
||||
import { ErisContext } from "./mod.ts";
|
||||
|
||||
export async function cancelCommand(ctx: ErisContext) {
|
||||
|
@ -7,7 +8,11 @@ export async function cancelCommand(ctx: ErisContext) {
|
|||
.filter((job) => job.lockUntil < new Date())
|
||||
.filter((j) => j.state.from.id === ctx.from?.id);
|
||||
for (const job of userJobs) await generationQueue.deleteJob(job.id);
|
||||
await ctx.reply(`Cancelled ${userJobs.length} jobs`, {
|
||||
await ctx.reply(
|
||||
`Cancelled ${userJobs.length} jobs`,
|
||||
omitUndef({
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
});
|
||||
allow_sending_without_reply: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import { CommandContext } from "grammy";
|
||||
import { StatelessQuestion } from "grammy_stateless_question";
|
||||
import { maxBy } from "std/collections";
|
||||
import { maxBy } from "std/collections/max_by.ts";
|
||||
import { debug } from "std/log/mod.ts";
|
||||
import { getConfig } from "../app/config.ts";
|
||||
import { generationQueue } from "../app/generationQueue.ts";
|
||||
import { parsePngInfo, PngInfo } from "../sd/parsePngInfo.ts";
|
||||
import { formatUserChat } from "../utils/formatUserChat.ts";
|
||||
import { ErisContext, logger } from "./mod.ts";
|
||||
import { ErisContext } from "./mod.ts";
|
||||
import { parsePngInfo, PngInfo } from "./parsePngInfo.ts";
|
||||
|
||||
type QuestionState = { fileId?: string; params?: Partial<PngInfo> };
|
||||
|
||||
|
@ -27,10 +28,11 @@ async function img2img(
|
|||
includeRepliedTo: boolean,
|
||||
state: QuestionState = {},
|
||||
): Promise<void> {
|
||||
if (!ctx.message?.from?.id) {
|
||||
await ctx.reply("I don't know who you are", {
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
});
|
||||
if (!ctx.from || !ctx.message) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.from.is_bot) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -53,7 +55,7 @@ async function img2img(
|
|||
|
||||
const userJobs = jobs.filter((job) => job.state.from.id === ctx.message?.from?.id);
|
||||
if (userJobs.length >= config.maxUserJobs) {
|
||||
await ctx.reply(`You already have ${config.maxUserJobs} jobs in queue. Try again later.`, {
|
||||
await ctx.reply(`You already have ${userJobs.length} jobs in queue. Try again later.`, {
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
});
|
||||
return;
|
||||
|
@ -127,7 +129,7 @@ async function img2img(
|
|||
chat: ctx.message.chat,
|
||||
requestMessage: ctx.message,
|
||||
replyMessage: replyMessage,
|
||||
}, { retryCount: 3, repeatDelayMs: 10_000 });
|
||||
}, { priority: 0, retryCount: 3, repeatDelayMs: 10_000 });
|
||||
|
||||
logger().debug(`Generation (img2img) enqueued for ${formatUserChat(ctx.message)}`);
|
||||
debug(`Generation (img2img) enqueued for ${formatUserChat(ctx.message)}`);
|
||||
}
|
||||
|
|
92
bot/mod.ts
92
bot/mod.ts
|
@ -1,9 +1,11 @@
|
|||
import { Api, Bot, Context, RawApi, session, SessionFlavor } from "grammy";
|
||||
import { FileFlavor, hydrateFiles } from "grammy_files";
|
||||
import { hydrateReply, ParseModeFlavor } from "grammy_parse_mode";
|
||||
import { getLogger } from "std/log";
|
||||
import { getConfig, setConfig } from "../app/config.ts";
|
||||
import { run, sequentialize } from "grammy_runner";
|
||||
import { error, info, warning } from "std/log/mod.ts";
|
||||
import { sessions } from "../api/sessionsRoute.ts";
|
||||
import { formatUserChat } from "../utils/formatUserChat.ts";
|
||||
import { omitUndef } from "../utils/omitUndef.ts";
|
||||
import { broadcastCommand } from "./broadcastCommand.ts";
|
||||
import { cancelCommand } from "./cancelCommand.ts";
|
||||
import { img2imgCommand, img2imgQuestion } from "./img2imgCommand.ts";
|
||||
|
@ -11,19 +13,17 @@ import { pnginfoCommand, pnginfoQuestion } from "./pnginfoCommand.ts";
|
|||
import { queueCommand } from "./queueCommand.ts";
|
||||
import { txt2imgCommand, txt2imgQuestion } from "./txt2imgCommand.ts";
|
||||
|
||||
export const logger = () => getLogger();
|
||||
|
||||
interface SessionData {
|
||||
chat: ErisChatData;
|
||||
user: ErisUserData;
|
||||
}
|
||||
|
||||
interface ErisChatData {
|
||||
language?: string;
|
||||
language?: string | undefined;
|
||||
}
|
||||
|
||||
interface ErisUserData {
|
||||
params?: Record<string, string>;
|
||||
params?: Record<string, string> | undefined;
|
||||
}
|
||||
|
||||
export type ErisContext =
|
||||
|
@ -46,6 +46,9 @@ export const bot = new Bot<ErisContext, ErisApi>(
|
|||
);
|
||||
|
||||
bot.use(hydrateReply);
|
||||
|
||||
bot.use(sequentialize((ctx) => ctx.chat?.id.toString()));
|
||||
|
||||
bot.use(session<SessionData, ErisContext>({
|
||||
type: "multi",
|
||||
chat: {
|
||||
|
@ -72,7 +75,7 @@ bot.api.config.use(async (prev, method, payload, signal) => {
|
|||
if (attempt >= maxAttempts) return result;
|
||||
const retryAfter = result.parameters?.retry_after ?? (attempt * 5);
|
||||
if (retryAfter > maxWait) return result;
|
||||
logger().warning(
|
||||
warning(
|
||||
`${method} (attempt ${attempt}) failed: ${result.error_code} ${result.description}`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
|
||||
|
@ -80,7 +83,7 @@ bot.api.config.use(async (prev, method, payload, signal) => {
|
|||
});
|
||||
|
||||
bot.catch((err) => {
|
||||
logger().error(
|
||||
error(
|
||||
`Handling update from ${formatUserChat(err.ctx)} failed: ${err.name} ${err.message}`,
|
||||
);
|
||||
});
|
||||
|
@ -91,9 +94,13 @@ bot.use(async (ctx, next) => {
|
|||
await next();
|
||||
} catch (err) {
|
||||
try {
|
||||
await ctx.reply(`Handling update failed: ${err}`, {
|
||||
await ctx.reply(
|
||||
`Handling update failed: ${err}`,
|
||||
omitUndef({
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
});
|
||||
allow_sending_without_reply: true,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
throw err;
|
||||
}
|
||||
|
@ -108,12 +115,44 @@ bot.api.setMyDescription(
|
|||
bot.api.setMyCommands([
|
||||
{ command: "txt2img", description: "Generate image from text" },
|
||||
{ command: "img2img", description: "Generate image from image" },
|
||||
{ command: "pnginfo", description: "Show generation parameters of an image" },
|
||||
{ command: "pnginfo", description: "Try to extract prompt from raw file" },
|
||||
{ command: "queue", description: "Show the current queue" },
|
||||
{ command: "cancel", description: "Cancel all your requests" },
|
||||
]);
|
||||
|
||||
bot.command("start", (ctx) => ctx.reply("Hello! Use the /txt2img command to generate an image"));
|
||||
bot.command("start", async (ctx) => {
|
||||
if (ctx.match) {
|
||||
const id = ctx.match.trim();
|
||||
const session = sessions.get(id);
|
||||
if (session == null) {
|
||||
await ctx.reply(
|
||||
"Login failed: Invalid session ID",
|
||||
omitUndef({
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
session.userId = ctx.from?.id;
|
||||
sessions.set(id, session);
|
||||
info(`User ${formatUserChat(ctx)} logged in`);
|
||||
// TODO: show link to web ui
|
||||
await ctx.reply(
|
||||
"Login successful! You can now return to the WebUI.",
|
||||
omitUndef({
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.reply(
|
||||
"Hello! Use the /txt2img command to generate an image",
|
||||
omitUndef({
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
bot.command("txt2img", txt2imgCommand);
|
||||
bot.use(txt2imgQuestion.middleware());
|
||||
|
@ -130,30 +169,11 @@ bot.command("cancel", cancelCommand);
|
|||
|
||||
bot.command("broadcast", broadcastCommand);
|
||||
|
||||
bot.command("pause", async (ctx) => {
|
||||
if (!ctx.from?.username) return;
|
||||
const config = await getConfig();
|
||||
if (!config.adminUsernames.includes(ctx.from.username)) return;
|
||||
if (config.pausedReason != null) {
|
||||
return ctx.reply(`Already paused: ${config.pausedReason}`);
|
||||
}
|
||||
config.pausedReason = ctx.match ?? "No reason given";
|
||||
await setConfig(config);
|
||||
logger().warning(`Bot paused by ${ctx.from.first_name} because ${config.pausedReason}`);
|
||||
return ctx.reply("Paused");
|
||||
});
|
||||
|
||||
bot.command("resume", async (ctx) => {
|
||||
if (!ctx.from?.username) return;
|
||||
const config = await getConfig();
|
||||
if (!config.adminUsernames.includes(ctx.from.username)) return;
|
||||
if (config.pausedReason == null) return ctx.reply("Already running");
|
||||
config.pausedReason = null;
|
||||
await setConfig(config);
|
||||
logger().info(`Bot resumed by ${ctx.from.first_name}`);
|
||||
return ctx.reply("Resumed");
|
||||
});
|
||||
|
||||
bot.command("crash", () => {
|
||||
throw new Error("Crash command used");
|
||||
});
|
||||
|
||||
export async function runBot() {
|
||||
const runner = run(bot, { runner: { silent: true } });
|
||||
await runner.task();
|
||||
}
|
||||
|
|
|
@ -1,12 +1,24 @@
|
|||
import { decode } from "png_chunk_text";
|
||||
import extractChunks from "png_chunks_extract";
|
||||
import * as ExifReader from "exifreader";
|
||||
|
||||
export function getPngInfo(pngData: Uint8Array): string | undefined {
|
||||
return extractChunks(pngData)
|
||||
.filter((chunk) => chunk.name === "tEXt")
|
||||
.map((chunk) => decode(chunk.data))
|
||||
.find((textChunk) => textChunk.keyword === "parameters")
|
||||
?.text;
|
||||
export function getPngInfo(pngData: ArrayBuffer): string | undefined {
|
||||
const info = ExifReader.load(pngData);
|
||||
|
||||
if (info.UserComment?.value && Array.isArray(info.UserComment.value)) {
|
||||
// JPEG image
|
||||
return String.fromCharCode(
|
||||
...info.UserComment.value
|
||||
.filter((char): char is number => typeof char == "number")
|
||||
.filter((char) => char !== 0),
|
||||
).replace("UNICODE", "");
|
||||
}
|
||||
|
||||
if (info.parameters?.description) {
|
||||
// PNG image
|
||||
return info.parameters.description;
|
||||
}
|
||||
|
||||
// Unknown image type
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface PngInfo {
|
||||
|
@ -25,7 +37,11 @@ interface PngInfoExtra extends PngInfo {
|
|||
upscale?: number;
|
||||
}
|
||||
|
||||
export function parsePngInfo(pngInfo: string, baseParams?: Partial<PngInfo>): Partial<PngInfo> {
|
||||
export function parsePngInfo(
|
||||
pngInfo: string,
|
||||
baseParams?: Partial<PngInfo>,
|
||||
shouldParseSeed?: boolean,
|
||||
): Partial<PngInfo> {
|
||||
const tags = pngInfo.split(/[,;]+|\.+\s|\n/u);
|
||||
let part: "prompt" | "negative_prompt" | "params" = "prompt";
|
||||
const params: Partial<PngInfoExtra> = {};
|
||||
|
@ -34,7 +50,7 @@ export function parsePngInfo(pngInfo: string, baseParams?: Partial<PngInfo>): Pa
|
|||
for (const tag of tags) {
|
||||
const paramValuePair = tag.trim().match(/^(\w+\s*\w*):\s+(.*)$/u);
|
||||
if (paramValuePair) {
|
||||
const [, param, value] = paramValuePair;
|
||||
const [_match, param = "", value = ""] = paramValuePair;
|
||||
switch (param.replace(/\s+/u, "").toLowerCase()) {
|
||||
case "positiveprompt":
|
||||
case "positive":
|
||||
|
@ -67,7 +83,7 @@ export function parsePngInfo(pngInfo: string, baseParams?: Partial<PngInfo>): Pa
|
|||
case "size":
|
||||
case "resolution": {
|
||||
part = "params";
|
||||
const [width, height] = value.trim()
|
||||
const [width = 0, height = 0] = value.trim()
|
||||
.split(/\s*[x,]\s*/u, 2)
|
||||
.map((v) => v.trim())
|
||||
.map(Number);
|
||||
|
@ -99,7 +115,16 @@ export function parsePngInfo(pngInfo: string, baseParams?: Partial<PngInfo>): Pa
|
|||
params.denoising_strength = denoisingStrength;
|
||||
break;
|
||||
}
|
||||
case "seed":
|
||||
case "seed": {
|
||||
part = "params";
|
||||
if (shouldParseSeed) {
|
||||
const seed = Number(value.trim());
|
||||
if (Number.isFinite(seed)) {
|
||||
params.seed = seed;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "model":
|
||||
case "modelhash":
|
||||
case "modelname":
|
|
@ -1,8 +1,9 @@
|
|||
import { CommandContext } from "grammy";
|
||||
import { bold, fmt } from "grammy_parse_mode";
|
||||
import { StatelessQuestion } from "grammy_stateless_question";
|
||||
import { getPngInfo, parsePngInfo } from "../sd/parsePngInfo.ts";
|
||||
import { omitUndef } from "../utils/omitUndef.ts";
|
||||
import { ErisContext } from "./mod.ts";
|
||||
import { getPngInfo, parsePngInfo } from "./parsePngInfo.ts";
|
||||
|
||||
export const pnginfoQuestion = new StatelessQuestion<ErisContext>(
|
||||
"pnginfo",
|
||||
|
@ -19,22 +20,31 @@ async function pnginfo(ctx: ErisContext, includeRepliedTo: boolean): Promise<voi
|
|||
const document = ctx.message?.document ||
|
||||
(includeRepliedTo ? ctx.message?.reply_to_message?.document : undefined);
|
||||
|
||||
if (document?.mime_type !== "image/png") {
|
||||
if (document?.mime_type !== "image/png" && document?.mime_type !== "image/jpeg") {
|
||||
await ctx.reply(
|
||||
"Please send me a PNG file." +
|
||||
"Please send me a PNG or JPEG file." +
|
||||
pnginfoQuestion.messageSuffixMarkdown(),
|
||||
omitUndef(
|
||||
{
|
||||
reply_markup: { force_reply: true, selective: true },
|
||||
parse_mode: "Markdown",
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
},
|
||||
} as const,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const file = await ctx.api.getFile(document.file_id);
|
||||
const buffer = await fetch(file.getUrl()).then((resp) => resp.arrayBuffer());
|
||||
const params = parsePngInfo(getPngInfo(new Uint8Array(buffer)) ?? "");
|
||||
const info = getPngInfo(buffer);
|
||||
if (!info) {
|
||||
return void await ctx.reply(
|
||||
"No info found in file.",
|
||||
omitUndef({ reply_to_message_id: ctx.message?.message_id }),
|
||||
);
|
||||
}
|
||||
const params = parsePngInfo(info, undefined, true);
|
||||
|
||||
const paramsText = fmt([
|
||||
`${params.prompt}\n`,
|
||||
|
@ -46,8 +56,11 @@ async function pnginfo(ctx: ErisContext, includeRepliedTo: boolean): Promise<voi
|
|||
params.width && params.height ? fmt`${bold("Size")}: ${params.width}x${params.height}` : "",
|
||||
]);
|
||||
|
||||
await ctx.reply(paramsText.text, {
|
||||
await ctx.reply(
|
||||
paramsText.text,
|
||||
omitUndef({
|
||||
entities: paramsText.entities,
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,21 +1,25 @@
|
|||
import { CommandContext } from "grammy";
|
||||
import { bold, fmt } from "grammy_parse_mode";
|
||||
import { getConfig } from "../app/config.ts";
|
||||
import { activeGenerationWorkers, generationQueue } from "../app/generationQueue.ts";
|
||||
import { workerInstanceStore } from "../app/workerInstanceStore.ts";
|
||||
import { getFlagEmoji } from "../utils/getFlagEmoji.ts";
|
||||
import { omitUndef } from "../utils/omitUndef.ts";
|
||||
import { ErisContext } from "./mod.ts";
|
||||
|
||||
export async function queueCommand(ctx: CommandContext<ErisContext>) {
|
||||
let formattedMessage = await getMessageText();
|
||||
const queueMessage = await ctx.replyFmt(formattedMessage, {
|
||||
const queueMessage = await ctx.replyFmt(
|
||||
formattedMessage,
|
||||
omitUndef({
|
||||
disable_notification: true,
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
});
|
||||
}),
|
||||
);
|
||||
handleFutureUpdates().catch(() => undefined);
|
||||
|
||||
async function getMessageText() {
|
||||
const config = await getConfig();
|
||||
const allJobs = await generationQueue.getAllJobs();
|
||||
const workerInstances = await workerInstanceStore.getAll();
|
||||
const processingJobs = allJobs
|
||||
.filter((job) => job.lockUntil > new Date()).map((job) => ({ ...job, index: 0 }));
|
||||
const waitingJobs = allJobs
|
||||
|
@ -30,24 +34,17 @@ export async function queueCommand(ctx: CommandContext<ErisContext>) {
|
|||
`${job.index}. `,
|
||||
fmt`${bold(job.state.from.first_name)} `,
|
||||
job.state.from.last_name ? fmt`${bold(job.state.from.last_name)} ` : "",
|
||||
job.state.from.username ? `(@${job.state.from.username}) ` : "",
|
||||
getFlagEmoji(job.state.from.language_code) ?? "",
|
||||
job.state.chat.type === "private" ? " in private chat " : ` in ${job.state.chat.title} `,
|
||||
job.state.chat.type !== "private" && job.state.chat.type !== "group" &&
|
||||
job.state.chat.username
|
||||
? `(@${job.state.chat.username}) `
|
||||
: "",
|
||||
job.index === 0 && job.state.progress && job.state.sdInstanceId
|
||||
? `(${(job.state.progress * 100).toFixed(0)}% using ${job.state.sdInstanceId}) `
|
||||
job.index === 0 && job.state.progress && job.state.workerInstanceKey
|
||||
? `(${(job.state.progress * 100).toFixed(0)}% using ${job.state.workerInstanceKey}) `
|
||||
: "",
|
||||
"\n",
|
||||
])
|
||||
: ["Queue is empty.\n"],
|
||||
"\nActive workers:\n",
|
||||
...config.sdInstances.flatMap((sdInstance) => [
|
||||
activeGenerationWorkers.get(sdInstance.id)?.isProcessing ? "✅ " : "☠️ ",
|
||||
fmt`${bold(sdInstance.name || sdInstance.id)} `,
|
||||
`(max ${(sdInstance.maxResolution / 1000000).toFixed(1)} Mpx) `,
|
||||
...workerInstances.flatMap((workerInstace) => [
|
||||
activeGenerationWorkers.get(workerInstace.id)?.isProcessing ? "✅ " : "☠️ ",
|
||||
fmt`${bold(workerInstace.value.name || workerInstace.value.key)} `,
|
||||
"\n",
|
||||
]),
|
||||
]);
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
import { CommandContext } from "grammy";
|
||||
import { StatelessQuestion } from "grammy_stateless_question";
|
||||
import { debug } from "std/log/mod.ts";
|
||||
import { getConfig } from "../app/config.ts";
|
||||
import { generationQueue } from "../app/generationQueue.ts";
|
||||
import { getPngInfo, parsePngInfo, PngInfo } from "../sd/parsePngInfo.ts";
|
||||
import { formatUserChat } from "../utils/formatUserChat.ts";
|
||||
import { ErisContext, logger } from "./mod.ts";
|
||||
import { ErisContext } from "./mod.ts";
|
||||
import { getPngInfo, parsePngInfo, PngInfo } from "./parsePngInfo.ts";
|
||||
import { adminStore } from "../app/adminStore.ts";
|
||||
|
||||
export const txt2imgQuestion = new StatelessQuestion<ErisContext>(
|
||||
"txt2img",
|
||||
|
@ -19,12 +21,16 @@ export async function txt2imgCommand(ctx: CommandContext<ErisContext>) {
|
|||
}
|
||||
|
||||
async function txt2img(ctx: ErisContext, match: string, includeRepliedTo: boolean): Promise<void> {
|
||||
if (!ctx.message?.from?.id) {
|
||||
await ctx.reply("I don't know who you are", { reply_to_message_id: ctx.message?.message_id });
|
||||
if (!ctx.from || !ctx.message) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.from.is_bot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
let priority = 0;
|
||||
|
||||
if (config.pausedReason != null) {
|
||||
await ctx.reply(`I'm paused: ${config.pausedReason || "No reason given"}`, {
|
||||
|
@ -33,6 +39,11 @@ async function txt2img(ctx: ErisContext, match: string, includeRepliedTo: boolea
|
|||
return;
|
||||
}
|
||||
|
||||
const adminEntry = await adminStore.get(["admins", ctx.from.id]);
|
||||
|
||||
if (adminEntry.versionstamp) {
|
||||
priority = 1;
|
||||
} else {
|
||||
const jobs = await generationQueue.getAllJobs();
|
||||
if (jobs.length >= config.maxJobs) {
|
||||
await ctx.reply(`The queue is full. Try again later. (Max queue size: ${config.maxJobs})`, {
|
||||
|
@ -43,11 +54,12 @@ async function txt2img(ctx: ErisContext, match: string, includeRepliedTo: boolea
|
|||
|
||||
const userJobs = jobs.filter((job) => job.state.from.id === ctx.message?.from?.id);
|
||||
if (userJobs.length >= config.maxUserJobs) {
|
||||
await ctx.reply(`You already have ${config.maxUserJobs} jobs in queue. Try again later.`, {
|
||||
await ctx.reply(`You already have ${userJobs.length} jobs in the queue. Try again later.`, {
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let params: Partial<PngInfo> = {};
|
||||
|
||||
|
@ -56,16 +68,25 @@ async function txt2img(ctx: ErisContext, match: string, includeRepliedTo: boolea
|
|||
if (includeRepliedTo && repliedToMsg?.document?.mime_type === "image/png") {
|
||||
const file = await ctx.api.getFile(repliedToMsg.document.file_id);
|
||||
const buffer = await fetch(file.getUrl()).then((resp) => resp.arrayBuffer());
|
||||
params = parsePngInfo(getPngInfo(new Uint8Array(buffer)) ?? "", params);
|
||||
params = parsePngInfo(getPngInfo(buffer) ?? "", params);
|
||||
}
|
||||
|
||||
const repliedToText = repliedToMsg?.text || repliedToMsg?.caption;
|
||||
if (includeRepliedTo && repliedToText) {
|
||||
const isReply = includeRepliedTo && repliedToText;
|
||||
|
||||
if (isReply) {
|
||||
// TODO: remove bot command from replied to text
|
||||
params = parsePngInfo(repliedToText, params);
|
||||
}
|
||||
|
||||
params = parsePngInfo(match, params);
|
||||
params = parsePngInfo(match, params, true);
|
||||
|
||||
if (isReply) {
|
||||
const parsedInfo = parsePngInfo(repliedToText, undefined, true);
|
||||
if (parsedInfo.prompt !== params.prompt) {
|
||||
params.seed = parsedInfo.seed ?? -1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!params.prompt) {
|
||||
await ctx.reply(
|
||||
|
@ -90,7 +111,7 @@ async function txt2img(ctx: ErisContext, match: string, includeRepliedTo: boolea
|
|||
chat: ctx.message.chat,
|
||||
requestMessage: ctx.message,
|
||||
replyMessage: replyMessage,
|
||||
}, { retryCount: 3, retryDelayMs: 10_000 });
|
||||
}, { retryCount: 3, retryDelayMs: 10_000, priority: priority });
|
||||
|
||||
logger().debug(`Generation (txt2img) enqueued for ${formatUserChat(ctx.message)}`);
|
||||
debug(`Generation (txt2img) enqueued for ${formatUserChat(ctx.message)}`);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"jsx": "react",
|
||||
"noUncheckedIndexedAccess": true
|
||||
},
|
||||
"fmt": {
|
||||
"lineWidth": 100
|
||||
},
|
||||
"imports": {
|
||||
"async": "https://deno.land/x/async@v2.0.2/mod.ts",
|
||||
"date-fns": "https://cdn.skypack.dev/date-fns@2.30.0?dts",
|
||||
"date-fns/utc": "https://cdn.skypack.dev/@date-fns/utc@1.1.0?dts",
|
||||
"elysia": "https://esm.sh/elysia@0.7.21?dev",
|
||||
"elysia/eden": "https://esm.sh/@elysiajs/eden@0.7.4?external=elysia&dev",
|
||||
"elysia/swagger": "https://esm.sh/@elysiajs/swagger@0.7.4?external=elysia&dev",
|
||||
"exifreader": "https://esm.sh/exifreader@4.14.1",
|
||||
"file_type": "https://esm.sh/file-type@18.5.0",
|
||||
"grammy": "https://lib.deno.dev/x/grammy@1/mod.ts",
|
||||
"grammy_autoquote": "https://lib.deno.dev/x/grammy_autoquote@1/mod.ts",
|
||||
"grammy_files": "https://lib.deno.dev/x/grammy_files@1/mod.ts",
|
||||
"grammy_parse_mode": "https://lib.deno.dev/x/grammy_parse_mode@1/mod.ts",
|
||||
"grammy_runner": "https://lib.deno.dev/x/grammy_runner@2/mod.ts",
|
||||
"grammy_stateless_question": "https://lib.deno.dev/x/grammy_stateless_question_alpha@3/mod.ts",
|
||||
"grammy_types": "https://lib.deno.dev/x/grammy_types@3/mod.ts",
|
||||
"indexed_kv": "https://deno.land/x/indexed_kv@v0.6.1/mod.ts",
|
||||
"kvfs": "https://deno.land/x/kvfs@v0.1.0/mod.ts",
|
||||
"kvmq": "https://deno.land/x/kvmq@v0.3.0/mod.ts",
|
||||
"openapi_fetch": "https://esm.sh/openapi-fetch@0.7.6",
|
||||
"react": "https://esm.sh/react@18.2.0?dev",
|
||||
"react-dom/client": "https://esm.sh/react-dom@18.2.0/client?external=react&dev",
|
||||
"react-flip-move": "https://esm.sh/react-flip-move@3.0.5?external=react&dev",
|
||||
"react-intl": "https://esm.sh/react-intl@6.4.7?external=react&dev",
|
||||
"react-router-dom": "https://esm.sh/react-router-dom@6.16.0?external=react&dev",
|
||||
"reroute": "https://deno.land/x/reroute@v0.1.0/mod.ts",
|
||||
"serve_spa": "https://deno.land/x/serve_spa@v0.2.0/mod.ts",
|
||||
"std/async/": "https://deno.land/std@0.201.0/async/",
|
||||
"std/collections/": "https://deno.land/std@0.202.0/collections/",
|
||||
"std/dotenv/": "https://deno.land/std@0.201.0/dotenv/",
|
||||
"std/encoding/": "https://deno.land/std@0.202.0/encoding/",
|
||||
"std/fmt/": "https://deno.land/std@0.202.0/fmt/",
|
||||
"std/log/": "https://deno.land/std@0.201.0/log/",
|
||||
"std/path/": "https://deno.land/std@0.204.0/path/",
|
||||
"swr": "https://esm.sh/swr@2.2.4?external=react&dev",
|
||||
"swr/mutation": "https://esm.sh/swr@2.2.4/mutation?external=react&dev",
|
||||
"twind/core": "https://esm.sh/@twind/core@1.1.3",
|
||||
"twind/preset-tailwind": "https://esm.sh/@twind/preset-tailwind@1.1.4",
|
||||
"ulid": "https://deno.land/x/ulid@v0.3.0/mod.ts"
|
||||
},
|
||||
"lint": {
|
||||
"rules": {
|
||||
"exclude": [
|
||||
"require-await"
|
||||
]
|
||||
}
|
||||
},
|
||||
"tasks": {
|
||||
"check": "deno check --unstable-kv main.ts && deno check --unstable-kv ui/main.tsx",
|
||||
"generate": "deno run npm:openapi-typescript http://localhost:7861/openapi.json -o app/sdApi.ts",
|
||||
"start": "deno run --unstable-kv --allow-env --allow-read=. --allow-write=db --allow-net main.ts",
|
||||
"test": "deno test"
|
||||
}
|
||||
}
|
30
deno.jsonc
30
deno.jsonc
|
@ -1,30 +0,0 @@
|
|||
{
|
||||
"tasks": {
|
||||
"start": "deno run --unstable --allow-env --allow-read --allow-write --allow-net main.ts"
|
||||
},
|
||||
"fmt": {
|
||||
"lineWidth": 100
|
||||
},
|
||||
"imports": {
|
||||
"std/log": "https://deno.land/std@0.201.0/log/mod.ts",
|
||||
"std/async": "https://deno.land/std@0.201.0/async/mod.ts",
|
||||
"std/fmt/duration": "https://deno.land/std@0.202.0/fmt/duration.ts",
|
||||
"std/collections": "https://deno.land/std@0.202.0/collections/mod.ts",
|
||||
"std/encoding/base64": "https://deno.land/std@0.202.0/encoding/base64.ts",
|
||||
"async": "https://deno.land/x/async@v2.0.2/mod.ts",
|
||||
"ulid": "https://deno.land/x/ulid@v0.3.0/mod.ts",
|
||||
"indexed_kv": "https://deno.land/x/indexed_kv@v0.4.0/mod.ts",
|
||||
"kvmq": "https://deno.land/x/kvmq@v0.2.0/mod.ts",
|
||||
"kvfs": "https://deno.land/x/kvfs@v0.1.0/mod.ts",
|
||||
"grammy": "https://lib.deno.dev/x/grammy@1/mod.ts",
|
||||
"grammy_types": "https://lib.deno.dev/x/grammy_types@3/mod.ts",
|
||||
"grammy_autoquote": "https://lib.deno.dev/x/grammy_autoquote@1/mod.ts",
|
||||
"grammy_parse_mode": "https://lib.deno.dev/x/grammy_parse_mode@1/mod.ts",
|
||||
"grammy_stateless_question": "https://lib.deno.dev/x/grammy_stateless_question_alpha@3/mod.ts",
|
||||
"grammy_files": "https://lib.deno.dev/x/grammy_files@1/mod.ts",
|
||||
"file_type": "https://esm.sh/file-type@18.5.0",
|
||||
"png_chunks_extract": "https://esm.sh/png-chunks-extract@1.0.0",
|
||||
"png_chunk_text": "https://esm.sh/png-chunk-text@1.0.0",
|
||||
"openapi_fetch": "https://esm.sh/openapi-fetch@0.7.6"
|
||||
}
|
||||
}
|
20
main.ts
20
main.ts
|
@ -1,18 +1,26 @@
|
|||
import "https://deno.land/std@0.201.0/dotenv/load.ts";
|
||||
import { handlers, setup } from "std/log";
|
||||
/// <reference lib="deno.unstable" />
|
||||
import "std/dotenv/load.ts";
|
||||
import { ConsoleHandler } from "std/log/handlers.ts";
|
||||
import { LevelName, setup } from "std/log/mod.ts";
|
||||
import { serveUi } from "./api/mod.ts";
|
||||
import { runAllTasks } from "./app/mod.ts";
|
||||
import { bot } from "./bot/mod.ts";
|
||||
import { runBot } from "./bot/mod.ts";
|
||||
|
||||
const logLevel = Deno.env.get("LOG_LEVEL")?.toUpperCase() as LevelName ?? "INFO";
|
||||
|
||||
// setup logging
|
||||
setup({
|
||||
handlers: {
|
||||
console: new handlers.ConsoleHandler("DEBUG"),
|
||||
console: new ConsoleHandler(logLevel),
|
||||
},
|
||||
loggers: {
|
||||
default: { level: "DEBUG", handlers: ["console"] },
|
||||
default: { level: logLevel, handlers: ["console"] },
|
||||
},
|
||||
});
|
||||
|
||||
// run parts of the app
|
||||
await Promise.all([
|
||||
bot.start(),
|
||||
runBot(),
|
||||
runAllTasks(),
|
||||
serveUi(),
|
||||
]);
|
||||
|
|
|
@ -0,0 +1,242 @@
|
|||
import React, { useRef } from "react";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
import { AdminData } from "../api/adminsRoute.ts";
|
||||
import { fetchApi, handleResponse } from "./apiClient.ts";
|
||||
|
||||
export function AdminsPage(props: { sessionId: string | null }) {
|
||||
const { sessionId } = props;
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
const addDialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
const getSession = useSWR(
|
||||
sessionId ? ["/sessions/:sessionId", { params: { sessionId } }] as const : null,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
const getUser = useSWR(
|
||||
getSession.data?.userId
|
||||
? ["/users/:userId", { params: { userId: String(getSession.data.userId) } }] as const
|
||||
: null,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
|
||||
const getAdmins = useSWR(
|
||||
["/admins", { method: "GET" }] as const,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{getUser.data && getAdmins.data && getAdmins.data.length === 0 && (
|
||||
<button
|
||||
className="button-filled"
|
||||
onClick={() => {
|
||||
fetchApi("/admins/promote_self", {
|
||||
method: "POST",
|
||||
query: { sessionId: sessionId ?? "" },
|
||||
}).then(handleResponse).then(() => mutate(() => true));
|
||||
}}
|
||||
>
|
||||
Promote me to admin
|
||||
</button>
|
||||
)}
|
||||
|
||||
{getAdmins.data?.length
|
||||
? (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{getAdmins.data.map((admin) => (
|
||||
<AdminListItem key={admin.tgUserId} admin={admin} sessionId={sessionId} />
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
: getAdmins.data?.length === 0
|
||||
? (
|
||||
<ul className="flex flex-col gap-2">
|
||||
<li className="flex flex-col gap-2 rounded-md bg-zinc-100 dark:bg-zinc-800 p-2">
|
||||
<p key="no-admins" className="text-center text-gray-500">No admins.</p>
|
||||
</li>
|
||||
</ul>
|
||||
)
|
||||
: getAdmins.error
|
||||
? <p className="alert">Loading admins failed</p>
|
||||
: <div className="spinner self-center" />}
|
||||
|
||||
{getUser.data?.admin && (
|
||||
<button
|
||||
className="button-filled"
|
||||
onClick={() => addDialogRef.current?.showModal()}
|
||||
>
|
||||
Add admin
|
||||
</button>
|
||||
)}
|
||||
|
||||
<AddAdminDialog
|
||||
dialogRef={addDialogRef}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AddAdminDialog(props: {
|
||||
dialogRef: React.RefObject<HTMLDialogElement>;
|
||||
sessionId: string | null;
|
||||
}) {
|
||||
const { dialogRef, sessionId } = props;
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
return (
|
||||
<dialog ref={dialogRef} className="dialog animate-pop-in backdrop-animate-fade-in">
|
||||
<form
|
||||
method="dialog"
|
||||
className="flex flex-col gap-4 p-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.target as HTMLFormElement);
|
||||
fetchApi("/admins", {
|
||||
method: "POST",
|
||||
query: { sessionId: sessionId! },
|
||||
body: {
|
||||
tgUserId: Number(data.get("tgUserId") as string),
|
||||
},
|
||||
}).then(handleResponse).then(() => mutate(() => true));
|
||||
dialogRef.current?.close();
|
||||
}}
|
||||
>
|
||||
<label className="flex flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
Telegram user ID
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
type="text"
|
||||
name="tgUserId"
|
||||
required
|
||||
pattern="-?\d+"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex gap-2 items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="button-outlined"
|
||||
onClick={() => dialogRef.current?.close()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button type="submit" className="button-filled">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminListItem(props: { admin: AdminData; sessionId: string | null }) {
|
||||
const { admin, sessionId } = props;
|
||||
|
||||
const deleteDialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
const getAdminUser = useSWR(
|
||||
["/users/:userId", { params: { userId: String(admin.tgUserId) } }] as const,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
|
||||
const getSession = useSWR(
|
||||
sessionId ? ["/sessions/:sessionId", { params: { sessionId } }] as const : null,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
const getUser = useSWR(
|
||||
getSession.data?.userId
|
||||
? ["/users/:userId", { params: { userId: String(getSession.data.userId) } }] as const
|
||||
: null,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
|
||||
return (
|
||||
<li className="flex flex-col gap-2 rounded-md bg-zinc-100 dark:bg-zinc-800 p-2">
|
||||
<p className="font-bold">
|
||||
{getAdminUser.data?.first_name ?? admin.tgUserId} {getAdminUser.data?.last_name}{" "}
|
||||
{getAdminUser.data?.username
|
||||
? (
|
||||
<a href={`https://t.me/${getAdminUser.data.username}`} className="link">
|
||||
@{getAdminUser.data.username}
|
||||
</a>
|
||||
)
|
||||
: null}
|
||||
</p>
|
||||
{getAdminUser.data?.bio
|
||||
? (
|
||||
<p>
|
||||
{getAdminUser.data?.bio}
|
||||
</p>
|
||||
)
|
||||
: null}
|
||||
{getUser.data?.admin && (
|
||||
<p className="flex gap-2">
|
||||
<button
|
||||
className="button-outlined"
|
||||
onClick={() => deleteDialogRef.current?.showModal()}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
<DeleteAdminDialog
|
||||
dialogRef={deleteDialogRef}
|
||||
adminId={admin.tgUserId}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAdminDialog(props: {
|
||||
dialogRef: React.RefObject<HTMLDialogElement>;
|
||||
adminId: number;
|
||||
sessionId: string | null;
|
||||
}) {
|
||||
const { dialogRef, adminId, sessionId } = props;
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className="dialog animate-pop-in backdrop-animate-fade-in"
|
||||
>
|
||||
<form
|
||||
method="dialog"
|
||||
className="flex flex-col gap-4 p-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
fetchApi("/admins/:adminId", {
|
||||
method: "DELETE",
|
||||
query: { sessionId: sessionId! },
|
||||
params: { adminId: String(adminId) },
|
||||
}).then(handleResponse).then(() => mutate(() => true));
|
||||
dialogRef.current?.close();
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
Are you sure you want to delete this admin?
|
||||
</p>
|
||||
<div className="flex gap-2 items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="button-outlined"
|
||||
onClick={() => dialogRef.current?.close()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button type="submit" className="button-filled">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
import React, { useEffect } from "react";
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import { AdminsPage } from "./AdminsPage.tsx";
|
||||
import { AppHeader } from "./AppHeader.tsx";
|
||||
import { QueuePage } from "./QueuePage.tsx";
|
||||
import { SettingsPage } from "./SettingsPage.tsx";
|
||||
import { StatsPage } from "./StatsPage.tsx";
|
||||
import { WorkersPage } from "./WorkersPage.tsx";
|
||||
import { fetchApi, handleResponse } from "./apiClient.ts";
|
||||
import { useLocalStorage } from "./useLocalStorage.ts";
|
||||
|
||||
export function App() {
|
||||
// store session ID in the local storage
|
||||
const [sessionId, setSessionId] = useLocalStorage("sessionId");
|
||||
|
||||
// initialize a new session when there is no session ID
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
fetchApi("/sessions", { method: "POST" }).then((resp) => resp).then(handleResponse).then(
|
||||
(session) => {
|
||||
console.log("Initialized session", session.id);
|
||||
setSessionId(session.id);
|
||||
},
|
||||
);
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppHeader
|
||||
className="self-stretch"
|
||||
sessionId={sessionId}
|
||||
onLogOut={() => setSessionId(null)}
|
||||
/>
|
||||
<div className="self-center w-full max-w-screen-md flex flex-col items-stretch gap-4 p-4">
|
||||
<Routes>
|
||||
<Route path="/" element={<StatsPage />} />
|
||||
<Route path="/admins" element={<AdminsPage sessionId={sessionId} />} />
|
||||
<Route path="/workers" element={<WorkersPage sessionId={sessionId} />} />
|
||||
<Route path="/queue" element={<QueuePage />} />
|
||||
<Route path="/settings" element={<SettingsPage sessionId={sessionId} />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,135 @@
|
|||
import React, { ReactNode } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import useSWR from "swr";
|
||||
import { cx } from "twind/core";
|
||||
import { API_URL, fetchApi, handleResponse } from "./apiClient.ts";
|
||||
|
||||
function NavTab(props: { to: string; children: ReactNode }) {
|
||||
return (
|
||||
<NavLink
|
||||
className={({ isActive }) => cx("tab", isActive && "tab-active")}
|
||||
to={props.to}
|
||||
>
|
||||
{props.children}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppHeader(props: {
|
||||
className?: string;
|
||||
sessionId: string | null;
|
||||
onLogOut: () => void;
|
||||
}) {
|
||||
const { className, sessionId, onLogOut } = props;
|
||||
|
||||
const getSession = useSWR(
|
||||
sessionId ? ["/sessions/:sessionId", { method: "GET", params: { sessionId } }] as const : null,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
{ onError: () => onLogOut() },
|
||||
);
|
||||
|
||||
const getUser = useSWR(
|
||||
getSession.data?.userId
|
||||
? ["/users/:userId", {
|
||||
method: "GET",
|
||||
params: { userId: String(getSession.data.userId) },
|
||||
}] as const
|
||||
: null,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
|
||||
const getBot = useSWR(
|
||||
["/bot", { method: "GET" }] as const,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
|
||||
const getUserPhoto = useSWR(
|
||||
getSession.data?.userId
|
||||
? ["/users/:userId/photo", {
|
||||
method: "GET",
|
||||
params: { userId: String(getSession.data.userId) },
|
||||
}] as const
|
||||
: null,
|
||||
() =>
|
||||
// elysia fetch can't download file
|
||||
fetch(`${API_URL}/users/${getSession.data?.userId}/photo`)
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error(response.statusText);
|
||||
return response;
|
||||
})
|
||||
.then((response) => response.blob())
|
||||
.then((blob) => blob ? URL.createObjectURL(blob) : null),
|
||||
);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cx(
|
||||
"bg-zinc-50 dark:bg-zinc-800 shadow-md flex items-center gap-2 px-4 py-1",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* logo */}
|
||||
<img className="w-12 h-12" src="/favicon.png" alt="logo" />
|
||||
|
||||
{/* tabs */}
|
||||
<nav className="flex-grow self-stretch flex items-stretch justify-center gap-2">
|
||||
<NavTab to="/">
|
||||
Stats
|
||||
</NavTab>
|
||||
<NavTab to="/admins">
|
||||
Admins
|
||||
</NavTab>
|
||||
<NavTab to="/workers">
|
||||
Workers
|
||||
</NavTab>
|
||||
<NavTab to="/queue">
|
||||
Queue
|
||||
</NavTab>
|
||||
<NavTab to="/settings">
|
||||
Settings
|
||||
</NavTab>
|
||||
</nav>
|
||||
|
||||
{/* loading indicator */}
|
||||
{getSession.isLoading || getUser.isLoading ? <div className="spinner" /> : null}
|
||||
|
||||
{/* user avatar */}
|
||||
{getUser.data
|
||||
? getUserPhoto.data
|
||||
? (
|
||||
<img
|
||||
src={getUserPhoto.data}
|
||||
alt="avatar"
|
||||
className="w-9 h-9 rounded-full"
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className="w-9 h-9 rounded-full bg-zinc-400 dark:bg-zinc-500 flex items-center justify-center text-white text-2xl font-bold select-none">
|
||||
{getUser.data.first_name.at(0)?.toUpperCase()}
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
|
||||
{/* login/logout button */}
|
||||
{!getSession.isLoading && !getUser.isLoading && getBot.data && sessionId
|
||||
? (
|
||||
getUser.data
|
||||
? (
|
||||
<button className="button-outlined" onClick={() => onLogOut()}>
|
||||
Logout
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<a
|
||||
className="button-filled"
|
||||
href={`https://t.me/${getBot.data.username}?start=${sessionId}`}
|
||||
target="_blank"
|
||||
>
|
||||
Login
|
||||
</a>
|
||||
)
|
||||
)
|
||||
: null}
|
||||
</header>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
import React from "react";
|
||||
import { cx } from "twind/core";
|
||||
|
||||
function CounterDigit(props: { value: number; transitionDurationMs?: number | undefined }) {
|
||||
const { value, transitionDurationMs = 1500 } = props;
|
||||
const rads = -(Math.floor(value) % 1_000_000) * 2 * Math.PI * 0.1;
|
||||
|
||||
return (
|
||||
<span className="w-[1em] relative">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="absolute inset-0 grid place-items-center transition-transform duration-1000 ease-in-out [backface-visibility:hidden]"
|
||||
style={{
|
||||
transform: `rotateX(${rads + i * 2 * Math.PI * 0.1}rad)`,
|
||||
transformOrigin: `center center 2em`,
|
||||
transitionDuration: `${transitionDurationMs}ms`,
|
||||
}}
|
||||
>
|
||||
{i}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const Spacer = () =>
|
||||
<span className="border-l-1 mx-[0.1em] border-zinc-200 dark:border-zinc-700" />;
|
||||
|
||||
const CounterText = (props: { children: React.ReactNode }) => (
|
||||
<span className="self-center px-[0.1em]">
|
||||
{props.children}
|
||||
</span>
|
||||
);
|
||||
|
||||
export function Counter(props: {
|
||||
value: number;
|
||||
digits: number;
|
||||
fractionDigits?: number | undefined;
|
||||
transitionDurationMs?: number | undefined;
|
||||
className?: string | undefined;
|
||||
postfix?: string | undefined;
|
||||
}) {
|
||||
const { value, digits, fractionDigits = 0, transitionDurationMs, className, postfix } = props;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cx(
|
||||
"inline-flex h-[1.5em] items-stretch border-1 border-zinc-300 dark:border-zinc-700 shadow-inner rounded-md overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{Array.from({ length: digits })
|
||||
.flatMap((_, i) => [
|
||||
i > 0 && i % 3 === 0 ? <Spacer key={`spacer${i}`} /> : null,
|
||||
<CounterDigit
|
||||
key={`digit${i}`}
|
||||
value={value / 10 ** i}
|
||||
transitionDurationMs={transitionDurationMs}
|
||||
/>,
|
||||
])
|
||||
.reverse()}
|
||||
|
||||
{fractionDigits > 0 && (
|
||||
<>
|
||||
<Spacer />
|
||||
<CounterText>.</CounterText>
|
||||
<Spacer />
|
||||
{Array.from({ length: fractionDigits })
|
||||
.flatMap((_, i) => [
|
||||
i > 0 && i % 3 === 0 ? <Spacer key={`fractionSpacer${i}`} /> : null,
|
||||
<CounterDigit
|
||||
key={`fractionDigit${i}`}
|
||||
value={value * 10 ** (i + 1)}
|
||||
transitionDurationMs={transitionDurationMs}
|
||||
/>,
|
||||
])}
|
||||
</>
|
||||
)}
|
||||
|
||||
{postfix && (
|
||||
<>
|
||||
<Spacer />
|
||||
<CounterText>{postfix}</CounterText>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
import React from "react";
|
||||
import { cx } from "twind/core";
|
||||
|
||||
export function Progress(props: { value: number; className?: string }) {
|
||||
const { value, className } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"flex items-stretch overflow-hidden rounded-md bg-zinc-200 text-xs text-white dark:bg-zinc-800",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="bg-stripes flex items-center justify-center overflow-hidden rounded-md bg-sky-500 transition-[flex-grow] duration-1000"
|
||||
style={{ flexGrow: value }}
|
||||
>
|
||||
{(value * 100).toFixed(0)}%
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center justify-center overflow-hidden transition-[flex-grow] duration-500"
|
||||
style={{ flexGrow: 1 - value }}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
import React from "react";
|
||||
import FlipMove from "react-flip-move";
|
||||
import useSWR from "swr";
|
||||
import { getFlagEmoji } from "../utils/getFlagEmoji.ts";
|
||||
import { Progress } from "./Progress.tsx";
|
||||
import { fetchApi, handleResponse } from "./apiClient.ts";
|
||||
|
||||
export function QueuePage() {
|
||||
const getJobs = useSWR(
|
||||
["/jobs", {}] as const,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
{ refreshInterval: 2000 },
|
||||
);
|
||||
|
||||
return (
|
||||
<FlipMove
|
||||
typeName={"ul"}
|
||||
className="flex flex-col items-stretch gap-2 p-2 bg-zinc-200 dark:bg-zinc-800 rounded-md"
|
||||
enterAnimation="fade"
|
||||
leaveAnimation="fade"
|
||||
>
|
||||
{getJobs.data && getJobs.data.length === 0
|
||||
? <li key="no-jobs" className="text-center text-gray-500">Queue is empty.</li>
|
||||
: (
|
||||
getJobs.data?.map((job) => (
|
||||
<li
|
||||
className="flex items-baseline gap-2 bg-zinc-100 dark:bg-zinc-700 px-2 py-1 rounded-md"
|
||||
key={job.id}
|
||||
>
|
||||
<span className="">{job.place}.</span>
|
||||
<span>{getFlagEmoji(job.state.from.language_code ?? undefined)}</span>
|
||||
<strong>{job.state.from.first_name} {job.state.from.last_name}</strong>
|
||||
{job.state.from.username
|
||||
? (
|
||||
<a
|
||||
className="link"
|
||||
href={`https://t.me/${job.state.from.username}`}
|
||||
target="_blank"
|
||||
>
|
||||
@{job.state.from.username}
|
||||
</a>
|
||||
)
|
||||
: null}
|
||||
<span className="flex-grow self-center h-full">
|
||||
{job.state.progress != null && (
|
||||
<Progress className="w-full h-full" value={job.state.progress} />
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{job.state.workerInstanceKey}
|
||||
</span>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</FlipMove>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,277 @@
|
|||
import React, { useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import { cx } from "twind/core";
|
||||
import { fetchApi, handleResponse } from "./apiClient.ts";
|
||||
import { omitUndef } from "../utils/omitUndef.ts";
|
||||
|
||||
export function SettingsPage(props: { sessionId: string | null }) {
|
||||
const { sessionId } = props;
|
||||
|
||||
const getSession = useSWR(
|
||||
sessionId ? ["/sessions/:sessionId", { params: { sessionId } }] as const : null,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
const getUser = useSWR(
|
||||
getSession.data?.userId
|
||||
? ["/users/:userId", { params: { userId: String( getSession.data.userId) } }] as const
|
||||
: null,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
const getParams = useSWR(
|
||||
["/settings/params", {}] as const,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
const [newParams, setNewParams] = useState<Partial<typeof getParams.data>>({});
|
||||
const [patchParamsError, setPatchParamsError] = useState<string>();
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col items-stretch gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
getParams.mutate(() =>
|
||||
fetchApi("/settings/params", {
|
||||
method: "PATCH",
|
||||
query: { sessionId: sessionId ?? "" },
|
||||
body: omitUndef(newParams ?? {}),
|
||||
}).then(handleResponse)
|
||||
)
|
||||
.then(() => setNewParams({}))
|
||||
.catch((e) => setPatchParamsError(String(e)));
|
||||
}}
|
||||
>
|
||||
<label className="flex flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
Negative prompt {newParams?.negative_prompt != null ? "(Changed)" : ""}
|
||||
</span>
|
||||
<textarea
|
||||
className="input-text"
|
||||
disabled={getParams.isLoading || !getUser.data?.admin}
|
||||
value={newParams?.negative_prompt ??
|
||||
getParams.data?.negative_prompt ??
|
||||
""}
|
||||
onChange={(e) =>
|
||||
setNewParams((params) => ({
|
||||
...params,
|
||||
negative_prompt: e.target.value,
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
Sampler {newParams?.sampler_name != null ? "(Changed)" : ""}
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
disabled={getParams.isLoading || !getUser.data?.admin}
|
||||
value={newParams?.sampler_name ??
|
||||
getParams.data?.sampler_name ??
|
||||
""}
|
||||
onChange={(e) =>
|
||||
setNewParams((params) => ({
|
||||
...params,
|
||||
sampler_name: e.target.value,
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
Steps {newParams?.steps != null ? "(Changed)" : ""}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<input
|
||||
className="input-text w-20"
|
||||
type="number"
|
||||
min={5}
|
||||
max={50}
|
||||
step={5}
|
||||
disabled={getParams.isLoading || !getUser.data?.admin}
|
||||
value={newParams?.steps ??
|
||||
getParams.data?.steps ??
|
||||
0}
|
||||
onChange={(e) =>
|
||||
setNewParams((params) => ({
|
||||
...params,
|
||||
steps: Number(e.target.value),
|
||||
}))}
|
||||
/>
|
||||
<input
|
||||
className="input-range flex-grow"
|
||||
type="range"
|
||||
min={5}
|
||||
max={50}
|
||||
step={5}
|
||||
disabled={getParams.isLoading || !getUser.data?.admin}
|
||||
value={newParams?.steps ??
|
||||
getParams.data?.steps ??
|
||||
0}
|
||||
onChange={(e) =>
|
||||
setNewParams((params) => ({
|
||||
...params,
|
||||
steps: Number(e.target.value),
|
||||
}))}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
Detail {newParams?.cfg_scale != null ? "(Changed)" : ""}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<input
|
||||
className="input-text w-20"
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
step={1}
|
||||
disabled={getParams.isLoading || !getUser.data?.admin}
|
||||
value={newParams?.cfg_scale ??
|
||||
getParams.data?.cfg_scale ??
|
||||
0}
|
||||
onChange={(e) =>
|
||||
setNewParams((params) => ({
|
||||
...params,
|
||||
cfg_scale: Number(e.target.value),
|
||||
}))}
|
||||
/>
|
||||
<input
|
||||
className="input-range flex-grow"
|
||||
type="range"
|
||||
min={1}
|
||||
max={20}
|
||||
step={1}
|
||||
disabled={getParams.isLoading || !getUser.data?.admin}
|
||||
value={newParams?.cfg_scale ??
|
||||
getParams.data?.cfg_scale ??
|
||||
0}
|
||||
onChange={(e) =>
|
||||
setNewParams((params) => ({
|
||||
...params,
|
||||
cfg_scale: Number(e.target.value),
|
||||
}))}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<label className="flex flex-1 flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
Width {newParams?.width != null ? "(Changed)" : ""}
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
type="number"
|
||||
min={64}
|
||||
max={2048}
|
||||
step={64}
|
||||
disabled={getParams.isLoading || !getUser.data?.admin}
|
||||
value={newParams?.width ??
|
||||
getParams.data?.width ??
|
||||
0}
|
||||
onChange={(e) =>
|
||||
setNewParams((params) => ({
|
||||
...params,
|
||||
width: Number(e.target.value),
|
||||
}))}
|
||||
/>
|
||||
<input
|
||||
className="input-range"
|
||||
type="range"
|
||||
min={64}
|
||||
max={2048}
|
||||
step={64}
|
||||
disabled={getParams.isLoading || !getUser.data?.admin}
|
||||
value={newParams?.width ??
|
||||
getParams.data?.width ??
|
||||
0}
|
||||
onChange={(e) =>
|
||||
setNewParams((params) => ({
|
||||
...params,
|
||||
width: Number(e.target.value),
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-1 flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
Height {newParams?.height != null ? "(Changed)" : ""}
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
type="number"
|
||||
min={64}
|
||||
max={2048}
|
||||
step={64}
|
||||
disabled={getParams.isLoading || !getUser.data?.admin}
|
||||
value={newParams?.height ??
|
||||
getParams.data?.height ??
|
||||
0}
|
||||
onChange={(e) =>
|
||||
setNewParams((params) => ({
|
||||
...params,
|
||||
height: Number(e.target.value),
|
||||
}))}
|
||||
/>
|
||||
<input
|
||||
className="input-range"
|
||||
type="range"
|
||||
min={64}
|
||||
max={2048}
|
||||
step={64}
|
||||
disabled={getParams.isLoading || !getUser.data?.admin}
|
||||
value={newParams?.height ??
|
||||
getParams.data?.height ??
|
||||
0}
|
||||
onChange={(e) =>
|
||||
setNewParams((params) => ({
|
||||
...params,
|
||||
height: Number(e.target.value),
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{patchParamsError
|
||||
? (
|
||||
<p className="alert">
|
||||
<span className="flex-grow">Updating params failed: {patchParamsError}</span>
|
||||
<button className="button-ghost" onClick={() => setPatchParamsError(undefined)}>
|
||||
Close
|
||||
</button>
|
||||
</p>
|
||||
)
|
||||
: null}
|
||||
{getParams.error
|
||||
? (
|
||||
<p className="alert">
|
||||
<span className="flex-grow">
|
||||
Loading params failed: {String(getParams.error)}
|
||||
</span>
|
||||
</p>
|
||||
)
|
||||
: null}
|
||||
|
||||
<div className="flex gap-2 items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className={cx("button-outlined ripple", getParams.isLoading && "bg-stripes")}
|
||||
disabled={getParams.isLoading || !getUser.data?.admin ||
|
||||
Object.keys(newParams ?? {}).length === 0}
|
||||
onClick={() => setNewParams({})}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={cx("button-filled ripple", getParams.isLoading && "bg-stripes")}
|
||||
disabled={getParams.isLoading || !getUser.data?.admin ||
|
||||
Object.keys(newParams ?? {}).length === 0}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
import React from "react";
|
||||
import { fetchApi, handleResponse } from "./apiClient.ts";
|
||||
import useSWR from "swr";
|
||||
import { Counter } from "./Counter.tsx";
|
||||
|
||||
export function StatsPage() {
|
||||
const getGlobalStats = useSWR(
|
||||
["/stats", {}] as const,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
{ refreshInterval: 2_000 },
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="my-16 flex flex-col gap-16 text-zinc-600 dark:text-zinc-400">
|
||||
<p className="flex flex-col items-center gap-2 text-2xl sm:text-3xl">
|
||||
<span>Pixelsteps diffused</span>
|
||||
<Counter
|
||||
className="font-bold text-zinc-700 dark:text-zinc-300"
|
||||
value={getGlobalStats.data?.pixelStepCount ?? 0}
|
||||
digits={15}
|
||||
transitionDurationMs={3_000}
|
||||
/>
|
||||
<Counter
|
||||
className="text-base"
|
||||
value={(getGlobalStats.data?.pixelStepsPerMinute ?? 0) / 60}
|
||||
digits={9}
|
||||
transitionDurationMs={2_000}
|
||||
postfix="/s"
|
||||
/>
|
||||
</p>
|
||||
<p className="flex flex-col items-center gap-2 text-2xl sm:text-3xl">
|
||||
<span>Pixels painted</span>
|
||||
<Counter
|
||||
className="font-bold text-zinc-700 dark:text-zinc-300"
|
||||
value={getGlobalStats.data?.pixelCount ?? 0}
|
||||
digits={15}
|
||||
transitionDurationMs={3_000}
|
||||
/>
|
||||
<Counter
|
||||
className="text-base"
|
||||
value={(getGlobalStats.data?.pixelsPerMinute ?? 0) / 60}
|
||||
digits={9}
|
||||
transitionDurationMs={2_000}
|
||||
postfix="/s"
|
||||
/>
|
||||
</p>
|
||||
<div className="flex flex-col md:flex-row gap-16 md:gap-8">
|
||||
<p className="flex-grow flex flex-col items-center gap-2 text-2xl">
|
||||
<span>Steps processed</span>
|
||||
<Counter
|
||||
className="font-bold text-zinc-700 dark:text-zinc-300"
|
||||
value={getGlobalStats.data?.stepCount ?? 0}
|
||||
digits={9}
|
||||
transitionDurationMs={3_000}
|
||||
/>
|
||||
<Counter
|
||||
className="text-base"
|
||||
value={(getGlobalStats.data?.stepsPerMinute ?? 0) / 60}
|
||||
digits={3}
|
||||
fractionDigits={3}
|
||||
transitionDurationMs={2_000}
|
||||
postfix="/s"
|
||||
/>
|
||||
</p>
|
||||
<p className="flex-grow flex flex-col items-center gap-2 text-2xl">
|
||||
<span>Images generated</span>
|
||||
<Counter
|
||||
className="font-bold text-zinc-700 dark:text-zinc-300"
|
||||
value={getGlobalStats.data?.imageCount ?? 0}
|
||||
digits={9}
|
||||
transitionDurationMs={3_000}
|
||||
/>
|
||||
<Counter
|
||||
className="text-base"
|
||||
value={(getGlobalStats.data?.imagesPerMinute ?? 0) / 60}
|
||||
digits={3}
|
||||
fractionDigits={3}
|
||||
transitionDurationMs={2_000}
|
||||
postfix="/s"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<p className="flex-grow flex flex-col items-center gap-2 text-2xl">
|
||||
<span>Unique users</span>
|
||||
<Counter
|
||||
className="font-bold text-zinc-700 dark:text-zinc-300"
|
||||
value={getGlobalStats.data?.userCount ?? 0}
|
||||
digits={6}
|
||||
transitionDurationMs={1_500}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,386 @@
|
|||
import React, { RefObject, useRef } from "react";
|
||||
import { FormattedRelativeTime } from "react-intl";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
import { WorkerResponse } from "../api/workersRoute.ts";
|
||||
import { Counter } from "./Counter.tsx";
|
||||
import { fetchApi, handleResponse } from "./apiClient.ts";
|
||||
|
||||
export function WorkersPage(props: { sessionId: string | null }) {
|
||||
const { sessionId } = props;
|
||||
|
||||
const addDialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
const getSession = useSWR(
|
||||
sessionId ? ["/sessions/:sessionId", { params: { sessionId } }] as const : null,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
const getUser = useSWR(
|
||||
getSession.data?.userId
|
||||
? ["/users/:userId", { params: { userId: String(getSession.data.userId) } }] as const
|
||||
: null,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
|
||||
const getWorkers = useSWR(
|
||||
["/workers", { method: "GET" }] as const,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
{ refreshInterval: 5000 },
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{getWorkers.data?.length
|
||||
? (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{getWorkers.data?.map((worker) => (
|
||||
<WorkerListItem key={worker.id} worker={worker} sessionId={sessionId} />
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
: getWorkers.data?.length === 0
|
||||
? (
|
||||
<ul className="flex flex-col gap-2">
|
||||
<li className="flex flex-col gap-2 rounded-md bg-zinc-100 dark:bg-zinc-800 p-2">
|
||||
<p key="no-workers" className="text-center text-gray-500">No workers.</p>
|
||||
</li>
|
||||
</ul>
|
||||
)
|
||||
: getWorkers.error
|
||||
? <p className="alert">Loading workers failed</p>
|
||||
: <div className="spinner self-center" />}
|
||||
|
||||
{getUser.data?.admin && (
|
||||
<button
|
||||
className="button-filled"
|
||||
onClick={() => addDialogRef.current?.showModal()}
|
||||
>
|
||||
Add worker
|
||||
</button>
|
||||
)}
|
||||
|
||||
<AddWorkerDialog
|
||||
dialogRef={addDialogRef}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AddWorkerDialog(props: {
|
||||
dialogRef: RefObject<HTMLDialogElement>;
|
||||
sessionId: string | null;
|
||||
}) {
|
||||
const { dialogRef, sessionId } = props;
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
return (
|
||||
<dialog ref={dialogRef} className="dialog animate-pop-in backdrop-animate-fade-in">
|
||||
<form
|
||||
method="dialog"
|
||||
className="flex flex-col gap-4 p-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.target as HTMLFormElement);
|
||||
const key = data.get("key") as string;
|
||||
const name = data.get("name") as string;
|
||||
const sdUrl = data.get("url") as string;
|
||||
const user = data.get("user") as string;
|
||||
const password = data.get("password") as string;
|
||||
console.log(key, name, user, password);
|
||||
fetchApi("/workers", {
|
||||
method: "POST",
|
||||
query: { sessionId: sessionId! },
|
||||
body: {
|
||||
key,
|
||||
name: name || null,
|
||||
sdUrl,
|
||||
sdAuth: user && password ? { user, password } : null,
|
||||
},
|
||||
}).then(handleResponse).then(() => mutate(() => true));
|
||||
dialogRef.current?.close();
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex flex-1 flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
Key
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
type="text"
|
||||
name="key"
|
||||
required
|
||||
/>
|
||||
<span className="text-sm text-zinc-500">
|
||||
Used for counting statistics
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex flex-1 flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
Name
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
type="text"
|
||||
name="name"
|
||||
/>
|
||||
<span className="text-sm text-zinc-500">
|
||||
Used for display
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
URL
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
type="url"
|
||||
name="url"
|
||||
required
|
||||
pattern="https?://.*"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex flex-1 flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
User
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
type="text"
|
||||
name="user"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-1 flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
Password
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
type="password"
|
||||
name="password"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="button-outlined"
|
||||
onClick={() => dialogRef.current?.close()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button type="submit" disabled={!sessionId} className="button-filled">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkerListItem(props: {
|
||||
worker: WorkerResponse;
|
||||
sessionId: string | null;
|
||||
}) {
|
||||
const { worker, sessionId } = props;
|
||||
const editDialogRef = useRef<HTMLDialogElement>(null);
|
||||
const deleteDialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
const getSession = useSWR(
|
||||
sessionId ? ["/sessions/:sessionId", { params: { sessionId } }] as const : null,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
const getUser = useSWR(
|
||||
getSession.data?.userId
|
||||
? ["/users/:userId", { params: { userId: String(getSession.data.userId) } }] as const
|
||||
: null,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
|
||||
return (
|
||||
<li className="flex flex-col gap-2 rounded-md bg-zinc-100 dark:bg-zinc-800 p-2">
|
||||
<p className="font-bold">
|
||||
{worker.name ?? worker.key}
|
||||
</p>
|
||||
{worker.isActive ? <p>✅ Active</p> : (
|
||||
<>
|
||||
<p>
|
||||
Last seen {worker.lastOnlineTime
|
||||
? (
|
||||
<FormattedRelativeTime
|
||||
value={(worker.lastOnlineTime - Date.now()) / 1000}
|
||||
numeric="auto"
|
||||
updateIntervalInSeconds={1}
|
||||
/>
|
||||
)
|
||||
: "never"}
|
||||
</p>
|
||||
{worker.lastError && (
|
||||
<p className="text-red-500">
|
||||
{worker.lastError.message} (
|
||||
<FormattedRelativeTime
|
||||
value={(worker.lastError.time - Date.now()) / 1000}
|
||||
numeric="auto"
|
||||
updateIntervalInSeconds={1}
|
||||
/>)
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<p className="flex gap-1">
|
||||
<Counter value={worker.imagesPerMinute} digits={2} fractionDigits={1} /> images per minute,
|
||||
{" "}
|
||||
<Counter value={worker.stepsPerMinute} digits={3} /> steps per minute
|
||||
</p>
|
||||
{getUser.data?.admin && (
|
||||
<p className="flex gap-2">
|
||||
<button
|
||||
className="button-outlined"
|
||||
onClick={() => editDialogRef.current?.showModal()}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
className="button-outlined"
|
||||
onClick={() => deleteDialogRef.current?.showModal()}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
<EditWorkerDialog
|
||||
dialogRef={editDialogRef}
|
||||
workerId={worker.id}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
<DeleteWorkerDialog
|
||||
dialogRef={deleteDialogRef}
|
||||
workerId={worker.id}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function EditWorkerDialog(props: {
|
||||
dialogRef: RefObject<HTMLDialogElement>;
|
||||
workerId: string;
|
||||
sessionId: string | null;
|
||||
}) {
|
||||
const { dialogRef, workerId, sessionId } = props;
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
return (
|
||||
<dialog
|
||||
className="dialog animate-pop-in backdrop-animate-fade-in"
|
||||
ref={dialogRef}
|
||||
>
|
||||
<form
|
||||
method="dialog"
|
||||
className="flex flex-col gap-4 p-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.target as HTMLFormElement);
|
||||
const user = data.get("user") as string;
|
||||
const password = data.get("password") as string;
|
||||
console.log(user, password);
|
||||
fetchApi("/workers/:workerId", {
|
||||
method: "PATCH",
|
||||
params: { workerId: workerId },
|
||||
query: { sessionId: sessionId! },
|
||||
body: {
|
||||
sdAuth: user && password ? { user, password } : null,
|
||||
},
|
||||
}).then(handleResponse).then(() => mutate(() => true));
|
||||
dialogRef.current?.close();
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex flex-1 flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
User
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
type="text"
|
||||
name="user"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-1 flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
Password
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
type="password"
|
||||
name="password"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="button-outlined"
|
||||
onClick={() => dialogRef.current?.close()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button type="submit" disabled={!sessionId} className="button-filled">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteWorkerDialog(props: {
|
||||
dialogRef: RefObject<HTMLDialogElement>;
|
||||
workerId: string;
|
||||
sessionId: string | null;
|
||||
}) {
|
||||
const { dialogRef, workerId, sessionId } = props;
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
return (
|
||||
<dialog
|
||||
className="dialog animate-pop-in backdrop-animate-fade-in"
|
||||
ref={dialogRef}
|
||||
>
|
||||
<form
|
||||
method="dialog"
|
||||
className="flex flex-col gap-4 p-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
fetchApi("/workers/:workerId", {
|
||||
method: "DELETE",
|
||||
params: { workerId: workerId },
|
||||
query: { sessionId: sessionId! },
|
||||
}).then(handleResponse).then(() => mutate(() => true));
|
||||
dialogRef.current?.close();
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
Are you sure you want to delete this worker?
|
||||
</p>
|
||||
<div className="flex gap-2 items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="button-outlined"
|
||||
onClick={() => dialogRef.current?.close()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button type="submit" disabled={!sessionId} className="button-filled">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import { edenFetch } from "elysia/eden";
|
||||
import { Api } from "../api/serveApi.ts";
|
||||
|
||||
export const API_URL = "/api";
|
||||
|
||||
export const fetchApi = edenFetch<Api>(API_URL);
|
||||
|
||||
export function handleResponse<
|
||||
T extends
|
||||
| { data: unknown; error: null }
|
||||
| { data: null; error: { status: number; value: unknown } },
|
||||
>(
|
||||
response: T,
|
||||
): (T & { error: null })["data"] {
|
||||
if (response.error) {
|
||||
throw new Error(`${response.error?.status}: ${response.error?.value}`);
|
||||
}
|
||||
return response.data;
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 37 KiB |
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" href="/favicon.png" type="image/png">
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
<title>Eris</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,16 @@
|
|||
/// <reference lib="deno.unstable" />
|
||||
/// <reference lib="dom" />
|
||||
import { createRoot } from "react-dom/client";
|
||||
import React from "react";
|
||||
import { App } from "./App.tsx";
|
||||
import "./twind.ts";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { IntlProvider } from "react-intl";
|
||||
|
||||
createRoot(document.body).render(
|
||||
<BrowserRouter>
|
||||
<IntlProvider locale="en">
|
||||
<App />
|
||||
</IntlProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
|
@ -0,0 +1,2 @@
|
|||
User-agent: *
|
||||
Disallow: /
|
|
@ -0,0 +1,143 @@
|
|||
import { defineConfig, injectGlobal, install } from "twind/core";
|
||||
import presetTailwind from "twind/preset-tailwind";
|
||||
|
||||
const twConfig = defineConfig({
|
||||
presets: [presetTailwind()],
|
||||
hash: false,
|
||||
});
|
||||
|
||||
install(twConfig, false);
|
||||
|
||||
injectGlobal`
|
||||
@layer base {
|
||||
html {
|
||||
@apply h-full bg-white text-zinc-900 dark:bg-zinc-900 dark:text-zinc-100;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply min-h-full flex flex-col;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.bg-stripes {
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 14px,
|
||||
rgba(0, 0, 0, 0.1) 14px,
|
||||
rgba(0, 0, 0, 0.1) 28px
|
||||
);
|
||||
animation: bg-scroll 0.5s linear infinite;
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
@keyframes bg-scroll {
|
||||
to {
|
||||
background-position: 40px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ripple {
|
||||
position: relative;
|
||||
}
|
||||
.ripple:not([disabled])::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
background-image: radial-gradient(circle, rgba(255, 255, 255, 0.2) 10%, transparent 10%);
|
||||
background-size: 1500%;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
transition:
|
||||
background 0.4s,
|
||||
opacity 0.7s;
|
||||
}
|
||||
.ripple:not([disabled]):active::after {
|
||||
opacity: 1;
|
||||
background-size: 0%;
|
||||
transition:
|
||||
background 0s,
|
||||
opacity 0s;
|
||||
}
|
||||
|
||||
.backdrop-animate-fade-in::backdrop {
|
||||
animation: fade-in 0.3s ease-out forwards;
|
||||
}
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pop-in {
|
||||
animation: pop-in 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
|
||||
}
|
||||
@keyframes pop-in {
|
||||
from {
|
||||
transform: scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.link {
|
||||
@apply text-sky-600 dark:text-sky-500 rounded-sm focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-sky-600 dark:focus:outline-sky-500;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
@apply h-8 w-8 animate-spin rounded-full border-4 border-transparent border-t-current;
|
||||
}
|
||||
|
||||
.alert {
|
||||
@apply px-4 py-2 flex gap-2 items-center bg-red-500 text-white rounded-sm shadow-md;
|
||||
}
|
||||
|
||||
.button-filled {
|
||||
@apply rounded-md bg-sky-600 px-3 py-2 min-h-12
|
||||
text-sm font-semibold uppercase tracking-wider text-white shadow-sm transition-all
|
||||
hover:bg-sky-500 focus:outline focus:outline-2 focus:outline-offset-2 focus:outline-sky-600
|
||||
disabled:bg-zinc-300 dark:disabled:bg-zinc-700 dark:disabled:text-zinc-500;
|
||||
}
|
||||
|
||||
.button-outlined {
|
||||
@apply rounded-md bg-transparent px-3 py-2 min-h-12 ring-1 ring-inset ring-sky-600/100
|
||||
text-sm font-semibold uppercase tracking-wider text-sky-600 transition-all
|
||||
hover:ring-sky-500/100 hover:text-sky-500 focus:outline focus:outline-2 focus:outline-offset-2 focus:outline-sky-600
|
||||
disabled:ring-zinc-300 disabled:text-zinc-300 dark:disabled:ring-zinc-700 dark:disabled:text-zinc-500;
|
||||
}
|
||||
|
||||
.button-ghost {
|
||||
@apply rounded-md bg-transparent px-3 py-2 min-h-12 ring-1 ring-inset ring-transparent
|
||||
text-sm font-semibold uppercase tracking-wider transition-all
|
||||
hover:ring-current focus:outline focus:outline-2 focus:outline-offset-2 focus:outline-current
|
||||
disabled:text-zinc-500;
|
||||
}
|
||||
|
||||
.tab {
|
||||
@apply inline-flex items-center px-4 text-sm text-center bg-transparent border-b-2 sm:text-base whitespace-nowrap outline-none
|
||||
border-transparent dark:text-white cursor-base hover:border-zinc-400 focus:border-zinc-400 transition-all;
|
||||
}
|
||||
|
||||
.tab-active {
|
||||
@apply text-sky-600 border-sky-500 dark:border-sky-600 dark:text-sky-500 hover:border-sky-400 focus:border-sky-400;
|
||||
}
|
||||
|
||||
.input-text {
|
||||
@apply block appearance-none rounded-md border-none bg-transparent px-3 py-1.5
|
||||
text-zinc-900 shadow-sm outline-none ring-1 ring-inset ring-zinc-400
|
||||
placeholder:text-zinc-500 focus:ring-2 focus:ring-sky-600/100
|
||||
dark:text-zinc-100 dark:ring-zinc-600;
|
||||
}
|
||||
|
||||
.input-range {
|
||||
@apply h-6 cursor-pointer accent-sky-600;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
@apply overflow-hidden overflow-y-auto rounded-md shadow-xl
|
||||
bg-white text-zinc-900 shadow-lg backdrop:bg-black/30 dark:bg-zinc-800 dark:text-zinc-100;
|
||||
}
|
||||
}
|
||||
`;
|
|
@ -0,0 +1,31 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useLocalStorage(key: string) {
|
||||
const [value, setValue] = useState(() => window.localStorage.getItem(key));
|
||||
|
||||
useEffect(() => {
|
||||
console.log(key, value);
|
||||
if (value != null) {
|
||||
window.localStorage.setItem(key, value);
|
||||
} else {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
}, [key, value]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key === key) {
|
||||
console.log(key, event.newValue);
|
||||
setValue(event.newValue);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("storage", handleStorage);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
};
|
||||
}, [key]);
|
||||
|
||||
return [value, setValue] as const;
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
export type TkvEntry<K extends Deno.KvKey, T> = {
|
||||
key: readonly [...K];
|
||||
value: T;
|
||||
versionstamp: string;
|
||||
};
|
||||
|
||||
export type TkvEntryMaybe<K extends Deno.KvKey, T> = TkvEntry<K, T> | {
|
||||
key: readonly [...K];
|
||||
value: null;
|
||||
versionstamp: null;
|
||||
};
|
||||
|
||||
export type TkvListSelector<K extends Deno.KvKey> =
|
||||
| { prefix: KvKeyPrefix<K> }
|
||||
| { prefix: KvKeyPrefix<K>; start: readonly [...K] }
|
||||
| { prefix: KvKeyPrefix<K>; end: readonly [...K] }
|
||||
| { start: readonly [...K]; end: readonly [...K] };
|
||||
|
||||
export type KvKeyPrefix<Key extends Deno.KvKey> = Key extends readonly [infer Prefix, ...infer Rest]
|
||||
? readonly [Prefix] | readonly [Prefix, ...Rest]
|
||||
: never;
|
||||
|
||||
/**
|
||||
* Typed wrapper for {@link Deno.Kv}
|
||||
*/
|
||||
export class Tkv<K extends Deno.KvKey, T> {
|
||||
constructor(readonly db: Deno.Kv) {}
|
||||
|
||||
get(
|
||||
key: readonly [...K],
|
||||
options: Parameters<Deno.Kv["get"]>[1] = {},
|
||||
): Promise<TkvEntryMaybe<K, T>> {
|
||||
return this.db.get<T>(key, options) as any;
|
||||
}
|
||||
|
||||
set(
|
||||
key: readonly [...K],
|
||||
value: T,
|
||||
options: Parameters<Deno.Kv["set"]>[2] = {},
|
||||
): ReturnType<Deno.Kv["set"]> {
|
||||
return this.db.set(key, value, options);
|
||||
}
|
||||
|
||||
atomicSet(
|
||||
key: readonly [...K],
|
||||
versionstamp: Parameters<Deno.AtomicOperation["check"]>[0]["versionstamp"],
|
||||
value: T,
|
||||
options: Parameters<Deno.AtomicOperation["set"]>[2] = {},
|
||||
): ReturnType<Deno.AtomicOperation["commit"]> {
|
||||
return this.db.atomic()
|
||||
.check({ key, versionstamp })
|
||||
.set(key, value, options)
|
||||
.commit();
|
||||
}
|
||||
|
||||
delete(key: readonly [...K]): ReturnType<Deno.Kv["delete"]> {
|
||||
return this.db.delete(key);
|
||||
}
|
||||
|
||||
atomicDelete(
|
||||
key: readonly [...K],
|
||||
versionstamp: Parameters<Deno.AtomicOperation["check"]>[0]["versionstamp"],
|
||||
): ReturnType<Deno.AtomicOperation["commit"]> {
|
||||
return this.db.atomic()
|
||||
.check({ key, versionstamp })
|
||||
.delete(key)
|
||||
.commit();
|
||||
}
|
||||
|
||||
list(
|
||||
selector: TkvListSelector<K>,
|
||||
options: Parameters<Deno.Kv["list"]>[1] = {},
|
||||
): AsyncIterableIterator<TkvEntry<K, T>> {
|
||||
return this.db.list<T>(selector as Deno.KvListSelector, options) as any;
|
||||
}
|
||||
}
|
|
@ -1,7 +1,11 @@
|
|||
import { Chat, User } from "grammy_types";
|
||||
|
||||
export function formatUserChat(
|
||||
ctx: { from?: User; chat?: Chat; sdInstanceId?: string },
|
||||
ctx: {
|
||||
from?: User | undefined;
|
||||
chat?: Chat | undefined;
|
||||
workerInstanceKey?: string | undefined;
|
||||
},
|
||||
) {
|
||||
const msg: string[] = [];
|
||||
if (ctx.from) {
|
||||
|
@ -26,8 +30,8 @@ export function formatUserChat(
|
|||
}
|
||||
}
|
||||
}
|
||||
if (ctx.sdInstanceId) {
|
||||
msg.push(`using ${ctx.sdInstanceId}`);
|
||||
if (ctx.workerInstanceKey) {
|
||||
msg.push(`using ${ctx.workerInstanceKey}`);
|
||||
}
|
||||
return msg.join(" ");
|
||||
}
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
export function getAuthHeader(auth: { user: string; password: string } | null) {
|
||||
if (!auth) return {};
|
||||
return { "Authorization": `Basic ${btoa(`${auth.user}:${auth.password}`)}` };
|
||||
}
|
|
@ -42,10 +42,12 @@ const languageToFlagMap: Record<string, string> = {
|
|||
"id": "🇮🇩", // indonesian - indonesia
|
||||
"is": "🇮🇸", // icelandic - iceland
|
||||
"lb": "🇱🇺", // luxembourgish - luxembourg
|
||||
"ca": "🇪🇸", // catalan - spain
|
||||
};
|
||||
|
||||
export function getFlagEmoji(languageCode?: string): string | undefined {
|
||||
const language = languageCode?.split("-").pop()?.toLowerCase();
|
||||
if (!language) return;
|
||||
return languageToFlagMap[language];
|
||||
if (!languageCode) return;
|
||||
const language = languageCode.split("-").shift()?.toLowerCase();
|
||||
if (language == null) return;
|
||||
return languageToFlagMap[language] ?? `[${language.toUpperCase()}]`;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
export interface KvMemoizeOptions<A extends Deno.KvKey, R> {
|
||||
/**
|
||||
* The time in milliseconds until the cached result expires.
|
||||
*/
|
||||
expireIn?: ((result: R, ...args: A) => number) | number | undefined;
|
||||
/**
|
||||
* Whether to recalculate the result if it was already cached.
|
||||
*
|
||||
* Runs whenever the result is retrieved from the cache.
|
||||
*/
|
||||
shouldRecalculate?: ((result: R, ...args: A) => boolean) | undefined;
|
||||
/**
|
||||
* Whether to cache the result after computing it.
|
||||
*
|
||||
* Runs whenever a new result is computed.
|
||||
*/
|
||||
shouldCache?: ((result: R, ...args: A) => boolean) | undefined;
|
||||
/**
|
||||
* Override the default KV store functions.
|
||||
*/
|
||||
override?: {
|
||||
set: (
|
||||
key: Deno.KvKey,
|
||||
args: A,
|
||||
value: R,
|
||||
options: { expireIn?: number },
|
||||
) => Promise<void>;
|
||||
get: (key: Deno.KvKey, args: A) => Promise<R | undefined>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoizes the function result in KV store.
|
||||
*/
|
||||
export function kvMemoize<A extends Deno.KvKey, R>(
|
||||
db: Deno.Kv,
|
||||
key: Deno.KvKey,
|
||||
fn: (...args: A) => Promise<R>,
|
||||
options?: KvMemoizeOptions<A, R>,
|
||||
): (...args: A) => Promise<R> {
|
||||
return async (...args) => {
|
||||
const cachedResult = options?.override?.get
|
||||
? await options.override.get(key, args)
|
||||
: (await db.get<R>([...key, ...args])).value;
|
||||
|
||||
if (cachedResult != null) {
|
||||
if (!options?.shouldRecalculate?.(cachedResult, ...args)) {
|
||||
return cachedResult;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await fn(...args);
|
||||
|
||||
const expireIn = typeof options?.expireIn === "function"
|
||||
? options.expireIn(result, ...args)
|
||||
: options?.expireIn;
|
||||
|
||||
if (options?.shouldCache?.(result, ...args) ?? (result != null)) {
|
||||
if (options?.override?.set) {
|
||||
await options.override.set(key, args, result, expireIn != null ? { expireIn } : {});
|
||||
} else {
|
||||
await db.set([...key, ...args], result, expireIn != null ? { expireIn } : {});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Removes all undefined properties from an object.
|
||||
*/
|
||||
export function omitUndef<O extends object | undefined>(object: O):
|
||||
& { [K in keyof O as undefined extends O[K] ? never : K]: O[K] }
|
||||
& { [K in keyof O as undefined extends O[K] ? K : never]?: O[K] & ({} | null) } {
|
||||
if (object == undefined) return object as never;
|
||||
return Object.fromEntries(
|
||||
Object.entries(object).filter(([, v]) => v !== undefined),
|
||||
) as never;
|
||||
}
|
Loading…
Reference in New Issue