wuyiqun0718 commited on
Commit
159e7fa
1 Parent(s): 9011efd
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Dockerfile +8 -11
  2. README.md +72 -2
  3. app/api/auth/[...nextauth]/route.ts +1 -1
  4. app/api/chat/route.ts +5 -6
  5. app/api/sign/route.ts +3 -9
  6. app/api/upload/route.ts +0 -51
  7. app/api/vision-agent/route.ts +307 -44
  8. app/chat/[id]/page.tsx +15 -4
  9. app/chat/[id]/server.tsx +26 -0
  10. app/chat/layout.tsx +2 -23
  11. app/chat/page.tsx +4 -115
  12. app/globals.css +42 -91
  13. app/layout.tsx +9 -7
  14. app/page.tsx +79 -9
  15. app/project/[projectId]/page.tsx +0 -33
  16. app/project/layout.tsx +2 -2
  17. assets/svg/LandingAI_white.svg +71 -0
  18. auth.ts +39 -14
  19. chart/.helmignore +23 -0
  20. chart/Chart.yaml +24 -0
  21. chart/dev.values.yaml +5 -0
  22. chart/prod.values.yaml +10 -0
  23. chart/templates/NOTES.txt +4 -0
  24. chart/templates/_helpers.tpl +62 -0
  25. chart/templates/configmap.yaml +8 -0
  26. chart/templates/deployment.yaml +73 -0
  27. chart/templates/hpa.yaml +32 -0
  28. chart/templates/ingressroute.yaml +35 -0
  29. chart/templates/service.yaml +15 -0
  30. chart/templates/serviceaccount.yaml +13 -0
  31. chart/templates/tests/test-connection.yaml +15 -0
  32. chart/values.yaml +119 -0
  33. components.json +17 -0
  34. components/Avatar.tsx +34 -0
  35. components/ChatInterface.tsx +40 -0
  36. components/ChatSelect.tsx +80 -0
  37. components/ChatSelectServer.tsx +8 -0
  38. components/CodeResultDisplay.tsx +167 -0
  39. components/Header.tsx +67 -10
  40. components/Providers.tsx +3 -3
  41. components/UserMenu.tsx +7 -14
  42. components/chat-sidebar/ChatAdminToggle.tsx +0 -14
  43. components/chat-sidebar/ChatCard.tsx +0 -63
  44. components/chat-sidebar/ChatListSidebar.tsx +0 -27
  45. components/chat/ChatList.tsx +95 -19
  46. components/chat/ChatMessage.tsx +275 -134
  47. components/chat/ChatMessageActions.tsx +2 -2
  48. components/chat/Composer.tsx +163 -170
  49. components/chat/ImageSelector.tsx +0 -91
  50. components/chat/MemoizedReactMarkdown.tsx +65 -0
Dockerfile CHANGED
@@ -5,7 +5,7 @@ RUN corepack enable
5
 
6
  FROM base AS deps
7
  WORKDIR /app
8
- COPY package.json pnpm-lock.yaml ./
9
  RUN pnpm i --frozen-lockfile
10
 
11
  # Rebuild the source code only when needed
@@ -15,12 +15,11 @@ COPY --from=deps --link /app/node_modules ./node_modules
15
  COPY --link . .
16
 
17
  RUN --mount=type=secret,id=AUTH_SECRET \
18
- --mount=type=secret,id=OPENAI_API_KEY \
19
- AUTH_SECRET="$(cat /run/secrets/AUTH_SECRET)" \
20
- OPENAI_API_KEY="$(cat /run/secrets/OPENAI_API_KEY)" \
21
- NEXT_SHARP_PATH="/app/node_modules/sharp" \
22
- USE_STANDALONE_BUILD=True \
23
- pnpm run build
24
 
25
  RUN mkdir -p /app/.next/cache/images
26
 
@@ -39,11 +38,9 @@ COPY --from=builder --link --chown=1000:1000 /app/.next/standalone ./
39
  COPY --from=builder --link --chown=1000:1000 /app/.next/static ./.next/static
40
  COPY --from=builder --link --chown=1000:1000 /app/.next/cache/images ./.next/cache/images
41
 
42
- USER nextjs
43
 
44
- EXPOSE 7860
45
-
46
- ENV PORT 7860
47
  ENV HOSTNAME 0.0.0.0
48
 
49
  CMD ["node", "server.js"]
 
5
 
6
  FROM base AS deps
7
  WORKDIR /app
8
+ COPY package.json pnpm-lock.yaml prisma/* ./
9
  RUN pnpm i --frozen-lockfile
10
 
11
  # Rebuild the source code only when needed
 
15
  COPY --link . .
16
 
17
  RUN --mount=type=secret,id=AUTH_SECRET \
18
+ --mount=type=secret,id=OPENAI_API_KEY \
19
+ AUTH_SECRET="$(cat /run/secrets/AUTH_SECRET)" \
20
+ OPENAI_API_KEY="$(cat /run/secrets/OPENAI_API_KEY)" \
21
+ USE_STANDALONE_BUILD=True \
22
+ pnpm run build
 
23
 
24
  RUN mkdir -p /app/.next/cache/images
25
 
 
38
  COPY --from=builder --link --chown=1000:1000 /app/.next/static ./.next/static
39
  COPY --from=builder --link --chown=1000:1000 /app/.next/cache/images ./.next/cache/images
40
 
41
+ EXPOSE 3000
42
 
43
+ ENV PORT 3000
 
 
44
  ENV HOSTNAME 0.0.0.0
45
 
46
  CMD ["node", "server.js"]
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: Vision Agent Landing
3
  emoji: 🏃
4
  colorFrom: yellow
5
  colorTo: indigo
@@ -7,4 +7,74 @@ sdk: docker
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Vision Agent
3
  emoji: 🏃
4
  colorFrom: yellow
5
  colorTo: indigo
 
7
  pinned: false
8
  ---
9
 
10
+ <a href="https://chat.vercel.ai/">
11
+ <img alt="Next.js 14 and App Router-ready AI chatbot." src="https://chat.vercel.ai/opengraph-image.png">
12
+ <h1 align="center">Next.js AI Chatbot</h1>
13
+ </a>
14
+
15
+ <p align="center">
16
+ An open-source AI chatbot app template built with Next.js, the Vercel AI SDK, OpenAI, and Vercel KV.
17
+ </p>
18
+
19
+ <p align="center">
20
+ <a href="#features"><strong>Features</strong></a> ·
21
+ <a href="#model-providers"><strong>Model Providers</strong></a> ·
22
+ <a href="#deploy-your-own"><strong>Deploy Your Own</strong></a> ·
23
+ <a href="#running-locally"><strong>Running locally</strong></a> ·
24
+ <a href="#authors"><strong>Authors</strong></a>
25
+ </p>
26
+ <br/>
27
+
28
+ ## Features
29
+
30
+ - [Next.js](https://nextjs.org) App Router
31
+ - React Server Components (RSCs), Suspense, and Server Actions
32
+ - [Vercel AI SDK](https://sdk.vercel.ai/docs) for streaming chat UI
33
+ - Support for OpenAI (default), Anthropic, Cohere, Hugging Face, or custom AI chat models and/or LangChain
34
+ - [shadcn/ui](https://ui.shadcn.com)
35
+ - Styling with [Tailwind CSS](https://tailwindcss.com)
36
+ - [Radix UI](https://radix-ui.com) for headless component primitives
37
+ - Icons from [Phosphor Icons](https://phosphoricons.com)
38
+ - Chat History, rate limiting, and session storage with [Vercel KV](https://vercel.com/storage/kv)
39
+ - [NextAuth.js](https://github.com/nextauthjs/next-auth) for authentication
40
+
41
+ ## Model Providers
42
+
43
+ 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.
44
+
45
+ ## Deploy Your Own
46
+
47
+ You can deploy your own version of the Next.js AI Chatbot to Vercel with one click:
48
+
49
+ [![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"}])
50
+
51
+ ## Creating a KV Database Instance
52
+
53
+ 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.
54
+
55
+ 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.
56
+
57
+ ## Running locally
58
+
59
+ 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.
60
+
61
+ > 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.
62
+
63
+ 1. Install Vercel CLI: `npm i -g vercel`
64
+ 2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link`
65
+ 3. Download your environment variables: `vercel env pull`
66
+
67
+ ```bash
68
+ pnpm install
69
+ pnpm dev
70
+ ```
71
+
72
+ Your app template should now be running on [localhost:3000](http://localhost:3000/).
73
+
74
+ ## Authors
75
+
76
+ This library is created by [Vercel](https://vercel.com) and [Next.js](https://nextjs.org) team members, with contributions from:
77
+
78
+ - Jared Palmer ([@jaredpalmer](https://twitter.com/jaredpalmer)) - [Vercel](https://vercel.com)
79
+ - Shu Ding ([@shuding\_](https://twitter.com/shuding_)) - [Vercel](https://vercel.com)
80
+ - shadcn ([@shadcn](https://twitter.com/shadcn)) - [Vercel](https://vercel.com)
app/api/auth/[...nextauth]/route.ts CHANGED
@@ -1,2 +1,2 @@
1
  export { GET, POST } from '@/auth';
2
- export const runtime = 'edge';
 
1
  export { GET, POST } from '@/auth';
2
+ // export const runtime = 'edge';
app/api/chat/route.ts CHANGED
@@ -6,18 +6,14 @@ import {
6
  ChatCompletionMessageParam,
7
  ChatCompletionContentPart,
8
  } from 'openai/resources';
9
- import { MessageBase } from '../../../lib/types';
10
 
11
  export const runtime = 'edge';
12
 
13
- const openai = new OpenAI({
14
- apiKey: process.env.OPENAI_API_KEY,
15
- });
16
-
17
  export const POST = async (req: Request) => {
18
  const json = await req.json();
19
  const { messages } = json as {
20
- messages: MessageBase[];
21
  id: string;
22
  url: string;
23
  };
@@ -43,6 +39,9 @@ export const POST = async (req: Request) => {
43
  };
44
  },
45
  );
 
 
 
46
  const res = await openai.chat.completions.create({
47
  model: 'gpt-4-vision-preview',
48
  messages: formattedMessage,
 
6
  ChatCompletionMessageParam,
7
  ChatCompletionContentPart,
8
  } from 'openai/resources';
9
+ import { MessageUI } from '@/lib/types';
10
 
11
  export const runtime = 'edge';
12
 
 
 
 
 
13
  export const POST = async (req: Request) => {
14
  const json = await req.json();
15
  const { messages } = json as {
16
+ messages: MessageUI[];
17
  id: string;
18
  url: string;
19
  };
 
39
  };
40
  },
41
  );
42
+ const openai = new OpenAI({
43
+ apiKey: process.env.OPENAI_API_KEY,
44
+ });
45
  const res = await openai.chat.completions.create({
46
  model: 'gpt-4-vision-preview',
47
  messages: formattedMessage,
app/api/sign/route.ts CHANGED
@@ -23,16 +23,10 @@ export const POST = withLogging(
23
  // }
24
 
25
  try {
26
- const { fileName, fileType, id } = json;
27
 
28
- const signedFileName = `${user}/${id ?? nanoid()}/${fileName}`;
29
- const res = await getPresignedUrl(signedFileName, fileType);
30
- return Response.json({
31
- id,
32
- signedUrl: res.url,
33
- publicUrl: `https://${process.env.AWS_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${signedFileName}`,
34
- fields: res.fields,
35
- });
36
  } catch (error) {
37
  return new Response((error as Error).message, {
38
  status: 400,
 
23
  // }
24
 
25
  try {
26
+ const { fileName, fileType, id = nanoid() } = json;
27
 
28
+ const res = await getPresignedUrl(fileName, fileType, id, user);
29
+ return Response.json(res);
 
 
 
 
 
 
30
  } catch (error) {
31
  return new Response((error as Error).message, {
32
  status: 400,
app/api/upload/route.ts DELETED
@@ -1,51 +0,0 @@
1
- import { auth } from '@/auth';
2
- import { createKVChat } from '@/lib/kv/chat';
3
- import { withLogging } from '@/lib/logger';
4
- import { ChatEntity, MessageBase } from '@/lib/types';
5
- import { nanoid } from '@/lib/utils';
6
- import { Session } from 'next-auth';
7
- import { revalidatePath } from 'next/cache';
8
-
9
- /**
10
- * @param req
11
- * @returns
12
- */
13
- export const POST = withLogging(
14
- async (
15
- session,
16
- json: {
17
- id?: string;
18
- url: string;
19
- initMessages?: MessageBase[];
20
- },
21
- ): Promise<Response> => {
22
- const user = session?.user?.email ?? 'anonymous';
23
- // if (!email) {
24
- // return new Response('Unauthorized', {
25
- // status: 401,
26
- // });
27
- // }
28
-
29
- try {
30
- const { id, url, initMessages } = json;
31
-
32
- const payload: ChatEntity = {
33
- url,
34
- id: id ?? nanoid(),
35
- user,
36
- messages: initMessages ?? [],
37
- updatedAt: Date.now(),
38
- };
39
-
40
- await createKVChat(payload);
41
-
42
- revalidatePath('/chat', 'layout');
43
-
44
- return Response.json(payload);
45
- } catch (error) {
46
- return new Response((error as Error).message, {
47
- status: 400,
48
- });
49
- }
50
- },
51
- );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/api/vision-agent/route.ts CHANGED
@@ -1,74 +1,337 @@
1
  import { StreamingTextResponse } from 'ai';
2
 
3
  // import { auth } from '@/auth';
4
- import { MessageBase } from '../../../lib/types';
5
- import { withLogging } from '@/lib/logger';
6
- import { CLEANED_SEPARATOR } from '@/lib/constants';
7
- import { cleanAnswerMessage, cleanInputMessage } from '@/lib/messageUtils';
 
8
 
9
  // export const runtime = 'edge';
10
  export const dynamic = 'force-dynamic';
11
  export const maxDuration = 300; // This function can run for a maximum of 5 minutes
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  export const POST = withLogging(
14
  async (
15
- _session,
16
  json: {
17
- messages: MessageBase[];
18
  id: string;
19
- url: string;
20
  },
 
21
  ) => {
22
- const { messages, url } = json;
23
-
24
- // const session = await auth();
25
- // if (!session?.user?.email) {
26
- // return new Response('Unauthorized', {
27
- // status: 401,
28
- // });
29
- // }
30
 
31
  const formData = new FormData();
32
- formData.append(
33
- 'input',
34
- JSON.stringify(
35
- messages.map(message => {
36
- if (message.role !== 'assistant') {
37
- return {
38
- ...message,
39
- content: cleanInputMessage(message.content),
40
- };
41
- } else {
42
- const splitedContent = message.content.split(CLEANED_SEPARATOR);
43
- return {
44
- ...message,
45
- content:
46
- splitedContent.length > 1
47
- ? cleanAnswerMessage(splitedContent[1])
48
- : message.content,
49
- };
50
- }
51
- }),
52
- ),
53
- );
54
- formData.append('image', url);
55
 
56
  const fetchResponse = await fetch(
57
- 'https://api.dev.landing.ai/v1/agent/chat?agent_class=vision_agent&visualize_output=true',
58
- // 'http://localhost:5050/v1/agent/chat?agent_class=vision_agent',
 
59
  {
60
  method: 'POST',
61
  headers: {
62
- apikey: 'land_sk_DKeoYtaZZrYqJ9TMMiXe4BIQgJcZ0s3XAoB0JT3jv73FFqnr6k',
 
 
 
 
63
  },
64
  body: formData,
65
  },
66
  );
67
 
68
- if (fetchResponse.body) {
69
- return new StreamingTextResponse(fetchResponse.body);
70
- } else {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  return fetchResponse;
72
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  },
74
  );
 
1
  import { StreamingTextResponse } from 'ai';
2
 
3
  // import { auth } from '@/auth';
4
+ import { MessageUI } from '@/lib/types';
5
+
6
+ import { logger, withLogging } from '@/lib/logger';
7
+ import { getPresignedUrl } from '@/lib/aws';
8
+ import { dbPostUpdateMessageResponse } from '@/lib/db/functions';
9
 
10
  // export const runtime = 'edge';
11
  export const dynamic = 'force-dynamic';
12
  export const maxDuration = 300; // This function can run for a maximum of 5 minutes
13
+ const TIMEOUT_MILI_SECONDS = 2 * 60 * 1000;
14
+ const FINAL_TIMEOUT_ERROR: PrismaJson.FinalErrorBody = {
15
+ type: 'final_error',
16
+ status: 'failed',
17
+ payload: {
18
+ name: 'AgentTimeout',
19
+ value: `Haven't received any response in last ${TIMEOUT_MILI_SECONDS / 60000} minutes, agent timed out.`,
20
+ traceback_raw: [],
21
+ },
22
+ };
23
+
24
+ const uploadBase64 = async (
25
+ base64: string,
26
+ messageId: string,
27
+ chatId: string,
28
+ index: number,
29
+ user: string,
30
+ ) => {
31
+ const res = await fetch(base64);
32
+ const blob = await res.blob();
33
+ const { signedUrl, publicUrl, fields } = await getPresignedUrl(
34
+ `answer-${index}.${blob.type.split('/')[1]}`,
35
+ blob.type,
36
+ `${chatId}/${messageId}`,
37
+ user,
38
+ );
39
+ const formData = new FormData();
40
+ Object.entries(fields).forEach(([key, value]) => {
41
+ formData.append(key, value as string);
42
+ });
43
+ formData.append('file', blob);
44
+
45
+ const uploadResponse = await fetch(signedUrl, {
46
+ method: 'POST',
47
+ body: formData,
48
+ });
49
+ if (uploadResponse.ok) {
50
+ return publicUrl;
51
+ } else {
52
+ throw new Error('Upload failed');
53
+ }
54
+ };
55
+
56
+ const modifyCodePayload = async (
57
+ msg: PrismaJson.MessageBody,
58
+ messageId: string,
59
+ chatId: string,
60
+ user: string,
61
+ ): Promise<PrismaJson.MessageBody> => {
62
+ if (
63
+ (msg.type !== 'final_code' &&
64
+ (msg.type !== 'code' ||
65
+ msg.status === 'started' ||
66
+ msg.status === 'running')) ||
67
+ !msg.payload?.result
68
+ ) {
69
+ return msg;
70
+ }
71
+ const result = (
72
+ typeof msg.payload.result === 'string'
73
+ ? JSON.parse(msg.payload.result)
74
+ : msg.payload.result
75
+ ) as PrismaJson.StructuredResult;
76
+ if (msg.type === 'code') {
77
+ if (result && result.results) {
78
+ msg.payload.result = {
79
+ ...result,
80
+ results: result.results.map((_result: any) => {
81
+ return {
82
+ ..._result,
83
+ png: undefined,
84
+ mp4: undefined,
85
+ };
86
+ }),
87
+ };
88
+ }
89
+ return msg;
90
+ }
91
+ for (let index = 0; index < result.results.length; index++) {
92
+ const png = result.results[index].png ?? '';
93
+ const mp4 = result.results[index].mp4 ?? '';
94
+ if (!png && !mp4) continue;
95
+ const resp = await uploadBase64(
96
+ png ? 'data:image/png;base64,' + png : 'data:video/mp4;base64,' + mp4,
97
+ messageId,
98
+ chatId,
99
+ index,
100
+ user,
101
+ );
102
+ if (png) result.results[index].png = resp;
103
+ if (mp4) result.results[index].mp4 = resp;
104
+ }
105
+ msg.payload.result = result;
106
+ return msg;
107
+ };
108
 
109
  export const POST = withLogging(
110
  async (
111
+ session,
112
  json: {
113
+ apiMessages: string;
114
  id: string;
115
+ mediaUrl: string;
116
  },
117
+ request,
118
  ) => {
119
+ const { apiMessages, mediaUrl, id: chatId } = json;
120
+ const messages: MessageUI[] = JSON.parse(apiMessages);
121
+ const messageId = messages[messages.length - 1].id.split('-')[0];
122
+ const user = session?.user?.email ?? 'anonymous';
 
 
 
 
123
 
124
  const formData = new FormData();
125
+ formData.append('input', apiMessages);
126
+ formData.append('image', mediaUrl);
127
+
128
+ const agentHost = process.env.LND_TIER
129
+ ? 'http://publicrestapi-app-lndsvc.publicrestapi.svc.cluster.local:5000'
130
+ : 'https://api.dev.landing.ai';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
 
132
  const fetchResponse = await fetch(
133
+ `${agentHost}/v1/agent/chat?agent_class=vision_agent&self_reflection=false`,
134
+ // `https://api.dev.landing.ai/v1/agent/chat?agent_class=vision_agent&self_reflection=false`,
135
+ // `http://localhost:5001/v1/agent/chat?agent_class=vision_agent&self_reflection=false`,
136
  {
137
  method: 'POST',
138
  headers: {
139
+ // default to dev apikey
140
+ apikey:
141
+ process.env.LND_TIER === 'production'
142
+ ? 'land_sk_nMnUf8xiJJUjyw1l5QaIJJ4ZyrvPthzVmPAIG7TtJY7F9CW6lu' // prod key
143
+ : 'land_sk_DKeoYtaZZrYqJ9TMMiXe4BIQgJcZ0s3XAoB0JT3jv73FFqnr6k', // dev key
144
  },
145
  body: formData,
146
  },
147
  );
148
 
149
+ if (!fetchResponse.ok && fetchResponse.body) {
150
+ const reader = fetchResponse.body.getReader();
151
+ return new StreamingTextResponse(
152
+ new ReadableStream({
153
+ async start(controller) {
154
+ try {
155
+ const { done, value } = await reader?.read();
156
+ if (!done) {
157
+ const errorText = new TextDecoder().decode(value);
158
+ logger.error(session, { message: errorText }, request);
159
+ controller.error(new Error(`Response error: ${errorText}`));
160
+ }
161
+ } catch (e) {
162
+ logger.error(session, (e as Error).message, request);
163
+ }
164
+ },
165
+ }),
166
+ {
167
+ status: 400,
168
+ },
169
+ );
170
+ }
171
+ // const streamData = new experimental_StreamData();
172
+
173
+ if (!fetchResponse.body) {
174
  return fetchResponse;
175
  }
176
+ const encoder = new TextEncoder();
177
+ const decoder = new TextDecoder('utf-8');
178
+ let maxChunkSize = 0;
179
+ let buffer = '';
180
+ let time = Date.now();
181
+ const results: PrismaJson.MessageBody[] = [];
182
+ const stream = new ReadableStream({
183
+ async start(controller) {
184
+ const parseLine = async (
185
+ line: string,
186
+ ignoreParsingError = false,
187
+ ): Promise<{ data?: PrismaJson.MessageBody; error?: Error }> => {
188
+ let msg = null;
189
+ try {
190
+ msg = JSON.parse(line);
191
+ } catch (e) {
192
+ if (ignoreParsingError) return {};
193
+ else {
194
+ return { error: e as Error };
195
+ }
196
+ }
197
+ if (!msg) return {};
198
+ try {
199
+ const modifiedMsg = await modifyCodePayload(
200
+ {
201
+ ...msg,
202
+ timestamp: new Date(),
203
+ },
204
+ messageId,
205
+ chatId,
206
+ user,
207
+ );
208
+ return { data: modifiedMsg };
209
+ } catch (e) {
210
+ return { error: e as Error };
211
+ }
212
+ };
213
+
214
+ const processChunk = async (lines: string[]) => {
215
+ if (lines.length === 0) {
216
+ if (Date.now() - time > TIMEOUT_MILI_SECONDS) {
217
+ results.push(FINAL_TIMEOUT_ERROR);
218
+ // https://github.com/vercel/ai/blob/f7002ad2c5aa58ce6ed83e8d31fe22f71ebdb7d7/packages/ui-utils/src/stream-parts.ts#L62
219
+ controller.enqueue(
220
+ '2:' +
221
+ encoder.encode(JSON.stringify(FINAL_TIMEOUT_ERROR) + '\n'),
222
+ );
223
+ return { done: true, reason: 'timeout' };
224
+ }
225
+ } else {
226
+ time = Date.now();
227
+ }
228
+ buffer = lines.pop() ?? ''; // Save the last incomplete line back to the buffer
229
+ for (let line of lines) {
230
+ const { data: parsedMsg, error } = await parseLine(line);
231
+ if (error) {
232
+ results.push({
233
+ type: 'final_error',
234
+ status: 'failed',
235
+ payload: {
236
+ name: 'ParseError',
237
+ value: line,
238
+ traceback_raw: [],
239
+ },
240
+ });
241
+ return { done: true, reason: 'api_error', error };
242
+ } else if (parsedMsg) {
243
+ results.push(parsedMsg);
244
+ controller.enqueue(
245
+ encoder.encode('2:' + JSON.stringify([parsedMsg]) + '\n'),
246
+ );
247
+ if (parsedMsg.type === 'final_code') {
248
+ return { done: true, reason: 'agent_concluded' };
249
+ } else if (parsedMsg.type === 'final_error') {
250
+ return {
251
+ done: true,
252
+ reason: 'agent_error',
253
+ error: parsedMsg.payload,
254
+ };
255
+ }
256
+ } else {
257
+ controller.enqueue(encoder.encode(''));
258
+ }
259
+ }
260
+ if (buffer) {
261
+ const { data: parsedBuffer, error } = await parseLine(buffer, true);
262
+ if (error) {
263
+ results.push({
264
+ type: 'final_error',
265
+ status: 'failed',
266
+ payload: {
267
+ name: 'ParseError',
268
+ value: buffer,
269
+ traceback_raw: [],
270
+ },
271
+ });
272
+ return { done: true, reason: 'api_error', error };
273
+ } else if (parsedBuffer) {
274
+ buffer = '';
275
+ results.push(parsedBuffer);
276
+ controller.enqueue(
277
+ encoder.encode('2:' + JSON.stringify([parsedBuffer]) + '\n'),
278
+ );
279
+ if (parsedBuffer.type === 'final_code') {
280
+ return { done: true, reason: 'agent_concluded' };
281
+ } else if (parsedBuffer.type === 'final_error') {
282
+ return {
283
+ done: true,
284
+ reason: 'agent_error',
285
+ error: parsedBuffer.payload,
286
+ };
287
+ }
288
+ } else {
289
+ controller.enqueue(encoder.encode(''));
290
+ }
291
+ }
292
+ return { done: false };
293
+ };
294
+
295
+ // const parser = createParser(streamParser);
296
+ for await (const chunk of fetchResponse.body as any) {
297
+ const data = decoder.decode(chunk);
298
+ buffer += data;
299
+ maxChunkSize = Math.max(data.length, maxChunkSize);
300
+ const lines = buffer
301
+ .split('\n')
302
+ .filter(line => line.trim().length > 0);
303
+ const { done, reason, error } = await processChunk(lines);
304
+ if (done) {
305
+ const processMsgs = results.filter(
306
+ res => res.type !== 'final_code',
307
+ ) as PrismaJson.AgentResponseBodies;
308
+ await dbPostUpdateMessageResponse(messageId, {
309
+ response: processMsgs.map(res => JSON.stringify(res)).join('\n'),
310
+ result: results.find(
311
+ res => res.type === 'final_code',
312
+ ) as PrismaJson.FinalCodeBody,
313
+ responseBody: processMsgs,
314
+ });
315
+ logger.info(
316
+ session,
317
+ {
318
+ message: 'Streaming ended',
319
+ maxChunkSize,
320
+ reason,
321
+ error,
322
+ },
323
+ request,
324
+ '__AGENT_DONE',
325
+ );
326
+ controller.close();
327
+ }
328
+ }
329
+ },
330
+ });
331
+ return new Response(stream, {
332
+ headers: {
333
+ 'Content-Type': 'application/x-ndjson',
334
+ },
335
+ });
336
  },
337
  );
app/chat/[id]/page.tsx CHANGED
@@ -1,5 +1,7 @@
1
- import { getKVChat } from '@/lib/kv/chat';
2
- import { Chat } from '@/components/chat';
 
 
3
 
4
  interface PageProps {
5
  params: {
@@ -9,6 +11,15 @@ interface PageProps {
9
 
10
  export default async function Page({ params }: PageProps) {
11
  const { id: chatId } = params;
12
- const chat = await getKVChat(chatId);
13
- return <Chat chat={chat} />;
 
 
 
 
 
 
 
 
 
14
  }
 
1
+ import { Suspense } from 'react';
2
+ import ChatServer from './server';
3
+ import Loading from '@/components/ui/Loading';
4
+ import { auth } from '@/auth';
5
 
6
  interface PageProps {
7
  params: {
 
11
 
12
  export default async function Page({ params }: PageProps) {
13
  const { id: chatId } = params;
14
+ return (
15
+ <Suspense
16
+ fallback={
17
+ <div className="h-screen w-screen flex justify-center items-center">
18
+ <Loading />
19
+ </div>
20
+ }
21
+ >
22
+ <ChatServer chatId={chatId} />
23
+ </Suspense>
24
+ );
25
  }
app/chat/[id]/server.tsx ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ChatInterface from '../../../components/ChatInterface';
2
+ import { auth, sessionUser } from '@/auth';
3
+ import { dbGetChat } from '@/lib/db/functions';
4
+ import { redirect } from 'next/navigation';
5
+ import { revalidatePath } from 'next/cache';
6
+ import TopPrompt from '@/components/chat/TopPrompt';
7
+
8
+ interface ChatServerProps {
9
+ chatId: string;
10
+ }
11
+
12
+ export default async function ChatServer({ chatId }: ChatServerProps) {
13
+ const chat = await dbGetChat(chatId);
14
+ const { id: userId } = await sessionUser();
15
+
16
+ if (!chat) {
17
+ revalidatePath('/');
18
+ redirect('/');
19
+ }
20
+ return (
21
+ <div className="w-[1600px] max-w-full mx-auto flex flex-col space-y-4 items-center">
22
+ <TopPrompt chat={chat} userId={userId} />
23
+ <ChatInterface chat={chat} userId={userId} />
24
+ </div>
25
+ );
26
+ }
app/chat/layout.tsx CHANGED
@@ -1,29 +1,8 @@
1
- import { auth, authEmail } from '@/auth';
2
- import ChatSidebarList from '@/components/chat-sidebar/ChatListSidebar';
3
- import Loading from '@/components/ui/Loading';
4
- import { Suspense } from 'react';
5
-
6
  interface ChatLayoutProps {
7
  children: React.ReactNode;
8
  }
9
 
10
  export default async function Layout({ children }: ChatLayoutProps) {
11
- const { email, isAdmin } = await authEmail();
12
- return (
13
- <div className="relative flex h-[calc(100vh_-_theme(spacing.16))] overflow-hidden">
14
- <div
15
- data-state={email ? 'open' : 'closed'}
16
- className="peer absolute inset-y-0 z-30 hidden border-r bg-muted duration-300 ease-in-out -translate-x-full data-[state=open]:translate-x-0 lg:flex lg:w-[250px] h-full flex-col overflow-auto py-2"
17
- >
18
- <Suspense fallback={<Loading />}>
19
- <ChatSidebarList />
20
- </Suspense>
21
- </div>
22
- <Suspense fallback={<Loading />}>
23
- <div className="group w-full overflow-auto pl-0 animate-in duration-300 ease-in-out peer-[[data-state=open]]:lg:pl-[250px]">
24
- {children}
25
- </div>
26
- </Suspense>
27
- </div>
28
- );
29
  }
 
 
 
 
 
 
1
  interface ChatLayoutProps {
2
  children: React.ReactNode;
3
  }
4
 
5
  export default async function Layout({ children }: ChatLayoutProps) {
6
+ // return <Suspense fallback={<Loading />}>{children}</Suspense>;
7
+ return children;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  }
app/chat/page.tsx CHANGED
@@ -1,118 +1,7 @@
1
- 'use client';
2
 
3
- import ImageSelector from '@/components/chat/ImageSelector';
4
- import { generateInputImageMarkdown } from '@/lib/messageUtils';
5
- import { ChatEntity, MessageBase } from '@/lib/types';
6
- import { fetcher } from '@/lib/utils';
7
- import Image from 'next/image';
8
- import { useRouter } from 'next/navigation';
9
 
10
- import {
11
- Tooltip,
12
- TooltipContent,
13
- TooltipTrigger,
14
- } from '@/components/ui/Tooltip';
15
- import { IconDiscord, IconGitHub } from '@/components/ui/Icons';
16
- import Link from 'next/link';
17
- import { Button } from '@/components/ui/Button';
18
- import Img from '@/components/ui/Img';
19
-
20
- const exampleMessages = [
21
- {
22
- heading: 'Counting',
23
- subheading: 'number of cereals in an image',
24
- url: 'https://landing-lens-support.s3.us-east-2.amazonaws.com/vision-agent-examples/cereal-example.jpg',
25
- initMessages: [
26
- {
27
- role: 'user',
28
- content:
29
- 'how many cereals are there in the image?' +
30
- '\n\n' +
31
- generateInputImageMarkdown(
32
- 'https://landing-lens-support.s3.us-east-2.amazonaws.com/vision-agent-examples/cereal-example.jpg',
33
- ),
34
- id: 'fake-id-1',
35
- },
36
- ],
37
- },
38
- // {
39
- // heading: 'Detecting',
40
- // url: 'https://landing-lens-support.s3.us-east-2.amazonaws.com/vision-agent-examples/cereal-example.jpg',
41
- // subheading: 'number of cereals in an image',
42
- // message: `How many cereals are there in the image?`,
43
- // },
44
- ];
45
-
46
- export default function Page() {
47
- const router = useRouter();
48
- return (
49
- <div className="mx-auto max-w-2xl px-4 mt-8">
50
- <div className="rounded-lg border bg-background p-8 mb-6">
51
- <h1 className="mb-2 text-lg font-semibold">Welcome to Vision Agent</h1>
52
- <p>
53
- Vision Agent is a library that helps you utilize agent frameworks for
54
- your vision tasks. Vision Agent aims to provide an in-seconds
55
- experience by allowing users to describe their problem in text and
56
- utilizing agent frameworks to solve the task for them.
57
- </p>
58
- <div className="my-2">
59
- <Tooltip>
60
- <TooltipTrigger asChild>
61
- <Button variant="link" size="icon" asChild className="mr-2">
62
- <Link
63
- href="https://github.com/landing-ai/vision-agent"
64
- target="_blank"
65
- >
66
- <IconGitHub className="size-6" />
67
- </Link>
68
- </Button>
69
- </TooltipTrigger>
70
- <TooltipContent>Github</TooltipContent>
71
- </Tooltip>
72
- <Tooltip>
73
- <TooltipTrigger asChild>
74
- <Button variant="link" size="icon" asChild className="mr-2">
75
- <Link href="https://discord.gg/wZ2A7J69" target="_blank">
76
- <IconDiscord className="size-6" />
77
- </Link>
78
- </Button>
79
- </TooltipTrigger>
80
- <TooltipContent>Discord</TooltipContent>
81
- </Tooltip>
82
- </div>
83
- <ImageSelector />
84
- </div>
85
- <div className="mb-4 grid grid-cols-2 gap-2 px-4 sm:px-0">
86
- {exampleMessages.map((example, index) => (
87
- <div
88
- key={index}
89
- className={`cursor-pointer rounded-lg border bg-white p-4 hover:bg-zinc-50 dark:bg-zinc-950 dark:hover:bg-zinc-900 flex items-center size-full ${
90
- index > 1 && 'hidden md:block'
91
- }`}
92
- onClick={async () => {
93
- const resp = await fetcher<ChatEntity>('/api/upload', {
94
- method: 'POST',
95
- headers: {
96
- 'Content-Type': 'application/json',
97
- },
98
- body: JSON.stringify({
99
- url: example.url,
100
- initMessages: example.initMessages,
101
- }),
102
- });
103
- if (resp) {
104
- router.push(`/chat/${resp.id}`);
105
- }
106
- }}
107
- >
108
- <Img src={example.url} alt="example images" className="w-1/4" />
109
- <div className="flex items-start flex-col h-full ml-3 w-3/4">
110
- <div className="text-sm font-semibold">{example.heading}</div>
111
- <div className="text-sm text-zinc-600">{example.subheading}</div>
112
- </div>
113
- </div>
114
- ))}
115
- </div>
116
- </div>
117
- );
118
  }
 
1
+ import { redirect } from 'next/navigation';
2
 
3
+ export interface PageProps {}
 
 
 
 
 
4
 
5
+ export default async function Page({}: PageProps) {
6
+ redirect('/');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  }
app/globals.css CHANGED
@@ -7,17 +7,11 @@
7
  --background: 0 0% 100%;
8
  --foreground: 240 10% 3.9%;
9
 
10
- --muted: 240 4.8% 95.9%;
11
- --muted-foreground: 240 3.8% 46.1%;
12
-
13
- --popover: 0 0% 100%;
14
- --popover-foreground: 240 10% 3.9%;
15
-
16
  --card: 0 0% 100%;
17
  --card-foreground: 240 10% 3.9%;
18
 
19
- --border: 240 5.9% 90%;
20
- --input: 240 5.9% 90%;
21
 
22
  --primary: 240 5.9% 10%;
23
  --primary-foreground: 0 0% 98%;
@@ -25,13 +19,18 @@
25
  --secondary: 240 4.8% 95.9%;
26
  --secondary-foreground: 240 5.9% 10%;
27
 
 
 
 
28
  --accent: 240 4.8% 95.9%;
29
- --accent-foreground: ;
30
 
31
  --destructive: 0 84.2% 60.2%;
32
  --destructive-foreground: 0 0% 98%;
33
 
34
- --ring: 240 5% 64.9%;
 
 
35
 
36
  --radius: 0.5rem;
37
  }
@@ -40,17 +39,11 @@
40
  --background: 240 10% 3.9%;
41
  --foreground: 0 0% 98%;
42
 
43
- --muted: 240 3.7% 15.9%;
44
- --muted-foreground: 240 5% 64.9%;
45
-
46
- --popover: 240 10% 3.9%;
47
- --popover-foreground: 0 0% 98%;
48
-
49
  --card: 240 10% 3.9%;
50
  --card-foreground: 0 0% 98%;
51
 
52
- --border: 240 3.7% 15.9%;
53
- --input: 240 3.7% 15.9%;
54
 
55
  --primary: 0 0% 98%;
56
  --primary-foreground: 240 5.9% 10%;
@@ -58,13 +51,18 @@
58
  --secondary: 240 3.7% 15.9%;
59
  --secondary-foreground: 0 0% 98%;
60
 
 
 
 
61
  --accent: 240 3.7% 15.9%;
62
- --accent-foreground: ;
63
 
64
  --destructive: 0 62.8% 30.6%;
65
- --destructive-foreground: 0 85.7% 97.3%;
66
 
67
- --ring: 240 3.7% 15.9%;
 
 
68
  }
69
  }
70
 
@@ -77,83 +75,36 @@
77
  }
78
  }
79
 
80
- @layer components {
81
- .scroll-fade::after {
82
- content: '';
83
- position: absolute;
84
- bottom: 0;
85
- left: 0;
86
- right: 0;
87
- height: 50px;
88
- background: linear-gradient(
89
- to bottom,
90
- rgba(255, 255, 255, 1),
91
- rgba(255, 255, 255, 0)
92
- );
93
- pointer-events: none;
94
- }
95
- .scroll-fade:active::after,
96
- .scroll-fade:hover::after {
97
- background: none;
98
- }
99
- .image-shadow::after {
100
- content: '';
101
- position: absolute;
102
- top: 0;
103
- right: 0;
104
- bottom: 0;
105
- left: 0;
106
- box-shadow:
107
- 0 10px 15px -3px rgba(0, 0, 0, 0.1),
108
- 0 4px 6px -2px rgba(0, 0, 0, 0.05);
109
- border-radius: 0.5rem;
110
- pointer-events: none;
111
- }
112
- }
113
-
114
- /* Light theme. */
115
- :root {
116
- --color-canvas-default: #ffffff;
117
- --color-canvas-subtle: #f6f8fa;
118
- --color-border-default: #d0d7de;
119
- --color-border-muted: hsla(210, 18%, 87%, 1);
120
- }
121
-
122
- /* Dark theme. */
123
- @media (prefers-color-scheme: dark) {
124
- :root {
125
- --color-canvas-default: #0d1117;
126
- --color-canvas-subtle: #161b22;
127
- --color-border-default: #30363d;
128
- --color-border-muted: #21262d;
129
- }
130
  }
131
 
132
- table {
133
- border-spacing: 0;
134
- border-collapse: collapse;
135
- display: block;
136
- margin-top: 0;
137
- margin-bottom: 16px;
138
- width: max-content;
139
- max-width: 100%;
140
- overflow: auto;
141
  }
142
 
143
- tr {
144
- border-top: 1px solid var(--color-border-muted);
145
  }
146
 
147
- td,
148
- th {
149
- padding: 6px 13px;
150
- border: 1px solid var(--color-border-default);
151
  }
152
 
153
- th {
154
- font-weight: 600;
155
- }
 
 
156
 
157
- table img {
158
- background-color: transparent;
 
 
159
  }
 
7
  --background: 0 0% 100%;
8
  --foreground: 240 10% 3.9%;
9
 
 
 
 
 
 
 
10
  --card: 0 0% 100%;
11
  --card-foreground: 240 10% 3.9%;
12
 
13
+ --popover: 0 0% 100%;
14
+ --popover-foreground: 240 10% 3.9%;
15
 
16
  --primary: 240 5.9% 10%;
17
  --primary-foreground: 0 0% 98%;
 
19
  --secondary: 240 4.8% 95.9%;
20
  --secondary-foreground: 240 5.9% 10%;
21
 
22
+ --muted: 240 4.8% 95.9%;
23
+ --muted-foreground: 240 3.8% 46.1%;
24
+
25
  --accent: 240 4.8% 95.9%;
26
+ --accent-foreground: 240 5.9% 10%;
27
 
28
  --destructive: 0 84.2% 60.2%;
29
  --destructive-foreground: 0 0% 98%;
30
 
31
+ --border: 240 5.9% 90%;
32
+ --input: 240 5.9% 90%;
33
+ --ring: 240 10% 3.9%;
34
 
35
  --radius: 0.5rem;
36
  }
 
39
  --background: 240 10% 3.9%;
40
  --foreground: 0 0% 98%;
41
 
 
 
 
 
 
 
42
  --card: 240 10% 3.9%;
43
  --card-foreground: 0 0% 98%;
44
 
45
+ --popover: 240 10% 3.9%;
46
+ --popover-foreground: 0 0% 98%;
47
 
48
  --primary: 0 0% 98%;
49
  --primary-foreground: 240 5.9% 10%;
 
51
  --secondary: 240 3.7% 15.9%;
52
  --secondary-foreground: 0 0% 98%;
53
 
54
+ --muted: 240 3.7% 15.9%;
55
+ --muted-foreground: 240 5% 64.9%;
56
+
57
  --accent: 240 3.7% 15.9%;
58
+ --accent-foreground: 0 0% 98%;
59
 
60
  --destructive: 0 62.8% 30.6%;
61
+ --destructive-foreground: 0 0% 98%;
62
 
63
+ --border: 240 3.7% 15.9%;
64
+ --input: 240 3.7% 15.9%;
65
+ --ring: 240 4.9% 83.9%;
66
  }
67
  }
68
 
 
75
  }
76
  }
77
 
78
+ h1 {
79
+ font-size: 3.75rem; /* 48px */
80
+ font-family: var(--font-geist-sans);
81
+ font-weight: bold;
82
+ letter-spacing: -1px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  }
84
 
85
+ .homepage {
86
+ 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");
87
+ background-size: cover;
88
+ background: linear-gradient(to bottom, rgb(9, 9, 11), rgb(32, 32, 39));
 
 
 
 
 
89
  }
90
 
91
+ .text-webkit-center {
92
+ text-align: -webkit-center;
93
  }
94
 
95
+ .svg-shadow {
96
+ border-radius: 100%;
97
+ animation: svg-shadow 1.5s ease-in-out infinite alternate;
 
98
  }
99
 
100
+ @keyframes svg-shadow {
101
+ from {
102
+ filter: drop-shadow(0 0 5px #fff) drop-shadow(0 0 5px transparent)
103
+ drop-shadow(0 0 10px transparent);
104
+ }
105
 
106
+ to {
107
+ filter: drop-shadow(0 0 20px #fff) drop-shadow(0 0 10px transparent)
108
+ drop-shadow(0 0 20px transparent);
109
+ }
110
  }
app/layout.tsx CHANGED
@@ -7,7 +7,6 @@ import { cn } from '@/lib/utils';
7
  import { TailwindIndicator } from '@/components/TailwindIndicator';
8
  import { Providers } from '@/components/Providers';
9
  import { Header } from '@/components/Header';
10
- import { ThemeToggle } from '@/components/ThemeToggle';
11
 
12
  export const metadata = {
13
  metadataBase: new URL(`https://${process.env.VERCEL_URL}`),
@@ -17,9 +16,9 @@ export const metadata = {
17
  },
18
  description: 'By Landing AI',
19
  icons: {
20
- icon: '/landing.png',
21
- shortcut: '/landing.png',
22
- apple: '/landing.png',
23
  },
24
  };
25
 
@@ -34,7 +33,8 @@ interface RootLayoutProps {
34
  children: React.ReactNode;
35
  }
36
 
37
- export default function RootLayout({ children }: RootLayoutProps) {
 
38
  return (
39
  <html lang="en" suppressHydrationWarning>
40
  <body
@@ -52,8 +52,10 @@ export default function RootLayout({ children }: RootLayoutProps) {
52
  disableTransitionOnChange
53
  >
54
  <div className="flex flex-col min-h-screen">
55
- {!process.env.NEXT_PUBLIC_IS_HUGGING_FACE && <Header />}
56
- <main className="flex flex-col flex-1 bg-muted/50">{children}</main>
 
 
57
  </div>
58
  <TailwindIndicator />
59
  </Providers>
 
7
  import { TailwindIndicator } from '@/components/TailwindIndicator';
8
  import { Providers } from '@/components/Providers';
9
  import { Header } from '@/components/Header';
 
10
 
11
  export const metadata = {
12
  metadataBase: new URL(`https://${process.env.VERCEL_URL}`),
 
16
  },
17
  description: 'By Landing AI',
18
  icons: {
19
+ icon: '/landing4.png',
20
+ shortcut: '/landing4.png',
21
+ apple: '/landing4.png',
22
  },
23
  };
24
 
 
33
  children: React.ReactNode;
34
  }
35
 
36
+ export default function RootLayout(props: RootLayoutProps) {
37
+ const { children } = props;
38
  return (
39
  <html lang="en" suppressHydrationWarning>
40
  <body
 
52
  disableTransitionOnChange
53
  >
54
  <div className="flex flex-col min-h-screen">
55
+ <Header />
56
+ <main className="flex p-4 h-[calc(100vh-64px)] bg-background overflow-hidden relative w-screen">
57
+ {children}
58
+ </main>
59
  </div>
60
  <TailwindIndicator />
61
  </Providers>
app/page.tsx CHANGED
@@ -1,12 +1,82 @@
1
- import { auth } from '@/auth';
2
- import { redirect } from 'next/navigation';
3
 
4
- export default async function Page() {
5
- redirect('/chat');
6
 
7
- // return (
8
- // <div className="flex flex-col h-[calc(100vh-theme(spacing.16))] items-center justify-center py-10 space-y-2">
9
- // Welcome to Insight Playground
10
- // </div>
11
- // );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  }
 
1
+ 'use client';
 
2
 
3
+ import { useRouter } from 'next/navigation';
 
4
 
5
+ import { useRef, useState } from 'react';
6
+ import Composer, { ComposerRef } from '@/components/chat/Composer';
7
+ import { dbPostCreateChat } from '@/lib/db/functions';
8
+ import { nanoid } from '@/lib/utils';
9
+ import Chip from '@/components/ui/Chip';
10
+ import { IconArrowUpRight } from '@/components/ui/Icons';
11
+
12
+ const EXAMPLES = [
13
+ {
14
+ title: 'Counting flowers in image',
15
+ mediaUrl:
16
+ 'https://vision-agent-dev.s3.us-east-2.amazonaws.com/examples/flower.png',
17
+ prompt:
18
+ 'Detect flowers in this image, draw boxes and output the image, also return total number of flowers',
19
+ },
20
+ {
21
+ title: 'Detecting sharks in video',
22
+ mediaUrl:
23
+ 'https://vision-agent-dev.s3.us-east-2.amazonaws.com/examples/shark3_short.mp4',
24
+ prompt:
25
+ '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.',
26
+ },
27
+ ];
28
+
29
+ export default function Page() {
30
+ const router = useRouter();
31
+ const composerRef = useRef<ComposerRef>(null);
32
+ return (
33
+ <div className="h-screen w-screen homepage">
34
+ <div className="mx-auto w-[42rem] max-w-full px-4 mt-[200px]">
35
+ <h1 className="mb-4 text-center relative">
36
+ Vision Agent
37
+ <Chip className="absolute bg-green-100 text-green-500">BETA</Chip>
38
+ </h1>
39
+ <h4 className="text-center">
40
+ Generate code to solve your vision problem with simple prompts.
41
+ </h4>
42
+ <div className="my-8">
43
+ <Composer
44
+ ref={composerRef}
45
+ onSubmit={async ({ input, mediaUrl }) => {
46
+ const newId = nanoid();
47
+ const resp = await dbPostCreateChat({
48
+ id: newId,
49
+ title: `conversation-${newId}`,
50
+ mediaUrl,
51
+ message: {
52
+ prompt: input,
53
+ mediaUrl,
54
+ },
55
+ });
56
+ if (resp) {
57
+ router.push(`/chat/${newId}`);
58
+ }
59
+ }}
60
+ />
61
+ </div>
62
+ {EXAMPLES.map((example, index) => {
63
+ return (
64
+ <Chip
65
+ key={index}
66
+ className="bg-transparent border border-zinc-500 cursor-pointer px-4"
67
+ onClick={() => {
68
+ composerRef.current?.setInput(example.prompt);
69
+ composerRef.current?.setMediaUrl(example.mediaUrl);
70
+ }}
71
+ >
72
+ <div className="flex flex-row items-center space-x-2">
73
+ <p className="text-primary text-sm">{example.title}</p>
74
+ <IconArrowUpRight className="text-primary" />
75
+ </div>
76
+ </Chip>
77
+ );
78
+ })}
79
+ </div>
80
+ </div>
81
+ );
82
  }
app/project/[projectId]/page.tsx DELETED
@@ -1,33 +0,0 @@
1
- import MediaGrid from '@/components/project/MediaGrid';
2
- import { fetchProjectClass, fetchProjectMedia } from '@/lib/fetch';
3
- import ProjectChat from '@/components/project/ProjectChat';
4
- import ClassBar from '@/components/project/ClassBar';
5
-
6
- interface PageProps {
7
- params: {
8
- projectId: string;
9
- };
10
- }
11
-
12
- export default async function Page({ params }: PageProps) {
13
- const { projectId } = params;
14
-
15
- const [mediaList, classList] = await Promise.all([
16
- fetchProjectMedia({ projectId: Number(projectId) }),
17
- fetchProjectClass({ projectId: Number(projectId) }),
18
- ]);
19
-
20
- return (
21
- <div className="pt-4 md:pt-10 h-full">
22
- <div className="flex h-full">
23
- <div className="w-1/2 relative border-r border-gray-300 overflow-auto">
24
- <ClassBar classList={classList} />
25
- <MediaGrid mediaList={mediaList} />
26
- </div>
27
- <div className="w-1/2 relative overflow-auto">
28
- <ProjectChat mediaList={mediaList} />
29
- </div>
30
- </div>
31
- </div>
32
- );
33
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/project/layout.tsx CHANGED
@@ -1,7 +1,7 @@
1
  import ProjectListSideBar from '@/components/project-sidebar/ProjectListSideBar';
2
  import { Suspense } from 'react';
3
  import Loading from '@/components/ui/Loading';
4
- import { authEmail } from '@/auth';
5
  import { redirect } from 'next/navigation';
6
 
7
  interface ChatLayoutProps {
@@ -9,7 +9,7 @@ interface ChatLayoutProps {
9
  }
10
 
11
  export default async function Layout({ children }: ChatLayoutProps) {
12
- const { isAdmin } = await authEmail();
13
 
14
  if (!isAdmin) {
15
  redirect('/');
 
1
  import ProjectListSideBar from '@/components/project-sidebar/ProjectListSideBar';
2
  import { Suspense } from 'react';
3
  import Loading from '@/components/ui/Loading';
4
+ import { sessionUser } from '@/auth';
5
  import { redirect } from 'next/navigation';
6
 
7
  interface ChatLayoutProps {
 
9
  }
10
 
11
  export default async function Layout({ children }: ChatLayoutProps) {
12
+ const { isAdmin } = await sessionUser();
13
 
14
  if (!isAdmin) {
15
  redirect('/');
assets/svg/LandingAI_white.svg ADDED
auth.ts CHANGED
@@ -1,6 +1,8 @@
1
  import NextAuth, { type DefaultSession } from 'next-auth';
2
  import GitHub from 'next-auth/providers/github';
3
  import Google from 'next-auth/providers/google';
 
 
4
 
5
  declare module 'next-auth' {
6
  interface Session {
@@ -25,23 +27,41 @@ export const {
25
  }),
26
  ],
27
  callbacks: {
28
- // signIn({ profile }) {
29
- // if (profile?.email?.endsWith('@landing.ai')) {
30
- // return !!profile;
31
- // } else {
32
- // return '/unauthorized';
33
- // }
34
- // },
35
- jwt({ token, profile }) {
 
 
 
 
 
 
 
 
 
 
 
36
  if (profile) {
37
  token.id = profile.id || profile.sub;
38
  token.image = profile.avatar_url || profile.picture;
39
  }
40
  return token;
41
  },
42
- session: ({ session, token }) => {
43
- if (session?.user && token?.id) {
44
- session.user.id = String(token.id);
 
 
 
 
 
 
 
45
  }
46
  return session;
47
  },
@@ -59,8 +79,13 @@ export const {
59
  },
60
  });
61
 
62
- export async function authEmail() {
63
  const session = await auth();
64
- const email = session?.user?.email;
65
- return { email, isAdmin: !!email?.endsWith('landing.ai') };
 
 
 
 
 
66
  }
 
1
  import NextAuth, { type DefaultSession } from 'next-auth';
2
  import GitHub from 'next-auth/providers/github';
3
  import Google from 'next-auth/providers/google';
4
+ import { dbFindOrCreateUser } from './lib/db/functions';
5
+ import { redirect } from 'next/navigation';
6
 
7
  declare module 'next-auth' {
8
  interface Session {
 
27
  }),
28
  ],
29
  callbacks: {
30
+ async signIn({ profile, user }) {
31
+ if (!profile) {
32
+ return false;
33
+ }
34
+ const { email, name, picture } = profile;
35
+
36
+ if (!email || !name) {
37
+ return false;
38
+ }
39
+
40
+ const dbUser = await dbFindOrCreateUser(email, name, picture);
41
+
42
+ if (dbUser) {
43
+ user.id = dbUser.id;
44
+ return true;
45
+ }
46
+ return false;
47
+ },
48
+ async jwt({ token, profile, user }) {
49
  if (profile) {
50
  token.id = profile.id || profile.sub;
51
  token.image = profile.avatar_url || profile.picture;
52
  }
53
  return token;
54
  },
55
+ async session({ session, token }) {
56
+ // TODO: this is temporary between we switch DB and make migration
57
+ // so also UI might still have session, DB might already have cleaned up
58
+ const email = session?.user?.email;
59
+ const name = session?.user?.name;
60
+ const avatar = session?.user?.image;
61
+ if (email && name) {
62
+ const dbUser = await dbFindOrCreateUser(email, name, avatar);
63
+ // put db user id into session
64
+ session.user.id = dbUser.id;
65
  }
66
  return session;
67
  },
 
79
  },
80
  });
81
 
82
+ export async function sessionUser() {
83
  const session = await auth();
84
+ const email = session?.user.email;
85
+ return {
86
+ email,
87
+ isAdmin: !!email?.endsWith('landing.ai'),
88
+ id: session?.user.id ?? null,
89
+ user: session?.user ?? null,
90
+ };
91
  }
chart/.helmignore ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Patterns to ignore when building packages.
2
+ # This supports shell glob matching, relative path matching, and
3
+ # negation (prefixed with !). Only one pattern per line.
4
+ .DS_Store
5
+ # Common VCS dirs
6
+ .git/
7
+ .gitignore
8
+ .bzr/
9
+ .bzrignore
10
+ .hg/
11
+ .hgignore
12
+ .svn/
13
+ # Common backup files
14
+ *.swp
15
+ *.bak
16
+ *.tmp
17
+ *.orig
18
+ *~
19
+ # Various IDEs
20
+ .project
21
+ .idea/
22
+ *.tmproj
23
+ .vscode/
chart/Chart.yaml ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ apiVersion: v2
2
+ name: vision-agent
3
+ description: A Helm chart for LandingAI Vision Agent
4
+
5
+ # A chart can be either an 'application' or a 'library' chart.
6
+ #
7
+ # Application charts are a collection of templates that can be packaged into versioned archives
8
+ # to be deployed.
9
+ #
10
+ # Library charts provide useful utilities or functions for the chart developer. They're included as
11
+ # a dependency of application charts to inject those utilities and functions into the rendering
12
+ # pipeline. Library charts do not define any templates and therefore cannot be deployed.
13
+ type: application
14
+
15
+ # This is the chart version. This version number should be incremented each time you make changes
16
+ # to the chart and its templates, including the app version.
17
+ # Versions are expected to follow Semantic Versioning (https://semver.org/)
18
+ version: 0.1.0
19
+
20
+ # This is the version number of the application being deployed. This version number should be
21
+ # incremented each time you make changes to the application. Versions are not expected to
22
+ # follow Semantic Versioning. They should reflect the version the application is using.
23
+ # It is recommended to use it with quotes.
24
+ appVersion: "1.16.0"
chart/dev.values.yaml ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ ingressRoute:
2
+ matchRule: "Host(`va.dev.landing.ai`)"
3
+
4
+ env:
5
+ LND_TIER: "dev"
chart/prod.values.yaml ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ ingressRoute:
2
+ matchRule: "Host(`va.landing.ai`)"
3
+
4
+ autoscaling:
5
+ enabled: true
6
+ minReplicas: 3
7
+ maxReplicas: 9
8
+
9
+ env:
10
+ LND_TIER: "production"
chart/templates/NOTES.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ 1. Get the application URL by running these commands:
2
+ {{- if .Values.ingressRoute.enabled }}
3
+ {{ .Values.ingressRoute.matchRule }}
4
+ {{- end }}
chart/templates/_helpers.tpl ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {{/*
2
+ Expand the name of the chart.
3
+ */}}
4
+ {{- define "chart.name" -}}
5
+ {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
6
+ {{- end }}
7
+
8
+ {{/*
9
+ Create a default fully qualified app name.
10
+ We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
11
+ If release name contains chart name it will be used as a full name.
12
+ */}}
13
+ {{- define "chart.fullname" -}}
14
+ {{- if .Values.fullnameOverride }}
15
+ {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
16
+ {{- else }}
17
+ {{- $name := default .Chart.Name .Values.nameOverride }}
18
+ {{- if contains $name .Release.Name }}
19
+ {{- .Release.Name | trunc 63 | trimSuffix "-" }}
20
+ {{- else }}
21
+ {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
22
+ {{- end }}
23
+ {{- end }}
24
+ {{- end }}
25
+
26
+ {{/*
27
+ Create chart name and version as used by the chart label.
28
+ */}}
29
+ {{- define "chart.chart" -}}
30
+ {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
31
+ {{- end }}
32
+
33
+ {{/*
34
+ Common labels
35
+ */}}
36
+ {{- define "chart.labels" -}}
37
+ helm.sh/chart: {{ include "chart.chart" . }}
38
+ {{ include "chart.selectorLabels" . }}
39
+ {{- if .Chart.AppVersion }}
40
+ app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
41
+ {{- end }}
42
+ app.kubernetes.io/managed-by: {{ .Release.Service }}
43
+ {{- end }}
44
+
45
+ {{/*
46
+ Selector labels
47
+ */}}
48
+ {{- define "chart.selectorLabels" -}}
49
+ app.kubernetes.io/name: {{ include "chart.name" . }}
50
+ app.kubernetes.io/instance: {{ .Release.Name }}
51
+ {{- end }}
52
+
53
+ {{/*
54
+ Create the name of the service account to use
55
+ */}}
56
+ {{- define "chart.serviceAccountName" -}}
57
+ {{- if .Values.serviceAccount.create }}
58
+ {{- default (include "chart.fullname" .) .Values.serviceAccount.name }}
59
+ {{- else }}
60
+ {{- default "default" .Values.serviceAccount.name }}
61
+ {{- end }}
62
+ {{- end }}
chart/templates/configmap.yaml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ apiVersion: v1
2
+ kind: ConfigMap
3
+ metadata:
4
+ name: env-config-{{ include "chart.fullname" . }}
5
+ labels:
6
+ {{- include "chart.labels" . | nindent 4 }}
7
+ data:
8
+ {{- toYaml .Values.env | nindent 2 }}
chart/templates/deployment.yaml ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ apiVersion: apps/v1
2
+ kind: Deployment
3
+ metadata:
4
+ name: {{ include "chart.fullname" . }}
5
+ labels:
6
+ {{- include "chart.labels" . | nindent 4 }}
7
+ spec:
8
+ {{- if not .Values.autoscaling.enabled }}
9
+ replicas: {{ .Values.replicaCount }}
10
+ {{- end }}
11
+ selector:
12
+ matchLabels:
13
+ {{- include "chart.selectorLabels" . | nindent 6 }}
14
+ template:
15
+ metadata:
16
+ {{- with .Values.podAnnotations }}
17
+ annotations:
18
+ {{- toYaml . | nindent 8 }}
19
+ {{- end }}
20
+ labels:
21
+ {{- include "chart.labels" . | nindent 8 }}
22
+ {{- with .Values.podLabels }}
23
+ {{- toYaml . | nindent 8 }}
24
+ {{- end }}
25
+ spec:
26
+ {{- with .Values.imagePullSecrets }}
27
+ imagePullSecrets:
28
+ {{- toYaml . | nindent 8 }}
29
+ {{- end }}
30
+ serviceAccountName: {{ include "chart.serviceAccountName" . }}
31
+ securityContext:
32
+ {{- toYaml .Values.podSecurityContext | nindent 8 }}
33
+ containers:
34
+ - name: {{ .Chart.Name }}
35
+ securityContext:
36
+ {{- toYaml .Values.securityContext | nindent 12 }}
37
+ image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
38
+ imagePullPolicy: {{ .Values.image.pullPolicy }}
39
+ ports:
40
+ - name: http
41
+ containerPort: {{ .Values.service.port }}
42
+ protocol: TCP
43
+ envFrom:
44
+ - configMapRef:
45
+ name: env-config-{{ include "chart.fullname" . }}
46
+ # - secretRef:
47
+ # name: secrets-{{ include "chart.fullname" . }}
48
+ livenessProbe:
49
+ {{- toYaml .Values.livenessProbe | nindent 12 }}
50
+ readinessProbe:
51
+ {{- toYaml .Values.readinessProbe | nindent 12 }}
52
+ resources:
53
+ {{- toYaml .Values.resources | nindent 12 }}
54
+ {{- with .Values.volumeMounts }}
55
+ volumeMounts:
56
+ {{- toYaml . | nindent 12 }}
57
+ {{- end }}
58
+ {{- with .Values.volumes }}
59
+ volumes:
60
+ {{- toYaml . | nindent 8 }}
61
+ {{- end }}
62
+ {{- with .Values.nodeSelector }}
63
+ nodeSelector:
64
+ {{- toYaml . | nindent 8 }}
65
+ {{- end }}
66
+ {{- with .Values.affinity }}
67
+ affinity:
68
+ {{- toYaml . | nindent 8 }}
69
+ {{- end }}
70
+ {{- with .Values.tolerations }}
71
+ tolerations:
72
+ {{- toYaml . | nindent 8 }}
73
+ {{- end }}
chart/templates/hpa.yaml ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {{- if .Values.autoscaling.enabled }}
2
+ apiVersion: autoscaling/v2
3
+ kind: HorizontalPodAutoscaler
4
+ metadata:
5
+ name: {{ include "chart.fullname" . }}
6
+ labels:
7
+ {{- include "chart.labels" . | nindent 4 }}
8
+ spec:
9
+ scaleTargetRef:
10
+ apiVersion: apps/v1
11
+ kind: Deployment
12
+ name: {{ include "chart.fullname" . }}
13
+ minReplicas: {{ .Values.autoscaling.minReplicas }}
14
+ maxReplicas: {{ .Values.autoscaling.maxReplicas }}
15
+ metrics:
16
+ {{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
17
+ - type: Resource
18
+ resource:
19
+ name: cpu
20
+ target:
21
+ type: Utilization
22
+ averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
23
+ {{- end }}
24
+ {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
25
+ - type: Resource
26
+ resource:
27
+ name: memory
28
+ target:
29
+ type: Utilization
30
+ averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
31
+ {{- end }}
32
+ {{- end }}
chart/templates/ingressroute.yaml ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {{- if .Values.ingressRoute.enabled -}}
2
+ apiVersion: traefik.containo.us/v1alpha1
3
+ kind: IngressRoute
4
+ metadata:
5
+ name: {{ include "chart.fullname" . }}
6
+ annotations:
7
+ {{- with .Values.ingressRoute.annotations }}
8
+ {{- toYaml . | nindent 4 }}
9
+ {{- end }}
10
+ labels:
11
+ {{- include "chart.labels" . | nindent 4 }}
12
+ {{- with .Values.ingressRoute.labels }}
13
+ {{- toYaml . | nindent 4 }}
14
+ {{- end }}
15
+ spec:
16
+ entryPoints:
17
+ {{- range .Values.ingressRoute.entryPoints }}
18
+ - {{ . }}
19
+ {{- end }}
20
+ routes:
21
+ - kind: Rule
22
+ match: {{ .Values.ingressRoute.matchRule }}
23
+ services:
24
+ - name: {{ include "chart.fullname" . }}
25
+ port: {{ .Values.service.port }}
26
+ {{- with .Values.ingressRoute.middlewares }}
27
+ middlewares:
28
+ {{- toYaml . | nindent 6 }}
29
+ {{- end -}}
30
+
31
+ {{- with .Values.ingressRoute.tls }}
32
+ tls:
33
+ {{- toYaml . | nindent 4 }}
34
+ {{- end }}
35
+ {{- end -}}
chart/templates/service.yaml ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ apiVersion: v1
2
+ kind: Service
3
+ metadata:
4
+ name: {{ include "chart.fullname" . }}
5
+ labels:
6
+ {{- include "chart.labels" . | nindent 4 }}
7
+ spec:
8
+ type: {{ .Values.service.type }}
9
+ ports:
10
+ - port: {{ .Values.service.port }}
11
+ targetPort: http
12
+ protocol: TCP
13
+ name: http
14
+ selector:
15
+ {{- include "chart.selectorLabels" . | nindent 4 }}
chart/templates/serviceaccount.yaml ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {{- if .Values.serviceAccount.create -}}
2
+ apiVersion: v1
3
+ kind: ServiceAccount
4
+ metadata:
5
+ name: {{ include "chart.serviceAccountName" . }}
6
+ labels:
7
+ {{- include "chart.labels" . | nindent 4 }}
8
+ {{- with .Values.serviceAccount.annotations }}
9
+ annotations:
10
+ {{- toYaml . | nindent 4 }}
11
+ {{- end }}
12
+ automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
13
+ {{- end }}
chart/templates/tests/test-connection.yaml ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ apiVersion: v1
2
+ kind: Pod
3
+ metadata:
4
+ name: "{{ include "chart.fullname" . }}-test-connection"
5
+ labels:
6
+ {{- include "chart.labels" . | nindent 4 }}
7
+ annotations:
8
+ "helm.sh/hook": test
9
+ spec:
10
+ containers:
11
+ - name: wget
12
+ image: busybox
13
+ command: ['wget']
14
+ args: ['{{ include "chart.fullname" . }}:{{ .Values.service.port }}']
15
+ restartPolicy: Never
chart/values.yaml ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Default values for chart.
2
+ # This is a YAML-formatted file.
3
+ # Declare variables to be passed into your templates.
4
+
5
+ replicaCount: 1
6
+
7
+ image:
8
+ repository: 970073041993.dkr.ecr.us-east-2.amazonaws.com/vision-agent
9
+ pullPolicy: IfNotPresent
10
+ # Overrides the image tag whose default is the chart appVersion.
11
+ tag: "latest"
12
+
13
+ imagePullSecrets: []
14
+ nameOverride: "vision-agent"
15
+ fullnameOverride: ""
16
+
17
+ serviceAccount:
18
+ # Specifies whether a service account should be created
19
+ create: false
20
+ # Automatically mount a ServiceAccount's API credentials?
21
+ automount: true
22
+ # Annotations to add to the service account
23
+ annotations: {}
24
+ # The name of the service account to use.
25
+ # If not set and create is true, a name is generated using the fullname template
26
+ name: "clef-user"
27
+
28
+ podAnnotations: {}
29
+ podLabels: {}
30
+
31
+ podSecurityContext:
32
+ {}
33
+ # fsGroup: 2000
34
+
35
+ securityContext:
36
+ {}
37
+ # capabilities:
38
+ # drop:
39
+ # - ALL
40
+ # readOnlyRootFilesystem: true
41
+ # runAsNonRoot: true
42
+ # runAsUser: 1000
43
+
44
+ service:
45
+ type: ClusterIP
46
+ port: 3000
47
+
48
+ ingressRoute:
49
+ enabled: true
50
+ entryPoints:
51
+ - websecure
52
+ matchRule: ""
53
+
54
+ resources:
55
+ # We usually recommend not to specify default resources and to leave this as a conscious
56
+ # choice for the user. This also increases chances charts run on environments with little
57
+ # resources, such as Minikube. If you do want to specify resources, uncomment the following
58
+ # lines, adjust them as necessary, and remove the curly braces after 'resources:'.
59
+ # limits:
60
+ # cpu: 100m
61
+ # memory: 128Mi
62
+ # requests:
63
+ # cpu: 100m
64
+ # memory: 128Mi
65
+
66
+ livenessProbe:
67
+ httpGet:
68
+ path: /
69
+ port: http
70
+ readinessProbe:
71
+ httpGet:
72
+ path: /
73
+ port: http
74
+
75
+ autoscaling:
76
+ enabled: false
77
+ minReplicas: 1
78
+ maxReplicas: 9
79
+ targetCPUUtilizationPercentage: 60
80
+ # targetMemoryUtilizationPercentage: 80
81
+
82
+ env:
83
+ AUTH_GITHUB_ID: ""
84
+ AUTH_GITHUB_SECRET: ""
85
+ AUTH_SECRET: ""
86
+ AUTH_TRUST_HOST: ""
87
+ AWS_ACCESS_KEY_ID: ""
88
+ AWS_BUCKET_NAME: ""
89
+ AWS_REGION: ""
90
+ AWS_SECRET_ACCESS_KEY: ""
91
+ COREPACK_ENABLE_STRICT: "0"
92
+ ENABLE_EXPERIMENTAL_COREPACK: "1"
93
+ GOOGLE_CLIENT_ID: ""
94
+ GOOGLE_SECRET: ""
95
+ LOKI_AUTH_USER_ID: "173854"
96
+ LOKI_AUTH_USER_PASSWORD: ""
97
+ OPENAI_API_KEY: ""
98
+ POSTGRES_PRISMA_URL: ""
99
+ NEXTAUTH_URL: ""
100
+ LND_TIER: ""
101
+
102
+ # Additional volumes on the output Deployment definition.
103
+ volumes: []
104
+ # - name: foo
105
+ # secret:
106
+ # secretName: mysecret
107
+ # optional: false
108
+
109
+ # Additional volumeMounts on the output Deployment definition.
110
+ volumeMounts: []
111
+ # - name: foo
112
+ # mountPath: "/etc/foo"
113
+ # readOnly: true
114
+
115
+ nodeSelector: {}
116
+
117
+ tolerations: []
118
+
119
+ affinity: {}
components.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "new-york",
4
+ "rsc": true,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "tailwind.config.ts",
8
+ "css": "app/globals.css",
9
+ "baseColor": "zinc",
10
+ "cssVariables": true,
11
+ "prefix": ""
12
+ },
13
+ "aliases": {
14
+ "components": "@/components",
15
+ "utils": "@/lib/utils"
16
+ }
17
+ }
components/Avatar.tsx ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Image from 'next/image';
2
+ import React from 'react';
3
+
4
+ export interface AvatarProps {
5
+ name?: string | null;
6
+ avatar?: string | null;
7
+ }
8
+
9
+ function getUserInitials(name: string) {
10
+ const [firstName, lastName] = name.split(' ');
11
+ return lastName ? `${firstName[0]}${lastName[0]}` : firstName.slice(0, 2);
12
+ }
13
+
14
+ const Avatar: React.FC<AvatarProps> = ({ name, avatar }) => {
15
+ return (
16
+ <>
17
+ {avatar ? (
18
+ <Image
19
+ className="size-6 transition-opacity duration-300 rounded-full select-none ring-1 ring-zinc-100/10 hover:opacity-80"
20
+ src={avatar ?? ''}
21
+ alt={name ?? 'Avatar'}
22
+ height={48}
23
+ width={48}
24
+ />
25
+ ) : (
26
+ <div className="flex items-center justify-center text-xs font-medium uppercase rounded-full select-none size-7 shrink-0 bg-muted/50 text-muted-foreground">
27
+ {name ? getUserInitials(name) : 'VA'}
28
+ </div>
29
+ )}
30
+ </>
31
+ );
32
+ };
33
+
34
+ export default Avatar;
components/ChatInterface.tsx ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { ChatWithMessages } from '@/lib/types';
4
+ import React from 'react';
5
+ import ChatList from './chat/ChatList';
6
+ import { Card } from './ui/Card';
7
+ import { useAtom, useAtomValue } from 'jotai';
8
+ import { selectedMessageId } from '@/state/chat';
9
+ import CodeResultDisplay from './CodeResultDisplay';
10
+
11
+ export interface ChatInterfaceProps {
12
+ chat: ChatWithMessages;
13
+ userId?: string | null;
14
+ }
15
+
16
+ const ChatInterface: React.FC<ChatInterfaceProps> = ({ chat, userId }) => {
17
+ const messageId = useAtomValue(selectedMessageId);
18
+ const messageCodeResult = chat.messages.find(
19
+ message => message.id === messageId,
20
+ )?.result;
21
+ return (
22
+ <div className="relative flex overflow-hidden space-x-4 size-full">
23
+ <div
24
+ data-state={messageCodeResult?.payload ? 'open' : 'closed'}
25
+ className="pl-4 peer absolute right-0 inset-y-0 hidden translate-x-full data-[state=open]:translate-x-0 z-30 duration-300 ease-in-out xl:flex flex-col items-start xl:w-1/2 h-full dark:bg-zinc-950 overflow-auto"
26
+ >
27
+ {messageCodeResult?.payload && (
28
+ <Card className="size-full overflow-auto">
29
+ <CodeResultDisplay codeResult={messageCodeResult.payload} />
30
+ </Card>
31
+ )}
32
+ </div>
33
+ <div className="w-full flex justify-center pr-0 animate-in duration-300 ease-in-out peer-[[data-state=open]]:xl:pr-[50%]">
34
+ <ChatList chat={chat} userId={userId} />
35
+ </div>
36
+ </div>
37
+ );
38
+ };
39
+
40
+ export default ChatInterface;
components/ChatSelect.tsx ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { Chat } from '@prisma/client';
4
+ import React from 'react';
5
+ import {
6
+ SelectItem,
7
+ Select,
8
+ SelectTrigger,
9
+ SelectContent,
10
+ SelectIcon,
11
+ SelectGroup,
12
+ SelectSeparator,
13
+ } from './ui/Select';
14
+ import Img from './ui/Img';
15
+ import { format } from 'date-fns';
16
+ import { useParams, useRouter } from 'next/navigation';
17
+ import { IconPlus } from './ui/Icons';
18
+
19
+ export interface ChatSelectProps {
20
+ chat: Chat;
21
+ }
22
+
23
+ const ChatSelectItem: React.FC<ChatSelectProps> = ({ chat }) => {
24
+ const { id, title, mediaUrl, updatedAt } = chat;
25
+ return (
26
+ <SelectItem value={id} className="size-full cursor-pointer">
27
+ <div className="overflow-hidden flex items-center size-full group">
28
+ <div className="size-[36px] relative m-1">
29
+ <Img
30
+ src={mediaUrl}
31
+ alt={`chat-${id}-card-image`}
32
+ className="object-cover size-full"
33
+ />
34
+ </div>
35
+ <div className="flex items-start flex-col h-full ml-3">
36
+ <p className="text-sm mb-1">{title ?? '(no title)'}</p>
37
+ <p className="text-xs text-gray-500">
38
+ {updatedAt ? format(Number(updatedAt), 'yyyy-MM-dd') : '-'}
39
+ </p>
40
+ </div>
41
+ </div>
42
+ </SelectItem>
43
+ );
44
+ };
45
+
46
+ const ChatSelect: React.FC<{ myChats: Chat[] }> = ({ myChats }) => {
47
+ const { id: chatIdFromParam } = useParams();
48
+
49
+ const currentChat = myChats.find(chat => chat.id === chatIdFromParam);
50
+ const router = useRouter();
51
+ return (
52
+ <Select
53
+ defaultValue={currentChat?.id}
54
+ value={currentChat?.id}
55
+ onValueChange={id => router.push(id === 'new' ? '/' : `/chat/${id}`)}
56
+ >
57
+ <SelectTrigger className="w-[240px]">
58
+ {currentChat?.title ?? 'Select a conversation'}
59
+ </SelectTrigger>
60
+ <SelectContent className="w-[320px]">
61
+ <SelectGroup>
62
+ <SelectItem value="new">
63
+ <div className="flex items-center justify-start">
64
+ <SelectIcon asChild>
65
+ <IconPlus className="size-4 opacity-50" />
66
+ </SelectIcon>
67
+ <div className="ml-4">New conversion</div>
68
+ </div>
69
+ </SelectItem>
70
+ {!!myChats.length && <SelectSeparator />}
71
+ {myChats.map(chat => (
72
+ <ChatSelectItem key={chat.id} chat={chat} />
73
+ ))}
74
+ </SelectGroup>
75
+ </SelectContent>
76
+ </Select>
77
+ );
78
+ };
79
+
80
+ export default ChatSelect;
components/ChatSelectServer.tsx ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import { dbGetMyChatList } from '@/lib/db/functions';
2
+ import ChatSelect from './ChatSelect';
3
+
4
+ export default async function ChatSelectServer() {
5
+ const [myChats] = await Promise.all([dbGetMyChatList()]);
6
+
7
+ return <ChatSelect myChats={myChats} />;
8
+ }
components/CodeResultDisplay.tsx ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ import { CodeBlock } from './ui/CodeBlock';
4
+ import {
5
+ Dialog,
6
+ DialogTrigger,
7
+ DialogContent,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ } from './ui/Dialog';
11
+ import { Button } from './ui/Button';
12
+ import { IconLog, IconTerminalWindow } from './ui/Icons';
13
+ import { Separator } from './ui/Separator';
14
+ import Img from './ui/Img';
15
+ import {
16
+ Carousel,
17
+ CarouselContent,
18
+ CarouselItem,
19
+ CarouselNext,
20
+ CarouselPrevious,
21
+ } from './ui/carousel';
22
+
23
+ export interface CodeResultDisplayProps {}
24
+
25
+ const CodeResultDisplay: React.FC<{
26
+ codeResult: PrismaJson.FinalCodeBody['payload'];
27
+ }> = ({ codeResult }) => {
28
+ const { code, test, result } = codeResult;
29
+ const getDetail = () => {
30
+ if (!result) return {};
31
+ try {
32
+ // IMPORTANT: This is for backwards compatibility with old chat that save result as JSON string
33
+ // updated in https://github.com/landing-ai/vision-agent-ui/pull/86
34
+ const detail =
35
+ typeof result === 'object'
36
+ ? result
37
+ : (JSON.parse(result) as PrismaJson.StructuredResult);
38
+ return {
39
+ results: detail.results,
40
+ stderr: detail.logs.stderr,
41
+ stdout: detail.logs.stdout,
42
+ error: detail.error,
43
+ };
44
+ } catch {
45
+ return {};
46
+ }
47
+ };
48
+
49
+ const { results = [], stderr, stdout, error } = getDetail();
50
+
51
+ const imageResults = results?.filter(_ => !!_.png).map(_ => _.png);
52
+ const videoResults = results?.filter(_ => !!_.mp4).map(_ => _.mp4);
53
+ const finalResult = results?.find(_ => _.is_main_result)?.text;
54
+
55
+ return (
56
+ <div className="rounded-lg overflow-hidden relative max-w-5xl">
57
+ <CodeBlock language="python" value={code} />
58
+ <div className="rounded-lg relative">
59
+ <div className="absolute left-1/2 -translate-x-1/2 -top-4 z-10">
60
+ <Dialog>
61
+ <DialogTrigger asChild>
62
+ <Button variant="ghost" size="icon" className="size-8">
63
+ <IconTerminalWindow className="text-teal-500 size-4" />
64
+ </Button>
65
+ </DialogTrigger>
66
+ <DialogContent className="max-w-5xl">
67
+ <DialogHeader>
68
+ <DialogTitle>Test code</DialogTitle>
69
+ </DialogHeader>
70
+ <CodeBlock language="python" value={test} />
71
+ </DialogContent>
72
+ </Dialog>
73
+ {Array.isArray(stderr) && !!stderr.join('').trim() && (
74
+ <Dialog>
75
+ <DialogTrigger asChild>
76
+ <Button variant="ghost" size="icon" className="size-8">
77
+ <IconLog className="text-gray-500 size-4" />
78
+ </Button>
79
+ </DialogTrigger>
80
+ <DialogContent className="max-w-5xl">
81
+ <CodeBlock language="vim" value={stderr.join('').trim()} />
82
+ </DialogContent>
83
+ </Dialog>
84
+ )}
85
+ </div>
86
+ </div>
87
+ {Array.isArray(stdout) && !!stdout.join('').trim() && (
88
+ <>
89
+ <Separator />
90
+ <CodeBlock language="print" value={stdout.join('').trim()} />
91
+ </>
92
+ )}
93
+ {!!error && (
94
+ <>
95
+ <Separator />
96
+ <CodeBlock
97
+ language="error"
98
+ value={
99
+ error.name +
100
+ '\n' +
101
+ error.value +
102
+ '\n' +
103
+ error.traceback_raw.join('\n')
104
+ }
105
+ />
106
+ </>
107
+ )}
108
+ {!!imageResults.length && (
109
+ <div className="p-4 text-xs lowercase bg-zinc-900 space-y-4 border-t border-muted">
110
+ <div className="flex items-center justify-between">
111
+ <p>image output</p>
112
+ <Dialog>
113
+ <DialogTrigger asChild>
114
+ <Button variant="outline" size="sm">
115
+ View all
116
+ </Button>
117
+ </DialogTrigger>
118
+ <DialogContent className="max-w-5xl flex justify-center items-center">
119
+ <Carousel className="w-3/4">
120
+ <CarouselContent>
121
+ {imageResults.map((png, index) => (
122
+ <CarouselItem key={'png' + index}>
123
+ <Img src={png!} width={1200} alt="result-image" />
124
+ </CarouselItem>
125
+ ))}
126
+ </CarouselContent>
127
+ <CarouselPrevious />
128
+ <CarouselNext />
129
+ </Carousel>
130
+ </DialogContent>
131
+ </Dialog>
132
+ </div>
133
+ <div className="flex flex-row space-x-4 overflow-auto">
134
+ {imageResults.map((png, index) => (
135
+ <Img
136
+ key={'png' + index}
137
+ src={png!}
138
+ width={200}
139
+ alt="result-image"
140
+ />
141
+ ))}
142
+ </div>
143
+ </div>
144
+ )}
145
+ {!!videoResults.length && (
146
+ <div className="p-4 text-xs lowercase bg-zinc-900 space-y-4 border-t border-muted">
147
+ <p>video output</p>
148
+ <div className="flex flex-row space-x-4 overflow-auto">
149
+ {videoResults.map((mp4, index) => (
150
+ <Dialog key={'png' + index}>
151
+ <DialogTrigger asChild>
152
+ <video src={mp4} controls width={400} height={400} />
153
+ </DialogTrigger>
154
+ <DialogContent className="max-w-5xl">
155
+ <video src={mp4} controls width={400} height={400} />
156
+ </DialogContent>
157
+ </Dialog>
158
+ ))}
159
+ </div>
160
+ </div>
161
+ )}
162
+ {!!finalResult && <CodeBlock language="output" value={finalResult} />}
163
+ </div>
164
+ );
165
+ };
166
+
167
+ export default CodeResultDisplay;
components/Header.tsx CHANGED
@@ -1,23 +1,52 @@
1
- import * as React from 'react';
2
  import Link from 'next/link';
3
 
4
- import { auth, authEmail } from '@/auth';
5
  import { Button } from '@/components/ui/Button';
6
  import { UserMenu } from '@/components/UserMenu';
 
 
 
 
 
 
 
 
7
  import {
8
  Tooltip,
9
  TooltipContent,
10
  TooltipTrigger,
11
  } from '@/components/ui/Tooltip';
12
- import { IconPlus, IconSeparator } from '@/components/ui/Icons';
13
- import { LoginMenu } from './LoginMenu';
14
- import { redirect } from 'next/navigation';
15
 
16
  export async function Header() {
17
  const session = await auth();
18
- const { isAdmin } = await authEmail();
 
 
 
 
 
 
 
 
 
 
 
19
  return (
20
- <header className="sticky top-0 z-50 flex items-center justify-end w-full h-16 px-8 border-b shrink-0 bg-gradient-to-b from-background/10 via-background/50 to-background/80 backdrop-blur-xl">
 
 
 
 
 
 
 
 
 
 
 
 
21
  {/* <Tooltip>
22
  <TooltipTrigger asChild>
23
  <Button variant="link" asChild className="mr-2">
@@ -28,16 +57,44 @@ export async function Header() {
28
  </TooltipTrigger>
29
  <TooltipContent>New chat</TooltipContent>
30
  </Tooltip> */}
 
 
 
 
 
31
  {isAdmin && (
32
  <Button variant="link" asChild className="mr-2">
33
  <Link href="/project">Projects (Internal)</Link>
34
  </Button>
35
- )}
36
  <Button variant="link" asChild className="mr-2">
37
- <Link href="/chat">Chat</Link>
38
  </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  <IconSeparator className="size-6 text-muted-foreground/50" />
40
- <div className="flex items-center">
41
  {session?.user ? <UserMenu user={session!.user} /> : <LoginMenu />}
42
  </div>
43
  </header>
 
1
+ import { Suspense } from 'react';
2
  import Link from 'next/link';
3
 
4
+ import { auth, sessionUser } from '@/auth';
5
  import { Button } from '@/components/ui/Button';
6
  import { UserMenu } from '@/components/UserMenu';
7
+ import { IconPlus, IconSeparator } from '@/components/ui/Icons';
8
+ import { LoginMenu } from './LoginMenu';
9
+ import { redirect } from 'next/navigation';
10
+ import Image from 'next/image';
11
+ import LandingLogo from '@/assets/svg/LandingAI_white.svg';
12
+ import ChatSelectServer from './ChatSelectServer';
13
+ import Loading from './ui/Loading';
14
+ import { Skeleton } from './ui/Skeleton';
15
  import {
16
  Tooltip,
17
  TooltipContent,
18
  TooltipTrigger,
19
  } from '@/components/ui/Tooltip';
20
+ import { IconDiscord, IconGitHub } from '@/components/ui/Icons';
 
 
21
 
22
  export async function Header() {
23
  const session = await auth();
24
+ // const { isAdmin } = await sessionUser();
25
+
26
+ if (process.env.NEXT_PUBLIC_IS_HUGGING_FACE) {
27
+ return (
28
+ <header className="sticky top-0 z-50 flex items-center justify-end w-full h-16 px-8 border-b shrink-0 bg-gradient-to-b from-background/10 via-background/50 to-background/80 backdrop-blur-xl">
29
+ <Button variant="link" asChild className="mr-2">
30
+ <Link href="/">New conversation</Link>
31
+ </Button>
32
+ </header>
33
+ );
34
+ }
35
+
36
  return (
37
+ <header className="sticky top-0 z-50 flex items-center justify-start w-full h-16 px-4 border-b shrink-0 bg-gradient-to-b from-background/10 via-background/50 to-background/80 backdrop-blur-xl">
38
+ <Link
39
+ className="overflow-hidden w-[150px] h-[45px] shrink-0 grow-0 relative mr-4 cursor-pointer"
40
+ href="/"
41
+ >
42
+ <Image src={LandingLogo} alt="Landing AI" fill />
43
+ </Link>
44
+ {session?.user && (
45
+ <Suspense fallback={<Skeleton className="w-[240px] h-[24px]" />}>
46
+ <ChatSelectServer />
47
+ </Suspense>
48
+ )}
49
+ <div className="grow" />
50
  {/* <Tooltip>
51
  <TooltipTrigger asChild>
52
  <Button variant="link" asChild className="mr-2">
 
57
  </TooltipTrigger>
58
  <TooltipContent>New chat</TooltipContent>
59
  </Tooltip> */}
60
+ {/* {isAdmin && (
61
+ <Button variant="link" asChild className="mr-2">
62
+ <Link href="/all">All Chats (Internal)</Link>
63
+ </Button>
64
+ )}
65
  {isAdmin && (
66
  <Button variant="link" asChild className="mr-2">
67
  <Link href="/project">Projects (Internal)</Link>
68
  </Button>
69
+ )} */}
70
  <Button variant="link" asChild className="mr-2">
71
+ <Link href="/">New conversation</Link>
72
  </Button>
73
+ <Tooltip>
74
+ <TooltipTrigger asChild>
75
+ <Button variant="link" size="icon" asChild className="mr-2">
76
+ <Link
77
+ href="https://github.com/landing-ai/vision-agent"
78
+ target="_blank"
79
+ >
80
+ <IconGitHub className="size-5" />
81
+ </Link>
82
+ </Button>
83
+ </TooltipTrigger>
84
+ <TooltipContent>Github</TooltipContent>
85
+ </Tooltip>
86
+ <Tooltip>
87
+ <TooltipTrigger asChild>
88
+ <Button variant="link" size="icon" asChild className="mr-2">
89
+ <Link href="https://discord.gg/gSC5p7ED" target="_blank">
90
+ <IconDiscord className="size-5" />
91
+ </Link>
92
+ </Button>
93
+ </TooltipTrigger>
94
+ <TooltipContent>Discord</TooltipContent>
95
+ </Tooltip>
96
  <IconSeparator className="size-6 text-muted-foreground/50" />
97
+ <div className="flex items-center grow-0">
98
  {session?.user ? <UserMenu user={session!.user} /> : <LoginMenu />}
99
  </div>
100
  </header>
components/Providers.tsx CHANGED
@@ -4,14 +4,14 @@ import * as React from 'react';
4
  import { ThemeProvider as NextThemesProvider } from 'next-themes';
5
  import { ThemeProviderProps } from 'next-themes/dist/types';
6
  import { TooltipProvider } from '@/components/ui/Tooltip';
7
- import { ThemeToggle } from './ThemeToggle';
8
 
9
  export function Providers({ children, ...props }: ThemeProviderProps) {
10
  return (
11
- <NextThemesProvider {...props}>
12
  <TooltipProvider>
13
  {children}
14
- <ThemeToggle />
15
  </TooltipProvider>
16
  </NextThemesProvider>
17
  );
 
4
  import { ThemeProvider as NextThemesProvider } from 'next-themes';
5
  import { ThemeProviderProps } from 'next-themes/dist/types';
6
  import { TooltipProvider } from '@/components/ui/Tooltip';
7
+ // import { ThemeToggle } from './ThemeToggle';
8
 
9
  export function Providers({ children, ...props }: ThemeProviderProps) {
10
  return (
11
+ <NextThemesProvider {...props} forcedTheme="dark">
12
  <TooltipProvider>
13
  {children}
14
+ {/* <ThemeToggle /> */}
15
  </TooltipProvider>
16
  </NextThemesProvider>
17
  );
components/UserMenu.tsx CHANGED
@@ -13,6 +13,7 @@ import {
13
  DropdownMenuTrigger,
14
  } from '@/components/ui/DropdownMenu';
15
  import { IconExternalLink } from '@/components/ui/Icons';
 
16
 
17
  export interface UserMenuProps {
18
  user: Session['user'];
@@ -29,23 +30,15 @@ export function UserMenu({ user }: UserMenuProps) {
29
  <DropdownMenu>
30
  <DropdownMenuTrigger asChild>
31
  <Button variant="ghost">
32
- {user?.image ? (
33
- <Image
34
- className="size-6 transition-opacity duration-300 rounded-full select-none ring-1 ring-zinc-100/10 hover:opacity-80"
35
- src={user?.image ?? ''}
36
- alt={user.name ?? 'Avatar'}
37
- height={48}
38
- width={48}
39
- />
40
- ) : (
41
- <div className="flex items-center justify-center text-xs font-medium uppercase rounded-full select-none size-7 shrink-0 bg-muted/50 text-muted-foreground">
42
- {user?.name ? getUserInitials(user?.name) : null}
43
- </div>
44
- )}
45
  <span className="ml-2">{user?.name}</span>
46
  </Button>
47
  </DropdownMenuTrigger>
48
- <DropdownMenuContent sideOffset={8} align="start" className="w-[180px]">
 
 
 
 
49
  <DropdownMenuItem className="flex-col items-start">
50
  <div className="text-xs font-medium">{user?.name}</div>
51
  <div className="text-xs text-zinc-500">{user?.email}</div>
 
13
  DropdownMenuTrigger,
14
  } from '@/components/ui/DropdownMenu';
15
  import { IconExternalLink } from '@/components/ui/Icons';
16
+ import Avatar from './Avatar';
17
 
18
  export interface UserMenuProps {
19
  user: Session['user'];
 
30
  <DropdownMenu>
31
  <DropdownMenuTrigger asChild>
32
  <Button variant="ghost">
33
+ <Avatar name={user?.name} avatar={user?.image} />
 
 
 
 
 
 
 
 
 
 
 
 
34
  <span className="ml-2">{user?.name}</span>
35
  </Button>
36
  </DropdownMenuTrigger>
37
+ <DropdownMenuContent
38
+ sideOffset={8}
39
+ align="center"
40
+ className="w-[160px]"
41
+ >
42
  <DropdownMenuItem className="flex-col items-start">
43
  <div className="text-xs font-medium">{user?.name}</div>
44
  <div className="text-xs text-zinc-500">{user?.email}</div>
components/chat-sidebar/ChatAdminToggle.tsx DELETED
@@ -1,14 +0,0 @@
1
- 'use client';
2
-
3
- import { chatViewMode } from '@/state/chat';
4
- import { useAtom } from 'jotai';
5
- import React from 'react';
6
-
7
- export interface ChatAdminToggleProps {}
8
-
9
- const ChatAdminToggle: React.FC<ChatAdminToggleProps> = () => {
10
- const modeAtom = useAtom(chatViewMode);
11
- return null;
12
- };
13
-
14
- export default ChatAdminToggle;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
components/chat-sidebar/ChatCard.tsx DELETED
@@ -1,63 +0,0 @@
1
- 'use client';
2
-
3
- import { PropsWithChildren } from 'react';
4
- import Link from 'next/link';
5
- import { useParams, usePathname, useRouter } from 'next/navigation';
6
- import { cn } from '@/lib/utils';
7
- import { ChatEntity } from '@/lib/types';
8
- import Image from 'next/image';
9
- import clsx from 'clsx';
10
- import Img from '../ui/Img';
11
- import { format } from 'date-fns';
12
- import { cleanInputMessage } from '@/lib/messageUtils';
13
- // import { format } from 'date-fns';
14
-
15
- type ChatCardProps = PropsWithChildren<{
16
- chat: ChatEntity;
17
- }>;
18
-
19
- export const ChatCardLayout: React.FC<
20
- PropsWithChildren<{ link: string; classNames?: clsx.ClassValue }>
21
- > = ({ link, children, classNames }) => {
22
- return (
23
- <Link
24
- className={cn(
25
- 'p-2 m-2 bg-background max-h-[100px] rounded-xl shadow-md flex items-center border border-transparent hover:border-gray-500 transition-all cursor-pointer',
26
- classNames,
27
- )}
28
- href={link}
29
- >
30
- {children}
31
- </Link>
32
- );
33
- };
34
-
35
- const ChatCard: React.FC<ChatCardProps> = ({ chat }) => {
36
- const { id: chatIdFromParam } = useParams();
37
- const pathname = usePathname();
38
- const { id, url, messages, user, updatedAt } = chat;
39
- const firstMessage = cleanInputMessage(messages?.[0]?.content ?? '');
40
- const title = firstMessage
41
- ? firstMessage.length > 50
42
- ? firstMessage.slice(0, 50) + '...'
43
- : firstMessage
44
- : '(No messages yet)';
45
- return (
46
- <ChatCardLayout
47
- link={`/chat/${id}`}
48
- classNames={chatIdFromParam === id && 'border-gray-500'}
49
- >
50
- <div className="overflow-hidden flex items-center size-full">
51
- <Img src={url} alt={`chat-${id}-card-image`} className="w-1/4" />
52
- <div className="flex items-start flex-col h-full ml-3 w-3/4">
53
- <p className="text-sm mb-1">{title}</p>
54
- <p className="text-xs text-gray-500">
55
- {updatedAt ? format(Number(updatedAt), 'yyyy-MM-dd') : '-'}
56
- </p>
57
- </div>
58
- </div>
59
- </ChatCardLayout>
60
- );
61
- };
62
-
63
- export default ChatCard;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
components/chat-sidebar/ChatListSidebar.tsx DELETED
@@ -1,27 +0,0 @@
1
- import { getKVChats } from '@/lib/kv/chat';
2
- import ChatCard, { ChatCardLayout } from './ChatCard';
3
- import { IconPlus } from '../ui/Icons';
4
- import { auth } from '@/auth';
5
-
6
- export interface ChatSidebarListProps {}
7
-
8
- export default async function ChatSidebarList({}: ChatSidebarListProps) {
9
- const session = await auth();
10
- if (!session || !session.user) {
11
- return null;
12
- }
13
- const chats = await getKVChats();
14
- return (
15
- <>
16
- <ChatCardLayout link="/chat">
17
- <div className="overflow-hidden flex items-center size-full">
18
- <IconPlus className="w-1/4 font-bold" />
19
- <p className="text-sm w-3/4 ml-3 font-bold">New chat</p>
20
- </div>
21
- </ChatCardLayout>
22
- {chats.map(chat => (
23
- <ChatCard key={chat.id} chat={chat} />
24
- ))}
25
- </>
26
- );
27
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
components/chat/ChatList.tsx CHANGED
@@ -1,26 +1,102 @@
1
  'use client';
2
 
3
- import { Separator } from '@/components/ui/Separator';
4
- import { ChatMessage } from '@/components/chat/ChatMessage';
5
- import { MessageBase } from '../../lib/types';
 
 
 
 
 
 
 
 
 
 
 
6
 
7
- export interface ChatList {
8
- messages: MessageBase[];
 
9
  }
10
 
11
- export function ChatList({ messages }: ChatList) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  return (
13
- <div className="relative mx-auto max-w-5xl px-8 pr-12">
14
- {messages
15
- // .filter(message => message.role !== 'system')
16
- .map((message, index) => (
17
- <div key={index}>
18
- <ChatMessage message={message} />
19
- {index < messages.length - 1 && (
20
- <Separator className="my-4 md:my-8" />
21
- )}
22
- </div>
23
- ))}
24
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  );
26
- }
 
 
 
1
  'use client';
2
 
3
+ // import { ChatList } from '@/components/chat/ChatList';
4
+ import Composer from '@/components/chat/Composer';
5
+ import useVisionAgent from '@/lib/hooks/useVisionAgent';
6
+ import { useScrollAnchor } from '@/lib/hooks/useScrollAnchor';
7
+ import { useEffect } from 'react';
8
+ import { ChatWithMessages } from '@/lib/types';
9
+ import { ChatMessage } from './ChatMessage';
10
+ import { Button } from '../ui/Button';
11
+ import { cn } from '@/lib/utils';
12
+ import { IconArrowDown } from '../ui/Icons';
13
+ import { dbPostCreateMessage } from '@/lib/db/functions';
14
+ import { Card } from '../ui/Card';
15
+ import { useSetAtom } from 'jotai';
16
+ import { selectedMessageId } from '@/state/chat';
17
 
18
+ export interface ChatListProps {
19
+ chat: ChatWithMessages;
20
+ userId?: string | null;
21
  }
22
 
23
+ export const SCROLL_BOTTOM = 120;
24
+
25
+ const ChatList: React.FC<ChatListProps> = ({ chat, userId }) => {
26
+ const { id, messages: dbMessages, userId: chatUserId } = chat;
27
+ const { messages, append, isLoading, data } = useVisionAgent(chat);
28
+
29
+ // Only login and chat owner can compose
30
+ const canCompose = !chatUserId || userId === chatUserId;
31
+
32
+ const lastDbMessage = dbMessages[dbMessages.length - 1];
33
+ const setMessageId = useSetAtom(selectedMessageId);
34
+
35
+ const { messagesRef, scrollRef, visibilityRef, isVisible, scrollToBottom } =
36
+ useScrollAnchor(SCROLL_BOTTOM);
37
+
38
+ // Scroll to bottom on init and highlight last message
39
+ useEffect(() => {
40
+ scrollToBottom();
41
+ if (lastDbMessage.result) {
42
+ setMessageId(lastDbMessage.id);
43
+ }
44
+ // eslint-disable-next-line react-hooks/exhaustive-deps
45
+ }, []);
46
+
47
  return (
48
+ <Card
49
+ className="size-full max-w-5xl overflow-auto relative"
50
+ ref={scrollRef}
51
+ >
52
+ <div className="overflow-auto h-full p-4 z-10" ref={messagesRef}>
53
+ {dbMessages.map((message, index) => {
54
+ const isLastMessage = index === dbMessages.length - 1;
55
+ return (
56
+ <ChatMessage
57
+ key={message.id}
58
+ message={message}
59
+ loading={isLastMessage && isLoading}
60
+ wipAssistantMessage={data}
61
+ />
62
+ );
63
+ })}
64
+ <div
65
+ className="w-full"
66
+ style={{ height: SCROLL_BOTTOM }}
67
+ ref={visibilityRef}
68
+ />
69
+ </div>
70
+ {canCompose && (
71
+ <div className="absolute bottom-4 w-full">
72
+ <Composer
73
+ // Use the last message mediaUrl as the initial mediaUrl
74
+ initMediaUrl={dbMessages[dbMessages.length - 1]?.mediaUrl}
75
+ disabled={isLoading}
76
+ onSubmit={async ({ input, mediaUrl: newMediaUrl }) => {
77
+ const messageInput = {
78
+ prompt: input,
79
+ mediaUrl: newMediaUrl,
80
+ };
81
+ const resp = await dbPostCreateMessage(id, messageInput);
82
+ append(resp);
83
+ }}
84
+ />
85
+ </div>
86
+ )}
87
+ {/* Scroll to bottom Icon */}
88
+ <Button
89
+ size="icon"
90
+ className={cn(
91
+ 'absolute bottom-3 right-3 transition-opacity duration-300 size-6',
92
+ isVisible ? 'opacity-0' : 'opacity-100',
93
+ )}
94
+ onClick={() => scrollToBottom()}
95
+ >
96
+ <IconArrowDown className="size-3" />
97
+ </Button>
98
+ </Card>
99
  );
100
+ };
101
+
102
+ export default ChatList;
components/chat/ChatMessage.tsx CHANGED
@@ -1,151 +1,292 @@
1
- // Inspired by Chatbot-UI and modified to fit the needs of this project
2
- // @see https://github.com/mckaywrigley/chatbot-ui/blob/main/components/Chat/ChatMessage.tsx
3
-
4
- import remarkGfm from 'remark-gfm';
5
- import remarkMath from 'remark-math';
6
-
7
- import { useMemo } from 'react';
8
- import { cn } from '@/lib/utils';
9
  import { CodeBlock } from '@/components/ui/CodeBlock';
10
- import { MemoizedReactMarkdown } from '@/components/chat/MemoizedReactMarkdown';
11
- import { IconOpenAI, IconUser } from '@/components/ui/Icons';
12
- import { MessageBase } from '../../lib/types';
13
  import {
14
- Tooltip,
15
- TooltipContent,
16
- TooltipTrigger,
17
- } from '@/components/ui/Tooltip';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  import Img from '../ui/Img';
19
- import { getCleanedUpMessages } from '@/lib/messageUtils';
 
 
 
 
 
20
 
21
  export interface ChatMessageProps {
22
- message: MessageBase;
 
 
23
  }
24
 
25
- export function ChatMessage({ message, ...props }: ChatMessageProps) {
26
- const { logs, content } = useMemo(() => {
27
- return getCleanedUpMessages({
28
- content: message.content,
29
- role: message.role,
30
- });
31
- }, [message.content, message.role]);
 
 
 
 
 
 
 
 
 
 
32
  return (
33
- <div className={cn('group relative mb-4 flex items-start')} {...props}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  <div
35
  className={cn(
36
- 'flex size-8 shrink-0 select-none items-center justify-center rounded-md border shadow',
37
- message.role === 'user'
38
- ? 'bg-background'
39
- : 'bg-primary text-primary-foreground',
40
  )}
41
  >
42
- {message.role === 'user' ? <IconUser /> : <IconOpenAI />}
43
  </div>
44
- <div className="flex-1 px-1 ml-4 space-y-2 overflow-hidden">
45
- {logs && message.role !== 'user' && (
46
- <div className="bg-slate-100 dark:bg-slate-900 mb-4 p-4 max-h-[400px] overflow-auto">
47
- <div className="text-xl font-bold">Thinking Process</div>
48
- <MemoizedReactMarkdown
49
- className="break-words text-sm"
50
- remarkPlugins={[remarkGfm, remarkMath]}
51
- components={{
52
- p({ children }) {
53
- return (
54
- <p className="my-2 last:mb-0 whitespace-pre-line">
55
- {children}
56
- </p>
57
- );
58
- },
59
- code({ children, ...props }) {
60
- return (
61
- <code className="whitespace-pre-line">{children}</code>
62
- );
63
- },
64
- }}
65
- >
66
- {logs}
67
- </MemoizedReactMarkdown>
68
- </div>
69
- )}
70
- <MemoizedReactMarkdown
71
- className="break-words"
72
- remarkPlugins={[remarkGfm, remarkMath]}
73
- components={{
74
- p({ children, ...props }) {
75
- if (
76
- props.node.children.some(
77
- child => child.type === 'element' && child.tagName === 'img',
78
- )
79
- ) {
80
- return (
81
- <p className="flex flex-wrap gap-2 items-start">{children}</p>
82
- );
83
- }
84
- return (
85
- <p className="my-2 last:mb-0 whitespace-pre-line">{children}</p>
86
- );
87
- },
88
- img(props) {
89
- return (
90
- <Tooltip>
91
- <TooltipTrigger asChild>
92
- <Img
93
- src={props.src ?? '/landing.png'}
94
- alt={props.alt ?? 'answer-image'}
95
- quality={100}
96
- className="cursor-zoom-in"
97
- sizes="(min-width: 66em) 25vw,
98
- (min-width: 44em) 40vw,
99
- 100vw"
100
- />
101
- </TooltipTrigger>
102
- <TooltipContent>
103
- <Img
104
- className="m-2"
105
- src={props.src ?? '/landing.png'}
106
- alt={props.alt ?? 'answer-image'}
107
- quality={100}
108
- width={500}
109
- />
110
- </TooltipContent>
111
- </Tooltip>
112
- );
113
- },
114
- code({ node, inline, className, children, ...props }) {
115
- if (children.length) {
116
- if (children[0] == '▍') {
117
- return (
118
- <span className="mt-1 cursor-default animate-pulse">▍</span>
119
- );
120
- }
121
 
122
- children[0] = (children[0] as string).replace('`▍`', '▍');
123
- }
 
124
 
125
- const match = /language-(\w+)/.exec(className || '');
126
- if (inline) {
127
- return (
128
- <code className={className} {...props}>
129
- {children}
130
- </code>
131
- );
132
- }
 
 
133
 
134
- return (
135
- <CodeBlock
136
- key={Math.random()}
137
- language={(match && match[1]) || ''}
138
- value={String(children).replace(/\n$/, '')}
139
- {...props}
140
- />
141
- );
142
- },
143
- }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  >
145
- {content}
146
- </MemoizedReactMarkdown>
147
- {/* <ChatMessageActions message={message} /> */}
148
- </div>
149
- </div>
150
- );
151
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
 
 
 
 
 
 
 
2
  import { CodeBlock } from '@/components/ui/CodeBlock';
 
 
 
3
  import {
4
+ IconCheckCircle,
5
+ IconCodeWrap,
6
+ IconCrossCircle,
7
+ IconLandingAI,
8
+ IconListUnordered,
9
+ IconTerminalWindow,
10
+ IconUser,
11
+ IconGlowingDot,
12
+ } from '@/components/ui/Icons';
13
+ import { MessageUI } from '@/lib/types';
14
+ import { WIPChunkBodyGroup, formatStreamLogs } from '@/lib/utils/content';
15
+ import {
16
+ Table,
17
+ TableBody,
18
+ TableCell,
19
+ TableHead,
20
+ TableHeader,
21
+ TableRow,
22
+ } from '../ui/Table';
23
+ import { Button } from '../ui/Button';
24
+ import { Dialog, DialogContent, DialogTrigger } from '../ui/Dialog';
25
  import Img from '../ui/Img';
26
+ import CodeResultDisplay from '../CodeResultDisplay';
27
+ import { useAtom, useSetAtom } from 'jotai';
28
+ import { selectedMessageId } from '@/state/chat';
29
+ import { Message } from '@prisma/client';
30
+ import { Separator } from '../ui/Separator';
31
+ import { cn } from '@/lib/utils';
32
 
33
  export interface ChatMessageProps {
34
+ message: Message;
35
+ loading?: boolean;
36
+ wipAssistantMessage?: PrismaJson.MessageBody[];
37
  }
38
 
39
+ export const ChatMessage: React.FC<ChatMessageProps> = ({
40
+ data,
41
+ message,
42
+ wipAssistantMessage,
43
+ loading,
44
+ }) => {
45
+ const [messageId, setMessageId] = useAtom(selectedMessageId);
46
+ const { id, mediaUrl, prompt, response, result, responseBody } = message;
47
+ const [formattedSections, finalResult, finalError] = useMemo(
48
+ () =>
49
+ formatStreamLogs(
50
+ responseBody ?? wipAssistantMessage ?? JSON.parse(response ?? ''),
51
+ ),
52
+ [response, wipAssistantMessage, result, responseBody, data],
53
+ );
54
+ // prioritize the result from the message over the WIP message
55
+ const codeResult = result?.payload ?? finalResult;
56
  return (
57
+ <div
58
+ className={cn(
59
+ 'rounded-md bg-muted border border-muted p-4 pb-5 mb-4 relative',
60
+ messageId === id && 'lg:border-primary/50',
61
+ result && 'lg:cursor-pointer',
62
+ )}
63
+ onClick={() => {
64
+ if (result) {
65
+ setMessageId(id);
66
+ }
67
+ }}
68
+ >
69
+ <div className="flex">
70
+ <div className="flex size-8 shrink-0 select-none items-center justify-center rounded-md border shadow bg-background">
71
+ <IconUser />
72
+ </div>
73
+ <div className="flex-1 px-1 ml-4 space-y-2 overflow-hidden">
74
+ <p>{prompt}</p>
75
+ {mediaUrl && (
76
+ <>
77
+ {mediaUrl?.endsWith('.mp4') ? (
78
+ <video src={mediaUrl} controls width={400} height={400} />
79
+ ) : (
80
+ <Dialog>
81
+ <DialogTrigger asChild>
82
+ <Img src={mediaUrl} alt={mediaUrl} width={300} />
83
+ </DialogTrigger>
84
+ <DialogContent className="max-w-5xl">
85
+ <Img src={mediaUrl} alt={mediaUrl} quality={100} />
86
+ </DialogContent>
87
+ </Dialog>
88
+ )}
89
+ </>
90
+ )}
91
+ </div>
92
+ </div>
93
+ {!!formattedSections.length && (
94
+ <>
95
+ <Separator className="bg-primary/30 my-4" />
96
+ <div className="flex">
97
+ <div className="flex size-8 shrink-0 select-none items-center justify-center rounded-md border shadow bg-primary text-primary-foreground">
98
+ <IconLandingAI />
99
+ </div>
100
+ <div className="flex-1 px-1 space-y-4 ml-4 overflow-hidden">
101
+ <Table className="w-[400px]">
102
+ <TableBody>
103
+ {formattedSections.map((section, index) => (
104
+ <TableRow
105
+ className="border-primary/50 h-[56px]"
106
+ key={index}
107
+ >
108
+ <TableCell className="text-center text-webkit-center">
109
+ {ChunkStatusToIconDict[section.status]}
110
+ </TableCell>
111
+ <TableCell className="font-medium">
112
+ <ChunkTypeToText
113
+ useTimer={!codeResult && !finalError}
114
+ chunk={section}
115
+ />
116
+ </TableCell>
117
+ <TableCell className="text-right">
118
+ <ChunkPayloadAction payload={section.payload} />
119
+ </TableCell>
120
+ </TableRow>
121
+ ))}
122
+ </TableBody>
123
+ </Table>
124
+ {codeResult && (
125
+ <>
126
+ <div className="xl:hidden">
127
+ <CodeResultDisplay codeResult={codeResult} />
128
+ </div>
129
+ <p>✨ Coding complete</p>
130
+ </>
131
+ )}
132
+ {!codeResult && finalError && (
133
+ <>
134
+ <p>❌ {finalError.name}</p>
135
+ <div>
136
+ <CodeBlock
137
+ language="error"
138
+ value={
139
+ finalError.value +
140
+ '\n' +
141
+ finalError.traceback_raw.join('\n')
142
+ }
143
+ />
144
+ </div>
145
+ </>
146
+ )}
147
+ </div>
148
+ </div>
149
+ </>
150
+ )}
151
  <div
152
  className={cn(
153
+ 'w-1/3 h-1 rounded-full overflow-hidden bg-zinc-700 absolute left-1/2 -translate-x-1/2 bottom-2',
154
+ loading ? 'opacity-100' : 'opacity-0',
 
 
155
  )}
156
  >
157
+ <div className="h-full bg-primary animate-progress origin-left-right" />
158
  </div>
159
+ </div>
160
+ );
161
+ };
162
+
163
+ const ChunkStatusToIconDict: Record<
164
+ WIPChunkBodyGroup['status'],
165
+ React.ReactElement
166
+ > = {
167
+ started: <IconGlowingDot className="bg-yellow-500/80" />,
168
+ completed: <IconCheckCircle className="text-green-500" />,
169
+ running: <IconGlowingDot className="bg-teal-500/80" />,
170
+ failed: <IconCrossCircle className="text-red-500" />,
171
+ };
172
+ const ChunkTypeToText: React.FC<{
173
+ chunk: WIPChunkBodyGroup;
174
+ useTimer: boolean;
175
+ }> = ({ chunk, useTimer }) => {
176
+ const { status, type, timestamp, duration } = chunk;
177
+
178
+ const [mSeconds, setMSeconds] = useState(0);
179
+ const isExecuting = !['completed', 'failed'].includes(status);
180
+
181
+ useEffect(() => {
182
+ if (isExecuting && timestamp && useTimer) {
183
+ const timerId = setInterval(() => {
184
+ setMSeconds(Date.now() - Date.parse(timestamp));
185
+ }, 200);
186
+ return () => clearInterval(timerId);
187
+ }
188
+ }, [isExecuting, timestamp, useTimer]);
189
+
190
+ const displayMs = isExecuting && useTimer ? mSeconds : duration;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
 
192
+ const durationDisplay = displayMs
193
+ ? `(${Math.round(displayMs / 100) / 10}s)`
194
+ : '';
195
 
196
+ if (type === 'plans') return <p>Creating instructions {durationDisplay}</p>;
197
+ if (type === 'tools') return <p>Retrieving tools {durationDisplay}</p>;
198
+ if (type === 'code' && status === 'started')
199
+ return <p>Generating code {durationDisplay}</p>;
200
+ if (type === 'code' && status === 'running')
201
+ return <p>Executing code {durationDisplay}</p>;
202
+ if (type === 'code' && status === 'completed')
203
+ return <p>Code execution success {durationDisplay}</p>;
204
+ if (type === 'code' && status === 'failed')
205
+ return <p>Code execution failure {durationDisplay}</p>;
206
 
207
+ return null;
208
+ };
209
+ const ChunkPayloadAction: React.FC<{
210
+ payload: WIPChunkBodyGroup['payload'];
211
+ }> = ({ payload }) => {
212
+ if (!payload) return null;
213
+ if (Array.isArray(payload)) {
214
+ // [{title: 123, content, 345}, {title: ..., content: ...}] => ['title', 'content']
215
+ const keyArray = Array.from(
216
+ payload.reduce((acc, curr) => {
217
+ Object.keys(curr).forEach(key => acc.add(key));
218
+ return acc;
219
+ }, new Set<string>()),
220
+ );
221
+
222
+ return (
223
+ <Dialog>
224
+ <DialogTrigger asChild>
225
+ <Button variant="ghost" size="icon">
226
+ <IconListUnordered />
227
+ </Button>
228
+ </DialogTrigger>
229
+ <DialogContent
230
+ className="max-w-5xl"
231
+ onOpenAutoFocus={e => e.preventDefault()}
232
  >
233
+ <Table>
234
+ <TableHeader>
235
+ <TableRow className="border-primary/50">
236
+ {keyArray.map(header => (
237
+ <TableHead key={header}>{header}</TableHead>
238
+ ))}
239
+ </TableRow>
240
+ </TableHeader>
241
+ <TableBody>
242
+ {payload.map((line, index) => (
243
+ <TableRow className="border-primary/50" key={index}>
244
+ {keyArray.map(header =>
245
+ header === 'documentation' ? (
246
+ <TableCell key={header}>
247
+ <Dialog>
248
+ <DialogTrigger asChild>
249
+ <Button
250
+ variant="ghost"
251
+ size="icon"
252
+ className="size-8 ml-[40%]"
253
+ >
254
+ <IconTerminalWindow className="text-teal-500 size-4" />
255
+ </Button>
256
+ </DialogTrigger>
257
+ <DialogContent className="max-w-5xl">
258
+ <CodeBlock language="md" value={line[header]} />
259
+ </DialogContent>
260
+ </Dialog>
261
+ </TableCell>
262
+ ) : (
263
+ <TableCell key={header}>{line[header]}</TableCell>
264
+ ),
265
+ )}
266
+ </TableRow>
267
+ ))}
268
+ </TableBody>
269
+ </Table>
270
+ </DialogContent>
271
+ </Dialog>
272
+ );
273
+ } else if ((payload as PrismaJson.FinalCodeBody['payload']).code) {
274
+ return (
275
+ <Dialog>
276
+ <DialogTrigger asChild>
277
+ <Button variant="ghost" size="icon">
278
+ <IconCodeWrap />
279
+ </Button>
280
+ </DialogTrigger>
281
+ <DialogContent className="max-w-5xl">
282
+ <CodeResultDisplay
283
+ codeResult={payload as PrismaJson.FinalCodeBody['payload']}
284
+ />
285
+ </DialogContent>
286
+ </Dialog>
287
+ );
288
+ }
289
+ return null;
290
+ };
291
+
292
+ export default ChatMessage;
components/chat/ChatMessageActions.tsx CHANGED
@@ -6,10 +6,10 @@ import { Button } from '@/components/ui/Button';
6
  import { IconCheck, IconCopy } from '@/components/ui/Icons';
7
  import { useCopyToClipboard } from '@/lib/hooks/useCopyToClipboard';
8
  import { cn } from '@/lib/utils';
9
- import { MessageBase } from '../../lib/types';
10
 
11
  interface ChatMessageActionsProps extends React.ComponentProps<'div'> {
12
- message: MessageBase;
13
  }
14
 
15
  export function ChatMessageActions({
 
6
  import { IconCheck, IconCopy } from '@/components/ui/Icons';
7
  import { useCopyToClipboard } from '@/lib/hooks/useCopyToClipboard';
8
  import { cn } from '@/lib/utils';
9
+ import { MessageUI } from '../../lib/types';
10
 
11
  interface ChatMessageActionsProps extends React.ComponentProps<'div'> {
12
+ message: MessageUI;
13
  }
14
 
15
  export function ChatMessageActions({
components/chat/Composer.tsx CHANGED
@@ -1,11 +1,14 @@
1
  'use client';
2
 
3
- import * as React from 'react';
4
- import { type UseChatHelpers } from 'ai/react';
5
- import Textarea from 'react-textarea-autosize';
 
 
 
 
6
 
7
  import { Button } from '@/components/ui/Button';
8
- import { MessageBase } from '../../lib/types';
9
  import { useEnterSubmit } from '@/lib/hooks/useEnterSubmit';
10
  import Img from '../ui/Img';
11
  import {
@@ -13,185 +16,175 @@ import {
13
  TooltipContent,
14
  TooltipTrigger,
15
  } from '@/components/ui/Tooltip';
16
- import {
17
- IconArrowDown,
18
- IconArrowElbow,
19
- IconRefresh,
20
- IconStop,
21
- } from '@/components/ui/Icons';
22
  import { cn } from '@/lib/utils';
23
- import { generateInputImageMarkdown } from '@/lib/messageUtils';
 
 
24
 
25
- export interface ComposerProps
26
- extends Pick<
27
- UseChatHelpers,
28
- 'append' | 'isLoading' | 'reload' | 'stop' | 'input' | 'setInput'
29
- > {
30
- id?: string;
31
  title?: string;
32
- messages: MessageBase[];
33
- url?: string;
34
- isAtBottom: boolean;
35
- scrollToBottom: () => void;
36
  }
37
 
38
- export function Composer({
39
- id,
40
- title,
41
- isLoading,
42
- stop,
43
- append,
44
- reload,
45
- input,
46
- setInput,
47
- messages,
48
- isAtBottom,
49
- scrollToBottom,
50
- url,
51
- }: ComposerProps) {
52
- const { formRef, onKeyDown } = useEnterSubmit();
53
- const inputRef = React.useRef<HTMLTextAreaElement>(null);
54
- React.useEffect(() => {
55
- if (inputRef.current) {
56
- inputRef.current.focus();
57
- }
58
- }, []);
 
 
 
 
 
 
 
 
 
 
59
 
60
- return (
61
- // <div className="fixed inset-x-0 bottom-0 w-full animate-in duration-300 ease-in-out peer-[[data-state=open]]:group-[]:lg:pl-[250px] peer-[[data-state=open]]:group-[]:xl:pl-[300px] h-[178px]">
62
- <div className="mx-auto sm:max-w-3xl sm:px-4 h-full">
63
- <div className="px-4 py-2 space-y-4 border-t shadow-lg bg-background sm:rounded-t-xl sm:border md:py-4 h-full">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  <form
65
  onSubmit={async e => {
66
  e.preventDefault();
67
- if (!input?.trim()) {
68
  return;
69
  }
70
- setInput('');
71
- await append({
72
- id,
73
- content:
74
- input + (url ? '\n\n' + generateInputImageMarkdown(url) : ''),
75
- role: 'user',
76
- });
77
- scrollToBottom();
78
  }}
79
  ref={formRef}
80
- className="h-full"
81
  >
82
- <div className="relative flex px-8 pl-2 overflow-hidden size-full bg-background sm:rounded-md sm:border sm:px-12 sm:pl-2 items-start">
83
- {url && (
84
- <div className="w-1/5 p-2 h-full flex items-center justify-center relative">
85
- <Tooltip>
86
- <TooltipTrigger asChild>
87
- <Img
88
- src={url}
89
- className="cursor-zoom-in"
90
- alt="preview-image"
91
- />
92
- </TooltipTrigger>
93
- <TooltipContent>
94
- <Img
95
- src={url}
96
- className="m-2"
97
- quality={100}
98
- width={500}
99
- alt="zoomed-in-image"
100
- />
101
- </TooltipContent>
102
- </Tooltip>
103
- </div>
104
- )}
105
- <Textarea
106
- ref={inputRef}
107
- tabIndex={0}
108
- onKeyDown={onKeyDown}
109
- rows={1}
110
- value={input}
111
- disabled={isLoading}
112
- onChange={e => setInput(e.target.value)}
113
- placeholder={
114
- isLoading
115
- ? 'Vision Agent is thinking...'
116
- : 'Ask question about the image.'
117
- }
118
- spellCheck={false}
119
- className="min-h-[60px] w-4/5 resize-none bg-transparent px-4 py-[1.3em] focus-within:outline-none sm:text-sm"
120
- />
121
- {/* Scroll to bottom Icon */}
122
- <div
123
- className={cn(
124
- 'absolute top-3 right-4 transition-opacity duration-300',
125
- isAtBottom ? 'opacity-0' : 'opacity-100',
126
- )}
127
- >
128
- <Tooltip>
129
- <TooltipTrigger asChild>
130
- <Button
131
- variant="outline"
132
- size="icon"
133
- className="bg-background"
134
- onClick={() => scrollToBottom()}
135
- >
136
- <IconArrowDown />
137
- </Button>
138
- </TooltipTrigger>
139
- <TooltipContent>Scroll to bottom</TooltipContent>
140
- </Tooltip>
141
- </div>
142
- {/* Stop / Regenerate Icon */}
143
- <div className="absolute bottom-14 right-4">
144
- {isLoading ? (
145
- <Tooltip>
146
- <TooltipTrigger asChild>
147
- <Button
148
- variant="outline"
149
- size="icon"
150
- className="bg-background"
151
- onClick={() => stop()}
152
- >
153
- <IconStop />
154
- </Button>
155
- </TooltipTrigger>
156
- <TooltipContent>Stop generating</TooltipContent>
157
- </Tooltip>
158
- ) : (
159
- messages?.length >= 2 && (
160
- <Tooltip>
161
- <TooltipTrigger asChild>
162
- <Button
163
- variant="outline"
164
- size="icon"
165
- className="bg-background"
166
- onClick={() => reload()}
167
- >
168
- <IconRefresh />
169
- </Button>
170
- </TooltipTrigger>
171
- <TooltipContent>Regenerate response</TooltipContent>
172
- </Tooltip>
173
- )
174
- )}
175
- </div>
176
- {/* Submit Icon */}
177
- <div className="absolute bottom-3 right-4">
178
- <Tooltip>
179
- <TooltipTrigger asChild>
180
- <Button
181
- type="submit"
182
- size="icon"
183
- disabled={isLoading || input === ''}
184
- >
185
- <IconArrowElbow />
186
- </Button>
187
- </TooltipTrigger>
188
- <TooltipContent>Send message</TooltipContent>
189
- </Tooltip>
190
- </div>
191
- </div>
192
  </form>
193
  </div>
194
- </div>
195
- // </div>
196
- );
197
- }
 
 
 
 
1
  'use client';
2
 
3
+ import {
4
+ useState,
5
+ useEffect,
6
+ useRef,
7
+ forwardRef,
8
+ useImperativeHandle,
9
+ } from 'react';
10
 
11
  import { Button } from '@/components/ui/Button';
 
12
  import { useEnterSubmit } from '@/lib/hooks/useEnterSubmit';
13
  import Img from '../ui/Img';
14
  import {
 
16
  TooltipContent,
17
  TooltipTrigger,
18
  } from '@/components/ui/Tooltip';
19
+ import { IconImage, IconArrowUp, IconClose } from '@/components/ui/Icons';
 
 
 
 
 
20
  import { cn } from '@/lib/utils';
21
+ import Chip from '../ui/Chip';
22
+ import Textarea from 'react-textarea-autosize';
23
+ import useMediaUpload from '@/lib/hooks/useMediaUpload';
24
 
25
+ export interface ComposerProps {
26
+ onSubmit: (params: { input: string; mediaUrl: string }) => Promise<void>;
27
+ disabled?: boolean;
 
 
 
28
  title?: string;
29
+ initMediaUrl?: string;
30
+ initInput?: string;
 
 
31
  }
32
 
33
+ export interface ComposerRef {
34
+ setMediaUrl: (url: string) => void;
35
+ setInput: (input: string) => void;
36
+ }
37
+
38
+ const Composer = forwardRef<ComposerRef, ComposerProps>(
39
+ ({ disabled, onSubmit, initMediaUrl, initInput }, ref) => {
40
+ const { formRef, onKeyDown } = useEnterSubmit();
41
+ const inputRef = useRef<HTMLTextAreaElement>(null);
42
+ const [localMediaUrl, setLocalMediaUrl] = useState<string | undefined>(
43
+ initMediaUrl,
44
+ );
45
+ const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
46
+ const [input, setLocalInput] = useState(initInput ?? '');
47
+ const noMediaValidation = !localMediaUrl && !!input;
48
+ const {
49
+ getRootProps,
50
+ getInputProps,
51
+ isDragActive,
52
+ isUploading,
53
+ openUpload,
54
+ } = useMediaUpload(uploadUrl => setLocalMediaUrl(uploadUrl));
55
+
56
+ const finalLoading = isUploading || isSubmitting;
57
+ const finalDisabled = finalLoading || disabled;
58
+
59
+ useEffect(() => {
60
+ if (inputRef.current) {
61
+ inputRef.current.focus();
62
+ }
63
+ }, []);
64
 
65
+ useImperativeHandle(ref, () => ({
66
+ setMediaUrl(mediaUrl) {
67
+ setLocalMediaUrl(mediaUrl);
68
+ },
69
+ setInput(input) {
70
+ setLocalInput(input);
71
+ },
72
+ }));
73
+
74
+ const mediaName = localMediaUrl?.split('/').pop();
75
+ return (
76
+ <div
77
+ {...getRootProps()}
78
+ className={cn(
79
+ 'mx-auto w-[42rem] max-w-full px-6 py-4 bg-zinc-600 rounded-xl relative shadow-lg shadow-zinc-600/40 z-50',
80
+ isDragActive && 'bg-indigo-700/50',
81
+ )}
82
+ >
83
+ <input {...getInputProps()} />
84
+ <div
85
+ className={cn(
86
+ 'w-1/3 h-1 rounded-full overflow-hidden bg-zinc-700 absolute left-1/2 -translate-x-1/2 top-2',
87
+ finalLoading ? 'opacity-100' : 'opacity-0',
88
+ )}
89
+ >
90
+ <div className="h-full bg-primary animate-progress origin-left-right" />
91
+ </div>
92
+ {localMediaUrl ? (
93
+ <Chip className="mb-0.5">
94
+ <div className="flex flex-row items-center space-x-2">
95
+ <Tooltip>
96
+ <TooltipTrigger>
97
+ <div className="flex flex-row items-center space-x-2">
98
+ <IconImage className="size-3" />
99
+ <p>{mediaName ?? 'unnamed_media'}</p>
100
+ </div>
101
+ </TooltipTrigger>
102
+ <TooltipContent sideOffset={12} className="max-w-2xl">
103
+ <Img
104
+ src={localMediaUrl}
105
+ className="m-1"
106
+ quality={100}
107
+ alt="zoomed-in-image"
108
+ />
109
+ </TooltipContent>
110
+ </Tooltip>
111
+ <Button
112
+ size="icon"
113
+ variant="ghost"
114
+ disabled={finalDisabled}
115
+ className="size-4"
116
+ onClick={() => setLocalMediaUrl(undefined)}
117
+ >
118
+ <IconClose className="size-3" />
119
+ </Button>
120
+ </div>
121
+ </Chip>
122
+ ) : (
123
+ <Button
124
+ variant="ghost"
125
+ size="sm"
126
+ className={cn(
127
+ 'ml-[-10px] border border-transparent',
128
+ noMediaValidation && 'border-red-500/50 text-red-500',
129
+ )}
130
+ onClick={openUpload}
131
+ >
132
+ <IconImage className="mr-2 size-4" />
133
+ {noMediaValidation ? 'Select media (required)' : 'Select media'}
134
+ </Button>
135
+ )}
136
  <form
137
  onSubmit={async e => {
138
  e.preventDefault();
139
+ if (!input?.trim() || !localMediaUrl) {
140
  return;
141
  }
142
+ setIsSubmitting(true);
143
+ try {
144
+ await onSubmit({ input, mediaUrl: localMediaUrl });
145
+ } finally {
146
+ setIsSubmitting(false);
147
+ setLocalInput('');
148
+ }
 
149
  }}
150
  ref={formRef}
151
+ className="h-full mt-4"
152
  >
153
+ {/* <div className="border-gray-500 flex overflow-hidden size-full flex flex-row items-center"> */}
154
+ <Textarea
155
+ ref={inputRef}
156
+ tabIndex={0}
157
+ onKeyDown={onKeyDown}
158
+ rows={1}
159
+ value={input}
160
+ disabled={finalDisabled}
161
+ onChange={e => setLocalInput(e.target.value)}
162
+ placeholder={
163
+ finalDisabled ? '🤖 Agent working ✨' : 'Message Vision Agent'
164
+ }
165
+ spellCheck={false}
166
+ className="w-full grow resize-none bg-transparent focus-within:outline-none"
167
+ />
168
+ {/* Submit Icon */}
169
+ <Tooltip>
170
+ <TooltipTrigger asChild>
171
+ <Button
172
+ type="submit"
173
+ size="icon"
174
+ className={cn('size-6 absolute bottom-3 right-3')}
175
+ disabled={finalDisabled || input === '' || noMediaValidation}
176
+ >
177
+ <IconArrowUp className="size-3" />
178
+ </Button>
179
+ </TooltipTrigger>
180
+ <TooltipContent>Message Vision Agent</TooltipContent>
181
+ </Tooltip>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  </form>
183
  </div>
184
+ );
185
+ },
186
+ );
187
+
188
+ Composer.displayName = 'Composer';
189
+
190
+ export default Composer;
components/chat/ImageSelector.tsx DELETED
@@ -1,91 +0,0 @@
1
- 'use client';
2
-
3
- import React, { useState } from 'react';
4
- import useImageUpload from '../../lib/hooks/useImageUpload';
5
- import { cn, fetcher } from '@/lib/utils';
6
- import { SignedPayload, MessageBase, ChatEntity } from '@/lib/types';
7
- import { useRouter } from 'next/navigation';
8
- import Loading from '../ui/Loading';
9
- import toast from 'react-hot-toast';
10
-
11
- export interface ImageSelectorProps {}
12
-
13
- type Example = {
14
- url: string;
15
- initMessages: MessageBase[];
16
- };
17
-
18
- const ImageSelector: React.FC<ImageSelectorProps> = () => {
19
- const router = useRouter();
20
- const [isUploading, setUploading] = useState(false);
21
- const { getRootProps, getInputProps, isDragActive } = useImageUpload(
22
- undefined,
23
- async files => {
24
- const formData = new FormData();
25
- if (files.length !== 1) {
26
- throw new Error('Only one image can be uploaded at a time');
27
- }
28
- setUploading(true);
29
- const reader = new FileReader();
30
- reader.readAsDataURL(files[0]);
31
- reader.onload = async () => {
32
- const { id, signedUrl, publicUrl, fields } =
33
- await fetcher<SignedPayload>('/api/sign', {
34
- method: 'POST',
35
- body: JSON.stringify({
36
- fileType: files[0].type,
37
- fileName: files[0].name,
38
- }),
39
- });
40
- const formData = new FormData();
41
- Object.entries(fields).forEach(([key, value]) => {
42
- formData.append(key, value as string);
43
- });
44
- formData.append('file', files[0]);
45
-
46
- const uploadResponse = await fetch(signedUrl, {
47
- method: 'POST',
48
- body: formData,
49
- });
50
- if (!uploadResponse.ok) {
51
- toast.error(uploadResponse.statusText);
52
- return;
53
- }
54
- const resp = await fetcher<ChatEntity>('/api/upload', {
55
- method: 'POST',
56
- headers: {
57
- 'Content-Type': 'application/json',
58
- },
59
- body: JSON.stringify({
60
- id,
61
- url: publicUrl,
62
- }),
63
- });
64
- setUploading(false);
65
- if (resp) {
66
- router.push(`/chat/${resp.id}`);
67
- }
68
- };
69
- },
70
- );
71
- return (
72
- <div
73
- {...getRootProps()}
74
- className={cn(
75
- 'dropzone border-2 border-dashed border-gray-400 w-full h-64 flex items-center justify-center rounded-lg mt-4 cursor-pointer',
76
- isDragActive && 'bg-gray-500/50 border-solid',
77
- )}
78
- >
79
- <input {...getInputProps()} />
80
- <div className="text-gray-400 text-md">
81
- {isUploading ? (
82
- <Loading />
83
- ) : (
84
- 'Start using Vision Agent by selecting an image'
85
- )}
86
- </div>
87
- </div>
88
- );
89
- };
90
-
91
- export default ImageSelector;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
components/chat/MemoizedReactMarkdown.tsx CHANGED
@@ -1,5 +1,11 @@
1
  import { FC, memo } from 'react';
2
  import ReactMarkdown, { Options } from 'react-markdown';
 
 
 
 
 
 
3
 
4
  export const MemoizedReactMarkdown: FC<Options> = memo(
5
  ReactMarkdown,
@@ -7,3 +13,62 @@ export const MemoizedReactMarkdown: FC<Options> = memo(
7
  prevProps.children === nextProps.children &&
8
  prevProps.className === nextProps.className,
9
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import { FC, memo } from 'react';
2
  import ReactMarkdown, { Options } from 'react-markdown';
3
+ import Img from '../ui/Img';
4
+
5
+ import remarkGfm from 'remark-gfm';
6
+ import remarkMath from 'remark-math';
7
+ import rehypeRaw from 'rehype-raw';
8
+ import { CodeBlock } from '../ui/CodeBlock';
9
 
10
  export const MemoizedReactMarkdown: FC<Options> = memo(
11
  ReactMarkdown,
 
13
  prevProps.children === nextProps.children &&
14
  prevProps.className === nextProps.className,
15
  );
16
+
17
+ export const Markdown: React.FC<{
18
+ content: string;
19
+ }> = ({ content }) => {
20
+ return (
21
+ <>
22
+ <MemoizedReactMarkdown
23
+ className="break-words overflow-auto"
24
+ remarkPlugins={[remarkGfm, remarkMath]}
25
+ rehypePlugins={[rehypeRaw] as any}
26
+ components={{
27
+ p({ children, ...props }) {
28
+ if (
29
+ props.node.children.some(
30
+ child => child.type === 'element' && child.tagName === 'img',
31
+ )
32
+ ) {
33
+ return (
34
+ <p className="flex flex-wrap gap-2 items-start">{children}</p>
35
+ );
36
+ }
37
+ return <p className="mb-2 whitespace-pre-line">{children}</p>;
38
+ },
39
+ img(props) {
40
+ if (props.src?.endsWith('.mp4')) {
41
+ return (
42
+ <video src={props.src} controls width={500} height={500} />
43
+ );
44
+ }
45
+ return (
46
+ <Img
47
+ src={props.src ?? '/landing.png'}
48
+ alt={props.alt ?? 'answer-image'}
49
+ quality={100}
50
+ sizes="(min-width: 66em) 15vw,
51
+ (min-width: 44em) 20vw,
52
+ 100vw"
53
+ />
54
+ );
55
+ },
56
+ code({ node, inline, className, children, ...props }) {
57
+ const match = /language-(\w+)/.exec(className || '');
58
+
59
+ return (
60
+ <CodeBlock
61
+ key={Math.random()}
62
+ language={(match && match[1]) || ''}
63
+ value={String(children).replace(/\n$/, '')}
64
+ {...props}
65
+ />
66
+ );
67
+ },
68
+ }}
69
+ >
70
+ {content}
71
+ </MemoizedReactMarkdown>
72
+ </>
73
+ );
74
+ };