From 8ba79fc39ca06031924caa580a253a0cc1aa34bf Mon Sep 17 00:00:00 2001 From: sabaimran Date: Sat, 13 Jul 2024 00:34:33 +0530 Subject: [PATCH 001/107] V1 of the new automations page Implemented: - Shareable - Editable - Suggested Cards - Create new cards --- .../app/automations/automations.module.css | 11 + .../automations/automationsLayout.module.css | 11 + src/interface/web/app/automations/layout.tsx | 28 + src/interface/web/app/automations/page.tsx | 849 ++++++++++++++++++ src/interface/web/app/common/utils.ts | 24 + .../web/app/components/navMenu/navMenu.tsx | 17 +- .../app/components/shareLink/shareLink.tsx | 43 +- src/interface/web/components/ui/form.tsx | 178 ++++ src/interface/web/components/ui/select.tsx | 160 ++++ src/interface/web/components/ui/toast.tsx | 129 +++ src/interface/web/components/ui/toaster.tsx | 35 + src/interface/web/components/ui/use-toast.ts | 194 ++++ src/interface/web/package.json | 8 +- src/interface/web/yarn.lock | 62 +- 14 files changed, 1725 insertions(+), 24 deletions(-) create mode 100644 src/interface/web/app/automations/automations.module.css create mode 100644 src/interface/web/app/automations/automationsLayout.module.css create mode 100644 src/interface/web/app/automations/layout.tsx create mode 100644 src/interface/web/app/automations/page.tsx create mode 100644 src/interface/web/components/ui/form.tsx create mode 100644 src/interface/web/components/ui/select.tsx create mode 100644 src/interface/web/components/ui/toast.tsx create mode 100644 src/interface/web/components/ui/toaster.tsx create mode 100644 src/interface/web/components/ui/use-toast.ts diff --git a/src/interface/web/app/automations/automations.module.css b/src/interface/web/app/automations/automations.module.css new file mode 100644 index 000000000..206d4c361 --- /dev/null +++ b/src/interface/web/app/automations/automations.module.css @@ -0,0 +1,11 @@ +div.automationsLayout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +@media screen and (max-width: 768px) { + div.automationsLayout { + grid-template-columns: 1fr; + } +} diff --git a/src/interface/web/app/automations/automationsLayout.module.css b/src/interface/web/app/automations/automationsLayout.module.css new file mode 100644 index 000000000..6e27ee7ac --- /dev/null +++ b/src/interface/web/app/automations/automationsLayout.module.css @@ -0,0 +1,11 @@ +.automationsLayout { + max-width: 70vw; + margin: auto; + margin-bottom: 2rem; +} + +@media screen and (max-width: 700px) { + .automationsLayout { + max-width: 90vw; + } +} diff --git a/src/interface/web/app/automations/layout.tsx b/src/interface/web/app/automations/layout.tsx new file mode 100644 index 000000000..23493c75d --- /dev/null +++ b/src/interface/web/app/automations/layout.tsx @@ -0,0 +1,28 @@ + +import type { Metadata } from "next"; +import NavMenu from '../components/navMenu/navMenu'; +import styles from './automationsLayout.module.css'; +import { Toaster } from "@/components/ui/toaster"; + + +export const metadata: Metadata = { + title: "Khoj AI - Automations", + description: "Use Autoomations with Khoj to simplify the process of running repetitive tasks.", + icons: { + icon: '/static/favicon.ico', + }, +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( +
+ + {children} + +
+ ); +} diff --git a/src/interface/web/app/automations/page.tsx b/src/interface/web/app/automations/page.tsx new file mode 100644 index 000000000..5bb286013 --- /dev/null +++ b/src/interface/web/app/automations/page.tsx @@ -0,0 +1,849 @@ +'use client' + +import useSWR from 'swr'; +import Loading, { InlineLoading } from '../components/loading/loading'; +import { + Card, + CardDescription, + CardContent, + CardFooter, + CardHeader, + CardTitle + +} from '@/components/ui/card'; +import { Button, buttonVariants } from '@/components/ui/button'; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +interface AutomationsData { + id: number; + subject: string; + query_to_run: string; + scheduling_request: string; + schedule: string; + crontime: string; + next: string; +} + +import cronstrue from 'cronstrue'; +import { zodResolver } from "@hookform/resolvers/zod" +import { UseFormReturn, useForm } from "react-hook-form" +import { z } from "zod" +import { Suspense, useEffect, useState } from 'react'; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'; +import { DialogTitle } from '@radix-ui/react-dialog'; +import { Textarea } from '@/components/ui/textarea'; +import { LocationData, useIPLocationData } from '../common/utils'; + +import styles from './automations.module.css'; +import ShareLink from '../components/shareLink/shareLink'; +import { useSearchParams } from 'next/navigation'; +import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'; +import { DotsThreeVertical, Pencil, Play, Trash } from '@phosphor-icons/react'; +import { useAuthenticatedData } from '../common/auth'; +import LoginPrompt from '../components/loginPrompt/loginPrompt'; +import { useToast } from '@/components/ui/use-toast'; +import { ToastAction} from '@/components/ui/toast'; + +const automationsFetcher = () => window.fetch('/api/automations').then(res => res.json()).catch(err => console.log(err)); + +function getEveryBlahFromCron(cron: string) { + const cronParts = cron.split(' '); + if (cronParts[2] === '*') { + return 'Day'; + } + if (cronParts[4] === '*') { + return 'Week'; + } + return 'Month'; +} + +function getDayIntervalFromCron(cron: string) { + const cronParts = cron.split(' '); + if (cronParts[4] === '*') { + return cronParts[5]; + } + return ''; +} + +function getTimeRecurrenceFromCron(cron: string) { + const cronParts = cron.split(' '); + const hour = cronParts[1]; + const minute = cronParts[0]; + const period = Number(hour) >= 12 ? 'PM' : 'AM'; + + let friendlyHour = Number(hour) > 12 ? Number(hour) - 12 : hour; + if (friendlyHour === '00') { + friendlyHour = '12'; + } + + let friendlyMinute = minute; + if (Number(friendlyMinute) < 10 && friendlyMinute !== '00') { + friendlyMinute = `0${friendlyMinute}`; + } + return `${friendlyHour}:${friendlyMinute} ${period}`; +} + +function getDayOfMonthFromCron(cron: string) { + const cronParts = cron.split(' '); + if (cronParts[2] === '*') { + return cronParts[3]; + } + return ''; +} + +function cronToHumanReadableString(cron: string) { + return cronstrue.toString(cron); +} + +const frequencies = ['Day', 'Week', 'Month']; + +const dayIntervals = Array.from({ length: 31 }, (_, i) => i + 1); + +const weekDays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + +const timeOptions: string[] = []; + +const timePeriods = ['AM', 'PM']; + +// Populate the time selector with options for each hour of the day +for (var i = 0; i < timePeriods.length; i++) { + for (var hour = 0; hour < 12; hour++) { + for (var minute = 0; minute < 60; minute += 15) { + // Ensure all minutes are two digits + const paddedMinute = String(minute).padStart(2, '0'); + const friendlyHour = hour === 0 ? 12 : hour; + timeOptions.push(`${friendlyHour}:${paddedMinute} ${timePeriods[i]}`); + } + } +} + +const timestamp = Date.now(); + +const suggestedAutomationsMetadata: AutomationsData[] = [ + { + "subject": "Weekly Newsletter", + "query_to_run": "Compile a message including: 1. A recap of news from last week 2. A reminder to work out and stay hydrated 3. A quote to inspire me for the week ahead", + "schedule": "9AM every Monday", + "next": "Next run at 9AM on Monday", + "crontime": "0 9 * * 1", + "id": timestamp, + "scheduling_request": "", + }, + { + "subject": "Daily Bedtime Story", + "query_to_run": "Compose a bedtime story that a five-year-old might enjoy. It should not exceed five paragraphs. Appeal to the imagination, but weave in learnings.", + "schedule": "9PM every night", + "next": "Next run at 9PM today", + "crontime": "0 21 * * *", + "id": timestamp + 1, + "scheduling_request": "", + }, + { + "subject": "Front Page of Hacker News", + "query_to_run": "Summarize the top 5 posts from https://news.ycombinator.com/best and share them with me, including links", + "schedule": "9PM on every Wednesday", + "next": "Next run at 9PM on Wednesday", + "crontime": "0 21 * * 3", + "id": timestamp + 2, + "scheduling_request": "", + }, + { + "subject": "Market Summary", + "query_to_run": "Get the market summary for today and share it with me. Focus on tech stocks and the S&P 500.", + "schedule": "9AM on every weekday", + "next": "Next run at 9AM on Monday", + "crontime": "0 9 * * 1-5", + "id": timestamp + 3, + "scheduling_request": "", + } +]; + +function createShareLink(automation: AutomationsData) { + const encodedSubject = encodeURIComponent(automation.subject); + const encodedQuery = encodeURIComponent(automation.query_to_run); + const encodedCrontime = encodeURIComponent(automation.crontime); + + const shareLink = `${window.location.origin}/automations?subject=${encodedSubject}&query=${encodedQuery}&crontime=${encodedCrontime}`; + + return shareLink; +} + +function deleteAutomation(automationId: string, setIsDeleted: (isDeleted: boolean) => void) { + fetch(`/api/automation?automation_id=${automationId}`, { method: 'DELETE' } + ).then(response => response.json()) + .then(data => { + setIsDeleted(true); + }); +} + +function sendAPreview(automationId: string, setToastMessage: (toastMessage: string) => void) { + fetch(`/api/trigger/automation?automation_id=${automationId}`, { method: 'POST' }) + .then(response => { + if (!response.ok) { + throw new Error('Network response was not ok'); + } + return response; + }) + .then(automations => { + setToastMessage("Automation triggered. Check your inbox in a few minutes!"); + }) + .catch(error => { + setToastMessage("Sorry, something went wrong. Try again later."); + }) +} + +interface AutomationsCardProps { + automation: AutomationsData; + locationData?: LocationData | null; + suggestedCard?: boolean; + setNewAutomationData?: (data: AutomationsData) => void; +} + + +function AutomationsCard(props: AutomationsCardProps) { + const [isEditing, setIsEditing] = useState(false); + const [updatedAutomationData, setUpdatedAutomationData] = useState(null); + const [isDeleted, setIsDeleted] = useState(false); + const [toastMessage, setToastMessage] = useState(''); + const { toast } = useToast(); + + const automation = props.automation; + + useEffect(() => { + if (toastMessage) { + toast({ + title: `Automation: ${updatedAutomationData?.subject || automation.subject}`, + description: toastMessage, + action: ( + Ok + ), + }) + setToastMessage(''); + } + }, [toastMessage]); + + if (isDeleted) { + return null; + } + + return ( + + + + {updatedAutomationData?.subject || automation.subject} + + + + + + + { + !props.suggestedCard && ( + { + setIsEditing(open); + }} + > + + + + + Edit Automation + + + + ) + } + { + !props.suggestedCard && ( + + ) + } + + + + + + {updatedAutomationData?.schedule || cronToHumanReadableString(automation.crontime)} + + + + {updatedAutomationData?.query_to_run || automation.query_to_run} + + + { + props.suggestedCard && props.setNewAutomationData && ( + { + setIsEditing(open); + }} + > + + + + + Add Automation + + + + ) + } + { + navigator.clipboard.writeText(createShareLink(automation)); + }} /> + + + ) +} + +interface SharedAutomationCardProps { + locationData?: LocationData | null; + setNewAutomationData: (data: AutomationsData) => void; +} + +function SharedAutomationCard(props: SharedAutomationCardProps) { + const searchParams = useSearchParams(); + const [isCreating, setIsCreating] = useState(true); + + const subject = searchParams.get('subject'); + const query = searchParams.get('query'); + const crontime = searchParams.get('crontime'); + + if (!subject || !query || !crontime) { + return null; + } + + const automation: AutomationsData = { + id: 0, + subject: decodeURIComponent(subject), + query_to_run: decodeURIComponent(query), + scheduling_request: '', + schedule: cronToHumanReadableString(decodeURIComponent(crontime)), + crontime: decodeURIComponent(crontime), + next: '', + } + + return ( + { + setIsCreating(open); + }} + > + + + + Create Automation + + + + ) +} + +const EditAutomationSchema = z.object({ + subject: z.optional(z.string()), + everyBlah: z.string({ required_error: "Every is required" }), + dayInterval: z.optional(z.string()), + dayOfMonth: z.optional(z.string()), + timeRecurrence: z.string({ required_error: "Time Recurrence is required" }), + queryToRun: z.string({ required_error: "Query to Run is required" }), +}); + +interface EditCardProps { + automation?: AutomationsData; + setIsEditing: (completed: boolean) => void; + setUpdatedAutomationData: (data: AutomationsData) => void; + locationData?: LocationData | null; + createNew?: boolean; +} + +function EditCard(props: EditCardProps) { + const automation = props.automation; + + const form = useForm>({ + resolver: zodResolver(EditAutomationSchema), + defaultValues: { + subject: automation?.subject, + everyBlah: (automation?.crontime ? getEveryBlahFromCron(automation.crontime) : 'Day'), + dayInterval: (automation?.crontime ? getDayIntervalFromCron(automation.crontime) : undefined), + timeRecurrence: (automation?.crontime ? getTimeRecurrenceFromCron(automation.crontime) : '12:00 PM'), + dayOfMonth: (automation?.crontime ? getDayOfMonthFromCron(automation.crontime) : "1"), + queryToRun: automation?.query_to_run, + }, + }) + + const onSubmit = (values: z.infer) => { + const cronFrequency = convertFrequencyToCron(values.everyBlah, values.timeRecurrence, values.dayInterval, values.dayOfMonth); + + let updateQueryUrl = `/api/automation?`; + + updateQueryUrl += `q=${values.queryToRun}`; + + if (automation?.id && !props.createNew) { + updateQueryUrl += `&automation_id=${automation.id}`; + } + + if (values.subject) { + updateQueryUrl += `&subject=${values.subject}`; + } + + updateQueryUrl += `&crontime=${cronFrequency}`; + + if (props.locationData) { + updateQueryUrl += `&city=${props.locationData.city}`; + updateQueryUrl += `®ion=${props.locationData.region}`; + updateQueryUrl += `&country=${props.locationData.country}`; + updateQueryUrl += `&timezone=${props.locationData.timezone}`; + } + + let method = props.createNew ? 'POST' : 'PUT'; + + fetch(updateQueryUrl, { method: method }) + .then(response => response.json()) + .then + ((data: AutomationsData) => { + props.setIsEditing(false); + props.setUpdatedAutomationData({ + id: data.id, + subject: data.subject || '', + query_to_run: data.query_to_run, + scheduling_request: data.scheduling_request, + schedule: cronToHumanReadableString(data.crontime), + crontime: data.crontime, + next: data.next, + }); + }); + } + + function convertFrequencyToCron(frequency: string, timeRecurrence: string, dayOfWeek?: string, dayOfMonth?: string) { + let cronString = ''; + + const minutes = timeRecurrence.split(':')[1].split(' ')[0]; + const period = timeRecurrence.split(':')[1].split(' ')[1]; + const rawHourAsNumber = Number(timeRecurrence.split(':')[0]); + const hours = period === 'PM' && (rawHourAsNumber < 12) ? String(rawHourAsNumber + 12) : rawHourAsNumber; + + const dayOfWeekNumber = weekDays.indexOf(dayOfWeek || ''); + + switch (frequency) { + case 'Day': + cronString = `${minutes} ${hours} * * *`; + break; + case 'Week': + cronString = `${minutes} ${hours} * * ${dayOfWeekNumber}`; + break; + case 'Month': + cronString = `${minutes} ${hours} ${dayOfMonth} * *`; + break; + } + + return cronString; + } + + return ( + + ) + +} + +interface AutomationModificationFormProps { + form: UseFormReturn>; + onSubmit: (values: z.infer) => void; + create?: boolean; +} + +function AutomationModificationForm(props: AutomationModificationFormProps) { + + const [isSaving, setIsSaving] = useState(false); + + function recommendationPill(recommendationText: string, onChange: (value: any, event: React.MouseEvent) => void) { + return ( + + ) + } + + const recommendationPills = [ + "Make a picture of", + "Generate a summary of", + "Create a newsletter of", + "Notify me when" + ]; + + return ( +
+ { + props.onSubmit(values); + setIsSaving(true); + })} className="space-y-8"> + { + !props.create && ( + ( + + Subject + + This is the subject of the email you'll receive. + + + + + + + )} + />) + } + + ( + + Frequency + + How frequently should this automation run? + + + + + )} + /> + { + props.form.watch('everyBlah') === 'Week' && ( + ( + + Day of Week + + + + )} + /> + ) + } + { + props.form.watch('everyBlah') === 'Month' && ( + ( + + Day of Month + + + + )} + /> + ) + } + { + ( + props.form.watch('everyBlah') === 'Day' || + props.form.watch('everyBlah') == 'Week' || + props.form.watch('everyBlah') == 'Month') && ( + ( + + Time + + On the days this automation runs, at what time should it run? + + + + + )} + /> + ) + } + ( + + Instructions + + What do you want Khoj to do? + + { + props.create && ( +
+ { + recommendationPills.map((recommendation) => recommendationPill(recommendation, field.onChange)) + } +
+ ) + } + +