Spaces:
Sleeping
Sleeping
Commit
•
159e7fa
1
Parent(s):
9011efd
update
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- Dockerfile +8 -11
- README.md +72 -2
- app/api/auth/[...nextauth]/route.ts +1 -1
- app/api/chat/route.ts +5 -6
- app/api/sign/route.ts +3 -9
- app/api/upload/route.ts +0 -51
- app/api/vision-agent/route.ts +307 -44
- app/chat/[id]/page.tsx +15 -4
- app/chat/[id]/server.tsx +26 -0
- app/chat/layout.tsx +2 -23
- app/chat/page.tsx +4 -115
- app/globals.css +42 -91
- app/layout.tsx +9 -7
- app/page.tsx +79 -9
- app/project/[projectId]/page.tsx +0 -33
- app/project/layout.tsx +2 -2
- assets/svg/LandingAI_white.svg +71 -0
- auth.ts +39 -14
- chart/.helmignore +23 -0
- chart/Chart.yaml +24 -0
- chart/dev.values.yaml +5 -0
- chart/prod.values.yaml +10 -0
- chart/templates/NOTES.txt +4 -0
- chart/templates/_helpers.tpl +62 -0
- chart/templates/configmap.yaml +8 -0
- chart/templates/deployment.yaml +73 -0
- chart/templates/hpa.yaml +32 -0
- chart/templates/ingressroute.yaml +35 -0
- chart/templates/service.yaml +15 -0
- chart/templates/serviceaccount.yaml +13 -0
- chart/templates/tests/test-connection.yaml +15 -0
- chart/values.yaml +119 -0
- components.json +17 -0
- components/Avatar.tsx +34 -0
- components/ChatInterface.tsx +40 -0
- components/ChatSelect.tsx +80 -0
- components/ChatSelectServer.tsx +8 -0
- components/CodeResultDisplay.tsx +167 -0
- components/Header.tsx +67 -10
- components/Providers.tsx +3 -3
- components/UserMenu.tsx +7 -14
- components/chat-sidebar/ChatAdminToggle.tsx +0 -14
- components/chat-sidebar/ChatCard.tsx +0 -63
- components/chat-sidebar/ChatListSidebar.tsx +0 -27
- components/chat/ChatList.tsx +95 -19
- components/chat/ChatMessage.tsx +275 -134
- components/chat/ChatMessageActions.tsx +2 -2
- components/chat/Composer.tsx +163 -170
- components/chat/ImageSelector.tsx +0 -91
- 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 |
-
|
22 |
-
|
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 |
-
|
43 |
|
44 |
-
|
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
|
3 |
emoji: 🏃
|
4 |
colorFrom: yellow
|
5 |
colorTo: indigo
|
@@ -7,4 +7,74 @@ sdk: docker
|
|
7 |
pinned: false
|
8 |
---
|
9 |
|
10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 {
|
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:
|
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
|
29 |
-
|
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 {
|
5 |
-
|
6 |
-
import {
|
7 |
-
import {
|
|
|
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 |
-
|
16 |
json: {
|
17 |
-
|
18 |
id: string;
|
19 |
-
|
20 |
},
|
|
|
21 |
) => {
|
22 |
-
const {
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
// return new Response('Unauthorized', {
|
27 |
-
// status: 401,
|
28 |
-
// });
|
29 |
-
// }
|
30 |
|
31 |
const formData = new FormData();
|
32 |
-
formData.append(
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
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 |
-
|
58 |
-
//
|
|
|
59 |
{
|
60 |
method: 'POST',
|
61 |
headers: {
|
62 |
-
apikey
|
|
|
|
|
|
|
|
|
63 |
},
|
64 |
body: formData,
|
65 |
},
|
66 |
);
|
67 |
|
68 |
-
if (fetchResponse.body) {
|
69 |
-
|
70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 {
|
2 |
-
import
|
|
|
|
|
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 |
-
|
13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
-
|
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 |
-
|
2 |
|
3 |
-
|
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 |
-
|
11 |
-
|
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 |
-
--
|
20 |
-
--
|
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 |
-
--
|
|
|
|
|
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 |
-
--
|
53 |
-
--
|
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
|
66 |
|
67 |
-
--
|
|
|
|
|
68 |
}
|
69 |
}
|
70 |
|
@@ -77,83 +75,36 @@
|
|
77 |
}
|
78 |
}
|
79 |
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
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 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
margin-top: 0;
|
137 |
-
margin-bottom: 16px;
|
138 |
-
width: max-content;
|
139 |
-
max-width: 100%;
|
140 |
-
overflow: auto;
|
141 |
}
|
142 |
|
143 |
-
|
144 |
-
|
145 |
}
|
146 |
|
147 |
-
|
148 |
-
|
149 |
-
|
150 |
-
border: 1px solid var(--color-border-default);
|
151 |
}
|
152 |
|
153 |
-
|
154 |
-
|
155 |
-
|
|
|
|
|
156 |
|
157 |
-
|
158 |
-
|
|
|
|
|
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: '/
|
21 |
-
shortcut: '/
|
22 |
-
apple: '/
|
23 |
},
|
24 |
};
|
25 |
|
@@ -34,7 +33,8 @@ interface RootLayoutProps {
|
|
34 |
children: React.ReactNode;
|
35 |
}
|
36 |
|
37 |
-
export default function RootLayout(
|
|
|
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 |
-
|
56 |
-
<main className="flex
|
|
|
|
|
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 |
-
|
2 |
-
import { redirect } from 'next/navigation';
|
3 |
|
4 |
-
|
5 |
-
redirect('/chat');
|
6 |
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
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 {
|
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
|
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 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
43 |
-
|
44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
45 |
}
|
46 |
return session;
|
47 |
},
|
@@ -59,8 +79,13 @@ export const {
|
|
59 |
},
|
60 |
});
|
61 |
|
62 |
-
export async function
|
63 |
const session = await auth();
|
64 |
-
const email = session?.user
|
65 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
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
|
2 |
import Link from 'next/link';
|
3 |
|
4 |
-
import { 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 {
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
19 |
return (
|
20 |
-
<header className="sticky top-0 z-50 flex items-center justify-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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="/
|
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
|
|
|
|
|
|
|
|
|
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 {
|
4 |
-
import
|
5 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
6 |
|
7 |
-
export interface
|
8 |
-
|
|
|
9 |
}
|
10 |
|
11 |
-
export
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
12 |
return (
|
13 |
-
<
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
-
|
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 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
18 |
import Img from '../ui/Img';
|
19 |
-
import
|
|
|
|
|
|
|
|
|
|
|
20 |
|
21 |
export interface ChatMessageProps {
|
22 |
-
message:
|
|
|
|
|
23 |
}
|
24 |
|
25 |
-
export
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
32 |
return (
|
33 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
34 |
<div
|
35 |
className={cn(
|
36 |
-
'
|
37 |
-
|
38 |
-
? 'bg-background'
|
39 |
-
: 'bg-primary text-primary-foreground',
|
40 |
)}
|
41 |
>
|
42 |
-
|
43 |
</div>
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
)
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
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 |
-
|
123 |
-
|
|
|
124 |
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
|
|
|
|
133 |
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
142 |
-
|
143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
144 |
>
|
145 |
-
|
146 |
-
|
147 |
-
|
148 |
-
|
149 |
-
|
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 {
|
10 |
|
11 |
interface ChatMessageActionsProps extends React.ComponentProps<'div'> {
|
12 |
-
message:
|
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
|
4 |
-
|
5 |
-
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
24 |
|
25 |
-
export interface ComposerProps
|
26 |
-
|
27 |
-
|
28 |
-
'append' | 'isLoading' | 'reload' | 'stop' | 'input' | 'setInput'
|
29 |
-
> {
|
30 |
-
id?: string;
|
31 |
title?: string;
|
32 |
-
|
33 |
-
|
34 |
-
isAtBottom: boolean;
|
35 |
-
scrollToBottom: () => void;
|
36 |
}
|
37 |
|
38 |
-
export
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
59 |
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
64 |
<form
|
65 |
onSubmit={async e => {
|
66 |
e.preventDefault();
|
67 |
-
if (!input?.trim()) {
|
68 |
return;
|
69 |
}
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
}
|
77 |
-
scrollToBottom();
|
78 |
}}
|
79 |
ref={formRef}
|
80 |
-
className="h-full"
|
81 |
>
|
82 |
-
<div className="
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
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 |
-
|
195 |
-
|
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 |
+
};
|