diff --git a/Dockerfile b/Dockerfile index c101ba549638961cfe0c945cc5f1568dc38806b1..2b0337b223e93582fca34b480e5196bf40832e89 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ RUN corepack enable FROM base AS deps WORKDIR /app -COPY package.json pnpm-lock.yaml ./ +COPY package.json pnpm-lock.yaml prisma/* ./ RUN pnpm i --frozen-lockfile # Rebuild the source code only when needed @@ -15,12 +15,11 @@ COPY --from=deps --link /app/node_modules ./node_modules COPY --link . . RUN --mount=type=secret,id=AUTH_SECRET \ ---mount=type=secret,id=OPENAI_API_KEY \ -AUTH_SECRET="$(cat /run/secrets/AUTH_SECRET)" \ -OPENAI_API_KEY="$(cat /run/secrets/OPENAI_API_KEY)" \ -NEXT_SHARP_PATH="/app/node_modules/sharp" \ -USE_STANDALONE_BUILD=True \ -pnpm run build + --mount=type=secret,id=OPENAI_API_KEY \ + AUTH_SECRET="$(cat /run/secrets/AUTH_SECRET)" \ + OPENAI_API_KEY="$(cat /run/secrets/OPENAI_API_KEY)" \ + USE_STANDALONE_BUILD=True \ + pnpm run build RUN mkdir -p /app/.next/cache/images @@ -39,11 +38,9 @@ COPY --from=builder --link --chown=1000:1000 /app/.next/standalone ./ COPY --from=builder --link --chown=1000:1000 /app/.next/static ./.next/static COPY --from=builder --link --chown=1000:1000 /app/.next/cache/images ./.next/cache/images -USER nextjs +EXPOSE 3000 -EXPOSE 7860 - -ENV PORT 7860 +ENV PORT 3000 ENV HOSTNAME 0.0.0.0 CMD ["node", "server.js"] diff --git a/README.md b/README.md index 90c2ad15e695a5f458d8fbad6007e301a870d1d0..58cd362b2a093d6a92eba0e5d7cb8512d80d406f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ --- -title: Vision Agent Landing +title: Vision Agent emoji: ๐Ÿƒ colorFrom: yellow colorTo: indigo @@ -7,4 +7,74 @@ sdk: docker pinned: false --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference + + Next.js 14 and App Router-ready AI chatbot. +

Next.js AI Chatbot

+
+ +

+ An open-source AI chatbot app template built with Next.js, the Vercel AI SDK, OpenAI, and Vercel KV. +

+ +

+ Features ยท + Model Providers ยท + Deploy Your Own ยท + Running locally ยท + Authors +

+
+ +## Features + +- [Next.js](https://nextjs.org) App Router +- React Server Components (RSCs), Suspense, and Server Actions +- [Vercel AI SDK](https://sdk.vercel.ai/docs) for streaming chat UI +- Support for OpenAI (default), Anthropic, Cohere, Hugging Face, or custom AI chat models and/or LangChain +- [shadcn/ui](https://ui.shadcn.com) + - Styling with [Tailwind CSS](https://tailwindcss.com) + - [Radix UI](https://radix-ui.com) for headless component primitives + - Icons from [Phosphor Icons](https://phosphoricons.com) +- Chat History, rate limiting, and session storage with [Vercel KV](https://vercel.com/storage/kv) +- [NextAuth.js](https://github.com/nextauthjs/next-auth) for authentication + +## Model Providers + +This template ships with OpenAI `gpt-3.5-turbo` as the default. However, thanks to the [Vercel AI SDK](https://sdk.vercel.ai/docs), you can switch LLM providers to [Anthropic](https://anthropic.com), [Cohere](https://cohere.com/), [Hugging Face](https://huggingface.co), or using [LangChain](https://js.langchain.com) with just a few lines of code. + +## Deploy Your Own + +You can deploy your own version of the Next.js AI Chatbot to Vercel with one click: + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?demo-title=Next.js+Chat&demo-description=A+full-featured%2C+hackable+Next.js+AI+chatbot+built+by+Vercel+Labs&demo-url=https%3A%2F%2Fchat.vercel.ai%2F&demo-image=%2F%2Fimages.ctfassets.net%2Fe5382hct74si%2F4aVPvWuTmBvzM5cEdRdqeW%2F4234f9baf160f68ffb385a43c3527645%2FCleanShot_2023-06-16_at_17.09.21.png&project-name=Next.js+Chat&repository-name=nextjs-chat&repository-url=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fai-chatbot&from=templates&skippable-integrations=1&env=OPENAI_API_KEY%2CAUTH_GITHUB_ID%2CAUTH_GITHUB_SECRET%2CAUTH_SECRET&envDescription=How+to+get+these+env+vars&envLink=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fai-chatbot%2Fblob%2Fmain%2F.env.example&teamCreateStatus=hidden&stores=[{"type":"kv"}]) + +## Creating a KV Database Instance + +Follow the steps outlined in the [quick start guide](https://vercel.com/docs/storage/vercel-kv/quickstart#create-a-kv-database) provided by Vercel. This guide will assist you in creating and configuring your KV database instance on Vercel, enabling your application to interact with it. + +Remember to update your environment variables (`KV_URL`, `KV_REST_API_URL`, `KV_REST_API_TOKEN`, `KV_REST_API_READ_ONLY_TOKEN`) in the `.env` file with the appropriate credentials provided during the KV database setup. + +## Running locally + +You will need to use the environment variables [defined in `.env.example`](.env.example) to run Next.js AI Chatbot. It's recommended you use [Vercel Environment Variables](https://vercel.com/docs/projects/environment-variables) for this, but a `.env` file is all that is necessary. + +> Note: You should not commit your `.env` file or it will expose secrets that will allow others to control access to your various OpenAI and authentication provider accounts. + +1. Install Vercel CLI: `npm i -g vercel` +2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link` +3. Download your environment variables: `vercel env pull` + +```bash +pnpm install +pnpm dev +``` + +Your app template should now be running on [localhost:3000](http://localhost:3000/). + +## Authors + +This library is created by [Vercel](https://vercel.com) and [Next.js](https://nextjs.org) team members, with contributions from: + +- Jared Palmer ([@jaredpalmer](https://twitter.com/jaredpalmer)) - [Vercel](https://vercel.com) +- Shu Ding ([@shuding\_](https://twitter.com/shuding_)) - [Vercel](https://vercel.com) +- shadcn ([@shadcn](https://twitter.com/shadcn)) - [Vercel](https://vercel.com) diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index 74197d24391f596ce15dec509e3080fea9defbc0..1fabf156b78589a578952b2dd5b4b971e8e09072 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,2 +1,2 @@ export { GET, POST } from '@/auth'; -export const runtime = 'edge'; +// export const runtime = 'edge'; diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index e25246dcb07fe00309b3d5e9e773fdef72f232ef..0ec41f32489438c4b9e24d420ce22d28d84bce13 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -6,18 +6,14 @@ import { ChatCompletionMessageParam, ChatCompletionContentPart, } from 'openai/resources'; -import { MessageBase } from '../../../lib/types'; +import { MessageUI } 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[]; + messages: MessageUI[]; id: string; url: string; }; @@ -43,6 +39,9 @@ export const POST = async (req: Request) => { }; }, ); + const openai = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, + }); const res = await openai.chat.completions.create({ model: 'gpt-4-vision-preview', messages: formattedMessage, diff --git a/app/api/sign/route.ts b/app/api/sign/route.ts index 299de6119b4bbf1f79ec2c74b6b90d6c1e782149..2937b43f5e25275ffbfc188e126ae26bc0cac16d 100644 --- a/app/api/sign/route.ts +++ b/app/api/sign/route.ts @@ -23,16 +23,10 @@ export const POST = withLogging( // } try { - const { fileName, fileType, id } = json; + const { fileName, fileType, id = nanoid() } = 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, - }); + const res = await getPresignedUrl(fileName, fileType, id, user); + return Response.json(res); } 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 deleted file mode 100644 index 3c3a694cacc0dcf457fb8fec0afb186493ea7679..0000000000000000000000000000000000000000 --- a/app/api/upload/route.ts +++ /dev/null @@ -1,51 +0,0 @@ -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 index d3abc53c526dc66fa137fda474e087500db9db0a..31d91fe47a54fe119afc6cd93e9e9dd4441c8340 100644 --- a/app/api/vision-agent/route.ts +++ b/app/api/vision-agent/route.ts @@ -1,74 +1,337 @@ 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'; +import { MessageUI } from '@/lib/types'; + +import { logger, withLogging } from '@/lib/logger'; +import { getPresignedUrl } from '@/lib/aws'; +import { dbPostUpdateMessageResponse } from '@/lib/db/functions'; // export const runtime = 'edge'; export const dynamic = 'force-dynamic'; export const maxDuration = 300; // This function can run for a maximum of 5 minutes +const TIMEOUT_MILI_SECONDS = 2 * 60 * 1000; +const FINAL_TIMEOUT_ERROR: PrismaJson.FinalErrorBody = { + type: 'final_error', + status: 'failed', + payload: { + name: 'AgentTimeout', + value: `Haven't received any response in last ${TIMEOUT_MILI_SECONDS / 60000} minutes, agent timed out.`, + traceback_raw: [], + }, +}; + +const uploadBase64 = async ( + base64: string, + messageId: string, + chatId: string, + index: number, + user: string, +) => { + const res = await fetch(base64); + const blob = await res.blob(); + const { signedUrl, publicUrl, fields } = await getPresignedUrl( + `answer-${index}.${blob.type.split('/')[1]}`, + blob.type, + `${chatId}/${messageId}`, + user, + ); + const formData = new FormData(); + Object.entries(fields).forEach(([key, value]) => { + formData.append(key, value as string); + }); + formData.append('file', blob); + + const uploadResponse = await fetch(signedUrl, { + method: 'POST', + body: formData, + }); + if (uploadResponse.ok) { + return publicUrl; + } else { + throw new Error('Upload failed'); + } +}; + +const modifyCodePayload = async ( + msg: PrismaJson.MessageBody, + messageId: string, + chatId: string, + user: string, +): Promise => { + if ( + (msg.type !== 'final_code' && + (msg.type !== 'code' || + msg.status === 'started' || + msg.status === 'running')) || + !msg.payload?.result + ) { + return msg; + } + const result = ( + typeof msg.payload.result === 'string' + ? JSON.parse(msg.payload.result) + : msg.payload.result + ) as PrismaJson.StructuredResult; + if (msg.type === 'code') { + if (result && result.results) { + msg.payload.result = { + ...result, + results: result.results.map((_result: any) => { + return { + ..._result, + png: undefined, + mp4: undefined, + }; + }), + }; + } + return msg; + } + for (let index = 0; index < result.results.length; index++) { + const png = result.results[index].png ?? ''; + const mp4 = result.results[index].mp4 ?? ''; + if (!png && !mp4) continue; + const resp = await uploadBase64( + png ? 'data:image/png;base64,' + png : 'data:video/mp4;base64,' + mp4, + messageId, + chatId, + index, + user, + ); + if (png) result.results[index].png = resp; + if (mp4) result.results[index].mp4 = resp; + } + msg.payload.result = result; + return msg; +}; export const POST = withLogging( async ( - _session, + session, json: { - messages: MessageBase[]; + apiMessages: string; id: string; - url: string; + mediaUrl: string; }, + request, ) => { - const { messages, url } = json; - - // const session = await auth(); - // if (!session?.user?.email) { - // return new Response('Unauthorized', { - // status: 401, - // }); - // } + const { apiMessages, mediaUrl, id: chatId } = json; + const messages: MessageUI[] = JSON.parse(apiMessages); + const messageId = messages[messages.length - 1].id.split('-')[0]; + const user = session?.user?.email ?? 'anonymous'; 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); + formData.append('input', apiMessages); + formData.append('image', mediaUrl); + + const agentHost = process.env.LND_TIER + ? 'http://publicrestapi-app-lndsvc.publicrestapi.svc.cluster.local:5000' + : 'https://api.dev.landing.ai'; 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', + `${agentHost}/v1/agent/chat?agent_class=vision_agent&self_reflection=false`, + // `https://api.dev.landing.ai/v1/agent/chat?agent_class=vision_agent&self_reflection=false`, + // `http://localhost:5001/v1/agent/chat?agent_class=vision_agent&self_reflection=false`, { method: 'POST', headers: { - apikey: 'land_sk_DKeoYtaZZrYqJ9TMMiXe4BIQgJcZ0s3XAoB0JT3jv73FFqnr6k', + // default to dev apikey + apikey: + process.env.LND_TIER === 'production' + ? 'land_sk_nMnUf8xiJJUjyw1l5QaIJJ4ZyrvPthzVmPAIG7TtJY7F9CW6lu' // prod key + : 'land_sk_DKeoYtaZZrYqJ9TMMiXe4BIQgJcZ0s3XAoB0JT3jv73FFqnr6k', // dev key }, body: formData, }, ); - if (fetchResponse.body) { - return new StreamingTextResponse(fetchResponse.body); - } else { + if (!fetchResponse.ok && fetchResponse.body) { + const reader = fetchResponse.body.getReader(); + return new StreamingTextResponse( + new ReadableStream({ + async start(controller) { + try { + const { done, value } = await reader?.read(); + if (!done) { + const errorText = new TextDecoder().decode(value); + logger.error(session, { message: errorText }, request); + controller.error(new Error(`Response error: ${errorText}`)); + } + } catch (e) { + logger.error(session, (e as Error).message, request); + } + }, + }), + { + status: 400, + }, + ); + } + // const streamData = new experimental_StreamData(); + + if (!fetchResponse.body) { return fetchResponse; } + const encoder = new TextEncoder(); + const decoder = new TextDecoder('utf-8'); + let maxChunkSize = 0; + let buffer = ''; + let time = Date.now(); + const results: PrismaJson.MessageBody[] = []; + const stream = new ReadableStream({ + async start(controller) { + const parseLine = async ( + line: string, + ignoreParsingError = false, + ): Promise<{ data?: PrismaJson.MessageBody; error?: Error }> => { + let msg = null; + try { + msg = JSON.parse(line); + } catch (e) { + if (ignoreParsingError) return {}; + else { + return { error: e as Error }; + } + } + if (!msg) return {}; + try { + const modifiedMsg = await modifyCodePayload( + { + ...msg, + timestamp: new Date(), + }, + messageId, + chatId, + user, + ); + return { data: modifiedMsg }; + } catch (e) { + return { error: e as Error }; + } + }; + + const processChunk = async (lines: string[]) => { + if (lines.length === 0) { + if (Date.now() - time > TIMEOUT_MILI_SECONDS) { + results.push(FINAL_TIMEOUT_ERROR); + // https://github.com/vercel/ai/blob/f7002ad2c5aa58ce6ed83e8d31fe22f71ebdb7d7/packages/ui-utils/src/stream-parts.ts#L62 + controller.enqueue( + '2:' + + encoder.encode(JSON.stringify(FINAL_TIMEOUT_ERROR) + '\n'), + ); + return { done: true, reason: 'timeout' }; + } + } else { + time = Date.now(); + } + buffer = lines.pop() ?? ''; // Save the last incomplete line back to the buffer + for (let line of lines) { + const { data: parsedMsg, error } = await parseLine(line); + if (error) { + results.push({ + type: 'final_error', + status: 'failed', + payload: { + name: 'ParseError', + value: line, + traceback_raw: [], + }, + }); + return { done: true, reason: 'api_error', error }; + } else if (parsedMsg) { + results.push(parsedMsg); + controller.enqueue( + encoder.encode('2:' + JSON.stringify([parsedMsg]) + '\n'), + ); + if (parsedMsg.type === 'final_code') { + return { done: true, reason: 'agent_concluded' }; + } else if (parsedMsg.type === 'final_error') { + return { + done: true, + reason: 'agent_error', + error: parsedMsg.payload, + }; + } + } else { + controller.enqueue(encoder.encode('')); + } + } + if (buffer) { + const { data: parsedBuffer, error } = await parseLine(buffer, true); + if (error) { + results.push({ + type: 'final_error', + status: 'failed', + payload: { + name: 'ParseError', + value: buffer, + traceback_raw: [], + }, + }); + return { done: true, reason: 'api_error', error }; + } else if (parsedBuffer) { + buffer = ''; + results.push(parsedBuffer); + controller.enqueue( + encoder.encode('2:' + JSON.stringify([parsedBuffer]) + '\n'), + ); + if (parsedBuffer.type === 'final_code') { + return { done: true, reason: 'agent_concluded' }; + } else if (parsedBuffer.type === 'final_error') { + return { + done: true, + reason: 'agent_error', + error: parsedBuffer.payload, + }; + } + } else { + controller.enqueue(encoder.encode('')); + } + } + return { done: false }; + }; + + // const parser = createParser(streamParser); + for await (const chunk of fetchResponse.body as any) { + const data = decoder.decode(chunk); + buffer += data; + maxChunkSize = Math.max(data.length, maxChunkSize); + const lines = buffer + .split('\n') + .filter(line => line.trim().length > 0); + const { done, reason, error } = await processChunk(lines); + if (done) { + const processMsgs = results.filter( + res => res.type !== 'final_code', + ) as PrismaJson.AgentResponseBodies; + await dbPostUpdateMessageResponse(messageId, { + response: processMsgs.map(res => JSON.stringify(res)).join('\n'), + result: results.find( + res => res.type === 'final_code', + ) as PrismaJson.FinalCodeBody, + responseBody: processMsgs, + }); + logger.info( + session, + { + message: 'Streaming ended', + maxChunkSize, + reason, + error, + }, + request, + '__AGENT_DONE', + ); + controller.close(); + } + } + }, + }); + return new Response(stream, { + headers: { + 'Content-Type': 'application/x-ndjson', + }, + }); }, ); diff --git a/app/chat/[id]/page.tsx b/app/chat/[id]/page.tsx index 7177525b1e162051c72cb93c0167ba1307253b35..423dd3899b584dd4cbc79f446352a3427e6c4240 100644 --- a/app/chat/[id]/page.tsx +++ b/app/chat/[id]/page.tsx @@ -1,5 +1,7 @@ -import { getKVChat } from '@/lib/kv/chat'; -import { Chat } from '@/components/chat'; +import { Suspense } from 'react'; +import ChatServer from './server'; +import Loading from '@/components/ui/Loading'; +import { auth } from '@/auth'; interface PageProps { params: { @@ -9,6 +11,15 @@ interface PageProps { export default async function Page({ params }: PageProps) { const { id: chatId } = params; - const chat = await getKVChat(chatId); - return ; + return ( + + + + } + > + + + ); } diff --git a/app/chat/[id]/server.tsx b/app/chat/[id]/server.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a18cabba96a3d28c442110c09b0f86a74bacada4 --- /dev/null +++ b/app/chat/[id]/server.tsx @@ -0,0 +1,26 @@ +import ChatInterface from '../../../components/ChatInterface'; +import { auth, sessionUser } from '@/auth'; +import { dbGetChat } from '@/lib/db/functions'; +import { redirect } from 'next/navigation'; +import { revalidatePath } from 'next/cache'; +import TopPrompt from '@/components/chat/TopPrompt'; + +interface ChatServerProps { + chatId: string; +} + +export default async function ChatServer({ chatId }: ChatServerProps) { + const chat = await dbGetChat(chatId); + const { id: userId } = await sessionUser(); + + if (!chat) { + revalidatePath('/'); + redirect('/'); + } + return ( +
+ + +
+ ); +} diff --git a/app/chat/layout.tsx b/app/chat/layout.tsx index d00af506358d493bd6f2e027f6885a1101c794a3..5093aeada3180330bdad53dfe1a897ac85b44557 100644 --- a/app/chat/layout.tsx +++ b/app/chat/layout.tsx @@ -1,29 +1,8 @@ -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} -
-
-
- ); + // return }>{children}; + return children; } diff --git a/app/chat/page.tsx b/app/chat/page.tsx index edc414b3da789d428ecb8f48daff25d132a063e2..9ac8333d48aa43c22d78900b3150407eb7d34b21 100644 --- a/app/chat/page.tsx +++ b/app/chat/page.tsx @@ -1,118 +1,7 @@ -'use client'; +import { redirect } from 'next/navigation'; -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'; +export interface PageProps {} -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}
-
-
- ))} -
-
- ); +export default async function Page({}: PageProps) { + redirect('/'); } diff --git a/app/globals.css b/app/globals.css index 460e4e39508b928d69f4378b26deb041a240b767..91f9beb051fafd4d7b370bfcb31351a804308e0f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -7,17 +7,11 @@ --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%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; --primary: 240 5.9% 10%; --primary-foreground: 0 0% 98%; @@ -25,13 +19,18 @@ --secondary: 240 4.8% 95.9%; --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 240 4.8% 95.9%; - --accent-foreground: ; + --accent-foreground: 240 5.9% 10%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 0 0% 98%; - --ring: 240 5% 64.9%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 10% 3.9%; --radius: 0.5rem; } @@ -40,17 +39,11 @@ --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%; + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; --primary: 0 0% 98%; --primary-foreground: 240 5.9% 10%; @@ -58,13 +51,18 @@ --secondary: 240 3.7% 15.9%; --secondary-foreground: 0 0% 98%; + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + --accent: 240 3.7% 15.9%; - --accent-foreground: ; + --accent-foreground: 0 0% 98%; --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 85.7% 97.3%; + --destructive-foreground: 0 0% 98%; - --ring: 240 3.7% 15.9%; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; } } @@ -77,83 +75,36 @@ } } -@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; - } +h1 { + font-size: 3.75rem; /* 48px */ + font-family: var(--font-geist-sans); + font-weight: bold; + letter-spacing: -1px; } -table { - border-spacing: 0; - border-collapse: collapse; - display: block; - margin-top: 0; - margin-bottom: 16px; - width: max-content; - max-width: 100%; - overflow: auto; +.homepage { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' version='1.1' xmlns:xlink='http://www.w3.org/1999/xlink' xmlns:svgjs='http://svgjs.dev/svgjs' width='1440' height='800' preserveAspectRatio='none' viewBox='0 0 1440 800'%3e%3cg mask='url(%26quot%3b%23SvgjsMask1041%26quot%3b)' fill='none'%3e%3crect width='1440' height='800' x='0' y='0' fill='rgba(39%2c 39%2c 42%2c 1)'%3e%3c/rect%3e%3cpath d='M0%2c586.338C117.188%2c602.632%2c249.981%2c607.097%2c343.984%2c535.25C436.988%2c464.167%2c428.51%2c324.327%2c480.215%2c219.307C534.041%2c109.979%2c656.285%2c27.833%2c653.789%2c-94.001C651.262%2c-217.335%2c542.961%2c-307.497%2c467.422%2c-405.024C387.438%2c-508.29%2c329.741%2c-667.611%2c199.733%2c-680.229C64.395%2c-693.364%2c-10.885%2c-516.553%2c-136.125%2c-463.601C-240.173%2c-419.609%2c-369.118%2c-464.571%2c-462.091%2c-400.404C-565.056%2c-329.341%2c-652.036%2c-220.136%2c-668.569%2c-96.126C-685.064%2c27.595%2c-620.538%2c148.465%2c-548.895%2c250.672C-485.01%2c341.811%2c-382.503%2c389.373%2c-287.551%2c447.439C-194.861%2c504.122%2c-107.613%2c571.375%2c0%2c586.338' fill='%23212124'%3e%3c/path%3e%3cpath d='M1440 1336.819C1541.131 1345.339 1640.5529999999999 1299.223 1721.426 1237.907 1798.82 1179.229 1854.8220000000001 1095.077 1881.876 1001.798 1906.8519999999999 915.683 1873.705 828.071 1866.208 738.721 1858.141 642.578 1889.666 537.0129999999999 1836.144 456.739 1781.231 374.378 1680.9279999999999 326.709 1582.8029999999999 313.659 1490.378 301.367 1407.661 359.059 1319.016 387.966 1233.469 415.863 1131.429 413.58 1071.248 480.474 1010.852 547.608 1027.055 649.928 1004.646 737.406 978.896 837.926 891.045 936.749 929.698 1033.047 968.1410000000001 1128.8229999999999 1098.805 1140.133 1187.4850000000001 1192.922 1272.452 1243.501 1341.467 1328.517 1440 1336.819' fill='%232d2d30'%3e%3c/path%3e%3c/g%3e%3cdefs%3e%3cmask id='SvgjsMask1041'%3e%3crect width='1440' height='800' fill='white'%3e%3c/rect%3e%3c/mask%3e%3c/defs%3e%3c/svg%3e"); + background-size: cover; + background: linear-gradient(to bottom, rgb(9, 9, 11), rgb(32, 32, 39)); } -tr { - border-top: 1px solid var(--color-border-muted); +.text-webkit-center { + text-align: -webkit-center; } -td, -th { - padding: 6px 13px; - border: 1px solid var(--color-border-default); +.svg-shadow { + border-radius: 100%; + animation: svg-shadow 1.5s ease-in-out infinite alternate; } -th { - font-weight: 600; -} +@keyframes svg-shadow { + from { + filter: drop-shadow(0 0 5px #fff) drop-shadow(0 0 5px transparent) + drop-shadow(0 0 10px transparent); + } -table img { - background-color: transparent; + to { + filter: drop-shadow(0 0 20px #fff) drop-shadow(0 0 10px transparent) + drop-shadow(0 0 20px transparent); + } } diff --git a/app/layout.tsx b/app/layout.tsx index 946dc5b0d2c48baf4830384a5a25e3b199b5d16d..112349d423ef248150804e0b0e6c0a492823dfa3 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -7,7 +7,6 @@ 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}`), @@ -17,9 +16,9 @@ export const metadata = { }, description: 'By Landing AI', icons: { - icon: '/landing.png', - shortcut: '/landing.png', - apple: '/landing.png', + icon: '/landing4.png', + shortcut: '/landing4.png', + apple: '/landing4.png', }, }; @@ -34,7 +33,8 @@ interface RootLayoutProps { children: React.ReactNode; } -export default function RootLayout({ children }: RootLayoutProps) { +export default function RootLayout(props: RootLayoutProps) { + const { children } = props; return (
- {!process.env.NEXT_PUBLIC_IS_HUGGING_FACE &&
} -
{children}
+
+
+ {children} +
diff --git a/app/page.tsx b/app/page.tsx index d38c402d702d3af89cea26747e2e89665d7397eb..accdea42fd8a301a03fe4ee1811df4ae5bd874f3 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,12 +1,82 @@ -import { auth } from '@/auth'; -import { redirect } from 'next/navigation'; +'use client'; -export default async function Page() { - redirect('/chat'); +import { useRouter } from 'next/navigation'; - // return ( - //
- // Welcome to Insight Playground - //
- // ); +import { useRef, useState } from 'react'; +import Composer, { ComposerRef } from '@/components/chat/Composer'; +import { dbPostCreateChat } from '@/lib/db/functions'; +import { nanoid } from '@/lib/utils'; +import Chip from '@/components/ui/Chip'; +import { IconArrowUpRight } from '@/components/ui/Icons'; + +const EXAMPLES = [ + { + title: 'Counting flowers in image', + mediaUrl: + 'https://vision-agent-dev.s3.us-east-2.amazonaws.com/examples/flower.png', + prompt: + 'Detect flowers in this image, draw boxes and output the image, also return total number of flowers', + }, + { + title: 'Detecting sharks in video', + mediaUrl: + 'https://vision-agent-dev.s3.us-east-2.amazonaws.com/examples/shark3_short.mp4', + prompt: + 'Can you detect any surfboards or sharks in the video, draw a green line between the shark and the nearest surfboard and add the distance between them in meters assuming 30 pixels is 1 meter. Make the line red if the shark is within 10 meters of a surfboard. Sample the video at 1 frames per second and save the output video as output.mp4.', + }, +]; + +export default function Page() { + const router = useRouter(); + const composerRef = useRef(null); + return ( +
+
+

+ Vision Agent + BETA +

+

+ Generate code to solve your vision problem with simple prompts. +

+
+ { + const newId = nanoid(); + const resp = await dbPostCreateChat({ + id: newId, + title: `conversation-${newId}`, + mediaUrl, + message: { + prompt: input, + mediaUrl, + }, + }); + if (resp) { + router.push(`/chat/${newId}`); + } + }} + /> +
+ {EXAMPLES.map((example, index) => { + return ( + { + composerRef.current?.setInput(example.prompt); + composerRef.current?.setMediaUrl(example.mediaUrl); + }} + > +
+

{example.title}

+ +
+
+ ); + })} +
+
+ ); } diff --git a/app/project/[projectId]/page.tsx b/app/project/[projectId]/page.tsx deleted file mode 100644 index c4f6f986992daabf8b891d1cd16d0a95cd215b23..0000000000000000000000000000000000000000 --- a/app/project/[projectId]/page.tsx +++ /dev/null @@ -1,33 +0,0 @@ -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 index c116f8aa435d1851f992292836f91552526525f6..41596171ecbadd1291e611ea0047c23961bcd50f 100644 --- a/app/project/layout.tsx +++ b/app/project/layout.tsx @@ -1,7 +1,7 @@ import ProjectListSideBar from '@/components/project-sidebar/ProjectListSideBar'; import { Suspense } from 'react'; import Loading from '@/components/ui/Loading'; -import { authEmail } from '@/auth'; +import { sessionUser } from '@/auth'; import { redirect } from 'next/navigation'; interface ChatLayoutProps { @@ -9,7 +9,7 @@ interface ChatLayoutProps { } export default async function Layout({ children }: ChatLayoutProps) { - const { isAdmin } = await authEmail(); + const { isAdmin } = await sessionUser(); if (!isAdmin) { redirect('/'); diff --git a/assets/svg/LandingAI_white.svg b/assets/svg/LandingAI_white.svg new file mode 100644 index 0000000000000000000000000000000000000000..ad6319bf07bc1b2c2fff7dca15df8be20db0c6e4 --- /dev/null +++ b/assets/svg/LandingAI_white.svg @@ -0,0 +1,71 @@ + + +AAAsaGp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAACxCanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46dXVpZDpmYjY2NTVlMC0wN2EwLTQ1MzMtOGI1Yy0xNTAxZDM4ZDk3MDIAAAABqGp1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAAMpqdW1iAAAAJmp1bWRjYm9yABEAEIAAAKoAOJtxA2MycGEuYWN0aW9ucwAAAACcY2JvcqFnYWN0aW9uc4GjZmFjdGlvbmtjMnBhLmVkaXRlZG1zb2Z0d2FyZUFnZW50bUFkb2JlIEZpcmVmbHlxZGlnaXRhbFNvdXJjZVR5cGV4Rmh0dHA6Ly9jdi5pcHRjLm9yZy9uZXdzY29kZXMvZGlnaXRhbHNvdXJjZXR5cGUvdHJhaW5lZEFsZ29yaXRobWljTWVkaWEAAACtanVtYgAAAChqdW1kY2JvcgARABCAAACqADibcQNjMnBhLmhhc2guZGF0YQAAAAB9Y2JvcqVqZXhjbHVzaW9uc4GiZXN0YXJ0GQGQZmxlbmd0aBk7OGRuYW1lbmp1bWJmIG1hbmlmZXN0Y2FsZ2ZzaGEyNTZkaGFzaFggAdW70npmi+lQ42Tpti+LNOfEb/Z+jYkO0wz33DrsQU1jcGFkSQAAAAAAAAAAAAAAAgtqdW1iAAAAJGp1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0AAAAB32Nib3KoaGRjOnRpdGxlb0dlbmVyYXRlZCBJbWFnZWlkYzpmb3JtYXRtaW1hZ2Uvc3ZnK3htbGppbnN0YW5jZUlEeCx4bXA6aWlkOmE0NjgxYzEwLTA5ZTgtNDQzMi04NDgzLWZhN2M4OTM5MTY2ZG9jbGFpbV9nZW5lcmF0b3J4NkFkb2JlX0lsbHVzdHJhdG9yLzI4LjEgYWRvYmVfYzJwYS8wLjcuNiBjMnBhLXJzLzAuMjUuMnRjbGFpbV9nZW5lcmF0b3JfaW5mb4G/ZG5hbWVxQWRvYmUgSWxsdXN0cmF0b3JndmVyc2lvbmQyOC4x/2lzaWduYXR1cmV4GXNlbGYjanVtYmY9YzJwYS5zaWduYXR1cmVqYXNzZXJ0aW9uc4KiY3VybHgnc2VsZiNqdW1iZj1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AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2WQEAWTqpfswuLhUHjCX93UfVBiQbxIpTQ9oJKkFdVzxjG2AZWbcCGBQ9ZgHP1FMiAO/dgj40RpjG0NsXgjQJcfoYEtoXczMpYx8n+sOUV79o0o+iaEwsiw4WaUOw9XkCEnkLHTIa2nKDPmWwJS5ILXjS/iglPJc0dHKCZhVg6eV6MwSpLGy126POft7iWuRpJbxHb9rxx2dcWY6s+X5fMsQgf4x2mxaIpMzr3OeW26QnUtsbO08RCkJczv9ZR5O5bhLNogLP4uWfJdJhAAmBD3ARc7AkMDBv73jqN9YVNhwEldbC/en7/Xjl+52Ji6zkx18K/+2Bi2m9tcyOarVE+MbHDQ== + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/auth.ts b/auth.ts index 4b25d806f452822b307b6356d6458cb65ff386e4..b3beb5b509becc680b8fc169c36860d0f160e86f 100644 --- a/auth.ts +++ b/auth.ts @@ -1,6 +1,8 @@ import NextAuth, { type DefaultSession } from 'next-auth'; import GitHub from 'next-auth/providers/github'; import Google from 'next-auth/providers/google'; +import { dbFindOrCreateUser } from './lib/db/functions'; +import { redirect } from 'next/navigation'; declare module 'next-auth' { interface Session { @@ -25,23 +27,41 @@ export const { }), ], callbacks: { - // signIn({ profile }) { - // if (profile?.email?.endsWith('@landing.ai')) { - // return !!profile; - // } else { - // return '/unauthorized'; - // } - // }, - jwt({ token, profile }) { + async signIn({ profile, user }) { + if (!profile) { + return false; + } + const { email, name, picture } = profile; + + if (!email || !name) { + return false; + } + + const dbUser = await dbFindOrCreateUser(email, name, picture); + + if (dbUser) { + user.id = dbUser.id; + return true; + } + return false; + }, + async jwt({ token, profile, user }) { 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); + async session({ session, token }) { + // TODO: this is temporary between we switch DB and make migration + // so also UI might still have session, DB might already have cleaned up + const email = session?.user?.email; + const name = session?.user?.name; + const avatar = session?.user?.image; + if (email && name) { + const dbUser = await dbFindOrCreateUser(email, name, avatar); + // put db user id into session + session.user.id = dbUser.id; } return session; }, @@ -59,8 +79,13 @@ export const { }, }); -export async function authEmail() { +export async function sessionUser() { const session = await auth(); - const email = session?.user?.email; - return { email, isAdmin: !!email?.endsWith('landing.ai') }; + const email = session?.user.email; + return { + email, + isAdmin: !!email?.endsWith('landing.ai'), + id: session?.user.id ?? null, + user: session?.user ?? null, + }; } diff --git a/chart/.helmignore b/chart/.helmignore new file mode 100644 index 0000000000000000000000000000000000000000..0e8a0eb36f4ca2c939201c0d54b5d82a1ea34778 --- /dev/null +++ b/chart/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/chart/Chart.yaml b/chart/Chart.yaml new file mode 100644 index 0000000000000000000000000000000000000000..871cc618c5047a8e036ffbe99696a8e545952f60 --- /dev/null +++ b/chart/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v2 +name: vision-agent +description: A Helm chart for LandingAI Vision Agent + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "1.16.0" diff --git a/chart/dev.values.yaml b/chart/dev.values.yaml new file mode 100644 index 0000000000000000000000000000000000000000..2b824744674d450c0913d543b62d9532bf8488de --- /dev/null +++ b/chart/dev.values.yaml @@ -0,0 +1,5 @@ +ingressRoute: + matchRule: "Host(`va.dev.landing.ai`)" + +env: + LND_TIER: "dev" diff --git a/chart/prod.values.yaml b/chart/prod.values.yaml new file mode 100644 index 0000000000000000000000000000000000000000..cdfdc2b129e5adc6932c4a20d87ec1006240a30f --- /dev/null +++ b/chart/prod.values.yaml @@ -0,0 +1,10 @@ +ingressRoute: + matchRule: "Host(`va.landing.ai`)" + +autoscaling: + enabled: true + minReplicas: 3 + maxReplicas: 9 + +env: + LND_TIER: "production" diff --git a/chart/templates/NOTES.txt b/chart/templates/NOTES.txt new file mode 100644 index 0000000000000000000000000000000000000000..d801a0b61b4eecde4a6b1e57d22d6c9de2d5233e --- /dev/null +++ b/chart/templates/NOTES.txt @@ -0,0 +1,4 @@ +1. Get the application URL by running these commands: +{{- if .Values.ingressRoute.enabled }} + {{ .Values.ingressRoute.matchRule }} +{{- end }} diff --git a/chart/templates/_helpers.tpl b/chart/templates/_helpers.tpl new file mode 100644 index 0000000000000000000000000000000000000000..7ba5edc272ae7212c1c4e6249987e2661775ee5f --- /dev/null +++ b/chart/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "chart.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "chart.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "chart.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "chart.labels" -}} +helm.sh/chart: {{ include "chart.chart" . }} +{{ include "chart.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "chart.selectorLabels" -}} +app.kubernetes.io/name: {{ include "chart.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "chart.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "chart.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml new file mode 100644 index 0000000000000000000000000000000000000000..3478ae48ab8ed42a1ff0c1773a121e72a947ae78 --- /dev/null +++ b/chart/templates/configmap.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: env-config-{{ include "chart.fullname" . }} + labels: + {{- include "chart.labels" . | nindent 4 }} +data: +{{- toYaml .Values.env | nindent 2 }} diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml new file mode 100644 index 0000000000000000000000000000000000000000..86683f0df66c3682c84de551df03512c620b12e1 --- /dev/null +++ b/chart/templates/deployment.yaml @@ -0,0 +1,73 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "chart.fullname" . }} + labels: + {{- include "chart.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "chart.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "chart.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "chart.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + envFrom: + - configMapRef: + name: env-config-{{ include "chart.fullname" . }} + # - secretRef: + # name: secrets-{{ include "chart.fullname" . }} + livenessProbe: + {{- toYaml .Values.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.readinessProbe | nindent 12 }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.volumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/chart/templates/hpa.yaml b/chart/templates/hpa.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a91f61bd5c7faa1ca3da0eb6883ff7bc0f912431 --- /dev/null +++ b/chart/templates/hpa.yaml @@ -0,0 +1,32 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "chart.fullname" . }} + labels: + {{- include "chart.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "chart.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} diff --git a/chart/templates/ingressroute.yaml b/chart/templates/ingressroute.yaml new file mode 100644 index 0000000000000000000000000000000000000000..fdde00b73a9e13951d6451c0ac1742925b2677f1 --- /dev/null +++ b/chart/templates/ingressroute.yaml @@ -0,0 +1,35 @@ +{{- if .Values.ingressRoute.enabled -}} +apiVersion: traefik.containo.us/v1alpha1 +kind: IngressRoute +metadata: + name: {{ include "chart.fullname" . }} + annotations: + {{- with .Values.ingressRoute.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + labels: + {{- include "chart.labels" . | nindent 4 }} + {{- with .Values.ingressRoute.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + entryPoints: + {{- range .Values.ingressRoute.entryPoints }} + - {{ . }} + {{- end }} + routes: + - kind: Rule + match: {{ .Values.ingressRoute.matchRule }} + services: + - name: {{ include "chart.fullname" . }} + port: {{ .Values.service.port }} + {{- with .Values.ingressRoute.middlewares }} + middlewares: + {{- toYaml . | nindent 6 }} + {{- end -}} + + {{- with .Values.ingressRoute.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end -}} diff --git a/chart/templates/service.yaml b/chart/templates/service.yaml new file mode 100644 index 0000000000000000000000000000000000000000..dfc5b3a33df5c53750ccf846f12f95780d738991 --- /dev/null +++ b/chart/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "chart.fullname" . }} + labels: + {{- include "chart.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "chart.selectorLabels" . | nindent 4 }} diff --git a/chart/templates/serviceaccount.yaml b/chart/templates/serviceaccount.yaml new file mode 100644 index 0000000000000000000000000000000000000000..1df935010adf5bbed85388720878f9d8573f62cb --- /dev/null +++ b/chart/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "chart.serviceAccountName" . }} + labels: + {{- include "chart.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} diff --git a/chart/templates/tests/test-connection.yaml b/chart/templates/tests/test-connection.yaml new file mode 100644 index 0000000000000000000000000000000000000000..8dfed872de8b8125728919d61b4381a12c4f98d7 --- /dev/null +++ b/chart/templates/tests/test-connection.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{ include "chart.fullname" . }}-test-connection" + labels: + {{- include "chart.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test +spec: + containers: + - name: wget + image: busybox + command: ['wget'] + args: ['{{ include "chart.fullname" . }}:{{ .Values.service.port }}'] + restartPolicy: Never diff --git a/chart/values.yaml b/chart/values.yaml new file mode 100644 index 0000000000000000000000000000000000000000..17993e125df07e8668eecad9097352d8766d24b2 --- /dev/null +++ b/chart/values.yaml @@ -0,0 +1,119 @@ +# Default values for chart. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +image: + repository: 970073041993.dkr.ecr.us-east-2.amazonaws.com/vision-agent + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "latest" + +imagePullSecrets: [] +nameOverride: "vision-agent" +fullnameOverride: "" + +serviceAccount: + # Specifies whether a service account should be created + create: false + # Automatically mount a ServiceAccount's API credentials? + automount: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "clef-user" + +podAnnotations: {} +podLabels: {} + +podSecurityContext: + {} + # fsGroup: 2000 + +securityContext: + {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +service: + type: ClusterIP + port: 3000 + +ingressRoute: + enabled: true + entryPoints: + - websecure + matchRule: "" + +resources: + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +livenessProbe: + httpGet: + path: / + port: http +readinessProbe: + httpGet: + path: / + port: http + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 9 + targetCPUUtilizationPercentage: 60 + # targetMemoryUtilizationPercentage: 80 + +env: + AUTH_GITHUB_ID: "" + AUTH_GITHUB_SECRET: "" + AUTH_SECRET: "" + AUTH_TRUST_HOST: "" + AWS_ACCESS_KEY_ID: "" + AWS_BUCKET_NAME: "" + AWS_REGION: "" + AWS_SECRET_ACCESS_KEY: "" + COREPACK_ENABLE_STRICT: "0" + ENABLE_EXPERIMENTAL_COREPACK: "1" + GOOGLE_CLIENT_ID: "" + GOOGLE_SECRET: "" + LOKI_AUTH_USER_ID: "173854" + LOKI_AUTH_USER_PASSWORD: "" + OPENAI_API_KEY: "" + POSTGRES_PRISMA_URL: "" + NEXTAUTH_URL: "" + LND_TIER: "" + +# Additional volumes on the output Deployment definition. +volumes: [] +# - name: foo +# secret: +# secretName: mysecret +# optional: false + +# Additional volumeMounts on the output Deployment definition. +volumeMounts: [] +# - name: foo +# mountPath: "/etc/foo" +# readOnly: true + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/components.json b/components.json new file mode 100644 index 0000000000000000000000000000000000000000..58b812d037d6d8c04547447d71847a2c12576ad3 --- /dev/null +++ b/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} \ No newline at end of file diff --git a/components/Avatar.tsx b/components/Avatar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..afdef0b57d72892274e9d4ee1bc2ab3cd7fdd536 --- /dev/null +++ b/components/Avatar.tsx @@ -0,0 +1,34 @@ +import Image from 'next/image'; +import React from 'react'; + +export interface AvatarProps { + name?: string | null; + avatar?: string | null; +} + +function getUserInitials(name: string) { + const [firstName, lastName] = name.split(' '); + return lastName ? `${firstName[0]}${lastName[0]}` : firstName.slice(0, 2); +} + +const Avatar: React.FC = ({ name, avatar }) => { + return ( + <> + {avatar ? ( + {name + ) : ( +
+ {name ? getUserInitials(name) : 'VA'} +
+ )} + + ); +}; + +export default Avatar; diff --git a/components/ChatInterface.tsx b/components/ChatInterface.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c927a80d48f07dee05f862f9dc2cb2876033b751 --- /dev/null +++ b/components/ChatInterface.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { ChatWithMessages } from '@/lib/types'; +import React from 'react'; +import ChatList from './chat/ChatList'; +import { Card } from './ui/Card'; +import { useAtom, useAtomValue } from 'jotai'; +import { selectedMessageId } from '@/state/chat'; +import CodeResultDisplay from './CodeResultDisplay'; + +export interface ChatInterfaceProps { + chat: ChatWithMessages; + userId?: string | null; +} + +const ChatInterface: React.FC = ({ chat, userId }) => { + const messageId = useAtomValue(selectedMessageId); + const messageCodeResult = chat.messages.find( + message => message.id === messageId, + )?.result; + return ( +
+
+ {messageCodeResult?.payload && ( + + + + )} +
+
+ +
+
+ ); +}; + +export default ChatInterface; diff --git a/components/ChatSelect.tsx b/components/ChatSelect.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2dd0ee6fbe508a23df0fb6da2404d7c111a3d821 --- /dev/null +++ b/components/ChatSelect.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { Chat } from '@prisma/client'; +import React from 'react'; +import { + SelectItem, + Select, + SelectTrigger, + SelectContent, + SelectIcon, + SelectGroup, + SelectSeparator, +} from './ui/Select'; +import Img from './ui/Img'; +import { format } from 'date-fns'; +import { useParams, useRouter } from 'next/navigation'; +import { IconPlus } from './ui/Icons'; + +export interface ChatSelectProps { + chat: Chat; +} + +const ChatSelectItem: React.FC = ({ chat }) => { + const { id, title, mediaUrl, updatedAt } = chat; + return ( + +
+
+ {`chat-${id}-card-image`} +
+
+

{title ?? '(no title)'}

+

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

+
+
+
+ ); +}; + +const ChatSelect: React.FC<{ myChats: Chat[] }> = ({ myChats }) => { + const { id: chatIdFromParam } = useParams(); + + const currentChat = myChats.find(chat => chat.id === chatIdFromParam); + const router = useRouter(); + return ( + + ); +}; + +export default ChatSelect; diff --git a/components/ChatSelectServer.tsx b/components/ChatSelectServer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e9672a8776a2ac3ee5e2534300a240aaf2ce481c --- /dev/null +++ b/components/ChatSelectServer.tsx @@ -0,0 +1,8 @@ +import { dbGetMyChatList } from '@/lib/db/functions'; +import ChatSelect from './ChatSelect'; + +export default async function ChatSelectServer() { + const [myChats] = await Promise.all([dbGetMyChatList()]); + + return ; +} diff --git a/components/CodeResultDisplay.tsx b/components/CodeResultDisplay.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5178898b89c68e3b2ec335048a9af7f6683fd05b --- /dev/null +++ b/components/CodeResultDisplay.tsx @@ -0,0 +1,167 @@ +import React from 'react'; + +import { CodeBlock } from './ui/CodeBlock'; +import { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, +} from './ui/Dialog'; +import { Button } from './ui/Button'; +import { IconLog, IconTerminalWindow } from './ui/Icons'; +import { Separator } from './ui/Separator'; +import Img from './ui/Img'; +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from './ui/carousel'; + +export interface CodeResultDisplayProps {} + +const CodeResultDisplay: React.FC<{ + codeResult: PrismaJson.FinalCodeBody['payload']; +}> = ({ codeResult }) => { + const { code, test, result } = codeResult; + const getDetail = () => { + if (!result) return {}; + try { + // IMPORTANT: This is for backwards compatibility with old chat that save result as JSON string + // updated in https://github.com/landing-ai/vision-agent-ui/pull/86 + const detail = + typeof result === 'object' + ? result + : (JSON.parse(result) as PrismaJson.StructuredResult); + return { + results: detail.results, + stderr: detail.logs.stderr, + stdout: detail.logs.stdout, + error: detail.error, + }; + } catch { + return {}; + } + }; + + const { results = [], stderr, stdout, error } = getDetail(); + + const imageResults = results?.filter(_ => !!_.png).map(_ => _.png); + const videoResults = results?.filter(_ => !!_.mp4).map(_ => _.mp4); + const finalResult = results?.find(_ => _.is_main_result)?.text; + + return ( +
+ +
+
+ + + + + + + Test code + + + + + {Array.isArray(stderr) && !!stderr.join('').trim() && ( + + + + + + + + + )} +
+
+ {Array.isArray(stdout) && !!stdout.join('').trim() && ( + <> + + + + )} + {!!error && ( + <> + + + + )} + {!!imageResults.length && ( +
+
+

image output

+ + + + + + + + {imageResults.map((png, index) => ( + + result-image + + ))} + + + + + + +
+
+ {imageResults.map((png, index) => ( + result-image + ))} +
+
+ )} + {!!videoResults.length && ( +
+

video output

+
+ {videoResults.map((mp4, index) => ( + + + + + + + ))} +
+
+ )} + {!!finalResult && } +
+ ); +}; + +export default CodeResultDisplay; diff --git a/components/Header.tsx b/components/Header.tsx index 9db092636c44b67a4f89be416766b05fef3bca74..91ac439f5d8d766f12559fd47fea435e8efe5575 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -1,23 +1,52 @@ -import * as React from 'react'; +import { Suspense } from 'react'; import Link from 'next/link'; -import { auth, authEmail } from '@/auth'; +import { auth, sessionUser } from '@/auth'; import { Button } from '@/components/ui/Button'; import { UserMenu } from '@/components/UserMenu'; +import { IconPlus, IconSeparator } from '@/components/ui/Icons'; +import { LoginMenu } from './LoginMenu'; +import { redirect } from 'next/navigation'; +import Image from 'next/image'; +import LandingLogo from '@/assets/svg/LandingAI_white.svg'; +import ChatSelectServer from './ChatSelectServer'; +import Loading from './ui/Loading'; +import { Skeleton } from './ui/Skeleton'; import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/Tooltip'; -import { IconPlus, IconSeparator } from '@/components/ui/Icons'; -import { LoginMenu } from './LoginMenu'; -import { redirect } from 'next/navigation'; +import { IconDiscord, IconGitHub } from '@/components/ui/Icons'; export async function Header() { const session = await auth(); - const { isAdmin } = await authEmail(); + // const { isAdmin } = await sessionUser(); + + if (process.env.NEXT_PUBLIC_IS_HUGGING_FACE) { + return ( +
+ +
+ ); + } + return ( -
+
+ + Landing AI + + {session?.user && ( + }> + + + )} +
{/* + )} {isAdmin && ( - )} + )} */} + + + + + Github + + + + + + Discord + -
+
{session?.user ? : }
diff --git a/components/Providers.tsx b/components/Providers.tsx index 87ba82e6661106f9cf8c5fbd1bbde006c7e3fa17..480055900a8b39f29e08cc62064f24c197c899dc 100644 --- a/components/Providers.tsx +++ b/components/Providers.tsx @@ -4,14 +4,14 @@ 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'; +// import { ThemeToggle } from './ThemeToggle'; export function Providers({ children, ...props }: ThemeProviderProps) { return ( - + {children} - + {/* */} ); diff --git a/components/UserMenu.tsx b/components/UserMenu.tsx index 14fe47924926cbcdd8818ae2702cbcedf45478f5..4ebfa6ad6a55361479e1c2c4af3cf6dd6b6cffab 100644 --- a/components/UserMenu.tsx +++ b/components/UserMenu.tsx @@ -13,6 +13,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/DropdownMenu'; import { IconExternalLink } from '@/components/ui/Icons'; +import Avatar from './Avatar'; export interface UserMenuProps { user: Session['user']; @@ -29,23 +30,15 @@ export function UserMenu({ user }: UserMenuProps) { - +
{user?.name}
{user?.email}
diff --git a/components/chat-sidebar/ChatAdminToggle.tsx b/components/chat-sidebar/ChatAdminToggle.tsx deleted file mode 100644 index 55ba12dc6a6f90d4d5ac632a06e82a059841a0fd..0000000000000000000000000000000000000000 --- a/components/chat-sidebar/ChatAdminToggle.tsx +++ /dev/null @@ -1,14 +0,0 @@ -'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 deleted file mode 100644 index d9931ffedde3f717ab05c19a2647229d901e2edd..0000000000000000000000000000000000000000 --- a/components/chat-sidebar/ChatCard.tsx +++ /dev/null @@ -1,63 +0,0 @@ -'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 deleted file mode 100644 index 2885dd0fb926ead3d50b06073b0edd9e5eb72d65..0000000000000000000000000000000000000000 --- a/components/chat-sidebar/ChatListSidebar.tsx +++ /dev/null @@ -1,27 +0,0 @@ -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 index 06f502b0c2f8478b65288d138087d1ca1a62fcde..50648e2f1846750fec3fbd89148170960fa4df99 100644 --- a/components/chat/ChatList.tsx +++ b/components/chat/ChatList.tsx @@ -1,26 +1,102 @@ 'use client'; -import { Separator } from '@/components/ui/Separator'; -import { ChatMessage } from '@/components/chat/ChatMessage'; -import { MessageBase } from '../../lib/types'; +// import { ChatList } from '@/components/chat/ChatList'; +import Composer from '@/components/chat/Composer'; +import useVisionAgent from '@/lib/hooks/useVisionAgent'; +import { useScrollAnchor } from '@/lib/hooks/useScrollAnchor'; +import { useEffect } from 'react'; +import { ChatWithMessages } from '@/lib/types'; +import { ChatMessage } from './ChatMessage'; +import { Button } from '../ui/Button'; +import { cn } from '@/lib/utils'; +import { IconArrowDown } from '../ui/Icons'; +import { dbPostCreateMessage } from '@/lib/db/functions'; +import { Card } from '../ui/Card'; +import { useSetAtom } from 'jotai'; +import { selectedMessageId } from '@/state/chat'; -export interface ChatList { - messages: MessageBase[]; +export interface ChatListProps { + chat: ChatWithMessages; + userId?: string | null; } -export function ChatList({ messages }: ChatList) { +export const SCROLL_BOTTOM = 120; + +const ChatList: React.FC = ({ chat, userId }) => { + const { id, messages: dbMessages, userId: chatUserId } = chat; + const { messages, append, isLoading, data } = useVisionAgent(chat); + + // Only login and chat owner can compose + const canCompose = !chatUserId || userId === chatUserId; + + const lastDbMessage = dbMessages[dbMessages.length - 1]; + const setMessageId = useSetAtom(selectedMessageId); + + const { messagesRef, scrollRef, visibilityRef, isVisible, scrollToBottom } = + useScrollAnchor(SCROLL_BOTTOM); + + // Scroll to bottom on init and highlight last message + useEffect(() => { + scrollToBottom(); + if (lastDbMessage.result) { + setMessageId(lastDbMessage.id); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( -
- {messages - // .filter(message => message.role !== 'system') - .map((message, index) => ( -
- - {index < messages.length - 1 && ( - - )} -
- ))} -
+ +
+ {dbMessages.map((message, index) => { + const isLastMessage = index === dbMessages.length - 1; + return ( + + ); + })} +
+
+ {canCompose && ( +
+ { + const messageInput = { + prompt: input, + mediaUrl: newMediaUrl, + }; + const resp = await dbPostCreateMessage(id, messageInput); + append(resp); + }} + /> +
+ )} + {/* Scroll to bottom Icon */} + + ); -} +}; + +export default ChatList; diff --git a/components/chat/ChatMessage.tsx b/components/chat/ChatMessage.tsx index f434128ebed7da8a0aa140dad1dba3744e388857..b38414a3673b997e7e75910f7585e088307fb08e 100644 --- a/components/chat/ChatMessage.tsx +++ b/components/chat/ChatMessage.tsx @@ -1,151 +1,292 @@ -// 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 { useEffect, useMemo, useRef, useState } from 'react'; 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'; + IconCheckCircle, + IconCodeWrap, + IconCrossCircle, + IconLandingAI, + IconListUnordered, + IconTerminalWindow, + IconUser, + IconGlowingDot, +} from '@/components/ui/Icons'; +import { MessageUI } from '@/lib/types'; +import { WIPChunkBodyGroup, formatStreamLogs } from '@/lib/utils/content'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '../ui/Table'; +import { Button } from '../ui/Button'; +import { Dialog, DialogContent, DialogTrigger } from '../ui/Dialog'; import Img from '../ui/Img'; -import { getCleanedUpMessages } from '@/lib/messageUtils'; +import CodeResultDisplay from '../CodeResultDisplay'; +import { useAtom, useSetAtom } from 'jotai'; +import { selectedMessageId } from '@/state/chat'; +import { Message } from '@prisma/client'; +import { Separator } from '../ui/Separator'; +import { cn } from '@/lib/utils'; export interface ChatMessageProps { - message: MessageBase; + message: Message; + loading?: boolean; + wipAssistantMessage?: PrismaJson.MessageBody[]; } -export function ChatMessage({ message, ...props }: ChatMessageProps) { - const { logs, content } = useMemo(() => { - return getCleanedUpMessages({ - content: message.content, - role: message.role, - }); - }, [message.content, message.role]); +export const ChatMessage: React.FC = ({ + data, + message, + wipAssistantMessage, + loading, +}) => { + const [messageId, setMessageId] = useAtom(selectedMessageId); + const { id, mediaUrl, prompt, response, result, responseBody } = message; + const [formattedSections, finalResult, finalError] = useMemo( + () => + formatStreamLogs( + responseBody ?? wipAssistantMessage ?? JSON.parse(response ?? ''), + ), + [response, wipAssistantMessage, result, responseBody, data], + ); + // prioritize the result from the message over the WIP message + const codeResult = result?.payload ?? finalResult; return ( -
+
{ + if (result) { + setMessageId(id); + } + }} + > +
+
+ +
+
+

{prompt}

+ {mediaUrl && ( + <> + {mediaUrl?.endsWith('.mp4') ? ( +
+
+ {!!formattedSections.length && ( + <> + +
+
+ +
+
+ + + {formattedSections.map((section, index) => ( + + + {ChunkStatusToIconDict[section.status]} + + + + + + + + + ))} + +
+ {codeResult && ( + <> +
+ +
+

โœจ Coding complete

+ + )} + {!codeResult && finalError && ( + <> +

โŒ {finalError.name}

+
+ +
+ + )} +
+
+ + )}
- {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 ( - โ– - ); - } +
+ ); +}; + +const ChunkStatusToIconDict: Record< + WIPChunkBodyGroup['status'], + React.ReactElement +> = { + started: , + completed: , + running: , + failed: , +}; +const ChunkTypeToText: React.FC<{ + chunk: WIPChunkBodyGroup; + useTimer: boolean; +}> = ({ chunk, useTimer }) => { + const { status, type, timestamp, duration } = chunk; + + const [mSeconds, setMSeconds] = useState(0); + const isExecuting = !['completed', 'failed'].includes(status); + + useEffect(() => { + if (isExecuting && timestamp && useTimer) { + const timerId = setInterval(() => { + setMSeconds(Date.now() - Date.parse(timestamp)); + }, 200); + return () => clearInterval(timerId); + } + }, [isExecuting, timestamp, useTimer]); + + const displayMs = isExecuting && useTimer ? mSeconds : duration; - children[0] = (children[0] as string).replace('`โ–`', 'โ–'); - } + const durationDisplay = displayMs + ? `(${Math.round(displayMs / 100) / 10}s)` + : ''; - const match = /language-(\w+)/.exec(className || ''); - if (inline) { - return ( - - {children} - - ); - } + if (type === 'plans') return

Creating instructions {durationDisplay}

; + if (type === 'tools') return

Retrieving tools {durationDisplay}

; + if (type === 'code' && status === 'started') + return

Generating code {durationDisplay}

; + if (type === 'code' && status === 'running') + return

Executing code {durationDisplay}

; + if (type === 'code' && status === 'completed') + return

Code execution success {durationDisplay}

; + if (type === 'code' && status === 'failed') + return

Code execution failure {durationDisplay}

; - return ( - - ); - }, - }} + return null; +}; +const ChunkPayloadAction: React.FC<{ + payload: WIPChunkBodyGroup['payload']; +}> = ({ payload }) => { + if (!payload) return null; + if (Array.isArray(payload)) { + // [{title: 123, content, 345}, {title: ..., content: ...}] => ['title', 'content'] + const keyArray = Array.from( + payload.reduce((acc, curr) => { + Object.keys(curr).forEach(key => acc.add(key)); + return acc; + }, new Set()), + ); + + return ( + + + + + e.preventDefault()} > - {content} - - {/* */} -
-
- ); -} + + + + {keyArray.map(header => ( + {header} + ))} + + + + {payload.map((line, index) => ( + + {keyArray.map(header => + header === 'documentation' ? ( + + + + + + + + + + + ) : ( + {line[header]} + ), + )} + + ))} + +
+ + + ); + } else if ((payload as PrismaJson.FinalCodeBody['payload']).code) { + return ( + + + + + + + + + ); + } + return null; +}; + +export default ChatMessage; diff --git a/components/chat/ChatMessageActions.tsx b/components/chat/ChatMessageActions.tsx index cf0333ff6de7d442a275e2afb3de69f872713e4c..1071f813eb7440e2c9612a3f5e9aeda8acdaa186 100644 --- a/components/chat/ChatMessageActions.tsx +++ b/components/chat/ChatMessageActions.tsx @@ -6,10 +6,10 @@ 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'; +import { MessageUI } from '../../lib/types'; interface ChatMessageActionsProps extends React.ComponentProps<'div'> { - message: MessageBase; + message: MessageUI; } export function ChatMessageActions({ diff --git a/components/chat/Composer.tsx b/components/chat/Composer.tsx index 05afbfad1c85b776bd66775b5b29d3165f217b3e..07ead9a7e2b1af7019de11d2dba4c0b14a12dc04 100644 --- a/components/chat/Composer.tsx +++ b/components/chat/Composer.tsx @@ -1,11 +1,14 @@ 'use client'; -import * as React from 'react'; -import { type UseChatHelpers } from 'ai/react'; -import Textarea from 'react-textarea-autosize'; +import { + useState, + useEffect, + useRef, + forwardRef, + useImperativeHandle, +} from 'react'; import { Button } from '@/components/ui/Button'; -import { MessageBase } from '../../lib/types'; import { useEnterSubmit } from '@/lib/hooks/useEnterSubmit'; import Img from '../ui/Img'; import { @@ -13,185 +16,175 @@ import { TooltipContent, TooltipTrigger, } from '@/components/ui/Tooltip'; -import { - IconArrowDown, - IconArrowElbow, - IconRefresh, - IconStop, -} from '@/components/ui/Icons'; +import { IconImage, IconArrowUp, IconClose } from '@/components/ui/Icons'; import { cn } from '@/lib/utils'; -import { generateInputImageMarkdown } from '@/lib/messageUtils'; +import Chip from '../ui/Chip'; +import Textarea from 'react-textarea-autosize'; +import useMediaUpload from '@/lib/hooks/useMediaUpload'; -export interface ComposerProps - extends Pick< - UseChatHelpers, - 'append' | 'isLoading' | 'reload' | 'stop' | 'input' | 'setInput' - > { - id?: string; +export interface ComposerProps { + onSubmit: (params: { input: string; mediaUrl: string }) => Promise; + disabled?: boolean; title?: string; - messages: MessageBase[]; - url?: string; - isAtBottom: boolean; - scrollToBottom: () => void; + initMediaUrl?: string; + initInput?: string; } -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(); - } - }, []); +export interface ComposerRef { + setMediaUrl: (url: string) => void; + setInput: (input: string) => void; +} + +const Composer = forwardRef( + ({ disabled, onSubmit, initMediaUrl, initInput }, ref) => { + const { formRef, onKeyDown } = useEnterSubmit(); + const inputRef = useRef(null); + const [localMediaUrl, setLocalMediaUrl] = useState( + initMediaUrl, + ); + const [isSubmitting, setIsSubmitting] = useState(false); + const [input, setLocalInput] = useState(initInput ?? ''); + const noMediaValidation = !localMediaUrl && !!input; + const { + getRootProps, + getInputProps, + isDragActive, + isUploading, + openUpload, + } = useMediaUpload(uploadUrl => setLocalMediaUrl(uploadUrl)); + + const finalLoading = isUploading || isSubmitting; + const finalDisabled = finalLoading || disabled; + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, []); - return ( - //
-
-
+ useImperativeHandle(ref, () => ({ + setMediaUrl(mediaUrl) { + setLocalMediaUrl(mediaUrl); + }, + setInput(input) { + setLocalInput(input); + }, + })); + + const mediaName = localMediaUrl?.split('/').pop(); + return ( +
+ +
+
+
+ {localMediaUrl ? ( + +
+ + +
+ +

{mediaName ?? 'unnamed_media'}

+
+
+ + zoomed-in-image + +
+ +
+
+ ) : ( + + )}
{ e.preventDefault(); - if (!input?.trim()) { + if (!input?.trim() || !localMediaUrl) { return; } - setInput(''); - await append({ - id, - content: - input + (url ? '\n\n' + generateInputImageMarkdown(url) : ''), - role: 'user', - }); - scrollToBottom(); + setIsSubmitting(true); + try { + await onSubmit({ input, mediaUrl: localMediaUrl }); + } finally { + setIsSubmitting(false); + setLocalInput(''); + } }} ref={formRef} - className="h-full" + className="h-full mt-4" > -
- {url && ( -
- - - preview-image - - - zoomed-in-image - - -
- )} -