Compare commits
7 Commits
01261b6f3a
...
477cb03ea2
Author | SHA1 | Date |
---|---|---|
pinks | 477cb03ea2 | |
pinks | 0e88bc5053 | |
pinks | bf75cce20c | |
pinks | a8d36db20b | |
pinks | c0354ef679 | |
pinks | 99540d4035 | |
pinks | adebd34db8 |
|
@ -1,4 +1,4 @@
|
|||
.env
|
||||
app.db*
|
||||
app*.db*
|
||||
deno.lock
|
||||
updateConfig.ts
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
import { createEndpoint, createMethodFilter } from "t_rest/server";
|
||||
import { generationQueue } from "../app/generationQueue.ts";
|
||||
|
||||
export const jobsRoute = createMethodFilter({
|
||||
GET: createEndpoint(
|
||||
{ query: null, body: null },
|
||||
async () => ({
|
||||
status: 200,
|
||||
body: {
|
||||
type: "application/json",
|
||||
data: await generationQueue.getAllJobs(),
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
74
api/mod.ts
74
api/mod.ts
|
@ -1,62 +1,22 @@
|
|||
import { initTRPC } from "@trpc/server";
|
||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||
import { serveDir } from "std/http/file_server.ts";
|
||||
import { transform } from "swc";
|
||||
import { generationQueue } from "../app/generationQueue.ts";
|
||||
import { route } from "reroute";
|
||||
import { serveSpa } from "serve_spa";
|
||||
import { serveApi } from "./serveApi.ts";
|
||||
|
||||
const t = initTRPC.create();
|
||||
|
||||
export const appRouter = t.router({
|
||||
ping: t.procedure.query(() => "pong"),
|
||||
getAllGenerationJobs: t.procedure.query(() => {
|
||||
return generationQueue.getAllJobs();
|
||||
}),
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
const webuiRoot = new URL("./webui/", import.meta.url);
|
||||
|
||||
export async function serveApi() {
|
||||
const server = Deno.serve({ port: 8000 }, async (request) => {
|
||||
const requestPath = new URL(request.url).pathname;
|
||||
const filePath = webuiRoot.pathname + requestPath;
|
||||
const fileExt = filePath.split("/").pop()?.split(".").pop()?.toLowerCase();
|
||||
const fileExists = await Deno.stat(filePath).then((stat) => stat.isFile).catch(() => false);
|
||||
|
||||
if (requestPath.startsWith("/api/trpc/")) {
|
||||
return fetchRequestHandler({
|
||||
endpoint: "/api/trpc",
|
||||
req: request,
|
||||
router: appRouter,
|
||||
createContext: () => ({}),
|
||||
});
|
||||
}
|
||||
|
||||
if (fileExists) {
|
||||
if (fileExt === "ts" || fileExt === "tsx") {
|
||||
const file = await Deno.readTextFile(filePath);
|
||||
const result = await transform(file, {
|
||||
jsc: {
|
||||
parser: {
|
||||
syntax: "typescript",
|
||||
tsx: fileExt === "tsx",
|
||||
},
|
||||
target: "es2022",
|
||||
export async function serveUi() {
|
||||
const server = Deno.serve({ port: 5999 }, (request) =>
|
||||
route(request, {
|
||||
"/api/*": (request) => serveApi(request),
|
||||
"/*": (request) =>
|
||||
serveSpa(request, {
|
||||
fsRoot: new URL("../ui/", import.meta.url).pathname,
|
||||
indexFallback: true,
|
||||
importMapFile: "../deno.json",
|
||||
aliasMap: {
|
||||
"/utils/*": "../utils/",
|
||||
},
|
||||
});
|
||||
return new Response(result.code, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/javascript",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return serveDir(request, {
|
||||
fsRoot: webuiRoot.pathname,
|
||||
});
|
||||
});
|
||||
log: (_request, response) => response.status >= 400,
|
||||
}),
|
||||
}));
|
||||
|
||||
await server.finished;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
import { deepMerge } from "std/collections/deep_merge.ts";
|
||||
import { getLogger } from "std/log/mod.ts";
|
||||
import { createEndpoint, createMethodFilter } from "t_rest/server";
|
||||
import { configSchema, getConfig, setConfig } from "../app/config.ts";
|
||||
import { bot } from "../bot/mod.ts";
|
||||
import { sessions } from "./sessionsRoute.ts";
|
||||
|
||||
export const logger = () => getLogger();
|
||||
|
||||
export const paramsRoute = createMethodFilter({
|
||||
GET: createEndpoint(
|
||||
{ query: null, body: null },
|
||||
async () => {
|
||||
const config = await getConfig();
|
||||
return { status: 200, body: { type: "application/json", data: config.defaultParams } };
|
||||
},
|
||||
),
|
||||
|
||||
PATCH: createEndpoint(
|
||||
{
|
||||
query: { sessionId: { type: "string" } },
|
||||
body: {
|
||||
type: "application/json",
|
||||
schema: configSchema.properties.defaultParams,
|
||||
},
|
||||
},
|
||||
async ({ query, body }) => {
|
||||
const session = sessions.get(query.sessionId);
|
||||
if (!session?.userId) {
|
||||
return { status: 401, body: { type: "text/plain", data: "Must be logged in" } };
|
||||
}
|
||||
const chat = await bot.api.getChat(session.userId);
|
||||
if (chat.type !== "private") {
|
||||
throw new Error("Chat is not private");
|
||||
}
|
||||
const userName = chat.username;
|
||||
if (!userName) {
|
||||
return { status: 403, body: { type: "text/plain", data: "Must have a username" } };
|
||||
}
|
||||
const config = await getConfig();
|
||||
if (!config?.adminUsernames?.includes(userName)) {
|
||||
return { status: 403, body: { type: "text/plain", data: "Must be an admin" } };
|
||||
}
|
||||
logger().info(`User ${userName} updated default params: ${JSON.stringify(body.data)}`);
|
||||
const defaultParams = deepMerge(config.defaultParams ?? {}, body.data);
|
||||
await setConfig({ defaultParams });
|
||||
return { status: 200, body: { type: "application/json", data: config.defaultParams } };
|
||||
},
|
||||
),
|
||||
});
|
|
@ -0,0 +1,19 @@
|
|||
import { createLoggerMiddleware, createPathFilter } from "t_rest/server";
|
||||
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";
|
||||
|
||||
export const serveApi = createLoggerMiddleware(
|
||||
createPathFilter({
|
||||
"jobs": jobsRoute,
|
||||
"sessions": sessionsRoute,
|
||||
"users": usersRoute,
|
||||
"settings/params": paramsRoute,
|
||||
"stats": statsRoute,
|
||||
}),
|
||||
{ filterStatus: (status) => status >= 400 },
|
||||
);
|
||||
|
||||
export type ApiHandler = typeof serveApi;
|
|
@ -0,0 +1,37 @@
|
|||
// deno-lint-ignore-file require-await
|
||||
import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server";
|
||||
import { ulid } from "ulid";
|
||||
|
||||
export const sessions = new Map<string, Session>();
|
||||
|
||||
export interface Session {
|
||||
userId?: number;
|
||||
}
|
||||
|
||||
export const sessionsRoute = createPathFilter({
|
||||
"": createMethodFilter({
|
||||
POST: createEndpoint(
|
||||
{ query: null, body: null },
|
||||
async () => {
|
||||
const id = ulid();
|
||||
const session: Session = {};
|
||||
sessions.set(id, session);
|
||||
return { status: 200, body: { type: "application/json", data: { id, ...session } } };
|
||||
},
|
||||
),
|
||||
}),
|
||||
|
||||
"{sessionId}": createMethodFilter({
|
||||
GET: createEndpoint(
|
||||
{ query: null, body: null },
|
||||
async ({ params }) => {
|
||||
const id = params.sessionId;
|
||||
const session = sessions.get(id);
|
||||
if (!session) {
|
||||
return { status: 401, body: { type: "text/plain", data: "Session not found" } };
|
||||
}
|
||||
return { status: 200, body: { type: "application/json", data: { id, ...session } } };
|
||||
},
|
||||
),
|
||||
}),
|
||||
});
|
|
@ -0,0 +1,96 @@
|
|||
// deno-lint-ignore-file require-await
|
||||
import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server";
|
||||
import { liveGlobalStats } from "../app/globalStatsStore.ts";
|
||||
import { getDailyStats } from "../app/dailyStatsStore.ts";
|
||||
import { getUserStats } from "../app/userStatsStore.ts";
|
||||
import { getUserDailyStats } from "../app/userDailyStatsStore.ts";
|
||||
|
||||
export const statsRoute = createPathFilter({
|
||||
"": createMethodFilter({
|
||||
GET: createEndpoint(
|
||||
{ query: null, body: null },
|
||||
async () => {
|
||||
const stats = liveGlobalStats;
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
type: "application/json",
|
||||
data: {
|
||||
imageCount: stats.imageCount,
|
||||
pixelCount: stats.pixelCount,
|
||||
userCount: stats.userIds.length,
|
||||
timestamp: stats.timestamp,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
),
|
||||
}),
|
||||
"daily/{year}/{month}/{day}": createMethodFilter({
|
||||
GET: createEndpoint(
|
||||
{ query: null, body: null },
|
||||
async ({ params }) => {
|
||||
const year = Number(params.year);
|
||||
const month = Number(params.month);
|
||||
const day = Number(params.day);
|
||||
const stats = await getDailyStats(year, month, day);
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
type: "application/json",
|
||||
data: {
|
||||
imageCount: stats.imageCount,
|
||||
pixelCount: stats.pixelCount,
|
||||
userCount: stats.userIds.length,
|
||||
timestamp: stats.timestamp,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
),
|
||||
}),
|
||||
"users/{userId}": createMethodFilter({
|
||||
GET: createEndpoint(
|
||||
{ query: null, body: null },
|
||||
async ({ params }) => {
|
||||
const userId = Number(params.userId);
|
||||
const stats = await getUserStats(userId);
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
type: "application/json",
|
||||
data: {
|
||||
imageCount: stats.imageCount,
|
||||
pixelCount: stats.pixelCount,
|
||||
tagCountMap: stats.tagCountMap,
|
||||
timestamp: stats.timestamp,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
),
|
||||
}),
|
||||
"users/{userId}/daily/{year}/{month}/{day}": createMethodFilter({
|
||||
GET: createEndpoint(
|
||||
{ query: null, body: null },
|
||||
async ({ params }) => {
|
||||
const userId = Number(params.userId);
|
||||
const year = Number(params.year);
|
||||
const month = Number(params.month);
|
||||
const day = Number(params.day);
|
||||
const stats = await getUserDailyStats(userId, year, month, day);
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
type: "application/json",
|
||||
data: {
|
||||
imageCount: stats.imageCount,
|
||||
pixelCount: stats.pixelCount,
|
||||
timestamp: stats.timestamp,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
),
|
||||
}),
|
||||
});
|
|
@ -0,0 +1,54 @@
|
|||
import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server";
|
||||
import { getConfig } from "../app/config.ts";
|
||||
import { bot } from "../bot/mod.ts";
|
||||
|
||||
export const usersRoute = createPathFilter({
|
||||
"{userId}": createMethodFilter({
|
||||
GET: createEndpoint(
|
||||
{ query: null, body: null },
|
||||
async ({ params }) => {
|
||||
const chat = await bot.api.getChat(params.userId);
|
||||
if (chat.type !== "private") {
|
||||
throw new Error("Chat is not private");
|
||||
}
|
||||
const config = await getConfig();
|
||||
const isAdmin = chat.username && config?.adminUsernames?.includes(chat.username);
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
type: "application/json",
|
||||
data: { ...chat, isAdmin },
|
||||
},
|
||||
};
|
||||
},
|
||||
),
|
||||
}),
|
||||
"{userId}/photo": createMethodFilter({
|
||||
GET: createEndpoint(
|
||||
{ query: null, body: null },
|
||||
async ({ params }) => {
|
||||
const chat = await bot.api.getChat(params.userId);
|
||||
if (chat.type !== "private") {
|
||||
throw new Error("Chat is not private");
|
||||
}
|
||||
const photoData = chat.photo?.small_file_id
|
||||
? await fetch(
|
||||
`https://api.telegram.org/file/bot${bot.token}/${await bot.api.getFile(
|
||||
chat.photo.small_file_id,
|
||||
).then((file) => file.file_path)}`,
|
||||
).then((resp) => resp.arrayBuffer())
|
||||
: undefined;
|
||||
if (!photoData) {
|
||||
return { status: 404, body: { type: "text/plain", data: "User has no photo" } };
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
type: "image/jpeg",
|
||||
data: new Blob([photoData], { type: "image/jpeg" }),
|
||||
},
|
||||
};
|
||||
},
|
||||
),
|
||||
}),
|
||||
});
|
|
@ -1,78 +0,0 @@
|
|||
/// <reference lib="dom" />
|
||||
import { QueryClient, QueryClientProvider } from "https://esm.sh/@tanstack/react-query@4.35.3";
|
||||
import { httpBatchLink } from "https://esm.sh/@trpc/client@10.38.4/links/httpBatchLink";
|
||||
import { createTRPCReact } from "https://esm.sh/@trpc/react-query@10.38.4";
|
||||
import { defineConfig, injectGlobal, install, tw } from "https://esm.sh/@twind/core@1.1.3";
|
||||
import presetTailwind from "https://esm.sh/@twind/preset-tailwind@1.1.4";
|
||||
import { createRoot } from "https://esm.sh/react-dom@18.2.0/client";
|
||||
import FlipMove from "https://esm.sh/react-flip-move@3.0.5";
|
||||
import React from "https://esm.sh/react@18.2.0";
|
||||
import type { AppRouter } from "../mod.ts";
|
||||
|
||||
const twConfig = defineConfig({
|
||||
presets: [presetTailwind()],
|
||||
});
|
||||
|
||||
install(twConfig);
|
||||
|
||||
injectGlobal`
|
||||
html {
|
||||
@apply h-full bg-white text-zinc-900 dark:bg-zinc-900 dark:text-zinc-100;
|
||||
}
|
||||
body {
|
||||
@apply flex min-h-full flex-col items-stretch;
|
||||
}
|
||||
`;
|
||||
|
||||
export const trpc = createTRPCReact<AppRouter>();
|
||||
|
||||
const trpcClient = trpc.createClient({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: "/api/trpc",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
suspense: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
createRoot(document.body).render(
|
||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</trpc.Provider>,
|
||||
);
|
||||
|
||||
function App() {
|
||||
const allJobs = trpc.getAllGenerationJobs.useQuery(undefined, { refetchInterval: 1000 });
|
||||
const processingJobs = (allJobs.data ?? [])
|
||||
.filter((job) => new Date(job.lockUntil) > new Date()).map((job) => ({ ...job, index: 0 }));
|
||||
const waitingJobs = (allJobs.data ?? [])
|
||||
.filter((job) => new Date(job.lockUntil) <= new Date())
|
||||
.map((job, index) => ({ ...job, index: index + 1 }));
|
||||
const jobs = [...processingJobs, ...waitingJobs];
|
||||
|
||||
return (
|
||||
<FlipMove
|
||||
typeName={"ul"}
|
||||
className={tw("p-4")}
|
||||
enterAnimation="fade"
|
||||
leaveAnimation="fade"
|
||||
>
|
||||
{jobs.map((job) => (
|
||||
<li key={job.id.join("/")} className={tw("")}>
|
||||
{job.index}. {job.state.from.first_name} {job.state.from.last_name}{" "}
|
||||
{job.state.from.username} {job.state.from.language_code}{" "}
|
||||
{((job.state.progress ?? 0) * 100).toFixed(0)}% {job.state.sdInstanceId}
|
||||
</li>
|
||||
))}
|
||||
</FlipMove>
|
||||
);
|
||||
}
|
110
app/config.ts
110
app/config.ts
|
@ -1,53 +1,73 @@
|
|||
import * as SdApi from "./sdApi.ts";
|
||||
import { db } from "./db.ts";
|
||||
import { JsonSchema, jsonType } from "t_rest/server";
|
||||
|
||||
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 interface SdInstanceData {
|
||||
id: string;
|
||||
name?: string;
|
||||
api: { url: string; auth?: string };
|
||||
maxResolution: number;
|
||||
}
|
||||
|
||||
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 = {
|
||||
type: "object",
|
||||
properties: {
|
||||
adminUsernames: { type: "array", items: { type: "string" } },
|
||||
pausedReason: { type: ["string", "null"] },
|
||||
maxUserJobs: { type: "number" },
|
||||
maxJobs: { type: "number" },
|
||||
defaultParams: {
|
||||
type: "object",
|
||||
properties: {
|
||||
batch_size: { type: "number" },
|
||||
n_iter: { type: "number" },
|
||||
width: { type: "number" },
|
||||
height: { type: "number" },
|
||||
steps: { type: "number" },
|
||||
cfg_scale: { type: "number" },
|
||||
sampler_name: { type: "string" },
|
||||
negative_prompt: { type: "string" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
sdInstances: {
|
||||
type: "object",
|
||||
additionalProperties: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
api: {
|
||||
type: "object",
|
||||
properties: {
|
||||
url: { type: "string" },
|
||||
auth: { type: "string" },
|
||||
},
|
||||
required: ["url"],
|
||||
},
|
||||
maxResolution: { type: "number" },
|
||||
},
|
||||
required: ["api", "maxResolution"],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["adminUsernames", "maxUserJobs", "maxJobs", "defaultParams", "sdInstances"],
|
||||
} as const satisfies JsonSchema;
|
||||
|
||||
export async function getConfig(): Promise<ConfigData> {
|
||||
const configEntry = await db.get<ConfigData>(["config"]);
|
||||
return configEntry.value ?? getDefaultConfig();
|
||||
export type Config = jsonType<typeof configSchema>;
|
||||
|
||||
export async function getConfig(): Promise<Config> {
|
||||
const configEntry = await db.get<Config>(["config"]);
|
||||
const config = configEntry?.value;
|
||||
return {
|
||||
adminUsernames: config?.adminUsernames ?? [],
|
||||
pausedReason: config?.pausedReason ?? null,
|
||||
maxUserJobs: config?.maxUserJobs ?? Infinity,
|
||||
maxJobs: config?.maxJobs ?? Infinity,
|
||||
defaultParams: config?.defaultParams ?? {},
|
||||
sdInstances: config?.sdInstances ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
export async function setConfig(config: ConfigData): Promise<void> {
|
||||
export async function setConfig(newConfig: Partial<Config>): Promise<void> {
|
||||
const oldConfig = await getConfig();
|
||||
const config: Config = {
|
||||
adminUsernames: newConfig.adminUsernames ?? oldConfig.adminUsernames,
|
||||
pausedReason: newConfig.pausedReason ?? oldConfig.pausedReason,
|
||||
maxUserJobs: newConfig.maxUserJobs ?? oldConfig.maxUserJobs,
|
||||
maxJobs: newConfig.maxJobs ?? oldConfig.maxJobs,
|
||||
defaultParams: newConfig.defaultParams ?? oldConfig.defaultParams,
|
||||
sdInstances: newConfig.sdInstances ?? oldConfig.sdInstances,
|
||||
};
|
||||
await db.set(["config"], config);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
import { UTCDateMini } from "@date-fns/utc";
|
||||
import { hoursToMilliseconds, isSameDay, minutesToMilliseconds } from "date-fns";
|
||||
import { getLogger } from "std/log/mod.ts";
|
||||
import { JsonSchema, jsonType } from "t_rest/server";
|
||||
import { db } from "./db.ts";
|
||||
import { generationStore } from "./generationStore.ts";
|
||||
import { kvMemoize } from "./kvMemoize.ts";
|
||||
|
||||
const logger = () => getLogger();
|
||||
|
||||
export const dailyStatsSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
userIds: { type: "array", items: { type: "number" } },
|
||||
imageCount: { type: "number" },
|
||||
pixelCount: { type: "number" },
|
||||
timestamp: { type: "number" },
|
||||
},
|
||||
required: ["userIds", "imageCount", "pixelCount", "timestamp"],
|
||||
} as const satisfies JsonSchema;
|
||||
|
||||
export type DailyStats = jsonType<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 pixelCount = 0;
|
||||
|
||||
const after = new Date(Date.UTC(year, month - 1, day));
|
||||
const before = new Date(Date.UTC(year, month - 1, day + 1));
|
||||
|
||||
logger().info(`Calculating daily stats for ${year}-${month}-${day}`);
|
||||
|
||||
for await (
|
||||
const generation of generationStore.listAll({ after, before })
|
||||
) {
|
||||
userIdSet.add(generation.value.from.id);
|
||||
imageCount++;
|
||||
pixelCount += (generation.value.info?.width ?? 0) * (generation.value.info?.height ?? 0);
|
||||
}
|
||||
|
||||
return {
|
||||
userIds: [...userIdSet],
|
||||
imageCount,
|
||||
pixelCount,
|
||||
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,
|
||||
},
|
||||
);
|
|
@ -11,7 +11,7 @@ import { PngInfo } from "../bot/parsePngInfo.ts";
|
|||
import { formatOrdinal } from "../utils/formatOrdinal.ts";
|
||||
import { formatUserChat } from "../utils/formatUserChat.ts";
|
||||
import { SdError } from "./SdError.ts";
|
||||
import { getConfig, SdInstanceData } from "./config.ts";
|
||||
import { getConfig } from "./config.ts";
|
||||
import { db, fs } from "./db.ts";
|
||||
import { SdGenerationInfo } from "./generationStore.ts";
|
||||
import * as SdApi from "./sdApi.ts";
|
||||
|
@ -49,15 +49,16 @@ export async function processGenerationQueue() {
|
|||
while (true) {
|
||||
const config = await getConfig();
|
||||
|
||||
for (const sdInstance of config.sdInstances) {
|
||||
const activeWorker = activeGenerationWorkers.get(sdInstance.id);
|
||||
if (activeWorker?.isProcessing) continue;
|
||||
for (const [sdInstanceId, sdInstance] of Object.entries(config?.sdInstances ?? {})) {
|
||||
const activeWorker = activeGenerationWorkers.get(sdInstanceId);
|
||||
if (activeWorker?.isProcessing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const workerSdClient = createOpenApiClient<SdApi.paths>({
|
||||
baseUrl: sdInstance.api.url,
|
||||
headers: { "Authorization": sdInstance.api.auth },
|
||||
});
|
||||
|
||||
// check if worker is up
|
||||
const activeWorkerStatus = await workerSdClient.GET("/sdapi/v1/memory", {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
|
@ -69,7 +70,7 @@ export async function processGenerationQueue() {
|
|||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
logger().warning(`Worker ${sdInstance.id} is down: ${error}`);
|
||||
logger().debug(`Worker ${sdInstanceId} is down: ${error}`);
|
||||
});
|
||||
if (!activeWorkerStatus?.data) {
|
||||
continue;
|
||||
|
@ -77,7 +78,7 @@ export async function processGenerationQueue() {
|
|||
|
||||
// create worker
|
||||
const newWorker = generationQueue.createWorker(async ({ state }, updateJob) => {
|
||||
await processGenerationJob(state, updateJob, sdInstance);
|
||||
await processGenerationJob(state, updateJob, sdInstanceId);
|
||||
});
|
||||
newWorker.addEventListener("error", (e) => {
|
||||
logger().error(
|
||||
|
@ -94,13 +95,12 @@ export async function processGenerationQueue() {
|
|||
allow_sending_without_reply: true,
|
||||
},
|
||||
).catch(() => undefined);
|
||||
if (e.detail.error instanceof SdError) {
|
||||
newWorker.stopProcessing();
|
||||
}
|
||||
newWorker.stopProcessing();
|
||||
logger().info(`Stopped worker ${sdInstanceId}`);
|
||||
});
|
||||
newWorker.processJobs();
|
||||
activeGenerationWorkers.set(sdInstance.id, newWorker);
|
||||
logger().info(`Started worker ${sdInstance.id}`);
|
||||
activeGenerationWorkers.set(sdInstanceId, newWorker);
|
||||
logger().info(`Started worker ${sdInstanceId}`);
|
||||
}
|
||||
await delay(60_000);
|
||||
}
|
||||
|
@ -112,14 +112,20 @@ export async function processGenerationQueue() {
|
|||
async function processGenerationJob(
|
||||
state: GenerationJob,
|
||||
updateJob: (job: Partial<JobData<GenerationJob>>) => Promise<void>,
|
||||
sdInstance: SdInstanceData,
|
||||
sdInstanceId: string,
|
||||
) {
|
||||
const startDate = new Date();
|
||||
const config = await getConfig();
|
||||
const sdInstance = config?.sdInstances?.[sdInstanceId];
|
||||
if (!sdInstance) {
|
||||
throw new Error(`Unknown sdInstanceId: ${sdInstanceId}`);
|
||||
}
|
||||
const workerSdClient = createOpenApiClient<SdApi.paths>({
|
||||
baseUrl: sdInstance.api.url,
|
||||
headers: { "Authorization": sdInstance.api.auth },
|
||||
});
|
||||
state.sdInstanceId = sdInstance.id;
|
||||
state.sdInstanceId = sdInstanceId;
|
||||
state.progress = 0;
|
||||
logger().debug(`Generation started for ${formatUserChat(state)}`);
|
||||
await updateJob({ state: state });
|
||||
|
||||
|
@ -136,12 +142,11 @@ async function processGenerationJob(
|
|||
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 ${sdInstance.name || sdInstanceId}`,
|
||||
{ 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,
|
||||
|
@ -204,6 +209,7 @@ async function processGenerationJob(
|
|||
responsePromise.catch(() => undefined);
|
||||
|
||||
// poll for progress while the generation request is pending
|
||||
|
||||
do {
|
||||
const progressResponse = await workerSdClient.GET("/sdapi/v1/progress", {
|
||||
params: {},
|
||||
|
@ -217,21 +223,22 @@ async function processGenerationJob(
|
|||
);
|
||||
}
|
||||
|
||||
state.progress = progressResponse.data.progress;
|
||||
await updateJob({ state: state });
|
||||
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 || sdInstanceId}`,
|
||||
{ maxAttempts: 1 },
|
||||
).catch(() => undefined);
|
||||
}
|
||||
|
||||
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
|
||||
}`,
|
||||
{ maxAttempts: 1 },
|
||||
).catch(() => undefined);
|
||||
|
||||
await Promise.race([delay(3000), responsePromise]).catch(() => undefined);
|
||||
await Promise.race([delay(1000), responsePromise]).catch(() => undefined);
|
||||
} while (await promiseState(responsePromise) === "pending");
|
||||
|
||||
// check response
|
||||
|
@ -261,7 +268,7 @@ async function processGenerationJob(
|
|||
from: state.from,
|
||||
requestMessage: state.requestMessage,
|
||||
replyMessage: state.replyMessage,
|
||||
sdInstanceId: sdInstance.id,
|
||||
sdInstanceId: sdInstanceId,
|
||||
startDate,
|
||||
endDate: new Date(),
|
||||
imageKeys,
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
import { addDays } from "date-fns";
|
||||
import { JsonSchema, jsonType } from "t_rest/server";
|
||||
import { decodeTime } from "ulid";
|
||||
import { getDailyStats } from "./dailyStatsStore.ts";
|
||||
import { generationStore } from "./generationStore.ts";
|
||||
|
||||
export const globalStatsSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
userIds: { type: "array", items: { type: "number" } },
|
||||
imageCount: { type: "number" },
|
||||
pixelCount: { type: "number" },
|
||||
timestamp: { type: "number" },
|
||||
},
|
||||
required: ["userIds", "imageCount", "pixelCount", "timestamp"],
|
||||
} as const satisfies JsonSchema;
|
||||
|
||||
export type GlobalStats = jsonType<typeof globalStatsSchema>;
|
||||
|
||||
export const liveGlobalStats: GlobalStats = await getGlobalStats();
|
||||
|
||||
export 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 pixelCount = 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;
|
||||
pixelCount += dailyStats.pixelCount;
|
||||
}
|
||||
|
||||
return {
|
||||
userIds: [...userIdSet],
|
||||
imageCount,
|
||||
pixelCount,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* Memoizes the function result in KV storage.
|
||||
*/
|
||||
export function kvMemoize<A extends Deno.KvKey, R>(
|
||||
db: Deno.Kv,
|
||||
key: Deno.KvKey,
|
||||
fn: (...args: A) => Promise<R>,
|
||||
options?: {
|
||||
expireIn?: number | ((result: R, ...args: A) => number);
|
||||
shouldRecalculate?: (result: R, ...args: A) => boolean;
|
||||
shouldCache?: (result: R, ...args: A) => boolean;
|
||||
override?: {
|
||||
set: (key: Deno.KvKey, args: A, value: R, options: { expireIn?: number }) => Promise<void>;
|
||||
get: (key: Deno.KvKey, args: A) => Promise<R | undefined>;
|
||||
};
|
||||
},
|
||||
): (...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 });
|
||||
} else {
|
||||
await db.set([...key, ...args], result, { expireIn });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
|
@ -9,6 +9,7 @@ import { bot } from "../bot/mod.ts";
|
|||
import { formatUserChat } from "../utils/formatUserChat.ts";
|
||||
import { db, fs } from "./db.ts";
|
||||
import { generationStore, SdGenerationInfo } from "./generationStore.ts";
|
||||
import { liveGlobalStats } from "./globalStatsStore.ts";
|
||||
|
||||
const logger = () => getLogger();
|
||||
|
||||
|
@ -109,6 +110,15 @@ export async function processUploadQueue() {
|
|||
info: state.info,
|
||||
});
|
||||
|
||||
// update live stats
|
||||
{
|
||||
liveGlobalStats.imageCount++;
|
||||
liveGlobalStats.pixelCount += state.info.width * state.info.height;
|
||||
const userIdSet = new Set(liveGlobalStats.userIds);
|
||||
userIdSet.add(state.from.id);
|
||||
liveGlobalStats.userIds = [...userIdSet];
|
||||
}
|
||||
|
||||
// delete the status message
|
||||
await bot.api.deleteMessage(state.replyMessage.chat.id, state.replyMessage.message_id)
|
||||
.catch(() => undefined);
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
import { JsonSchema, jsonType } from "t_rest/server";
|
||||
import { kvMemoize } from "./kvMemoize.ts";
|
||||
import { db } from "./db.ts";
|
||||
import { generationStore } from "./generationStore.ts";
|
||||
|
||||
export const userDailyStatsSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
imageCount: { type: "number" },
|
||||
pixelCount: { type: "number" },
|
||||
timestamp: { type: "number" },
|
||||
},
|
||||
required: ["imageCount", "pixelCount", "timestamp"],
|
||||
} as const satisfies JsonSchema;
|
||||
|
||||
export type UserDailyStats = jsonType<typeof userDailyStatsSchema>;
|
||||
|
||||
export const getUserDailyStats = kvMemoize(
|
||||
db,
|
||||
["userDailyStats"],
|
||||
async (userId: number, year: number, month: number, day: number): Promise<UserDailyStats> => {
|
||||
throw new Error("Not implemented");
|
||||
},
|
||||
);
|
|
@ -0,0 +1,112 @@
|
|||
import { minutesToMilliseconds } from "date-fns";
|
||||
import { Store } from "indexed_kv";
|
||||
import { getLogger } from "std/log/mod.ts";
|
||||
import { JsonSchema, jsonType } from "t_rest/server";
|
||||
import { db } from "./db.ts";
|
||||
import { generationStore } from "./generationStore.ts";
|
||||
import { kvMemoize } from "./kvMemoize.ts";
|
||||
|
||||
const logger = () => getLogger();
|
||||
|
||||
export const userStatsSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
userId: { type: "number" },
|
||||
imageCount: { type: "number" },
|
||||
pixelCount: { type: "number" },
|
||||
tagCountMap: {
|
||||
type: "object",
|
||||
additionalProperties: { type: "number" },
|
||||
},
|
||||
timestamp: { type: "number" },
|
||||
},
|
||||
required: ["userId", "imageCount", "pixelCount", "tagCountMap", "timestamp"],
|
||||
} as const satisfies JsonSchema;
|
||||
|
||||
export type UserStats = jsonType<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 pixelCount = 0;
|
||||
const tagCountMap: Record<string, number> = {};
|
||||
|
||||
logger().info(`Calculating user stats for ${userId}`);
|
||||
|
||||
for await (
|
||||
const generation of generationStore.listBy("fromId", { value: userId })
|
||||
) {
|
||||
imageCount++;
|
||||
pixelCount += (generation.value.info?.width ?? 0) * (generation.value.info?.height ?? 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) {
|
||||
tagCountMap[tag] = (tagCountMap[tag] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
userId,
|
||||
imageCount,
|
||||
pixelCount,
|
||||
tagCountMap,
|
||||
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);
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
|
@ -39,6 +39,7 @@ 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
|
||||
|
|
|
@ -9,5 +9,6 @@ export async function cancelCommand(ctx: ErisContext) {
|
|||
for (const job of userJobs) await generationQueue.deleteJob(job.id);
|
||||
await ctx.reply(`Cancelled ${userJobs.length} jobs`, {
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
allow_sending_without_reply: true,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -28,9 +28,6 @@ async function img2img(
|
|||
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,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -53,7 +50,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;
|
||||
|
|
37
bot/mod.ts
37
bot/mod.ts
|
@ -10,6 +10,7 @@ import { img2imgCommand, img2imgQuestion } from "./img2imgCommand.ts";
|
|||
import { pnginfoCommand, pnginfoQuestion } from "./pnginfoCommand.ts";
|
||||
import { queueCommand } from "./queueCommand.ts";
|
||||
import { txt2imgCommand, txt2imgQuestion } from "./txt2imgCommand.ts";
|
||||
import { sessions } from "../api/sessionsRoute.ts";
|
||||
|
||||
export const logger = () => getLogger();
|
||||
|
||||
|
@ -93,6 +94,7 @@ bot.use(async (ctx, next) => {
|
|||
try {
|
||||
await ctx.reply(`Handling update failed: ${err}`, {
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
allow_sending_without_reply: true,
|
||||
});
|
||||
} catch {
|
||||
throw err;
|
||||
|
@ -113,7 +115,30 @@ bot.api.setMyCommands([
|
|||
{ 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", {
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
session.userId = ctx.from?.id;
|
||||
sessions.set(id, session);
|
||||
logger().info(`User ${formatUserChat(ctx)} logged in`);
|
||||
// TODO: show link to web ui
|
||||
await ctx.reply("Login successful! You can now return to the WebUI.", {
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.reply("Hello! Use the /txt2img command to generate an image", {
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
});
|
||||
});
|
||||
|
||||
bot.command("txt2img", txt2imgCommand);
|
||||
bot.use(txt2imgQuestion.middleware());
|
||||
|
@ -137,19 +162,19 @@ bot.command("pause", async (ctx) => {
|
|||
if (config.pausedReason != null) {
|
||||
return ctx.reply(`Already paused: ${config.pausedReason}`);
|
||||
}
|
||||
config.pausedReason = ctx.match ?? "No reason given";
|
||||
await setConfig(config);
|
||||
await setConfig({
|
||||
pausedReason: ctx.match || "No reason given",
|
||||
});
|
||||
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();
|
||||
let 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);
|
||||
await setConfig({ pausedReason: null });
|
||||
logger().info(`Bot resumed by ${ctx.from.first_name}`);
|
||||
return ctx.reply("Resumed");
|
||||
});
|
||||
|
|
|
@ -44,9 +44,9 @@ export async function queueCommand(ctx: CommandContext<ErisContext>) {
|
|||
])
|
||||
: ["Queue is empty.\n"],
|
||||
"\nActive workers:\n",
|
||||
...config.sdInstances.flatMap((sdInstance) => [
|
||||
activeGenerationWorkers.get(sdInstance.id)?.isProcessing ? "✅ " : "☠️ ",
|
||||
fmt`${bold(sdInstance.name || sdInstance.id)} `,
|
||||
...Object.entries(config.sdInstances).flatMap(([sdInstanceId, sdInstance]) => [
|
||||
activeGenerationWorkers.get(sdInstanceId)?.isProcessing ? "✅ " : "☠️ ",
|
||||
fmt`${bold(sdInstance.name || sdInstanceId)} `,
|
||||
`(max ${(sdInstance.maxResolution / 1000000).toFixed(1)} Mpx) `,
|
||||
"\n",
|
||||
]),
|
||||
|
|
|
@ -20,7 +20,6 @@ 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 });
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -43,7 +42,7 @@ 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 queue. Try again later.`, {
|
||||
reply_to_message_id: ctx.message?.message_id,
|
||||
});
|
||||
return;
|
||||
|
|
28
deno.json
28
deno.json
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"tasks": {
|
||||
"start": "deno run --unstable --allow-env --allow-read --allow-write --allow-net main.ts"
|
||||
"start": "deno run --unstable --allow-env --allow-read --allow-write --allow-net main.ts",
|
||||
"check": "deno check --unstable main.ts && deno check --unstable ui/main.tsx"
|
||||
},
|
||||
"compilerOptions": {
|
||||
"jsx": "react"
|
||||
|
@ -15,24 +16,37 @@
|
|||
"std/fmt/": "https://deno.land/std@0.202.0/fmt/",
|
||||
"std/collections/": "https://deno.land/std@0.202.0/collections/",
|
||||
"std/encoding/": "https://deno.land/std@0.202.0/encoding/",
|
||||
"std/http/": "https://deno.land/std@0.202.0/http/",
|
||||
|
||||
"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",
|
||||
"indexed_kv": "https://deno.land/x/indexed_kv@v0.5.0/mod.ts",
|
||||
"kvmq": "https://deno.land/x/kvmq@v0.3.0/mod.ts",
|
||||
"kvfs": "https://deno.land/x/kvfs@v0.1.0/mod.ts",
|
||||
"swc": "https://deno.land/x/swc@0.2.1/mod.ts",
|
||||
"serve_spa": "https://deno.land/x/serve_spa@v0.2.0/mod.ts",
|
||||
"reroute": "https://deno.land/x/reroute@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",
|
||||
"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",
|
||||
"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",
|
||||
"@trpc/server": "https://esm.sh/@trpc/server@10.38.4",
|
||||
"@trpc/server/": "https://esm.sh/@trpc/server@10.38.4/"
|
||||
"t_rest/server": "https://esm.sh/ty-rest@0.4.0/server?dev",
|
||||
|
||||
"t_rest/client": "https://esm.sh/ty-rest@0.4.0/client?dev",
|
||||
"react": "https://esm.sh/react@18.2.0?dev",
|
||||
"react-dom/client": "https://esm.sh/react-dom@18.2.0/client?dev",
|
||||
"react-router-dom": "https://esm.sh/react-router-dom@6.16.0?dev",
|
||||
"swr": "https://esm.sh/swr@2.2.4?dev",
|
||||
"swr/mutation": "https://esm.sh/swr@2.2.4/mutation?dev",
|
||||
"use-local-storage": "https://esm.sh/use-local-storage@3.0.0?dev",
|
||||
"@twind/core": "https://esm.sh/@twind/core@1.1.3?dev",
|
||||
"@twind/preset-tailwind": "https://esm.sh/@twind/preset-tailwind@1.1.4?dev",
|
||||
"react-flip-move": "https://esm.sh/react-flip-move@3.0.5?dev"
|
||||
}
|
||||
}
|
||||
|
|
8
main.ts
8
main.ts
|
@ -1,17 +1,17 @@
|
|||
import "std/dotenv/load.ts";
|
||||
import { ConsoleHandler } from "std/log/handlers.ts";
|
||||
import { setup } from "std/log/mod.ts";
|
||||
import { serveApi } from "./api/mod.ts";
|
||||
import { serveUi } from "./api/mod.ts";
|
||||
import { runAllTasks } from "./app/mod.ts";
|
||||
import { bot } from "./bot/mod.ts";
|
||||
|
||||
// setup logging
|
||||
setup({
|
||||
handlers: {
|
||||
console: new ConsoleHandler("DEBUG"),
|
||||
console: new ConsoleHandler("INFO"),
|
||||
},
|
||||
loggers: {
|
||||
default: { level: "DEBUG", handlers: ["console"] },
|
||||
default: { level: "INFO", handlers: ["console"] },
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -19,5 +19,5 @@ setup({
|
|||
await Promise.all([
|
||||
bot.start(),
|
||||
runAllTasks(),
|
||||
serveApi(),
|
||||
serveUi(),
|
||||
]);
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
import React, { useEffect } from "react";
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import useLocalStorage from "use-local-storage";
|
||||
import { AppHeader } from "./AppHeader.tsx";
|
||||
import { QueuePage } from "./QueuePage.tsx";
|
||||
import { SettingsPage } from "./SettingsPage.tsx";
|
||||
import { fetchApi, handleResponse } from "./apiClient.tsx";
|
||||
import { HomePage } from "./HomePage.tsx";
|
||||
|
||||
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", "POST", {}).then(handleResponse).then((session) => {
|
||||
console.log("Initialized session", session.id);
|
||||
setSessionId(session.id);
|
||||
});
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppHeader
|
||||
className="self-stretch"
|
||||
sessionId={sessionId}
|
||||
onLogOut={() => setSessionId("")}
|
||||
/>
|
||||
<div className="self-center w-full max-w-screen-md flex flex-col items-stretch gap-4 p-4">
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/queue" element={<QueuePage />} />
|
||||
<Route path="/settings" element={<SettingsPage sessionId={sessionId} />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,110 @@
|
|||
import { cx } from "@twind/core";
|
||||
import React, { ReactNode } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import useSWR from "swr";
|
||||
import { fetchApi, handleResponse } from "./apiClient.tsx";
|
||||
|
||||
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; onLogOut: () => void },
|
||||
) {
|
||||
const { className, sessionId, onLogOut } = props;
|
||||
|
||||
const session = useSWR(
|
||||
sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
{ onError: () => onLogOut() },
|
||||
);
|
||||
|
||||
const user = useSWR(
|
||||
session.data?.userId
|
||||
? ["users/{userId}", "GET", { params: { userId: String(session.data.userId) } }] as const
|
||||
: null,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
|
||||
const userPhoto = useSWR(
|
||||
session.data?.userId
|
||||
? ["users/{userId}/photo", "GET", {
|
||||
params: { userId: String(session.data.userId) },
|
||||
}] as const
|
||||
: null,
|
||||
(args) => fetchApi(...args).then(handleResponse).then((blob) => URL.createObjectURL(blob)),
|
||||
);
|
||||
|
||||
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="/queue">
|
||||
Queue
|
||||
</NavTab>
|
||||
<NavTab to="/settings">
|
||||
Settings
|
||||
</NavTab>
|
||||
</nav>
|
||||
|
||||
{/* loading indicator */}
|
||||
{session.isLoading || user.isLoading ? <div className="spinner" /> : null}
|
||||
|
||||
{/* user avatar */}
|
||||
{user.data
|
||||
? userPhoto.data
|
||||
? (
|
||||
<img
|
||||
src={userPhoto.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">
|
||||
{user.data.first_name.at(0)?.toUpperCase()}
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
|
||||
{/* login/logout button */}
|
||||
{!session.isLoading && !user.isLoading && sessionId
|
||||
? (
|
||||
user.data
|
||||
? (
|
||||
<button className="button-outlined" onClick={() => onLogOut()}>
|
||||
Logout
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<a
|
||||
className="button-filled"
|
||||
href={`https://t.me/ErisTheBot?start=${sessionId}`}
|
||||
target="_blank"
|
||||
>
|
||||
Login
|
||||
</a>
|
||||
)
|
||||
)
|
||||
: null}
|
||||
</header>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
import { cx } from "@twind/core";
|
||||
import React from "react";
|
||||
|
||||
function CounterDigit(props: { value: number; transitionDurationMs?: number }) {
|
||||
const { value, transitionDurationMs = 1000 } = props;
|
||||
const rads = -(Math.floor(value) % 1_000_000) * 2 * Math.PI * 0.1;
|
||||
|
||||
return (
|
||||
<span className="w-[1em] h-[1.3em] 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 1.8em`,
|
||||
transitionDuration: `${transitionDurationMs}ms`,
|
||||
}}
|
||||
>
|
||||
{i}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Counter(props: {
|
||||
value: number;
|
||||
digits: number;
|
||||
transitionDurationMs?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const { value, digits, transitionDurationMs, className } = props;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cx(
|
||||
"inline-flex 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
|
||||
? (
|
||||
<span
|
||||
key={`spacer${i}`}
|
||||
className="border-l-1 mx-[0.1em] border-zinc-200 dark:border-zinc-700"
|
||||
/>
|
||||
)
|
||||
: null,
|
||||
<CounterDigit
|
||||
key={`digit${i}`}
|
||||
value={value / 10 ** i}
|
||||
transitionDurationMs={transitionDurationMs}
|
||||
/>,
|
||||
])
|
||||
.reverse()}
|
||||
</span>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
import React from "react";
|
||||
import { fetchApi, handleResponse } from "./apiClient.tsx";
|
||||
import useSWR from "swr";
|
||||
import { eachDayOfInterval, endOfMonth, startOfMonth, subMonths } from "date-fns";
|
||||
import { UTCDateMini } from "@date-fns/utc";
|
||||
import { Counter } from "./Counter.tsx";
|
||||
|
||||
export function HomePage() {
|
||||
const globalStats = useSWR(
|
||||
["stats", "GET", {}] 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>Pixels painted</span>
|
||||
<Counter
|
||||
className="font-bold text-zinc-700 dark:text-zinc-300"
|
||||
value={globalStats.data?.pixelCount ?? 0}
|
||||
digits={12}
|
||||
transitionDurationMs={3_000}
|
||||
/>
|
||||
</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>Images generated</span>
|
||||
<Counter
|
||||
className="font-bold text-zinc-700 dark:text-zinc-300"
|
||||
value={globalStats.data?.imageCount ?? 0}
|
||||
digits={9}
|
||||
transitionDurationMs={1_500}
|
||||
/>
|
||||
</p>
|
||||
<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={globalStats.data?.userCount ?? 0}
|
||||
digits={6}
|
||||
transitionDurationMs={1_500}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
import { cx } from "@twind/core";
|
||||
import React from "react";
|
||||
|
||||
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,50 @@
|
|||
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.tsx";
|
||||
|
||||
export function QueuePage() {
|
||||
const jobs = useSWR(
|
||||
["jobs", "GET", {}] 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-xl"
|
||||
enterAnimation="fade"
|
||||
leaveAnimation="fade"
|
||||
>
|
||||
{jobs.data?.map((job) => (
|
||||
<li
|
||||
className="flex items-baseline gap-2 bg-zinc-100 dark:bg-zinc-700 px-2 py-1 rounded-xl"
|
||||
key={job.id.join("/")}
|
||||
>
|
||||
<span className="">
|
||||
{job.place}.
|
||||
</span>
|
||||
<span>{getFlagEmoji(job.state.from.language_code)}</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.sdInstanceId}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</FlipMove>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,275 @@
|
|||
import { cx } from "@twind/core";
|
||||
import React, { ReactNode, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import { fetchApi, handleResponse } from "./apiClient.tsx";
|
||||
|
||||
export function SettingsPage(props: { sessionId: string }) {
|
||||
const { sessionId } = props;
|
||||
const session = useSWR(
|
||||
sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
const user = useSWR(
|
||||
session.data?.userId
|
||||
? ["users/{userId}", "GET", { params: { userId: String(session.data.userId) } }] as const
|
||||
: null,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
const params = useSWR(
|
||||
["settings/params", "GET", {}] as const,
|
||||
(args) => fetchApi(...args).then(handleResponse),
|
||||
);
|
||||
const [changedParams, setChangedParams] = useState<Partial<typeof params.data>>({});
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col items-stretch gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
params.mutate(() =>
|
||||
fetchApi("settings/params", "PATCH", {
|
||||
query: { sessionId },
|
||||
body: { type: "application/json", data: changedParams ?? {} },
|
||||
}).then(handleResponse)
|
||||
)
|
||||
.then(() => setChangedParams({}))
|
||||
.catch((e) => setError(String(e)));
|
||||
}}
|
||||
>
|
||||
<label className="flex flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
Negative prompt {changedParams?.negative_prompt != null ? "(Changed)" : ""}
|
||||
</span>
|
||||
<textarea
|
||||
className="input-text"
|
||||
disabled={params.isLoading || !user.data?.isAdmin}
|
||||
value={changedParams?.negative_prompt ??
|
||||
params.data?.negative_prompt ??
|
||||
""}
|
||||
onChange={(e) =>
|
||||
setChangedParams((params) => ({
|
||||
...params,
|
||||
negative_prompt: e.target.value,
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
Sampler {changedParams?.sampler_name != null ? "(Changed)" : ""}
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
disabled={params.isLoading || !user.data?.isAdmin}
|
||||
value={changedParams?.sampler_name ??
|
||||
params.data?.sampler_name ??
|
||||
""}
|
||||
onChange={(e) =>
|
||||
setChangedParams((params) => ({
|
||||
...params,
|
||||
sampler_name: e.target.value,
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
Steps {changedParams?.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={params.isLoading || !user.data?.isAdmin}
|
||||
value={changedParams?.steps ??
|
||||
params.data?.steps ??
|
||||
0}
|
||||
onChange={(e) =>
|
||||
setChangedParams((params) => ({
|
||||
...params,
|
||||
steps: Number(e.target.value),
|
||||
}))}
|
||||
/>
|
||||
<input
|
||||
className="input-range flex-grow"
|
||||
type="range"
|
||||
min={5}
|
||||
max={50}
|
||||
step={5}
|
||||
disabled={params.isLoading || !user.data?.isAdmin}
|
||||
value={changedParams?.steps ??
|
||||
params.data?.steps ??
|
||||
0}
|
||||
onChange={(e) =>
|
||||
setChangedParams((params) => ({
|
||||
...params,
|
||||
steps: Number(e.target.value),
|
||||
}))}
|
||||
/>
|
||||
</span>
|
||||
span
|
||||
</label>
|
||||
<label className="flex flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
Detail {changedParams?.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={params.isLoading || !user.data?.isAdmin}
|
||||
value={changedParams?.cfg_scale ??
|
||||
params.data?.cfg_scale ??
|
||||
0}
|
||||
onChange={(e) =>
|
||||
setChangedParams((params) => ({
|
||||
...params,
|
||||
cfg_scale: Number(e.target.value),
|
||||
}))}
|
||||
/>
|
||||
<input
|
||||
className="input-range flex-grow"
|
||||
type="range"
|
||||
min={1}
|
||||
max={20}
|
||||
step={1}
|
||||
disabled={params.isLoading || !user.data?.isAdmin}
|
||||
value={changedParams?.cfg_scale ??
|
||||
params.data?.cfg_scale ??
|
||||
0}
|
||||
onChange={(e) =>
|
||||
setChangedParams((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 {changedParams?.width != null ? "(Changed)" : ""}
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
type="number"
|
||||
min={64}
|
||||
max={2048}
|
||||
step={64}
|
||||
disabled={params.isLoading || !user.data?.isAdmin}
|
||||
value={changedParams?.width ??
|
||||
params.data?.width ??
|
||||
0}
|
||||
onChange={(e) =>
|
||||
setChangedParams((params) => ({
|
||||
...params,
|
||||
width: Number(e.target.value),
|
||||
}))}
|
||||
/>
|
||||
<input
|
||||
className="input-range"
|
||||
type="range"
|
||||
min={64}
|
||||
max={2048}
|
||||
step={64}
|
||||
disabled={params.isLoading || !user.data?.isAdmin}
|
||||
value={changedParams?.width ??
|
||||
params.data?.width ??
|
||||
0}
|
||||
onChange={(e) =>
|
||||
setChangedParams((params) => ({
|
||||
...params,
|
||||
width: Number(e.target.value),
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-1 flex-col items-stretch gap-1">
|
||||
<span className="text-sm">
|
||||
Height {changedParams?.height != null ? "(Changed)" : ""}
|
||||
</span>
|
||||
<input
|
||||
className="input-text"
|
||||
type="number"
|
||||
min={64}
|
||||
max={2048}
|
||||
step={64}
|
||||
disabled={params.isLoading || !user.data?.isAdmin}
|
||||
value={changedParams?.height ??
|
||||
params.data?.height ??
|
||||
0}
|
||||
onChange={(e) =>
|
||||
setChangedParams((params) => ({
|
||||
...params,
|
||||
height: Number(e.target.value),
|
||||
}))}
|
||||
/>
|
||||
<input
|
||||
className="input-range"
|
||||
type="range"
|
||||
min={64}
|
||||
max={2048}
|
||||
step={64}
|
||||
disabled={params.isLoading || !user.data?.isAdmin}
|
||||
value={changedParams?.height ??
|
||||
params.data?.height ??
|
||||
0}
|
||||
onChange={(e) =>
|
||||
setChangedParams((params) => ({
|
||||
...params,
|
||||
height: Number(e.target.value),
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{error ? <Alert onClose={() => setError(undefined)}>{error}</Alert> : null}
|
||||
{params.error ? <Alert>{params.error.message}</Alert> : null}
|
||||
<div className="flex gap-2 items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className={cx("button-outlined ripple", params.isLoading && "bg-stripes")}
|
||||
disabled={params.isLoading || !user.data?.isAdmin ||
|
||||
Object.keys(changedParams ?? {}).length === 0}
|
||||
onClick={() => setChangedParams({})}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={cx("button-filled ripple", params.isLoading && "bg-stripes")}
|
||||
disabled={params.isLoading || !user.data?.isAdmin ||
|
||||
Object.keys(changedParams ?? {}).length === 0}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Alert(props: { children: ReactNode; onClose?: () => void }) {
|
||||
const { children, onClose } = props;
|
||||
return (
|
||||
<p
|
||||
role="alert"
|
||||
className="px-4 py-2 flex gap-2 items-center bg-red-500 text-white rounded-sm shadow-md"
|
||||
>
|
||||
<span className="flex-grow">{children}</span>
|
||||
{onClose
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="button-ghost"
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
)
|
||||
: null}
|
||||
</p>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import { createFetcher, Output } from "t_rest/client";
|
||||
import { ApiHandler } from "../api/serveApi.ts";
|
||||
|
||||
export const fetchApi = createFetcher<ApiHandler>({
|
||||
baseUrl: `${location.origin}/api/`,
|
||||
});
|
||||
|
||||
export function handleResponse<T extends Output>(
|
||||
response: T,
|
||||
): (T & { status: 200 })["body"]["data"] {
|
||||
if (response.status !== 200) {
|
||||
throw new Error(String(response.body.data));
|
||||
}
|
||||
return response.body.data;
|
||||
}
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
@ -0,0 +1,12 @@
|
|||
/// <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";
|
||||
|
||||
createRoot(document.body).render(
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>,
|
||||
);
|
|
@ -0,0 +1,141 @@
|
|||
import { defineConfig, injectGlobal, install } from "@twind/core";
|
||||
import presetTailwind from "@twind/preset-tailwind";
|
||||
|
||||
const twConfig = defineConfig({
|
||||
presets: [presetTailwind()],
|
||||
});
|
||||
|
||||
install(twConfig);
|
||||
|
||||
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-stripes-scroll 0.5s linear infinite;
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
@keyframes bg-stripes-scroll {
|
||||
0% {
|
||||
background-position: 0 40px;
|
||||
}
|
||||
100% {
|
||||
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;
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.3s ease-out forwards;
|
||||
}
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pop-in {
|
||||
animation: pop-in 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
|
||||
}
|
||||
@keyframes pop-in {
|
||||
0% {
|
||||
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;
|
||||
}
|
||||
|
||||
.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 focus: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 animate-pop-in backdrop:animate-fade-in overflow-hidden overflow-y-auto rounded-md
|
||||
bg-zinc-100 text-zinc-900 shadow-lg backdrop:bg-black/20 dark:bg-zinc-800 dark:text-zinc-100;
|
||||
}
|
||||
}
|
||||
`;
|
|
@ -42,6 +42,7 @@ const languageToFlagMap: Record<string, string> = {
|
|||
"id": "🇮🇩", // indonesian - indonesia
|
||||
"is": "🇮🇸", // icelandic - iceland
|
||||
"lb": "🇱🇺", // luxembourgish - luxembourg
|
||||
"ca": "🇪🇸", // catalan - spain
|
||||
};
|
||||
|
||||
export function getFlagEmoji(languageCode?: string): string | undefined {
|
||||
|
|
Loading…
Reference in New Issue