feat: admin ui

This commit is contained in:
pinks 2023-10-05 11:00:51 +02:00
parent adebd34db8
commit 99540d4035
28 changed files with 956 additions and 223 deletions

2
.gitignore vendored
View File

@ -1,4 +1,4 @@
.env
app.db*
app*.db*
deno.lock
updateConfig.ts

14
api/api.ts Normal file
View File

@ -0,0 +1,14 @@
import { Api } from "t_rest/server";
import { jobsRoute } from "./jobsRoute.ts";
import { sessionsRoute } from "./sessionsRoute.ts";
import { usersRoute } from "./usersRoute.ts";
import { paramsRoute } from "./paramsRoute.ts";
export const api = new Api({
"jobs": jobsRoute,
"sessions": sessionsRoute,
"users": usersRoute,
"settings/params": paramsRoute,
});
export type ErisApi = typeof api;

13
api/jobsRoute.ts Normal file
View File

@ -0,0 +1,13 @@
import { Endpoint, Route } from "t_rest/server";
import { generationQueue } from "../app/generationQueue.ts";
export const jobsRoute = {
GET: new Endpoint(
{ query: null, body: null },
async () => ({
status: 200,
type: "application/json",
body: await generationQueue.getAllJobs(),
}),
),
} satisfies Route;

View File

@ -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 { api } from "./api.ts";
const t = initTRPC.create();
export const appRouter = t.router({
ping: t.procedure.query(() => "pong"),
getAllGenerationJobs: t.procedure.query(() => {
return generationQueue.getAllJobs();
export async function serveUi() {
const server = Deno.serve({ port: 5999 }, (request) =>
route(request, {
"/api/*": (request) => api.serve(request),
"/*": (request) =>
serveSpa(request, {
fsRoot: new URL("../ui/", import.meta.url).pathname,
indexFallback: true,
importMapFile: "../deno.json",
aliasMap: {
"/utils/*": "../utils/",
},
quiet: true,
}),
});
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",
},
});
return new Response(result.code, {
status: 200,
headers: {
"Content-Type": "application/javascript",
},
});
}
}
return serveDir(request, {
fsRoot: webuiRoot.pathname,
});
});
}));
await server.finished;
}

54
api/paramsRoute.ts Normal file
View File

@ -0,0 +1,54 @@
import { deepMerge } from "std/collections/deep_merge.ts";
import { getLogger } from "std/log/mod.ts";
import { Endpoint, Route } 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 = {
GET: new Endpoint(
{ query: null, body: null },
async () => {
const config = await getConfig();
return {
status: 200,
type: "application/json",
body: config?.defaultParams,
};
},
),
PATCH: new Endpoint(
{
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, type: "text/plain", body: "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, type: "text/plain", body: "Must have a username" };
}
const config = await getConfig();
if (!config?.adminUsernames?.includes(userName)) {
return { status: 403, type: "text/plain", body: "Must be an admin" };
}
logger().info(`User ${userName} updated default params: ${JSON.stringify(body)}`);
const defaultParams = deepMerge(config.defaultParams ?? {}, body);
await setConfig({ defaultParams });
return { status: 200, type: "application/json", body: config.defaultParams };
},
),
} satisfies Route;

32
api/sessionsRoute.ts Normal file
View File

@ -0,0 +1,32 @@
// deno-lint-ignore-file require-await
import { Endpoint, Route } from "t_rest/server";
import { ulid } from "ulid";
export const sessions = new Map<string, Session>();
export interface Session {
userId?: number;
}
export const sessionsRoute = {
POST: new Endpoint(
{ query: null, body: null },
async () => {
const id = ulid();
const session: Session = {};
sessions.set(id, session);
return { status: 200, type: "application/json", body: { id, ...session } };
},
),
GET: new Endpoint(
{ query: { sessionId: { type: "string" } }, body: null },
async ({ query }) => {
const id = query.sessionId;
const session = sessions.get(id);
if (!session) {
return { status: 401, type: "text/plain", body: "Session not found" };
}
return { status: 200, type: "application/json", body: { id, ...session } };
},
),
} satisfies Route;

36
api/usersRoute.ts Normal file
View File

@ -0,0 +1,36 @@
import { encode } from "std/encoding/base64.ts";
import { Endpoint, Route } from "t_rest/server";
import { getConfig } from "../app/config.ts";
import { bot } from "../bot/mod.ts";
export const usersRoute = {
GET: new Endpoint(
{ query: { userId: { type: "number" } }, body: null },
async ({ query }) => {
const chat = await bot.api.getChat(query.userId);
if (chat.type !== "private") {
throw new Error("Chat is not private");
}
const photoData = chat.photo?.small_file_id
? encode(
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;
const config = await getConfig();
const isAdmin = config?.adminUsernames?.includes(chat.username);
return {
status: 200,
type: "application/json",
body: {
...chat,
photoData,
isAdmin,
},
};
},
),
} satisfies Route;

View File

@ -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>
);
}

View File

@ -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,
export const configSchema = {
type: "object",
properties: {
adminUsernames: { type: "array", items: { type: "string" } },
pausedReason: { type: ["string", "null"] },
maxUserJobs: { type: "number" },
maxJobs: { type: "number" },
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",
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: [
{
id: "local",
api: { url: Deno.env.get("SD_API_URL") ?? "http://127.0.0.1:7860/" },
maxResolution: 1024 * 1024,
},
],
});
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);
}

View File

@ -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();
}
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,19 @@ 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 });
@ -137,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,
@ -229,7 +233,7 @@ async function processGenerationJob(
state.replyMessage.message_id,
`Generating your prompt now... ${
(progressResponse.data.progress * 100).toFixed(0)
}% using ${sdInstance.name || sdInstance.id}`,
}% using ${sdInstance.name || sdInstanceId}`,
{ maxAttempts: 1 },
).catch(() => undefined);
}
@ -264,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,

View File

@ -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

View File

@ -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,
});
}

View File

@ -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;

View File

@ -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");
});

View File

@ -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",
]),

View File

@ -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;

View File

@ -15,13 +15,14 @@
"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",
"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.1.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",
@ -32,7 +33,17 @@
"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.2.1/server?dev",
"t_rest/client": "https://esm.sh/ty-rest@0.2.1/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"
}
}

View File

@ -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(),
]);

43
ui/App.tsx Normal file
View File

@ -0,0 +1,43 @@
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 { apiClient, handleResponse } from "./apiClient.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) {
apiClient.fetch("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>
</>
);
}
function HomePage() {
return <h1>hi</h1>;
}

101
ui/AppHeader.tsx Normal file
View File

@ -0,0 +1,101 @@
import { cx } from "@twind/core";
import React, { ReactNode } from "react";
import { NavLink } from "react-router-dom";
import useSWR from "swr";
import { apiClient, 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", "GET", { query: { sessionId } }] as const : null,
(args) => apiClient.fetch(...args).then(handleResponse),
{ onError: () => onLogOut() },
);
const user = useSWR(
session.data?.userId
? ["users", "GET", { query: { userId: session.data.userId } }] as const
: null,
(args) => apiClient.fetch(...args).then(handleResponse),
);
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
? user.data.photoData
? (
<img
src={`data:image/jpeg;base64,${user.data.photoData}`}
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>
);
}

27
ui/Progress.tsx Normal file
View File

@ -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>
);
}

50
ui/QueuePage.tsx Normal file
View File

@ -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 { apiClient, handleResponse } from "./apiClient.tsx";
export function QueuePage() {
const jobs = useSWR(
["jobs", "GET", {}] as const,
(args) => apiClient.fetch(...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>
);
}

258
ui/SettingsPage.tsx Normal file
View File

@ -0,0 +1,258 @@
import { cx } from "@twind/core";
import React, { ReactNode, useState } from "react";
import useSWR from "swr";
import { apiClient, handleResponse } from "./apiClient.tsx";
export function SettingsPage(props: { sessionId: string }) {
const { sessionId } = props;
const session = useSWR(
["sessions", "GET", { query: { sessionId } }] as const,
(args) => apiClient.fetch(...args).then(handleResponse),
);
const user = useSWR(
session.data?.userId
? ["users", "GET", { query: { userId: session.data.userId } }] as const
: null,
(args) => apiClient.fetch(...args).then(handleResponse),
);
const params = useSWR(
["settings/params", "GET", {}] as const,
(args) => apiClient.fetch(...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(() =>
apiClient.fetch("settings/params", "PATCH", {
query: { sessionId },
type: "application/json",
body: 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">
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>
</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>
);
}

12
ui/apiClient.tsx Normal file
View File

@ -0,0 +1,12 @@
import { Client } from "t_rest/client";
import { ApiResponse } from "t_rest/server";
import { ErisApi } from "../api/api.ts";
export const apiClient = new Client<ErisApi>(`${location.origin}/api/`);
export function handleResponse<T extends ApiResponse>(response: T): (T & { status: 200 })["body"] {
if (response.status !== 200) {
throw new Error(String(response.body));
}
return response.body;
}

View File

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

12
ui/main.tsx Normal file
View File

@ -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>,
);

141
ui/twind.ts Normal file
View File

@ -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;
}
}
`;