diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000000000000000000000000000000000000..c17b5320a8a70d3212b44ac7bb6f6635cd1d1a5a --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json.schemastore.org/eslintrc", + "root": true, + "extends": [ + "next/core-web-vitals", + "prettier", + "plugin:tailwindcss/recommended" + ], + "plugins": ["tailwindcss"], + "rules": { + "tailwindcss/no-custom-classname": "off", + "tailwindcss/classnames-order": "off" + }, + "settings": { + "tailwindcss": { + "callees": ["cn", "cva"], + "config": "tailwind.config.js" + } + }, + "overrides": [ + { + "files": ["*.ts", "*.tsx"], + "parser": "@typescript-eslint/parser" + } + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..97db9d31fb21b1c10bfecb0a2f55f92a71e20045 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +node_modules +.pnp +.pnp.js + +# testing +coverage + +# next.js +.next/ +out/ +build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# turbo +.turbo + +.env +.vercel +.vscode +.env*.local + +# ts +tsconfig.tsbuildinfo \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000000000000000000000000000000000000..4b72bfb355445076b750e11c577b99333f225ed1 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,34 @@ +{ + "endOfLine": "lf", + "semi": true, + "singleQuote": true, + "arrowParens": "avoid", + "tabWidth": 2, + "useTabs": false, + "trailingComma": "all", + "bracketSpacing": true, + "importOrder": [ + "^(react/(.*)$)|^(react$)", + "^(next/(.*)$)|^(next$)", + "", + "", + "^types$", + "^@/types/(.*)$", + "^@/config/(.*)$", + "^@/lib/(.*)$", + "^@/hooks/(.*)$", + "^@/components/ui/(.*)$", + "^@/components/(.*)$", + "^@/registry/(.*)$", + "^@/styles/(.*)$", + "^@/app/(.*)$", + "", + "^[./]" + ], + "importOrderSeparation": false, + "importOrderSortSpecifiers": true, + "importOrderBuiltinModulesToTop": true, + "importOrderParserPlugins": ["typescript", "jsx", "decorators-legacy"], + "importOrderMergeDuplicateImports": true, + "importOrderCombineTypeAndValueImports": true +} diff --git a/.vscode.settings.json b/.vscode.settings.json new file mode 100644 index 0000000000000000000000000000000000000000..25357b6b91a1ca8107c1f04418b0ba51ac7cce79 --- /dev/null +++ b/.vscode.settings.json @@ -0,0 +1,3 @@ +{ + "prettier.prettierPath": "./node_modules/prettier/index.cjs" +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..75aa6d0a22c2f5a4289a84458560060db05cb226 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +FROM node:20 +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable +WORKDIR /app +COPY package.json pnpm-lock.yaml ./ +RUN pnpm i --frozen-lockfile + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps --link /app/node_modules ./node_modules +COPY --link . . + +RUN pnpm run build + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV production + +RUN \ + addgroup --system --gid 1001 nodejs; \ + adduser --system --uid 1001 nextjs + +COPY --from=builder --link /app/public ./public + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder --link --chown=1001:1001 /app/.next/standalone ./ +COPY --from=builder --link --chown=1001:1001 /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 7860 + +ENV PORT 7860 +ENV HOSTNAME 0.0.0.0 + +CMD ["node", "server.js"] diff --git a/app/(logout)/sign-in/page.tsx b/app/(logout)/sign-in/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7516b70783c0658bfb62f301a674012b2dacb7b8 --- /dev/null +++ b/app/(logout)/sign-in/page.tsx @@ -0,0 +1,18 @@ +import { auth } from '@/auth'; +import { LoginButton } from '@/components/LoginButton'; +import { redirect } from 'next/navigation'; + +export default async function SignInPage() { + const session = await auth(); + // redirect to home if user is already logged in + if (session?.user) { + redirect('/'); + } + + return ( +
+ + +
+ ); +} diff --git a/app/(logout)/unauthorized/page.tsx b/app/(logout)/unauthorized/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7e73cb3b8b54ce514c3e277e86604bcfa5d1d7c4 --- /dev/null +++ b/app/(logout)/unauthorized/page.tsx @@ -0,0 +1,24 @@ +import { auth } from '@/auth'; +import { redirect } from 'next/navigation'; +import { Button } from '@/components/ui/Button'; +import Link from 'next/link'; + +export default async function Unauthorized() { + const session = await auth(); + // redirect to home if user is already logged in + if (session?.user) { + redirect('/'); + } + + return ( +
+
+ You are not authorized to view this page. Please sign in with Landing + account to continue. +
+ +
+ ); +} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..74197d24391f596ce15dec509e3080fea9defbc0 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,2 @@ +export { GET, POST } from '@/auth'; +export const runtime = 'edge'; diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..e25246dcb07fe00309b3d5e9e773fdef72f232ef --- /dev/null +++ b/app/api/chat/route.ts @@ -0,0 +1,57 @@ +import { OpenAIStream, StreamingTextResponse } from 'ai'; +import OpenAI from 'openai'; + +import { auth } from '@/auth'; +import { + ChatCompletionMessageParam, + ChatCompletionContentPart, +} from 'openai/resources'; +import { MessageBase } from '../../../lib/types'; + +export const runtime = 'edge'; + +const openai = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, +}); + +export const POST = async (req: Request) => { + const json = await req.json(); + const { messages } = json as { + messages: MessageBase[]; + id: string; + url: string; + }; + + const session = await auth(); + if (!session?.user?.email) { + return new Response('Unauthorized', { + status: 401, + }); + } + + const formattedMessage: ChatCompletionMessageParam[] = messages.map( + message => { + const contentWithImage: ChatCompletionContentPart[] = [ + { + type: 'text', + text: message.content as string, + }, + ]; + return { + role: 'user', + content: contentWithImage, + }; + }, + ); + const res = await openai.chat.completions.create({ + model: 'gpt-4-vision-preview', + messages: formattedMessage, + temperature: 0.3, + stream: true, + max_tokens: 300, + }); + + const stream = OpenAIStream(res); + + return new StreamingTextResponse(stream); +}; diff --git a/app/api/sign/route.ts b/app/api/sign/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..299de6119b4bbf1f79ec2c74b6b90d6c1e782149 --- /dev/null +++ b/app/api/sign/route.ts @@ -0,0 +1,42 @@ +import { getPresignedUrl } from '@/lib/aws'; +import { withLogging } from '../../../lib/logger'; +import { nanoid } from '@/lib/utils'; + +/** + * @param req + * @returns + */ +export const POST = withLogging( + async ( + session, + json: { + id?: string; + fileName: string; + fileType: string; + }, + ): Promise => { + const user = session?.user?.email ?? 'anonymous'; + // if (!email) { + // return new Response('Unauthorized', { + // status: 401, + // }); + // } + + try { + const { fileName, fileType, id } = json; + + const signedFileName = `${user}/${id ?? nanoid()}/${fileName}`; + const res = await getPresignedUrl(signedFileName, fileType); + return Response.json({ + id, + signedUrl: res.url, + publicUrl: `https://${process.env.AWS_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${signedFileName}`, + fields: res.fields, + }); + } catch (error) { + return new Response((error as Error).message, { + status: 400, + }); + } + }, +); diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..3c3a694cacc0dcf457fb8fec0afb186493ea7679 --- /dev/null +++ b/app/api/upload/route.ts @@ -0,0 +1,51 @@ +import { auth } from '@/auth'; +import { createKVChat } from '@/lib/kv/chat'; +import { withLogging } from '@/lib/logger'; +import { ChatEntity, MessageBase } from '@/lib/types'; +import { nanoid } from '@/lib/utils'; +import { Session } from 'next-auth'; +import { revalidatePath } from 'next/cache'; + +/** + * @param req + * @returns + */ +export const POST = withLogging( + async ( + session, + json: { + id?: string; + url: string; + initMessages?: MessageBase[]; + }, + ): Promise => { + const user = session?.user?.email ?? 'anonymous'; + // if (!email) { + // return new Response('Unauthorized', { + // status: 401, + // }); + // } + + try { + const { id, url, initMessages } = json; + + const payload: ChatEntity = { + url, + id: id ?? nanoid(), + user, + messages: initMessages ?? [], + updatedAt: Date.now(), + }; + + await createKVChat(payload); + + revalidatePath('/chat', 'layout'); + + return Response.json(payload); + } catch (error) { + return new Response((error as Error).message, { + status: 400, + }); + } + }, +); diff --git a/app/api/vision-agent/route.ts b/app/api/vision-agent/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..d3abc53c526dc66fa137fda474e087500db9db0a --- /dev/null +++ b/app/api/vision-agent/route.ts @@ -0,0 +1,74 @@ +import { StreamingTextResponse } from 'ai'; + +// import { auth } from '@/auth'; +import { MessageBase } from '../../../lib/types'; +import { withLogging } from '@/lib/logger'; +import { CLEANED_SEPARATOR } from '@/lib/constants'; +import { cleanAnswerMessage, cleanInputMessage } from '@/lib/messageUtils'; + +// export const runtime = 'edge'; +export const dynamic = 'force-dynamic'; +export const maxDuration = 300; // This function can run for a maximum of 5 minutes + +export const POST = withLogging( + async ( + _session, + json: { + messages: MessageBase[]; + id: string; + url: string; + }, + ) => { + const { messages, url } = json; + + // const session = await auth(); + // if (!session?.user?.email) { + // return new Response('Unauthorized', { + // status: 401, + // }); + // } + + const formData = new FormData(); + formData.append( + 'input', + JSON.stringify( + messages.map(message => { + if (message.role !== 'assistant') { + return { + ...message, + content: cleanInputMessage(message.content), + }; + } else { + const splitedContent = message.content.split(CLEANED_SEPARATOR); + return { + ...message, + content: + splitedContent.length > 1 + ? cleanAnswerMessage(splitedContent[1]) + : message.content, + }; + } + }), + ), + ); + formData.append('image', url); + + const fetchResponse = await fetch( + 'https://api.dev.landing.ai/v1/agent/chat?agent_class=vision_agent&visualize_output=true', + // 'http://localhost:5050/v1/agent/chat?agent_class=vision_agent', + { + method: 'POST', + headers: { + apikey: 'land_sk_DKeoYtaZZrYqJ9TMMiXe4BIQgJcZ0s3XAoB0JT3jv73FFqnr6k', + }, + body: formData, + }, + ); + + if (fetchResponse.body) { + return new StreamingTextResponse(fetchResponse.body); + } else { + return fetchResponse; + } + }, +); diff --git a/app/chat/[id]/page.tsx b/app/chat/[id]/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7177525b1e162051c72cb93c0167ba1307253b35 --- /dev/null +++ b/app/chat/[id]/page.tsx @@ -0,0 +1,14 @@ +import { getKVChat } from '@/lib/kv/chat'; +import { Chat } from '@/components/chat'; + +interface PageProps { + params: { + id: string; + }; +} + +export default async function Page({ params }: PageProps) { + const { id: chatId } = params; + const chat = await getKVChat(chatId); + return ; +} diff --git a/app/chat/layout.tsx b/app/chat/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d00af506358d493bd6f2e027f6885a1101c794a3 --- /dev/null +++ b/app/chat/layout.tsx @@ -0,0 +1,29 @@ +import { auth, authEmail } from '@/auth'; +import ChatSidebarList from '@/components/chat-sidebar/ChatListSidebar'; +import Loading from '@/components/ui/Loading'; +import { Suspense } from 'react'; + +interface ChatLayoutProps { + children: React.ReactNode; +} + +export default async function Layout({ children }: ChatLayoutProps) { + const { email, isAdmin } = await authEmail(); + return ( +
+
+ }> + + +
+ }> +
+ {children} +
+
+
+ ); +} diff --git a/app/chat/page.tsx b/app/chat/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..edc414b3da789d428ecb8f48daff25d132a063e2 --- /dev/null +++ b/app/chat/page.tsx @@ -0,0 +1,118 @@ +'use client'; + +import ImageSelector from '@/components/chat/ImageSelector'; +import { generateInputImageMarkdown } from '@/lib/messageUtils'; +import { ChatEntity, MessageBase } from '@/lib/types'; +import { fetcher } from '@/lib/utils'; +import Image from 'next/image'; +import { useRouter } from 'next/navigation'; + +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/Tooltip'; +import { IconDiscord, IconGitHub } from '@/components/ui/Icons'; +import Link from 'next/link'; +import { Button } from '@/components/ui/Button'; +import Img from '@/components/ui/Img'; + +const exampleMessages = [ + { + heading: 'Counting', + subheading: 'number of cereals in an image', + url: 'https://landing-lens-support.s3.us-east-2.amazonaws.com/vision-agent-examples/cereal-example.jpg', + initMessages: [ + { + role: 'user', + content: + 'how many cereals are there in the image?' + + '\n\n' + + generateInputImageMarkdown( + 'https://landing-lens-support.s3.us-east-2.amazonaws.com/vision-agent-examples/cereal-example.jpg', + ), + id: 'fake-id-1', + }, + ], + }, + // { + // heading: 'Detecting', + // url: 'https://landing-lens-support.s3.us-east-2.amazonaws.com/vision-agent-examples/cereal-example.jpg', + // subheading: 'number of cereals in an image', + // message: `How many cereals are there in the image?`, + // }, +]; + +export default function Page() { + const router = useRouter(); + return ( +
+
+

Welcome to Vision Agent

+

+ Vision Agent is a library that helps you utilize agent frameworks for + your vision tasks. Vision Agent aims to provide an in-seconds + experience by allowing users to describe their problem in text and + utilizing agent frameworks to solve the task for them. +

+
+ + + + + Github + + + + + + Discord + +
+ +
+
+ {exampleMessages.map((example, index) => ( +
1 && 'hidden md:block' + }`} + onClick={async () => { + const resp = await fetcher('/api/upload', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + url: example.url, + initMessages: example.initMessages, + }), + }); + if (resp) { + router.push(`/chat/${resp.id}`); + } + }} + > + example images +
+
{example.heading}
+
{example.subheading}
+
+
+ ))} +
+
+ ); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000000000000000000000000000000000000..460e4e39508b928d69f4378b26deb041a240b767 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,159 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + + --primary: 240 5.9% 10%; + --primary-foreground: 0 0% 98%; + + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + + --accent: 240 4.8% 95.9%; + --accent-foreground: ; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + + --ring: 240 5% 64.9%; + + --radius: 0.5rem; + } + + .dark { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + + --accent: 240 3.7% 15.9%; + --accent-foreground: ; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 85.7% 97.3%; + + --ring: 240 3.7% 15.9%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} + +@layer components { + .scroll-fade::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 50px; + background: linear-gradient( + to bottom, + rgba(255, 255, 255, 1), + rgba(255, 255, 255, 0) + ); + pointer-events: none; + } + .scroll-fade:active::after, + .scroll-fade:hover::after { + background: none; + } + .image-shadow::after { + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); + border-radius: 0.5rem; + pointer-events: none; + } +} + +/* Light theme. */ +:root { + --color-canvas-default: #ffffff; + --color-canvas-subtle: #f6f8fa; + --color-border-default: #d0d7de; + --color-border-muted: hsla(210, 18%, 87%, 1); +} + +/* Dark theme. */ +@media (prefers-color-scheme: dark) { + :root { + --color-canvas-default: #0d1117; + --color-canvas-subtle: #161b22; + --color-border-default: #30363d; + --color-border-muted: #21262d; + } +} + +table { + border-spacing: 0; + border-collapse: collapse; + display: block; + margin-top: 0; + margin-bottom: 16px; + width: max-content; + max-width: 100%; + overflow: auto; +} + +tr { + border-top: 1px solid var(--color-border-muted); +} + +td, +th { + padding: 6px 13px; + border: 1px solid var(--color-border-default); +} + +th { + font-weight: 600; +} + +table img { + background-color: transparent; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ff193dfaf4428d94653cf6f73ccef592bb4f3fee --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,63 @@ +import { Toaster } from 'react-hot-toast'; +import { GeistSans } from 'geist/font/sans'; +import { GeistMono } from 'geist/font/mono'; + +import '@/app/globals.css'; +import { cn } from '@/lib/utils'; +import { TailwindIndicator } from '@/components/TailwindIndicator'; +import { Providers } from '@/components/Providers'; +import { Header } from '@/components/Header'; +import { ThemeToggle } from '@/components/ThemeToggle'; + +export const metadata = { + metadataBase: new URL(`https://${process.env.VERCEL_URL}`), + title: { + default: 'Vision Agent', + template: `%s - Vision Agent`, + }, + description: 'By Landing AI', + icons: { + icon: '/landing.png', + shortcut: '/landing.png', + apple: '/landing.png', + }, +}; + +export const viewport = { + themeColor: [ + { media: '(prefers-color-scheme: light)', color: 'white' }, + { media: '(prefers-color-scheme: dark)', color: 'black' }, + ], +}; + +interface RootLayoutProps { + children: React.ReactNode; +} + +export default function RootLayout({ children }: RootLayoutProps) { + return ( + + + + +
+
+
{children}
+
+ +
+ + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d38c402d702d3af89cea26747e2e89665d7397eb --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,12 @@ +import { auth } from '@/auth'; +import { redirect } from 'next/navigation'; + +export default async function Page() { + redirect('/chat'); + + // return ( + //
+ // Welcome to Insight Playground + //
+ // ); +} diff --git a/app/project/[projectId]/page.tsx b/app/project/[projectId]/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c4f6f986992daabf8b891d1cd16d0a95cd215b23 --- /dev/null +++ b/app/project/[projectId]/page.tsx @@ -0,0 +1,33 @@ +import MediaGrid from '@/components/project/MediaGrid'; +import { fetchProjectClass, fetchProjectMedia } from '@/lib/fetch'; +import ProjectChat from '@/components/project/ProjectChat'; +import ClassBar from '@/components/project/ClassBar'; + +interface PageProps { + params: { + projectId: string; + }; +} + +export default async function Page({ params }: PageProps) { + const { projectId } = params; + + const [mediaList, classList] = await Promise.all([ + fetchProjectMedia({ projectId: Number(projectId) }), + fetchProjectClass({ projectId: Number(projectId) }), + ]); + + return ( +
+
+
+ + +
+
+ +
+
+
+ ); +} diff --git a/app/project/layout.tsx b/app/project/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c116f8aa435d1851f992292836f91552526525f6 --- /dev/null +++ b/app/project/layout.tsx @@ -0,0 +1,35 @@ +import ProjectListSideBar from '@/components/project-sidebar/ProjectListSideBar'; +import { Suspense } from 'react'; +import Loading from '@/components/ui/Loading'; +import { authEmail } from '@/auth'; +import { redirect } from 'next/navigation'; + +interface ChatLayoutProps { + children: React.ReactNode; +} + +export default async function Layout({ children }: ChatLayoutProps) { + const { isAdmin } = await authEmail(); + + if (!isAdmin) { + redirect('/'); + } + + return ( +
+
+ }> + + +
+ }> +
+ {children} +
+
+
+ ); +} diff --git a/app/project/page.tsx b/app/project/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e51dad8188912bc927ff9a77fcdf5fe0af8240cc --- /dev/null +++ b/app/project/page.tsx @@ -0,0 +1,8 @@ +export default function Page() { + const content = '<- Select a project from sidebar'; + return ( +
+ {content} +
+ ); +} diff --git a/auth.ts b/auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..4b25d806f452822b307b6356d6458cb65ff386e4 --- /dev/null +++ b/auth.ts @@ -0,0 +1,66 @@ +import NextAuth, { type DefaultSession } from 'next-auth'; +import GitHub from 'next-auth/providers/github'; +import Google from 'next-auth/providers/google'; + +declare module 'next-auth' { + interface Session { + user: { + /** The user's id. */ + id: string; + } & DefaultSession['user']; + } +} + +const restrictedPath = ['/project']; + +export const { + handlers: { GET, POST }, + auth, +} = NextAuth({ + providers: [ + GitHub, + Google({ + clientId: process.env.GOOGLE_CLIENT_ID!, + clientSecret: process.env.GOOGLE_SECRET!, + }), + ], + callbacks: { + // signIn({ profile }) { + // if (profile?.email?.endsWith('@landing.ai')) { + // return !!profile; + // } else { + // return '/unauthorized'; + // } + // }, + jwt({ token, profile }) { + if (profile) { + token.id = profile.id || profile.sub; + token.image = profile.avatar_url || profile.picture; + } + return token; + }, + session: ({ session, token }) => { + if (session?.user && token?.id) { + session.user.id = String(token.id); + } + return session; + }, + authorized({ request, auth }) { + const isAdmin = !!auth?.user?.email?.endsWith('landing.ai'); + return restrictedPath.find(path => + request.nextUrl.pathname.startsWith(path), + ) + ? isAdmin + : true; + }, + }, + pages: { + signIn: '/sign-in', // overrides the next-auth default signin page https://authjs.dev/guides/basics/pages + }, +}); + +export async function authEmail() { + const session = await auth(); + const email = session?.user?.email; + return { email, isAdmin: !!email?.endsWith('landing.ai') }; +} diff --git a/components/Header.tsx b/components/Header.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9db092636c44b67a4f89be416766b05fef3bca74 --- /dev/null +++ b/components/Header.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import Link from 'next/link'; + +import { auth, authEmail } from '@/auth'; +import { Button } from '@/components/ui/Button'; +import { UserMenu } from '@/components/UserMenu'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/Tooltip'; +import { IconPlus, IconSeparator } from '@/components/ui/Icons'; +import { LoginMenu } from './LoginMenu'; +import { redirect } from 'next/navigation'; + +export async function Header() { + const session = await auth(); + const { isAdmin } = await authEmail(); + return ( +
+ {/* + + + + New chat + */} + {isAdmin && ( + + )} + + +
+ {session?.user ? : } +
+
+ ); +} diff --git a/components/LoginButton.tsx b/components/LoginButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6efcf0f8fbee219c32dc7e5458996693a43ebbab --- /dev/null +++ b/components/LoginButton.tsx @@ -0,0 +1,38 @@ +'use client'; + +import * as React from 'react'; +import { signIn } from 'next-auth/react'; + +import { cn } from '@/lib/utils'; +import { Button, type ButtonProps } from '@/components/ui/Button'; +import { IconGitHub, IconSpinner, IconGoogle } from '@/components/ui/Icons'; + +interface LoginButtonProps extends ButtonProps { + oauth: 'github' | 'google'; +} + +export function LoginButton({ oauth, ...props }: LoginButtonProps) { + const [isLoading, setIsLoading] = React.useState(false); + + const icon = + oauth === 'github' ? ( + + ) : ( + + ); + return ( + + ); +} diff --git a/components/LoginMenu.tsx b/components/LoginMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..71742e7e572d681b6c629f0eb4b52950c339bc36 --- /dev/null +++ b/components/LoginMenu.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { Button } from '@/components/ui/Button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/DropdownMenu'; +import { LoginButton } from './LoginButton'; + +export interface UserMenuProps {} + +export function LoginMenu() { + return ( +
+ + + + + + + + + + + + + +
+ ); +} diff --git a/components/Providers.tsx b/components/Providers.tsx new file mode 100644 index 0000000000000000000000000000000000000000..87ba82e6661106f9cf8c5fbd1bbde006c7e3fa17 --- /dev/null +++ b/components/Providers.tsx @@ -0,0 +1,18 @@ +'use client'; + +import * as React from 'react'; +import { ThemeProvider as NextThemesProvider } from 'next-themes'; +import { ThemeProviderProps } from 'next-themes/dist/types'; +import { TooltipProvider } from '@/components/ui/Tooltip'; +import { ThemeToggle } from './ThemeToggle'; + +export function Providers({ children, ...props }: ThemeProviderProps) { + return ( + + + {children} + + + + ); +} diff --git a/components/TailwindIndicator.tsx b/components/TailwindIndicator.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0e60a7b2ca2333421bfb85846655517720f7cb03 --- /dev/null +++ b/components/TailwindIndicator.tsx @@ -0,0 +1,14 @@ +export function TailwindIndicator() { + if (process.env.NODE_ENV === 'production') return null; + + return ( +
+
xs
+
sm
+
md
+
lg
+
xl
+
2xl
+
+ ); +} diff --git a/components/ThemeToggle.tsx b/components/ThemeToggle.tsx new file mode 100644 index 0000000000000000000000000000000000000000..edd3705183b5fedf7842bd71d0ed96ad6eab1942 --- /dev/null +++ b/components/ThemeToggle.tsx @@ -0,0 +1,32 @@ +'use client'; + +import * as React from 'react'; +import { useTheme } from 'next-themes'; + +import { Button } from '@/components/ui/Button'; +import { IconMoon, IconSun } from '@/components/ui/Icons'; + +export function ThemeToggle() { + const { setTheme, theme } = useTheme(); + const [_, startTransition] = React.useTransition(); + + return ( + + ); +} diff --git a/components/UserMenu.tsx b/components/UserMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..14fe47924926cbcdd8818ae2702cbcedf45478f5 --- /dev/null +++ b/components/UserMenu.tsx @@ -0,0 +1,68 @@ +'use client'; + +import Image from 'next/image'; +import { type Session } from 'next-auth'; +import { signOut } from 'next-auth/react'; + +import { Button } from '@/components/ui/Button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/DropdownMenu'; +import { IconExternalLink } from '@/components/ui/Icons'; + +export interface UserMenuProps { + user: Session['user']; +} + +function getUserInitials(name: string) { + const [firstName, lastName] = name.split(' '); + return lastName ? `${firstName[0]}${lastName[0]}` : firstName.slice(0, 2); +} + +export function UserMenu({ user }: UserMenuProps) { + return ( +
+ + + + + + +
{user?.name}
+
{user?.email}
+
+ + + signOut({ + callbackUrl: '/', + }) + } + className="text-xs" + > + Log Out + +
+
+
+ ); +} diff --git a/components/chat-sidebar/ChatAdminToggle.tsx b/components/chat-sidebar/ChatAdminToggle.tsx new file mode 100644 index 0000000000000000000000000000000000000000..55ba12dc6a6f90d4d5ac632a06e82a059841a0fd --- /dev/null +++ b/components/chat-sidebar/ChatAdminToggle.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { chatViewMode } from '@/state/chat'; +import { useAtom } from 'jotai'; +import React from 'react'; + +export interface ChatAdminToggleProps {} + +const ChatAdminToggle: React.FC = () => { + const modeAtom = useAtom(chatViewMode); + return null; +}; + +export default ChatAdminToggle; diff --git a/components/chat-sidebar/ChatCard.tsx b/components/chat-sidebar/ChatCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d9931ffedde3f717ab05c19a2647229d901e2edd --- /dev/null +++ b/components/chat-sidebar/ChatCard.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { PropsWithChildren } from 'react'; +import Link from 'next/link'; +import { useParams, usePathname, useRouter } from 'next/navigation'; +import { cn } from '@/lib/utils'; +import { ChatEntity } from '@/lib/types'; +import Image from 'next/image'; +import clsx from 'clsx'; +import Img from '../ui/Img'; +import { format } from 'date-fns'; +import { cleanInputMessage } from '@/lib/messageUtils'; +// import { format } from 'date-fns'; + +type ChatCardProps = PropsWithChildren<{ + chat: ChatEntity; +}>; + +export const ChatCardLayout: React.FC< + PropsWithChildren<{ link: string; classNames?: clsx.ClassValue }> +> = ({ link, children, classNames }) => { + return ( + + {children} + + ); +}; + +const ChatCard: React.FC = ({ chat }) => { + const { id: chatIdFromParam } = useParams(); + const pathname = usePathname(); + const { id, url, messages, user, updatedAt } = chat; + const firstMessage = cleanInputMessage(messages?.[0]?.content ?? ''); + const title = firstMessage + ? firstMessage.length > 50 + ? firstMessage.slice(0, 50) + '...' + : firstMessage + : '(No messages yet)'; + return ( + +
+ {`chat-${id}-card-image`} +
+

{title}

+

+ {updatedAt ? format(Number(updatedAt), 'yyyy-MM-dd') : '-'} +

+
+
+
+ ); +}; + +export default ChatCard; diff --git a/components/chat-sidebar/ChatListSidebar.tsx b/components/chat-sidebar/ChatListSidebar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2885dd0fb926ead3d50b06073b0edd9e5eb72d65 --- /dev/null +++ b/components/chat-sidebar/ChatListSidebar.tsx @@ -0,0 +1,27 @@ +import { getKVChats } from '@/lib/kv/chat'; +import ChatCard, { ChatCardLayout } from './ChatCard'; +import { IconPlus } from '../ui/Icons'; +import { auth } from '@/auth'; + +export interface ChatSidebarListProps {} + +export default async function ChatSidebarList({}: ChatSidebarListProps) { + const session = await auth(); + if (!session || !session.user) { + return null; + } + const chats = await getKVChats(); + return ( + <> + +
+ +

New chat

+
+
+ {chats.map(chat => ( + + ))} + + ); +} diff --git a/components/chat/ChatList.tsx b/components/chat/ChatList.tsx new file mode 100644 index 0000000000000000000000000000000000000000..06f502b0c2f8478b65288d138087d1ca1a62fcde --- /dev/null +++ b/components/chat/ChatList.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { Separator } from '@/components/ui/Separator'; +import { ChatMessage } from '@/components/chat/ChatMessage'; +import { MessageBase } from '../../lib/types'; + +export interface ChatList { + messages: MessageBase[]; +} + +export function ChatList({ messages }: ChatList) { + return ( +
+ {messages + // .filter(message => message.role !== 'system') + .map((message, index) => ( +
+ + {index < messages.length - 1 && ( + + )} +
+ ))} +
+ ); +} diff --git a/components/chat/ChatMessage.tsx b/components/chat/ChatMessage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f434128ebed7da8a0aa140dad1dba3744e388857 --- /dev/null +++ b/components/chat/ChatMessage.tsx @@ -0,0 +1,151 @@ +// Inspired by Chatbot-UI and modified to fit the needs of this project +// @see https://github.com/mckaywrigley/chatbot-ui/blob/main/components/Chat/ChatMessage.tsx + +import remarkGfm from 'remark-gfm'; +import remarkMath from 'remark-math'; + +import { useMemo } from 'react'; +import { cn } from '@/lib/utils'; +import { CodeBlock } from '@/components/ui/CodeBlock'; +import { MemoizedReactMarkdown } from '@/components/chat/MemoizedReactMarkdown'; +import { IconOpenAI, IconUser } from '@/components/ui/Icons'; +import { MessageBase } from '../../lib/types'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/Tooltip'; +import Img from '../ui/Img'; +import { getCleanedUpMessages } from '@/lib/messageUtils'; + +export interface ChatMessageProps { + message: MessageBase; +} + +export function ChatMessage({ message, ...props }: ChatMessageProps) { + const { logs, content } = useMemo(() => { + return getCleanedUpMessages({ + content: message.content, + role: message.role, + }); + }, [message.content, message.role]); + return ( +
+
+ {message.role === 'user' ? : } +
+
+ {logs && message.role !== 'user' && ( +
+
Thinking Process
+ + {children} +

+ ); + }, + code({ children, ...props }) { + return ( + {children} + ); + }, + }} + > + {logs} +
+
+ )} + child.type === 'element' && child.tagName === 'img', + ) + ) { + return ( +

{children}

+ ); + } + return ( +

{children}

+ ); + }, + img(props) { + return ( + + + {props.alt + + + {props.alt + + + ); + }, + code({ node, inline, className, children, ...props }) { + if (children.length) { + if (children[0] == '▍') { + return ( + + ); + } + + children[0] = (children[0] as string).replace('`▍`', '▍'); + } + + const match = /language-(\w+)/.exec(className || ''); + if (inline) { + return ( + + {children} + + ); + } + + return ( + + ); + }, + }} + > + {content} +
+ {/* */} +
+
+ ); +} diff --git a/components/chat/ChatMessageActions.tsx b/components/chat/ChatMessageActions.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cf0333ff6de7d442a275e2afb3de69f872713e4c --- /dev/null +++ b/components/chat/ChatMessageActions.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { type Message } from 'ai'; + +import { Button } from '@/components/ui/Button'; +import { IconCheck, IconCopy } from '@/components/ui/Icons'; +import { useCopyToClipboard } from '@/lib/hooks/useCopyToClipboard'; +import { cn } from '@/lib/utils'; +import { MessageBase } from '../../lib/types'; + +interface ChatMessageActionsProps extends React.ComponentProps<'div'> { + message: MessageBase; +} + +export function ChatMessageActions({ + message, + className, + ...props +}: ChatMessageActionsProps) { + const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 }); + + const onCopy = () => { + if (isCopied) return; + copyToClipboard(message.content); + }; + + return ( +
+ +
+ ); +} diff --git a/components/chat/Composer.tsx b/components/chat/Composer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..05afbfad1c85b776bd66775b5b29d3165f217b3e --- /dev/null +++ b/components/chat/Composer.tsx @@ -0,0 +1,197 @@ +'use client'; + +import * as React from 'react'; +import { type UseChatHelpers } from 'ai/react'; +import Textarea from 'react-textarea-autosize'; + +import { Button } from '@/components/ui/Button'; +import { MessageBase } from '../../lib/types'; +import { useEnterSubmit } from '@/lib/hooks/useEnterSubmit'; +import Img from '../ui/Img'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/Tooltip'; +import { + IconArrowDown, + IconArrowElbow, + IconRefresh, + IconStop, +} from '@/components/ui/Icons'; +import { cn } from '@/lib/utils'; +import { generateInputImageMarkdown } from '@/lib/messageUtils'; + +export interface ComposerProps + extends Pick< + UseChatHelpers, + 'append' | 'isLoading' | 'reload' | 'stop' | 'input' | 'setInput' + > { + id?: string; + title?: string; + messages: MessageBase[]; + url?: string; + isAtBottom: boolean; + scrollToBottom: () => void; +} + +export function Composer({ + id, + title, + isLoading, + stop, + append, + reload, + input, + setInput, + messages, + isAtBottom, + scrollToBottom, + url, +}: ComposerProps) { + const { formRef, onKeyDown } = useEnterSubmit(); + const inputRef = React.useRef(null); + React.useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, []); + + return ( + //
+
+
+
{ + e.preventDefault(); + if (!input?.trim()) { + return; + } + setInput(''); + await append({ + id, + content: + input + (url ? '\n\n' + generateInputImageMarkdown(url) : ''), + role: 'user', + }); + scrollToBottom(); + }} + ref={formRef} + className="h-full" + > +
+ {url && ( +
+ + + preview-image + + + zoomed-in-image + + +
+ )} +