diff --git a/README.md b/README.md index 3b18e51..1c4d3a3 100644 --- a/README.md +++ b/README.md @@ -1 +1,49 @@ -hello world +# Create Rubric App + +This project is bootstrapped with [`create-rubric-app`](https://github.com/RubricLab/create-rubric-app). + +## Getting Started + +### 1. Install dependencies + +```sh +npm i +``` + +```sh +bun i +``` + +### 2. Set up the DB + +```sh +npm run db:push +``` + +```sh +bun db:push +``` + +### 3. Run the development server + +```sh +npm run dev +``` + +```sh +bun dev +``` + +Open [localhost:3000](http://localhost:3000) in your browser to see the result. + +You can start modifying the UI by editing [src/app/page.tsx](./src/app/(app)/page.tsx). The page auto-updates as you edit the file. + +### Deployment + +To serve your app to users, simply deploy the Next.js app eg. on [Railway](https://railway.app/new) or [Vercel](https://deploy.new/). + +To persist data, you'll need a database. Both [Railway](https://docs.railway.app/databases/postgresql) and [Vercel](https://vercel.com/docs/storage/vercel-postgres) provide Postgres DBs. + +## Learn More + +To learn more about this project, take a look at this [blog post](https://rubriclabs.com/blog/create-rubric-app). diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..e8d99dd --- /dev/null +++ b/biome.json @@ -0,0 +1,3 @@ +{ + "extends": ["@rubriclab/config/biome"] +} diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..0f34b5f --- /dev/null +++ b/next.config.ts @@ -0,0 +1,15 @@ +import type { NextConfig } from 'next' + +export default { + reactStrictMode: true, + transpilePackages: [ + '@rubriclab/actions', + '@rubriclab/agents', + '@rubriclab/auth', + '@rubriclab/blocks', + '@rubriclab/chains', + '@rubriclab/events', + '@rubriclab/shapes', + '@rubriclab/webhooks' + ] +} satisfies NextConfig diff --git a/package.json b/package.json new file mode 100644 index 0000000..955c54a --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "dependencies": { + "@prisma/client": "^6.14.0", + "@rubriclab/agents": "^0.0.58", + "@rubriclab/auth": "^0.0.50", + "@rubriclab/events": "^0.0.37", + "@t3-oss/env-nextjs": "^0.13.8", + "dotenv": "^17.2.1", + "next": "^15.5.1", + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "description": "This project was bootstrapped with create-rubric-app", + "devDependencies": { + "@rubriclab/config": "^0.0.22", + "@types/node": "^24.3.0", + "@types/react": "^19.1.11", + "@types/react-dom": "^19.1.8", + "prisma": "^6.14.0", + "typescript": "^5.9.2", + "zod": "^4.1.3" + }, + "license": "go nuts", + "name": "my-app", + "private": true, + "scripts": { + "bleed": "bun x npm-check-updates -u --dep prod,dev,optional,peer", + "build": "next build", + "check": "bun x biome check .", + "clean": "rm -rf .next && rm -rf node_modules", + "db:generate": "prisma generate", + "db:push": "bun --env-file=.env prisma generate && prisma db push", + "db:seed": "prisma db seed", + "db:studio": "prisma studio", + "dev": "next dev", + "format": "bun x biome check --write .", + "start": "next start" + }, + "version": "0.0.0" +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..fc6c4d8 --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1 @@ +export { default } from '@rubriclab/config/postcss' diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 0000000..8e9db6e --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,9 @@ +import 'dotenv/config' +import { defineConfig } from 'prisma/config' + +export default defineConfig({ + migrations: { + seed: 'bun run prisma/seed.ts' + }, + schema: 'prisma' +}) diff --git a/prisma/auth.prisma b/prisma/auth.prisma new file mode 100644 index 0000000..5919aae --- /dev/null +++ b/prisma/auth.prisma @@ -0,0 +1,72 @@ +model User { + id String @id @default(nanoid(6)) + email String @unique + + oAuth2AuthenticationAccounts OAuth2AuthenticationAccount[] + oAuth2AuthorizationAccounts OAuth2AuthorizationAccount[] + apiKeyAuthorizationAccounts ApiKeyAuthorizationAccount[] + + sessions Session[] + + tasks Task[] +} + +model OAuth2AuthenticationRequest { + token String @id + callbackUrl String + expiresAt DateTime +} + +model OAuth2AuthorizationRequest { + token String @id + userId String + callbackUrl String + expiresAt DateTime +} + +model MagicLinkRequest { + token String @id + email String + expiresAt DateTime +} + +model OAuth2AuthenticationAccount { + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + provider String + accountId String + accessToken String + refreshToken String + expiresAt DateTime + + @@id([userId, provider, accountId]) +} + +model OAuth2AuthorizationAccount { + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + provider String + accountId String + accessToken String + refreshToken String + expiresAt DateTime + + @@id([userId, provider, accountId]) +} + +model ApiKeyAuthorizationAccount { + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + provider String + accountId String + apiKey String + + @@id([userId, provider, accountId]) +} + +model Session { + key String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + expiresAt DateTime +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..a6210d5 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,18 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Task { + id Int @id @default(autoincrement()) + title String + createdAt DateTime @default(now()) + status Boolean @default(false) + + user User? @relation(fields: [userId], references: [id]) + userId String? +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..374c587 --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,14 @@ +#!/usr/bin/env bun + +import db from '~/db' + +async function seed() { + await db.task.create({ + data: { + status: false, + title: 'Create your first task' + } + }) +} + +seed() diff --git a/public/fonts/PlusJakartaSans-Bold.ttf b/public/fonts/PlusJakartaSans-Bold.ttf new file mode 100644 index 0000000..386d3a6 Binary files /dev/null and b/public/fonts/PlusJakartaSans-Bold.ttf differ diff --git a/src/app/(app)/ai.tsx b/src/app/(app)/ai.tsx new file mode 100644 index 0000000..c9e187d --- /dev/null +++ b/src/app/(app)/ai.tsx @@ -0,0 +1,34 @@ +'use server' + +import { executeTodoAgent } from '~/agents/todo' +import env from '~/env' +import { publish } from '~/events/server' + +export async function sendMessage({ userId, message }: { userId: string; message: string }) { + const { response } = await executeTodoAgent({ + messages: [{ content: message, role: 'user' }], + onEvent: async events => { + switch (events.type) { + case 'assistant_message': { + await publish({ + channel: userId, + eventType: events.type, + payload: events + }) + break + } + case 'function_call': { + await publish({ + channel: userId, + eventType: events.name, + payload: events + }) + break + } + } + }, + openAIKey: env.OPENAI_API_KEY + }) + + console.log(response) +} diff --git a/src/app/(app)/chat.tsx b/src/app/(app)/chat.tsx new file mode 100644 index 0000000..cfb546c --- /dev/null +++ b/src/app/(app)/chat.tsx @@ -0,0 +1,90 @@ +'use client' + +import { useState } from 'react' +import type { TodoAgentResponseEvent, TodoAgentToolEvent } from '~/agents/todo' +import { useSession } from '~/auth/client' +import { ChatBox } from '~/components/ChatBox' +import { AssistantMessage, ToolMessage, UserMessage } from '~/components/Message' + +import { useEvents } from '~/events/client' +import { sendMessage } from './ai' + +type Message = + | TodoAgentToolEvent + | TodoAgentResponseEvent + | { + id: string + type: 'user_message' + message: string + } + +function MessageSwitch({ message }: { message: Message }) { + switch (message.type) { + case 'user_message': { + return {message.message} + } + + case 'assistant_message': { + return {message.message.response} + } + case 'function_call': { + return ( + + ) + } + } +} + +function ChatMessages({ + userId, + messages, + addMessage +}: { + userId: string + messages: Message[] + addMessage: (message: Message) => void +}) { + useEvents({ + id: userId, + on: { + assistant_message: addMessage + } + }) + + return ( +
+ {messages.map(message => ( + + ))} +
+ ) +} + +export default function Chat() { + const { userId } = useSession() + const [messages, setMessages] = useState([]) + + function addMessage(message: Message) { + setMessages(prev => [...prev, message]) + } + + function handleSubmit(message: string) { + addMessage({ + id: Date.now().toString(), + message, + type: 'user_message' + }) + sendMessage({ message, userId }) + } + + return ( +
+ + +
+ ) +} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx new file mode 100644 index 0000000..30354ff --- /dev/null +++ b/src/app/(app)/layout.tsx @@ -0,0 +1,17 @@ +import { getSession } from '~/auth/actions' +import { ClientAuthProvider } from '~/auth/client' +import { Nav } from '~/components/Nav' + +export default async function AppLayout({ children }: { children: React.ReactNode }) { + return ( + // @ts-expect-error: Auth Package Bug + +
+
+
+ ) +} diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx new file mode 100644 index 0000000..744f3c5 --- /dev/null +++ b/src/app/(app)/page.tsx @@ -0,0 +1,5 @@ +import Chat from './chat' + +export default function Page() { + return +} diff --git a/src/app/(landing)/signin/page.tsx b/src/app/(landing)/signin/page.tsx new file mode 100644 index 0000000..2f898e3 --- /dev/null +++ b/src/app/(landing)/signin/page.tsx @@ -0,0 +1,5 @@ +import { SignInWithGithubButton } from '~/components/SignIn' + +export default function SignInPage() { + return +} diff --git a/src/app/api/auth/[...auth]/route.ts b/src/app/api/auth/[...auth]/route.ts new file mode 100644 index 0000000..e772cb1 --- /dev/null +++ b/src/app/api/auth/[...auth]/route.ts @@ -0,0 +1,3 @@ +import { routes } from '~/auth/server' + +export const { GET } = routes diff --git a/src/app/api/events/route.ts b/src/app/api/events/route.ts new file mode 100644 index 0000000..f96bd80 --- /dev/null +++ b/src/app/api/events/route.ts @@ -0,0 +1 @@ +export { GET, maxDuration } from '~/events/server' diff --git a/src/app/icon.tsx b/src/app/icon.tsx new file mode 100644 index 0000000..9e70457 --- /dev/null +++ b/src/app/icon.tsx @@ -0,0 +1,30 @@ +import { ImageResponse } from 'next/og' + +export const contentType = 'image/png' +export const size = { + height: 32, + width: 32 +} + +export default async function Icon() { + return new ImageResponse( +
+ R +
, + { + ...size + } + ) +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..bb1ec7c --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,14 @@ +import './styles.css' + +export const metadata = { + description: 'Generated by Create Rubric App', + title: 'Create Rubric App' +} + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/src/app/opengraph-image.tsx b/src/app/opengraph-image.tsx new file mode 100644 index 0000000..d1e6475 --- /dev/null +++ b/src/app/opengraph-image.tsx @@ -0,0 +1,41 @@ +import { readFile } from 'node:fs/promises' +import { ImageResponse } from 'next/og' + +export const alt = 'Create Rubric App' +export const size = { + height: 630, + width: 1200 +} + +export const contentType = 'image/png' + +export default async function Image() { + const interSemiBold = await readFile('public/fonts/PlusJakartaSans-Bold.ttf') + + return new ImageResponse( +
+ R +
, + { + ...size, + fonts: [ + { + data: interSemiBold, + name: 'Inter', + style: 'normal', + weight: 400 + } + ] + } + ) +} diff --git a/src/app/styles.css b/src/app/styles.css new file mode 100644 index 0000000..cca18ce --- /dev/null +++ b/src/app/styles.css @@ -0,0 +1,3 @@ +/** biome-ignore-all lint/suspicious/noUnknownAtRules: Tailwind Grammar */ + +@import "tailwindcss"; diff --git a/src/app/twitter-image.tsx b/src/app/twitter-image.tsx new file mode 100644 index 0000000..90779fd --- /dev/null +++ b/src/app/twitter-image.tsx @@ -0,0 +1 @@ +export { alt, contentType, default, size } from './opengraph-image' diff --git a/src/lib/agents/todo.ts b/src/lib/agents/todo.ts new file mode 100644 index 0000000..a416c02 --- /dev/null +++ b/src/lib/agents/todo.ts @@ -0,0 +1,33 @@ +import { createAgent, createResponseFormat, noTabs } from '@rubriclab/agents' +import { z } from 'zod/v4' +import createTodo from '~/tools/createTodo' +import getTodoList from '~/tools/getTodoList' + +const responseFormat = createResponseFormat({ + name: 'todo_agent_response_format', + schema: z.object({ + response: z.string() + }) +}) + +const systemPrompt = noTabs` + You are a todo agent. + The user will ask you to do CRUD operations against a TODO database. + You should use tools to help them. +` + +const { executeAgent, eventTypes, __ToolEvent, __ResponseEvent } = createAgent({ + model: 'gpt-4.1-mini', + responseFormat, + systemPrompt, + tools: { + createTodo, + getTodoList + } +}) + +export { eventTypes as todoAgentEventTypes } +export { executeAgent as executeTodoAgent } + +export type TodoAgentToolEvent = typeof __ToolEvent +export type TodoAgentResponseEvent = typeof __ResponseEvent diff --git a/src/lib/auth/actions.ts b/src/lib/auth/actions.ts new file mode 100644 index 0000000..aa760fb --- /dev/null +++ b/src/lib/auth/actions.ts @@ -0,0 +1,5 @@ +'use server' + +import { actions } from './server' + +export const { signIn, signOut, sendMagicLink, getAuthConstants, getSession } = actions diff --git a/src/lib/auth/client.ts b/src/lib/auth/client.ts new file mode 100644 index 0000000..1156d65 --- /dev/null +++ b/src/lib/auth/client.ts @@ -0,0 +1,19 @@ +'use client' + +import type { Prisma } from '@prisma/client' +import { CreateAuthContext } from '@rubriclab/auth/client' + +export const { ClientAuthProvider, useSession } = + CreateAuthContext< + Prisma.SessionGetPayload<{ + include: { + user: { + include: { + apiKeyAuthorizationAccounts: true + oAuth2AuthenticationAccounts: true + oAuth2AuthorizationAccounts: true + } + } + } + }> + >() diff --git a/src/lib/auth/server.ts b/src/lib/auth/server.ts new file mode 100644 index 0000000..7f1e9fe --- /dev/null +++ b/src/lib/auth/server.ts @@ -0,0 +1,14 @@ +import { createAuth, createGithubAuthenticationProvider, prismaAdapter } from '@rubriclab/auth' +import db from '~/db' +import env from '~/env' + +export const { routes, actions, __types } = createAuth({ + authUrl: env.NEXT_PUBLIC_AUTH_URL, + databaseProvider: prismaAdapter(db), + oAuth2AuthenticationProviders: { + github: createGithubAuthenticationProvider({ + githubClientId: env.GITHUB_CLIENT_ID, + githubClientSecret: env.GITHUB_CLIENT_SECRET + }) + } +}) diff --git a/src/lib/components/ChatBox.tsx b/src/lib/components/ChatBox.tsx new file mode 100644 index 0000000..0982808 --- /dev/null +++ b/src/lib/components/ChatBox.tsx @@ -0,0 +1,51 @@ +'use client' + +import { type KeyboardEvent, useState } from 'react' + +export function ChatBox({ + submit, + placeholder = 'Type a message...' +}: { + submit: (message: string) => void + placeholder?: string +}) { + const [message, setMessage] = useState(placeholder) + + return ( +
+
+