Spaces:
Runtime error
Runtime error
Synced repo using 'sync_with_huggingface' Github Action
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .dockerignore +9 -0
- .eslintrc.json +3 -0
- .example.env +1 -0
- .gitattributes +1 -0
- Dockerfile +50 -0
- LICENSE +21 -0
- Makefile +2 -0
- components.json +17 -0
- next.config.mjs +8 -0
- ollama-nextjs-ui.gif +3 -0
- package.json +49 -0
- postcss.config.js +6 -0
- public/next.svg +1 -0
- public/ollama.png +0 -0
- public/vercel.svg +1 -0
- src/app/api/chat/route.ts +222 -0
- src/app/api/health/route.ts +5 -0
- src/app/api/models/route.ts +55 -0
- src/app/chats/[id]/page.tsx +15 -0
- src/app/favicon.ico +0 -0
- src/app/globals.css +95 -0
- src/app/layout.tsx +36 -0
- src/app/page.tsx +9 -0
- src/components/chat/chat-bottombar.tsx +100 -0
- src/components/chat/chat-layout.tsx +83 -0
- src/components/chat/chat-list.tsx +136 -0
- src/components/chat/chat-options.ts +5 -0
- src/components/chat/chat-page.tsx +110 -0
- src/components/chat/chat-topbar.tsx +175 -0
- src/components/chat/chat.tsx +70 -0
- src/components/code-display-block.tsx +56 -0
- src/components/settings-clear-chats.tsx +70 -0
- src/components/settings-theme-toggle.tsx +36 -0
- src/components/settings.tsx +71 -0
- src/components/sidebar-skeleton.tsx +33 -0
- src/components/sidebar-tabs.tsx +167 -0
- src/components/sidebar.tsx +163 -0
- src/components/system-prompt.tsx +55 -0
- src/components/ui/button.tsx +59 -0
- src/components/ui/card.tsx +76 -0
- src/components/ui/dialog.tsx +115 -0
- src/components/ui/input.tsx +25 -0
- src/components/ui/sheet.tsx +138 -0
- src/components/ui/skeleton.tsx +15 -0
- src/components/ui/sonner.tsx +31 -0
- src/components/ui/tabs.tsx +31 -0
- src/components/ui/textarea.tsx +24 -0
- src/components/ui/tooltip.tsx +31 -0
- src/lib/mistral-tokenizer-js.d.ts +3 -0
- src/lib/token-counter.ts +21 -0
.dockerignore
ADDED
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.next/
|
2 |
+
node_modules/
|
3 |
+
.env
|
4 |
+
next-env.d.ts
|
5 |
+
Dockerfile
|
6 |
+
.dockerignore
|
7 |
+
Makefile
|
8 |
+
.github
|
9 |
+
.git
|
.eslintrc.json
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"extends": "next/core-web-vitals"
|
3 |
+
}
|
.example.env
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
VLLM_URL="http://localhost:8000"
|
.gitattributes
CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
36 |
+
ollama-nextjs-ui.gif filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
ADDED
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
ARG NODE_VERSION=lts
|
2 |
+
# Install dependencies only when needed
|
3 |
+
FROM node:$NODE_VERSION-alpine AS builder
|
4 |
+
ENV YARN_CACHE_FOLDER=/opt/yarncache
|
5 |
+
WORKDIR /opt/app
|
6 |
+
COPY package.json yarn.lock ./
|
7 |
+
RUN yarn install --frozen-lockfile
|
8 |
+
# patch logging for requestHandler
|
9 |
+
RUN sed -Ei \
|
10 |
+
-e '/await requestHandler/iconst __start = new Date;' \
|
11 |
+
-e '/await requestHandler/aconsole.log(`[${__start.toISOString()}] ${((new Date - __start) / 1000).toFixed(3)} ${req.method} ${req.url}`);' \
|
12 |
+
node_modules/next/dist/server/lib/start-server.js
|
13 |
+
|
14 |
+
ENV NODE_ENV=production
|
15 |
+
ENV NEXT_TELEMETRY_DISABLED=1
|
16 |
+
ENV VLLM_URL="http://localhost:8000"
|
17 |
+
ENV VLLM_API_KEY=""
|
18 |
+
|
19 |
+
COPY . .
|
20 |
+
RUN yarn build
|
21 |
+
|
22 |
+
# Production image, copy all the files and run next
|
23 |
+
FROM node:$NODE_VERSION-alpine AS runner
|
24 |
+
WORKDIR /opt/app
|
25 |
+
|
26 |
+
ENV NODE_ENV production
|
27 |
+
ENV NEXT_TELEMETRY_DISABLED 1
|
28 |
+
|
29 |
+
RUN addgroup --system --gid 1001 nodejs
|
30 |
+
RUN adduser --system --uid 1001 nextjs
|
31 |
+
|
32 |
+
COPY --from=builder /opt/app/public ./public
|
33 |
+
|
34 |
+
# Set the correct permission for prerender cache
|
35 |
+
RUN mkdir .next
|
36 |
+
RUN chown nextjs:nodejs .next
|
37 |
+
|
38 |
+
# Automatically leverage output traces to reduce image size
|
39 |
+
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
40 |
+
COPY --from=builder --chown=nextjs:nodejs /opt/app/.next/standalone ./
|
41 |
+
COPY --from=builder --chown=nextjs:nodejs /opt/app/.next/static ./.next/static
|
42 |
+
|
43 |
+
USER nextjs
|
44 |
+
|
45 |
+
EXPOSE 3000
|
46 |
+
|
47 |
+
ENV PORT 3000
|
48 |
+
# set hostname to localhost
|
49 |
+
ENV HOSTNAME "0.0.0.0"
|
50 |
+
CMD ["node", "server.js"]
|
LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
MIT License
|
2 |
+
|
3 |
+
Copyright (c) 2024 Jakob Hoeg Mørk
|
4 |
+
|
5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6 |
+
of this software and associated documentation files (the "Software"), to deal
|
7 |
+
in the Software without restriction, including without limitation the rights
|
8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9 |
+
copies of the Software, and to permit persons to whom the Software is
|
10 |
+
furnished to do so, subject to the following conditions:
|
11 |
+
|
12 |
+
The above copyright notice and this permission notice shall be included in all
|
13 |
+
copies or substantial portions of the Software.
|
14 |
+
|
15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21 |
+
SOFTWARE.
|
Makefile
ADDED
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
1 |
+
build:
|
2 |
+
docker buildx build --platform linux/amd64,linux/arm64 . -t yoziru/nextjs-vllm-ui
|
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": "src/app/globals.css",
|
9 |
+
"baseColor": "zinc",
|
10 |
+
"cssVariables": true,
|
11 |
+
"prefix": ""
|
12 |
+
},
|
13 |
+
"aliases": {
|
14 |
+
"components": "@/components",
|
15 |
+
"utils": "@/lib/utils"
|
16 |
+
}
|
17 |
+
}
|
next.config.mjs
ADDED
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/** @type {import('next').NextConfig} */
|
2 |
+
const nextConfig = {
|
3 |
+
// set basepath based on environment
|
4 |
+
basePath: process.env.NEXT_PUBLIC_BASE_PATH ?? "",
|
5 |
+
output: "standalone",
|
6 |
+
};
|
7 |
+
|
8 |
+
export default nextConfig;
|
ollama-nextjs-ui.gif
ADDED
Git LFS Details
|
package.json
ADDED
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"name": "nextjs-vllm-ui",
|
3 |
+
"version": "0.1.0",
|
4 |
+
"private": true,
|
5 |
+
"scripts": {
|
6 |
+
"dev": "next dev",
|
7 |
+
"build": "next build",
|
8 |
+
"start": "next start",
|
9 |
+
"lint": "next lint"
|
10 |
+
},
|
11 |
+
"dependencies": {
|
12 |
+
"@hookform/resolvers": "^3.3.4",
|
13 |
+
"@radix-ui/react-dialog": "^1.0.5",
|
14 |
+
"@radix-ui/react-icons": "^1.3.0",
|
15 |
+
"@radix-ui/react-slot": "^1.0.2",
|
16 |
+
"@radix-ui/react-tabs": "^1.0.4",
|
17 |
+
"@radix-ui/react-tooltip": "^1.0.7",
|
18 |
+
"ai": "^3.0.15",
|
19 |
+
"class-variance-authority": "^0.7.0",
|
20 |
+
"clsx": "^2.1.0",
|
21 |
+
"mistral-tokenizer-js": "^1.0.0",
|
22 |
+
"next": "14.1.4",
|
23 |
+
"next-themes": "^0.3.0",
|
24 |
+
"openai": "^4.30.0",
|
25 |
+
"react": "^18",
|
26 |
+
"react-code-blocks": "^0.1.6",
|
27 |
+
"react-dom": "^18",
|
28 |
+
"react-hook-form": "^7.51.2",
|
29 |
+
"react-textarea-autosize": "^8.5.3",
|
30 |
+
"sonner": "^1.4.41",
|
31 |
+
"tailwind-merge": "^2.2.2",
|
32 |
+
"use-debounce": "^10.0.0",
|
33 |
+
"use-local-storage-state": "^19.2.0",
|
34 |
+
"uuid": "^9.0.1"
|
35 |
+
},
|
36 |
+
"devDependencies": {
|
37 |
+
"@types/node": "^20.11.30",
|
38 |
+
"@types/react": "^18.2.73",
|
39 |
+
"@types/react-dom": "^18.2.23",
|
40 |
+
"@types/uuid": "^9.0.8",
|
41 |
+
"autoprefixer": "^10.4.19",
|
42 |
+
"eslint": "^8.57.0",
|
43 |
+
"eslint-config-next": "14.1.4",
|
44 |
+
"postcss": "^8.4.38",
|
45 |
+
"tailwindcss": "^3.4.3",
|
46 |
+
"typescript": "^5.4.3",
|
47 |
+
"typescript-eslint": "^7.4.0"
|
48 |
+
}
|
49 |
+
}
|
postcss.config.js
ADDED
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
module.exports = {
|
2 |
+
plugins: {
|
3 |
+
tailwindcss: {},
|
4 |
+
autoprefixer: {},
|
5 |
+
},
|
6 |
+
};
|
public/next.svg
ADDED
public/ollama.png
ADDED
public/vercel.svg
ADDED
src/app/api/chat/route.ts
ADDED
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {
|
2 |
+
createParser,
|
3 |
+
ParsedEvent,
|
4 |
+
ReconnectInterval,
|
5 |
+
} from "eventsource-parser";
|
6 |
+
import { NextRequest, NextResponse } from "next/server";
|
7 |
+
import {
|
8 |
+
ChatCompletionAssistantMessageParam,
|
9 |
+
ChatCompletionCreateParamsStreaming,
|
10 |
+
ChatCompletionMessageParam,
|
11 |
+
ChatCompletionSystemMessageParam,
|
12 |
+
ChatCompletionUserMessageParam,
|
13 |
+
} from "openai/resources/index.mjs";
|
14 |
+
|
15 |
+
import { encodeChat, tokenLimit } from "@/lib/token-counter";
|
16 |
+
|
17 |
+
const addSystemMessage = (
|
18 |
+
messages: ChatCompletionMessageParam[],
|
19 |
+
systemPrompt?: string
|
20 |
+
) => {
|
21 |
+
// early exit if system prompt is empty
|
22 |
+
if (!systemPrompt || systemPrompt === "") {
|
23 |
+
return messages;
|
24 |
+
}
|
25 |
+
|
26 |
+
// add system prompt to the chat (if it's not already there)
|
27 |
+
// check first message in the chat
|
28 |
+
if (!messages) {
|
29 |
+
// if there are no messages, add the system prompt as the first message
|
30 |
+
messages = [
|
31 |
+
{
|
32 |
+
content: systemPrompt,
|
33 |
+
role: "system",
|
34 |
+
},
|
35 |
+
];
|
36 |
+
} else if (messages.length === 0) {
|
37 |
+
// if there are no messages, add the system prompt as the first message
|
38 |
+
messages.push({
|
39 |
+
content: systemPrompt,
|
40 |
+
role: "system",
|
41 |
+
});
|
42 |
+
} else {
|
43 |
+
// if there are messages, check if the first message is a system prompt
|
44 |
+
if (messages[0].role === "system") {
|
45 |
+
// if the first message is a system prompt, update it
|
46 |
+
messages[0].content = systemPrompt;
|
47 |
+
} else {
|
48 |
+
// if the first message is not a system prompt, add the system prompt as the first message
|
49 |
+
messages.unshift({
|
50 |
+
content: systemPrompt,
|
51 |
+
role: "system",
|
52 |
+
});
|
53 |
+
}
|
54 |
+
}
|
55 |
+
return messages;
|
56 |
+
};
|
57 |
+
|
58 |
+
const formatMessages = (
|
59 |
+
messages: ChatCompletionMessageParam[]
|
60 |
+
): ChatCompletionMessageParam[] => {
|
61 |
+
let mappedMessages: ChatCompletionMessageParam[] = [];
|
62 |
+
let messagesTokenCounts: number[] = [];
|
63 |
+
const responseTokens = 512;
|
64 |
+
const tokenLimitRemaining = tokenLimit - responseTokens;
|
65 |
+
let tokenCount = 0;
|
66 |
+
|
67 |
+
messages.forEach((m) => {
|
68 |
+
if (m.role === "system") {
|
69 |
+
mappedMessages.push({
|
70 |
+
role: "system",
|
71 |
+
content: m.content,
|
72 |
+
} as ChatCompletionSystemMessageParam);
|
73 |
+
} else if (m.role === "user") {
|
74 |
+
mappedMessages.push({
|
75 |
+
role: "user",
|
76 |
+
content: m.content,
|
77 |
+
} as ChatCompletionUserMessageParam);
|
78 |
+
} else if (m.role === "assistant") {
|
79 |
+
mappedMessages.push({
|
80 |
+
role: "assistant",
|
81 |
+
content: m.content,
|
82 |
+
} as ChatCompletionAssistantMessageParam);
|
83 |
+
} else {
|
84 |
+
return;
|
85 |
+
}
|
86 |
+
|
87 |
+
// ignore typing
|
88 |
+
// tslint:disable-next-line
|
89 |
+
const messageTokens = encodeChat([m]);
|
90 |
+
messagesTokenCounts.push(messageTokens);
|
91 |
+
tokenCount += messageTokens;
|
92 |
+
});
|
93 |
+
|
94 |
+
if (tokenCount <= tokenLimitRemaining) {
|
95 |
+
return mappedMessages;
|
96 |
+
}
|
97 |
+
|
98 |
+
// remove the middle messages until the token count is below the limit
|
99 |
+
while (tokenCount > tokenLimitRemaining) {
|
100 |
+
const middleMessageIndex = Math.floor(messages.length / 2);
|
101 |
+
const middleMessageTokens = messagesTokenCounts[middleMessageIndex];
|
102 |
+
mappedMessages.splice(middleMessageIndex, 1);
|
103 |
+
messagesTokenCounts.splice(middleMessageIndex, 1);
|
104 |
+
tokenCount -= middleMessageTokens;
|
105 |
+
}
|
106 |
+
return mappedMessages;
|
107 |
+
};
|
108 |
+
|
109 |
+
export async function POST(req: NextRequest): Promise<NextResponse> {
|
110 |
+
try {
|
111 |
+
const { messages, chatOptions } = await req.json();
|
112 |
+
if (!chatOptions.selectedModel || chatOptions.selectedModel === "") {
|
113 |
+
throw new Error("Selected model is required");
|
114 |
+
}
|
115 |
+
|
116 |
+
const baseUrl = process.env.VLLM_URL;
|
117 |
+
if (!baseUrl) {
|
118 |
+
throw new Error("VLLM_URL is not set");
|
119 |
+
}
|
120 |
+
const apiKey = process.env.VLLM_API_KEY;
|
121 |
+
|
122 |
+
const formattedMessages = formatMessages(
|
123 |
+
addSystemMessage(messages, chatOptions.systemPrompt)
|
124 |
+
);
|
125 |
+
|
126 |
+
const stream = await getOpenAIStream(
|
127 |
+
baseUrl,
|
128 |
+
chatOptions.selectedModel,
|
129 |
+
formattedMessages,
|
130 |
+
chatOptions.temperature,
|
131 |
+
apiKey,
|
132 |
+
);
|
133 |
+
return new NextResponse(stream, {
|
134 |
+
headers: { "Content-Type": "text/event-stream" },
|
135 |
+
});
|
136 |
+
} catch (error) {
|
137 |
+
console.error(error);
|
138 |
+
return NextResponse.json(
|
139 |
+
{
|
140 |
+
success: false,
|
141 |
+
error: error instanceof Error ? error.message : "Unknown error",
|
142 |
+
},
|
143 |
+
{ status: 500 }
|
144 |
+
);
|
145 |
+
}
|
146 |
+
}
|
147 |
+
|
148 |
+
const getOpenAIStream = async (
|
149 |
+
apiUrl: string,
|
150 |
+
model: string,
|
151 |
+
messages: ChatCompletionMessageParam[],
|
152 |
+
temperature?: number,
|
153 |
+
apiKey?: string
|
154 |
+
): Promise<ReadableStream<Uint8Array>> => {
|
155 |
+
const encoder = new TextEncoder();
|
156 |
+
const decoder = new TextDecoder();
|
157 |
+
const headers = new Headers();
|
158 |
+
headers.set("Content-Type", "application/json");
|
159 |
+
if (apiKey !== undefined) {
|
160 |
+
headers.set("Authorization", `Bearer ${apiKey}`);
|
161 |
+
headers.set("api-key", apiKey);
|
162 |
+
}
|
163 |
+
const chatOptions: ChatCompletionCreateParamsStreaming = {
|
164 |
+
model: model,
|
165 |
+
// frequency_penalty: 0,
|
166 |
+
// max_tokens: 2000,
|
167 |
+
messages: messages,
|
168 |
+
// presence_penalty: 0,
|
169 |
+
stream: true,
|
170 |
+
temperature: temperature ?? 0.5,
|
171 |
+
// response_format: {
|
172 |
+
// type: "json_object",
|
173 |
+
// }
|
174 |
+
// top_p: 0.95,
|
175 |
+
};
|
176 |
+
const res = await fetch(apiUrl + "/v1/chat/completions", {
|
177 |
+
headers: headers,
|
178 |
+
method: "POST",
|
179 |
+
body: JSON.stringify(chatOptions),
|
180 |
+
});
|
181 |
+
|
182 |
+
if (res.status !== 200) {
|
183 |
+
const statusText = res.statusText;
|
184 |
+
const responseBody = await res.text();
|
185 |
+
console.error(`vLLM API response error: ${responseBody}`);
|
186 |
+
throw new Error(
|
187 |
+
`The vLLM API has encountered an error with a status code of ${res.status} ${statusText}: ${responseBody}`
|
188 |
+
);
|
189 |
+
}
|
190 |
+
|
191 |
+
return new ReadableStream({
|
192 |
+
async start(controller) {
|
193 |
+
const onParse = (event: ParsedEvent | ReconnectInterval) => {
|
194 |
+
if (event.type === "event") {
|
195 |
+
const data = event.data;
|
196 |
+
|
197 |
+
if (data === "[DONE]") {
|
198 |
+
controller.close();
|
199 |
+
return;
|
200 |
+
}
|
201 |
+
|
202 |
+
try {
|
203 |
+
const json = JSON.parse(data);
|
204 |
+
const text = json.choices[0].delta.content;
|
205 |
+
const queue = encoder.encode(text);
|
206 |
+
controller.enqueue(queue);
|
207 |
+
} catch (e) {
|
208 |
+
controller.error(e);
|
209 |
+
}
|
210 |
+
}
|
211 |
+
};
|
212 |
+
|
213 |
+
const parser = createParser(onParse);
|
214 |
+
|
215 |
+
for await (const chunk of res.body as any) {
|
216 |
+
// An extra newline is required to make AzureOpenAI work.
|
217 |
+
const str = decoder.decode(chunk).replace("[DONE]\n", "[DONE]\n\n");
|
218 |
+
parser.feed(str);
|
219 |
+
}
|
220 |
+
},
|
221 |
+
});
|
222 |
+
};
|
src/app/api/health/route.ts
ADDED
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { NextRequest, NextResponse } from "next/server";
|
2 |
+
|
3 |
+
export async function GET(req: NextRequest): Promise<NextResponse> {
|
4 |
+
return new NextResponse("OK", { status: 200 });
|
5 |
+
}
|
src/app/api/models/route.ts
ADDED
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { NextRequest, NextResponse } from "next/server";
|
2 |
+
|
3 |
+
export async function GET(req: NextRequest): Promise<NextResponse> {
|
4 |
+
const baseUrl = process.env.VLLM_URL;
|
5 |
+
const apiKey = process.env.VLLM_API_KEY;
|
6 |
+
const headers = new Headers();
|
7 |
+
if (apiKey !== undefined) {
|
8 |
+
headers.set("Authorization", `Bearer ${apiKey}`);
|
9 |
+
headers.set("api-key", apiKey);
|
10 |
+
}
|
11 |
+
if (!baseUrl) {
|
12 |
+
throw new Error("VLLM_URL is not set");
|
13 |
+
}
|
14 |
+
|
15 |
+
const envModel = process.env.VLLM_MODEL;
|
16 |
+
if (envModel) {
|
17 |
+
return NextResponse.json({
|
18 |
+
object: "list",
|
19 |
+
data: [
|
20 |
+
{
|
21 |
+
id: envModel,
|
22 |
+
},
|
23 |
+
],
|
24 |
+
});
|
25 |
+
}
|
26 |
+
|
27 |
+
try {
|
28 |
+
const res = await fetch(`${baseUrl}/v1/models`, {
|
29 |
+
headers: headers,
|
30 |
+
cache: "no-store",
|
31 |
+
});
|
32 |
+
if (res.status !== 200) {
|
33 |
+
const statusText = res.statusText;
|
34 |
+
const responseBody = await res.text();
|
35 |
+
console.error(`vLLM /api/models response error: ${responseBody}`);
|
36 |
+
return NextResponse.json(
|
37 |
+
{
|
38 |
+
success: false,
|
39 |
+
error: statusText,
|
40 |
+
},
|
41 |
+
{ status: res.status }
|
42 |
+
);
|
43 |
+
}
|
44 |
+
return new NextResponse(res.body, res);
|
45 |
+
} catch (error) {
|
46 |
+
console.error(error);
|
47 |
+
return NextResponse.json(
|
48 |
+
{
|
49 |
+
success: false,
|
50 |
+
error: error instanceof Error ? error.message : "Unknown error",
|
51 |
+
},
|
52 |
+
{ status: 500 }
|
53 |
+
);
|
54 |
+
}
|
55 |
+
}
|
src/app/chats/[id]/page.tsx
ADDED
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import React from "react";
|
4 |
+
|
5 |
+
import ChatPage from "@/components/chat/chat-page";
|
6 |
+
|
7 |
+
export default function Page({ params }: { params: { id: string } }) {
|
8 |
+
const [chatId, setChatId] = React.useState<string>("");
|
9 |
+
React.useEffect(() => {
|
10 |
+
if (params.id) {
|
11 |
+
setChatId(params.id);
|
12 |
+
}
|
13 |
+
}, [params.id]);
|
14 |
+
return <ChatPage chatId={chatId} setChatId={setChatId} />;
|
15 |
+
}
|
src/app/favicon.ico
ADDED
src/app/globals.css
ADDED
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
@tailwind base;
|
2 |
+
@tailwind components;
|
3 |
+
@tailwind utilities;
|
4 |
+
|
5 |
+
|
6 |
+
@layer base {
|
7 |
+
:root {
|
8 |
+
--background: 0 0% 100%;
|
9 |
+
--foreground: 240 10% 3.9%;
|
10 |
+
--card: 0 0% 100%;
|
11 |
+
--card-foreground: 240 10% 3.9%;
|
12 |
+
--popover: 0 0% 100%;
|
13 |
+
--popover-foreground: 240 10% 3.9%;
|
14 |
+
--primary: 240 5.9% 10%;
|
15 |
+
--primary-foreground: 0 0% 98%;
|
16 |
+
--secondary: 240 4.8% 95.9%;
|
17 |
+
--secondary-foreground: 240 5.9% 10%;
|
18 |
+
--muted: 240 4.8% 95.9%;
|
19 |
+
--muted-foreground: 240 3.8% 46.1%;
|
20 |
+
--accent: 240 4.8% 95.9%;
|
21 |
+
--accent-foreground: 240 5.9% 10%;
|
22 |
+
--destructive: 0 84.2% 60.2%;
|
23 |
+
--destructive-foreground: 0 0% 98%;
|
24 |
+
--border: 240 5.9% 90%;
|
25 |
+
--input: 240 5.9% 90%;
|
26 |
+
--ring: 240 5.9% 10%;
|
27 |
+
--radius: 1rem;
|
28 |
+
}
|
29 |
+
|
30 |
+
.dark {
|
31 |
+
--background: 0 0% 9%;
|
32 |
+
--foreground: 0 0% 98%;
|
33 |
+
--card: 0 0% 12%;
|
34 |
+
--card-foreground: 0 0% 98%;
|
35 |
+
--popover: 0 0% 12%;
|
36 |
+
--popover-foreground: 0 0% 98%;
|
37 |
+
--primary: 0 0% 98%;
|
38 |
+
--primary-foreground: 240 5.9% 10%;
|
39 |
+
--secondary: 240 3.7% 15.9%;
|
40 |
+
--secondary-foreground: 0 0% 98%;
|
41 |
+
--muted: 240 3.7% 15.9%;
|
42 |
+
--muted-foreground: 240 5% 64.9%;
|
43 |
+
--accent: 240 3.7% 15.9%;
|
44 |
+
--accent-foreground: 0 0% 98%;
|
45 |
+
--destructive: 0 62.8% 30.6%;
|
46 |
+
--destructive-foreground: 0 0% 98%;
|
47 |
+
--border: 240 3.7% 15.9%;
|
48 |
+
--input: 240 3.7% 15.9%;
|
49 |
+
--ring: 240 4.9% 83.9%;
|
50 |
+
}
|
51 |
+
}
|
52 |
+
|
53 |
+
@layer base {
|
54 |
+
* {
|
55 |
+
@apply border-border;
|
56 |
+
}
|
57 |
+
body {
|
58 |
+
@apply bg-background text-foreground;
|
59 |
+
}
|
60 |
+
}
|
61 |
+
|
62 |
+
#scroller * {
|
63 |
+
overflow-anchor: none;
|
64 |
+
}
|
65 |
+
|
66 |
+
#anchor {
|
67 |
+
overflow-anchor: auto;
|
68 |
+
height: 1px;
|
69 |
+
}
|
70 |
+
|
71 |
+
:root {
|
72 |
+
--scrollbar-thumb-color: #ccc;
|
73 |
+
--scrollbar-thumb-hover-color: #aaa;
|
74 |
+
}
|
75 |
+
|
76 |
+
::-webkit-scrollbar {
|
77 |
+
width: 6px;
|
78 |
+
height: 6px;
|
79 |
+
}
|
80 |
+
|
81 |
+
::-webkit-scrollbar-thumb {
|
82 |
+
background-color: var(--scrollbar-thumb-color);
|
83 |
+
border-radius: 999px;
|
84 |
+
transition: width 0.3s, height 0.3s, visibility 0.3s;
|
85 |
+
}
|
86 |
+
|
87 |
+
::-webkit-scrollbar-thumb:hover {
|
88 |
+
background-color: var(--scrollbar-thumb-hover-color);
|
89 |
+
}
|
90 |
+
|
91 |
+
::-webkit-scrollbar-thumb:not(:hover) {
|
92 |
+
width: 0;
|
93 |
+
height: 0;
|
94 |
+
visibility: hidden;
|
95 |
+
}
|
src/app/layout.tsx
ADDED
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { ThemeProvider } from "@/providers/theme-provider";
|
2 |
+
import type { Metadata } from "next";
|
3 |
+
|
4 |
+
import { Toaster } from "@/components/ui/sonner";
|
5 |
+
import "./globals.css";
|
6 |
+
|
7 |
+
export const runtime = "edge"; // 'nodejs' (default) | 'edge'
|
8 |
+
|
9 |
+
export const metadata: Metadata = {
|
10 |
+
title: "vLLM UI",
|
11 |
+
description: "vLLM chatbot web interface",
|
12 |
+
};
|
13 |
+
|
14 |
+
export const viewport = {
|
15 |
+
width: "device-width",
|
16 |
+
initialScale: 1,
|
17 |
+
maximumScale: 1,
|
18 |
+
userScalable: 1,
|
19 |
+
};
|
20 |
+
|
21 |
+
export default function RootLayout({
|
22 |
+
children,
|
23 |
+
}: Readonly<{
|
24 |
+
children: React.ReactNode;
|
25 |
+
}>) {
|
26 |
+
return (
|
27 |
+
<html lang="en">
|
28 |
+
<body>
|
29 |
+
<ThemeProvider attribute="class" defaultTheme="dark">
|
30 |
+
{children}
|
31 |
+
<Toaster />
|
32 |
+
</ThemeProvider>
|
33 |
+
</body>
|
34 |
+
</html>
|
35 |
+
);
|
36 |
+
}
|
src/app/page.tsx
ADDED
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import React from "react";
|
4 |
+
import ChatPage from "@/components/chat/chat-page";
|
5 |
+
|
6 |
+
export default function Home() {
|
7 |
+
const [chatId, setChatId] = React.useState<string>("");
|
8 |
+
return <ChatPage chatId={chatId} setChatId={setChatId} />;
|
9 |
+
}
|
src/components/chat/chat-bottombar.tsx
ADDED
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import React from "react";
|
4 |
+
|
5 |
+
import { PaperPlaneIcon, StopIcon } from "@radix-ui/react-icons";
|
6 |
+
import { ChatRequestOptions } from "ai";
|
7 |
+
import mistralTokenizer from "mistral-tokenizer-js";
|
8 |
+
import TextareaAutosize from "react-textarea-autosize";
|
9 |
+
|
10 |
+
import { tokenLimit } from "@/lib/token-counter";
|
11 |
+
import { Button } from "../ui/button";
|
12 |
+
|
13 |
+
interface ChatBottombarProps {
|
14 |
+
selectedModel: string | undefined;
|
15 |
+
input: string;
|
16 |
+
handleInputChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
17 |
+
handleSubmit: (
|
18 |
+
e: React.FormEvent<HTMLFormElement>,
|
19 |
+
chatRequestOptions?: ChatRequestOptions
|
20 |
+
) => void;
|
21 |
+
isLoading: boolean;
|
22 |
+
stop: () => void;
|
23 |
+
}
|
24 |
+
|
25 |
+
export default function ChatBottombar({
|
26 |
+
selectedModel,
|
27 |
+
input,
|
28 |
+
handleInputChange,
|
29 |
+
handleSubmit,
|
30 |
+
isLoading,
|
31 |
+
stop,
|
32 |
+
}: ChatBottombarProps) {
|
33 |
+
const inputRef = React.useRef<HTMLTextAreaElement>(null);
|
34 |
+
const hasSelectedModel = selectedModel && selectedModel !== "";
|
35 |
+
|
36 |
+
const handleKeyPress = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
37 |
+
if (e.key === "Enter" && !e.shiftKey && hasSelectedModel && !isLoading) {
|
38 |
+
e.preventDefault();
|
39 |
+
handleSubmit(e as unknown as React.FormEvent<HTMLFormElement>);
|
40 |
+
}
|
41 |
+
};
|
42 |
+
const tokenCount = input ? mistralTokenizer.encode(input).length - 1 : 0;
|
43 |
+
|
44 |
+
return (
|
45 |
+
<div>
|
46 |
+
<div className="stretch flex flex-row gap-3 last:mb-2 md:last:mb-6 mx-2 md:mx-4 md:mx-auto md:max-w-2xl xl:max-w-3xl">
|
47 |
+
{/* <div className="p-2 pb-1 flex justify-between w-full items-center "> */}
|
48 |
+
<div key="input" className="w-full relative mb-1 items-center">
|
49 |
+
<form
|
50 |
+
onSubmit={handleSubmit}
|
51 |
+
className="w-full items-center flex relative gap-2"
|
52 |
+
>
|
53 |
+
<TextareaAutosize
|
54 |
+
autoComplete="off"
|
55 |
+
value={input}
|
56 |
+
ref={inputRef}
|
57 |
+
onKeyDown={handleKeyPress}
|
58 |
+
onChange={handleInputChange}
|
59 |
+
name="message"
|
60 |
+
placeholder="Ask vLLM anything..."
|
61 |
+
className="border-input max-h-48 px-4 py-4 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 dark:focus-visible:ring-slate-500 disabled:cursor-not-allowed disabled:opacity-50 w-full border rounded-md flex items-center h-14 resize-none overflow-hidden dark:bg-card/35 pr-32"
|
62 |
+
/>
|
63 |
+
<div className="text-xs text-muted-foreground absolute right-14 px-0 text-right">
|
64 |
+
{tokenCount > tokenLimit ? (
|
65 |
+
<span className="text-red-700">
|
66 |
+
{tokenCount} token{tokenCount == 1 ? "" : "s"}
|
67 |
+
</span>
|
68 |
+
) : (
|
69 |
+
<span>
|
70 |
+
{tokenCount} token{tokenCount == 1 ? "" : "s"}
|
71 |
+
</span>
|
72 |
+
)}
|
73 |
+
</div>
|
74 |
+
{!isLoading ? (
|
75 |
+
<Button
|
76 |
+
size="icon"
|
77 |
+
className="absolute bottom-1.5 md:bottom-2 md:right-2 right-2 z-100"
|
78 |
+
type="submit"
|
79 |
+
disabled={isLoading || !input.trim() || !hasSelectedModel}
|
80 |
+
>
|
81 |
+
<PaperPlaneIcon className="w-5 h-5 text-white dark:text-black" />
|
82 |
+
</Button>
|
83 |
+
) : (
|
84 |
+
<Button
|
85 |
+
size="icon"
|
86 |
+
className="absolute bottom-1.5 md:bottom-2 md:right-2 right-2 z-100"
|
87 |
+
onClick={stop}
|
88 |
+
>
|
89 |
+
<StopIcon className="w-5 h-5 text-white dark:text-black" />
|
90 |
+
</Button>
|
91 |
+
)}
|
92 |
+
</form>
|
93 |
+
</div>
|
94 |
+
</div>
|
95 |
+
<div className="relative px-2 py-2 text-center text-xs text-slate-500 md:px-[60px]">
|
96 |
+
<span>Enter to send, Shift + Enter for new line</span>
|
97 |
+
</div>
|
98 |
+
</div>
|
99 |
+
);
|
100 |
+
}
|
src/components/chat/chat-layout.tsx
ADDED
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import React, { useEffect, useState } from "react";
|
4 |
+
|
5 |
+
import { Sidebar } from "../sidebar";
|
6 |
+
import Chat, { ChatProps, ChatTopbarProps } from "./chat";
|
7 |
+
|
8 |
+
interface ChatLayoutProps {
|
9 |
+
defaultLayout: number[] | undefined;
|
10 |
+
defaultCollapsed?: boolean;
|
11 |
+
navCollapsedSize: number;
|
12 |
+
chatId: string;
|
13 |
+
}
|
14 |
+
|
15 |
+
type MergedProps = ChatLayoutProps & ChatProps & ChatTopbarProps;
|
16 |
+
|
17 |
+
export function ChatLayout({
|
18 |
+
defaultLayout = [30, 160],
|
19 |
+
defaultCollapsed = false,
|
20 |
+
navCollapsedSize = 768,
|
21 |
+
messages,
|
22 |
+
input,
|
23 |
+
handleInputChange,
|
24 |
+
handleSubmit,
|
25 |
+
isLoading,
|
26 |
+
error,
|
27 |
+
stop,
|
28 |
+
chatId,
|
29 |
+
setChatId,
|
30 |
+
chatOptions,
|
31 |
+
setChatOptions,
|
32 |
+
}: MergedProps) {
|
33 |
+
const [isCollapsed, setIsCollapsed] = React.useState(false);
|
34 |
+
const [isMobile, setIsMobile] = useState(false);
|
35 |
+
|
36 |
+
useEffect(() => {
|
37 |
+
const checkScreenWidth = () => {
|
38 |
+
setIsMobile(window.innerWidth <= 768);
|
39 |
+
setIsCollapsed(window.innerWidth <= 768);
|
40 |
+
};
|
41 |
+
|
42 |
+
// Initial check
|
43 |
+
checkScreenWidth();
|
44 |
+
|
45 |
+
// Event listener for screen width changes
|
46 |
+
window.addEventListener("resize", checkScreenWidth);
|
47 |
+
|
48 |
+
// Cleanup the event listener on component unmount
|
49 |
+
return () => {
|
50 |
+
window.removeEventListener("resize", checkScreenWidth);
|
51 |
+
};
|
52 |
+
}, []);
|
53 |
+
|
54 |
+
return (
|
55 |
+
<div className="relative z-0 flex h-full w-full overflow-hidden">
|
56 |
+
<div className="flex-shrink-0 overflow-x-hidden bg-token-sidebar-surface-primary md:w-[260px]">
|
57 |
+
<Sidebar
|
58 |
+
isCollapsed={isCollapsed}
|
59 |
+
isMobile={isMobile}
|
60 |
+
chatId={chatId}
|
61 |
+
setChatId={setChatId}
|
62 |
+
chatOptions={chatOptions}
|
63 |
+
setChatOptions={setChatOptions}
|
64 |
+
/>
|
65 |
+
</div>
|
66 |
+
<div className="relative flex h-full max-w-full flex-1 flex-col overflow-hidden">
|
67 |
+
<Chat
|
68 |
+
chatId={chatId}
|
69 |
+
setChatId={setChatId}
|
70 |
+
chatOptions={chatOptions}
|
71 |
+
setChatOptions={setChatOptions}
|
72 |
+
messages={messages}
|
73 |
+
input={input}
|
74 |
+
handleInputChange={handleInputChange}
|
75 |
+
handleSubmit={handleSubmit}
|
76 |
+
isLoading={isLoading}
|
77 |
+
error={error}
|
78 |
+
stop={stop}
|
79 |
+
/>
|
80 |
+
</div>
|
81 |
+
</div>
|
82 |
+
);
|
83 |
+
}
|
src/components/chat/chat-list.tsx
ADDED
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useEffect, useRef } from "react";
|
2 |
+
|
3 |
+
import Image from "next/image";
|
4 |
+
|
5 |
+
import OllamaLogo from "../../../public/ollama.png";
|
6 |
+
import CodeDisplayBlock from "../code-display-block";
|
7 |
+
import { Message } from "ai";
|
8 |
+
|
9 |
+
interface ChatListProps {
|
10 |
+
messages: Message[];
|
11 |
+
isLoading: boolean;
|
12 |
+
}
|
13 |
+
|
14 |
+
const MessageToolbar = () => (
|
15 |
+
<div className="mt-1 flex gap-3 empty:hidden juice:flex-row-reverse">
|
16 |
+
<div className="text-gray-400 flex self-end lg:self-center items-center justify-center lg:justify-start mt-0 -ml-1 h-7 gap-[2px] invisible">
|
17 |
+
<span>Regenerate</span>
|
18 |
+
<span>Edit</span>
|
19 |
+
</div>
|
20 |
+
</div>
|
21 |
+
);
|
22 |
+
|
23 |
+
export default function ChatList({ messages, isLoading }: ChatListProps) {
|
24 |
+
const bottomRef = useRef<HTMLDivElement>(null);
|
25 |
+
|
26 |
+
const scrollToBottom = () => {
|
27 |
+
bottomRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
|
28 |
+
};
|
29 |
+
|
30 |
+
useEffect(() => {
|
31 |
+
if (isLoading) {
|
32 |
+
return;
|
33 |
+
}
|
34 |
+
// if user scrolls up, disable auto-scroll
|
35 |
+
scrollToBottom();
|
36 |
+
}, [messages, isLoading]);
|
37 |
+
|
38 |
+
if (messages.length === 0) {
|
39 |
+
return (
|
40 |
+
<div className="w-full h-full flex justify-center items-center">
|
41 |
+
<div className="flex flex-col gap-4 items-center">
|
42 |
+
<Image
|
43 |
+
src={OllamaLogo}
|
44 |
+
alt="AI"
|
45 |
+
width={60}
|
46 |
+
height={60}
|
47 |
+
className="h-20 w-14 object-contain dark:invert"
|
48 |
+
/>
|
49 |
+
<p className="text-center text-xl text-muted-foreground">
|
50 |
+
How can I help you today?
|
51 |
+
</p>
|
52 |
+
</div>
|
53 |
+
</div>
|
54 |
+
);
|
55 |
+
}
|
56 |
+
|
57 |
+
return (
|
58 |
+
<div
|
59 |
+
id="scroller"
|
60 |
+
className="w-[800px] overflow-y-scroll overflow-x-hidden h-full justify-center m-auto"
|
61 |
+
>
|
62 |
+
<div className="px-4 py-2 justify-center text-base md:gap-6 m-auto">
|
63 |
+
{messages
|
64 |
+
.filter((message) => message.role !== "system")
|
65 |
+
.map((message, index) => (
|
66 |
+
<div
|
67 |
+
key={index}
|
68 |
+
className="flex flex-1 text-base mx-auto gap-3 juice:gap-4 juice:md:gap-6 md:px-5 lg:px-1 xl:px-5 md:max-w-3xl lg:max-w-[40rem] xl:max-w-[48rem]"
|
69 |
+
>
|
70 |
+
<div className="flex flex-1 text-base mx-auto gap-3 juice:gap-4 juice:md:gap-6 md:px-5 lg:px-1 xl:px-5 md:max-w-3xl lg:max-w-[40rem] xl:max-w-[48rem]">
|
71 |
+
<div className="flex-shrink-0 flex flex-col relative items-end">
|
72 |
+
<div className="pt-0.5">
|
73 |
+
<div className="gizmo-shadow-stroke flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
|
74 |
+
{message.role === "user" ? (
|
75 |
+
<div className="dark:invert h-full w-full bg-black" />
|
76 |
+
) : (
|
77 |
+
<Image
|
78 |
+
src={OllamaLogo}
|
79 |
+
alt="AI"
|
80 |
+
className="object-contain dark:invert aspect-square h-full w-full"
|
81 |
+
/>
|
82 |
+
)}
|
83 |
+
</div>
|
84 |
+
</div>
|
85 |
+
</div>
|
86 |
+
{message.role === "user" && (
|
87 |
+
<div className="relative flex w-full min-w-0 flex-col">
|
88 |
+
<div className="font-semibold pb-2">You</div>
|
89 |
+
<div className="flex-col gap-1 md:gap-3">
|
90 |
+
{message.content}
|
91 |
+
</div>
|
92 |
+
<MessageToolbar />
|
93 |
+
</div>
|
94 |
+
)}
|
95 |
+
{message.role === "assistant" && (
|
96 |
+
<div className="relative flex w-full min-w-0 flex-col">
|
97 |
+
<div className="font-semibold pb-2">Assistant</div>
|
98 |
+
<div className="flex-col gap-1 md:gap-3">
|
99 |
+
<span className="whitespace-pre-wrap">
|
100 |
+
{/* Check if the message content contains a code block */}
|
101 |
+
{message.content.split("```").map((part, index) => {
|
102 |
+
if (index % 2 === 0) {
|
103 |
+
return (
|
104 |
+
<React.Fragment key={index}>
|
105 |
+
{part}
|
106 |
+
</React.Fragment>
|
107 |
+
);
|
108 |
+
} else {
|
109 |
+
return (
|
110 |
+
<CodeDisplayBlock
|
111 |
+
key={index}
|
112 |
+
code={part.trim()}
|
113 |
+
lang=""
|
114 |
+
/>
|
115 |
+
);
|
116 |
+
}
|
117 |
+
})}
|
118 |
+
{isLoading &&
|
119 |
+
messages.indexOf(message) === messages.length - 1 && (
|
120 |
+
<span className="animate-pulse" aria-label="Typing">
|
121 |
+
...
|
122 |
+
</span>
|
123 |
+
)}
|
124 |
+
</span>
|
125 |
+
</div>
|
126 |
+
<MessageToolbar />
|
127 |
+
</div>
|
128 |
+
)}
|
129 |
+
</div>
|
130 |
+
</div>
|
131 |
+
))}
|
132 |
+
</div>
|
133 |
+
<div id="anchor" ref={bottomRef}></div>
|
134 |
+
</div>
|
135 |
+
);
|
136 |
+
}
|
src/components/chat/chat-options.ts
ADDED
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
export interface ChatOptions {
|
2 |
+
selectedModel?: string;
|
3 |
+
systemPrompt?: string;
|
4 |
+
temperature?: number;
|
5 |
+
}
|
src/components/chat/chat-page.tsx
ADDED
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import React from "react";
|
4 |
+
|
5 |
+
import { ChatRequestOptions } from "ai";
|
6 |
+
import { useChat } from "ai/react";
|
7 |
+
import { toast } from "sonner";
|
8 |
+
import useLocalStorageState from "use-local-storage-state";
|
9 |
+
import { v4 as uuidv4 } from "uuid";
|
10 |
+
|
11 |
+
import { ChatLayout } from "@/components/chat/chat-layout";
|
12 |
+
import { ChatOptions } from "@/components/chat/chat-options";
|
13 |
+
import { basePath } from "@/lib/utils";
|
14 |
+
|
15 |
+
interface ChatPageProps {
|
16 |
+
chatId: string;
|
17 |
+
setChatId: React.Dispatch<React.SetStateAction<string>>;
|
18 |
+
}
|
19 |
+
export default function ChatPage({ chatId, setChatId }: ChatPageProps) {
|
20 |
+
const {
|
21 |
+
messages,
|
22 |
+
input,
|
23 |
+
handleInputChange,
|
24 |
+
handleSubmit,
|
25 |
+
isLoading,
|
26 |
+
error,
|
27 |
+
stop,
|
28 |
+
setMessages,
|
29 |
+
} = useChat({
|
30 |
+
api: basePath + "/api/chat",
|
31 |
+
streamMode: "text",
|
32 |
+
onError: (error) => {
|
33 |
+
toast.error("Something went wrong: " + error);
|
34 |
+
},
|
35 |
+
});
|
36 |
+
const [chatOptions, setChatOptions] = useLocalStorageState<ChatOptions>(
|
37 |
+
"chatOptions",
|
38 |
+
{
|
39 |
+
defaultValue: {
|
40 |
+
selectedModel: "",
|
41 |
+
systemPrompt: "",
|
42 |
+
temperature: 0.9,
|
43 |
+
},
|
44 |
+
}
|
45 |
+
);
|
46 |
+
|
47 |
+
React.useEffect(() => {
|
48 |
+
if (chatId) {
|
49 |
+
const item = localStorage.getItem(`chat_${chatId}`);
|
50 |
+
if (item) {
|
51 |
+
setMessages(JSON.parse(item));
|
52 |
+
}
|
53 |
+
} else {
|
54 |
+
setMessages([]);
|
55 |
+
}
|
56 |
+
}, [setMessages, chatId]);
|
57 |
+
|
58 |
+
React.useEffect(() => {
|
59 |
+
if (!isLoading && !error && chatId && messages.length > 0) {
|
60 |
+
// Save messages to local storage
|
61 |
+
localStorage.setItem(`chat_${chatId}`, JSON.stringify(messages));
|
62 |
+
// Trigger the storage event to update the sidebar component
|
63 |
+
window.dispatchEvent(new Event("storage"));
|
64 |
+
}
|
65 |
+
}, [messages, chatId, isLoading, error]);
|
66 |
+
|
67 |
+
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
68 |
+
e.preventDefault();
|
69 |
+
|
70 |
+
if (messages.length === 0) {
|
71 |
+
// Generate a random id for the chat
|
72 |
+
const id = uuidv4();
|
73 |
+
setChatId(id);
|
74 |
+
}
|
75 |
+
|
76 |
+
setMessages([...messages]);
|
77 |
+
|
78 |
+
// Prepare the options object with additional body data, to pass the model.
|
79 |
+
const requestOptions: ChatRequestOptions = {
|
80 |
+
options: {
|
81 |
+
body: {
|
82 |
+
chatOptions: chatOptions,
|
83 |
+
},
|
84 |
+
},
|
85 |
+
};
|
86 |
+
|
87 |
+
// Call the handleSubmit function with the options
|
88 |
+
handleSubmit(e, requestOptions);
|
89 |
+
};
|
90 |
+
|
91 |
+
return (
|
92 |
+
<main className="flex h-[calc(100dvh)] flex-col items-center ">
|
93 |
+
<ChatLayout
|
94 |
+
chatId={chatId}
|
95 |
+
setChatId={setChatId}
|
96 |
+
chatOptions={chatOptions}
|
97 |
+
setChatOptions={setChatOptions}
|
98 |
+
messages={messages}
|
99 |
+
input={input}
|
100 |
+
handleInputChange={handleInputChange}
|
101 |
+
handleSubmit={onSubmit}
|
102 |
+
isLoading={isLoading}
|
103 |
+
error={error}
|
104 |
+
stop={stop}
|
105 |
+
navCollapsedSize={10}
|
106 |
+
defaultLayout={[30, 160]}
|
107 |
+
/>
|
108 |
+
</main>
|
109 |
+
);
|
110 |
+
}
|
src/components/chat/chat-topbar.tsx
ADDED
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import React, { useEffect } from "react";
|
4 |
+
|
5 |
+
import {
|
6 |
+
CheckCircledIcon,
|
7 |
+
CrossCircledIcon,
|
8 |
+
DotFilledIcon,
|
9 |
+
HamburgerMenuIcon,
|
10 |
+
InfoCircledIcon,
|
11 |
+
} from "@radix-ui/react-icons";
|
12 |
+
import { Message } from "ai/react";
|
13 |
+
import { toast } from "sonner";
|
14 |
+
|
15 |
+
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
16 |
+
import {
|
17 |
+
Tooltip,
|
18 |
+
TooltipContent,
|
19 |
+
TooltipProvider,
|
20 |
+
TooltipTrigger,
|
21 |
+
} from "@/components/ui/tooltip";
|
22 |
+
import { encodeChat, tokenLimit } from "@/lib/token-counter";
|
23 |
+
import { basePath, useHasMounted } from "@/lib/utils";
|
24 |
+
import { Sidebar } from "../sidebar";
|
25 |
+
import { ChatOptions } from "./chat-options";
|
26 |
+
|
27 |
+
interface ChatTopbarProps {
|
28 |
+
chatOptions: ChatOptions;
|
29 |
+
setChatOptions: React.Dispatch<React.SetStateAction<ChatOptions>>;
|
30 |
+
isLoading: boolean;
|
31 |
+
chatId?: string;
|
32 |
+
setChatId: React.Dispatch<React.SetStateAction<string>>;
|
33 |
+
messages: Message[];
|
34 |
+
}
|
35 |
+
|
36 |
+
export default function ChatTopbar({
|
37 |
+
chatOptions,
|
38 |
+
setChatOptions,
|
39 |
+
isLoading,
|
40 |
+
chatId,
|
41 |
+
setChatId,
|
42 |
+
messages,
|
43 |
+
}: ChatTopbarProps) {
|
44 |
+
const hasMounted = useHasMounted();
|
45 |
+
|
46 |
+
const currentModel = chatOptions && chatOptions.selectedModel;
|
47 |
+
const [error, setError] = React.useState<string | undefined>(undefined);
|
48 |
+
|
49 |
+
const fetchData = async () => {
|
50 |
+
if (!hasMounted) {
|
51 |
+
return null;
|
52 |
+
}
|
53 |
+
try {
|
54 |
+
const res = await fetch(basePath + "/api/models");
|
55 |
+
|
56 |
+
if (!res.ok) {
|
57 |
+
const errorResponse = await res.json();
|
58 |
+
const errorMessage = `Connection to vLLM server failed: ${errorResponse.error} [${res.status} ${res.statusText}]`;
|
59 |
+
throw new Error(errorMessage);
|
60 |
+
}
|
61 |
+
|
62 |
+
const data = await res.json();
|
63 |
+
// Extract the "name" field from each model object and store them in the state
|
64 |
+
const modelNames = data.data.map((model: any) => model.id);
|
65 |
+
// save the first and only model in the list as selectedModel in localstorage
|
66 |
+
setChatOptions({ ...chatOptions, selectedModel: modelNames[0] });
|
67 |
+
} catch (error) {
|
68 |
+
setChatOptions({ ...chatOptions, selectedModel: undefined });
|
69 |
+
toast.error(error as string);
|
70 |
+
}
|
71 |
+
};
|
72 |
+
|
73 |
+
useEffect(() => {
|
74 |
+
fetchData();
|
75 |
+
}, [hasMounted]);
|
76 |
+
|
77 |
+
if (!hasMounted) {
|
78 |
+
return (
|
79 |
+
<div className="md:w-full flex px-4 py-6 items-center gap-1 md:justify-center">
|
80 |
+
<DotFilledIcon className="w-4 h-4 text-blue-500" />
|
81 |
+
<span className="text-xs">Booting up..</span>
|
82 |
+
</div>
|
83 |
+
);
|
84 |
+
}
|
85 |
+
|
86 |
+
const chatTokens = messages.length > 0 ? encodeChat(messages) : 0;
|
87 |
+
|
88 |
+
return (
|
89 |
+
<div className="md:w-full flex px-4 py-4 items-center justify-between md:justify-center">
|
90 |
+
<Sheet>
|
91 |
+
<SheetTrigger>
|
92 |
+
<div className="flex items-center gap-2">
|
93 |
+
<HamburgerMenuIcon className="md:hidden w-5 h-5" />
|
94 |
+
</div>
|
95 |
+
</SheetTrigger>
|
96 |
+
<SheetContent side="left">
|
97 |
+
<div>
|
98 |
+
<Sidebar
|
99 |
+
chatId={chatId || ""}
|
100 |
+
setChatId={setChatId}
|
101 |
+
isCollapsed={false}
|
102 |
+
isMobile={false}
|
103 |
+
chatOptions={chatOptions}
|
104 |
+
setChatOptions={setChatOptions}
|
105 |
+
/>
|
106 |
+
</div>
|
107 |
+
</SheetContent>
|
108 |
+
</Sheet>
|
109 |
+
|
110 |
+
<div className="flex justify-center md:justify-between gap-4 w-full">
|
111 |
+
<div className="gap-1 flex items-center">
|
112 |
+
{currentModel !== undefined && (
|
113 |
+
<>
|
114 |
+
{isLoading ? (
|
115 |
+
<DotFilledIcon className="w-4 h-4 text-blue-500" />
|
116 |
+
) : (
|
117 |
+
<TooltipProvider>
|
118 |
+
<Tooltip>
|
119 |
+
<TooltipTrigger>
|
120 |
+
<span className="cursor-help">
|
121 |
+
<CheckCircledIcon className="w-4 h-4 text-green-500" />
|
122 |
+
</span>
|
123 |
+
</TooltipTrigger>
|
124 |
+
<TooltipContent
|
125 |
+
sideOffset={4}
|
126 |
+
className="bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 p-2 rounded-sm text-xs"
|
127 |
+
>
|
128 |
+
<p className="font-bold">Current Model</p>
|
129 |
+
<p className="text-gray-500">{currentModel}</p>
|
130 |
+
</TooltipContent>
|
131 |
+
</Tooltip>
|
132 |
+
</TooltipProvider>
|
133 |
+
)}
|
134 |
+
<span className="text-xs">
|
135 |
+
{isLoading ? "Generating.." : "Ready"}
|
136 |
+
</span>
|
137 |
+
</>
|
138 |
+
)}
|
139 |
+
{currentModel === undefined && (
|
140 |
+
<>
|
141 |
+
<CrossCircledIcon className="w-4 h-4 text-red-500" />
|
142 |
+
<span className="text-xs">Connection to vLLM server failed</span>
|
143 |
+
</>
|
144 |
+
)}
|
145 |
+
</div>
|
146 |
+
<div className="flex items-end gap-2">
|
147 |
+
{chatTokens > tokenLimit && (
|
148 |
+
<TooltipProvider>
|
149 |
+
<Tooltip>
|
150 |
+
<TooltipTrigger>
|
151 |
+
<span>
|
152 |
+
<InfoCircledIcon className="w-4 h-4 text-blue-500" />
|
153 |
+
</span>
|
154 |
+
</TooltipTrigger>
|
155 |
+
<TooltipContent
|
156 |
+
sideOffset={4}
|
157 |
+
className="bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-sm text-xs"
|
158 |
+
>
|
159 |
+
<p className="text-gray-500">
|
160 |
+
Token limit exceeded. Truncating middle messages.
|
161 |
+
</p>
|
162 |
+
</TooltipContent>
|
163 |
+
</Tooltip>
|
164 |
+
</TooltipProvider>
|
165 |
+
)}
|
166 |
+
{messages.length > 0 && (
|
167 |
+
<span className="text-xs text-gray-500">
|
168 |
+
{chatTokens} / {tokenLimit} token{chatTokens > 1 ? "s" : ""}
|
169 |
+
</span>
|
170 |
+
)}
|
171 |
+
</div>
|
172 |
+
</div>
|
173 |
+
</div>
|
174 |
+
);
|
175 |
+
}
|
src/components/chat/chat.tsx
ADDED
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from "react";
|
2 |
+
|
3 |
+
import { ChatRequestOptions } from "ai";
|
4 |
+
import { Message } from "ai/react";
|
5 |
+
|
6 |
+
import ChatBottombar from "./chat-bottombar";
|
7 |
+
import ChatList from "./chat-list";
|
8 |
+
import { ChatOptions } from "./chat-options";
|
9 |
+
import ChatTopbar from "./chat-topbar";
|
10 |
+
|
11 |
+
export interface ChatProps {
|
12 |
+
chatId?: string;
|
13 |
+
setChatId: React.Dispatch<React.SetStateAction<string>>;
|
14 |
+
messages: Message[];
|
15 |
+
input: string;
|
16 |
+
handleInputChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
17 |
+
handleSubmit: (
|
18 |
+
e: React.FormEvent<HTMLFormElement>,
|
19 |
+
chatRequestOptions?: ChatRequestOptions
|
20 |
+
) => void;
|
21 |
+
isLoading: boolean;
|
22 |
+
error: undefined | Error;
|
23 |
+
stop: () => void;
|
24 |
+
}
|
25 |
+
|
26 |
+
export interface ChatTopbarProps {
|
27 |
+
chatOptions: ChatOptions;
|
28 |
+
setChatOptions: React.Dispatch<React.SetStateAction<ChatOptions>>;
|
29 |
+
}
|
30 |
+
|
31 |
+
export default function Chat({
|
32 |
+
messages,
|
33 |
+
input,
|
34 |
+
handleInputChange,
|
35 |
+
handleSubmit,
|
36 |
+
isLoading,
|
37 |
+
error,
|
38 |
+
stop,
|
39 |
+
chatOptions,
|
40 |
+
setChatOptions,
|
41 |
+
chatId,
|
42 |
+
setChatId,
|
43 |
+
}: ChatProps & ChatTopbarProps) {
|
44 |
+
return (
|
45 |
+
<div className="flex flex-col justify-between w-full h-full ">
|
46 |
+
<ChatTopbar
|
47 |
+
chatOptions={chatOptions}
|
48 |
+
setChatOptions={setChatOptions}
|
49 |
+
isLoading={isLoading}
|
50 |
+
chatId={chatId}
|
51 |
+
setChatId={setChatId}
|
52 |
+
messages={messages}
|
53 |
+
/>
|
54 |
+
|
55 |
+
<ChatList
|
56 |
+
messages={messages}
|
57 |
+
isLoading={isLoading}
|
58 |
+
/>
|
59 |
+
|
60 |
+
<ChatBottombar
|
61 |
+
selectedModel={chatOptions.selectedModel}
|
62 |
+
input={input}
|
63 |
+
handleInputChange={handleInputChange}
|
64 |
+
handleSubmit={handleSubmit}
|
65 |
+
isLoading={isLoading}
|
66 |
+
stop={stop}
|
67 |
+
/>
|
68 |
+
</div>
|
69 |
+
);
|
70 |
+
}
|
src/components/code-display-block.tsx
ADDED
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
import React from "react";
|
3 |
+
|
4 |
+
import { CheckIcon, CopyIcon } from "@radix-ui/react-icons";
|
5 |
+
import { useTheme } from "next-themes";
|
6 |
+
import { CodeBlock, dracula, github } from "react-code-blocks";
|
7 |
+
import { toast } from "sonner";
|
8 |
+
|
9 |
+
import { Button } from "./ui/button";
|
10 |
+
|
11 |
+
interface ButtonCodeblockProps {
|
12 |
+
code: string;
|
13 |
+
lang: string;
|
14 |
+
}
|
15 |
+
|
16 |
+
export default function CodeDisplayBlock({ code, lang }: ButtonCodeblockProps) {
|
17 |
+
const [isCopied, setisCopied] = React.useState(false);
|
18 |
+
const { theme } = useTheme();
|
19 |
+
|
20 |
+
const copyToClipboard = () => {
|
21 |
+
navigator.clipboard.writeText(code);
|
22 |
+
setisCopied(true);
|
23 |
+
toast.success("Code copied to clipboard!");
|
24 |
+
setTimeout(() => {
|
25 |
+
setisCopied(false);
|
26 |
+
}, 1500);
|
27 |
+
};
|
28 |
+
|
29 |
+
return (
|
30 |
+
<div className="relative my-4 overflow-scroll overflow-x-scroll flex flex-col text-start ">
|
31 |
+
<CodeBlock
|
32 |
+
customStyle={
|
33 |
+
theme === "dark"
|
34 |
+
? { background: "#303033" }
|
35 |
+
: { background: "#fcfcfc" }
|
36 |
+
}
|
37 |
+
text={code}
|
38 |
+
language="tsx"
|
39 |
+
showLineNumbers={false}
|
40 |
+
theme={theme === "dark" ? dracula : github}
|
41 |
+
/>
|
42 |
+
<Button
|
43 |
+
onClick={copyToClipboard}
|
44 |
+
variant="ghost"
|
45 |
+
size="iconSm"
|
46 |
+
className="h-5 w-5 absolute top-2 right-2"
|
47 |
+
>
|
48 |
+
{isCopied ? (
|
49 |
+
<CheckIcon className="w-4 h-4" />
|
50 |
+
) : (
|
51 |
+
<CopyIcon className="w-4 h-4" />
|
52 |
+
)}
|
53 |
+
</Button>
|
54 |
+
</div>
|
55 |
+
);
|
56 |
+
}
|
src/components/settings-clear-chats.tsx
ADDED
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import * as React from "react";
|
4 |
+
|
5 |
+
import {
|
6 |
+
Dialog,
|
7 |
+
DialogClose,
|
8 |
+
DialogContent,
|
9 |
+
DialogDescription,
|
10 |
+
DialogTrigger,
|
11 |
+
} from "@radix-ui/react-dialog";
|
12 |
+
import { TrashIcon } from "@radix-ui/react-icons";
|
13 |
+
import { useRouter } from "next/navigation";
|
14 |
+
|
15 |
+
import { useHasMounted } from "@/lib/utils";
|
16 |
+
import { DialogHeader } from "./ui/dialog";
|
17 |
+
|
18 |
+
export default function ClearChatsButton() {
|
19 |
+
const hasMounted = useHasMounted();
|
20 |
+
const router = useRouter();
|
21 |
+
|
22 |
+
if (!hasMounted) {
|
23 |
+
return null;
|
24 |
+
}
|
25 |
+
|
26 |
+
const chats = Object.keys(localStorage).filter((key) =>
|
27 |
+
key.startsWith("chat_")
|
28 |
+
);
|
29 |
+
|
30 |
+
const disabled = chats.length === 0;
|
31 |
+
|
32 |
+
const clearChats = () => {
|
33 |
+
chats.forEach((key) => {
|
34 |
+
localStorage.removeItem(key);
|
35 |
+
});
|
36 |
+
window.dispatchEvent(new Event("storage"));
|
37 |
+
router.push("/");
|
38 |
+
};
|
39 |
+
|
40 |
+
return (
|
41 |
+
<Dialog>
|
42 |
+
<DialogTrigger
|
43 |
+
className="inline-flex items-center whitespace-nowrap font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-8 rounded-sm px-3 text-xs justify-start gap-2 w-full"
|
44 |
+
disabled={disabled}
|
45 |
+
>
|
46 |
+
<TrashIcon className="w-4 h-4" />
|
47 |
+
<span>Clear chats</span>
|
48 |
+
</DialogTrigger>
|
49 |
+
<DialogContent>
|
50 |
+
<DialogHeader className="space-y-2">
|
51 |
+
<DialogDescription className="text-xs">
|
52 |
+
Are you sure you want to delete all chats? This action cannot be
|
53 |
+
undone.
|
54 |
+
</DialogDescription>
|
55 |
+
<div className="flex justify-end gap-2">
|
56 |
+
<DialogClose className="border border-input bg-background hover:bg-accent hover:text-accent-foreground px-3 py-2 rounded-sm text-xs">
|
57 |
+
Cancel
|
58 |
+
</DialogClose>
|
59 |
+
<DialogClose
|
60 |
+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 px-3 py-2 rounded-sm text-xs"
|
61 |
+
onClick={() => clearChats()}
|
62 |
+
>
|
63 |
+
Delete
|
64 |
+
</DialogClose>
|
65 |
+
</div>
|
66 |
+
</DialogHeader>
|
67 |
+
</DialogContent>
|
68 |
+
</Dialog>
|
69 |
+
);
|
70 |
+
}
|
src/components/settings-theme-toggle.tsx
ADDED
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import * as React from "react";
|
4 |
+
|
5 |
+
import { MoonIcon, SunIcon } from "@radix-ui/react-icons";
|
6 |
+
import { useTheme } from "next-themes";
|
7 |
+
|
8 |
+
import { useHasMounted } from "@/lib/utils";
|
9 |
+
import { Button } from "./ui/button";
|
10 |
+
|
11 |
+
export default function SettingsThemeToggle() {
|
12 |
+
const hasMounted = useHasMounted();
|
13 |
+
const { setTheme, theme } = useTheme();
|
14 |
+
|
15 |
+
if (!hasMounted) {
|
16 |
+
return null;
|
17 |
+
}
|
18 |
+
|
19 |
+
const nextTheme = theme === "light" ? "dark" : "light";
|
20 |
+
|
21 |
+
return (
|
22 |
+
<Button
|
23 |
+
className="justify-start gap-2 w-full"
|
24 |
+
size="sm"
|
25 |
+
variant="ghost"
|
26 |
+
onClick={() => setTheme(nextTheme)}
|
27 |
+
>
|
28 |
+
{nextTheme === "light" ? (
|
29 |
+
<SunIcon className="w-4 h-4" />
|
30 |
+
) : (
|
31 |
+
<MoonIcon className="w-4 h-4" />
|
32 |
+
)}
|
33 |
+
<p>{nextTheme === "light" ? "Light mode" : "Dark mode"}</p>
|
34 |
+
</Button>
|
35 |
+
);
|
36 |
+
}
|
src/components/settings.tsx
ADDED
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import ClearChatsButton from "./settings-clear-chats";
|
4 |
+
import SettingsThemeToggle from "./settings-theme-toggle";
|
5 |
+
import SystemPrompt, { SystemPromptProps } from "./system-prompt";
|
6 |
+
import { Input } from "./ui/input";
|
7 |
+
|
8 |
+
const TemperatureSlider = ({
|
9 |
+
chatOptions,
|
10 |
+
setChatOptions,
|
11 |
+
}: SystemPromptProps) => {
|
12 |
+
const handleTemperatureChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
13 |
+
setChatOptions({ ...chatOptions, temperature: parseFloat(e.target.value) });
|
14 |
+
};
|
15 |
+
|
16 |
+
return (
|
17 |
+
<div>
|
18 |
+
<div className="mx-2 flex align-middle gap-4 items-center justify-between
|
19 |
+
">
|
20 |
+
<label
|
21 |
+
htmlFor="small-input"
|
22 |
+
className="text-xs font-medium text-gray-900 dark:text-white align-middle"
|
23 |
+
>
|
24 |
+
Temperature
|
25 |
+
</label>
|
26 |
+
<Input
|
27 |
+
type="text"
|
28 |
+
id="small-input"
|
29 |
+
className="w-1/4 text-gray-900 hover:border hover:border-gray-300 rounded-sm hover:bg-gray-200 text-xs focus:ring-blue-500 focus:border-blue-500 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500 h-6
|
30 |
+
text-right
|
31 |
+
"
|
32 |
+
value={chatOptions.temperature}
|
33 |
+
onChange={handleTemperatureChange}
|
34 |
+
min={0}
|
35 |
+
max={2}
|
36 |
+
step={0.1}
|
37 |
+
/>
|
38 |
+
</div>
|
39 |
+
|
40 |
+
<div className="p-2">
|
41 |
+
<input
|
42 |
+
id="labels-range-input"
|
43 |
+
type="range"
|
44 |
+
value={chatOptions.temperature}
|
45 |
+
onChange={handleTemperatureChange}
|
46 |
+
min="0.0"
|
47 |
+
max="2.0"
|
48 |
+
step="0.1"
|
49 |
+
className="w-full h-1 bg-gray-200 rounded-sm appearance-none cursor-pointer range-sm dark:bg-gray-700"
|
50 |
+
/>
|
51 |
+
</div>
|
52 |
+
</div>
|
53 |
+
);
|
54 |
+
};
|
55 |
+
|
56 |
+
export default function Settings({
|
57 |
+
chatOptions,
|
58 |
+
setChatOptions,
|
59 |
+
}: SystemPromptProps) {
|
60 |
+
return (
|
61 |
+
<>
|
62 |
+
<SystemPrompt chatOptions={chatOptions} setChatOptions={setChatOptions} />
|
63 |
+
<TemperatureSlider
|
64 |
+
chatOptions={chatOptions}
|
65 |
+
setChatOptions={setChatOptions}
|
66 |
+
/>
|
67 |
+
<SettingsThemeToggle />
|
68 |
+
<ClearChatsButton />
|
69 |
+
</>
|
70 |
+
);
|
71 |
+
}
|
src/components/sidebar-skeleton.tsx
ADDED
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Skeleton } from "@/components/ui/skeleton";
|
2 |
+
|
3 |
+
export default function SidebarSkeleton() {
|
4 |
+
return (
|
5 |
+
<div className="flex flex-col w-full gap-2 ">
|
6 |
+
<div className="flex h-14 w-full bg-primary/5 justify-between rounded-md items-center p-2">
|
7 |
+
<Skeleton className="h-6 w-2/3 rounded-sm" />
|
8 |
+
<Skeleton className="h-6 w-6 rounded-md" />
|
9 |
+
</div>
|
10 |
+
|
11 |
+
<div className="flex h-14 w-full bg-primary/5 opacity-80 justify-between rounded-md items-center p-2">
|
12 |
+
<Skeleton className="h-6 w-2/3 rounded-sm" />
|
13 |
+
<Skeleton className="h-6 w-6 rounded-md" />
|
14 |
+
</div>
|
15 |
+
|
16 |
+
<div className="flex h-14 w-full bg-primary/5 opacity-60 justify-between rounded-md items-center p-2">
|
17 |
+
<Skeleton className="h-6 w-2/3 rounded-sm" />
|
18 |
+
<Skeleton className="h-6 w-6 rounded-md" />
|
19 |
+
</div>
|
20 |
+
|
21 |
+
<div className="flex h-14 w-full bg-primary/5 opacity-40 justify-between rounded-md items-center p-2">
|
22 |
+
<Skeleton className="h-6 w-2/3 rounded-sm" />
|
23 |
+
<Skeleton className="h-6 w-6 rounded-md" />
|
24 |
+
</div>
|
25 |
+
|
26 |
+
<div className="flex h-14 w-full bg-primary/5 opacity-20 justify-between rounded-md items-center p-2">
|
27 |
+
<Skeleton className="h-6 w-2/3 rounded-sm" />
|
28 |
+
<Skeleton className="h-6 w-6 rounded-md" />
|
29 |
+
</div>
|
30 |
+
|
31 |
+
</div>
|
32 |
+
);
|
33 |
+
}
|
src/components/sidebar-tabs.tsx
ADDED
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import React from "react";
|
4 |
+
|
5 |
+
import { DialogClose } from "@radix-ui/react-dialog";
|
6 |
+
import { ChatBubbleIcon, GearIcon, TrashIcon } from "@radix-ui/react-icons";
|
7 |
+
import * as Tabs from "@radix-ui/react-tabs";
|
8 |
+
import { Message } from "ai/react";
|
9 |
+
import Link from "next/link";
|
10 |
+
|
11 |
+
import { buttonVariants } from "@/components/ui/button";
|
12 |
+
import { cn } from "@/lib/utils";
|
13 |
+
import { ChatOptions } from "./chat/chat-options";
|
14 |
+
import Settings from "./settings";
|
15 |
+
import SidebarSkeleton from "./sidebar-skeleton";
|
16 |
+
import {
|
17 |
+
Dialog,
|
18 |
+
DialogContent,
|
19 |
+
DialogDescription,
|
20 |
+
DialogHeader,
|
21 |
+
DialogTitle,
|
22 |
+
DialogTrigger,
|
23 |
+
} from "./ui/dialog";
|
24 |
+
|
25 |
+
interface Chats {
|
26 |
+
[key: string]: { chatId: string; messages: Message[] }[];
|
27 |
+
}
|
28 |
+
interface SidebarTabsProps {
|
29 |
+
isLoading: boolean;
|
30 |
+
localChats: Chats;
|
31 |
+
selectedChatId: string;
|
32 |
+
chatOptions: ChatOptions;
|
33 |
+
setChatOptions: React.Dispatch<React.SetStateAction<ChatOptions>>;
|
34 |
+
handleDeleteChat: (chatId: string) => void;
|
35 |
+
}
|
36 |
+
|
37 |
+
const SidebarTabs = ({
|
38 |
+
localChats,
|
39 |
+
selectedChatId,
|
40 |
+
isLoading,
|
41 |
+
chatOptions,
|
42 |
+
setChatOptions,
|
43 |
+
handleDeleteChat,
|
44 |
+
}: SidebarTabsProps) => (
|
45 |
+
<Tabs.Root
|
46 |
+
className="overflow-hidden h-full bg-accent/20 dark:bg-card/35"
|
47 |
+
defaultValue="chats"
|
48 |
+
>
|
49 |
+
<div className=" text-sm o h-full">
|
50 |
+
<Tabs.Content className="h-screen overflow-y-auto" value="chats">
|
51 |
+
<div className="h-full mb-28">
|
52 |
+
{Object.keys(localChats).length > 0 && (
|
53 |
+
<>
|
54 |
+
{Object.keys(localChats).map((group, index) => (
|
55 |
+
<div key={index} className="flex flex-col py-2 pl-3 pr-1 gap-2 mb-8">
|
56 |
+
<p className="px-2 text-xs font-medium text-muted-foreground">
|
57 |
+
{group}
|
58 |
+
</p>
|
59 |
+
<ol>
|
60 |
+
{localChats[group].map(
|
61 |
+
({ chatId, messages }, chatIndex) => {
|
62 |
+
const isSelected =
|
63 |
+
chatId.substring(5) === selectedChatId;
|
64 |
+
return (
|
65 |
+
<li
|
66 |
+
className="flex w-full items-center relative"
|
67 |
+
key={chatIndex}
|
68 |
+
>
|
69 |
+
<div className="flex-col w-full truncate">
|
70 |
+
<Link
|
71 |
+
href={`/chats/${chatId.substring(5)}`}
|
72 |
+
className={cn(
|
73 |
+
{
|
74 |
+
[buttonVariants({
|
75 |
+
variant: "secondaryLink",
|
76 |
+
})]: isSelected,
|
77 |
+
[buttonVariants({ variant: "ghost" })]:
|
78 |
+
!isSelected,
|
79 |
+
},
|
80 |
+
"flex gap-2 p-2 justify-start"
|
81 |
+
)}
|
82 |
+
>
|
83 |
+
<span className="text-sm font-normal max-w-[184px] truncate">
|
84 |
+
{messages.length > 0
|
85 |
+
? messages[0].content
|
86 |
+
: ""}
|
87 |
+
</span>
|
88 |
+
</Link>
|
89 |
+
</div>
|
90 |
+
<div className="absolute right-0 rounded-xs">
|
91 |
+
<Dialog>
|
92 |
+
<DialogTrigger
|
93 |
+
className={
|
94 |
+
"items-center whitespace-nowrap font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 h-8 rounded-sm px-3 text-xs justify-start gap-2 w-full hover:text-red-500 hover:bg-card dark:hover:bg-accent" +
|
95 |
+
(isSelected
|
96 |
+
? " bg-accent dark:bg-card"
|
97 |
+
: " ")
|
98 |
+
}
|
99 |
+
>
|
100 |
+
<TrashIcon className="w-4 h-4" />
|
101 |
+
</DialogTrigger>
|
102 |
+
<DialogContent>
|
103 |
+
<DialogHeader className="space-y-4">
|
104 |
+
<DialogTitle>Delete chat?</DialogTitle>
|
105 |
+
<DialogDescription>
|
106 |
+
Are you sure you want to delete this chat?
|
107 |
+
This action cannot be undone.
|
108 |
+
</DialogDescription>
|
109 |
+
<div className="flex justify-end gap-2">
|
110 |
+
<DialogClose className="border border-input bg-background hover:bg-accent hover:text-accent-foreground px-4 py-2 rounded-sm">
|
111 |
+
Cancel
|
112 |
+
</DialogClose>
|
113 |
+
|
114 |
+
<DialogClose
|
115 |
+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 px-4 py-2 rounded-sm"
|
116 |
+
onClick={() => handleDeleteChat(chatId)}
|
117 |
+
>
|
118 |
+
Delete
|
119 |
+
</DialogClose>
|
120 |
+
</div>
|
121 |
+
</DialogHeader>
|
122 |
+
</DialogContent>
|
123 |
+
</Dialog>
|
124 |
+
</div>
|
125 |
+
</li>
|
126 |
+
);
|
127 |
+
}
|
128 |
+
)}
|
129 |
+
</ol>
|
130 |
+
</div>
|
131 |
+
))}
|
132 |
+
</>
|
133 |
+
)}
|
134 |
+
{isLoading && <SidebarSkeleton />}
|
135 |
+
</div>
|
136 |
+
</Tabs.Content>
|
137 |
+
<Tabs.Content className="h-screen overflow-y-auto" value="settings">
|
138 |
+
<div className="h-full mb-16 pl-2">
|
139 |
+
<Settings chatOptions={chatOptions} setChatOptions={setChatOptions} />
|
140 |
+
</div>
|
141 |
+
</Tabs.Content>
|
142 |
+
</div>
|
143 |
+
<div className="sticky left-0 right-0 bottom-0 z-20 m-0 overflow-hidden">
|
144 |
+
<Tabs.List
|
145 |
+
className="flex flex-wrap -mb-px py-2 text-sm font-medium text-center justify-center gap-2 bg-accent dark:bg-card"
|
146 |
+
aria-label="Navigation"
|
147 |
+
>
|
148 |
+
<Tabs.Trigger
|
149 |
+
className="inline-flex items-center justify-center p-0.5 rounded-sm data-[state=active]:bg-gray-200 dark:data-[state=active]:bg-gray-700 h-10 w-10"
|
150 |
+
value="chats"
|
151 |
+
>
|
152 |
+
<ChatBubbleIcon className="w-5 h-5" />
|
153 |
+
{/* <span className="text-xs">Chats</span> */}
|
154 |
+
</Tabs.Trigger>
|
155 |
+
<Tabs.Trigger
|
156 |
+
className="inline-flex items-center justify-center p-0.5 rounded-sm data-[state=active]:bg-gray-200 dark:data-[state=active]:bg-gray-700 h-10 w-10"
|
157 |
+
value="settings"
|
158 |
+
>
|
159 |
+
<GearIcon className="w-5 h-5" />
|
160 |
+
{/* <span className="text-xs">Settings</span> */}
|
161 |
+
</Tabs.Trigger>
|
162 |
+
</Tabs.List>
|
163 |
+
</div>
|
164 |
+
</Tabs.Root>
|
165 |
+
);
|
166 |
+
|
167 |
+
export default SidebarTabs;
|
src/components/sidebar.tsx
ADDED
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
import { useEffect, useState } from "react";
|
3 |
+
|
4 |
+
import { Pencil2Icon } from "@radix-ui/react-icons";
|
5 |
+
import { Message } from "ai/react";
|
6 |
+
import Image from "next/image";
|
7 |
+
|
8 |
+
import OllamaLogo from "../../public/ollama.png";
|
9 |
+
import { ChatOptions } from "./chat/chat-options";
|
10 |
+
import SidebarTabs from "./sidebar-tabs";
|
11 |
+
import Link from "next/link";
|
12 |
+
|
13 |
+
interface SidebarProps {
|
14 |
+
isCollapsed: boolean;
|
15 |
+
onClick?: () => void;
|
16 |
+
isMobile: boolean;
|
17 |
+
chatId: string;
|
18 |
+
setChatId: React.Dispatch<React.SetStateAction<string>>;
|
19 |
+
chatOptions: ChatOptions;
|
20 |
+
setChatOptions: React.Dispatch<React.SetStateAction<ChatOptions>>;
|
21 |
+
}
|
22 |
+
|
23 |
+
interface Chats {
|
24 |
+
[key: string]: { chatId: string; messages: Message[] }[];
|
25 |
+
}
|
26 |
+
|
27 |
+
export function Sidebar({
|
28 |
+
isCollapsed,
|
29 |
+
isMobile,
|
30 |
+
chatId,
|
31 |
+
setChatId,
|
32 |
+
chatOptions,
|
33 |
+
setChatOptions,
|
34 |
+
}: SidebarProps) {
|
35 |
+
const [localChats, setLocalChats] = useState<Chats>({});
|
36 |
+
const [isLoading, setIsLoading] = useState(true);
|
37 |
+
|
38 |
+
useEffect(() => {
|
39 |
+
setLocalChats(getLocalstorageChats());
|
40 |
+
const handleStorageChange = () => {
|
41 |
+
setLocalChats(getLocalstorageChats());
|
42 |
+
};
|
43 |
+
window.addEventListener("storage", handleStorageChange);
|
44 |
+
return () => {
|
45 |
+
window.removeEventListener("storage", handleStorageChange);
|
46 |
+
};
|
47 |
+
}, [chatId]);
|
48 |
+
|
49 |
+
const getLocalstorageChats = (): Chats => {
|
50 |
+
const chats = Object.keys(localStorage).filter((key) =>
|
51 |
+
key.startsWith("chat_")
|
52 |
+
);
|
53 |
+
|
54 |
+
if (chats.length === 0) {
|
55 |
+
setIsLoading(false);
|
56 |
+
}
|
57 |
+
|
58 |
+
// Map through the chats and return an object with chatId and messages
|
59 |
+
const chatObjects = chats.map((chat) => {
|
60 |
+
const item = localStorage.getItem(chat);
|
61 |
+
return item
|
62 |
+
? { chatId: chat, messages: JSON.parse(item) }
|
63 |
+
: { chatId: "", messages: [] };
|
64 |
+
});
|
65 |
+
|
66 |
+
// Sort chats by the createdAt date of the first message of each chat
|
67 |
+
chatObjects.sort((a, b) => {
|
68 |
+
const aDate = new Date(a.messages[0].createdAt);
|
69 |
+
const bDate = new Date(b.messages[0].createdAt);
|
70 |
+
return bDate.getTime() - aDate.getTime();
|
71 |
+
});
|
72 |
+
|
73 |
+
const groupChatsByDate = (
|
74 |
+
chats: { chatId: string; messages: Message[] }[]
|
75 |
+
) => {
|
76 |
+
const today = new Date();
|
77 |
+
const yesterday = new Date(today);
|
78 |
+
yesterday.setDate(yesterday.getDate() - 1);
|
79 |
+
|
80 |
+
const groupedChats: Chats = {};
|
81 |
+
|
82 |
+
chats.forEach((chat) => {
|
83 |
+
const createdAt = new Date(chat.messages[0].createdAt ?? "");
|
84 |
+
const diffInDays = Math.floor(
|
85 |
+
(today.getTime() - createdAt.getTime()) / (1000 * 3600 * 24)
|
86 |
+
);
|
87 |
+
|
88 |
+
let group: string;
|
89 |
+
if (diffInDays === 0) {
|
90 |
+
group = "Today";
|
91 |
+
} else if (diffInDays === 1) {
|
92 |
+
group = "Yesterday";
|
93 |
+
} else if (diffInDays <= 7) {
|
94 |
+
group = "Previous 7 Days";
|
95 |
+
} else if (diffInDays <= 30) {
|
96 |
+
group = "Previous 30 Days";
|
97 |
+
} else {
|
98 |
+
group = "Older";
|
99 |
+
}
|
100 |
+
|
101 |
+
if (!groupedChats[group]) {
|
102 |
+
groupedChats[group] = [];
|
103 |
+
}
|
104 |
+
groupedChats[group].push(chat);
|
105 |
+
});
|
106 |
+
|
107 |
+
return groupedChats;
|
108 |
+
};
|
109 |
+
|
110 |
+
setIsLoading(false);
|
111 |
+
const groupedChats = groupChatsByDate(chatObjects);
|
112 |
+
|
113 |
+
return groupedChats;
|
114 |
+
};
|
115 |
+
|
116 |
+
const handleDeleteChat = (chatId: string) => {
|
117 |
+
localStorage.removeItem(chatId);
|
118 |
+
setLocalChats(getLocalstorageChats());
|
119 |
+
};
|
120 |
+
|
121 |
+
return (
|
122 |
+
<div
|
123 |
+
data-collapsed={isCollapsed}
|
124 |
+
className="relative justify-between group bg-accent/20 dark:bg-card/35 flex flex-col h-full gap-4 data-[collapsed=true]:p-0 data-[collapsed=true]:hidden"
|
125 |
+
>
|
126 |
+
<div className="sticky left-0 right-0 top-0 z-20 p-1 rounded-sm m-2">
|
127 |
+
<Link
|
128 |
+
className="flex w-full h-10 text-sm font-medium items-center
|
129 |
+
border border-input bg-background hover:bg-accent hover:text-accent-foreground
|
130 |
+
px-2 py-2 rounded-sm"
|
131 |
+
href="/"
|
132 |
+
onClick={() => {
|
133 |
+
setChatId("");
|
134 |
+
}}
|
135 |
+
>
|
136 |
+
<div className="flex gap-3 p-2 items-center justify-between w-full">
|
137 |
+
<div className="flex align-start gap-2">
|
138 |
+
{!isCollapsed && !isMobile && (
|
139 |
+
<Image
|
140 |
+
src={OllamaLogo}
|
141 |
+
alt="AI"
|
142 |
+
width={14}
|
143 |
+
height={14}
|
144 |
+
className="dark:invert 2xl:block"
|
145 |
+
/>
|
146 |
+
)}
|
147 |
+
<span>New chat</span>
|
148 |
+
</div>
|
149 |
+
<Pencil2Icon className="w-4 h-4" />
|
150 |
+
</div>
|
151 |
+
</Link>
|
152 |
+
</div>
|
153 |
+
<SidebarTabs
|
154 |
+
isLoading={isLoading}
|
155 |
+
localChats={localChats}
|
156 |
+
selectedChatId={chatId}
|
157 |
+
chatOptions={chatOptions}
|
158 |
+
setChatOptions={setChatOptions}
|
159 |
+
handleDeleteChat={handleDeleteChat}
|
160 |
+
/>
|
161 |
+
</div>
|
162 |
+
);
|
163 |
+
}
|
src/components/system-prompt.tsx
ADDED
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import { Dispatch, SetStateAction, useEffect, useState } from "react";
|
4 |
+
|
5 |
+
import { toast } from "sonner";
|
6 |
+
import { useDebounce } from "use-debounce";
|
7 |
+
|
8 |
+
import { useHasMounted } from "@/lib/utils";
|
9 |
+
import { ChatOptions } from "./chat/chat-options";
|
10 |
+
import { Textarea } from "./ui/textarea";
|
11 |
+
|
12 |
+
export interface SystemPromptProps {
|
13 |
+
chatOptions: ChatOptions;
|
14 |
+
setChatOptions: Dispatch<SetStateAction<ChatOptions>>;
|
15 |
+
}
|
16 |
+
export default function SystemPrompt({
|
17 |
+
chatOptions,
|
18 |
+
setChatOptions,
|
19 |
+
}: SystemPromptProps) {
|
20 |
+
const hasMounted = useHasMounted();
|
21 |
+
|
22 |
+
const systemPrompt = chatOptions ? chatOptions.systemPrompt : "";
|
23 |
+
const [text, setText] = useState<string>(systemPrompt || "");
|
24 |
+
const [debouncedText] = useDebounce(text, 500);
|
25 |
+
|
26 |
+
useEffect(() => {
|
27 |
+
if (!hasMounted) {
|
28 |
+
return;
|
29 |
+
}
|
30 |
+
if (debouncedText !== systemPrompt) {
|
31 |
+
setChatOptions({ ...chatOptions, systemPrompt: debouncedText });
|
32 |
+
toast.success("System prompt updated", { duration: 1000 });
|
33 |
+
}
|
34 |
+
}, [hasMounted, debouncedText]);
|
35 |
+
|
36 |
+
return (
|
37 |
+
<div>
|
38 |
+
<div className="justify-start gap-2 w-full rounded-sm px-2 text-xs">
|
39 |
+
<p>System prompt</p>
|
40 |
+
</div>
|
41 |
+
|
42 |
+
<div className="m-2">
|
43 |
+
<Textarea
|
44 |
+
className="resize-none bg-white/20 dark:bg-card/35"
|
45 |
+
autoComplete="off"
|
46 |
+
rows={7}
|
47 |
+
value={text}
|
48 |
+
onChange={(e) => setText(e.currentTarget.value)}
|
49 |
+
name="systemPrompt"
|
50 |
+
placeholder="You are a helpful assistant."
|
51 |
+
/>
|
52 |
+
</div>
|
53 |
+
</div>
|
54 |
+
);
|
55 |
+
}
|
src/components/ui/button.tsx
ADDED
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react"
|
2 |
+
import { Slot } from "@radix-ui/react-slot"
|
3 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
4 |
+
|
5 |
+
import { cn } from "@/lib/utils"
|
6 |
+
|
7 |
+
const buttonVariants = cva(
|
8 |
+
"inline-flex items-center justify-center whitespace-nowrap rounded-sm text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
9 |
+
{
|
10 |
+
variants: {
|
11 |
+
variant: {
|
12 |
+
default:
|
13 |
+
"bg-primary text-primary-foreground hover:bg-primary/90 border border-black bg-black p-0.5 text-white transition-colors enabled:bg-black disabled:text-gray-400 disabled:opacity-10 dark:border-white dark:bg-white dark:hover:bg-white",
|
14 |
+
destructive:
|
15 |
+
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
16 |
+
outline:
|
17 |
+
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
18 |
+
secondary:
|
19 |
+
"dark:bg-card/60 bg-accent/20 text-secondary-foreground hover:bg-secondary/60 hover:dark:bg-card/40",
|
20 |
+
ghost: "hover:bg-accent hover:text-accent-foreground",
|
21 |
+
link: "text-primary underline-offset-4 hover:underline",
|
22 |
+
secondaryLink: "bg-accent/90 dark:bg-secondary/80 text-secondary-foreground dark:hover:bg-secondary hover:bg-accent",
|
23 |
+
},
|
24 |
+
size: {
|
25 |
+
default: "h-9 px-4 py-2",
|
26 |
+
sm: "h-8 rounded-sm px-3 text-xs",
|
27 |
+
lg: "h-10 rounded-sm px-8",
|
28 |
+
icon: "h-10 w-10",
|
29 |
+
iconSm: "h-8 w-8"
|
30 |
+
},
|
31 |
+
},
|
32 |
+
defaultVariants: {
|
33 |
+
variant: "default",
|
34 |
+
size: "default",
|
35 |
+
},
|
36 |
+
}
|
37 |
+
)
|
38 |
+
|
39 |
+
export interface ButtonProps
|
40 |
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
41 |
+
VariantProps<typeof buttonVariants> {
|
42 |
+
asChild?: boolean
|
43 |
+
}
|
44 |
+
|
45 |
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
46 |
+
({ className, variant, size, asChild = false, ...props }, ref) => {
|
47 |
+
const Comp = asChild ? Slot : "button"
|
48 |
+
return (
|
49 |
+
<Comp
|
50 |
+
className={cn(buttonVariants({ variant, size, className }))}
|
51 |
+
ref={ref}
|
52 |
+
{...props}
|
53 |
+
/>
|
54 |
+
)
|
55 |
+
}
|
56 |
+
)
|
57 |
+
Button.displayName = "Button"
|
58 |
+
|
59 |
+
export { Button, buttonVariants }
|
src/components/ui/card.tsx
ADDED
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react"
|
2 |
+
|
3 |
+
import { cn } from "@/lib/utils"
|
4 |
+
|
5 |
+
const Card = React.forwardRef<
|
6 |
+
HTMLDivElement,
|
7 |
+
React.HTMLAttributes<HTMLDivElement>
|
8 |
+
>(({ className, ...props }, ref) => (
|
9 |
+
<div
|
10 |
+
ref={ref}
|
11 |
+
className={cn(
|
12 |
+
"rounded-md border bg-card text-card-foreground shadow",
|
13 |
+
className
|
14 |
+
)}
|
15 |
+
{...props}
|
16 |
+
/>
|
17 |
+
))
|
18 |
+
Card.displayName = "Card"
|
19 |
+
|
20 |
+
const CardHeader = React.forwardRef<
|
21 |
+
HTMLDivElement,
|
22 |
+
React.HTMLAttributes<HTMLDivElement>
|
23 |
+
>(({ className, ...props }, ref) => (
|
24 |
+
<div
|
25 |
+
ref={ref}
|
26 |
+
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
27 |
+
{...props}
|
28 |
+
/>
|
29 |
+
))
|
30 |
+
CardHeader.displayName = "CardHeader"
|
31 |
+
|
32 |
+
const CardTitle = React.forwardRef<
|
33 |
+
HTMLParagraphElement,
|
34 |
+
React.HTMLAttributes<HTMLHeadingElement>
|
35 |
+
>(({ className, ...props }, ref) => (
|
36 |
+
<h3
|
37 |
+
ref={ref}
|
38 |
+
className={cn("font-semibold leading-none tracking-tight", className)}
|
39 |
+
{...props}
|
40 |
+
/>
|
41 |
+
))
|
42 |
+
CardTitle.displayName = "CardTitle"
|
43 |
+
|
44 |
+
const CardDescription = React.forwardRef<
|
45 |
+
HTMLParagraphElement,
|
46 |
+
React.HTMLAttributes<HTMLParagraphElement>
|
47 |
+
>(({ className, ...props }, ref) => (
|
48 |
+
<p
|
49 |
+
ref={ref}
|
50 |
+
className={cn("text-sm text-muted-foreground", className)}
|
51 |
+
{...props}
|
52 |
+
/>
|
53 |
+
))
|
54 |
+
CardDescription.displayName = "CardDescription"
|
55 |
+
|
56 |
+
const CardContent = React.forwardRef<
|
57 |
+
HTMLDivElement,
|
58 |
+
React.HTMLAttributes<HTMLDivElement>
|
59 |
+
>(({ className, ...props }, ref) => (
|
60 |
+
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
61 |
+
))
|
62 |
+
CardContent.displayName = "CardContent"
|
63 |
+
|
64 |
+
const CardFooter = React.forwardRef<
|
65 |
+
HTMLDivElement,
|
66 |
+
React.HTMLAttributes<HTMLDivElement>
|
67 |
+
>(({ className, ...props }, ref) => (
|
68 |
+
<div
|
69 |
+
ref={ref}
|
70 |
+
className={cn("flex items-center p-6 pt-0", className)}
|
71 |
+
{...props}
|
72 |
+
/>
|
73 |
+
))
|
74 |
+
CardFooter.displayName = "CardFooter"
|
75 |
+
|
76 |
+
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
src/components/ui/dialog.tsx
ADDED
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import * as React from "react"
|
4 |
+
|
5 |
+
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
6 |
+
import { Cross2Icon } from "@radix-ui/react-icons"
|
7 |
+
|
8 |
+
import { cn } from "@/lib/utils"
|
9 |
+
|
10 |
+
const Dialog = DialogPrimitive.Root
|
11 |
+
|
12 |
+
const DialogTrigger = DialogPrimitive.Trigger
|
13 |
+
|
14 |
+
const DialogPortal = DialogPrimitive.Portal
|
15 |
+
|
16 |
+
const DialogClose = DialogPrimitive.Close
|
17 |
+
|
18 |
+
const DialogOverlay = React.forwardRef<
|
19 |
+
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
20 |
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
21 |
+
>(({ className, ...props }, ref) => (
|
22 |
+
<DialogPrimitive.Overlay
|
23 |
+
ref={ref}
|
24 |
+
className={cn(
|
25 |
+
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
26 |
+
className
|
27 |
+
)}
|
28 |
+
{...props}
|
29 |
+
/>
|
30 |
+
))
|
31 |
+
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
32 |
+
|
33 |
+
const DialogContent = React.forwardRef<
|
34 |
+
React.ElementRef<typeof DialogPrimitive.Content>,
|
35 |
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
36 |
+
>(({ className, children, ...props }, ref) => (
|
37 |
+
<DialogPortal>
|
38 |
+
<DialogOverlay />
|
39 |
+
<DialogPrimitive.Content
|
40 |
+
ref={ref}
|
41 |
+
className={cn(
|
42 |
+
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
43 |
+
className
|
44 |
+
)}
|
45 |
+
{...props}
|
46 |
+
>
|
47 |
+
{children}
|
48 |
+
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
49 |
+
<Cross2Icon className="h-4 w-4" />
|
50 |
+
<span className="sr-only">Close</span>
|
51 |
+
</DialogPrimitive.Close>
|
52 |
+
</DialogPrimitive.Content>
|
53 |
+
</DialogPortal>
|
54 |
+
))
|
55 |
+
DialogContent.displayName = DialogPrimitive.Content.displayName
|
56 |
+
|
57 |
+
const DialogHeader = ({
|
58 |
+
className,
|
59 |
+
...props
|
60 |
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
61 |
+
<div
|
62 |
+
className={cn(
|
63 |
+
"flex flex-col space-y-1.5 text-start sm:text-left",
|
64 |
+
className
|
65 |
+
)}
|
66 |
+
{...props}
|
67 |
+
/>
|
68 |
+
)
|
69 |
+
DialogHeader.displayName = "DialogHeader"
|
70 |
+
|
71 |
+
const DialogFooter = ({
|
72 |
+
className,
|
73 |
+
...props
|
74 |
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
75 |
+
<div
|
76 |
+
className={cn(
|
77 |
+
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
78 |
+
className
|
79 |
+
)}
|
80 |
+
{...props}
|
81 |
+
/>
|
82 |
+
)
|
83 |
+
DialogFooter.displayName = "DialogFooter"
|
84 |
+
|
85 |
+
const DialogTitle = React.forwardRef<
|
86 |
+
React.ElementRef<typeof DialogPrimitive.Title>,
|
87 |
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
88 |
+
>(({ className, ...props }, ref) => (
|
89 |
+
<DialogPrimitive.Title
|
90 |
+
ref={ref}
|
91 |
+
className={cn(
|
92 |
+
"text-lg font-semibold leading-none tracking-tight",
|
93 |
+
className
|
94 |
+
)}
|
95 |
+
{...props}
|
96 |
+
/>
|
97 |
+
))
|
98 |
+
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
99 |
+
|
100 |
+
const DialogDescription = React.forwardRef<
|
101 |
+
React.ElementRef<typeof DialogPrimitive.Description>,
|
102 |
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
103 |
+
>(({ className, ...props }, ref) => (
|
104 |
+
<DialogPrimitive.Description
|
105 |
+
ref={ref}
|
106 |
+
className={cn("text-sm text-muted-foreground", className)}
|
107 |
+
{...props}
|
108 |
+
/>
|
109 |
+
))
|
110 |
+
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
111 |
+
|
112 |
+
export {
|
113 |
+
Dialog, DialogClose,
|
114 |
+
DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger
|
115 |
+
}
|
src/components/ui/input.tsx
ADDED
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react"
|
2 |
+
|
3 |
+
import { cn } from "@/lib/utils"
|
4 |
+
|
5 |
+
export interface InputProps
|
6 |
+
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
7 |
+
|
8 |
+
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
9 |
+
({ className, type, ...props }, ref) => {
|
10 |
+
return (
|
11 |
+
<input
|
12 |
+
type={type}
|
13 |
+
className={cn(
|
14 |
+
"flex h-9 w-full rounded-sm border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
15 |
+
className
|
16 |
+
)}
|
17 |
+
ref={ref}
|
18 |
+
{...props}
|
19 |
+
/>
|
20 |
+
)
|
21 |
+
}
|
22 |
+
)
|
23 |
+
Input.displayName = "Input"
|
24 |
+
|
25 |
+
export { Input }
|
src/components/ui/sheet.tsx
ADDED
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import * as React from "react";
|
4 |
+
|
5 |
+
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
6 |
+
import { CrossCircledIcon } from "@radix-ui/react-icons";
|
7 |
+
import { cva, type VariantProps } from "class-variance-authority";
|
8 |
+
|
9 |
+
import { cn } from "@/lib/utils";
|
10 |
+
|
11 |
+
const Sheet = SheetPrimitive.Root;
|
12 |
+
const SheetTrigger = SheetPrimitive.Trigger;
|
13 |
+
const SheetClose = SheetPrimitive.Close;
|
14 |
+
const SheetPortal = SheetPrimitive.Portal;
|
15 |
+
|
16 |
+
const SheetOverlay = React.forwardRef<
|
17 |
+
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
18 |
+
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
19 |
+
>(({ className, ...props }, ref) => (
|
20 |
+
<SheetPrimitive.Overlay
|
21 |
+
className={cn(
|
22 |
+
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
23 |
+
className
|
24 |
+
)}
|
25 |
+
{...props}
|
26 |
+
ref={ref}
|
27 |
+
/>
|
28 |
+
));
|
29 |
+
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
30 |
+
|
31 |
+
const sheetVariants = cva(
|
32 |
+
"fixed z-50 gap-4 bg-background shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
33 |
+
{
|
34 |
+
variants: {
|
35 |
+
side: {
|
36 |
+
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
37 |
+
bottom:
|
38 |
+
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
39 |
+
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
40 |
+
right:
|
41 |
+
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
42 |
+
},
|
43 |
+
},
|
44 |
+
defaultVariants: {
|
45 |
+
side: "right",
|
46 |
+
},
|
47 |
+
}
|
48 |
+
);
|
49 |
+
|
50 |
+
interface SheetContentProps
|
51 |
+
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
52 |
+
VariantProps<typeof sheetVariants> {}
|
53 |
+
|
54 |
+
const SheetContent = React.forwardRef<
|
55 |
+
React.ElementRef<typeof SheetPrimitive.Content>,
|
56 |
+
SheetContentProps
|
57 |
+
>(({ side = "right", className, children, ...props }, ref) => (
|
58 |
+
<SheetPortal>
|
59 |
+
<SheetOverlay />
|
60 |
+
<SheetPrimitive.Content
|
61 |
+
ref={ref}
|
62 |
+
className={cn(sheetVariants({ side }), className)}
|
63 |
+
{...props}
|
64 |
+
>
|
65 |
+
{children}
|
66 |
+
</SheetPrimitive.Content>
|
67 |
+
<SheetPrimitive.Close className="absolute w-1/4 pl-4 pt-4 z-200 right-0 top-0 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
68 |
+
<CrossCircledIcon className="h-8 w-8" />
|
69 |
+
<span className="sr-only">Close</span>
|
70 |
+
</SheetPrimitive.Close>
|
71 |
+
</SheetPortal>
|
72 |
+
));
|
73 |
+
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
74 |
+
|
75 |
+
const SheetHeader = ({
|
76 |
+
className,
|
77 |
+
...props
|
78 |
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
79 |
+
<div
|
80 |
+
className={cn(
|
81 |
+
"flex flex-col space-y-2 text-center sm:text-left",
|
82 |
+
className
|
83 |
+
)}
|
84 |
+
{...props}
|
85 |
+
/>
|
86 |
+
);
|
87 |
+
SheetHeader.displayName = "SheetHeader";
|
88 |
+
|
89 |
+
const SheetFooter = ({
|
90 |
+
className,
|
91 |
+
...props
|
92 |
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
93 |
+
<div
|
94 |
+
className={cn(
|
95 |
+
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
96 |
+
className
|
97 |
+
)}
|
98 |
+
{...props}
|
99 |
+
/>
|
100 |
+
);
|
101 |
+
SheetFooter.displayName = "SheetFooter";
|
102 |
+
|
103 |
+
const SheetTitle = React.forwardRef<
|
104 |
+
React.ElementRef<typeof SheetPrimitive.Title>,
|
105 |
+
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
106 |
+
>(({ className, ...props }, ref) => (
|
107 |
+
<SheetPrimitive.Title
|
108 |
+
ref={ref}
|
109 |
+
className={cn("text-lg font-semibold text-foreground", className)}
|
110 |
+
{...props}
|
111 |
+
/>
|
112 |
+
));
|
113 |
+
SheetTitle.displayName = SheetPrimitive.Title.displayName;
|
114 |
+
|
115 |
+
const SheetDescription = React.forwardRef<
|
116 |
+
React.ElementRef<typeof SheetPrimitive.Description>,
|
117 |
+
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
118 |
+
>(({ className, ...props }, ref) => (
|
119 |
+
<SheetPrimitive.Description
|
120 |
+
ref={ref}
|
121 |
+
className={cn("text-sm text-muted-foreground", className)}
|
122 |
+
{...props}
|
123 |
+
/>
|
124 |
+
));
|
125 |
+
SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
126 |
+
|
127 |
+
export {
|
128 |
+
Sheet,
|
129 |
+
SheetClose,
|
130 |
+
SheetContent,
|
131 |
+
SheetDescription,
|
132 |
+
SheetFooter,
|
133 |
+
SheetHeader,
|
134 |
+
SheetOverlay,
|
135 |
+
SheetPortal,
|
136 |
+
SheetTitle,
|
137 |
+
SheetTrigger,
|
138 |
+
};
|
src/components/ui/skeleton.tsx
ADDED
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { cn } from "@/lib/utils"
|
2 |
+
|
3 |
+
function Skeleton({
|
4 |
+
className,
|
5 |
+
...props
|
6 |
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
7 |
+
return (
|
8 |
+
<div
|
9 |
+
className={cn("animate-pulse rounded-sm bg-primary/10", className)}
|
10 |
+
{...props}
|
11 |
+
/>
|
12 |
+
)
|
13 |
+
}
|
14 |
+
|
15 |
+
export { Skeleton }
|
src/components/ui/sonner.tsx
ADDED
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import { useTheme } from "next-themes"
|
4 |
+
import { Toaster as Sonner } from "sonner"
|
5 |
+
|
6 |
+
type ToasterProps = React.ComponentProps<typeof Sonner>
|
7 |
+
|
8 |
+
const Toaster = ({ ...props }: ToasterProps) => {
|
9 |
+
const { theme = "system" } = useTheme()
|
10 |
+
|
11 |
+
return (
|
12 |
+
<Sonner
|
13 |
+
theme={theme as ToasterProps["theme"]}
|
14 |
+
className="toaster group"
|
15 |
+
toastOptions={{
|
16 |
+
classNames: {
|
17 |
+
toast:
|
18 |
+
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
19 |
+
description: "group-[.toast]:text-muted-foreground",
|
20 |
+
actionButton:
|
21 |
+
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
22 |
+
cancelButton:
|
23 |
+
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
24 |
+
},
|
25 |
+
}}
|
26 |
+
{...props}
|
27 |
+
/>
|
28 |
+
)
|
29 |
+
}
|
30 |
+
|
31 |
+
export { Toaster }
|
src/components/ui/tabs.tsx
ADDED
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import * as React from "react"
|
4 |
+
|
5 |
+
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
6 |
+
|
7 |
+
import { cn } from "@/lib/utils"
|
8 |
+
|
9 |
+
const TooltipProvider = TooltipPrimitive.Provider
|
10 |
+
|
11 |
+
const Tooltip = TooltipPrimitive.Root
|
12 |
+
|
13 |
+
const TooltipTrigger = TooltipPrimitive.Trigger
|
14 |
+
|
15 |
+
const TooltipContent = React.forwardRef<
|
16 |
+
React.ElementRef<typeof TooltipPrimitive.Content>,
|
17 |
+
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
18 |
+
>(({ className, sideOffset = 4, ...props }, ref) => (
|
19 |
+
<TooltipPrimitive.Content
|
20 |
+
ref={ref}
|
21 |
+
sideOffset={sideOffset}
|
22 |
+
className={cn(
|
23 |
+
"z-50 overflow-hidden rounded-sm bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
24 |
+
className
|
25 |
+
)}
|
26 |
+
{...props}
|
27 |
+
/>
|
28 |
+
))
|
29 |
+
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
30 |
+
|
31 |
+
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
src/components/ui/textarea.tsx
ADDED
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react"
|
2 |
+
|
3 |
+
import { cn } from "@/lib/utils"
|
4 |
+
|
5 |
+
export interface TextareaProps
|
6 |
+
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
7 |
+
|
8 |
+
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
9 |
+
({ className, ...props }, ref) => {
|
10 |
+
return (
|
11 |
+
<textarea
|
12 |
+
className={cn(
|
13 |
+
"flex w-full border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
14 |
+
className
|
15 |
+
)}
|
16 |
+
ref={ref}
|
17 |
+
{...props}
|
18 |
+
/>
|
19 |
+
)
|
20 |
+
}
|
21 |
+
)
|
22 |
+
Textarea.displayName = "Textarea"
|
23 |
+
|
24 |
+
export { Textarea }
|
src/components/ui/tooltip.tsx
ADDED
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import * as React from "react"
|
4 |
+
|
5 |
+
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
6 |
+
|
7 |
+
import { cn } from "@/lib/utils"
|
8 |
+
|
9 |
+
const TooltipProvider = TooltipPrimitive.Provider
|
10 |
+
|
11 |
+
const Tooltip = TooltipPrimitive.Root
|
12 |
+
|
13 |
+
const TooltipTrigger = TooltipPrimitive.Trigger
|
14 |
+
|
15 |
+
const TooltipContent = React.forwardRef<
|
16 |
+
React.ElementRef<typeof TooltipPrimitive.Content>,
|
17 |
+
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
18 |
+
>(({ className, sideOffset = 4, ...props }, ref) => (
|
19 |
+
<TooltipPrimitive.Content
|
20 |
+
ref={ref}
|
21 |
+
sideOffset={sideOffset}
|
22 |
+
className={cn(
|
23 |
+
"z-50 overflow-hidden rounded-sm bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
24 |
+
className
|
25 |
+
)}
|
26 |
+
{...props}
|
27 |
+
/>
|
28 |
+
))
|
29 |
+
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
30 |
+
|
31 |
+
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
src/lib/mistral-tokenizer-js.d.ts
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
declare module "mistral-tokenizer-js" {
|
2 |
+
export function encode(input: string): string[];
|
3 |
+
}
|
src/lib/token-counter.ts
ADDED
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Message } from "ai";
|
2 |
+
import mistralTokenizer from "mistral-tokenizer-js";
|
3 |
+
import { ChatCompletionMessageParam } from "openai/resources/index.mjs";
|
4 |
+
|
5 |
+
export const tokenLimit = process.env.NEXT_PUBLIC_TOKEN_LIMIT ? parseInt(process.env.NEXT_PUBLIC_TOKEN_LIMIT) : 4096;
|
6 |
+
|
7 |
+
export const encodeChat = (
|
8 |
+
messages: Message[] | ChatCompletionMessageParam[]
|
9 |
+
): number => {
|
10 |
+
const tokensPerMessage = 3;
|
11 |
+
let numTokens = 0;
|
12 |
+
for (const message of messages) {
|
13 |
+
numTokens += tokensPerMessage;
|
14 |
+
numTokens += mistralTokenizer.encode(message.role).length;
|
15 |
+
if (typeof message.content === "string") {
|
16 |
+
numTokens += mistralTokenizer.encode(message.content).length;
|
17 |
+
}
|
18 |
+
}
|
19 |
+
numTokens += 3;
|
20 |
+
return numTokens;
|
21 |
+
};
|