diff --git a/.gitignore b/.gitignore index d5d7057..2138e1b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ .env -app.db* +app*.db* deno.lock updateConfig.ts diff --git a/api/api.ts b/api/api.ts new file mode 100644 index 0000000..a13f176 --- /dev/null +++ b/api/api.ts @@ -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; diff --git a/api/jobsRoute.ts b/api/jobsRoute.ts new file mode 100644 index 0000000..6e49425 --- /dev/null +++ b/api/jobsRoute.ts @@ -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; diff --git a/api/mod.ts b/api/mod.ts index 191c8bd..5e506c7 100644 --- a/api/mod.ts +++ b/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 { 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 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) => api.serve(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, - }); - }); + quiet: true, + }), + })); await server.finished; } diff --git a/api/paramsRoute.ts b/api/paramsRoute.ts new file mode 100644 index 0000000..0b13c53 --- /dev/null +++ b/api/paramsRoute.ts @@ -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; diff --git a/api/sessionsRoute.ts b/api/sessionsRoute.ts new file mode 100644 index 0000000..1276626 --- /dev/null +++ b/api/sessionsRoute.ts @@ -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(); + +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; diff --git a/api/usersRoute.ts b/api/usersRoute.ts new file mode 100644 index 0000000..61d38d5 --- /dev/null +++ b/api/usersRoute.ts @@ -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; diff --git a/api/webui/main.tsx b/api/webui/main.tsx deleted file mode 100644 index 84be409..0000000 --- a/api/webui/main.tsx +++ /dev/null @@ -1,78 +0,0 @@ -/// -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(); - -const trpcClient = trpc.createClient({ - links: [ - httpBatchLink({ - url: "/api/trpc", - }), - ], -}); - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - suspense: true, - }, - }, -}); - -createRoot(document.body).render( - - - - - , -); - -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 ( - - {jobs.map((job) => ( -
  • - {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} -
  • - ))} -
    - ); -} diff --git a/app/config.ts b/app/config.ts index d1d0197..c60c396 100644 --- a/app/config.ts +++ b/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 { - const configEntry = await db.get(["config"]); - return configEntry.value ?? getDefaultConfig(); +export type Config = jsonType; + +export async function getConfig(): Promise { + const configEntry = await db.get(["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 { +export async function setConfig(newConfig: Partial): Promise { + 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); } diff --git a/app/generationQueue.ts b/app/generationQueue.ts index b5dc91c..32ea287 100644 --- a/app/generationQueue.ts +++ b/app/generationQueue.ts @@ -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({ 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,19 @@ export async function processGenerationQueue() { async function processGenerationJob( state: GenerationJob, updateJob: (job: Partial>) => Promise, - 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({ 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, diff --git a/bot/broadcastCommand.ts b/bot/broadcastCommand.ts index 0712472..34a6318 100644 --- a/bot/broadcastCommand.ts +++ b/bot/broadcastCommand.ts @@ -39,6 +39,7 @@ export async function broadcastCommand(ctx: CommandContext) { const replyMessage = await ctx.replyFmt(getMessage(), { reply_to_message_id: ctx.message?.message_id, + allow_sending_without_reply: true, }); // send message to each user diff --git a/bot/cancelCommand.ts b/bot/cancelCommand.ts index 657992d..4077212 100644 --- a/bot/cancelCommand.ts +++ b/bot/cancelCommand.ts @@ -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, }); } diff --git a/bot/img2imgCommand.ts b/bot/img2imgCommand.ts index 16a27ac..e837191 100644 --- a/bot/img2imgCommand.ts +++ b/bot/img2imgCommand.ts @@ -28,9 +28,6 @@ async function img2img( state: QuestionState = {}, ): Promise { 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; diff --git a/bot/mod.ts b/bot/mod.ts index 3239c47..c70e6a9 100644 --- a/bot/mod.ts +++ b/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"); }); diff --git a/bot/queueCommand.ts b/bot/queueCommand.ts index 61c16ee..ff0d0a8 100644 --- a/bot/queueCommand.ts +++ b/bot/queueCommand.ts @@ -44,9 +44,9 @@ export async function queueCommand(ctx: CommandContext) { ]) : ["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", ]), diff --git a/bot/txt2imgCommand.ts b/bot/txt2imgCommand.ts index 459e4c0..e63fb0a 100644 --- a/bot/txt2imgCommand.ts +++ b/bot/txt2imgCommand.ts @@ -20,7 +20,6 @@ export async function txt2imgCommand(ctx: CommandContext) { async function txt2img(ctx: ErisContext, match: string, includeRepliedTo: boolean): Promise { 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; diff --git a/deno.json b/deno.json index e5a4494..21d9e00 100644 --- a/deno.json +++ b/deno.json @@ -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" } } diff --git a/main.ts b/main.ts index 261baec..dc8f551 100644 --- a/main.ts +++ b/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(), ]); diff --git a/ui/App.tsx b/ui/App.tsx new file mode 100644 index 0000000..0b72901 --- /dev/null +++ b/ui/App.tsx @@ -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 ( + <> + setSessionId("")} + /> +
    + + } /> + } /> + } /> + +
    + + ); +} + +function HomePage() { + return

    hi

    ; +} diff --git a/ui/AppHeader.tsx b/ui/AppHeader.tsx new file mode 100644 index 0000000..4b6d6f9 --- /dev/null +++ b/ui/AppHeader.tsx @@ -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 ( + cx("tab", isActive && "tab-active")} + to={props.to} + > + {props.children} + + ); +} + +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 ( +
    + {/* logo */} + logo + + {/* tabs */} + + + {/* loading indicator */} + {session.isLoading || user.isLoading ?
    : null} + + {/* user avatar */} + {user.data + ? user.data.photoData + ? ( + avatar + ) + : ( +
    + {user.data.first_name.at(0)?.toUpperCase()} +
    + ) + : null} + + {/* login/logout button */} + {!session.isLoading && !user.isLoading && sessionId + ? ( + user.data + ? ( + + ) + : ( + + Login + + ) + ) + : null} +
    + ); +} diff --git a/ui/Progress.tsx b/ui/Progress.tsx new file mode 100644 index 0000000..5ce6911 --- /dev/null +++ b/ui/Progress.tsx @@ -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 ( +
    +
    + {(value * 100).toFixed(0)}% +
    +
    +
    +
    + ); +} diff --git a/ui/QueuePage.tsx b/ui/QueuePage.tsx new file mode 100644 index 0000000..65b1d5b --- /dev/null +++ b/ui/QueuePage.tsx @@ -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 ( + + {jobs.data?.map((job) => ( +
  • + + {job.place}. + + {getFlagEmoji(job.state.from.language_code)} + {job.state.from.first_name} {job.state.from.last_name} + {job.state.from.username + ? ( + + @{job.state.from.username} + + ) + : null} + + {job.state.progress != null && + } + + + {job.state.sdInstanceId} + +
  • + ))} +
    + ); +} diff --git a/ui/SettingsPage.tsx b/ui/SettingsPage.tsx new file mode 100644 index 0000000..a687c7d --- /dev/null +++ b/ui/SettingsPage.tsx @@ -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>({}); + const [error, setError] = useState(); + + return ( +
    { + 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))); + }} + > +