123 lines
4.0 KiB
TypeScript
123 lines
4.0 KiB
TypeScript
import { useFetcher } from "@remix-run/react";
|
|
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
|
|
import { json } from "@remix-run/server-runtime";
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { z } from "zod";
|
|
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
|
|
import { Button } from "~/components/primitives/Buttons";
|
|
import { FormError } from "~/components/primitives/FormError";
|
|
import { Label } from "~/components/primitives/Label";
|
|
import { Spinner } from "~/components/primitives/Spinner";
|
|
import { env } from "~/env.server";
|
|
import { useOrganization } from "~/hooks/useOrganizations";
|
|
import { useProject } from "~/hooks/useProject";
|
|
import { requireUserId } from "~/services/session.server";
|
|
import { humanToCron } from "~/v3/humanToCron.server";
|
|
|
|
const schema = z.object({
|
|
message: z.string(),
|
|
});
|
|
|
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
|
const userId = await requireUserId(request);
|
|
|
|
if (!env.OPENAI_API_KEY) {
|
|
return json(
|
|
{
|
|
isValid: false as const,
|
|
error: "OpenAI API key is not set",
|
|
cron: undefined,
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const data = await request.json();
|
|
const submission = schema.safeParse(data);
|
|
|
|
if (!submission.success) {
|
|
return json(
|
|
{
|
|
isValid: false as const,
|
|
error: "Invalid input",
|
|
cron: undefined,
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const result = await humanToCron(submission.data.message, userId);
|
|
|
|
return json(result);
|
|
};
|
|
|
|
type AIGeneratedCronFieldProps = {
|
|
onSuccess: (cron: string) => void;
|
|
};
|
|
|
|
export function AIGeneratedCronField({ onSuccess }: AIGeneratedCronFieldProps) {
|
|
const fetcher = useFetcher<typeof action>();
|
|
const [text, setText] = useState<string>("");
|
|
const organization = useOrganization();
|
|
const project = useProject();
|
|
const isLoading = fetcher.state !== "idle";
|
|
|
|
const resultData = fetcher.data;
|
|
|
|
useEffect(() => {
|
|
if (resultData?.cron !== undefined) {
|
|
onSuccess(resultData.cron);
|
|
}
|
|
}, [resultData?.cron]);
|
|
|
|
const submit = useCallback(async (value: string) => {
|
|
fetcher.submit(
|
|
{ message: value },
|
|
{
|
|
method: "POST",
|
|
action: `/resources/orgs/${organization.slug}/projects/${project.slug}/schedules/new/natural-language`,
|
|
encType: "application/json",
|
|
}
|
|
);
|
|
}, []);
|
|
|
|
return (
|
|
<div className="max-w-md">
|
|
<Label>
|
|
<AISparkleIcon className="inline-block size-4" /> Describe your schedule using natural
|
|
language
|
|
</Label>
|
|
<div
|
|
className="mt-1 rounded p-px"
|
|
style={{ background: "linear-gradient(to bottom right, #E543FF, #286399)" }}
|
|
>
|
|
<div className="overflow-hidden rounded-sm bg-background-bright">
|
|
<textarea
|
|
value={text}
|
|
placeholder="e.g. the last Friday of the month at 6am"
|
|
onChange={(e) => setText(e.target.value)}
|
|
rows={3}
|
|
className="m-0 min-h-10 w-full border-0 bg-background-bright px-3 py-2 text-sm text-text-bright scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control file:border-0 file:bg-transparent file:text-base file:font-medium focus:border-0 focus:outline-hidden focus:ring-0 focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50"
|
|
/>
|
|
<div className="flex justify-end gap-2 px-2 pb-2">
|
|
<Button
|
|
type="button"
|
|
variant="tertiary/small"
|
|
disabled={isLoading}
|
|
LeadingIcon={isLoading ? Spinner : AISparkleIcon}
|
|
onClick={() => submit(text)}
|
|
className="pl-1.5"
|
|
iconSpacing="gap-1.5"
|
|
>
|
|
{isLoading ? "Generating" : "Generate"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{resultData?.isValid === false ? (
|
|
<FormError className="mt-2">{resultData.error}</FormError>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|