add ai scheduler demo

This commit is contained in:
vincanger 2024-01-05 17:19:57 -05:00
parent 82f8463b54
commit d6f89ad9f0
8 changed files with 537 additions and 202 deletions

View File

@ -49,7 +49,7 @@ app SaaSTemplate {
additionalFields: import setIsAdminViaEmailSignup from "@server/auth/setIsAdminViaEmailSignup.js",
},
onAuthFailedRedirectTo: "/login",
onAuthSucceededRedirectTo: "/gpt",
onAuthSucceededRedirectTo: "/demo-app",
},
db: {
system: PostgreSQL,
@ -73,17 +73,16 @@ app SaaSTemplate {
("@headlessui/react", "1.7.13"),
("@tailwindcss/forms", "^0.5.3"),
("@tailwindcss/typography", "^0.5.7"),
("react-hook-form", "7.43.1"),
("react-icons", "4.11.0"),
("node-fetch", "3.3.0"),
("react-hook-form", "^7.45.4"),
("stripe", "11.15.0"),
("react-hot-toast", "^2.4.1"),
("react-apexcharts", "^1.4.1"),
("apexcharts", "^3.41.0"),
("headlessui", "^0.0.0"),
("@faker-js/faker", "8.3.1"),
("@google-analytics/data", "4.1.0")
("@google-analytics/data", "4.1.0"),
("openai", "^4.24.1"),
],
}
@ -113,6 +112,7 @@ entity User {=psl
gptResponses GptResponse[]
externalAuthAssociations SocialLogin[]
contactFormMessages ContactFormMessage[]
tasks Task[]
psl=}
entity SocialLogin {=psl
@ -134,6 +134,16 @@ entity GptResponse {=psl
updatedAt DateTime @updatedAt
psl=}
entity Task {=psl
id String @id @default(uuid())
description String
time String @default("1")
isDone Boolean @default(false)
user User @relation(fields: [userId], references: [id])
userId Int
createdAt DateTime @default(now())
psl=}
// TODO: add functionality to allow users to send messages to admin
// and make them accessible via the admin dashboard
entity ContactFormMessage {=psl
@ -210,10 +220,10 @@ page SignupPage {
// component: import { EmailVerification } from "@client/auth/EmailVerification",
// }
route GptRoute { path: "/gpt", to: GptPage }
page GptPage {
route DemoAppRoute { path: "/demo-app", to: DemoAppPage }
page DemoAppPage {
authRequired: true,
component: import GptPage from "@client/app/GptPage"
component: import DemoAppPage from "@client/app/DemoAppPage"
}
route PricingPageRoute { path: "/pricing", to: PricingPage }
@ -301,7 +311,22 @@ page AdminUIButtonsPage {
action generateGptResponse {
fn: import { generateGptResponse } from "@server/actions.js",
entities: [User, GptResponse]
entities: [User, Task, GptResponse]
}
action createTask {
fn: import { createTask } from "@server/actions.js",
entities: [Task]
}
action deleteTask {
fn: import { deleteTask } from "@server/actions.js",
entities: [Task]
}
action updateTask {
fn: import { updateTask } from "@server/actions.js",
entities: [Task]
}
action stripePayment {
@ -326,6 +351,11 @@ query getGptResponses {
entities: [User, GptResponse]
}
query getAllTasksByUser {
fn: import { getAllTasksByUser } from "@server/queries.js",
entities: [Task]
}
query getDailyStats {
fn: import { getDailyStats } from "@server/queries.js",
entities: [User, DailyStats]

View File

@ -1,21 +1,10 @@
import { useState, useEffect } from 'react';
import { User } from '@wasp/entities';
import { useQuery } from '@wasp/queries';
import getGptResponses from '@wasp/queries/getGptResponses';
import logout from '@wasp/auth/logout';
import { Link } from '@wasp/router';
import { STRIPE_CUSTOMER_PORTAL_LINK } from '@wasp/shared/constants';
import { TierIds } from '@wasp/shared/constants';
export default function AccountPage({ user }: { user: User }) {
const [ lastGptResponse, setLastGptResponse ] = useState<string[]>([]);
const { data: gptResponses, isLoading: isLoadingGptResponses } = useQuery(getGptResponses);
useEffect(() => {
if (gptResponses && gptResponses.length > 0) {
setLastGptResponse(gptResponses[gptResponses.length - 1].content.split('\n'));
}
}, [gptResponses]);
return (
<div className='mt-10 px-6'>
<div className='overflow-hidden bg-white ring-1 ring-gray-900/10 shadow-lg sm:rounded-lg lg:m-8 '>
@ -24,10 +13,18 @@ export default function AccountPage({ user }: { user: User }) {
</div>
<div className='border-t border-gray-200 px-4 py-5 sm:p-0'>
<dl className='sm:divide-y sm:divide-gray-200'>
<div className='py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:px-6'>
<dt className='text-sm font-medium text-gray-500'>Email address</dt>
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>{user.email}</dd>
</div>
{!!user.email && (
<div className='py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:px-6'>
<dt className='text-sm font-medium text-gray-500'>Email address</dt>
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>{user.email}</dd>
</div>
)}
{!!user.username && (
<div className='py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:px-6'>
<dt className='text-sm font-medium text-gray-500'>Username</dt>
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>{user.username}</dd>
</div>
)}
<div className='py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:px-6'>
<dt className='text-sm font-medium text-gray-500'>Your Plan</dt>
{user.hasPaid ? (
@ -56,15 +53,6 @@ export default function AccountPage({ user }: { user: User }) {
<dt className='text-sm font-medium text-gray-500'>About</dt>
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>I'm a cool customer.</dd>
</div>
<div className='py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:px-6'>
<dt className='text-sm font-medium text-gray-500'>Most Recent GPT Response</dt>
<dd className='flex flex-col gap-2 mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>
{isLoadingGptResponses
? 'Loading...'
: lastGptResponse.length > 0 ? lastGptResponse.map((str) => <p key={str}>{str}</p>)
: "You don't have any at this time."}
</dd>
</div>
</dl>
</div>
</div>

View File

@ -0,0 +1,272 @@
import { useState, useEffect, useMemo } from 'react';
import generateGptResponse from '@wasp/actions/generateGptResponse';
import deleteTask from '@wasp/actions/deleteTask';
import updateTask from '@wasp/actions/updateTask';
import createTask from '@wasp/actions/createTask';
import { useQuery } from '@wasp/queries';
import getAllTasksByUser from '@wasp/queries/getAllTasksByUser';
import { Task } from '@wasp/entities';
import { CgSpinner } from 'react-icons/cg';
export default function DemoAppPage() {
return (
<div className='my-10 lg:mt-20'>
<div className='mx-auto max-w-7xl px-6 lg:px-8'>
<div className='mx-auto max-w-4xl text-center'>
<h2 className='mt-2 text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl dark:text-white'>
Create your AI-powered <span className='text-yellow-500'>SaaS</span>
</h2>
</div>
<p className='mx-auto mt-6 max-w-2xl text-center text-lg leading-8 text-gray-600 dark:text-white'>
Below is an example of integrating the OpenAI API into your SaaS.
</p>
{/* begin AI-powered Todo List */}
<div className='my-8 border rounded-3xl border-gray-900/10'>
<div className='sm:w-[90%] md:w-[70%] lg:w-[50%] py-10 px-6 mx-auto my-8 space-y-10'>
<NewTaskForm handleCreateTask={createTask} />
</div>
</div>
{/* end AI-powered Todo List */}
</div>
</div>
);
}
type TodoProps = Pick<Task, 'id' | 'isDone' | 'description' | 'time'>;
function Todo({ id, isDone, description, time }: TodoProps) {
const handleCheckboxChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
await updateTask({ id, isDone: e.currentTarget.checked });
};
const handleTimeChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
await updateTask({ id, time: e.currentTarget.value });
};
const handleDeleteClick = async () => {
await deleteTask({ id });
};
return (
<div className='flex items-center justify-between bg-purple-50 rounded-lg border border-gray-200 p-2 w-full'>
<div className='flex items-center justify-between gap-5 w-full'>
<div className='flex items-center gap-3'>
<input
type='checkbox'
className='ml-1 form-checkbox bg-purple-500 checked:bg-purple-300 rounded border-purple-600 duration-200 ease-in-out hover:bg-purple-600 hover:checked:bg-purple-600 focus:ring focus:ring-purple-300 focus:checked:bg-purple-400 focus:ring-opacity-50'
checked={isDone}
onChange={handleCheckboxChange}
/>
<span className={`text-slate-600 ${isDone ? 'line-through text-slate-500' : ''}`}>{description}</span>
</div>
<div className='flex items-center gap-2'>
<input
id='time'
type='number'
min={0.5}
step={0.5}
className={`w-15 h-8 text-center text-slate-600 text-xs rounded border border-gray-200 focus:outline-none focus:border-transparent focus:ring-2 focus:ring-purple-300 focus:ring-opacity-50 ${
isDone && 'pointer-events-none opacity-50'
}`}
value={time}
onChange={handleTimeChange}
/>
<span className={`italic text-slate-600 text-xs ${isDone ? 'text-slate-500' : ''}`}>hrs</span>
</div>
</div>
<div className='flex items-center justify-end w-15'>
<button className={`p-1 ${!isDone ? 'hidden' : ''}`} onClick={handleDeleteClick}>
</button>
</div>
</div>
);
}
function NewTaskForm({ handleCreateTask }: { handleCreateTask: typeof createTask }) {
const [description, setDescription] = useState<string>('');
const [todaysHours, setTodaysHours] = useState<string>('8');
const [response, setResponse] = useState<any>(null);
const [isPlanGenerating, setIsPlanGenerating] = useState<boolean>(false);
const { data: tasks, isLoading: isTasksLoading } = useQuery(getAllTasksByUser);
useEffect(() => {
console.log('response', response);
}, [response]);
const handleSubmit = async () => {
try {
await handleCreateTask({ description });
setDescription('');
} catch (err: any) {
window.alert('Error: ' + (err.message || 'Something went wrong'));
}
};
const handleGeneratePlan = async () => {
try {
setIsPlanGenerating(true);
const response = await generateGptResponse({ hours: todaysHours });
if (response) {
console.log('response', response);
setResponse(JSON.parse(response));
}
} catch (err: any) {
window.alert('Error: ' + (err.message || 'Something went wrong'));
} finally {
setIsPlanGenerating(false);
}
};
return (
<div className='flex flex-col justify-center gap-10'>
<div className='flex flex-col gap-3'>
<div className='flex items-center justify-between gap-3'>
<input
type='text'
id='description'
className='text-sm text-gray-600 w-full rounded-md border border-gray-200 bg-[#f5f0ff] shadow-md focus:outline-none focus:border-transparent focus:shadow-none duration-200 ease-in-out hover:shadow-none'
placeholder='Enter task description'
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
/>
<button
type='button'
onClick={handleSubmit}
className='min-w-[7rem] font-medium text-gray-800/90 bg-yellow-50 shadow-md ring-1 ring-inset ring-slate-200 py-2 px-4 rounded-md hover:bg-yellow-100 duration-200 ease-in-out focus:outline-none focus:shadow-none hover:shadow-none'
>
Add Task
</button>
</div>
</div>
<div className='space-y-10 col-span-full'>
{isTasksLoading && <div>Loading...</div>}
{tasks!! && tasks.length > 0 ? (
<div className='space-y-4'>
{tasks.map((task: Task) => (
<Todo key={task.id} id={task.id} isDone={task.isDone} description={task.description} time={task.time} />
))}
<div className='flex flex-col gap-3'>
<div className='flex items-center justify-between gap-3'>
<label htmlFor='time' className='text-sm text-gray-600 text-nowrap font-semibold'>
How many hours will you work today?
</label>
<input
type='number'
id='time'
step={0.5}
min={1}
max={24}
className='min-w-[7rem] text-gray-800/90 text-center font-medium rounded-md border border-gray-200 bg-yellow-50 hover:bg-yellow-100 shadow-md focus:outline-none focus:border-transparent focus:shadow-none duration-200 ease-in-out hover:shadow-none'
value={todaysHours}
onChange={(e) => setTodaysHours(e.currentTarget.value)}
/>
</div>
</div>
</div>
) : (
<div className='text-gray-600 text-center'>Add tasks to begin</div>
)}
</div>
<button
type='button'
disabled={ isPlanGenerating || tasks?.length === 0 }
onClick={() => handleGeneratePlan()}
className='flex items-center justify-center min-w-[7rem] font-medium text-gray-800/90 bg-yellow-50 shadow-md ring-1 ring-inset ring-slate-200 py-2 px-4 rounded-md hover:bg-yellow-100 duration-200 ease-in-out focus:outline-none focus:shadow-none hover:shadow-none disabled:opacity-70 disabled:cursor-not-allowed'
>
{isPlanGenerating ?
<>
<CgSpinner className='inline-block mr-2 animate-spin' />
Generating...
</>
:
'Generate Schedule'}
</button>
{!!response && (
<div className='flex flex-col'>
<h3 className='text-lg font-semibold text-gray-900 dark:text-white'>Today's Schedule</h3>
<TaskTable schedule={response.schedule} />
</div>
)}
</div>
);
}
function TaskTable({ schedule }: { schedule: any[] }) {
return (
<div className='flex flex-col gap-6 py-6'>
{schedule.map((task: any) => (
<table
key={task.name}
className='table-auto w-full border-separate border border-spacing-2 rounded-md border-slate-200 shadow-sm'
>
<thead>
<tr>
<th
className={`flex items-center justify-between gap-5 py-4 px-3 text-slate-800 border rounded-md border-slate-200 ${
task.priority === 'high' ? 'bg-red-50' : task.priority === 'low' ? 'bg-green-50' : 'bg-yellow-50'
}`}
>
<span>{task.name}</span>
<span className='opacity-70 text-xs font-medium italic'> {task.priority} priority</span>
</th>
</tr>
</thead>
<tbody className=''>
{task.subtasks.map((subtask: { description: any; time: any }) => (
<tr>
<td
className={`flex items-center justify-between py-2 px-3 text-slate-600 border rounded-md border-purple-100 bg-purple-50`}
>
<Subtask description={subtask.description} time={subtask.time} />
</td>
</tr>
))}
{task.breaks.map((breakItem: { description: any; time: any }) => (
<tr key={breakItem.description}>
<td
className={`flex items-center justify-between py-2 px-3 text-slate-600 border rounded-md border-purple-100 bg-purple-50`}
>
<Subtask description={breakItem.description} time={breakItem.time} />
</td>
</tr>
))}
</tbody>
</table>
))}
</div>
);
}
function Subtask({ description, time }: { description: string; time: number }) {
const [isDone, setIsDone] = useState<boolean>(false);
const convertHrsToMinutes = (time: number) => {
if (time === 0) return 0;
const hours = Math.floor(time);
const minutes = Math.round((time - hours) * 60);
return `${hours > 0 ? hours + 'hr' : ''} ${minutes > 0 ? minutes + 'min' : ''}`;
}
const minutes = useMemo(() => convertHrsToMinutes(time), [time]);
return (
<>
<input
type='checkbox'
className='ml-1 form-checkbox bg-purple-500 checked:bg-purple-300 rounded border-purple-600 duration-200 ease-in-out hover:bg-purple-600 hover:checked:bg-purple-600 focus:ring focus:ring-purple-300 focus:checked:bg-purple-400 focus:ring-opacity-50'
checked={isDone}
onChange={(e) => setIsDone(e.currentTarget.checked)}
/>
<span className={`text-slate-600 ${isDone ? 'line-through text-slate-500 opacity-50' : ''}`}>{description}</span>
<span className={`text-slate-600 ${isDone ? 'line-through text-slate-500 opacity-50' : ''}`}>{minutes}</span>
</>
);
}

View File

@ -1,145 +0,0 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import generateGptResponse from '@wasp/actions/generateGptResponse';
import useAuth from '@wasp/auth/useAuth';
export default function GptPage() {
const [temperature, setTemperature] = useState<number>(1);
const [response, setResponse] = useState<string[]>([]);
const { data: user } = useAuth();
const onSubmit = async ({ instructions, command, temperature }: any) => {
if (!user) {
alert('You must be logged in to use this feature.');
return;
}
try {
const response = await generateGptResponse({ instructions, command, temperature });
if (response) {
setResponse(response.split('\n'));
}
} catch (error: any) {
alert(error.message);
console.error(error);
}
};
const {
handleSubmit,
register,
reset,
formState: { errors: formErrors, isSubmitting },
} = useForm();
return (
<div className='my-10 lg:mt-20 dark:bg-boxdark-2'>
<div className='mx-auto max-w-7xl px-6 lg:px-8 dark:bg-boxdark'>
<div id='pricing' className='mx-auto max-w-4xl text-center'>
<h2 className='mt-2 text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl dark:text-white'>
Create your AI-powered <span className='text-yellow-500'>SaaS</span>
</h2>
</div>
<p className='mx-auto mt-6 max-w-2xl text-center text-lg leading-8 text-gray-600 dark:text-white'>
Below is an example of integrating the OpenAI API into your SaaS.
</p>
<form onSubmit={handleSubmit(onSubmit)} className='py-8 mt-10 sm:mt-20 ring-1 ring-gray-200 rounded-lg'>
<div className='space-y-6 sm:w-[90%] md:w-[60%] mx-auto border-b border-gray-900/10 px-6 pb-12'>
<div className='col-span-full'>
<label htmlFor='instructions' className='block text-sm font-medium leading-6 text-gray-900 dark:text-white'>
Instructions -- How should GPT behave?
</label>
<div className='mt-2'>
<textarea
id='instructions'
placeholder='You are a career advice assistant. You are given a prompt and you must respond with of career advice and 10 actionable items.'
rows={3}
className='block w-full rounded-md border-0 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:py-1.5 sm:text-sm sm:leading-6'
defaultValue={''}
{...register('instructions', {
required: 'This is required',
minLength: {
value: 5,
message: 'Minimum length should be 5',
},
})}
/>
</div>
<span className='text-sm text-red-500'>
{typeof formErrors?.instructions?.message === 'string' ? formErrors.instructions.message : null}
</span>
</div>
<div className='col-span-full'>
<label htmlFor='command' className='block text-sm font-medium leading-6 text-gray-900 dark:text-white'>
Command -- What should GPT do?
</label>
<div className='mt-2'>
<textarea
id='command'
placeholder='How should I prepare for opening my own speciatly-coffee shop?'
rows={3}
className='block w-full rounded-md border-0 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:py-1.5 sm:text-sm sm:leading-6'
defaultValue={''}
{...register('command', {
required: 'This is required',
minLength: {
value: 5,
message: 'Minimum length should be 5',
},
})}
/>
</div>
<span className='text-sm text-red-500'>
{typeof formErrors?.command?.message === 'string' ? formErrors.command.message : null}
</span>
</div>
<div className='h-10'>
<label htmlFor='temperature' className='w-full text-gray-700 text-sm font-semibold dark:text-white'>
Temperature Input -- Controls How Random GPT's Output is
</label>
<div className='w-32 mt-2'>
<div className='flex flex-row h-10 w-full rounded-lg relative rounded-md border-0 ring-1 ring-inset ring-gray-300 bg-transparent mt-1'>
<input
type='number'
className='outline-none focus:outline-none border-0 rounded-md ring-1 ring-inset ring-gray-300 text-center w-full font-semibold text-md hover:text-black focus:text-black md:text-basecursor-default flex items-center text-gray-700 outline-none'
value={temperature}
min='0'
max='2'
step='0.1'
{...register('temperature', {
onChange: (e) => {
setTemperature(Number(e.target.value));
},
required: 'This is required',
})}
></input>
</div>
</div>
</div>
</div>
<div className='mt-6 flex justify-end gap-x-6 sm:w-[90%] md:w-[50%] mx-auto'>
<button
type='submit'
className={`${
isSubmitting && 'opacity-70 cursor-wait'
} rounded-md bg-yellow-500 py-2 px-3 text-sm font-semibold text-white shadow-sm hover:bg-yellow-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600`}
>
{!isSubmitting ? 'Submit' : 'Loading...'}
</button>
</div>
</form>
<div
className={`${
isSubmitting && 'animate-pulse'
} mt-4 mx-6 flex justify-center rounded-lg border border-dashed border-gray-900/25 dark:border-white sm:w-[90%] md:w-[50%] mx-auto mt-12 px-6 py-10`}
>
<div className='space-y-2 flex flex-col gap-2 text-center text-sm text-gray-500 w-full dark:text-white'>
{response.length > 0 ? response.map((str) => <p key={str}>{str}</p>) : <p>GPT Response will load here</p>}
</div>
</div>
</div>
</div>
);
}

View File

@ -1,19 +1,25 @@
import { useState } from 'react';
import { Dialog } from '@headlessui/react';
import { BiLogIn } from 'react-icons/bi';
import { AiFillCloseCircle } from 'react-icons/ai';
import { HiBars3 } from 'react-icons/hi2';
import useAuth from '@wasp/auth/useAuth';
import logo from '../static/logo.png';
import DropdownUser from './DropdownUser';
import { DOCS_URL, BLOG_URL } from '@wasp/shared/constants';
import DarkModeSwitcher from '../admin/components/DarkModeSwitcher';
import { UserMenuItems } from '../components/UserMenuItems';
import { Link } from '@wasp/router';
const navigation = [
{ name: 'GPT Wrapper', href: '/gpt' },
{ name: 'AI Scheduler (Demo App)', href: '/demo-app' },
{ name: 'Pricing', href: '/pricing'},
{ name: 'Documentation', href: DOCS_URL },
{ name: 'Blog', href: BLOG_URL },
];
const NavLogo = () => <img className='h-8 w-8' src={logo} alt='Your SaaS App' />;
export default function AppNavBar() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
@ -65,8 +71,52 @@ export default function AppNavBar() {
</div>
)}
</div>
</nav>
<Dialog as='div' className='lg:hidden' open={mobileMenuOpen} onClose={setMobileMenuOpen}>
<div className='fixed inset-0 z-50' />
<Dialog.Panel className='fixed inset-y-0 right-0 z-50 w-full overflow-y-auto bg-white px-6 py-6 sm:max-w-sm sm:ring-1 sm:ring-gray-900/10'>
<div className='flex items-center justify-between'>
<a href='/' className='-m-1.5 p-1.5'>
<span className='sr-only'>Your SaaS</span>
<NavLogo />
</a>
<button
type='button'
className='-m-2.5 rounded-md p-2.5 text-gray-700'
onClick={() => setMobileMenuOpen(false)}
>
<span className='sr-only'>Close menu</span>
<AiFillCloseCircle className='h-6 w-6' aria-hidden='true' />
</button>
</div>
<div className='mt-6 flow-root'>
<div className='-my-6 divide-y divide-gray-500/10'>
<div className='space-y-2 py-6'>
{navigation.map((item) => (
<a
key={item.name}
href={item.href}
className='-mx-3 block rounded-lg px-3 py-2 text-base font-semibold leading-7 text-gray-900 hover:bg-gray-50'
>
{item.name}
</a>
))}
</div>
<div className='py-6'>
{isUserLoading ? null : !user ? (
<Link to='/login'>
<div className='flex justify-end items-center duration-300 ease-in-out text-gray-900 hover:text-yellow-500'>
Log in <BiLogIn size='1.1rem' className='ml-1' />
</div>
</Link>
) : (
<UserMenuItems user={user} />
)}
</div>
</div>
</div>
</Dialog.Panel>
</Dialog>
</header>
);
}

View File

@ -11,11 +11,11 @@ export const UserMenuItems = ({ user }: { user?: Partial<User> }) => {
<ul className='flex flex-col gap-5 border-b border-stroke px-6 py-4 dark:border-strokedark'>
<li>
<Link
to='/gpt'
to='/demo-app'
className='flex items-center gap-3.5 text-sm font-medium duration-300 ease-in-out hover:text-yellow-500'
>
<MdOutlineSpaceDashboard size='1.1rem' />
App
AI Scheduler (Demo App)
</Link>
</li>
<li>

View File

@ -1,10 +1,10 @@
import Stripe from 'stripe';
import fetch from 'node-fetch';
import HttpError from '@wasp/core/HttpError.js';
import type { GptResponse, User } from '@wasp/entities';
import type { User, Task } from '@wasp/entities';
import type { GenerateGptResponse, StripePayment } from '@wasp/actions/types';
import type { StripePaymentResult, OpenAIResponse } from './types';
import { UpdateCurrentUser, UpdateUserById } from '@wasp/actions/types';
import type { StripePaymentResult } from './types';
import { UpdateCurrentUser, UpdateUserById, CreateTask, DeleteTask, UpdateTask } from '@wasp/actions/types';
import { fetchStripeCustomer, createStripeCheckoutSession } from './stripeUtils.js';
import { TierIds } from '@wasp/shared/constants.js';
@ -51,32 +51,103 @@ export const stripePayment: StripePayment<string, StripePaymentResult> = async (
};
type GptPayload = {
instructions: string;
command: string;
temperature: number;
hours: string;
};
export const generateGptResponse: GenerateGptResponse<GptPayload, string> = async (
{ instructions, command, temperature },
context
) => {
export const generateGptResponse: GenerateGptResponse<GptPayload, string> = async ({ hours }, context) => {
if (!context.user) {
throw new HttpError(401);
}
const tasks = await context.entities.Task.findMany({
where: {
user: {
id: context.user.id,
},
},
});
// use map to extract the description and time from each task
const parsedTasks = tasks.map(({ description, time }) => ({ description, time }));
const payload = {
model: 'gpt-3.5-turbo',
model: 'gpt-3.5-turbo', // e.g. 'gpt-3.5-turbo', 'gpt-4', 'gpt-4-0613', gpt-4-1106-preview
messages: [
{
role: 'system',
content: instructions,
content:
'you are an expert daily planner and scheduling assistant. you will be given a list of main tasks and an estimated time to complete each task. You will also receive the total amount of hours to be worked that day. Your job is to return a detailed plan of how to achieve those tasks throughout the day by breaking down the main tasks provided by the user into multiple subtasks. Be specific in your reply and offer advice on the best way to fulfill each task. Please also schedule in standing, water, lunch, and coffee breaks. Plan the higher priority tasks first. Also give the total amount of time to be spent on each (sub)task after considering all of the above. ',
},
{
role: 'user',
content: command,
content: `I will work ${hours} today. Here are the tasks I have to complete: ` + JSON.stringify(parsedTasks),
},
],
temperature: Number(temperature),
functions: [
{
name: 'parseTodaysSchedule',
description: 'parses the days tasks and returns a schedule',
parameters: {
type: 'object',
properties: {
schedule: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Name of main task provided by user' },
subtasks: {
type: 'array',
items: {
type: 'object',
properties: {
description: {
type: 'string',
description:
'detailed breakdown and description of sub-task related to main task. e.g., "Prepare your learning session by first reading through the documentation"',
},
time: {
type: 'number',
description: 'time allocated for a given subtask in hours, e.g. 0.5',
},
},
},
},
breaks: {
type: 'array',
items: {
type: 'object',
properties: {
description: {
type: 'string',
description:
'detailed breakdown and description of break. e.g., "take a 15 minute standing break and reflect on what you have learned".',
},
time: { type: 'number', description: 'time allocated for a given break in hours, e.g. 0.2' },
},
},
},
time: {
type: 'number',
description: 'total time in it takes to complete given main task in hours, e.g. 2.75',
},
priority: {
type: 'string',
enum: ['low', 'medium', 'high'],
description: 'task priority',
},
},
},
},
},
required: ['schedule'],
},
},
],
function_call: {
name: 'parseTodaysSchedule',
},
temperature: 1,
};
try {
@ -103,18 +174,23 @@ export const generateGptResponse: GenerateGptResponse<GptPayload, string> = asyn
body: JSON.stringify(payload),
});
const json = (await response.json()) as OpenAIResponse;
if (!response.ok) {
throw new HttpError(500, 'Bad response from OpenAI');
}
let json = (await response.json()) as any;
const gptArgs = json.choices[0].message.function_call.arguments;
if (!json?.choices[0].message.content) {
throw new HttpError(500, 'No response from OpenAI');
}
await context.entities.GptResponse.create({
data: {
content: json?.choices[0].message.content,
user: { connect: { id: context.user.id } },
content: JSON.stringify(gptArgs),
},
});
return json?.choices[0].message.content;
return gptArgs;
} catch (error: any) {
if (!context.user.hasPaid && error?.statusCode != 402) {
await context.entities.User.update({
@ -133,6 +209,53 @@ export const generateGptResponse: GenerateGptResponse<GptPayload, string> = asyn
}
};
export const createTask: CreateTask<Pick<Task, 'description'>, Task> = async ({ description }, context) => {
if (!context.user) {
throw new HttpError(401);
}
const task = await context.entities.Task.create({
data: {
description,
user: { connect: { id: context.user.id } },
},
});
return task;
};
export const updateTask: UpdateTask<Partial<Task>, Task> = async ({ id, isDone, time }, context) => {
if (!context.user) {
throw new HttpError(401);
}
const task = await context.entities.Task.update({
where: {
id,
},
data: {
isDone,
time,
},
});
return task;
};
export const deleteTask: DeleteTask<Pick<Task, 'id'>, Task> = async ({ id }, context) => {
if (!context.user) {
throw new HttpError(401);
}
const task = await context.entities.Task.delete({
where: {
id,
},
});
return task;
};
export const updateUserById: UpdateUserById<{ id: number; data: Partial<User> }, User> = async (
{ id, data },
context

View File

@ -1,9 +1,10 @@
import HttpError from '@wasp/core/HttpError.js';
import type { DailyStats, GptResponse, User, PageViewSource } from '@wasp/entities';
import type { DailyStats, GptResponse, User, PageViewSource, Task } from '@wasp/entities';
import type {
GetGptResponses,
GetDailyStats,
GetPaginatedUsers,
GetAllTasksByUser
} from '@wasp/queries/types';
type DailyStatsWithSources = DailyStats & {
@ -28,6 +29,22 @@ export const getGptResponses: GetGptResponses<void, GptResponse[]> = async (args
});
};
export const getAllTasksByUser: GetAllTasksByUser<void, Task[]> = async (_args, context) => {
if (!context.user) {
throw new HttpError(401);
}
return context.entities.Task.findMany({
where: {
user: {
id: context.user.id,
},
},
orderBy: {
createdAt: 'desc',
},
});
}
export const getDailyStats: GetDailyStats<void, DailyStatsValues> = async (_args, context) => {
if (!context.user?.isAdmin) {
throw new HttpError(401);