Compare commits

..

2 Commits

Author SHA1 Message Date
pinks 4f2371fa8b exactOptionalPropertyTypes 2023-10-19 23:37:03 +02:00
pinks f020499f4d dialog animations 2023-10-19 03:18:56 +02:00
20 changed files with 169 additions and 96 deletions

View File

@ -1,7 +1,7 @@
import { route } from "reroute";
import { serveSpa } from "serve_spa";
import { serveApi } from "./serveApi.ts";
import { fromFileUrl } from "std/path/mod.ts"
import { fromFileUrl } from "std/path/mod.ts";
export async function serveUi() {
const server = Deno.serve({ port: 5999 }, (request) =>

View File

@ -4,7 +4,7 @@ import { ulid } from "ulid";
export const sessions = new Map<string, Session>();
export interface Session {
userId?: number;
userId?: number | undefined;
}
export const sessionsRoute = createPathFilter({
@ -24,7 +24,7 @@ export const sessionsRoute = createPathFilter({
GET: createEndpoint(
{ query: null, body: null },
async ({ params }) => {
const id = params.sessionId;
const id = params.sessionId!;
const session = sessions.get(id);
if (!session) {
return { status: 401, body: { type: "text/plain", data: "Session not found" } };

View File

@ -7,7 +7,7 @@ export const usersRoute = createPathFilter({
GET: createEndpoint(
{ query: null, body: null },
async ({ params }) => {
const chat = await bot.api.getChat(params.userId);
const chat = await bot.api.getChat(params.userId!);
if (chat.type !== "private") {
throw new Error("Chat is not private");
}
@ -36,7 +36,7 @@ export const usersRoute = createPathFilter({
GET: createEndpoint(
{ query: null, body: null },
async ({ params }) => {
const chat = await bot.api.getChat(params.userId);
const chat = await bot.api.getChat(params.userId!);
if (chat.type !== "private") {
throw new Error("Chat is not private");
}

View File

@ -12,6 +12,7 @@ import {
workerInstanceStore,
} from "../app/workerInstanceStore.ts";
import { getAuthHeader } from "../utils/getAuthHeader.ts";
import { omitUndef } from "../utils/omitUndef.ts";
import { withUser } from "./withUser.ts";
export type WorkerData = Omit<WorkerInstance, "sdUrl" | "sdAuth"> & {
@ -105,7 +106,7 @@ export const workersRoute = createPathFilter({
GET: createEndpoint(
{ query: null, body: null },
async ({ params }) => {
const workerInstance = await workerInstanceStore.getById(params.workerId);
const workerInstance = await workerInstanceStore.getById(params.workerId!);
if (!workerInstance) {
return { status: 404, body: { type: "text/plain", data: `Worker not found` } };
}
@ -126,7 +127,7 @@ export const workersRoute = createPathFilter({
},
},
async ({ params, query, body }) => {
const workerInstance = await workerInstanceStore.getById(params.workerId);
const workerInstance = await workerInstanceStore.getById(params.workerId!);
if (!workerInstance) {
return { status: 404, body: { type: "text/plain", data: `Worker not found` } };
}
@ -136,7 +137,7 @@ export const workersRoute = createPathFilter({
JSON.stringify(body.data)
}`,
);
await workerInstance.update(body.data);
await workerInstance.update(omitUndef(body.data));
return {
status: 200,
body: { type: "application/json", data: await getWorkerData(workerInstance) },
@ -152,7 +153,7 @@ export const workersRoute = createPathFilter({
body: null,
},
async ({ params, query }) => {
const workerInstance = await workerInstanceStore.getById(params.workerId);
const workerInstance = await workerInstanceStore.getById(params.workerId!);
if (!workerInstance) {
return { status: 404, body: { type: "text/plain", data: `Worker not found` } };
}
@ -169,7 +170,7 @@ export const workersRoute = createPathFilter({
GET: createEndpoint(
{ query: null, body: null },
async ({ params }) => {
const workerInstance = await workerInstanceStore.getById(params.workerId);
const workerInstance = await workerInstanceStore.getById(params.workerId!);
if (!workerInstance) {
return { status: 404, body: { type: "text/plain", data: `Worker not found` } };
}
@ -200,7 +201,7 @@ export const workersRoute = createPathFilter({
GET: createEndpoint(
{ query: null, body: null },
async ({ params }) => {
const workerInstance = await workerInstanceStore.getById(params.workerId);
const workerInstance = await workerInstanceStore.getById(params.workerId!);
if (!workerInstance) {
return { status: 404, body: { type: "text/plain", data: `Worker not found` } };
}

View File

@ -11,6 +11,7 @@ import { PngInfo } from "../bot/parsePngInfo.ts";
import { formatOrdinal } from "../utils/formatOrdinal.ts";
import { formatUserChat } from "../utils/formatUserChat.ts";
import { getAuthHeader } from "../utils/getAuthHeader.ts";
import { omitUndef } from "../utils/omitUndef.ts";
import { SdError } from "./SdError.ts";
import { getConfig } from "./config.ts";
import { db, fs } from "./db.ts";
@ -161,7 +162,7 @@ async function processGenerationJob(
// reduce size if worker can't handle the resolution
const size = limitSize(
{ ...config.defaultParams, ...state.task.params },
omitUndef({ ...config.defaultParams, ...state.task.params }),
1024 * 1024,
);
function limitSize(
@ -182,18 +183,18 @@ async function processGenerationJob(
// start generating the image
const responsePromise = state.task.type === "txt2img"
? workerSdClient.POST("/sdapi/v1/txt2img", {
body: {
body: omitUndef({
...config.defaultParams,
...state.task.params,
...size,
negative_prompt: state.task.params.negative_prompt
? state.task.params.negative_prompt
: config.defaultParams?.negative_prompt,
},
}),
})
: state.task.type === "img2img"
? workerSdClient.POST("/sdapi/v1/img2img", {
body: {
body: omitUndef({
...config.defaultParams,
...state.task.params,
...size,
@ -209,7 +210,7 @@ async function processGenerationJob(
).then((resp) => resp.arrayBuffer()),
),
],
},
}),
})
: undefined;

View File

@ -5,10 +5,10 @@ import { db } from "./db.ts";
export interface GenerationSchema {
from: User;
chat: Chat;
sdInstanceId?: string; // TODO: change to workerInstanceKey
info?: SdGenerationInfo;
startDate?: Date;
endDate?: Date;
sdInstanceId?: string | undefined; // TODO: change to workerInstanceKey
info?: SdGenerationInfo | undefined;
startDate?: Date | undefined;
endDate?: Date | undefined;
}
/**

View File

@ -1,19 +1,42 @@
export interface KvMemoizeOptions<A extends Deno.KvKey, R> {
/**
* The time in milliseconds until the cached result expires.
*/
expireIn?: ((result: R, ...args: A) => number) | number | undefined;
/**
* Whether to recalculate the result if it was already cached.
*
* Runs whenever the result is retrieved from the cache.
*/
shouldRecalculate?: ((result: R, ...args: A) => boolean) | undefined;
/**
* Whether to cache the result after computing it.
*
* Runs whenever a new result is computed.
*/
shouldCache?: ((result: R, ...args: A) => boolean) | undefined;
/**
* Override the default KV store functions.
*/
override?: {
set: (
key: Deno.KvKey,
args: A,
value: R,
options: { expireIn?: number },
) => Promise<void>;
get: (key: Deno.KvKey, args: A) => Promise<R | undefined>;
};
}
/**
* Memoizes the function result in KV storage.
* Memoizes the function result in KV store.
*/
export function kvMemoize<A extends Deno.KvKey, R>(
db: Deno.Kv,
key: Deno.KvKey,
fn: (...args: A) => Promise<R>,
options?: {
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>;
};
},
options?: KvMemoizeOptions<A, R>,
): (...args: A) => Promise<R> {
return async (...args) => {
const cachedResult = options?.override?.get
@ -34,9 +57,9 @@ export function kvMemoize<A extends Deno.KvKey, R>(
if (options?.shouldCache?.(result, ...args) ?? (result != null)) {
if (options?.override?.set) {
await options.override.set(key, args, result, { expireIn });
await options.override.set(key, args, result, expireIn != null ? { expireIn } : {});
} else {
await db.set([...key, ...args], result, { expireIn });
await db.set([...key, ...args], result, expireIn != null ? { expireIn } : {});
}
}

View File

@ -92,7 +92,7 @@ export async function processUploadQueue() {
// send caption in separate message if it couldn't fit
if (caption.text.length > 1024 && caption.text.length <= 4096) {
await bot.api.sendMessage(state.chat.id, caption.text, {
reply_to_message_id: resultMessages[0].message_id,
reply_to_message_id: resultMessages[0]!.message_id,
allow_sending_without_reply: true,
entities: caption.entities,
maxWait: 60,

View File

@ -1,4 +1,5 @@
import { generationQueue } from "../app/generationQueue.ts";
import { omitUndef } from "../utils/omitUndef.ts";
import { ErisContext } from "./mod.ts";
export async function cancelCommand(ctx: ErisContext) {
@ -7,8 +8,11 @@ export async function cancelCommand(ctx: ErisContext) {
.filter((job) => job.lockUntil < new Date())
.filter((j) => j.state.from.id === ctx.from?.id);
for (const job of userJobs) await generationQueue.deleteJob(job.id);
await ctx.reply(`Cancelled ${userJobs.length} jobs`, {
reply_to_message_id: ctx.message?.message_id,
allow_sending_without_reply: true,
});
await ctx.reply(
`Cancelled ${userJobs.length} jobs`,
omitUndef({
reply_to_message_id: ctx.message?.message_id,
allow_sending_without_reply: true,
}),
);
}

View File

@ -6,6 +6,7 @@ import { error, info, warning } from "std/log/mod.ts";
import { sessions } from "../api/sessionsRoute.ts";
import { getConfig, setConfig } from "../app/config.ts";
import { formatUserChat } from "../utils/formatUserChat.ts";
import { omitUndef } from "../utils/omitUndef.ts";
import { broadcastCommand } from "./broadcastCommand.ts";
import { cancelCommand } from "./cancelCommand.ts";
import { img2imgCommand, img2imgQuestion } from "./img2imgCommand.ts";
@ -19,11 +20,11 @@ interface SessionData {
}
interface ErisChatData {
language?: string;
language?: string | undefined;
}
interface ErisUserData {
params?: Record<string, string>;
params?: Record<string, string> | undefined;
}
export type ErisContext =
@ -94,10 +95,13 @@ bot.use(async (ctx, next) => {
await next();
} catch (err) {
try {
await ctx.reply(`Handling update failed: ${err}`, {
reply_to_message_id: ctx.message?.message_id,
allow_sending_without_reply: true,
});
await ctx.reply(
`Handling update failed: ${err}`,
omitUndef({
reply_to_message_id: ctx.message?.message_id,
allow_sending_without_reply: true,
}),
);
} catch {
throw err;
}
@ -122,24 +126,33 @@ bot.command("start", async (ctx) => {
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,
});
await ctx.reply(
"Login failed: Invalid session ID",
omitUndef({
reply_to_message_id: ctx.message?.message_id,
}),
);
return;
}
session.userId = ctx.from?.id;
sessions.set(id, session);
info(`User ${formatUserChat(ctx)} logged in`);
// TODO: show link to web ui
await ctx.reply("Login successful! You can now return to the WebUI.", {
reply_to_message_id: ctx.message?.message_id,
});
await ctx.reply(
"Login successful! You can now return to the WebUI.",
omitUndef({
reply_to_message_id: ctx.message?.message_id,
}),
);
return;
}
await ctx.reply("Hello! Use the /txt2img command to generate an image", {
reply_to_message_id: ctx.message?.message_id,
});
await ctx.reply(
"Hello! Use the /txt2img command to generate an image",
omitUndef({
reply_to_message_id: ctx.message?.message_id,
}),
);
});
bot.command("txt2img", txt2imgCommand);

View File

@ -25,7 +25,11 @@ interface PngInfoExtra extends PngInfo {
upscale?: number;
}
export function parsePngInfo(pngInfo: string, baseParams?: Partial<PngInfo>, shouldParseSeed?: boolean): Partial<PngInfo> {
export function parsePngInfo(
pngInfo: string,
baseParams?: Partial<PngInfo>,
shouldParseSeed?: boolean,
): Partial<PngInfo> {
const tags = pngInfo.split(/[,;]+|\.+\s|\n/u);
let part: "prompt" | "negative_prompt" | "params" = "prompt";
const params: Partial<PngInfoExtra> = {};
@ -34,7 +38,7 @@ export function parsePngInfo(pngInfo: string, baseParams?: Partial<PngInfo>, sho
for (const tag of tags) {
const paramValuePair = tag.trim().match(/^(\w+\s*\w*):\s+(.*)$/u);
if (paramValuePair) {
const [, param, value] = paramValuePair;
const [_match, param = "", value = ""] = paramValuePair;
switch (param.replace(/\s+/u, "").toLowerCase()) {
case "positiveprompt":
case "positive":
@ -67,7 +71,7 @@ export function parsePngInfo(pngInfo: string, baseParams?: Partial<PngInfo>, sho
case "size":
case "resolution": {
part = "params";
const [width, height] = value.trim()
const [width = 0, height = 0] = value.trim()
.split(/\s*[x,]\s*/u, 2)
.map((v) => v.trim())
.map(Number);
@ -103,9 +107,11 @@ export function parsePngInfo(pngInfo: string, baseParams?: Partial<PngInfo>, sho
part = "params";
if (shouldParseSeed) {
const seed = Number(value.trim());
params.seed = seed;
break;
if (Number.isFinite(seed)) {
params.seed = seed;
}
}
break;
}
case "model":
case "modelhash":

View File

@ -1,6 +1,7 @@
import { CommandContext } from "grammy";
import { bold, fmt } from "grammy_parse_mode";
import { StatelessQuestion } from "grammy_stateless_question";
import { omitUndef } from "../utils/omitUndef.ts";
import { ErisContext } from "./mod.ts";
import { getPngInfo, parsePngInfo } from "./parsePngInfo.ts";
@ -23,11 +24,13 @@ async function pnginfo(ctx: ErisContext, includeRepliedTo: boolean): Promise<voi
await ctx.reply(
"Please send me a PNG file." +
pnginfoQuestion.messageSuffixMarkdown(),
{
reply_markup: { force_reply: true, selective: true },
parse_mode: "Markdown",
reply_to_message_id: ctx.message?.message_id,
},
omitUndef(
{
reply_markup: { force_reply: true, selective: true },
parse_mode: "Markdown",
reply_to_message_id: ctx.message?.message_id,
} as const,
),
);
return;
}
@ -46,8 +49,11 @@ async function pnginfo(ctx: ErisContext, includeRepliedTo: boolean): Promise<voi
params.width && params.height ? fmt`${bold("Size")}: ${params.width}x${params.height}` : "",
]);
await ctx.reply(paramsText.text, {
entities: paramsText.entities,
reply_to_message_id: ctx.message?.message_id,
});
await ctx.reply(
paramsText.text,
omitUndef({
entities: paramsText.entities,
reply_to_message_id: ctx.message?.message_id,
}),
);
}

View File

@ -1,16 +1,20 @@
import { CommandContext } from "grammy";
import { bold, fmt } from "grammy_parse_mode";
import { activeGenerationWorkers, generationQueue } from "../app/generationQueue.ts";
import { getFlagEmoji } from "../utils/getFlagEmoji.ts";
import { ErisContext } from "./mod.ts";
import { workerInstanceStore } from "../app/workerInstanceStore.ts";
import { getFlagEmoji } from "../utils/getFlagEmoji.ts";
import { omitUndef } from "../utils/omitUndef.ts";
import { ErisContext } from "./mod.ts";
export async function queueCommand(ctx: CommandContext<ErisContext>) {
let formattedMessage = await getMessageText();
const queueMessage = await ctx.replyFmt(formattedMessage, {
disable_notification: true,
reply_to_message_id: ctx.message?.message_id,
});
const queueMessage = await ctx.replyFmt(
formattedMessage,
omitUndef({
disable_notification: true,
reply_to_message_id: ctx.message?.message_id,
}),
);
handleFutureUpdates().catch(() => undefined);
async function getMessageText() {

View File

@ -5,7 +5,9 @@
"start": "deno run --unstable --allow-env --allow-read --allow-write --allow-net main.ts"
},
"compilerOptions": {
"jsx": "react"
"exactOptionalPropertyTypes": true,
"jsx": "react",
"noUncheckedIndexedAccess": true
},
"fmt": {
"lineWidth": 100

View File

@ -34,7 +34,8 @@ export function AppHeader(
);
const bot = useSWR(
['bot',"GET",{}] as const, (args) => fetchApi(...args).then(handleResponse),
["bot", "GET", {}] as const,
(args) => fetchApi(...args).then(handleResponse),
);
const userPhoto = useSWR(

View File

@ -1,7 +1,7 @@
import { cx } from "@twind/core";
import React from "react";
function CounterDigit(props: { value: number; transitionDurationMs?: number }) {
function CounterDigit(props: { value: number; transitionDurationMs?: number | undefined }) {
const { value, transitionDurationMs = 1500 } = props;
const rads = -(Math.floor(value) % 1_000_000) * 2 * Math.PI * 0.1;
@ -36,10 +36,10 @@ const CounterText = (props: { children: React.ReactNode }) => (
export function Counter(props: {
value: number;
digits: number;
fractionDigits?: number;
transitionDurationMs?: number;
className?: string;
postfix?: string;
fractionDigits?: number | undefined;
transitionDurationMs?: number | undefined;
className?: string | undefined;
postfix?: string | undefined;
}) {
const { value, digits, fractionDigits = 0, transitionDurationMs, className, postfix } = props;

View File

@ -43,7 +43,7 @@ export function WorkersPage(props: { sessionId?: string }) {
</button>
)}
<dialog
className="dialog"
className="dialog animate-pop-in backdrop-animate-fade-in"
ref={createWorkerModalRef}
>
<form
@ -159,7 +159,7 @@ export function WorkersPage(props: { sessionId?: string }) {
);
}
function WorkerListItem(props: { worker: WorkerData; sessionId?: string }) {
function WorkerListItem(props: { worker: WorkerData; sessionId: string | undefined }) {
const { worker, sessionId } = props;
const editWorkerModalRef = useRef<HTMLDialogElement>(null);
const deleteWorkerModalRef = useRef<HTMLDialogElement>(null);
@ -231,7 +231,7 @@ function WorkerListItem(props: { worker: WorkerData; sessionId?: string }) {
)}
</p>
<dialog
className="dialog"
className="dialog animate-pop-in backdrop-animate-fade-in"
ref={editWorkerModalRef}
>
<form
@ -293,7 +293,7 @@ function WorkerListItem(props: { worker: WorkerData; sessionId?: string }) {
</form>
</dialog>
<dialog
className="dialog"
className="dialog animate-pop-in backdrop-animate-fade-in"
ref={deleteWorkerModalRef}
>
<form

View File

@ -27,14 +27,11 @@ injectGlobal`
rgba(0, 0, 0, 0.1) 14px,
rgba(0, 0, 0, 0.1) 28px
);
animation: bg-stripes-scroll 0.5s linear infinite;
animation: bg-scroll 0.5s linear infinite;
background-size: 40px 40px;
}
@keyframes bg-stripes-scroll {
0% {
background-position: 0 40px;
}
100% {
@keyframes bg-scroll {
to {
background-position: 40px 0;
}
}
@ -63,11 +60,11 @@ injectGlobal`
opacity 0s;
}
.animate-fade-in {
.backdrop-animate-fade-in::backdrop {
animation: fade-in 0.3s ease-out forwards;
}
@keyframes fade-in {
0% {
from {
opacity: 0;
}
}
@ -76,13 +73,13 @@ injectGlobal`
animation: pop-in 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
}
@keyframes pop-in {
0% {
from {
transform: scale(0.8);
opacity: 0;
}
}
}
@layer components {
.link {
@apply text-sky-600 dark:text-sky-500 rounded-sm focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-sky-600 dark:focus:outline-sky-500;
@ -134,8 +131,8 @@ injectGlobal`
}
.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;
@apply overflow-hidden overflow-y-auto rounded-md
bg-zinc-100 text-zinc-900 shadow-lg backdrop:bg-black/30 dark:bg-zinc-800 dark:text-zinc-100;
}
}
`;

View File

@ -1,7 +1,11 @@
import { Chat, User } from "grammy_types";
export function formatUserChat(
ctx: { from?: User; chat?: Chat; workerInstanceKey?: string },
ctx: {
from?: User | undefined;
chat?: Chat | undefined;
workerInstanceKey?: string | undefined;
},
) {
const msg: string[] = [];
if (ctx.from) {

11
utils/omitUndef.ts Normal file
View File

@ -0,0 +1,11 @@
/**
* Removes all undefined properties from an object.
*/
export function omitUndef<O extends object | undefined>(object: O):
& { [K in keyof O as undefined extends O[K] ? never : K]: O[K] }
& { [K in keyof O as undefined extends O[K] ? K : never]?: O[K] & ({} | null) } {
if (object == undefined) return object as never;
return Object.fromEntries(
Object.entries(object).filter(([, v]) => v !== undefined),
) as never;
}