Skip to content

Commit

Permalink
AI card mass generation (#32)
Browse files Browse the repository at this point in the history
* Fix removeFormat doesn't clear H1-H6

* AI mass generator + slight redising deck form / card form

* Introduce replacement for the mobx-utils
  • Loading branch information
kubk authored Apr 15, 2024
1 parent b5d8691 commit 32848bf
Show file tree
Hide file tree
Showing 51 changed files with 1,730 additions and 318 deletions.
39 changes: 38 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"mobx-log": "^2.2.3",
"mobx-persist-store": "^1.1.3",
"mobx-react-lite": "^4.0.5",
"notistack": "^3.0.1",
"openai": "^4.33.1",
"react": "^18.2.0",
"react-content-loader": "^6.2.1",
Expand Down
13 changes: 13 additions & 0 deletions shared/access/can-use-ai-mass-generate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { PlansForUser } from "../../functions/db/plan/get-active-plans-for-user.ts";
import type { UserDbType } from "../../functions/db/user/upsert-user-db.ts";

export const canUseAiMassGenerate = (
user: UserDbType,
plans?: PlansForUser,
) => {
if (user.is_admin) {
return true;
}

return plans?.some((plan) => plan.ai_mass_generate);
};
43 changes: 43 additions & 0 deletions src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,19 @@ import {
CardsFreezeResponse,
} from "../../functions/cards-freeze.ts";
import { DeleteFolderResponse } from "../../functions/delete-folder.ts";
import { UserAiCredentialsResponse } from "../../functions/user-ai-credentials.ts";
import {
UpsertUserAiCredentialsRequest,
UpsertUserAiCredentialsResponse,
} from "../../functions/upsert-user-ai-credentials.ts";
import {
AddCardsMultipleRequest,
AddCardsMultipleResponse,
} from "../../functions/add-cards-multiple.ts";
import {
AiMassGenerateRequest,
AiMassGenerateResponse,
} from "../../functions/ai-mass-generate.ts";

export const healthRequest = () => {
return request<HealthResponse>("/health");
Expand Down Expand Up @@ -204,3 +217,33 @@ export const cardsFreezeRequest = (body: CardsFreezeRequest) => {
body,
);
};

export const aiMassGenerateRequest = (body: AiMassGenerateRequest) => {
return request<AiMassGenerateResponse, AiMassGenerateRequest>(
// TODO: remove mock
"/ai-mass-generate-mock",
"POST",
body,
);
};

export const aiUserCredentialsCheckRequest = () => {
return request<UserAiCredentialsResponse>("/user-ai-credentials", "GET");
};

export const upsertUserAiCredentialsRequest = (
body: UpsertUserAiCredentialsRequest,
) => {
return request<
UpsertUserAiCredentialsResponse,
UpsertUserAiCredentialsRequest
>("/upsert-user-ai-credentials", "POST", body);
};

export const addCardsMultipleRequest = (body: AddCardsMultipleRequest) => {
return request<AddCardsMultipleResponse, AddCardsMultipleRequest>(
"/add-cards-multiple",
"POST",
body,
);
};
13 changes: 13 additions & 0 deletions src/lib/mobx-request/request.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { RequestStore } from "./requestStore.ts";
import { expect, test } from "vitest";
import { when } from "mobx";

test("request - success", async () => {
const sum = (a: number, b: number) => Promise.resolve(a + b);
const request = new RequestStore(sum);

expect(request.result).toEqual({ data: null, status: "idle" });
request.execute(1, 2);
await when(() => request.result.status === "success");
expect(request.result).toEqual({ data: 3, status: "success" });
});
52 changes: 52 additions & 0 deletions src/lib/mobx-request/requestStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { makeAutoObservable, runInAction } from "mobx";

type SuccessResult<T> = { data: T; status: "success" };
type ErrorResult = { data: null; status: "error"; error: any };
type LoadingResult = { data: null; status: "loading" };
type IdleResult = { data: null; status: "idle" };

type Result<T> = SuccessResult<T> | ErrorResult | LoadingResult | IdleResult;
type ExecuteResult<T> = SuccessResult<T> | ErrorResult;

export class RequestStore<T, Args extends any[] = []> {
result: Result<T> = { data: null, status: "idle" };

constructor(private fetchFn: (...args: Args) => Promise<T>) {
makeAutoObservable<this, "fetchFn">(
this,
{ fetchFn: false },
{ autoBind: true },
);
}

execute = async (...args: Args): Promise<ExecuteResult<T>> => {
this.result = { data: null, status: "loading" };

try {
const data = await this.fetchFn(...args);
runInAction(() => {
this.result = { data, status: "success" };
});
} catch (error) {
runInAction(() => {
this.result = { data: null, status: "error", error };
});
}

return this.result as unknown as ExecuteResult<T>;
};

overrideSuccess(data: T) {
this.result = { data, status: "success" };
}

// Non type-safe shorthand
get isLoading() {
return this.result.status === "loading";
}

// Non type-safe shorthand
get isSuccess() {
return this.result.status === "success";
}
}
2 changes: 1 addition & 1 deletion src/lib/request/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const request = async <Output, Input = object>(
try {
return await requestInner(path, method, body);
} catch (error) {
if (method === "GET") {
if (method === "GET" || path === "/upsert-deck") {
return requestInner(path, method, body);
}
throw error;
Expand Down
87 changes: 87 additions & 0 deletions src/screens/ai-mass-creation/ai-mass-creation-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { observer } from "mobx-react-lite";
import { useAiMassCreationStore } from "./store/ai-mass-creation-store-provider.tsx";
import { Screen } from "../shared/screen.tsx";
import { Flex } from "../../ui/flex.tsx";
import { List } from "../../ui/list.tsx";
import { FilledIcon } from "../../ui/filled-icon.tsx";
import { theme } from "../../ui/theme.tsx";
import { ListRightText } from "../../ui/list-right-text.tsx";
import { t } from "../../translations/t.ts";
import { Label } from "../../ui/label.tsx";
import { Input } from "../../ui/input.tsx";
import React from "react";
import { ValidationError } from "../../ui/validation-error.tsx";
import { useMainButton } from "../../lib/telegram/use-main-button.tsx";
import { useTelegramProgress } from "../../lib/telegram/use-telegram-progress.tsx";

export const AiMassCreationForm = observer(() => {
const store = useAiMassCreationStore();
const { promptForm } = store;

useMainButton("Generate cards", () => {
store.submitPromptForm();
});

useTelegramProgress(() => store.aiMassGenerateRequest.isLoading);

return (
<Screen title={"Generate cards with AI"}>
<Flex direction={"column"} gap={0}>
<List
items={[
{
text: t("how"),
icon: (
<FilledIcon
backgroundColor={theme.icons.turquoise}
icon={"mdi-help"}
/>
),
onClick: () => {
store.screen.onChange("how");
},
},
{
text: "API keys",
icon: (
<FilledIcon
backgroundColor={theme.icons.blue}
icon={"mdi-key"}
/>
),
right: (
<ListRightText
text={
store.isApiKeysSetRequest.isLoading
? t("ui_loading")
: store.isApiKeysSet
? "Configured"
: "Not configured"
}
/>
),
onClick: () => {
store.goApiKeysScreen();
},
},
]}
/>
{promptForm.apiKey.isTouched && promptForm.apiKey.error && (
<ValidationError error={promptForm.apiKey.error} />
)}
</Flex>

<Label text={"Prompt"} isRequired>
<Input field={promptForm.prompt} rows={3} type={"textarea"} />
</Label>

<Label isRequired text={"Card front description"}>
<Input field={promptForm.frontPrompt} />
</Label>

<Label isRequired text={"Card back description"}>
<Input field={promptForm.backPrompt} />
</Label>
</Screen>
);
});
27 changes: 27 additions & 0 deletions src/screens/ai-mass-creation/ai-mass-creation-screen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { observer } from "mobx-react-lite";
import { useAiMassCreationStore } from "./store/ai-mass-creation-store-provider.tsx";
import React from "react";
import { AiMassCreationForm } from "./ai-mass-creation-form.tsx";
import { HowMassCreationWorksScreen } from "./how-mass-creation-works-screen.tsx";
import { ApiKeysScreen } from "./api-keys-screen.tsx";
import { useMount } from "../../lib/react/use-mount.ts";
import { CardsGeneratedScreen } from "./cards-generated-screen.tsx";

export const AiMassCreationScreen = observer(() => {
const store = useAiMassCreationStore();

useMount(() => {
store.load();
});

if (store.screen.value === "how") {
return <HowMassCreationWorksScreen />;
}
if (store.screen.value === "apiKeys") {
return <ApiKeysScreen />;
}
if (store.screen.value === "cardsGenerated") {
return <CardsGeneratedScreen />;
}
return <AiMassCreationForm />;
});
Loading

0 comments on commit 32848bf

Please sign in to comment.