├── .eslintrc.json
├── .example.env
├── .gitignore
├── Dockerfile
├── LICENSE
├── README.md
├── components.json
├── next.config.mjs
├── ollama-nextjs-ui.gif
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
├── next.svg
├── ollama.png
├── user.jpg
└── vercel.svg
├── src
├── app
│ ├── (chat)
│ │ ├── c
│ │ │ └── [id]
│ │ │ │ └── page.tsx
│ │ ├── layout.tsx
│ │ └── page.tsx
│ ├── api
│ │ ├── chat
│ │ │ └── route.ts
│ │ ├── model
│ │ │ └── route.ts
│ │ └── tags
│ │ │ └── route.ts
│ ├── favicon.ico
│ ├── globals.css
│ └── hooks
│ │ ├── useChatStore.ts
│ │ └── useSpeechRecognition.ts
├── components
│ ├── button-with-tooltip.tsx
│ ├── chat
│ │ ├── chat-bottombar.tsx
│ │ ├── chat-layout.tsx
│ │ ├── chat-list.tsx
│ │ ├── chat-message.tsx
│ │ ├── chat-topbar.tsx
│ │ └── chat.tsx
│ ├── code-display-block.tsx
│ ├── edit-username-form.tsx
│ ├── emoji-picker.tsx
│ ├── image-embedder.tsx
│ ├── mode-toggle.tsx
│ ├── pull-model-form.tsx
│ ├── pull-model.tsx
│ ├── sidebar-skeleton.tsx
│ ├── sidebar.tsx
│ ├── ui
│ │ ├── avatar.tsx
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── chat
│ │ │ ├── chat-bubble.tsx
│ │ │ ├── chat-input.tsx
│ │ │ ├── chat-message-list.tsx
│ │ │ ├── expandable-chat.tsx
│ │ │ ├── hooks
│ │ │ │ └── useAutoScroll.tsx
│ │ │ └── message-loading.tsx
│ │ ├── dialog.tsx
│ │ ├── dropdown-menu.tsx
│ │ ├── form.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── popover.tsx
│ │ ├── resizable.tsx
│ │ ├── select.tsx
│ │ ├── sheet.tsx
│ │ ├── skeleton.tsx
│ │ ├── sonner.tsx
│ │ ├── textarea.tsx
│ │ └── tooltip.tsx
│ ├── user-settings.tsx
│ └── username-form.tsx
├── lib
│ ├── model-helper.ts
│ └── utils.ts
├── providers
│ └── theme-provider.tsx
└── utils
│ └── initial-questions.ts
├── tailwind.config.ts
└── tsconfig.json
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/.example.env:
--------------------------------------------------------------------------------
1 | OLLAMA_URL="http://localhost:11434"
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
38 | # env
39 | .env
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Use Node.js as the base image
2 | FROM node:20-alpine AS builder
3 |
4 | WORKDIR /app
5 |
6 | COPY package.json package-lock.json ./
7 |
8 | RUN npm ci
9 |
10 | # Set a build-time argument for OLLAMA_URL with a default value
11 | ARG OLLAMA_URL=http://127.0.0.1:11434
12 | ENV OLLAMA_URL=${OLLAMA_URL}
13 |
14 | COPY . .
15 |
16 | RUN npm run build
17 |
18 | FROM node:20-alpine
19 |
20 | WORKDIR /app
21 |
22 | # Copy built files from the builder stage
23 | COPY --from=builder /app/.next ./.next
24 | COPY --from=builder /app/public ./public
25 | COPY --from=builder /app/package.json ./package.json
26 | COPY --from=builder /app/package-lock.json ./package-lock.json
27 | COPY --from=builder /app/node_modules ./node_modules
28 |
29 | # Set environment variable with a default value that can be overridden at runtime
30 | ENV OLLAMA_URL=http://127.0.0.1:11434
31 | ENV PORT=3000
32 |
33 | EXPOSE 3000
34 |
35 | CMD ["npm", "start"]
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
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.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |

3 |
4 |
5 |
6 | Fully-featured web interface for Ollama LLMs
7 |
8 |
9 |
10 |
11 | 
12 |
13 |
14 |
15 | Get up and running with Large Language Models **quickly**, **locally** and even **offline**.
16 | This project aims to be the easiest way for you to get started with LLMs. No tedious and annoying setup required!
17 |
18 | > This is a hobby project. If you want a more complete experience, I suggest taking a look at [this](https://github.com/open-webui/open-webui) instead.
19 |
20 | # Features ✨
21 |
22 | - **Beautiful & intuitive UI:** Inspired by ChatGPT, to enhance similarity in the user experience.
23 | - **Fully local:** Stores chats in localstorage for convenience. No need to run a database.
24 | - **Fully responsive:** Use your phone to chat, with the same ease as on desktop.
25 | - **Easy setup:** No tedious and annoying setup required. Just clone the repo and you're good to go!
26 | - **Code syntax highligting:** Messages that include code, will be highlighted for easy access.
27 | - **Copy codeblocks easily:** Easily copy the highlighted code with one click.
28 | - **Download/Pull & Delete models:** Easily download and delete models directly from the interface.
29 | - **Switch between models:** Switch between models fast with a click.
30 | - **Chat history:** Chats are saved and easily accessed.
31 | - **Light & Dark mode:** Switch between light & dark mode.
32 |
33 | # Preview
34 |
35 | https://github.com/jakobhoeg/nextjs-ollama-llm-ui/assets/114422072/08eaed4f-9deb-4e1b-b87a-ba17d81b9a02
36 |
37 | # Requisites ⚙️
38 |
39 | To use the web interface, these requisites must be met:
40 |
41 | 1. Download [Ollama](https://ollama.com/download) and have it running. Or run it in a Docker container. Check the [docs](https://github.com/ollama/ollama) for instructions.
42 | 2. Node.js (18+) and npm is required. [Download](https://nodejs.org/en/download)
43 |
44 | # Quick start with Docker
45 |
46 | ## Installation with prebuilt Docker image
47 |
48 | - **If Ollama is running on your pc**:
49 |
50 | ```
51 | docker run -d -p 8080:3000 --add-host=host.docker.internal:host-gateway -e OLLAMA_URL=http://host.docker.internal:11434 --name nextjs-ollama-ui --restart always jakobhoeg/nextjs-ollama-ui:latest
52 | ```
53 |
54 | - **If Ollama is on a different server than the Web UI**:
55 |
56 | ```
57 | docker run -d -p 8080:3000 --add-host=host.docker.internal:host-gateway -e OLLAMA_URL=http://example.com:11434 --name nextjs-ollama-ui --restart always jakobhoeg/nextjs-ollama-ui:latest
58 | ```
59 |
60 | > You can also change the default 8080 port if you wish.
61 |
62 | # Installation locally 📖
63 |
64 | [](https://repology.org/project/nextjs-ollama-llm-ui/versions)
65 |
66 | Use a pre-build package from one of the supported package managers to run a local environment of the web interface.
67 | Alternatively you can install from source with the instructions below.
68 |
69 | > [!NOTE]
70 | > If your frontend runs on something other than `http://localhost` or `http://127.0.0.1`, you'll need to set the OLLAMA_ORIGINS to your frontend url.
71 | >
72 | > This is also stated in the [documentation](https://github.com/ollama/ollama/blob/main/docs/faq.md#how-do-i-configure-ollama-server):
73 | >
74 | > `Ollama allows cross-origin requests from 127.0.0.1 and 0.0.0.0 by default. Additional origins can be configured with OLLAMA_ORIGINS`
75 |
76 | ## Install from source
77 |
78 | **1. Clone the repository to a directory on your pc via command prompt:**
79 |
80 | ```
81 | git clone https://github.com/jakobhoeg/nextjs-ollama-llm-ui
82 | ```
83 |
84 | **2. Open the folder:**
85 |
86 | ```
87 | cd nextjs-ollama-llm-ui
88 | ```
89 |
90 | **3. Rename the `.example.env` to `.env`:**
91 |
92 | ```
93 | mv .example.env .env
94 | ```
95 |
96 | **4. If your instance of Ollama is NOT running on the default ip-address and port, change the variable in the .env file to fit your usecase:**
97 |
98 | ```
99 | OLLAMA_URL="http://localhost:11434"
100 | ```
101 |
102 | **5. Install dependencies:**
103 |
104 | ```
105 | npm install
106 | ```
107 |
108 | **6. Start the development server:**
109 |
110 | ```
111 | npm run dev
112 | ```
113 |
114 | **5. Go to [localhost:3000](http://localhost:3000) and start chatting with your favourite model!**
115 |
116 | # Upcoming features
117 |
118 | This is a to-do list consisting of upcoming features.
119 |
120 | - ✅ Voice input support
121 | - ✅ Code syntax highlighting
122 | - ✅ Ability to send an image in the prompt to utilize vision language models.
123 | - ✅ Ability to regenerate responses
124 | - ⬜️ Import and export chats
125 |
126 | # Tech stack
127 |
128 | [NextJS](https://nextjs.org/) - React Framework for the Web
129 |
130 | [TailwindCSS](https://tailwindcss.com/) - Utility-first CSS framework
131 |
132 | [shadcn-ui](https://ui.shadcn.com/) - UI component built using Radix UI and Tailwind CSS
133 |
134 | [shadcn-chat](https://github.com/jakobhoeg/shadcn-chat) - Chat components for NextJS/React projects
135 |
136 | [Framer Motion](https://www.framer.com/motion/) - Motion/animation library for React
137 |
138 | [Lucide Icons](https://lucide.dev/) - Icon library
139 |
140 | # Helpful links
141 |
142 | [Medium Article](https://medium.com/@bartek.lewicz/launch-your-own-chatgpt-clone-for-free-on-colab-shareable-and-online-in-less-than-10-minutes-da19e44be5eb) - How to launch your own ChatGPT clone for free on Google Colab. By Bartek Lewicz.
143 |
144 | [Lobehub mention](https://lobehub.com/blog/5-ollama-web-ui-recommendation#5-next-js-ollama-llm-ui) - Five Excellent Free Ollama WebUI Client Recommendations
145 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
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:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | output: 'standalone',
4 | webpack: (config, { isServer }) => {
5 | // Fixes npm packages that depend on `fs` module
6 | if (!isServer) {
7 | config.resolve.fallback = {
8 | ...config.resolve.fallback, // if you miss it, all the other options in fallback, specified
9 | // by next.js will be dropped. Doesn't make much sense, but how it is
10 | fs: false, // the solution
11 | module: false,
12 | perf_hooks: false,
13 | };
14 | }
15 |
16 | return config
17 | },
18 | typescript: {
19 | // !! WARN !!
20 | // Dangerously allow production builds to successfully complete even if
21 | // your project has type errors.
22 | // !! WARN !!
23 | ignoreBuildErrors: true,
24 | },
25 | };
26 |
27 |
28 |
29 | export default nextConfig;
30 |
--------------------------------------------------------------------------------
/ollama-nextjs-ui.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jakobhoeg/nextjs-ollama-llm-ui/79c53f4ab0db3ebe18e9389e56eb639e1371c75f/ollama-nextjs-ui.gif
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nextjs-ollama-local-ai",
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 | "@emoji-mart/data": "^1.2.1",
13 | "@emoji-mart/react": "^1.1.1",
14 | "@hookform/resolvers": "^3.9.0",
15 | "@langchain/community": "^0.3.1",
16 | "@langchain/core": "^0.3.3",
17 | "@radix-ui/react-avatar": "^1.1.0",
18 | "@radix-ui/react-dialog": "^1.1.1",
19 | "@radix-ui/react-dropdown-menu": "^2.1.1",
20 | "@radix-ui/react-icons": "^1.3.0",
21 | "@radix-ui/react-label": "^2.1.0",
22 | "@radix-ui/react-popover": "^1.1.1",
23 | "@radix-ui/react-scroll-area": "^1.1.0",
24 | "@radix-ui/react-select": "^2.1.1",
25 | "@radix-ui/react-slot": "^1.1.0",
26 | "@radix-ui/react-tooltip": "^1.1.2",
27 | "@tanstack/react-query": "^5.62.15",
28 | "@types/dom-speech-recognition": "^0.0.4",
29 | "ai": "^4.0.33",
30 | "class-variance-authority": "^0.7.0",
31 | "clsx": "^2.1.1",
32 | "emoji-mart": "^5.6.0",
33 | "framer-motion": "^11.5.6",
34 | "langchain": "^0.3.2",
35 | "lodash": "^4.17.21",
36 | "lucide-react": "^0.445.0",
37 | "next": "^14.2.13",
38 | "next-themes": "^0.3.0",
39 | "ollama-ai-provider": "^0.15.0",
40 | "react": "^18.3.1",
41 | "react-code-blocks": "^0.1.6",
42 | "react-dom": "^18.3.1",
43 | "react-dropzone": "^14.2.9",
44 | "react-hook-form": "^7.53.0",
45 | "react-markdown": "^9.0.1",
46 | "react-resizable-panels": "^2.1.3",
47 | "react-textarea-autosize": "^8.5.3",
48 | "remark-gfm": "^4.0.0",
49 | "sharp": "^0.33.5",
50 | "sonner": "^1.5.0",
51 | "tailwind-merge": "^2.5.2",
52 | "tailwindcss-animate": "^1.0.7",
53 | "uuid": "^10.0.0",
54 | "zod": "^3.23.8",
55 | "zustand": "^5.0.0-rc.2"
56 | },
57 | "devDependencies": {
58 | "@types/lodash": "^4.17.14",
59 | "@types/node": "^22.5.5",
60 | "@types/react": "^18.3.8",
61 | "@types/react-dom": "^18.3.0",
62 | "@types/uuid": "^10.0.0",
63 | "autoprefixer": "^10.4.20",
64 | "eslint": "^8.0.0",
65 | "eslint-config-next": "14.2.13",
66 | "postcss": "^8.4.47",
67 | "tailwindcss": "^3.4.12",
68 | "typescript": "^5.6.2"
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/ollama.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jakobhoeg/nextjs-ollama-llm-ui/79c53f4ab0db3ebe18e9389e56eb639e1371c75f/public/ollama.png
--------------------------------------------------------------------------------
/public/user.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jakobhoeg/nextjs-ollama-llm-ui/79c53f4ab0db3ebe18e9389e56eb639e1371c75f/public/user.jpg
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/(chat)/c/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ChatLayout } from "@/components/chat/chat-layout";
4 | import React, { Suspense } from "react";
5 | import { notFound } from "next/navigation";
6 | import useChatStore from "@/app/hooks/useChatStore";
7 |
8 | export default function Page({ params }: { params: { id: string } }) {
9 | const id = params.id;
10 |
11 | const getChatById = useChatStore((state) => state.getChatById);
12 | const chat = getChatById(id);
13 |
14 | if (!chat) {
15 | return notFound();
16 | }
17 |
18 | return (
19 |
20 |
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/src/app/(chat)/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Inter } from "next/font/google";
3 | import "../globals.css";
4 | import { ThemeProvider } from "@/providers/theme-provider";
5 | import { Toaster } from "@/components/ui/sonner";
6 |
7 | const inter = Inter({ subsets: ["latin"] });
8 |
9 | export const metadata: Metadata = {
10 | title: "Ollama UI",
11 | description: "Ollama 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 |
28 |
29 |
30 | {children}
31 |
32 |
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/src/app/(chat)/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ChatLayout } from "@/components/chat/chat-layout";
4 | import {
5 | Dialog,
6 | DialogDescription,
7 | DialogHeader,
8 | DialogTitle,
9 | DialogContent,
10 | } from "@/components/ui/dialog";
11 | import UsernameForm from "@/components/username-form";
12 | import { generateUUID } from "@/lib/utils";
13 | import React from "react";
14 | import useChatStore from "../hooks/useChatStore";
15 |
16 | export default function Home() {
17 | const id = generateUUID();
18 | const [open, setOpen] = React.useState(false);
19 | const userName = useChatStore((state) => state.userName);
20 | const setUserName = useChatStore((state) => state.setUserName);
21 |
22 | const onOpenChange = (isOpen: boolean) => {
23 | if (userName) return setOpen(isOpen);
24 |
25 | setUserName("Anonymous");
26 | setOpen(isOpen);
27 | };
28 |
29 | return (
30 |
31 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/src/app/api/chat/route.ts:
--------------------------------------------------------------------------------
1 | import { createOllama } from 'ollama-ai-provider';
2 | import { streamText, convertToCoreMessages, CoreMessage, UserContent } from 'ai';
3 |
4 | export const runtime = "edge";
5 | export const dynamic = "force-dynamic";
6 |
7 | export async function POST(req: Request) {
8 | // Destructure request data
9 | const { messages, selectedModel, data } = await req.json();
10 |
11 | const ollamaUrl = process.env.OLLAMA_URL;
12 |
13 | const initialMessages = messages.slice(0, -1);
14 | const currentMessage = messages[messages.length - 1];
15 |
16 | const ollama = createOllama({baseURL: ollamaUrl + "/api"});
17 |
18 | // Build message content array directly
19 | const messageContent: UserContent = [{ type: 'text', text: currentMessage.content }];
20 |
21 | // Add images if they exist
22 | data?.images?.forEach((imageUrl: string) => {
23 | const image = new URL(imageUrl);
24 | messageContent.push({ type: 'image', image });
25 | });
26 |
27 | // Stream text using the ollama model
28 | const result = await streamText({
29 | model: ollama(selectedModel),
30 | messages: [
31 | ...convertToCoreMessages(initialMessages),
32 | { role: 'user', content: messageContent },
33 | ],
34 | });
35 |
36 | return result.toDataStreamResponse();
37 | }
38 |
--------------------------------------------------------------------------------
/src/app/api/model/route.ts:
--------------------------------------------------------------------------------
1 | // app/api/model/route.ts
2 | export async function POST(req: Request) {
3 | const { name } = await req.json();
4 |
5 | const ollamaUrl = process.env.OLLAMA_URL;
6 |
7 | const response = await fetch(ollamaUrl + "/api/pull", {
8 | method: "POST",
9 | body: JSON.stringify({ name }),
10 | });
11 |
12 | if (!response.ok) {
13 | throw new Error("Failed to pull model");
14 | }
15 |
16 | const contentLength = response.headers.get("content-length");
17 | const totalBytes = contentLength ? parseInt(contentLength, 10) : null;
18 |
19 | const stream = createProgressStream(response.body, totalBytes);
20 |
21 | const headers = new Headers(response.headers);
22 | headers.set("Content-Type", "application/json");
23 | return new Response(stream, { headers });
24 | }
25 |
26 | function createProgressStream(
27 | body: ReadableStream | null,
28 | totalBytes: number | null
29 | ): ReadableStream {
30 | return new ReadableStream({
31 | async start(controller) {
32 | const reader = body?.getReader();
33 | if (!reader) {
34 | controller.close();
35 | return;
36 | }
37 |
38 | let receivedBytes = 0;
39 |
40 | while (true) {
41 | const { done, value } = await reader.read();
42 | if (done) {
43 | const progressMessage = JSON.stringify({ progress: 100 });
44 | controller.enqueue(new TextEncoder().encode(progressMessage + "\n"));
45 | controller.close();
46 | return;
47 | }
48 |
49 | receivedBytes += value.length;
50 | const progress = totalBytes ? (receivedBytes / totalBytes) * 100 : null;
51 |
52 | const progressMessage = JSON.stringify({ progress });
53 | controller.enqueue(new TextEncoder().encode(progressMessage + "\n"));
54 |
55 | controller.enqueue(value);
56 | }
57 | },
58 | });
59 | }
--------------------------------------------------------------------------------
/src/app/api/tags/route.ts:
--------------------------------------------------------------------------------
1 | export const dynamic = "force-dynamic";
2 | export const revalidate = 0;
3 |
4 | export async function GET(req: Request) {
5 | const OLLAMA_URL = process.env.OLLAMA_URL;
6 | console.log('OLLAMA_URL:', process.env.OLLAMA_URL);
7 | const res = await fetch(
8 | OLLAMA_URL + "/api/tags"
9 | );
10 | return new Response(res.body, res);
11 | }
12 |
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jakobhoeg/nextjs-ollama-llm-ui/79c53f4ab0db3ebe18e9389e56eb639e1371c75f/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 240 10% 3.9%;
9 | --card: 0 0% 100%;
10 | --card-foreground: 240 10% 3.9%;
11 | --popover: 0 0% 100%;
12 | --popover-foreground: 240 10% 3.9%;
13 | --primary: 240 5.9% 10%;
14 | --primary-foreground: 0 0% 98%;
15 | --secondary: 240 4.8% 95.9%;
16 | --secondary-foreground: 240 5.9% 10%;
17 | --muted: 240 4.8% 95.9%;
18 | --muted-foreground: 240 3.8% 46.1%;
19 | --accent: 240 4.8% 95.9%;
20 | --accent-foreground: 240 5.9% 10%;
21 | --destructive: 0 84.2% 60.2%;
22 | --destructive-foreground: 0 0% 98%;
23 | --border: 240 5.9% 90%;
24 | --input: 240 5.9% 90%;
25 | --ring: 240 5.9% 10%;
26 | --radius: 1rem;
27 | }
28 |
29 | .dark {
30 | --background: 0 0% 9%;
31 | --foreground: 0 0% 98%;
32 | --card: 0 0% 12%;
33 | --card-foreground: 0 0% 98%;
34 | --popover: 0 0% 12%;
35 | --popover-foreground: 0 0% 98%;
36 | --primary: 0 0% 98%;
37 | --primary-foreground: 240 5.9% 10%;
38 | --secondary: 240 3.7% 15.9%;
39 | --secondary-foreground: 0 0% 98%;
40 | --muted: 240 3.7% 15.9%;
41 | --muted-foreground: 240 5% 64.9%;
42 | --accent: 240 3.7% 15.9%;
43 | --accent-foreground: 0 0% 98%;
44 | --destructive: 0 62.8% 30.6%;
45 | --destructive-foreground: 0 0% 98%;
46 | --border: 240 3.7% 15.9%;
47 | --input: 240 3.7% 15.9%;
48 | --ring: 240 4.9% 83.9%;
49 | }
50 | }
51 |
52 | @layer base {
53 | * {
54 | @apply border-border;
55 | }
56 | body {
57 | @apply bg-background text-foreground;
58 | }
59 | }
60 |
61 | #scroller * {
62 | overflow-anchor: none;
63 | }
64 |
65 | #anchor {
66 | overflow-anchor: auto;
67 | height: 1px;
68 | }
69 |
70 | :root {
71 | --scrollbar-thumb-color: #ccc;
72 | --scrollbar-thumb-hover-color: #aaa;
73 | }
74 |
75 | ::-webkit-scrollbar {
76 | width: 6px;
77 | height: 6px;
78 | }
79 |
80 | ::-webkit-scrollbar-thumb {
81 | background-color: var(--scrollbar-thumb-color);
82 | border-radius: 999px;
83 | transition: width 0.3s, height 0.3s, visibility 0.3s;
84 | }
85 |
86 | ::-webkit-scrollbar-thumb:hover {
87 | background-color: var(--scrollbar-thumb-hover-color);
88 | }
89 |
90 | ::-webkit-scrollbar-thumb:not(:hover) {
91 | width: 0;
92 | height: 0;
93 | visibility: hidden;
94 | }
95 |
--------------------------------------------------------------------------------
/src/app/hooks/useChatStore.ts:
--------------------------------------------------------------------------------
1 | import { CoreMessage, generateId, Message } from "ai";
2 | import { create } from "zustand";
3 | import { createJSONStorage, persist } from "zustand/middleware";
4 |
5 | interface ChatSession {
6 | messages: Message[];
7 | createdAt: string;
8 | }
9 |
10 | interface State {
11 | base64Images: string[] | null;
12 | chats: Record;
13 | currentChatId: string | null;
14 | selectedModel: string | null;
15 | userName: string | "Anonymous";
16 | isDownloading: boolean;
17 | downloadProgress: number;
18 | downloadingModel: string | null;
19 | }
20 |
21 | interface Actions {
22 | setBase64Images: (base64Images: string[] | null) => void;
23 | setCurrentChatId: (chatId: string) => void;
24 | setSelectedModel: (selectedModel: string) => void;
25 | getChatById: (chatId: string) => ChatSession | undefined;
26 | getMessagesById: (chatId: string) => Message[];
27 | saveMessages: (chatId: string, messages: Message[]) => void;
28 | handleDelete: (chatId: string, messageId?: string) => void;
29 | setUserName: (userName: string) => void;
30 | startDownload: (modelName: string) => void;
31 | stopDownload: () => void;
32 | setDownloadProgress: (progress: number) => void;
33 | }
34 |
35 | const useChatStore = create()(
36 | persist(
37 | (set, get) => ({
38 | base64Images: null,
39 | chats: {},
40 | currentChatId: null,
41 | selectedModel: null,
42 | userName: "Anonymous",
43 | isDownloading: false,
44 | downloadProgress: 0,
45 | downloadingModel: null,
46 |
47 | setBase64Images: (base64Images) => set({ base64Images }),
48 | setUserName: (userName) => set({ userName }),
49 |
50 | setCurrentChatId: (chatId) => set({ currentChatId: chatId }),
51 | setSelectedModel: (selectedModel) => set({ selectedModel }),
52 | getChatById: (chatId) => {
53 | const state = get();
54 | return state.chats[chatId];
55 | },
56 | getMessagesById: (chatId) => {
57 | const state = get();
58 | return state.chats[chatId]?.messages || [];
59 | },
60 | saveMessages: (chatId, messages) => {
61 | set((state) => {
62 | const existingChat = state.chats[chatId];
63 |
64 | return {
65 | chats: {
66 | ...state.chats,
67 | [chatId]: {
68 | messages: [...messages],
69 | createdAt: existingChat?.createdAt || new Date().toISOString(),
70 | },
71 | },
72 | };
73 | });
74 | },
75 | handleDelete: (chatId, messageId) => {
76 | set((state) => {
77 | const chat = state.chats[chatId];
78 | if (!chat) return state;
79 |
80 | // If messageId is provided, delete specific message
81 | if (messageId) {
82 | const updatedMessages = chat.messages.filter(
83 | (message) => message.id !== messageId
84 | );
85 | return {
86 | chats: {
87 | ...state.chats,
88 | [chatId]: {
89 | ...chat,
90 | messages: updatedMessages,
91 | },
92 | },
93 | };
94 | }
95 |
96 | // If no messageId, delete the entire chat
97 | const { [chatId]: _, ...remainingChats } = state.chats;
98 | return {
99 | chats: remainingChats,
100 | };
101 | });
102 | },
103 |
104 | startDownload: (modelName) =>
105 | set({ isDownloading: true, downloadingModel: modelName, downloadProgress: 0 }),
106 | stopDownload: () =>
107 | set({ isDownloading: false, downloadingModel: null, downloadProgress: 0 }),
108 | setDownloadProgress: (progress) => set({ downloadProgress: progress }),
109 | }),
110 | {
111 | name: "nextjs-ollama-ui-state",
112 | partialize: (state) => ({
113 | chats: state.chats,
114 | currentChatId: state.currentChatId,
115 | selectedModel: state.selectedModel,
116 | userName: state.userName,
117 | }),
118 | }
119 | )
120 | );
121 |
122 | export default useChatStore;
--------------------------------------------------------------------------------
/src/app/hooks/useSpeechRecognition.ts:
--------------------------------------------------------------------------------
1 | import { useState, useRef, useEffect } from "react";
2 |
3 | interface SpeechRecognitionOptions {
4 | interimResults?: boolean;
5 | lang?: string;
6 | continuous?: boolean;
7 | }
8 |
9 | const useSpeechToText = (options: SpeechRecognitionOptions = {}) => {
10 | const [isListening, setIsListening] = useState(false);
11 | const [transcript, setTranscript] = useState("");
12 | const recognitionRef = useRef(null);
13 |
14 | useEffect(() => {
15 | if (!("webkitSpeechRecognition" in window)) {
16 | console.error("Web Speech API is not supported");
17 | return;
18 | }
19 |
20 | const recognition = new window.webkitSpeechRecognition();
21 | recognitionRef.current = recognition;
22 |
23 | recognition.interimResults = options.interimResults || true;
24 | recognition.lang = options.lang || "en-US";
25 | recognition.continuous = options.continuous || false;
26 |
27 | if ("webkitSpeechGrammarList" in window) {
28 | const grammar =
29 | "#JSGF V1.0; grammar punctuation; public = . | , | ! | ; | : ;";
30 | const speechRecognitionList = new window.webkitSpeechGrammarList();
31 | speechRecognitionList.addFromString(grammar, 1);
32 | recognition.grammars = speechRecognitionList;
33 | }
34 |
35 | recognition.onresult = (event: SpeechRecognitionEvent) => {
36 | let text = "";
37 |
38 | for (let i = 0; i < event.results.length; i++) {
39 | text += event.results[i][0].transcript;
40 | }
41 |
42 | // Always capitalize the first letter
43 | setTranscript(text.charAt(0).toUpperCase() + text.slice(1));
44 | };
45 |
46 | recognition.onerror = (event) => {
47 | console.error(event.error);
48 | };
49 |
50 | recognition.onend = () => {
51 | setIsListening(false);
52 | setTranscript("");
53 | };
54 |
55 | return () => {
56 | if (recognitionRef.current) {
57 | recognitionRef.current.stop();
58 | }
59 | };
60 | }, []);
61 |
62 | const startListening = () => {
63 | if (recognitionRef.current && !isListening) {
64 | recognitionRef.current.start();
65 | setIsListening(true);
66 | }
67 | };
68 |
69 | const stopListening = () => {
70 | if (recognitionRef.current && isListening) {
71 | recognitionRef.current.stop();
72 | setIsListening(false);
73 | }
74 | };
75 |
76 | return {
77 | isListening,
78 | transcript,
79 | startListening,
80 | stopListening,
81 | };
82 | };
83 |
84 | export default useSpeechToText;
85 |
--------------------------------------------------------------------------------
/src/components/button-with-tooltip.tsx:
--------------------------------------------------------------------------------
1 | import React, { forwardRef } from "react";
2 | import { Button } from "./ui/button";
3 | import {
4 | Tooltip,
5 | TooltipContent,
6 | TooltipProvider,
7 | TooltipTrigger,
8 | } from "@/components/ui/tooltip";
9 |
10 | interface ButtonWithTooltipProps {
11 | children: React.ReactElement;
12 | side: "top" | "bottom" | "left" | "right";
13 | toolTipText: string;
14 | }
15 |
16 | const ButtonWithTooltip = forwardRef(
17 | ({ children, side, toolTipText }, ref) => {
18 | return (
19 |
20 |
21 |
22 | {React.cloneElement(children, { ref })}
23 |
24 |
25 | {toolTipText}
26 |
27 |
28 |
29 | );
30 | }
31 | );
32 |
33 | ButtonWithTooltip.displayName = "ButtonWithTooltip";
34 |
35 | export default ButtonWithTooltip;
36 |
--------------------------------------------------------------------------------
/src/components/chat/chat-bottombar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React, { useEffect } from "react";
4 | import { ChatProps } from "./chat";
5 | import Link from "next/link";
6 | import { cn } from "@/lib/utils";
7 | import { Button, buttonVariants } from "../ui/button";
8 | import TextareaAutosize from "react-textarea-autosize";
9 | import { motion, AnimatePresence } from "framer-motion";
10 | import {
11 | Cross2Icon,
12 | ImageIcon,
13 | PaperPlaneIcon,
14 | StopIcon,
15 | } from "@radix-ui/react-icons";
16 | import { Mic, SendHorizonal } from "lucide-react";
17 | import useSpeechToText from "@/app/hooks/useSpeechRecognition";
18 | import MultiImagePicker from "../image-embedder";
19 | import useChatStore from "@/app/hooks/useChatStore";
20 | import Image from "next/image";
21 | import { ChatRequestOptions, Message } from "ai";
22 | import { ChatInput } from "../ui/chat/chat-input";
23 |
24 | interface ChatBottombarProps {
25 | handleInputChange: (e: React.ChangeEvent) => void;
26 | handleSubmit: (
27 | e: React.FormEvent,
28 | chatRequestOptions?: ChatRequestOptions
29 | ) => void;
30 | isLoading: boolean;
31 | stop: () => void;
32 | setInput?: React.Dispatch>;
33 | input: string;
34 | }
35 |
36 | export default function ChatBottombar({
37 | input,
38 | handleInputChange,
39 | handleSubmit,
40 | isLoading,
41 | stop,
42 | setInput,
43 | }: ChatBottombarProps) {
44 | const inputRef = React.useRef(null);
45 | const base64Images = useChatStore((state) => state.base64Images);
46 | const setBase64Images = useChatStore((state) => state.setBase64Images);
47 | const selectedModel = useChatStore((state) => state.selectedModel);
48 |
49 | const handleKeyPress = (e: React.KeyboardEvent) => {
50 | if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
51 | e.preventDefault();
52 | handleSubmit(e as unknown as React.FormEvent);
53 | }
54 | };
55 |
56 | const { isListening, transcript, startListening, stopListening } =
57 | useSpeechToText({ continuous: true });
58 |
59 | const listen = () => {
60 | isListening ? stopVoiceInput() : startListening();
61 | };
62 |
63 | const stopVoiceInput = () => {
64 | setInput && setInput(transcript.length ? transcript : "");
65 | stopListening();
66 | };
67 |
68 | const handleListenClick = () => {
69 | listen();
70 | };
71 |
72 | useEffect(() => {
73 | if (inputRef.current) {
74 | inputRef.current.focus();
75 | console.log("Input focused");
76 | }
77 | }, [inputRef]);
78 |
79 | return (
80 |
207 | );
208 | }
209 |
--------------------------------------------------------------------------------
/src/components/chat/chat-layout.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React, { useEffect, useState } from "react";
4 | import {
5 | ResizableHandle,
6 | ResizablePanel,
7 | ResizablePanelGroup,
8 | } from "@/components/ui/resizable";
9 | import { cn } from "@/lib/utils";
10 | import { Sidebar } from "../sidebar";
11 | import { Message, useChat } from "ai/react";
12 | import Chat, { ChatProps } from "./chat";
13 | import ChatList from "./chat-list";
14 | import { HamburgerMenuIcon } from "@radix-ui/react-icons";
15 |
16 | interface ChatLayoutProps {
17 | defaultLayout: number[] | undefined;
18 | defaultCollapsed?: boolean;
19 | navCollapsedSize: number;
20 | }
21 |
22 | type MergedProps = ChatLayoutProps & ChatProps;
23 |
24 | export function ChatLayout({
25 | defaultLayout = [30, 160],
26 | defaultCollapsed = false,
27 | navCollapsedSize,
28 | initialMessages,
29 | id,
30 | }: MergedProps) {
31 | const [isCollapsed, setIsCollapsed] = React.useState(defaultCollapsed);
32 | const [isMobile, setIsMobile] = useState(false);
33 |
34 | useEffect(() => {
35 | const checkScreenWidth = () => {
36 | setIsMobile(window.innerWidth <= 1023);
37 | };
38 |
39 | // Initial check
40 | checkScreenWidth();
41 |
42 | // Event listener for screen width changes
43 | window.addEventListener("resize", checkScreenWidth);
44 |
45 | // Cleanup the event listener on component unmount
46 | return () => {
47 | window.removeEventListener("resize", checkScreenWidth);
48 | };
49 | }, []);
50 |
51 | return (
52 | {
55 | document.cookie = `react-resizable-panels:layout=${JSON.stringify(
56 | sizes
57 | )}`;
58 | }}
59 | className="h-screen items-stretch"
60 | >
61 | {
68 | setIsCollapsed(true);
69 | document.cookie = `react-resizable-panels:collapsed=${JSON.stringify(
70 | true
71 | )}`;
72 | }}
73 | onExpand={() => {
74 | setIsCollapsed(false);
75 | document.cookie = `react-resizable-panels:collapsed=${JSON.stringify(
76 | false
77 | )}`;
78 | }}
79 | className={cn(
80 | isCollapsed
81 | ? "min-w-[50px] md:min-w-[70px] transition-all duration-300 ease-in-out"
82 | : "hidden md:block"
83 | )}
84 | >
85 |
91 |
92 |
93 |
97 |
98 |
99 |
100 | );
101 | }
102 |
--------------------------------------------------------------------------------
/src/components/chat/chat-list.tsx:
--------------------------------------------------------------------------------
1 | import { Message } from "ai/react";
2 | import React from "react";
3 | import ChatMessage from "./chat-message";
4 | import { ChatMessageList } from "../ui/chat/chat-message-list";
5 | import {
6 | ChatBubble,
7 | ChatBubbleAvatar,
8 | ChatBubbleMessage,
9 | } from "../ui/chat/chat-bubble";
10 | import { ChatRequestOptions } from "ai";
11 |
12 | interface ChatListProps {
13 | messages: Message[];
14 | isLoading: boolean;
15 | loadingSubmit?: boolean;
16 | reload: (
17 | chatRequestOptions?: ChatRequestOptions
18 | ) => Promise;
19 | }
20 |
21 | export default function ChatList({
22 | messages,
23 | isLoading,
24 | loadingSubmit,
25 | reload,
26 | }: ChatListProps) {
27 | return (
28 |
29 |
30 | {messages.map((message, index) => (
31 |
38 | ))}
39 | {loadingSubmit && (
40 |
41 |
47 |
48 |
49 | )}
50 |
51 |
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/src/components/chat/chat-message.tsx:
--------------------------------------------------------------------------------
1 | import React, { memo, useMemo, useState } from "react";
2 | import { motion } from "framer-motion";
3 | import Markdown from "react-markdown";
4 | import remarkGfm from "remark-gfm";
5 | import { Message } from "ai/react";
6 | import { ChatRequestOptions } from "ai";
7 | import { CheckIcon, CopyIcon } from "@radix-ui/react-icons";
8 | import { RefreshCcw } from "lucide-react";
9 | import Image from "next/image";
10 | import {
11 | ChatBubble,
12 | ChatBubbleAvatar,
13 | ChatBubbleMessage,
14 | } from "../ui/chat/chat-bubble";
15 | import ButtonWithTooltip from "../button-with-tooltip";
16 | import { Button } from "../ui/button";
17 | import CodeDisplayBlock from "../code-display-block";
18 |
19 | export type ChatMessageProps = {
20 | message: Message;
21 | isLast: boolean;
22 | isLoading: boolean | undefined;
23 | reload: (chatRequestOptions?: ChatRequestOptions) => Promise;
24 | };
25 |
26 | const MOTION_CONFIG = {
27 | initial: { opacity: 0, scale: 1, y: 20, x: 0 },
28 | animate: { opacity: 1, scale: 1, y: 0, x: 0 },
29 | exit: { opacity: 0, scale: 1, y: 20, x: 0 },
30 | transition: {
31 | opacity: { duration: 0.1 },
32 | layout: {
33 | type: "spring",
34 | bounce: 0.3,
35 | duration: 0.2,
36 | },
37 | },
38 | };
39 |
40 | function ChatMessage({ message, isLast, isLoading, reload }: ChatMessageProps) {
41 | const [isCopied, setIsCopied] = useState(false);
42 |
43 | // Extract "think" content from Deepseek R1 models and clean message (rest) content
44 | const { thinkContent, cleanContent } = useMemo(() => {
45 | const getThinkContent = (content: string) => {
46 | const match = content.match(/([\s\S]*?)(?:<\/think>|$)/);
47 | return match ? match[1].trim() : null;
48 | };
49 |
50 | return {
51 | thinkContent: message.role === "assistant" ? getThinkContent(message.content) : null,
52 | cleanContent: message.content.replace(/[\s\S]*?(?:<\/think>|$)/g, '').trim(),
53 | };
54 | }, [message.content, message.role]);
55 |
56 | const contentParts = useMemo(() => cleanContent.split("```"), [cleanContent]);
57 |
58 | const handleCopy = () => {
59 | navigator.clipboard.writeText(message.content);
60 | setIsCopied(true);
61 | setTimeout(() => setIsCopied(false), 1500);
62 | };
63 |
64 | const renderAttachments = () => (
65 |
66 | {message.experimental_attachments
67 | ?.filter((attachment) => attachment.contentType?.startsWith("image/"))
68 | .map((attachment, index) => (
69 |
77 | ))}
78 |
79 | );
80 |
81 | const renderThinkingProcess = () => (
82 | thinkContent && message.role === "assistant" && (
83 |
84 |
85 | Thinking process
86 |
87 |
88 | {thinkContent}
89 |
90 |
91 | )
92 | );
93 |
94 | const renderContent = () => (
95 | contentParts.map((part, index) => (
96 | index % 2 === 0 ? (
97 | {part}
98 | ) : (
99 |
100 |
101 |
102 | )
103 | ))
104 | );
105 |
106 | const renderActionButtons = () => (
107 | message.role === "assistant" && (
108 |
109 | {!isLoading && (
110 |
111 |
123 |
124 | )}
125 | {!isLoading && isLast && (
126 |
127 |
135 |
136 | )}
137 |
138 | )
139 | );
140 |
141 | return (
142 |
143 |
144 |
151 |
152 | {renderThinkingProcess()}
153 | {renderAttachments()}
154 | {renderContent()}
155 | {renderActionButtons()}
156 |
157 |
158 |
159 | );
160 | }
161 |
162 | export default memo(ChatMessage, (prevProps, nextProps) => {
163 | if (nextProps.isLast) return false;
164 | return prevProps.isLast === nextProps.isLast && prevProps.message === nextProps.message;
165 | });
--------------------------------------------------------------------------------
/src/components/chat/chat-topbar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React, { useEffect } from "react";
4 | import {
5 | Popover,
6 | PopoverContent,
7 | PopoverTrigger,
8 | } from "@/components/ui/popover";
9 | import {
10 | Sheet,
11 | SheetContent,
12 | SheetDescription,
13 | SheetHeader,
14 | SheetTitle,
15 | SheetTrigger,
16 | } from "@/components/ui/sheet";
17 |
18 | import { Button } from "../ui/button";
19 | import { CaretSortIcon, HamburgerMenuIcon } from "@radix-ui/react-icons";
20 | import { Sidebar } from "../sidebar";
21 | import { Message } from "ai/react";
22 | import { getSelectedModel } from "@/lib/model-helper";
23 | import useChatStore from "@/app/hooks/useChatStore";
24 |
25 | interface ChatTopbarProps {
26 | isLoading: boolean;
27 | chatId?: string;
28 | messages: Message[];
29 | setMessages: (messages: Message[]) => void;
30 | }
31 |
32 | export default function ChatTopbar({
33 | isLoading,
34 | chatId,
35 | messages,
36 | setMessages,
37 | }: ChatTopbarProps) {
38 | const [models, setModels] = React.useState([]);
39 | const [open, setOpen] = React.useState(false);
40 | const [sheetOpen, setSheetOpen] = React.useState(false);
41 | const selectedModel = useChatStore((state) => state.selectedModel);
42 | const setSelectedModel = useChatStore((state) => state.setSelectedModel);
43 |
44 | useEffect(() => {
45 | (async () => {
46 | try {
47 | const res = await fetch("/api/tags");
48 | if (!res.ok) throw new Error(`HTTP Error: ${res.status}`);
49 |
50 | const data = await res.json().catch(() => null);
51 | if (!data?.models?.length) return;
52 |
53 | setModels(data.models.map(({ name }: { name: string }) => name));
54 | } catch (error) {
55 | console.error("Error fetching models:", error);
56 | }
57 | })();
58 | }, []);
59 |
60 |
61 | const handleModelChange = (model: string) => {
62 | setSelectedModel(model);
63 | setOpen(false);
64 | };
65 |
66 | const handleCloseSidebar = () => {
67 | setSheetOpen(false);
68 | };
69 |
70 | return (
71 |
72 |
73 |
74 |
75 |
76 |
77 |
84 |
85 |
86 |
87 |
88 |
89 |
99 |
100 |
101 | {models.length > 0 ? (
102 | models.map((model) => (
103 |
113 | ))
114 | ) : (
115 |
118 | )}
119 |
120 |
121 |
122 | );
123 | }
124 |
--------------------------------------------------------------------------------
/src/components/chat/chat.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import ChatTopbar from "./chat-topbar";
4 | import ChatList from "./chat-list";
5 | import ChatBottombar from "./chat-bottombar";
6 | import { AIMessage, HumanMessage } from "@langchain/core/messages";
7 | import { BytesOutputParser } from "@langchain/core/output_parsers";
8 | import { Attachment, ChatRequestOptions, generateId } from "ai";
9 | import { Message, useChat } from "ai/react";
10 | import React, { useEffect, useRef, useState } from "react";
11 | import { toast } from "sonner";
12 | import { v4 as uuidv4 } from "uuid";
13 | import useChatStore from "@/app/hooks/useChatStore";
14 | import { useRouter } from "next/navigation";
15 | import Image from "next/image";
16 |
17 | export interface ChatProps {
18 | id: string;
19 | initialMessages: Message[] | [];
20 | isMobile?: boolean;
21 | }
22 |
23 | export default function Chat({ initialMessages, id, isMobile }: ChatProps) {
24 | const {
25 | messages,
26 | input,
27 | handleInputChange,
28 | handleSubmit,
29 | isLoading,
30 | stop,
31 | setMessages,
32 | setInput,
33 | reload,
34 | } = useChat({
35 | id,
36 | initialMessages,
37 | onResponse: (response) => {
38 | if (response) {
39 | setLoadingSubmit(false);
40 | }
41 | },
42 | onFinish: (message) => {
43 | const savedMessages = getMessagesById(id);
44 | saveMessages(id, [...savedMessages, message]);
45 | setLoadingSubmit(false);
46 | router.replace(`/c/${id}`);
47 | },
48 | onError: (error) => {
49 | setLoadingSubmit(false);
50 | router.replace("/");
51 | console.error(error.message);
52 | console.error(error.cause);
53 | },
54 | });
55 | const [loadingSubmit, setLoadingSubmit] = React.useState(false);
56 | const formRef = useRef(null);
57 | const base64Images = useChatStore((state) => state.base64Images);
58 | const setBase64Images = useChatStore((state) => state.setBase64Images);
59 | const selectedModel = useChatStore((state) => state.selectedModel);
60 | const saveMessages = useChatStore((state) => state.saveMessages);
61 | const getMessagesById = useChatStore((state) => state.getMessagesById);
62 | const router = useRouter();
63 |
64 | const onSubmit = (e: React.FormEvent) => {
65 | e.preventDefault();
66 | window.history.replaceState({}, "", `/c/${id}`);
67 |
68 | if (!selectedModel) {
69 | toast.error("Please select a model");
70 | return;
71 | }
72 |
73 | const userMessage: Message = {
74 | id: generateId(),
75 | role: "user",
76 | content: input,
77 | };
78 |
79 | setLoadingSubmit(true);
80 |
81 | const attachments: Attachment[] = base64Images
82 | ? base64Images.map((image) => ({
83 | contentType: "image/base64",
84 | url: image,
85 | }))
86 | : [];
87 |
88 | const requestOptions: ChatRequestOptions = {
89 | body: {
90 | selectedModel: selectedModel,
91 | },
92 | ...(base64Images && {
93 | data: {
94 | images: base64Images,
95 | },
96 | experimental_attachments: attachments,
97 | }),
98 | };
99 |
100 | handleSubmit(e, requestOptions);
101 | saveMessages(id, [...messages, userMessage]);
102 | setBase64Images(null);
103 | };
104 |
105 | const removeLatestMessage = () => {
106 | const updatedMessages = messages.slice(0, -1);
107 | setMessages(updatedMessages);
108 | saveMessages(id, updatedMessages);
109 | return updatedMessages;
110 | };
111 |
112 | const handleStop = () => {
113 | stop();
114 | saveMessages(id, [...messages]);
115 | setLoadingSubmit(false);
116 | };
117 |
118 | return (
119 |
120 |
126 |
127 | {messages.length === 0 ? (
128 |
129 |
136 |
137 | How can I help you today?
138 |
139 |
147 |
148 | ) : (
149 | <>
150 |
{
155 | removeLatestMessage();
156 |
157 | const requestOptions: ChatRequestOptions = {
158 | body: {
159 | selectedModel: selectedModel,
160 | },
161 | };
162 |
163 | setLoadingSubmit(true);
164 | return reload(requestOptions);
165 | }}
166 | />
167 |
175 | >
176 | )}
177 |
178 | );
179 | }
180 |
--------------------------------------------------------------------------------
/src/components/code-display-block.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { CheckIcon, CopyIcon } from "@radix-ui/react-icons";
3 | import React, { useMemo, useRef, useState } from "react";
4 | import { CodeBlock, dracula, github } from "react-code-blocks";
5 | import { Button } from "./ui/button";
6 | import { toast } from "sonner";
7 | import { useTheme } from "next-themes";
8 |
9 | interface ButtonCodeblockProps {
10 | code: string;
11 | }
12 |
13 | export default function CodeDisplayBlock({ code }: ButtonCodeblockProps) {
14 | const [isCopied, setIsCopied] = useState(false);
15 | const isCopiedRef = useRef(false);
16 | const { resolvedTheme } = useTheme();
17 |
18 | const filteredCode = useMemo(
19 | () => code.split("\n").slice(1).join("\n") || code,
20 | [code]
21 | );
22 | const trimmedCode = useMemo(() => filteredCode.trim(), [filteredCode]);
23 | const language = useMemo(
24 | () =>
25 | ["tsx", "js", "python", "css", "html", "cs"].includes(code.split("\n")[0])
26 | ? code.split("\n")[0]
27 | : "tsx",
28 | [code]
29 | );
30 |
31 | const customStyle = useMemo(
32 | () =>
33 | resolvedTheme === "dark"
34 | ? { background: "#303033" }
35 | : { background: "#fcfcfc" },
36 | [resolvedTheme]
37 | );
38 | const codeTheme = useMemo(
39 | () => (resolvedTheme === "dark" ? dracula : github),
40 | [resolvedTheme]
41 | );
42 |
43 | const copyToClipboard = () => {
44 | if (isCopiedRef.current) return; // Prevent multiple triggers
45 | navigator.clipboard.writeText(trimmedCode);
46 | isCopiedRef.current = true;
47 | setIsCopied(true);
48 | toast.success("Code copied to clipboard!");
49 |
50 | setTimeout(() => {
51 | isCopiedRef.current = false;
52 | setIsCopied(false);
53 | }, 1500);
54 | };
55 |
56 | return (
57 |
58 |
70 |
77 |
78 | );
79 | }
80 |
--------------------------------------------------------------------------------
/src/components/edit-username-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { z } from "zod";
4 | import { zodResolver } from "@hookform/resolvers/zod";
5 | import { useForm } from "react-hook-form";
6 | import { Button } from "@/components/ui/button";
7 | import {
8 | Form,
9 | FormControl,
10 | FormField,
11 | FormItem,
12 | FormLabel,
13 | FormMessage,
14 | } from "@/components/ui/form";
15 | import { Input } from "@/components/ui/input";
16 | import React from "react";
17 | import { ModeToggle } from "./mode-toggle";
18 | import { toast } from "sonner";
19 | import useChatStore from "@/app/hooks/useChatStore";
20 |
21 | const formSchema = z.object({
22 | username: z.string().min(2, {
23 | message: "Name must be at least 2 characters.",
24 | }),
25 | });
26 |
27 | interface EditUsernameFormProps {
28 | setOpen: React.Dispatch>;
29 | }
30 |
31 | export default function EditUsernameForm({ setOpen }: EditUsernameFormProps) {
32 | const userName = useChatStore((state) => state.userName);
33 | const setUserName = useChatStore((state) => state.setUserName);
34 |
35 | const form = useForm>({
36 | resolver: zodResolver(formSchema),
37 | defaultValues: {
38 | username: userName,
39 | },
40 | });
41 |
42 | function onSubmit(values: z.infer) {
43 | setUserName(values.username); // Update the userName in the store
44 | toast.success("Name updated successfully");
45 | }
46 |
47 | return (
48 |
71 |
72 | );
73 | }
74 |
--------------------------------------------------------------------------------
/src/components/emoji-picker.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import {
4 | Popover,
5 | PopoverContent,
6 | PopoverTrigger,
7 | } from "@/components/ui/popover"
8 | import { SmileIcon } from "lucide-react";
9 | import Picker from '@emoji-mart/react';
10 | import data from "@emoji-mart/data"
11 |
12 | interface EmojiPickerProps {
13 | onChange: (value: string) => void;
14 | }
15 |
16 |
17 | export const EmojiPicker = ({
18 | onChange
19 | }: EmojiPickerProps) => {
20 |
21 | return (
22 |
23 |
24 |
25 |
26 |
28 | onChange(emoji.native)}
34 | />
35 |
36 |
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/image-embedder.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React, { useCallback } from "react";
4 | import { useDropzone } from "react-dropzone";
5 | import { Button } from "./ui/button";
6 | import { ImageIcon } from "lucide-react";
7 |
8 | interface MultiImagePickerProps {
9 | onImagesPick: (base64Images: string[]) => void;
10 | disabled: boolean
11 | }
12 |
13 | const MultiImagePicker: React.FC = ({ onImagesPick, disabled }) => {
14 | const convertToBase64 = (file: File): Promise => {
15 | return new Promise((resolve, reject) => {
16 | const reader = new FileReader();
17 | reader.readAsDataURL(file);
18 | reader.onload = () => resolve(reader.result as string);
19 | reader.onerror = error => reject(error);
20 | });
21 | };
22 |
23 | const onDrop = useCallback(
24 | async (acceptedFiles: File[]) => {
25 | try {
26 | const base64Images = await Promise.all(acceptedFiles.map(convertToBase64));
27 | onImagesPick(base64Images);
28 | } catch (error) {
29 | console.error("Error converting images to base64:", error);
30 | }
31 | },
32 | [onImagesPick]
33 | );
34 |
35 | const { getRootProps, getInputProps, isDragActive } = useDropzone({
36 | onDrop,
37 | accept: {
38 | 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp']
39 | },
40 | multiple: true, // Allow multiple file selection
41 | maxSize: 10485760, // 10 MB per file
42 | });
43 |
44 | return (
45 |
46 |
47 |
51 |
52 | );
53 | };
54 |
55 | export default MultiImagePicker;
--------------------------------------------------------------------------------
/src/components/mode-toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Monitor, Moon, Sun } from "lucide-react";
4 | import { useTheme } from "next-themes";
5 | import { Button } from "./ui/button";
6 | import clsx from "clsx";
7 |
8 | export function ModeToggle() {
9 | const { setTheme, theme } = useTheme();
10 |
11 | return (
12 |
16 |
25 |
33 |
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/src/components/pull-model-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React from "react";
4 | import {
5 | Form,
6 | FormField,
7 | FormItem,
8 | FormLabel,
9 | FormMessage,
10 | FormControl, // Add FormControl
11 | } from "@/components/ui/form";
12 | import { Button } from "./ui/button";
13 | import { z } from "zod";
14 | import { useForm } from "react-hook-form";
15 | import { zodResolver } from "@hookform/resolvers/zod";
16 | import { toast } from "sonner";
17 | import { Loader2Icon } from "lucide-react";
18 | import { Input } from "./ui/input";
19 | import { throttle } from "lodash";
20 | import useChatStore from "@/app/hooks/useChatStore";
21 | import { useRouter } from "next/navigation";
22 |
23 | const formSchema = z.object({
24 | name: z.string().min(1, {
25 | message: "Please select a model to pull",
26 | }),
27 | });
28 |
29 | export default function PullModelForm() {
30 | const {
31 | isDownloading,
32 | downloadProgress,
33 | downloadingModel,
34 | startDownload,
35 | stopDownload,
36 | setDownloadProgress,
37 | } = useChatStore();
38 |
39 | const router = useRouter();
40 |
41 | const form = useForm>({
42 | resolver: zodResolver(formSchema),
43 | defaultValues: {
44 | name: "",
45 | },
46 | });
47 |
48 | const handlePullModel = async (data: z.infer) => {
49 | const modelName = data.name.trim();
50 | startDownload(modelName);
51 |
52 | const throttledSetProgress = throttle((progress: number) => {
53 | setDownloadProgress(progress);
54 | }, 200);
55 |
56 | let lastStatus: string | null = null;
57 |
58 | try {
59 | const response = await fetch("/api/model", {
60 | method: "POST",
61 | headers: {
62 | "Content-Type": "application/json",
63 | },
64 | body: JSON.stringify({ name: modelName }),
65 | });
66 |
67 | if (!response.ok) {
68 | throw new Error("Network response was not ok");
69 | }
70 |
71 | if (!response.body) {
72 | throw new Error("Something went wrong");
73 | }
74 |
75 | await processStream(response.body, throttledSetProgress, lastStatus);
76 |
77 | toast.success("Model pulled successfully");
78 | router.refresh();
79 | } catch (error) {
80 | toast.error(
81 | `Error: ${
82 | error instanceof Error ? error.message : "Failed to pull model"
83 | }`
84 | );
85 | } finally {
86 | stopDownload();
87 | throttledSetProgress.cancel();
88 | }
89 | };
90 |
91 | const processStream = async (
92 | body: ReadableStream,
93 | throttledSetProgress: (progress: number) => void,
94 | lastStatus: string | null
95 | ) => {
96 | const reader = body.getReader();
97 | const decoder = new TextDecoder();
98 |
99 | while (true) {
100 | const { done, value } = await reader.read();
101 | if (done) break;
102 |
103 | const text = decoder.decode(value);
104 | const jsonObjects = text.trim().split("\n");
105 |
106 | for (const jsonObject of jsonObjects) {
107 | try {
108 | const responseJson = JSON.parse(jsonObject);
109 |
110 | if (responseJson.error) {
111 | throw new Error(responseJson.error);
112 | }
113 |
114 | if (
115 | responseJson.completed !== undefined &&
116 | responseJson.total !== undefined
117 | ) {
118 | const progress =
119 | (responseJson.completed / responseJson.total) * 100;
120 | throttledSetProgress(progress);
121 | }
122 |
123 | if (responseJson.status && responseJson.status !== lastStatus) {
124 | toast.info(`Status: ${responseJson.status}`);
125 | lastStatus = responseJson.status;
126 | }
127 | } catch (error) {
128 | throw new Error("Error parsing JSON");
129 | }
130 | }
131 | }
132 | };
133 |
134 | const onSubmit = (data: z.infer) => {
135 | handlePullModel(data);
136 | };
137 |
138 | return (
139 |
195 |
196 | );
197 | }
198 |
--------------------------------------------------------------------------------
/src/components/pull-model.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "./ui/dialog";
3 |
4 | import { DownloadIcon } from "@radix-ui/react-icons";
5 | import PullModelForm from "./pull-model-form";
6 |
7 | export default function PullModel() {
8 | return (
9 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/sidebar-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from "@/components/ui/skeleton";
2 |
3 | export default function SidebarSkeleton() {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/sidebar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Link from "next/link";
4 | import { MoreHorizontal, SquarePen, Trash2 } from "lucide-react";
5 | import { cn } from "@/lib/utils";
6 | import { Button, buttonVariants } from "@/components/ui/button";
7 | import { Message } from "ai/react";
8 | import Image from "next/image";
9 | import { Suspense, useEffect, useState } from "react";
10 | import SidebarSkeleton from "./sidebar-skeleton";
11 | import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
12 | import UserSettings from "./user-settings";
13 | import { ScrollArea, Scrollbar } from "@radix-ui/react-scroll-area";
14 | import PullModel from "./pull-model";
15 | import {
16 | Dialog,
17 | DialogContent,
18 | DialogDescription,
19 | DialogHeader,
20 | DialogTitle,
21 | DialogTrigger,
22 | } from "./ui/dialog";
23 | import {
24 | DropdownMenu,
25 | DropdownMenuContent,
26 | DropdownMenuTrigger,
27 | } from "./ui/dropdown-menu";
28 | import { TrashIcon } from "@radix-ui/react-icons";
29 | import { useRouter } from "next/navigation";
30 | import useChatStore from "@/app/hooks/useChatStore";
31 |
32 | interface SidebarProps {
33 | isCollapsed: boolean;
34 | messages: Message[];
35 | onClick?: () => void;
36 | isMobile: boolean;
37 | chatId: string;
38 | closeSidebar?: () => void;
39 | }
40 |
41 | export function Sidebar({
42 | messages,
43 | isCollapsed,
44 | isMobile,
45 | chatId,
46 | closeSidebar,
47 | }: SidebarProps) {
48 | const router = useRouter();
49 |
50 | const chats = useChatStore((state) => state.chats);
51 | const handleDelete = useChatStore((state) => state.handleDelete);
52 |
53 | return (
54 |
58 |
59 |
83 |
84 |
85 |
Your chats
86 |
87 | {chats &&
88 | Object.entries(chats)
89 | .sort(
90 | ([, a], [, b]) =>
91 | new Date(b.createdAt).getTime() -
92 | new Date(a.createdAt).getTime()
93 | )
94 | .map(([id, chat]) => (
95 |
107 |
108 |
109 |
110 | {chat.messages.length > 0
111 | ? chat.messages[0].content
112 | : ""}
113 |
114 |
115 |
116 |
117 |
118 |
125 |
126 |
127 |
161 |
162 |
163 |
164 | ))}
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 | );
174 | }
175 |
--------------------------------------------------------------------------------
/src/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Avatar = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ))
21 | Avatar.displayName = AvatarPrimitive.Root.displayName
22 |
23 | const AvatarImage = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
32 | ))
33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName
34 |
35 | const AvatarFallback = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 | ))
48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
49 |
50 | export { Avatar, AvatarImage, AvatarFallback }
51 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
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-md 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 shadow hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
16 | outline:
17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
18 | secondary:
19 | "dark:bg-card/60 bg-accent/20 text-secondary-foreground shadow-sm 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:
23 | "bg-accent/90 dark:bg-secondary/80 text-secondary-foreground shadow-sm dark:hover:bg-secondary hover:bg-accent",
24 | },
25 | size: {
26 | default: "h-9 px-4 py-2",
27 | sm: "h-8 rounded-md px-3 text-xs",
28 | lg: "h-10 rounded-md px-8",
29 | icon: "h-10 w-10",
30 | iconSm: "h-8 w-8",
31 | },
32 | },
33 | defaultVariants: {
34 | variant: "default",
35 | size: "default",
36 | },
37 | }
38 | );
39 |
40 | export interface ButtonProps
41 | extends React.ButtonHTMLAttributes,
42 | VariantProps {
43 | asChild?: boolean;
44 | }
45 |
46 | const Button = React.forwardRef(
47 | ({ className, variant, size, asChild = false, ...props }, ref) => {
48 | const Comp = asChild ? Slot : "button";
49 | return (
50 |
55 | );
56 | }
57 | );
58 | Button.displayName = "Button";
59 |
60 | export { Button, buttonVariants };
61 |
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | Card.displayName = "Card"
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ))
30 | CardHeader.displayName = "CardHeader"
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
41 | ))
42 | CardTitle.displayName = "CardTitle"
43 |
44 | const CardDescription = React.forwardRef<
45 | HTMLParagraphElement,
46 | React.HTMLAttributes
47 | >(({ className, ...props }, ref) => (
48 |
53 | ))
54 | CardDescription.displayName = "CardDescription"
55 |
56 | const CardContent = React.forwardRef<
57 | HTMLDivElement,
58 | React.HTMLAttributes
59 | >(({ className, ...props }, ref) => (
60 |
61 | ))
62 | CardContent.displayName = "CardContent"
63 |
64 | const CardFooter = React.forwardRef<
65 | HTMLDivElement,
66 | React.HTMLAttributes
67 | >(({ className, ...props }, ref) => (
68 |
73 | ))
74 | CardFooter.displayName = "CardFooter"
75 |
76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
77 |
--------------------------------------------------------------------------------
/src/components/ui/chat/chat-bubble.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { cva, type VariantProps } from "class-variance-authority";
3 | import { cn } from "@/lib/utils";
4 | import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
5 | import MessageLoading from "./message-loading";
6 | import { Button, ButtonProps } from "../button";
7 |
8 | // ChatBubble
9 | const chatBubbleVariant = cva(
10 | "flex gap-2 max-w-[80%] items-end relative group",
11 | {
12 | variants: {
13 | variant: {
14 | received: "self-start",
15 | sent: "self-end flex-row-reverse",
16 | },
17 | layout: {
18 | default: "",
19 | ai: "max-w-full w-full items-center",
20 | },
21 | },
22 | defaultVariants: {
23 | variant: "received",
24 | layout: "default",
25 | },
26 | }
27 | );
28 |
29 | interface ChatBubbleProps
30 | extends React.HTMLAttributes,
31 | VariantProps {}
32 |
33 | const ChatBubble = React.forwardRef(
34 | ({ className, variant, layout, children, ...props }, ref) => (
35 |
43 | {React.Children.map(children, (child) =>
44 | React.isValidElement(child) && typeof child.type !== "string"
45 | ? React.cloneElement(child, {
46 | variant,
47 | layout,
48 | } as React.ComponentProps)
49 | : child
50 | )}
51 |
52 | )
53 | );
54 | ChatBubble.displayName = "ChatBubble";
55 |
56 | // ChatBubbleAvatar
57 | interface ChatBubbleAvatarProps {
58 | src?: string;
59 | fallback?: string;
60 | className?: string;
61 | width?: number;
62 | height?: number;
63 | }
64 |
65 | const ChatBubbleAvatar: React.FC = ({
66 | src,
67 | fallback,
68 | className,
69 | width,
70 | height,
71 | }) => (
72 |
73 |
80 | {fallback}
81 |
82 | );
83 |
84 | // ChatBubbleMessage
85 | const chatBubbleMessageVariants = cva("p-4", {
86 | variants: {
87 | variant: {
88 | received:
89 | "bg-secondary text-secondary-foreground rounded-r-lg rounded-tl-lg",
90 | sent: "bg-primary text-primary-foreground rounded-l-lg rounded-tr-lg",
91 | },
92 | layout: {
93 | default: "",
94 | ai: "border-t w-full rounded-none bg-transparent",
95 | },
96 | },
97 | defaultVariants: {
98 | variant: "received",
99 | layout: "default",
100 | },
101 | });
102 |
103 | interface ChatBubbleMessageProps
104 | extends React.HTMLAttributes,
105 | VariantProps {
106 | isLoading?: boolean;
107 | }
108 |
109 | const ChatBubbleMessage = React.forwardRef<
110 | HTMLDivElement,
111 | ChatBubbleMessageProps
112 | >(
113 | (
114 | { className, variant, layout, isLoading = false, children, ...props },
115 | ref
116 | ) => (
117 |
125 | {isLoading ? (
126 |
127 |
128 |
129 | ) : (
130 | children
131 | )}
132 |
133 | )
134 | );
135 | ChatBubbleMessage.displayName = "ChatBubbleMessage";
136 |
137 | // ChatBubbleTimestamp
138 | interface ChatBubbleTimestampProps
139 | extends React.HTMLAttributes {
140 | timestamp: string;
141 | }
142 |
143 | const ChatBubbleTimestamp: React.FC = ({
144 | timestamp,
145 | className,
146 | ...props
147 | }) => (
148 |
149 | {timestamp}
150 |
151 | );
152 |
153 | // ChatBubbleAction
154 | type ChatBubbleActionProps = ButtonProps & {
155 | icon: React.ReactNode;
156 | };
157 |
158 | const ChatBubbleAction: React.FC = ({
159 | icon,
160 | onClick,
161 | className,
162 | variant = "ghost",
163 | size = "icon",
164 | ...props
165 | }) => (
166 |
175 | );
176 |
177 | interface ChatBubbleActionWrapperProps
178 | extends React.HTMLAttributes {
179 | variant?: "sent" | "received";
180 | className?: string;
181 | }
182 |
183 | const ChatBubbleActionWrapper = React.forwardRef<
184 | HTMLDivElement,
185 | ChatBubbleActionWrapperProps
186 | >(({ variant, className, children, ...props }, ref) => (
187 |
198 | {children}
199 |
200 | ));
201 | ChatBubbleActionWrapper.displayName = "ChatBubbleActionWrapper";
202 |
203 | export {
204 | ChatBubble,
205 | ChatBubbleAvatar,
206 | ChatBubbleMessage,
207 | ChatBubbleTimestamp,
208 | chatBubbleVariant,
209 | chatBubbleMessageVariants,
210 | ChatBubbleAction,
211 | ChatBubbleActionWrapper,
212 | };
213 |
--------------------------------------------------------------------------------
/src/components/ui/chat/chat-input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Textarea } from "@/components/ui/textarea";
3 | import { cn } from "@/lib/utils";
4 |
5 | interface ChatInputProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const ChatInput = React.forwardRef(
9 | ({ className, ...props }, forwardedRef) => {
10 | const handleRef = (node: HTMLTextAreaElement | null) => {
11 | if (node) {
12 | // Apply auto-resize logic
13 | node.style.height = "0px";
14 | node.style.height = node.scrollHeight + "px";
15 |
16 | if (typeof forwardedRef === "function") {
17 | forwardedRef(node);
18 | } else if (forwardedRef) {
19 | forwardedRef.current = node;
20 | }
21 | }
22 | };
23 |
24 | return (
25 |
35 | );
36 | }
37 | );
38 |
39 | ChatInput.displayName = "ChatInput";
40 |
41 | export { ChatInput };
42 |
--------------------------------------------------------------------------------
/src/components/ui/chat/chat-message-list.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { ArrowDown } from "lucide-react";
3 | import { Button } from "@/components/ui/button";
4 | import { useAutoScroll } from "@/components/ui/chat/hooks/useAutoScroll";
5 |
6 | interface ChatMessageListProps extends React.HTMLAttributes {
7 | smooth?: boolean;
8 | }
9 |
10 | const ChatMessageList = React.forwardRef(
11 | ({ className, children, smooth = false, ...props }, _ref) => {
12 | const {
13 | scrollRef,
14 | isAtBottom,
15 | autoScrollEnabled,
16 | scrollToBottom,
17 | disableAutoScroll,
18 | } = useAutoScroll({
19 | smooth,
20 | content: children,
21 | });
22 |
23 | return (
24 |
25 |
34 |
35 | {!isAtBottom && (
36 |
47 | )}
48 |
49 | );
50 | }
51 | );
52 |
53 | ChatMessageList.displayName = "ChatMessageList";
54 |
55 | export { ChatMessageList };
56 |
--------------------------------------------------------------------------------
/src/components/ui/chat/expandable-chat.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React, { useRef, useState } from "react";
4 | import { X, MessageCircle } from "lucide-react";
5 | import { cn } from "@/lib/utils";
6 | import { Button } from "@/components/ui/button";
7 |
8 | export type ChatPosition = "bottom-right" | "bottom-left";
9 | export type ChatSize = "sm" | "md" | "lg" | "xl" | "full";
10 |
11 | const chatConfig = {
12 | dimensions: {
13 | sm: "sm:max-w-sm sm:max-h-[500px]",
14 | md: "sm:max-w-md sm:max-h-[600px]",
15 | lg: "sm:max-w-lg sm:max-h-[700px]",
16 | xl: "sm:max-w-xl sm:max-h-[800px]",
17 | full: "sm:w-full sm:h-full",
18 | },
19 | positions: {
20 | "bottom-right": "bottom-5 right-5",
21 | "bottom-left": "bottom-5 left-5",
22 | },
23 | chatPositions: {
24 | "bottom-right": "sm:bottom-[calc(100%+10px)] sm:right-0",
25 | "bottom-left": "sm:bottom-[calc(100%+10px)] sm:left-0",
26 | },
27 | states: {
28 | open: "pointer-events-auto opacity-100 visible scale-100 translate-y-0",
29 | closed:
30 | "pointer-events-none opacity-0 invisible scale-100 sm:translate-y-5",
31 | },
32 | };
33 |
34 | interface ExpandableChatProps extends React.HTMLAttributes {
35 | position?: ChatPosition;
36 | size?: ChatSize;
37 | icon?: React.ReactNode;
38 | }
39 |
40 | const ExpandableChat: React.FC = ({
41 | className,
42 | position = "bottom-right",
43 | size = "md",
44 | icon,
45 | children,
46 | ...props
47 | }) => {
48 | const [isOpen, setIsOpen] = useState(false);
49 | const chatRef = useRef(null);
50 |
51 | const toggleChat = () => setIsOpen(!isOpen);
52 |
53 | return (
54 |
58 |
68 | {children}
69 |
77 |
78 |
83 |
84 | );
85 | };
86 |
87 | ExpandableChat.displayName = "ExpandableChat";
88 |
89 | const ExpandableChatHeader: React.FC> = ({
90 | className,
91 | ...props
92 | }) => (
93 |
97 | );
98 |
99 | ExpandableChatHeader.displayName = "ExpandableChatHeader";
100 |
101 | const ExpandableChatBody: React.FC> = ({
102 | className,
103 | ...props
104 | }) => ;
105 |
106 | ExpandableChatBody.displayName = "ExpandableChatBody";
107 |
108 | const ExpandableChatFooter: React.FC> = ({
109 | className,
110 | ...props
111 | }) => ;
112 |
113 | ExpandableChatFooter.displayName = "ExpandableChatFooter";
114 |
115 | interface ExpandableChatToggleProps
116 | extends React.ButtonHTMLAttributes {
117 | icon?: React.ReactNode;
118 | isOpen: boolean;
119 | toggleChat: () => void;
120 | }
121 |
122 | const ExpandableChatToggle: React.FC = ({
123 | className,
124 | icon,
125 | isOpen,
126 | toggleChat,
127 | ...props
128 | }) => (
129 |
144 | );
145 |
146 | ExpandableChatToggle.displayName = "ExpandableChatToggle";
147 |
148 | export {
149 | ExpandableChat,
150 | ExpandableChatHeader,
151 | ExpandableChatBody,
152 | ExpandableChatFooter,
153 | };
154 |
--------------------------------------------------------------------------------
/src/components/ui/chat/hooks/useAutoScroll.tsx:
--------------------------------------------------------------------------------
1 | // @hidden
2 | import { useCallback, useEffect, useRef, useState } from "react";
3 |
4 | interface ScrollState {
5 | isAtBottom: boolean;
6 | autoScrollEnabled: boolean;
7 | }
8 |
9 | interface UseAutoScrollOptions {
10 | offset?: number;
11 | smooth?: boolean;
12 | content?: React.ReactNode;
13 | }
14 |
15 | export function useAutoScroll(options: UseAutoScrollOptions = {}) {
16 | const { offset = 20, smooth = false, content } = options;
17 | const scrollRef = useRef(null);
18 | const lastContentHeight = useRef(0);
19 | const userHasScrolled = useRef(false);
20 |
21 | const [scrollState, setScrollState] = useState({
22 | isAtBottom: true,
23 | autoScrollEnabled: true,
24 | });
25 |
26 | const checkIsAtBottom = useCallback(
27 | (element: HTMLElement) => {
28 | const { scrollTop, scrollHeight, clientHeight } = element;
29 | const distanceToBottom = Math.abs(
30 | scrollHeight - scrollTop - clientHeight
31 | );
32 | return distanceToBottom <= offset;
33 | },
34 | [offset]
35 | );
36 |
37 | const scrollToBottom = useCallback(
38 | (instant?: boolean) => {
39 | if (!scrollRef.current) return;
40 |
41 | const targetScrollTop =
42 | scrollRef.current.scrollHeight - scrollRef.current.clientHeight;
43 |
44 | if (instant) {
45 | scrollRef.current.scrollTop = targetScrollTop;
46 | } else {
47 | scrollRef.current.scrollTo({
48 | top: targetScrollTop,
49 | behavior: smooth ? "smooth" : "auto",
50 | });
51 | }
52 |
53 | setScrollState({
54 | isAtBottom: true,
55 | autoScrollEnabled: true,
56 | });
57 | userHasScrolled.current = false;
58 | },
59 | [smooth]
60 | );
61 |
62 | const handleScroll = useCallback(() => {
63 | if (!scrollRef.current) return;
64 |
65 | const atBottom = checkIsAtBottom(scrollRef.current);
66 |
67 | setScrollState((prev) => ({
68 | isAtBottom: atBottom,
69 | // Re-enable auto-scroll if at the bottom
70 | autoScrollEnabled: atBottom ? true : prev.autoScrollEnabled,
71 | }));
72 | }, [checkIsAtBottom]);
73 |
74 | useEffect(() => {
75 | const element = scrollRef.current;
76 | if (!element) return;
77 |
78 | element.addEventListener("scroll", handleScroll, { passive: true });
79 | return () => element.removeEventListener("scroll", handleScroll);
80 | }, [handleScroll]);
81 |
82 | useEffect(() => {
83 | const scrollElement = scrollRef.current;
84 | if (!scrollElement) return;
85 |
86 | const currentHeight = scrollElement.scrollHeight;
87 | const hasNewContent = currentHeight !== lastContentHeight.current;
88 |
89 | if (hasNewContent) {
90 | if (scrollState.autoScrollEnabled) {
91 | requestAnimationFrame(() => {
92 | scrollToBottom(lastContentHeight.current === 0);
93 | });
94 | }
95 | lastContentHeight.current = currentHeight;
96 | }
97 | }, [content, scrollState.autoScrollEnabled, scrollToBottom]);
98 |
99 | useEffect(() => {
100 | const element = scrollRef.current;
101 | if (!element) return;
102 |
103 | const resizeObserver = new ResizeObserver(() => {
104 | if (scrollState.autoScrollEnabled) {
105 | scrollToBottom(true);
106 | }
107 | });
108 |
109 | resizeObserver.observe(element);
110 | return () => resizeObserver.disconnect();
111 | }, [scrollState.autoScrollEnabled, scrollToBottom]);
112 |
113 | const disableAutoScroll = useCallback(() => {
114 | const atBottom = scrollRef.current
115 | ? checkIsAtBottom(scrollRef.current)
116 | : false;
117 |
118 | // Only disable if not at bottom
119 | if (!atBottom) {
120 | userHasScrolled.current = true;
121 | setScrollState((prev) => ({
122 | ...prev,
123 | autoScrollEnabled: false,
124 | }));
125 | }
126 | }, [checkIsAtBottom]);
127 |
128 | return {
129 | scrollRef,
130 | isAtBottom: scrollState.isAtBottom,
131 | autoScrollEnabled: scrollState.autoScrollEnabled,
132 | scrollToBottom: () => scrollToBottom(false),
133 | disableAutoScroll,
134 | };
135 | }
136 |
--------------------------------------------------------------------------------
/src/components/ui/chat/message-loading.tsx:
--------------------------------------------------------------------------------
1 | // @hidden
2 | export default function MessageLoading() {
3 | return (
4 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { Cross2Icon } from "@radix-ui/react-icons"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Dialog = DialogPrimitive.Root
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger
12 |
13 | const DialogPortal = DialogPrimitive.Portal
14 |
15 | const DialogClose = DialogPrimitive.Close
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ))
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 |
48 |
49 | Close
50 |
51 |
52 |
53 | ))
54 | DialogContent.displayName = DialogPrimitive.Content.displayName
55 |
56 | const DialogHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
67 | )
68 | DialogHeader.displayName = "DialogHeader"
69 |
70 | const DialogFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
81 | )
82 | DialogFooter.displayName = "DialogFooter"
83 |
84 | const DialogTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ))
97 | DialogTitle.displayName = DialogPrimitive.Title.displayName
98 |
99 | const DialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | DialogDescription.displayName = DialogPrimitive.Description.displayName
110 |
111 | export {
112 | Dialog,
113 | DialogPortal,
114 | DialogOverlay,
115 | DialogTrigger,
116 | DialogClose,
117 | DialogContent,
118 | DialogHeader,
119 | DialogFooter,
120 | DialogTitle,
121 | DialogDescription,
122 | }
123 |
--------------------------------------------------------------------------------
/src/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5 | import {
6 | CheckIcon,
7 | ChevronRightIcon,
8 | DotFilledIcon,
9 | } from "@radix-ui/react-icons"
10 |
11 | import { cn } from "@/lib/utils"
12 |
13 | const DropdownMenu = DropdownMenuPrimitive.Root
14 |
15 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
16 |
17 | const DropdownMenuGroup = DropdownMenuPrimitive.Group
18 |
19 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal
20 |
21 | const DropdownMenuSub = DropdownMenuPrimitive.Sub
22 |
23 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
24 |
25 | const DropdownMenuSubTrigger = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef & {
28 | inset?: boolean
29 | }
30 | >(({ className, inset, children, ...props }, ref) => (
31 |
40 | {children}
41 |
42 |
43 | ))
44 | DropdownMenuSubTrigger.displayName =
45 | DropdownMenuPrimitive.SubTrigger.displayName
46 |
47 | const DropdownMenuSubContent = React.forwardRef<
48 | React.ElementRef,
49 | React.ComponentPropsWithoutRef
50 | >(({ className, ...props }, ref) => (
51 |
59 | ))
60 | DropdownMenuSubContent.displayName =
61 | DropdownMenuPrimitive.SubContent.displayName
62 |
63 | const DropdownMenuContent = React.forwardRef<
64 | React.ElementRef,
65 | React.ComponentPropsWithoutRef
66 | >(({ className, sideOffset = 4, ...props }, ref) => (
67 |
68 |
78 |
79 | ))
80 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
81 |
82 | const DropdownMenuItem = React.forwardRef<
83 | React.ElementRef,
84 | React.ComponentPropsWithoutRef & {
85 | inset?: boolean
86 | }
87 | >(({ className, inset, ...props }, ref) => (
88 |
97 | ))
98 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
99 |
100 | const DropdownMenuCheckboxItem = React.forwardRef<
101 | React.ElementRef,
102 | React.ComponentPropsWithoutRef
103 | >(({ className, children, checked, ...props }, ref) => (
104 |
113 |
114 |
115 |
116 |
117 |
118 | {children}
119 |
120 | ))
121 | DropdownMenuCheckboxItem.displayName =
122 | DropdownMenuPrimitive.CheckboxItem.displayName
123 |
124 | const DropdownMenuRadioItem = React.forwardRef<
125 | React.ElementRef,
126 | React.ComponentPropsWithoutRef
127 | >(({ className, children, ...props }, ref) => (
128 |
136 |
137 |
138 |
139 |
140 |
141 | {children}
142 |
143 | ))
144 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
145 |
146 | const DropdownMenuLabel = React.forwardRef<
147 | React.ElementRef,
148 | React.ComponentPropsWithoutRef & {
149 | inset?: boolean
150 | }
151 | >(({ className, inset, ...props }, ref) => (
152 |
161 | ))
162 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
163 |
164 | const DropdownMenuSeparator = React.forwardRef<
165 | React.ElementRef,
166 | React.ComponentPropsWithoutRef
167 | >(({ className, ...props }, ref) => (
168 |
173 | ))
174 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
175 |
176 | const DropdownMenuShortcut = ({
177 | className,
178 | ...props
179 | }: React.HTMLAttributes) => {
180 | return (
181 |
185 | )
186 | }
187 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
188 |
189 | export {
190 | DropdownMenu,
191 | DropdownMenuTrigger,
192 | DropdownMenuContent,
193 | DropdownMenuItem,
194 | DropdownMenuCheckboxItem,
195 | DropdownMenuRadioItem,
196 | DropdownMenuLabel,
197 | DropdownMenuSeparator,
198 | DropdownMenuShortcut,
199 | DropdownMenuGroup,
200 | DropdownMenuPortal,
201 | DropdownMenuSub,
202 | DropdownMenuSubContent,
203 | DropdownMenuSubTrigger,
204 | DropdownMenuRadioGroup,
205 | }
206 |
--------------------------------------------------------------------------------
/src/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as LabelPrimitive from "@radix-ui/react-label"
3 | import { Slot } from "@radix-ui/react-slot"
4 | import {
5 | Controller,
6 | ControllerProps,
7 | FieldPath,
8 | FieldValues,
9 | FormProvider,
10 | useFormContext,
11 | } from "react-hook-form"
12 |
13 | import { cn } from "@/lib/utils"
14 | import { Label } from "@/components/ui/label"
15 |
16 | const Form = FormProvider
17 |
18 | type FormFieldContextValue<
19 | TFieldValues extends FieldValues = FieldValues,
20 | TName extends FieldPath = FieldPath
21 | > = {
22 | name: TName
23 | }
24 |
25 | const FormFieldContext = React.createContext(
26 | {} as FormFieldContextValue
27 | )
28 |
29 | const FormField = <
30 | TFieldValues extends FieldValues = FieldValues,
31 | TName extends FieldPath = FieldPath
32 | >({
33 | ...props
34 | }: ControllerProps) => {
35 | return (
36 |
37 |
38 |
39 | )
40 | }
41 |
42 | const useFormField = () => {
43 | const fieldContext = React.useContext(FormFieldContext)
44 | const itemContext = React.useContext(FormItemContext)
45 | const { getFieldState, formState } = useFormContext()
46 |
47 | const fieldState = getFieldState(fieldContext.name, formState)
48 |
49 | if (!fieldContext) {
50 | throw new Error("useFormField should be used within ")
51 | }
52 |
53 | const { id } = itemContext
54 |
55 | return {
56 | id,
57 | name: fieldContext.name,
58 | formItemId: `${id}-form-item`,
59 | formDescriptionId: `${id}-form-item-description`,
60 | formMessageId: `${id}-form-item-message`,
61 | ...fieldState,
62 | }
63 | }
64 |
65 | type FormItemContextValue = {
66 | id: string
67 | }
68 |
69 | const FormItemContext = React.createContext(
70 | {} as FormItemContextValue
71 | )
72 |
73 | const FormItem = React.forwardRef<
74 | HTMLDivElement,
75 | React.HTMLAttributes
76 | >(({ className, ...props }, ref) => {
77 | const id = React.useId()
78 |
79 | return (
80 |
81 |
82 |
83 | )
84 | })
85 | FormItem.displayName = "FormItem"
86 |
87 | const FormLabel = React.forwardRef<
88 | React.ElementRef,
89 | React.ComponentPropsWithoutRef
90 | >(({ className, ...props }, ref) => {
91 | const { error, formItemId } = useFormField()
92 |
93 | return (
94 |
100 | )
101 | })
102 | FormLabel.displayName = "FormLabel"
103 |
104 | const FormControl = React.forwardRef<
105 | React.ElementRef,
106 | React.ComponentPropsWithoutRef
107 | >(({ ...props }, ref) => {
108 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
109 |
110 | return (
111 |
122 | )
123 | })
124 | FormControl.displayName = "FormControl"
125 |
126 | const FormDescription = React.forwardRef<
127 | HTMLParagraphElement,
128 | React.HTMLAttributes
129 | >(({ className, ...props }, ref) => {
130 | const { formDescriptionId } = useFormField()
131 |
132 | return (
133 |
139 | )
140 | })
141 | FormDescription.displayName = "FormDescription"
142 |
143 | const FormMessage = React.forwardRef<
144 | HTMLParagraphElement,
145 | React.HTMLAttributes
146 | >(({ className, children, ...props }, ref) => {
147 | const { error, formMessageId } = useFormField()
148 | const body = error ? String(error?.message) : children
149 |
150 | if (!body) {
151 | return null
152 | }
153 |
154 | return (
155 |
161 | {body}
162 |
163 | )
164 | })
165 | FormMessage.displayName = "FormMessage"
166 |
167 | export {
168 | useFormField,
169 | Form,
170 | FormItem,
171 | FormLabel,
172 | FormControl,
173 | FormDescription,
174 | FormMessage,
175 | FormField,
176 | }
177 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | }
22 | )
23 | Input.displayName = "Input"
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/src/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as PopoverPrimitive from "@radix-ui/react-popover"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Popover = PopoverPrimitive.Root
9 |
10 | const PopoverTrigger = PopoverPrimitive.Trigger
11 |
12 | const PopoverAnchor = PopoverPrimitive.Anchor
13 |
14 | const PopoverContent = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
18 |
19 |
29 |
30 | ))
31 | PopoverContent.displayName = PopoverPrimitive.Content.displayName
32 |
33 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
34 |
--------------------------------------------------------------------------------
/src/components/ui/resizable.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { DragHandleDots2Icon } from "@radix-ui/react-icons"
4 | import * as ResizablePrimitive from "react-resizable-panels"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const ResizablePanelGroup = ({
9 | className,
10 | ...props
11 | }: React.ComponentProps) => (
12 |
19 | )
20 |
21 | const ResizablePanel = ResizablePrimitive.Panel
22 |
23 | const ResizableHandle = ({
24 | withHandle,
25 | className,
26 | ...props
27 | }: React.ComponentProps & {
28 | withHandle?: boolean
29 | }) => (
30 | div]:rotate-90",
33 | className
34 | )}
35 | {...props}
36 | >
37 | {withHandle && (
38 |
39 |
40 |
41 | )}
42 |
43 | )
44 |
45 | export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
46 |
--------------------------------------------------------------------------------
/src/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import {
5 | CaretSortIcon,
6 | CheckIcon,
7 | ChevronDownIcon,
8 | ChevronUpIcon,
9 | } from "@radix-ui/react-icons"
10 | import * as SelectPrimitive from "@radix-ui/react-select"
11 |
12 | import { cn } from "@/lib/utils"
13 |
14 | const Select = SelectPrimitive.Root
15 |
16 | const SelectGroup = SelectPrimitive.Group
17 |
18 | const SelectValue = SelectPrimitive.Value
19 |
20 | const SelectTrigger = React.forwardRef<
21 | React.ElementRef,
22 | React.ComponentPropsWithoutRef
23 | >(({ className, children, ...props }, ref) => (
24 | span]:line-clamp-1",
28 | className
29 | )}
30 | {...props}
31 | >
32 | {children}
33 |
34 |
35 |
36 |
37 | ))
38 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
39 |
40 | const SelectScrollUpButton = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
52 |
53 |
54 | ))
55 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
56 |
57 | const SelectScrollDownButton = React.forwardRef<
58 | React.ElementRef,
59 | React.ComponentPropsWithoutRef
60 | >(({ className, ...props }, ref) => (
61 |
69 |
70 |
71 | ))
72 | SelectScrollDownButton.displayName =
73 | SelectPrimitive.ScrollDownButton.displayName
74 |
75 | const SelectContent = React.forwardRef<
76 | React.ElementRef,
77 | React.ComponentPropsWithoutRef
78 | >(({ className, children, position = "popper", ...props }, ref) => (
79 |
80 |
91 |
92 |
99 | {children}
100 |
101 |
102 |
103 |
104 | ))
105 | SelectContent.displayName = SelectPrimitive.Content.displayName
106 |
107 | const SelectLabel = React.forwardRef<
108 | React.ElementRef,
109 | React.ComponentPropsWithoutRef
110 | >(({ className, ...props }, ref) => (
111 |
116 | ))
117 | SelectLabel.displayName = SelectPrimitive.Label.displayName
118 |
119 | const SelectItem = React.forwardRef<
120 | React.ElementRef,
121 | React.ComponentPropsWithoutRef
122 | >(({ className, children, ...props }, ref) => (
123 |
131 |
132 |
133 |
134 |
135 |
136 | {children}
137 |
138 | ))
139 | SelectItem.displayName = SelectPrimitive.Item.displayName
140 |
141 | const SelectSeparator = React.forwardRef<
142 | React.ElementRef,
143 | React.ComponentPropsWithoutRef
144 | >(({ className, ...props }, ref) => (
145 |
150 | ))
151 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName
152 |
153 | export {
154 | Select,
155 | SelectGroup,
156 | SelectValue,
157 | SelectTrigger,
158 | SelectContent,
159 | SelectLabel,
160 | SelectItem,
161 | SelectSeparator,
162 | SelectScrollUpButton,
163 | SelectScrollDownButton,
164 | }
165 |
--------------------------------------------------------------------------------
/src/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SheetPrimitive from "@radix-ui/react-dialog"
5 | import { Cross2Icon } from "@radix-ui/react-icons"
6 | import { cva, type VariantProps } from "class-variance-authority"
7 |
8 | import { cn } from "@/lib/utils"
9 |
10 | const Sheet = SheetPrimitive.Root
11 |
12 | const SheetTrigger = SheetPrimitive.Trigger
13 |
14 | const SheetClose = SheetPrimitive.Close
15 |
16 | const SheetPortal = SheetPrimitive.Portal
17 |
18 | const SheetOverlay = React.forwardRef<
19 | React.ElementRef,
20 | React.ComponentPropsWithoutRef
21 | >(({ className, ...props }, ref) => (
22 |
30 | ))
31 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
32 |
33 | const sheetVariants = cva(
34 | "fixed z-50 gap-4 bg-background p-6 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",
35 | {
36 | variants: {
37 | side: {
38 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
39 | bottom:
40 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
41 | 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",
42 | right:
43 | "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",
44 | },
45 | },
46 | defaultVariants: {
47 | side: "right",
48 | },
49 | }
50 | )
51 |
52 | interface SheetContentProps
53 | extends React.ComponentPropsWithoutRef,
54 | VariantProps {}
55 |
56 | const SheetContent = React.forwardRef<
57 | React.ElementRef,
58 | SheetContentProps
59 | >(({ side = "right", className, children, ...props }, ref) => (
60 |
61 |
62 |
67 | {children}
68 |
69 |
70 | Close
71 |
72 |
73 |
74 | ))
75 | SheetContent.displayName = SheetPrimitive.Content.displayName
76 |
77 | const SheetHeader = ({
78 | className,
79 | ...props
80 | }: React.HTMLAttributes) => (
81 |
88 | )
89 | SheetHeader.displayName = "SheetHeader"
90 |
91 | const SheetFooter = ({
92 | className,
93 | ...props
94 | }: React.HTMLAttributes) => (
95 |
102 | )
103 | SheetFooter.displayName = "SheetFooter"
104 |
105 | const SheetTitle = React.forwardRef<
106 | React.ElementRef,
107 | React.ComponentPropsWithoutRef
108 | >(({ className, ...props }, ref) => (
109 |
114 | ))
115 | SheetTitle.displayName = SheetPrimitive.Title.displayName
116 |
117 | const SheetDescription = React.forwardRef<
118 | React.ElementRef,
119 | React.ComponentPropsWithoutRef
120 | >(({ className, ...props }, ref) => (
121 |
126 | ))
127 | SheetDescription.displayName = SheetPrimitive.Description.displayName
128 |
129 | export {
130 | Sheet,
131 | SheetPortal,
132 | SheetOverlay,
133 | SheetTrigger,
134 | SheetClose,
135 | SheetContent,
136 | SheetHeader,
137 | SheetFooter,
138 | SheetTitle,
139 | SheetDescription,
140 | }
141 |
--------------------------------------------------------------------------------
/src/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 |
3 | function Skeleton({
4 | className,
5 | ...props
6 | }: React.HTMLAttributes) {
7 | return (
8 |
12 | )
13 | }
14 |
15 | export { Skeleton }
16 |
--------------------------------------------------------------------------------
/src/components/ui/sonner.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useTheme } from "next-themes"
4 | import { Toaster as Sonner } from "sonner"
5 |
6 | type ToasterProps = React.ComponentProps
7 |
8 | const Toaster = ({ ...props }: ToasterProps) => {
9 | const { theme = "system" } = useTheme()
10 |
11 | return (
12 |
28 | )
29 | }
30 |
31 | export { Toaster }
32 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | )
20 | }
21 | )
22 | Textarea.displayName = "Textarea"
23 |
24 | export { Textarea }
25 |
--------------------------------------------------------------------------------
/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const TooltipProvider = TooltipPrimitive.Provider
9 |
10 | const Tooltip = TooltipPrimitive.Root
11 |
12 | const TooltipTrigger = TooltipPrimitive.Trigger
13 |
14 | const TooltipContent = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, sideOffset = 4, ...props }, ref) => (
18 |
27 | ))
28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
29 |
30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
31 |
--------------------------------------------------------------------------------
/src/components/user-settings.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | DropdownMenu,
5 | DropdownMenuContent,
6 | DropdownMenuItem,
7 | DropdownMenuLabel,
8 | DropdownMenuSeparator,
9 | DropdownMenuTrigger,
10 | } from "@/components/ui/dropdown-menu";
11 |
12 | import {
13 | Dialog,
14 | DialogContent,
15 | DialogDescription,
16 | DialogHeader,
17 | DialogTitle,
18 | DialogTrigger,
19 | } from "@/components/ui/dialog";
20 | import { Button } from "./ui/button";
21 | import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
22 | import { GearIcon } from "@radix-ui/react-icons";
23 | import { useEffect, useState } from "react";
24 | import { Loader2 } from "lucide-react";
25 | import { Skeleton } from "./ui/skeleton";
26 | import { set } from "zod";
27 | import UsernameForm from "./username-form";
28 | import EditUsernameForm from "./edit-username-form";
29 | import PullModel from "./pull-model";
30 | import useChatStore from "@/app/hooks/useChatStore";
31 |
32 | export default function UserSettings() {
33 | const [open, setOpen] = useState(false);
34 |
35 | const userName = useChatStore((state) => state.userName);
36 |
37 | return (
38 |
39 |
40 |
60 |
61 |
62 | e.preventDefault()}>
63 |
64 |
65 |
81 |
82 |
83 |
84 | );
85 | }
86 |
--------------------------------------------------------------------------------
/src/components/username-form.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { set, z } from "zod"
4 | import { zodResolver } from "@hookform/resolvers/zod"
5 | import { useForm } from "react-hook-form"
6 | import { Button } from "@/components/ui/button"
7 | import {
8 | Form,
9 | FormControl,
10 | FormDescription,
11 | FormField,
12 | FormItem,
13 | FormLabel,
14 | FormMessage,
15 | } from "@/components/ui/form"
16 | import { Input } from "@/components/ui/input"
17 | import React from "react"
18 |
19 | const formSchema = z.object({
20 | username: z.string().min(2, {
21 | message: "Name must be at least 2 characters.",
22 | }),
23 | })
24 |
25 | interface UsernameFormProps {
26 | setOpen: React.Dispatch>;
27 | }
28 |
29 | export default function UsernameForm({ setOpen }: UsernameFormProps) {
30 | const form = useForm>({
31 | resolver: zodResolver(formSchema),
32 | defaultValues: {
33 | username: "",
34 | },
35 | })
36 |
37 |
38 | function onSubmit(values: z.infer) {
39 | localStorage.setItem("ollama_user", values.username)
40 | window.dispatchEvent(new Event("storage"));
41 | setOpen(false)
42 | }
43 |
44 | return (
45 |
65 |
66 | )
67 | }
68 |
--------------------------------------------------------------------------------
/src/lib/model-helper.ts:
--------------------------------------------------------------------------------
1 | export function getSelectedModel(): string {
2 | if (typeof window !== 'undefined') {
3 | const storedModel = localStorage.getItem('selectedModel');
4 | return storedModel || 'gemma:2b';
5 | } else {
6 | // Default model
7 | return 'gemma:2b';
8 | }
9 | }
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
8 | export function generateUUID(): string {
9 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
10 | const r = (Math.random() * 16) | 0;
11 | const v = c === 'x' ? r : (r & 0x3) | 0x8;
12 | return v.toString(16);
13 | });
14 | }
--------------------------------------------------------------------------------
/src/providers/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { ThemeProvider as NextThemesProvider } from "next-themes"
5 | import { type ThemeProviderProps } from "next-themes/dist/types"
6 |
7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
8 | return {children}
9 | }
10 |
--------------------------------------------------------------------------------
/src/utils/initial-questions.ts:
--------------------------------------------------------------------------------
1 | export const INITIAL_QUESTIONS = [
2 | {
3 | content: "What is the capital of France?",
4 | },
5 | {
6 | content: 'Who wrote "To Kill a Mockingbird"?',
7 | },
8 | {
9 | content: "What is the boiling point of water in Celsius?",
10 | },
11 | {
12 | content: "How many planets are there in our solar system?",
13 | },
14 | {
15 | content: "What year did the Titanic sink?",
16 | },
17 | {
18 | content: "Who painted the Mona Lisa?",
19 | },
20 | {
21 | content: "What is the square root of 144?",
22 | },
23 | {
24 | content: "Who is the current President of the United States?",
25 | },
26 | {
27 | content: "What is the tallest mountain in the world?",
28 | },
29 | {
30 | content: "What is the chemical symbol for gold?",
31 | },
32 | {
33 | content: "Who discovered penicillin?",
34 | },
35 | ];
36 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss"
2 |
3 | const config = {
4 | darkMode: ["class"],
5 | content: [
6 | './pages/**/*.{ts,tsx}',
7 | './components/**/*.{ts,tsx}',
8 | './app/**/*.{ts,tsx}',
9 | './src/**/*.{ts,tsx}',
10 | ],
11 | prefix: "",
12 | theme: {
13 | container: {
14 | center: true,
15 | padding: "2rem",
16 | screens: {
17 | "2xl": "1400px",
18 | },
19 | },
20 | extend: {
21 | colors: {
22 | border: "hsl(var(--border))",
23 | input: "hsl(var(--input))",
24 | ring: "hsl(var(--ring))",
25 | background: "hsl(var(--background))",
26 | foreground: "hsl(var(--foreground))",
27 | primary: {
28 | DEFAULT: "hsl(var(--primary))",
29 | foreground: "hsl(var(--primary-foreground))",
30 | },
31 | secondary: {
32 | DEFAULT: "hsl(var(--secondary))",
33 | foreground: "hsl(var(--secondary-foreground))",
34 | },
35 | destructive: {
36 | DEFAULT: "hsl(var(--destructive))",
37 | foreground: "hsl(var(--destructive-foreground))",
38 | },
39 | muted: {
40 | DEFAULT: "hsl(var(--muted))",
41 | foreground: "hsl(var(--muted-foreground))",
42 | },
43 | accent: {
44 | DEFAULT: "hsl(var(--accent))",
45 | foreground: "hsl(var(--accent-foreground))",
46 | },
47 | popover: {
48 | DEFAULT: "hsl(var(--popover))",
49 | foreground: "hsl(var(--popover-foreground))",
50 | },
51 | card: {
52 | DEFAULT: "hsl(var(--card))",
53 | foreground: "hsl(var(--card-foreground))",
54 | },
55 | },
56 | borderRadius: {
57 | lg: "var(--radius)",
58 | md: "calc(var(--radius) - 2px)",
59 | sm: "calc(var(--radius) - 4px)",
60 | },
61 | keyframes: {
62 | "accordion-down": {
63 | from: { height: "0" },
64 | to: { height: "var(--radix-accordion-content-height)" },
65 | },
66 | "accordion-up": {
67 | from: { height: "var(--radix-accordion-content-height)" },
68 | to: { height: "0" },
69 | },
70 | },
71 | animation: {
72 | "accordion-down": "accordion-down 0.2s ease-out",
73 | "accordion-up": "accordion-up 0.2s ease-out",
74 | },
75 | },
76 | },
77 | plugins: [require("tailwindcss-animate")],
78 | } satisfies Config
79 |
80 | export default config
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./src/*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------