Преглед на файлове

Merge pull request #216 from ollama-webui/dev

feat: full backend support (including auth/rbac)
Timothy Jaeryang Baek преди 1 година
родител
ревизия
fbdae0f769
променени са 46 файла, в които са добавени 2630 реда и са изтрити 1266 реда
  1. 16 0
      .dockerignore
  2. 25 90
      README.md
  3. 10 40
      TROUBLESHOOTING.md
  4. 7 0
      backend/.dockerignore
  5. 4 1
      backend/.gitignore
  6. 8 1
      backend/apps/ollama/main.py
  7. 4 0
      backend/apps/web/internal/db.py
  8. 5 1
      backend/apps/web/main.py
  9. 25 15
      backend/apps/web/models/auths.py
  10. 157 0
      backend/apps/web/models/chats.py
  11. 135 0
      backend/apps/web/models/modelfiles.py
  12. 41 24
      backend/apps/web/models/users.py
  13. 2 2
      backend/apps/web/routers/auths.py
  14. 161 0
      backend/apps/web/routers/chats.py
  15. 191 0
      backend/apps/web/routers/modelfiles.py
  16. 5 21
      backend/config.py
  17. 6 0
      backend/constants.py
  18. 1 0
      backend/data/readme.txt
  19. 1 1
      backend/dev.sh
  20. 2 2
      backend/requirements.txt
  21. 3 0
      docker-compose.yml
  22. 1 1
      run.sh
  23. 90 0
      src/lib/apis/auths/index.ts
  24. 193 0
      src/lib/apis/chats/index.ts
  25. 23 0
      src/lib/apis/index.ts
  26. 173 0
      src/lib/apis/modelfiles/index.ts
  27. 204 0
      src/lib/apis/ollama/index.ts
  28. 33 0
      src/lib/apis/openai/index.ts
  29. 58 0
      src/lib/apis/users/index.ts
  30. 22 10
      src/lib/components/chat/Messages.svelte
  31. 280 54
      src/lib/components/chat/SettingsModal.svelte
  32. 6 10
      src/lib/components/layout/Navbar.svelte
  33. 48 208
      src/lib/components/layout/Sidebar.svelte
  34. 9 6
      src/lib/constants.ts
  35. 0 1
      src/lib/stores/index.ts
  36. 15 4
      src/lib/utils/index.ts
  37. 174 208
      src/routes/(app)/+layout.svelte
  38. 81 154
      src/routes/(app)/+page.svelte
  39. 13 48
      src/routes/(app)/admin/+page.svelte
  40. 117 171
      src/routes/(app)/c/[id]/+page.svelte
  41. 115 32
      src/routes/(app)/modelfiles/+page.svelte
  42. 14 18
      src/routes/(app)/modelfiles/create/+page.svelte
  43. 20 28
      src/routes/(app)/modelfiles/edit/+page.svelte
  44. 27 40
      src/routes/+layout.svelte
  45. 50 75
      src/routes/auth/+page.svelte
  46. 55 0
      src/routes/error/+page.svelte

+ 16 - 0
.dockerignore

@@ -0,0 +1,16 @@
+.DS_Store
+node_modules
+/.svelte-kit
+/package
+.env
+.env.*
+!.env.example
+vite.config.js.timestamp-*
+vite.config.ts.timestamp-*
+__pycache__
+.env
+_old
+uploads
+.ipynb_checkpoints
+**/*.db
+_test

+ 25 - 90
README.md

@@ -57,9 +57,9 @@ Also check our sibling project, [OllamaHub](https://ollamahub.com/), where you c
 
 - ⚙️ **Fine-Tuned Control with Advanced Parameters**: Gain a deeper level of control by adjusting parameters such as temperature and defining your system prompts to tailor the conversation to your specific preferences and needs.
 
-- 🔐 **Auth Header Support**: Effortlessly enhance security by adding Authorization headers to Ollama requests directly from the web UI settings, ensuring access to secured Ollama servers.
+- 🔗 **External Ollama Server Connection**: Seamlessly link to an external Ollama server hosted on a different address by configuring the environment variable.
 
-- 🔗 **External Ollama Server Connection**: Seamlessly link to an external Ollama server hosted on a different address by configuring the environment variable during the Docker build phase. Additionally, you can also set the external server connection URL from the web UI post-build.
+- 🔐 **Role-Based Access Control (RBAC)**: Ensure secure access with restricted permissions; only authorized individuals can access your Ollama, and exclusive model creation/pulling rights are reserved for administrators.
 
 - 🔒 **Backend Reverse Proxy Support**: Strengthen security by enabling direct communication between Ollama Web UI backend and Ollama, eliminating the need to expose Ollama over LAN.
 
@@ -82,13 +82,17 @@ docker compose up -d --build
 This command will install both Ollama and Ollama Web UI on your system.
 
 #### Enable GPU
+
 Use the additional Docker Compose file designed to enable GPU support by running the following command:
+
 ```bash
 docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build
 ```
 
 #### Expose Ollama API outside the container stack
+
 Deploy the service with an additional Docker Compose file designed for API exposure:
+
 ```bash
 docker compose -f docker-compose.yml -f docker-compose.api.yml up -d --build
 ```
@@ -105,17 +109,19 @@ After installing Ollama, verify that Ollama is running by accessing the followin
 
 #### Using Docker 🐳
 
+**Important:** When using Docker to install Ollama Web UI, make sure to include the `-v ollama-webui:/app/backend/data` in your Docker command. This step is crucial as it ensures your database is properly mounted and prevents any loss of data.
+
 If Ollama is hosted on your local machine and accessible at [http://127.0.0.1:11434/](http://127.0.0.1:11434/), run the following command:
 
 ```bash
-docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway --name ollama-webui --restart always ghcr.io/ollama-webui/ollama-webui:main
+docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v ollama-webui:/app/backend/data --name ollama-webui --restart always ghcr.io/ollama-webui/ollama-webui:main
 ```
 
 Alternatively, if you prefer to build the container yourself, use the following command:
 
 ```bash
 docker build -t ollama-webui .
-docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway --name ollama-webui --restart always ollama-webui
+docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v ollama-webui:/app/backend/data --name ollama-webui --restart always ollama-webui
 ```
 
 Your Ollama Web UI should now be hosted at [http://localhost:3000](http://localhost:3000) and accessible over LAN (or Network). Enjoy! 😄
@@ -125,23 +131,25 @@ Your Ollama Web UI should now be hosted at [http://localhost:3000](http://localh
 Change `OLLAMA_API_BASE_URL` environment variable to match the external Ollama Server url:
 
 ```bash
-docker run -d -p 3000:8080 -e OLLAMA_API_BASE_URL=https://example.com/api --name ollama-webui --restart always ghcr.io/ollama-webui/ollama-webui:main
+docker run -d -p 3000:8080 -e OLLAMA_API_BASE_URL=https://example.com/api -v ollama-webui:/app/backend/data --name ollama-webui --restart always ghcr.io/ollama-webui/ollama-webui:main
 ```
 
 Alternatively, if you prefer to build the container yourself, use the following command:
 
 ```bash
 docker build -t ollama-webui .
-docker run -d -p 3000:8080 -e OLLAMA_API_BASE_URL=https://example.com/api --name ollama-webui --restart always ollama-webui
+docker run -d -p 3000:8080 -e OLLAMA_API_BASE_URL=https://example.com/api -v ollama-webui:/app/backend/data --name ollama-webui --restart always ollama-webui
 ```
 
 ## How to Install Without Docker
 
 While we strongly recommend using our convenient Docker container installation for optimal support, we understand that some situations may require a non-Docker setup, especially for development purposes. Please note that non-Docker installations are not officially supported, and you might need to troubleshoot on your own.
 
-**Warning: Backend Dependency for Proper Functionality**
+### Project Components
+
+The Ollama Web UI consists of two primary components: the frontend and the backend (which serves as a reverse proxy, handling static frontend files, and additional features). Both need to be running concurrently for the development environment.
 
-In order to ensure the seamless operation of our application, it is crucial to run both the backend and frontend components simultaneously. Serving only the frontend in isolation is not supported and may lead to unpredictable outcomes, rendering the application inoperable. Attempting to raise an issue when solely serving the frontend will not be addressed, as it falls outside the intended usage. To achieve optimal results, please strictly adhere to the specified steps outlined in this documentation. Utilize the frontend solely for building static files, and subsequently run the complete application with the provided backend commands. Failure to follow these instructions may result in unsupported configurations, and we may not be able to provide assistance in such cases. Your cooperation in following the prescribed procedures is essential for a smooth user experience and effective issue resolution.
+**Warning: Backend Dependency for Proper Functionality**
 
 ### TL;DR 🚀
 
@@ -166,86 +174,6 @@ sh start.sh
 
 You should have the Ollama Web UI up and running at http://localhost:8080/. Enjoy! 😄
 
-### Project Components
-
-The Ollama Web UI consists of two primary components: the frontend and the backend (which serves as a reverse proxy, handling static frontend files, and additional features). Both need to be running concurrently for the development environment using `npm run dev`. Alternatively, you can set the `PUBLIC_API_BASE_URL` during the build process to have the frontend connect directly to your Ollama instance or build the frontend as static files and serve them with the backend.
-
-### Prerequisites
-
-1. **Clone and Enter the Project:**
-
-   ```sh
-   git clone https://github.com/ollama-webui/ollama-webui.git
-   cd ollama-webui/
-   ```
-
-2. **Create and Edit `.env`:**
-
-   ```sh
-   cp -RPp example.env .env
-   ```
-
-### Building Ollama Web UI Frontend
-
-1. **Install Node Dependencies:**
-
-   ```sh
-   npm install
-   ```
-
-2. **Run in Dev Mode or Build for Deployment:**
-
-   - Dev Mode (requires the backend to be running simultaneously):
-
-     ```sh
-     npm run dev
-     ```
-
-   - Build for Deployment:
-
-     ```sh
-     # `PUBLIC_API_BASE_URL` overwrites the value in `.env`
-     PUBLIC_API_BASE_URL='https://example.com/api' npm run build
-     ```
-
-3. **Test the Build with `Caddy` (or your preferred server):**
-
-   ```sh
-   curl https://webi.sh/caddy | sh
-
-   PUBLIC_API_BASE_URL='https://localhost/api' npm run build
-   caddy run --envfile .env --config ./Caddyfile.localhost
-   ```
-
-### Running Ollama Web UI Backend
-
-If you wish to run the backend for deployment, ensure that the frontend is built so that the backend can serve the frontend files along with the API route.
-
-#### Setup Instructions
-
-1. **Install Python Requirements:**
-
-   ```sh
-   cd ./backend
-   pip install -r requirements.txt
-   ```
-
-2. **Run Python Backend:**
-
-   - Dev Mode with Hot Reloading:
-
-     ```sh
-     sh dev.sh
-     ```
-
-   - Deployment:
-
-     ```sh
-     sh start.sh
-     ```
-
-Now, you should have the Ollama Web UI up and running at [http://localhost:8080/](http://localhost:8080/). Feel free to explore the features and functionalities of Ollama! If you encounter any issues, please refer to the instructions above or reach out to the community for assistance.
-
 ## Troubleshooting
 
 See [TROUBLESHOOTING.md](/TROUBLESHOOTING.md) for information on how to troubleshoot and/or join our [Ollama Web UI Discord community](https://discord.gg/5rJgQTnV4s).
@@ -257,7 +185,10 @@ See [TROUBLESHOOTING.md](/TROUBLESHOOTING.md) for information on how to troubles
 Here are some exciting tasks on our roadmap:
 
 - 📚 **RAG Integration**: Experience first-class retrieval augmented generation support, enabling chat with your documents.
-- 🔐 **Access Control**: Securely manage requests to Ollama by utilizing the backend as a reverse proxy gateway, ensuring only authenticated users can send specific requests.
+- 🌐 **Web Browsing Capability**: Experience the convenience of seamlessly integrating web content directly into your chat. Easily browse and share information without leaving the conversation.
+- 🔄 **Function Calling**: Empower your interactions by running code directly within the chat. Execute functions and commands effortlessly, enhancing the functionality of your conversations.
+- ⚙️ **Custom Python Backend Actions**: Empower your Ollama Web UI by creating or downloading custom Python backend actions. Unleash the full potential of your web interface with tailored actions that suit your specific needs, enhancing functionality and versatility.
+- 🧠 **Long-Term Memory**: Witness the power of persistent memory in our agents. Enjoy conversations that feel continuous as agents remember and reference past interactions, creating a more cohesive and personalized user experience.
 - 🧪 **Research-Centric Features**: Empower researchers in the fields of LLM and HCI with a comprehensive web UI for conducting user studies. Stay tuned for ongoing feature enhancements (e.g., surveys, analytics, and participant tracking) to facilitate their research.
 - 📈 **User Study Tools**: Providing specialized tools, like heat maps and behavior tracking modules, to empower researchers in capturing and analyzing user behavior patterns with precision and accuracy.
 - 📚 **Enhanced Documentation**: Elevate your setup and customization experience with improved, comprehensive documentation.
@@ -270,7 +201,11 @@ A big shoutout to our amazing supporters who's helping to make this project poss
 
 ### Platinum Sponsors 🤍
 
-- [Prof. Lawrence Kim @ SFU](https://www.lhkim.com/)
+- We're looking for Sponsors!
+
+### Acknowledgments
+
+Special thanks to [Prof. Lawrence Kim @ SFU](https://www.lhkim.com/) and [Prof. Nick Vincent @ SFU](https://www.nickmvincent.com/) for their invaluable support and guidance in shaping this project into a research endeavor. Grateful for your mentorship throughout the journey! 🙌
 
 ## License 📜
 

+ 10 - 40
TROUBLESHOOTING.md

@@ -1,22 +1,22 @@
 # Ollama Web UI Troubleshooting Guide
 
-## Connection Errors
-
-Make sure you have the **latest version of Ollama** installed before proceeding with the installation. You can find the latest version of Ollama at [https://ollama.ai/](https://ollama.ai/).
-
-If you encounter difficulties connecting to the Ollama server, please follow these steps to diagnose and resolve the issue:
+## Ollama WebUI: Server Connection Error
 
-**1. Verify Ollama Server Configuration**
+If you're running ollama-webui and have chosen to install webui and ollama separately, you might encounter connection issues. This is often due to the docker container being unable to reach the Ollama server at 127.0.0.1:11434(host.docker.internal:11434). To resolve this, you can use the `--network=host` flag in the docker command. When done so port would be changed from 3000 to 8080, and the link would be: http://localhost:8080.
 
-Ensure that the Ollama server is properly configured to accept incoming connections from all origins. To do this, make sure the server is launched with the `OLLAMA_ORIGINS=*` environment variable, as shown in the following command:
+Here's an example of the command you should run:
 
 ```bash
-OLLAMA_HOST=0.0.0.0 OLLAMA_ORIGINS=* ollama serve
+docker run -d --network=host -e OLLAMA_API_BASE_URL=http://127.0.0.1:11434/api --name ollama-webui --restart always ghcr.io/ollama-webui/ollama-webui:main
 ```
 
-This configuration allows Ollama to accept connections from any source.
+## Connection Errors
+
+Make sure you have the **latest version of Ollama** installed before proceeding with the installation. You can find the latest version of Ollama at [https://ollama.ai/](https://ollama.ai/).
+
+If you encounter difficulties connecting to the Ollama server, please follow these steps to diagnose and resolve the issue:
 
-**2. Check Ollama URL Format**
+**1. Check Ollama URL Format**
 
 Ensure that the Ollama URL is correctly formatted in the application settings. Follow these steps:
 
@@ -28,33 +28,3 @@ Ensure that the Ollama URL is correctly formatted in the application settings. F
 It is crucial to include the `/api` at the end of the URL to ensure that the Ollama Web UI can communicate with the server.
 
 By following these troubleshooting steps, you should be able to identify and resolve connection issues with your Ollama server configuration. If you require further assistance or have additional questions, please don't hesitate to reach out or refer to our documentation for comprehensive guidance.
-
-## Running ollama-webui as a container on Apple Silicon Mac
-
-If you are running Docker on a M{1..3} based Mac and have taken the steps to run an x86 container, add "--platform linux/amd64" to the docker run command to prevent a warning.
-
-Example:
-
-```bash
-docker run -d -p 3000:8080 -e OLLAMA_API_BASE_URL=http://example.com:11434/api --name ollama-webui --restart always ghcr.io/ollama-webui/ollama-webui:main
-```
-
-Becomes
-
-```
-docker run --platform linux/amd64 -d -p 3000:8080 -e OLLAMA_API_BASE_URL=http://example.com:11434/api --name ollama-webui --restart always ghcr.io/ollama-webui/ollama-webui:main
-```
-
-## Running ollama-webui as a container on WSL Ubuntu
-If you're running ollama-webui via docker on WSL Ubuntu and have chosen to install webui and ollama separately, you might encounter connection issues. This is often due to the docker container being unable to reach the Ollama server at 127.0.0.1:11434. To resolve this, you can use the `--network=host` flag in the docker command. When done so port would be changed from 3000 to 8080, and the link would be: http://localhost:8080.
-
-Here's an example of the command you should run:
-
-```bash
-docker run -d --network=host -e OLLAMA_API_BASE_URL=http://127.0.0.1:11434/api --name ollama-webui --restart always ghcr.io/ollama-webui/ollama-webui:main
-```
-
-## References
-
-[Change Docker Desktop Settings on Mac](https://docs.docker.com/desktop/settings/mac/) Search for "x86" in that page.
-[Run x86 (Intel) and ARM based images on Apple Silicon (M1) Macs?](https://forums.docker.com/t/run-x86-intel-and-arm-based-images-on-apple-silicon-m1-macs/117123)

+ 7 - 0
backend/.dockerignore

@@ -0,0 +1,7 @@
+__pycache__
+.env
+_old
+uploads
+.ipynb_checkpoints
+*.db
+_test

+ 4 - 1
backend/.gitignore

@@ -1,4 +1,7 @@
 __pycache__
 .env
 _old
-uploads
+uploads
+.ipynb_checkpoints
+*.db
+_test

+ 8 - 1
backend/apps/ollama/main.py

@@ -25,7 +25,7 @@ TARGET_SERVER_URL = OLLAMA_API_BASE_URL
 def proxy(path):
     # Combine the base URL of the target server with the requested path
     target_url = f"{TARGET_SERVER_URL}/{path}"
-    print(path)
+    print(target_url)
 
     # Get data from the original request
     data = request.get_data()
@@ -61,6 +61,11 @@ def proxy(path):
 
     r = None
 
+    headers.pop("Host", None)
+    headers.pop("Authorization", None)
+    headers.pop("Origin", None)
+    headers.pop("Referer", None)
+
     try:
         # Make a request to the target server
         r = requests.request(
@@ -86,8 +91,10 @@ def proxy(path):
 
         return response
     except Exception as e:
+        print(e)
         error_detail = "Ollama WebUI: Server Connection Error"
         if r != None:
+            print(r.text)
             res = r.json()
             if "error" in res:
                 error_detail = f"Ollama: {res['error']}"

+ 4 - 0
backend/apps/web/internal/db.py

@@ -0,0 +1,4 @@
+from peewee import *
+
+DB = SqliteDatabase("./data/ollama.db")
+DB.connect()

+ 5 - 1
backend/apps/web/main.py

@@ -1,7 +1,7 @@
 from fastapi import FastAPI, Request, Depends, HTTPException
 from fastapi.middleware.cors import CORSMiddleware
 
-from apps.web.routers import auths, users, utils
+from apps.web.routers import auths, users, chats, modelfiles, utils
 from config import WEBUI_VERSION, WEBUI_AUTH
 
 app = FastAPI()
@@ -19,6 +19,10 @@ app.add_middleware(
 
 app.include_router(auths.router, prefix="/auths", tags=["auths"])
 app.include_router(users.router, prefix="/users", tags=["users"])
+app.include_router(chats.router, prefix="/chats", tags=["chats"])
+app.include_router(modelfiles.router, prefix="/modelfiles", tags=["modelfiles"])
+
+
 app.include_router(utils.router, prefix="/utils", tags=["utils"])
 
 

+ 25 - 15
backend/apps/web/models/auths.py

@@ -2,6 +2,7 @@ from pydantic import BaseModel
 from typing import List, Union, Optional
 import time
 import uuid
+from peewee import *
 
 
 from apps.web.models.users import UserModel, Users
@@ -12,15 +13,23 @@ from utils.utils import (
     create_token,
 )
 
-import config
-
-DB = config.DB
+from apps.web.internal.db import DB
 
 ####################
 # DB MODEL
 ####################
 
 
+class Auth(Model):
+    id = CharField(unique=True)
+    email = CharField()
+    password = CharField()
+    active = BooleanField()
+
+    class Meta:
+        database = DB
+
+
 class AuthModel(BaseModel):
     id: str
     email: str
@@ -64,7 +73,7 @@ class SignupForm(BaseModel):
 class AuthsTable:
     def __init__(self, db):
         self.db = db
-        self.table = db.auths
+        self.db.create_tables([Auth])
 
     def insert_new_auth(
         self, email: str, password: str, name: str, role: str = "pending"
@@ -76,27 +85,28 @@ class AuthsTable:
         auth = AuthModel(
             **{"id": id, "email": email, "password": password, "active": True}
         )
-        result = self.table.insert_one(auth.model_dump())
+        result = Auth.create(**auth.model_dump())
+
         user = Users.insert_new_user(id, name, email, role)
 
-        print(result, user)
         if result and user:
             return user
         else:
             return None
 
     def authenticate_user(self, email: str, password: str) -> Optional[UserModel]:
-        print("authenticate_user")
-
-        auth = self.table.find_one({"email": email, "active": True})
-
-        if auth:
-            if verify_password(password, auth["password"]):
-                user = self.db.users.find_one({"id": auth["id"]})
-                return UserModel(**user)
+        print("authenticate_user", email)
+        try:
+            auth = Auth.get(Auth.email == email, Auth.active == True)
+            if auth:
+                if verify_password(password, auth.password):
+                    user = Users.get_user_by_id(auth.id)
+                    return user
+                else:
+                    return None
             else:
                 return None
-        else:
+        except:
             return None
 
 

+ 157 - 0
backend/apps/web/models/chats.py

@@ -0,0 +1,157 @@
+from pydantic import BaseModel
+from typing import List, Union, Optional
+from peewee import *
+from playhouse.shortcuts import model_to_dict
+
+
+import json
+import uuid
+import time
+
+from apps.web.internal.db import DB
+
+
+####################
+# Chat DB Schema
+####################
+
+
+class Chat(Model):
+    id = CharField(unique=True)
+    user_id = CharField()
+    title = CharField()
+    chat = TextField()  # Save Chat JSON as Text
+    timestamp = DateField()
+
+    class Meta:
+        database = DB
+
+
+class ChatModel(BaseModel):
+    id: str
+    user_id: str
+    title: str
+    chat: str
+    timestamp: int  # timestamp in epoch
+
+
+####################
+# Forms
+####################
+
+
+class ChatForm(BaseModel):
+    chat: dict
+
+
+class ChatTitleForm(BaseModel):
+    title: str
+
+
+class ChatResponse(BaseModel):
+    id: str
+    user_id: str
+    title: str
+    chat: dict
+    timestamp: int  # timestamp in epoch
+
+
+class ChatTitleIdResponse(BaseModel):
+    id: str
+    title: str
+
+
+class ChatTable:
+    def __init__(self, db):
+        self.db = db
+        db.create_tables([Chat])
+
+    def insert_new_chat(self, user_id: str, form_data: ChatForm) -> Optional[ChatModel]:
+        id = str(uuid.uuid4())
+        chat = ChatModel(
+            **{
+                "id": id,
+                "user_id": user_id,
+                "title": form_data.chat["title"]
+                if "title" in form_data.chat
+                else "New Chat",
+                "chat": json.dumps(form_data.chat),
+                "timestamp": int(time.time()),
+            }
+        )
+
+        result = Chat.create(**chat.model_dump())
+        return chat if result else None
+
+    def update_chat_by_id(self, id: str, chat: dict) -> Optional[ChatModel]:
+        try:
+            query = Chat.update(
+                chat=json.dumps(chat),
+                title=chat["title"] if "title" in chat else "New Chat",
+                timestamp=int(time.time()),
+            ).where(Chat.id == id)
+            query.execute()
+
+            chat = Chat.get(Chat.id == id)
+            return ChatModel(**model_to_dict(chat))
+        except:
+            return None
+
+    def update_chat_by_id(self, id: str, chat: dict) -> Optional[ChatModel]:
+        try:
+            query = Chat.update(
+                chat=json.dumps(chat),
+                title=chat["title"] if "title" in chat else "New Chat",
+                timestamp=int(time.time()),
+            ).where(Chat.id == id)
+            query.execute()
+
+            chat = Chat.get(Chat.id == id)
+            return ChatModel(**model_to_dict(chat))
+        except:
+            return None
+
+    def get_chat_lists_by_user_id(
+        self, user_id: str, skip: int = 0, limit: int = 50
+    ) -> List[ChatModel]:
+        return [
+            ChatModel(**model_to_dict(chat))
+            for chat in Chat.select()
+            .where(Chat.user_id == user_id)
+            .order_by(Chat.timestamp.desc())
+            # .limit(limit)
+            # .offset(skip)
+        ]
+
+    def get_all_chats_by_user_id(self, user_id: str) -> List[ChatModel]:
+        return [
+            ChatModel(**model_to_dict(chat))
+            for chat in Chat.select()
+            .where(Chat.user_id == user_id)
+            .order_by(Chat.timestamp.desc())
+        ]
+
+    def get_chat_by_id_and_user_id(self, id: str, user_id: str) -> Optional[ChatModel]:
+        try:
+            chat = Chat.get(Chat.id == id, Chat.user_id == user_id)
+            return ChatModel(**model_to_dict(chat))
+        except:
+            return None
+
+    def get_chats(self, skip: int = 0, limit: int = 50) -> List[ChatModel]:
+        return [
+            ChatModel(**model_to_dict(chat))
+            for chat in Chat.select().limit(limit).offset(skip)
+        ]
+
+    def delete_chat_by_id_and_user_id(self, id: str, user_id: str) -> bool:
+        try:
+            query = Chat.delete().where((Chat.id == id) & (Chat.user_id == user_id))
+            query.execute()  # Remove the rows, return number of rows removed.
+
+            return True
+        except:
+            return False
+
+
+Chats = ChatTable(DB)

+ 135 - 0
backend/apps/web/models/modelfiles.py

@@ -0,0 +1,135 @@
+from pydantic import BaseModel
+from peewee import *
+from playhouse.shortcuts import model_to_dict
+from typing import List, Union, Optional
+import time
+
+from utils.utils import decode_token
+from utils.misc import get_gravatar_url
+
+from apps.web.internal.db import DB
+
+import json
+
+####################
+# User DB Schema
+####################
+
+
+class Modelfile(Model):
+    tag_name = CharField(unique=True)
+    user_id = CharField()
+    modelfile = TextField()
+    timestamp = DateField()
+
+    class Meta:
+        database = DB
+
+
+class ModelfileModel(BaseModel):
+    tag_name: str
+    user_id: str
+    modelfile: str
+    timestamp: int  # timestamp in epoch
+
+
+####################
+# Forms
+####################
+
+
+class ModelfileForm(BaseModel):
+    modelfile: dict
+
+
+class ModelfileTagNameForm(BaseModel):
+    tag_name: str
+
+
+class ModelfileUpdateForm(ModelfileForm, ModelfileTagNameForm):
+    pass
+
+
+class ModelfileResponse(BaseModel):
+    tag_name: str
+    user_id: str
+    modelfile: dict
+    timestamp: int  # timestamp in epoch
+
+
+class ModelfilesTable:
+    def __init__(self, db):
+        self.db = db
+        self.db.create_tables([Modelfile])
+
+    def insert_new_modelfile(
+        self, user_id: str, form_data: ModelfileForm
+    ) -> Optional[ModelfileModel]:
+        if "tagName" in form_data.modelfile:
+            modelfile = ModelfileModel(
+                **{
+                    "user_id": user_id,
+                    "tag_name": form_data.modelfile["tagName"],
+                    "modelfile": json.dumps(form_data.modelfile),
+                    "timestamp": int(time.time()),
+                }
+            )
+
+            try:
+                result = Modelfile.create(**modelfile.model_dump())
+                if result:
+                    return modelfile
+                else:
+                    return None
+            except:
+                return None
+
+        else:
+            return None
+
+    def get_modelfile_by_tag_name(self, tag_name: str) -> Optional[ModelfileModel]:
+        try:
+            modelfile = Modelfile.get(Modelfile.tag_name == tag_name)
+            return ModelfileModel(**model_to_dict(modelfile))
+        except:
+            return None
+
+    def get_modelfiles(self, skip: int = 0, limit: int = 50) -> List[ModelfileResponse]:
+        return [
+            ModelfileResponse(
+                **{
+                    **model_to_dict(modelfile),
+                    "modelfile": json.loads(modelfile.modelfile),
+                }
+            )
+            for modelfile in Modelfile.select()
+            # .limit(limit).offset(skip)
+        ]
+
+    def update_modelfile_by_tag_name(
+        self, tag_name: str, modelfile: dict
+    ) -> Optional[ModelfileModel]:
+        try:
+            query = Modelfile.update(
+                modelfile=json.dumps(modelfile),
+                timestamp=int(time.time()),
+            ).where(Modelfile.tag_name == tag_name)
+
+            query.execute()
+
+            modelfile = Modelfile.get(Modelfile.tag_name == tag_name)
+            return ModelfileModel(**model_to_dict(modelfile))
+        except:
+            return None
+
+    def delete_modelfile_by_tag_name(self, tag_name: str) -> bool:
+        try:
+            query = Modelfile.delete().where((Modelfile.tag_name == tag_name))
+            query.execute()  # Remove the rows, return number of rows removed.
+
+            return True
+        except:
+            return False
+
+
+Modelfiles = ModelfilesTable(DB)

+ 41 - 24
backend/apps/web/models/users.py

@@ -1,25 +1,38 @@
 from pydantic import BaseModel
+from peewee import *
+from playhouse.shortcuts import model_to_dict
 from typing import List, Union, Optional
-from pymongo import ReturnDocument
 import time
 
 from utils.utils import decode_token
 from utils.misc import get_gravatar_url
 
-from config import DB
+from apps.web.internal.db import DB
 
 ####################
 # User DB Schema
 ####################
 
 
+class User(Model):
+    id = CharField(unique=True)
+    name = CharField()
+    email = CharField()
+    role = CharField()
+    profile_image_url = CharField()
+    timestamp = DateField()
+
+    class Meta:
+        database = DB
+
+
 class UserModel(BaseModel):
     id: str
     name: str
     email: str
     role: str = "pending"
     profile_image_url: str = "/user.png"
-    created_at: int  # timestamp in epoch
+    timestamp: int  # timestamp in epoch
 
 
 ####################
@@ -35,7 +48,7 @@ class UserRoleUpdateForm(BaseModel):
 class UsersTable:
     def __init__(self, db):
         self.db = db
-        self.table = db.users
+        self.db.create_tables([User])
 
     def insert_new_user(
         self, id: str, name: str, email: str, role: str = "pending"
@@ -47,22 +60,27 @@ class UsersTable:
                 "email": email,
                 "role": role,
                 "profile_image_url": get_gravatar_url(email),
-                "created_at": int(time.time()),
+                "timestamp": int(time.time()),
             }
         )
-        result = self.table.insert_one(user.model_dump())
-
+        result = User.create(**user.model_dump())
         if result:
             return user
         else:
             return None
 
-    def get_user_by_email(self, email: str) -> Optional[UserModel]:
-        user = self.table.find_one({"email": email}, {"_id": False})
+    def get_user_by_id(self, id: str) -> Optional[UserModel]:
+        try:
+            user = User.get(User.id == id)
+            return UserModel(**model_to_dict(user))
+        except:
+            return None
 
-        if user:
-            return UserModel(**user)
-        else:
+    def get_user_by_email(self, email: str) -> Optional[UserModel]:
+        try:
+            user = User.get(User.email == email)
+            return UserModel(**model_to_dict(user))
+        except:
             return None
 
     def get_user_by_token(self, token: str) -> Optional[UserModel]:
@@ -75,23 +93,22 @@ class UsersTable:
 
     def get_users(self, skip: int = 0, limit: int = 50) -> List[UserModel]:
         return [
-            UserModel(**user)
-            for user in list(
-                self.table.find({}, {"_id": False}).skip(skip).limit(limit)
-            )
+            UserModel(**model_to_dict(user))
+            for user in User.select().limit(limit).offset(skip)
         ]
 
     def get_num_users(self) -> Optional[int]:
-        return self.table.count_documents({})
-
-    def update_user_by_id(self, id: str, updated: dict) -> Optional[UserModel]:
-        user = self.table.find_one_and_update(
-            {"id": id}, {"$set": updated}, return_document=ReturnDocument.AFTER
-        )
-        return UserModel(**user)
+        return User.select().count()
 
     def update_user_role_by_id(self, id: str, role: str) -> Optional[UserModel]:
-        return self.update_user_by_id(id, {"role": role})
+        try:
+            query = User.update(role=role).where(User.id == id)
+            query.execute()
+
+            user = User.get(User.id == id)
+            return UserModel(**model_to_dict(user))
+        except:
+            return None
 
 
 Users = UsersTable(DB)

+ 2 - 2
backend/apps/web/routers/auths.py

@@ -104,8 +104,8 @@ async def signup(form_data: SignupForm):
                     "profile_image_url": user.profile_image_url,
                 }
             else:
-                raise HTTPException(500, detail=ERROR_MESSAGES.DEFAULT(err))
+                raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
         except Exception as err:
             raise HTTPException(500, detail=ERROR_MESSAGES.DEFAULT(err))
     else:
-        raise HTTPException(400, detail=ERROR_MESSAGES.DEFAULT())
+        raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)

+ 161 - 0
backend/apps/web/routers/chats.py

@@ -0,0 +1,161 @@
+from fastapi import Response
+from fastapi import Depends, FastAPI, HTTPException, status
+from datetime import datetime, timedelta
+from typing import List, Union, Optional
+
+from fastapi import APIRouter
+from pydantic import BaseModel
+import json
+
+from apps.web.models.users import Users
+from apps.web.models.chats import (
+    ChatModel,
+    ChatResponse,
+    ChatTitleForm,
+    ChatForm,
+    ChatTitleIdResponse,
+    Chats,
+)
+
+from utils.utils import (
+    bearer_scheme,
+)
+from constants import ERROR_MESSAGES
+
+router = APIRouter()
+
+############################
+# GetChats
+############################
+
+
+@router.get("/", response_model=List[ChatTitleIdResponse])
+async def get_user_chats(skip: int = 0, limit: int = 50, cred=Depends(bearer_scheme)):
+    token = cred.credentials
+    user = Users.get_user_by_token(token)
+
+    if user:
+        return Chats.get_chat_lists_by_user_id(user.id, skip, limit)
+    else:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail=ERROR_MESSAGES.INVALID_TOKEN,
+        )
+
+
+############################
+# GetAllChats
+############################
+
+
+@router.get("/all", response_model=List[ChatResponse])
+async def get_all_user_chats(cred=Depends(bearer_scheme)):
+    token = cred.credentials
+    user = Users.get_user_by_token(token)
+
+    if user:
+        return [
+            ChatResponse(**{**chat.model_dump(), "chat": json.loads(chat.chat)})
+            for chat in Chats.get_all_chats_by_user_id(user.id)
+        ]
+    else:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail=ERROR_MESSAGES.INVALID_TOKEN,
+        )
+
+
+############################
+# CreateNewChat
+############################
+
+
+@router.post("/new", response_model=Optional[ChatResponse])
+async def create_new_chat(form_data: ChatForm, cred=Depends(bearer_scheme)):
+    token = cred.credentials
+    user = Users.get_user_by_token(token)
+
+    if user:
+        chat = Chats.insert_new_chat(user.id, form_data)
+        return ChatResponse(**{**chat.model_dump(), "chat": json.loads(chat.chat)})
+    else:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail=ERROR_MESSAGES.INVALID_TOKEN,
+        )
+
+
+############################
+# GetChatById
+############################
+
+
+@router.get("/{id}", response_model=Optional[ChatResponse])
+async def get_chat_by_id(id: str, cred=Depends(bearer_scheme)):
+    token = cred.credentials
+    user = Users.get_user_by_token(token)
+
+    if user:
+        chat = Chats.get_chat_by_id_and_user_id(id, user.id)
+
+        if chat:
+            return ChatResponse(**{**chat.model_dump(), "chat": json.loads(chat.chat)})
+        else:
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail=ERROR_MESSAGES.NOT_FOUND,
+            )
+    else:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail=ERROR_MESSAGES.INVALID_TOKEN,
+        )
+
+
+############################
+# UpdateChatById
+############################
+
+
+@router.post("/{id}", response_model=Optional[ChatResponse])
+async def update_chat_by_id(id: str, form_data: ChatForm, cred=Depends(bearer_scheme)):
+    token = cred.credentials
+    user = Users.get_user_by_token(token)
+
+    if user:
+        chat = Chats.get_chat_by_id_and_user_id(id, user.id)
+        if chat:
+            updated_chat = {**json.loads(chat.chat), **form_data.chat}
+
+            chat = Chats.update_chat_by_id(id, updated_chat)
+            return ChatResponse(**{**chat.model_dump(), "chat": json.loads(chat.chat)})
+        else:
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
+            )
+    else:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail=ERROR_MESSAGES.INVALID_TOKEN,
+        )
+
+
+############################
+# DeleteChatById
+############################
+
+
+@router.delete("/{id}", response_model=bool)
+async def delete_chat_by_id(id: str, cred=Depends(bearer_scheme)):
+    token = cred.credentials
+    user = Users.get_user_by_token(token)
+
+    if user:
+        result = Chats.delete_chat_by_id_and_user_id(id, user.id)
+        return result
+    else:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail=ERROR_MESSAGES.INVALID_TOKEN,
+        )

+ 191 - 0
backend/apps/web/routers/modelfiles.py

@@ -0,0 +1,191 @@
+from fastapi import Response
+from fastapi import Depends, FastAPI, HTTPException, status
+from datetime import datetime, timedelta
+from typing import List, Union, Optional
+
+from fastapi import APIRouter
+from pydantic import BaseModel
+import json
+
+from apps.web.models.users import Users
+from apps.web.models.modelfiles import (
+    Modelfiles,
+    ModelfileForm,
+    ModelfileTagNameForm,
+    ModelfileUpdateForm,
+    ModelfileResponse,
+)
+
+from utils.utils import (
+    bearer_scheme,
+)
+from constants import ERROR_MESSAGES
+
+router = APIRouter()
+
+############################
+# GetModelfiles
+############################
+
+
+@router.get("/", response_model=List[ModelfileResponse])
+async def get_modelfiles(skip: int = 0, limit: int = 50, cred=Depends(bearer_scheme)):
+    token = cred.credentials
+    user = Users.get_user_by_token(token)
+
+    if user:
+        return Modelfiles.get_modelfiles(skip, limit)
+    else:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail=ERROR_MESSAGES.INVALID_TOKEN,
+        )
+
+
+############################
+# CreateNewModelfile
+############################
+
+
+@router.post("/create", response_model=Optional[ModelfileResponse])
+async def create_new_modelfile(form_data: ModelfileForm, cred=Depends(bearer_scheme)):
+    token = cred.credentials
+    user = Users.get_user_by_token(token)
+
+    if user:
+        # Admin Only
+        if user.role == "admin":
+            modelfile = Modelfiles.insert_new_modelfile(user.id, form_data)
+
+            if modelfile:
+                return ModelfileResponse(
+                    **{
+                        **modelfile.model_dump(),
+                        "modelfile": json.loads(modelfile.modelfile),
+                    }
+                )
+            else:
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail=ERROR_MESSAGES.DEFAULT(),
+                )
+        else:
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
+            )
+    else:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail=ERROR_MESSAGES.INVALID_TOKEN,
+        )
+
+
+############################
+# GetModelfileByTagName
+############################
+
+
+@router.post("/", response_model=Optional[ModelfileResponse])
+async def get_modelfile_by_tag_name(
+    form_data: ModelfileTagNameForm, cred=Depends(bearer_scheme)
+):
+    token = cred.credentials
+    user = Users.get_user_by_token(token)
+
+    if user:
+        modelfile = Modelfiles.get_modelfile_by_tag_name(form_data.tag_name)
+
+        if modelfile:
+            return ModelfileResponse(
+                **{
+                    **modelfile.model_dump(),
+                    "modelfile": json.loads(modelfile.modelfile),
+                }
+            )
+        else:
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail=ERROR_MESSAGES.NOT_FOUND,
+            )
+    else:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail=ERROR_MESSAGES.INVALID_TOKEN,
+        )
+
+
+############################
+# UpdateModelfileByTagName
+############################
+
+
+@router.post("/update", response_model=Optional[ModelfileResponse])
+async def update_modelfile_by_tag_name(
+    form_data: ModelfileUpdateForm, cred=Depends(bearer_scheme)
+):
+    token = cred.credentials
+    user = Users.get_user_by_token(token)
+
+    if user:
+        if user.role == "admin":
+            modelfile = Modelfiles.get_modelfile_by_tag_name(form_data.tag_name)
+            if modelfile:
+                updated_modelfile = {
+                    **json.loads(modelfile.modelfile),
+                    **form_data.modelfile,
+                }
+
+                modelfile = Modelfiles.update_modelfile_by_tag_name(
+                    form_data.tag_name, updated_modelfile
+                )
+
+                return ModelfileResponse(
+                    **{
+                        **modelfile.model_dump(),
+                        "modelfile": json.loads(modelfile.modelfile),
+                    }
+                )
+            else:
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
+                )
+        else:
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
+            )
+    else:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail=ERROR_MESSAGES.INVALID_TOKEN,
+        )
+
+
+############################
+# DeleteModelfileByTagName
+############################
+
+
+@router.delete("/delete", response_model=bool)
+async def delete_modelfile_by_tag_name(
+    form_data: ModelfileTagNameForm, cred=Depends(bearer_scheme)
+):
+    token = cred.credentials
+    user = Users.get_user_by_token(token)
+
+    if user:
+        if user.role == "admin":
+            result = Modelfiles.delete_modelfile_by_tag_name(form_data.tag_name)
+            return result
+        else:
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
+            )
+    else:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail=ERROR_MESSAGES.INVALID_TOKEN,
+        )

+ 5 - 21
backend/config.py

@@ -1,9 +1,10 @@
 from dotenv import load_dotenv, find_dotenv
-from pymongo import MongoClient
+
 from constants import ERROR_MESSAGES
 
 from secrets import token_bytes
 from base64 import b64encode
+
 import os
 
 load_dotenv(find_dotenv("../.env"))
@@ -30,30 +31,13 @@ if ENV == "prod":
 # WEBUI_VERSION
 ####################################
 
-WEBUI_VERSION = os.environ.get("WEBUI_VERSION", "v1.0.0-alpha.40")
-
-####################################
-# WEBUI_AUTH
-####################################
-
-
-WEBUI_AUTH = True if os.environ.get("WEBUI_AUTH", "FALSE") == "TRUE" else False
-
+WEBUI_VERSION = os.environ.get("WEBUI_VERSION", "v1.0.0-alpha.42")
 
 ####################################
-# WEBUI_DB (Deprecated, Should be removed)
+# WEBUI_AUTH (Required for security)
 ####################################
 
-
-WEBUI_DB_URL = os.environ.get("WEBUI_DB_URL", "mongodb://root:root@localhost:27017/")
-
-if WEBUI_AUTH and WEBUI_DB_URL == "":
-    raise ValueError(ERROR_MESSAGES.ENV_VAR_NOT_FOUND)
-
-
-DB_CLIENT = MongoClient(f"{WEBUI_DB_URL}?authSource=admin")
-DB = DB_CLIENT["ollama-webui"]
-
+WEBUI_AUTH = True
 
 ####################################
 # WEBUI_JWT_SECRET_KEY

+ 6 - 0
backend/constants.py

@@ -11,6 +11,11 @@ class ERROR_MESSAGES(str, Enum):
 
     DEFAULT = lambda err="": f"Something went wrong :/\n{err if err else ''}"
     ENV_VAR_NOT_FOUND = "Required environment variable not found. Terminating now."
+    CREATE_USER_ERROR = "Oops! Something went wrong while creating your account. Please try again later. If the issue persists, contact support for assistance."
+    EMAIL_TAKEN = "Uh-oh! This email is already registered. Sign in with your existing account or choose another email to start anew."
+    USERNAME_TAKEN = (
+        "Uh-oh! This username is already registered. Please choose another username."
+    )
     INVALID_TOKEN = (
         "Your session has expired or the token is invalid. Please sign in again."
     )
@@ -20,5 +25,6 @@ class ERROR_MESSAGES(str, Enum):
     ACTION_PROHIBITED = (
         "The requested action has been restricted as a security measure."
     )
+    NOT_FOUND = "We could not find what you're looking for :/"
     USER_NOT_FOUND = "We could not find what you're looking for :/"
     MALICIOUS = "Unusual activities detected, please try again in a few minutes."

+ 1 - 0
backend/data/readme.txt

@@ -0,0 +1 @@
+dir for backend files (db, documents, etc.)

+ 1 - 1
backend/dev.sh

@@ -1 +1 @@
-uvicorn main:app --port 8080 --reload
+uvicorn main:app --port 8080 --host 0.0.0.0 --reload

+ 2 - 2
backend/requirements.txt

@@ -13,8 +13,8 @@ uuid
 
 requests
 aiohttp
-pymongo
+peewee
 bcrypt
 
 PyJWT
-pyjwt[crypto]
+pyjwt[crypto]

+ 3 - 0
docker-compose.yml

@@ -18,6 +18,8 @@ services:
       dockerfile: Dockerfile
     image: ollama-webui:latest
     container_name: ollama-webui
+    volumes:
+      - ollama-webui:/app/backend/data
     depends_on:
       - ollama
     ports:
@@ -30,3 +32,4 @@ services:
 
 volumes:
   ollama: {}
+  ollama-webui: {}

+ 1 - 1
run.sh

@@ -1,5 +1,5 @@
 docker stop ollama-webui || true
 docker rm ollama-webui || true
 docker build -t ollama-webui .
-docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway --name ollama-webui --restart always ollama-webui
+docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v ollama-webui:/app/backend/data --name ollama-webui --restart always ollama-webui
 docker image prune -f

+ 90 - 0
src/lib/apis/auths/index.ts

@@ -0,0 +1,90 @@
+import { WEBUI_API_BASE_URL } from '$lib/constants';
+
+export const getSessionUser = async (token: string) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/auths`, {
+		method: 'GET',
+		headers: {
+			'Content-Type': 'application/json',
+			Authorization: `Bearer ${token}`
+		}
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.catch((err) => {
+			console.log(err);
+			error = err.detail;
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
+export const userSignIn = async (email: string, password: string) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/auths/signin`, {
+		method: 'POST',
+		headers: {
+			'Content-Type': 'application/json'
+		},
+		body: JSON.stringify({
+			email: email,
+			password: password
+		})
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.catch((err) => {
+			console.log(err);
+
+			error = err.detail;
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
+export const userSignUp = async (name: string, email: string, password: string) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/auths/signup`, {
+		method: 'POST',
+		headers: {
+			'Content-Type': 'application/json'
+		},
+		body: JSON.stringify({
+			name: name,
+			email: email,
+			password: password
+		})
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.catch((err) => {
+			console.log(err);
+			error = err.detail;
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};

+ 193 - 0
src/lib/apis/chats/index.ts

@@ -0,0 +1,193 @@
+import { WEBUI_API_BASE_URL } from '$lib/constants';
+
+export const createNewChat = async (token: string, chat: object) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/chats/new`, {
+		method: 'POST',
+		headers: {
+			Accept: 'application/json',
+			'Content-Type': 'application/json',
+			authorization: `Bearer ${token}`
+		},
+		body: JSON.stringify({
+			chat: chat
+		})
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.catch((err) => {
+			error = err;
+			console.log(err);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
+export const getChatList = async (token: string = '') => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/chats/`, {
+		method: 'GET',
+		headers: {
+			Accept: 'application/json',
+			'Content-Type': 'application/json',
+			...(token && { authorization: `Bearer ${token}` })
+		}
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.then((json) => {
+			return json;
+		})
+		.catch((err) => {
+			error = err;
+			console.log(err);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
+export const getAllChats = async (token: string) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/chats/all`, {
+		method: 'GET',
+		headers: {
+			Accept: 'application/json',
+			'Content-Type': 'application/json',
+			...(token && { authorization: `Bearer ${token}` })
+		}
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.then((json) => {
+			return json;
+		})
+		.catch((err) => {
+			error = err;
+			console.log(err);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
+export const getChatById = async (token: string, id: string) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}`, {
+		method: 'GET',
+		headers: {
+			Accept: 'application/json',
+			'Content-Type': 'application/json',
+			...(token && { authorization: `Bearer ${token}` })
+		}
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.then((json) => {
+			return json;
+		})
+		.catch((err) => {
+			error = err;
+
+			console.log(err);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
+export const updateChatById = async (token: string, id: string, chat: object) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}`, {
+		method: 'POST',
+		headers: {
+			Accept: 'application/json',
+			'Content-Type': 'application/json',
+			...(token && { authorization: `Bearer ${token}` })
+		},
+		body: JSON.stringify({
+			chat: chat
+		})
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.then((json) => {
+			return json;
+		})
+		.catch((err) => {
+			error = err;
+
+			console.log(err);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
+export const deleteChatById = async (token: string, id: string) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}`, {
+		method: 'DELETE',
+		headers: {
+			Accept: 'application/json',
+			'Content-Type': 'application/json',
+			...(token && { authorization: `Bearer ${token}` })
+		}
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.then((json) => {
+			return json;
+		})
+		.catch((err) => {
+			error = err;
+
+			console.log(err);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};

+ 23 - 0
src/lib/apis/index.ts

@@ -0,0 +1,23 @@
+import { WEBUI_API_BASE_URL } from '$lib/constants';
+
+export const getBackendConfig = async () => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/`, {
+		method: 'GET',
+		headers: {
+			'Content-Type': 'application/json'
+		}
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.catch((err) => {
+			console.log(err);
+			error = err;
+			return null;
+		});
+
+	return res;
+};

+ 173 - 0
src/lib/apis/modelfiles/index.ts

@@ -0,0 +1,173 @@
+import { WEBUI_API_BASE_URL } from '$lib/constants';
+
+export const createNewModelfile = async (token: string, modelfile: object) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/modelfiles/create`, {
+		method: 'POST',
+		headers: {
+			Accept: 'application/json',
+			'Content-Type': 'application/json',
+			authorization: `Bearer ${token}`
+		},
+		body: JSON.stringify({
+			modelfile: modelfile
+		})
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.catch((err) => {
+			error = err.detail;
+			console.log(err);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
+export const getModelfiles = async (token: string = '') => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/modelfiles/`, {
+		method: 'GET',
+		headers: {
+			Accept: 'application/json',
+			'Content-Type': 'application/json',
+			authorization: `Bearer ${token}`
+		}
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.then((json) => {
+			return json;
+		})
+		.catch((err) => {
+			error = err;
+			console.log(err);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res.map((modelfile) => modelfile.modelfile);
+};
+
+export const getModelfileByTagName = async (token: string, tagName: string) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/modelfiles/`, {
+		method: 'POST',
+		headers: {
+			Accept: 'application/json',
+			'Content-Type': 'application/json',
+			authorization: `Bearer ${token}`
+		},
+		body: JSON.stringify({
+			tag_name: tagName
+		})
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.then((json) => {
+			return json;
+		})
+		.catch((err) => {
+			error = err;
+
+			console.log(err);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res.modelfile;
+};
+
+export const updateModelfileByTagName = async (
+	token: string,
+	tagName: string,
+	modelfile: object
+) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/modelfiles/update`, {
+		method: 'POST',
+		headers: {
+			Accept: 'application/json',
+			'Content-Type': 'application/json',
+			authorization: `Bearer ${token}`
+		},
+		body: JSON.stringify({
+			tag_name: tagName,
+			modelfile: modelfile
+		})
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.then((json) => {
+			return json;
+		})
+		.catch((err) => {
+			error = err;
+
+			console.log(err);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
+export const deleteModelfileByTagName = async (token: string, tagName: string) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/modelfiles/delete`, {
+		method: 'DELETE',
+		headers: {
+			Accept: 'application/json',
+			'Content-Type': 'application/json',
+			authorization: `Bearer ${token}`
+		},
+		body: JSON.stringify({
+			tag_name: tagName
+		})
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.then((json) => {
+			return json;
+		})
+		.catch((err) => {
+			error = err;
+
+			console.log(err);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};

+ 204 - 0
src/lib/apis/ollama/index.ts

@@ -0,0 +1,204 @@
+import { OLLAMA_API_BASE_URL } from '$lib/constants';
+
+export const getOllamaVersion = async (
+	base_url: string = OLLAMA_API_BASE_URL,
+	token: string = ''
+) => {
+	let error = null;
+
+	const res = await fetch(`${base_url}/version`, {
+		method: 'GET',
+		headers: {
+			Accept: 'application/json',
+			'Content-Type': 'application/json',
+			...(token && { authorization: `Bearer ${token}` })
+		}
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.catch((err) => {
+			console.log(err);
+			if ('detail' in err) {
+				error = err.detail;
+			} else {
+				error = 'Server connection failed';
+			}
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res?.version ?? '';
+};
+
+export const getOllamaModels = async (
+	base_url: string = OLLAMA_API_BASE_URL,
+	token: string = ''
+) => {
+	let error = null;
+
+	const res = await fetch(`${base_url}/tags`, {
+		method: 'GET',
+		headers: {
+			Accept: 'application/json',
+			'Content-Type': 'application/json',
+			...(token && { authorization: `Bearer ${token}` })
+		}
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.catch((err) => {
+			console.log(err);
+			if ('detail' in err) {
+				error = err.detail;
+			} else {
+				error = 'Server connection failed';
+			}
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res?.models ?? [];
+};
+
+export const generateTitle = async (
+	base_url: string = OLLAMA_API_BASE_URL,
+	token: string = '',
+	model: string,
+	prompt: string
+) => {
+	let error = null;
+
+	const res = await fetch(`${base_url}/generate`, {
+		method: 'POST',
+		headers: {
+			'Content-Type': 'text/event-stream',
+			Authorization: `Bearer ${token}`
+		},
+		body: JSON.stringify({
+			model: model,
+			prompt: `Generate a brief 3-5 word title for this question, excluding the term 'title.' Then, please reply with only the title: ${prompt}`,
+			stream: false
+		})
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.catch((err) => {
+			console.log(err);
+			if ('detail' in err) {
+				error = err.detail;
+			}
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res?.response ?? 'New Chat';
+};
+
+export const generateChatCompletion = async (
+	base_url: string = OLLAMA_API_BASE_URL,
+	token: string = '',
+	body: object
+) => {
+	let error = null;
+
+	const res = await fetch(`${base_url}/chat`, {
+		method: 'POST',
+		headers: {
+			'Content-Type': 'text/event-stream',
+			Authorization: `Bearer ${token}`
+		},
+		body: JSON.stringify(body)
+	}).catch((err) => {
+		error = err;
+		return null;
+	});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
+export const createModel = async (
+	base_url: string = OLLAMA_API_BASE_URL,
+	token: string,
+	tagName: string,
+	content: string
+) => {
+	let error = null;
+
+	const res = await fetch(`${base_url}/create`, {
+		method: 'POST',
+		headers: {
+			'Content-Type': 'text/event-stream',
+			Authorization: `Bearer ${token}`
+		},
+		body: JSON.stringify({
+			name: tagName,
+			modelfile: content
+		})
+	}).catch((err) => {
+		error = err;
+		return null;
+	});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
+export const deleteModel = async (
+	base_url: string = OLLAMA_API_BASE_URL,
+	token: string,
+	tagName: string
+) => {
+	let error = null;
+
+	const res = await fetch(`${base_url}/delete`, {
+		method: 'DELETE',
+		headers: {
+			'Content-Type': 'text/event-stream',
+			Authorization: `Bearer ${token}`
+		},
+		body: JSON.stringify({
+			name: tagName
+		})
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.then((json) => {
+			console.log(json);
+			return true;
+		})
+		.catch((err) => {
+			console.log(err);
+			error = err.error;
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};

+ 33 - 0
src/lib/apis/openai/index.ts

@@ -0,0 +1,33 @@
+export const getOpenAIModels = async (
+	base_url: string = 'https://api.openai.com/v1',
+	api_key: string = ''
+) => {
+	let error = null;
+
+	const res = await fetch(`${base_url}/models`, {
+		method: 'GET',
+		headers: {
+			'Content-Type': 'application/json',
+			Authorization: `Bearer ${api_key}`
+		}
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.catch((err) => {
+			console.log(err);
+			error = `OpenAI: ${err?.error?.message ?? 'Network Problem'}`;
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	let models = Array.isArray(res) ? res : res?.data ?? null;
+
+	return models
+		.map((model) => ({ name: model.id, external: true }))
+		.filter((model) => (base_url.includes('openai') ? model.name.includes('gpt') : true));
+};

+ 58 - 0
src/lib/apis/users/index.ts

@@ -0,0 +1,58 @@
+import { WEBUI_API_BASE_URL } from '$lib/constants';
+
+export const updateUserRole = async (token: string, id: string, role: string) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/users/update/role`, {
+		method: 'POST',
+		headers: {
+			'Content-Type': 'application/json',
+			Authorization: `Bearer ${token}`
+		},
+		body: JSON.stringify({
+			id: id,
+			role: role
+		})
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.catch((err) => {
+			console.log(err);
+			error = err.detail;
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
+export const getUsers = async (token: string) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/users`, {
+		method: 'GET',
+		headers: {
+			'Content-Type': 'application/json',
+			Authorization: `Bearer ${token}`
+		}
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.catch((error) => {
+			console.log(error);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res ? res : [];
+};

+ 22 - 10
src/lib/components/chat/Messages.svelte

@@ -8,11 +8,13 @@
 	import auto_render from 'katex/dist/contrib/auto-render.mjs';
 	import 'katex/dist/katex.min.css';
 
-	import { chatId, config, db, modelfiles, settings, user } from '$lib/stores';
+	import { chats, config, db, modelfiles, settings, user } from '$lib/stores';
 	import { tick } from 'svelte';
 
 	import toast from 'svelte-french-toast';
+	import { getChatList, updateChatById } from '$lib/apis/chats';
 
+	export let chatId = '';
 	export let sendPrompt: Function;
 	export let regenerateResponse: Function;
 
@@ -27,14 +29,25 @@
 	$: if (messages && messages.length > 0 && (messages.at(-1).done ?? false)) {
 		(async () => {
 			await tick();
+
+			[...document.querySelectorAll('*')].forEach((node) => {
+				if (node._tippy) {
+					node._tippy.destroy();
+				}
+			});
+
+			console.log('rendering message');
+
 			renderLatex();
 			hljs.highlightAll();
 			createCopyCodeBlockButton();
 
 			for (const message of messages) {
 				if (message.info) {
+					console.log(message);
+
 					tippy(`#info-${message.id}`, {
-						content: `<span class="text-xs">token/s: ${
+						content: `<span class="text-xs" id="tooltip-${message.id}">token/s: ${
 							`${
 								Math.round(
 									((message.info.eval_count ?? 0) / (message.info.eval_duration / 1000000000)) * 100
@@ -81,7 +94,7 @@
 		blocks.forEach((block) => {
 			// only add button if browser supports Clipboard API
 
-			if (navigator.clipboard && block.childNodes.length < 2 && block.id !== 'user-message') {
+			if (block.childNodes.length < 2 && block.id !== 'user-message') {
 				let code = block.querySelector('code');
 				code.style.borderTopRightRadius = 0;
 				code.style.borderTopLeftRadius = 0;
@@ -119,10 +132,6 @@
 				topBarDiv.appendChild(button);
 
 				block.prepend(topBarDiv);
-
-				// button.addEventListener('click', async () => {
-				// 	await copyCode(block, button);
-				// });
 			}
 		});
 
@@ -130,7 +139,7 @@
 			let code = block.querySelector('code');
 			let text = code.innerText;
 
-			await navigator.clipboard.writeText(text);
+			await copyToClipboard(text);
 
 			// visual feedback that task is completed
 			button.innerText = 'Copied!';
@@ -239,7 +248,7 @@
 		history.currentId = userMessageId;
 
 		await tick();
-		await sendPrompt(userPrompt, userMessageId, $chatId);
+		await sendPrompt(userPrompt, userMessageId, chatId);
 	};
 
 	const confirmEditResponseMessage = async (messageId) => {
@@ -253,6 +262,7 @@
 	};
 
 	const rateMessage = async (messageIdx, rating) => {
+		// TODO: Move this function to parent
 		messages = messages.map((message, idx) => {
 			if (messageIdx === idx) {
 				message.rating = rating;
@@ -260,10 +270,12 @@
 			return message;
 		});
 
-		$db.updateChatById(chatId, {
+		await updateChatById(localStorage.token, chatId, {
 			messages: messages,
 			history: history
 		});
+
+		await chats.set(await getChatList(localStorage.token));
 	};
 
 	const showPreviousMessage = async (message) => {

+ 280 - 54
src/lib/components/chat/SettingsModal.svelte

@@ -1,18 +1,23 @@
 <script lang="ts">
-	import Modal from '../common/Modal.svelte';
+	import toast from 'svelte-french-toast';
+	import fileSaver from 'file-saver';
+	const { saveAs } = fileSaver;
+
+	import { onMount } from 'svelte';
+	import { config, models, settings, user, chats } from '$lib/stores';
+	import { splitStream, getGravatarURL } from '$lib/utils';
 
+	import { getOllamaVersion } from '$lib/apis/ollama';
+	import { createNewChat, getAllChats, getChatList } from '$lib/apis/chats';
 	import {
 		WEB_UI_VERSION,
 		OLLAMA_API_BASE_URL,
 		WEBUI_API_BASE_URL,
 		WEBUI_BASE_URL
 	} from '$lib/constants';
-	import toast from 'svelte-french-toast';
-	import { onMount } from 'svelte';
-	import { config, info, models, settings, user } from '$lib/stores';
-	import { splitStream, getGravatarURL } from '$lib/utils';
+
 	import Advanced from './Settings/Advanced.svelte';
-	import { stringify } from 'postcss';
+	import Modal from '../common/Modal.svelte';
 
 	export let show = false;
 
@@ -74,11 +79,48 @@
 	let OPENAI_API_KEY = '';
 	let OPENAI_API_BASE_URL = '';
 
+	// Chats
+
+	let importFiles;
+	let showDeleteHistoryConfirm = false;
+
+	const importChats = async (_chats) => {
+		for (const chat of _chats) {
+			console.log(chat);
+			await createNewChat(localStorage.token, chat);
+		}
+
+		await chats.set(await getChatList(localStorage.token));
+	};
+
+	const exportChats = async () => {
+		let blob = new Blob([JSON.stringify(await getAllChats(localStorage.token))], {
+			type: 'application/json'
+		});
+		saveAs(blob, `chat-export-${Date.now()}.json`);
+	};
+
+	$: if (importFiles) {
+		console.log(importFiles);
+
+		let reader = new FileReader();
+		reader.onload = (event) => {
+			let chats = JSON.parse(event.target.result);
+			console.log(chats);
+			importChats(chats);
+		};
+
+		reader.readAsText(importFiles[0]);
+	}
+
 	// Auth
 	let authEnabled = false;
 	let authType = 'Basic';
 	let authContent = '';
 
+	// About
+	let ollamaVersion = '';
+
 	const checkOllamaConnection = async () => {
 		if (API_BASE_URL === '') {
 			API_BASE_URL = OLLAMA_API_BASE_URL;
@@ -553,7 +595,7 @@
 		return models;
 	};
 
-	onMount(() => {
+	onMount(async () => {
 		let settings = JSON.parse(localStorage.getItem('settings') ?? '{}');
 		console.log(settings);
 
@@ -586,6 +628,13 @@
 			authType = settings.authHeader.split(' ')[0];
 			authContent = settings.authHeader.split(' ')[1];
 		}
+
+		ollamaVersion = await getOllamaVersion(
+			API_BASE_URL ?? OLLAMA_API_BASE_URL,
+			localStorage.token
+		).catch((error) => {
+			return '';
+		});
 	});
 </script>
 
@@ -741,6 +790,32 @@
 					<div class=" self-center">Add-ons</div>
 				</button>
 
+				<button
+					class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
+					'chats'
+						? 'bg-gray-200 dark:bg-gray-700'
+						: ' hover:bg-gray-300 dark:hover:bg-gray-800'}"
+					on:click={() => {
+						selectedTab = 'chats';
+					}}
+				>
+					<div class=" self-center mr-2">
+						<svg
+							xmlns="http://www.w3.org/2000/svg"
+							viewBox="0 0 16 16"
+							fill="currentColor"
+							class="w-4 h-4"
+						>
+							<path
+								fill-rule="evenodd"
+								d="M8 2C4.262 2 1 4.57 1 8c0 1.86.98 3.486 2.455 4.566a3.472 3.472 0 0 1-.469 1.26.75.75 0 0 0 .713 1.14 6.961 6.961 0 0 0 3.06-1.06c.403.062.818.094 1.241.094 3.738 0 7-2.57 7-6s-3.262-6-7-6ZM5 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Zm7-1a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM8 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"
+								clip-rule="evenodd"
+							/>
+						</svg>
+					</div>
+					<div class=" self-center">Chats</div>
+				</button>
+
 				{#if !$config || ($config && !$config.auth)}
 					<button
 						class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
@@ -1089,13 +1164,65 @@
 							</div>
 							<hr class=" dark:border-gray-700" />
 
+							<div>
+								<div class=" mb-2.5 text-sm font-medium">Delete a model</div>
+								<div class="flex w-full">
+									<div class="flex-1 mr-2">
+										<select
+											class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
+											bind:value={deleteModelTag}
+											placeholder="Select a model"
+										>
+											{#if !deleteModelTag}
+												<option value="" disabled selected>Select a model</option>
+											{/if}
+											{#each $models.filter((m) => m.size != null) as model}
+												<option value={model.name} class="bg-gray-100 dark:bg-gray-700"
+													>{model.name +
+														' (' +
+														(model.size / 1024 ** 3).toFixed(1) +
+														' GB)'}</option
+												>
+											{/each}
+										</select>
+									</div>
+									<button
+										class="px-3 bg-red-700 hover:bg-red-800 text-gray-100 rounded transition"
+										on:click={() => {
+											deleteModelHandler();
+										}}
+									>
+										<svg
+											xmlns="http://www.w3.org/2000/svg"
+											viewBox="0 0 16 16"
+											fill="currentColor"
+											class="w-4 h-4"
+										>
+											<path
+												fill-rule="evenodd"
+												d="M5 3.25V4H2.75a.75.75 0 0 0 0 1.5h.3l.815 8.15A1.5 1.5 0 0 0 5.357 15h5.285a1.5 1.5 0 0 0 1.493-1.35l.815-8.15h.3a.75.75 0 0 0 0-1.5H11v-.75A2.25 2.25 0 0 0 8.75 1h-1.5A2.25 2.25 0 0 0 5 3.25Zm2.25-.75a.75.75 0 0 0-.75.75V4h3v-.75a.75.75 0 0 0-.75-.75h-1.5ZM6.05 6a.75.75 0 0 1 .787.713l.275 5.5a.75.75 0 0 1-1.498.075l-.275-5.5A.75.75 0 0 1 6.05 6Zm3.9 0a.75.75 0 0 1 .712.787l-.275 5.5a.75.75 0 0 1-1.498-.075l.275-5.5a.75.75 0 0 1 .786-.711Z"
+												clip-rule="evenodd"
+											/>
+										</svg>
+									</button>
+								</div>
+							</div>
+
+							<hr class=" dark:border-gray-700" />
+
 							<form
 								on:submit|preventDefault={() => {
 									uploadModelHandler();
 								}}
 							>
 								<div class=" mb-2 flex w-full justify-between">
-									<div class="  text-sm font-medium">Upload a GGUF model</div>
+									<div class="  text-sm font-medium">
+										Upload a GGUF model <a
+											class=" text-xs font-medium text-gray-500 underline"
+											href="https://github.com/jmorganca/ollama/blob/main/README.md#import-from-gguf"
+											target="_blank">(Experimental)</a
+										>
+									</div>
 
 									<button
 										class="p-1 px-3 text-xs flex rounded transition"
@@ -1252,51 +1379,6 @@
 									</div>
 								{/if}
 							</form>
-							<hr class=" dark:border-gray-700" />
-
-							<div>
-								<div class=" mb-2.5 text-sm font-medium">Delete a model</div>
-								<div class="flex w-full">
-									<div class="flex-1 mr-2">
-										<select
-											class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
-											bind:value={deleteModelTag}
-											placeholder="Select a model"
-										>
-											{#if !deleteModelTag}
-												<option value="" disabled selected>Select a model</option>
-											{/if}
-											{#each $models.filter((m) => m.size != null) as model}
-												<option value={model.name} class="bg-gray-100 dark:bg-gray-700"
-													>{model.name +
-														' (' +
-														(model.size / 1024 ** 3).toFixed(1) +
-														' GB)'}</option
-												>
-											{/each}
-										</select>
-									</div>
-									<button
-										class="px-3 bg-red-700 hover:bg-red-800 text-gray-100 rounded transition"
-										on:click={() => {
-											deleteModelHandler();
-										}}
-									>
-										<svg
-											xmlns="http://www.w3.org/2000/svg"
-											viewBox="0 0 16 16"
-											fill="currentColor"
-											class="w-4 h-4"
-										>
-											<path
-												fill-rule="evenodd"
-												d="M5 3.25V4H2.75a.75.75 0 0 0 0 1.5h.3l.815 8.15A1.5 1.5 0 0 0 5.357 15h5.285a1.5 1.5 0 0 0 1.493-1.35l.815-8.15h.3a.75.75 0 0 0 0-1.5H11v-.75A2.25 2.25 0 0 0 8.75 1h-1.5A2.25 2.25 0 0 0 5 3.25Zm2.25-.75a.75.75 0 0 0-.75.75V4h3v-.75a.75.75 0 0 0-.75-.75h-1.5ZM6.05 6a.75.75 0 0 1 .787.713l.275 5.5a.75.75 0 0 1-1.498.075l-.275-5.5A.75.75 0 0 1 6.05 6Zm3.9 0a.75.75 0 0 1 .712.787l-.275 5.5a.75.75 0 0 1-1.498-.075l.275-5.5a.75.75 0 0 1 .786-.711Z"
-												clip-rule="evenodd"
-											/>
-										</svg>
-									</button>
-								</div>
-							</div>
 						</div>
 					</div>
 				{:else if selectedTab === 'external'}
@@ -1472,6 +1554,150 @@
 							</button>
 						</div>
 					</form>
+				{:else if selectedTab === 'chats'}
+					<div class="flex flex-col h-full justify-between space-y-3 text-sm">
+						<div class="flex flex-col">
+							<input
+								id="chat-import-input"
+								bind:files={importFiles}
+								type="file"
+								accept=".json"
+								hidden
+							/>
+							<button
+								class=" flex rounded-md py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
+								on:click={() => {
+									document.getElementById('chat-import-input').click();
+								}}
+							>
+								<div class=" self-center mr-3">
+									<svg
+										xmlns="http://www.w3.org/2000/svg"
+										viewBox="0 0 16 16"
+										fill="currentColor"
+										class="w-4 h-4"
+									>
+										<path
+											fill-rule="evenodd"
+											d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 9.5a.75.75 0 0 1-.75-.75V8.06l-.72.72a.75.75 0 0 1-1.06-1.06l2-2a.75.75 0 0 1 1.06 0l2 2a.75.75 0 1 1-1.06 1.06l-.72-.72v2.69a.75.75 0 0 1-.75.75Z"
+											clip-rule="evenodd"
+										/>
+									</svg>
+								</div>
+								<div class=" self-center text-sm font-medium">Import Chats</div>
+							</button>
+							<button
+								class=" flex rounded-md py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
+								on:click={() => {
+									exportChats();
+								}}
+							>
+								<div class=" self-center mr-3">
+									<svg
+										xmlns="http://www.w3.org/2000/svg"
+										viewBox="0 0 16 16"
+										fill="currentColor"
+										class="w-4 h-4"
+									>
+										<path
+											fill-rule="evenodd"
+											d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 3.5a.75.75 0 0 1 .75.75v2.69l.72-.72a.75.75 0 1 1 1.06 1.06l-2 2a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 0 1 1.06-1.06l.72.72V6.25A.75.75 0 0 1 8 5.5Z"
+											clip-rule="evenodd"
+										/>
+									</svg>
+								</div>
+								<div class=" self-center text-sm font-medium">Export Chats</div>
+							</button>
+						</div>
+						<!-- {#if showDeleteHistoryConfirm}
+							<div
+								class="flex justify-between rounded-md items-center py-3 px-3.5 w-full transition"
+							>
+								<div class="flex items-center">
+									<svg
+										xmlns="http://www.w3.org/2000/svg"
+										fill="none"
+										viewBox="0 0 24 24"
+										stroke-width="1.5"
+										stroke="currentColor"
+										class="w-5 h-5 mr-3"
+									>
+										<path
+											stroke-linecap="round"
+											stroke-linejoin="round"
+											d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
+										/>
+									</svg>
+									<span>Are you sure?</span>
+								</div>
+
+								<div class="flex space-x-1.5 items-center">
+									<button
+										class="hover:text-white transition"
+										on:click={() => {
+											deleteChatHistory();
+											showDeleteHistoryConfirm = false;
+										}}
+									>
+										<svg
+											xmlns="http://www.w3.org/2000/svg"
+											viewBox="0 0 20 20"
+											fill="currentColor"
+											class="w-4 h-4"
+										>
+											<path
+												fill-rule="evenodd"
+												d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
+												clip-rule="evenodd"
+											/>
+										</svg>
+									</button>
+									<button
+										class="hover:text-white transition"
+										on:click={() => {
+											showDeleteHistoryConfirm = false;
+										}}
+									>
+										<svg
+											xmlns="http://www.w3.org/2000/svg"
+											viewBox="0 0 20 20"
+											fill="currentColor"
+											class="w-4 h-4"
+										>
+											<path
+												d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
+											/>
+										</svg>
+									</button>
+								</div>
+							</div>
+						{:else}
+							<button
+								class=" flex rounded-md py-3 px-3.5 w-full hover:bg-gray-900 transition"
+								on:click={() => {
+									showDeleteHistoryConfirm = true;
+								}}
+							>
+								<div class="mr-3">
+									<svg
+										xmlns="http://www.w3.org/2000/svg"
+										fill="none"
+										viewBox="0 0 24 24"
+										stroke-width="1.5"
+										stroke="currentColor"
+										class="w-5 h-5"
+									>
+										<path
+											stroke-linecap="round"
+											stroke-linejoin="round"
+											d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
+										/>
+									</svg>
+								</div>
+								<span>Clear conversations</span>
+							</button>
+						{/if} -->
+					</div>
 				{:else if selectedTab === 'auth'}
 					<form
 						class="flex flex-col h-full justify-between space-y-3 text-sm"
@@ -1607,7 +1833,7 @@
 								<div class=" mb-2.5 text-sm font-medium">Ollama Version</div>
 								<div class="flex w-full">
 									<div class="flex-1 text-xs text-gray-700 dark:text-gray-200">
-										{$info?.ollama?.version ?? 'N/A'}
+										{ollamaVersion ?? 'N/A'}
 									</div>
 								</div>
 							</div>

+ 6 - 10
src/lib/components/layout/Navbar.svelte

@@ -1,18 +1,17 @@
 <script lang="ts">
-	import { v4 as uuidv4 } from 'uuid';
-
-	import { goto } from '$app/navigation';
+	import { getChatById } from '$lib/apis/chats';
 	import { chatId, db, modelfiles } from '$lib/stores';
 	import toast from 'svelte-french-toast';
 
+	export let initNewChat: Function;
 	export let title: string = 'Ollama Web UI';
 	export let shareEnabled: boolean = false;
 
 	const shareChat = async () => {
-		const chat = await $db.getChatById($chatId);
+		const chat = (await getChatById(localStorage.token, $chatId)).chat;
 		console.log('share', chat);
-		toast.success('Redirecting you to OllamaHub');
 
+		toast.success('Redirecting you to OllamaHub');
 		const url = 'https://ollamahub.com';
 		// const url = 'http://localhost:5173';
 
@@ -44,12 +43,9 @@
 		<div class="flex w-full max-w-full">
 			<div class="pr-2 self-center">
 				<button
+					id="new-chat-button"
 					class=" cursor-pointer p-1 flex dark:hover:bg-gray-700 rounded-lg transition"
-					on:click={async () => {
-						console.log('newChat');
-						goto('/');
-						await chatId.set(uuidv4());
-					}}
+					on:click={initNewChat}
 				>
 					<div class=" m-auto self-center">
 						<svg

+ 48 - 208
src/lib/components/layout/Sidebar.svelte

@@ -6,32 +6,28 @@
 
 	import { goto, invalidateAll } from '$app/navigation';
 	import { page } from '$app/stores';
-	import { user, db, chats, showSettings, chatId } from '$lib/stores';
+	import { user, chats, showSettings, chatId } from '$lib/stores';
 	import { onMount } from 'svelte';
+	import { deleteChatById, getChatList, updateChatById } from '$lib/apis/chats';
 
 	let show = false;
 	let navElement;
-	let importFileInputElement;
-	let importFiles;
 
 	let title: string = 'Ollama Web UI';
 	let search = '';
 
 	let chatDeleteId = null;
-
 	let chatTitleEditId = null;
 	let chatTitle = '';
 
 	let showDropdown = false;
 
-	let showDeleteHistoryConfirm = false;
-
 	onMount(async () => {
 		if (window.innerWidth > 1280) {
 			show = true;
 		}
 
-		await chats.set(await $db.getChats());
+		await chats.set(await getChatList(localStorage.token));
 	});
 
 	const loadChat = async (id) => {
@@ -39,49 +35,27 @@
 	};
 
 	const editChatTitle = async (id, _title) => {
-		await $db.updateChatById(id, {
+		title = _title;
+
+		await updateChatById(localStorage.token, id, {
 			title: _title
 		});
-		title = _title;
+		await chats.set(await getChatList(localStorage.token));
 	};
 
 	const deleteChat = async (id) => {
 		goto('/');
-		$db.deleteChatById(id);
-	};
-
-	const deleteChatHistory = async () => {
-		await $db.deleteAllChat();
-	};
-
-	const importChats = async (chatHistory) => {
-		await $db.addChats(chatHistory);
-	};
 
-	const exportChats = async () => {
-		let blob = new Blob([JSON.stringify(await $db.exportChats())], { type: 'application/json' });
-		saveAs(blob, `chat-export-${Date.now()}.json`);
+		await deleteChatById(localStorage.token, id);
+		await chats.set(await getChatList(localStorage.token));
 	};
-
-	$: if (importFiles) {
-		console.log(importFiles);
-
-		let reader = new FileReader();
-		reader.onload = (event) => {
-			let chats = JSON.parse(event.target.result);
-			console.log(chats);
-			importChats(chats);
-		};
-
-		reader.readAsText(importFiles[0]);
-	}
 </script>
 
 <div
 	bind:this={navElement}
 	class="h-screen {show
 		? ''
-		: '-translate-x-[260px]'}  w-[260px] fixed top-0 left-0 z-40 transition bg-[#0a0a0a] text-gray-200 shadow-2xl text-sm
+		: '-translate-x-[260px]'}  w-[260px] fixed top-0 left-0 z-40 transition bg-black text-gray-200 shadow-2xl text-sm
         "
 >
 	<div class="py-2.5 my-auto flex flex-col justify-between h-screen">
@@ -91,8 +65,11 @@
 				on:click={async () => {
 					goto('/');
 
-					await chatId.set(uuidv4());
-					// createNewChat();
+					const newChatButton = document.getElementById('new-chat-button');
+
+					if (newChatButton) {
+						newChatButton.click();
+					}
 				}}
 			>
 				<div class="flex self-center">
@@ -121,39 +98,41 @@
 			</button>
 		</div>
 
-		<div class="px-2.5 flex justify-center my-1">
-			<button
-				class="flex-grow flex space-x-3 rounded-md px-3 py-2 hover:bg-gray-900 transition"
-				on:click={async () => {
-					goto('/modelfiles');
-				}}
-			>
-				<div class="self-center">
-					<svg
-						xmlns="http://www.w3.org/2000/svg"
-						fill="none"
-						viewBox="0 0 24 24"
-						stroke-width="1.5"
-						stroke="currentColor"
-						class="w-4 h-4"
-					>
-						<path
-							stroke-linecap="round"
-							stroke-linejoin="round"
-							d="M13.5 16.875h3.375m0 0h3.375m-3.375 0V13.5m0 3.375v3.375M6 10.5h2.25a2.25 2.25 0 002.25-2.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v2.25A2.25 2.25 0 006 10.5zm0 9.75h2.25A2.25 2.25 0 0010.5 18v-2.25a2.25 2.25 0 00-2.25-2.25H6a2.25 2.25 0 00-2.25 2.25V18A2.25 2.25 0 006 20.25zm9.75-9.75H18a2.25 2.25 0 002.25-2.25V6A2.25 2.25 0 0018 3.75h-2.25A2.25 2.25 0 0013.5 6v2.25a2.25 2.25 0 002.25 2.25z"
-						/>
-					</svg>
-				</div>
+		{#if $user?.role === 'admin'}
+			<div class="px-2.5 flex justify-center my-1">
+				<button
+					class="flex-grow flex space-x-3 rounded-md px-3 py-2 hover:bg-gray-900 transition"
+					on:click={async () => {
+						goto('/modelfiles');
+					}}
+				>
+					<div class="self-center">
+						<svg
+							xmlns="http://www.w3.org/2000/svg"
+							fill="none"
+							viewBox="0 0 24 24"
+							stroke-width="1.5"
+							stroke="currentColor"
+							class="w-4 h-4"
+						>
+							<path
+								stroke-linecap="round"
+								stroke-linejoin="round"
+								d="M13.5 16.875h3.375m0 0h3.375m-3.375 0V13.5m0 3.375v3.375M6 10.5h2.25a2.25 2.25 0 002.25-2.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v2.25A2.25 2.25 0 006 10.5zm0 9.75h2.25A2.25 2.25 0 0010.5 18v-2.25a2.25 2.25 0 00-2.25-2.25H6a2.25 2.25 0 00-2.25 2.25V18A2.25 2.25 0 006 20.25zm9.75-9.75H18a2.25 2.25 0 002.25-2.25V6A2.25 2.25 0 0018 3.75h-2.25A2.25 2.25 0 0013.5 6v2.25a2.25 2.25 0 002.25 2.25z"
+							/>
+						</svg>
+					</div>
 
-				<div class="flex self-center">
-					<div class=" self-center font-medium text-sm">Modelfiles</div>
-				</div>
-			</button>
-		</div>
+					<div class="flex self-center">
+						<div class=" self-center font-medium text-sm">Modelfiles</div>
+					</div>
+				</button>
+			</div>
+		{/if}
 
 		<div class="px-2.5 mt-1 mb-2 flex justify-center space-x-2">
 			<div class="flex w-full">
-				<div class="self-center pl-3 py-2 rounded-l bg-gray-900">
+				<div class="self-center pl-3 py-2 rounded-l bg-gray-950">
 					<svg
 						xmlns="http://www.w3.org/2000/svg"
 						viewBox="0 0 20 20"
@@ -169,7 +148,7 @@
 				</div>
 
 				<input
-					class="w-full rounded-r py-1.5 pl-2.5 pr-4 text-sm text-gray-300 bg-gray-900 outline-none"
+					class="w-full rounded-r py-1.5 pl-2.5 pr-4 text-sm text-gray-300 bg-gray-950 outline-none"
 					placeholder="Search"
 					bind:value={search}
 				/>
@@ -394,148 +373,9 @@
 		</div>
 
 		<div class="px-2.5">
-			<hr class=" border-gray-800 mb-2 w-full" />
+			<hr class=" border-gray-900 mb-1 w-full" />
 
 			<div class="flex flex-col">
-				<div class="flex">
-					<input bind:this={importFileInputElement} bind:files={importFiles} type="file" hidden />
-					<button
-						class=" flex rounded-md py-3 px-3.5 w-full hover:bg-gray-900 transition"
-						on:click={() => {
-							importFileInputElement.click();
-							// importChats();
-						}}
-					>
-						<div class=" self-center mr-3">
-							<svg
-								xmlns="http://www.w3.org/2000/svg"
-								fill="none"
-								viewBox="0 0 24 24"
-								stroke-width="1.5"
-								stroke="currentColor"
-								class="w-5 h-5"
-							>
-								<path
-									stroke-linecap="round"
-									stroke-linejoin="round"
-									d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m6.75 12l-3-3m0 0l-3 3m3-3v6m-1.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
-								/>
-							</svg>
-						</div>
-						<div class=" self-center">Import</div>
-					</button>
-					<button
-						class=" flex rounded-md py-3 px-3.5 w-full hover:bg-gray-900 transition"
-						on:click={() => {
-							exportChats();
-						}}
-					>
-						<div class=" self-center mr-3">
-							<svg
-								xmlns="http://www.w3.org/2000/svg"
-								fill="none"
-								viewBox="0 0 24 24"
-								stroke-width="1.5"
-								stroke="currentColor"
-								class="w-5 h-5"
-							>
-								<path
-									stroke-linecap="round"
-									stroke-linejoin="round"
-									d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m.75 12l3 3m0 0l3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
-								/>
-							</svg>
-						</div>
-						<div class=" self-center">Export</div>
-					</button>
-				</div>
-				{#if showDeleteHistoryConfirm}
-					<div class="flex justify-between rounded-md items-center py-3 px-3.5 w-full transition">
-						<div class="flex items-center">
-							<svg
-								xmlns="http://www.w3.org/2000/svg"
-								fill="none"
-								viewBox="0 0 24 24"
-								stroke-width="1.5"
-								stroke="currentColor"
-								class="w-5 h-5 mr-3"
-							>
-								<path
-									stroke-linecap="round"
-									stroke-linejoin="round"
-									d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
-								/>
-							</svg>
-							<span>Are you sure?</span>
-						</div>
-
-						<div class="flex space-x-1.5 items-center">
-							<button
-								class="hover:text-white transition"
-								on:click={() => {
-									deleteChatHistory();
-									showDeleteHistoryConfirm = false;
-								}}
-							>
-								<svg
-									xmlns="http://www.w3.org/2000/svg"
-									viewBox="0 0 20 20"
-									fill="currentColor"
-									class="w-4 h-4"
-								>
-									<path
-										fill-rule="evenodd"
-										d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
-										clip-rule="evenodd"
-									/>
-								</svg>
-							</button>
-							<button
-								class="hover:text-white transition"
-								on:click={() => {
-									showDeleteHistoryConfirm = false;
-								}}
-							>
-								<svg
-									xmlns="http://www.w3.org/2000/svg"
-									viewBox="0 0 20 20"
-									fill="currentColor"
-									class="w-4 h-4"
-								>
-									<path
-										d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
-									/>
-								</svg>
-							</button>
-						</div>
-					</div>
-				{:else}
-					<button
-						class=" flex rounded-md py-3 px-3.5 w-full hover:bg-gray-900 transition"
-						on:click={() => {
-							showDeleteHistoryConfirm = true;
-						}}
-					>
-						<div class="mr-3">
-							<svg
-								xmlns="http://www.w3.org/2000/svg"
-								fill="none"
-								viewBox="0 0 24 24"
-								stroke-width="1.5"
-								stroke="currentColor"
-								class="w-5 h-5"
-							>
-								<path
-									stroke-linecap="round"
-									stroke-linejoin="round"
-									d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
-								/>
-							</svg>
-						</div>
-						<span>Clear conversations</span>
-					</button>
-				{/if}
-
 				{#if $user !== undefined}
 					<button
 						class=" flex rounded-md py-3 px-3.5 w-full hover:bg-gray-900 transition"

+ 9 - 6
src/lib/constants.ts

@@ -1,18 +1,21 @@
 import { dev, browser } from '$app/environment';
 import { PUBLIC_API_BASE_URL } from '$env/static/public';
 
-export const OLLAMA_API_BASE_URL =
-	PUBLIC_API_BASE_URL === ''
-		? browser
-			? `http://${location.hostname}:11434/api`
-			: `http://localhost:11434/api`
-		: PUBLIC_API_BASE_URL;
+export const OLLAMA_API_BASE_URL = dev
+	? `http://${location.hostname}:8080/ollama/api`
+	: PUBLIC_API_BASE_URL === ''
+	? browser
+		? `http://${location.hostname}:11434/api`
+		: `http://localhost:11434/api`
+	: PUBLIC_API_BASE_URL;
 
 export const WEBUI_BASE_URL = dev ? `http://${location.hostname}:8080` : ``;
 export const WEBUI_API_BASE_URL = `${WEBUI_BASE_URL}/api/v1`;
 
 export const WEB_UI_VERSION = 'v1.0.0-alpha-static';
 
+export const REQUIRED_OLLAMA_VERSION = '0.1.16';
+
 // Source: https://kit.svelte.dev/docs/modules#$env-static-public
 // This feature, akin to $env/static/private, exclusively incorporates environment variables
 // that are prefixed with config.kit.env.publicPrefix (usually set to PUBLIC_).

+ 0 - 1
src/lib/stores/index.ts

@@ -1,7 +1,6 @@
 import { writable } from 'svelte/store';
 
 // Backend
-export const info = writable({});
 export const config = writable(undefined);
 export const user = writable(undefined);
 

+ 15 - 4
src/lib/utils/index.ts

@@ -66,9 +66,9 @@ export const getGravatarURL = (email) => {
 	return `https://www.gravatar.com/avatar/${hash}`;
 };
 
-const copyToClipboard = (text) => {
+export const copyToClipboard = (text) => {
 	if (!navigator.clipboard) {
-		var textArea = document.createElement('textarea');
+		const textArea = document.createElement('textarea');
 		textArea.value = text;
 
 		// Avoid scrolling to bottom
@@ -81,8 +81,8 @@ const copyToClipboard = (text) => {
 		textArea.select();
 
 		try {
-			var successful = document.execCommand('copy');
-			var msg = successful ? 'successful' : 'unsuccessful';
+			const successful = document.execCommand('copy');
+			const msg = successful ? 'successful' : 'unsuccessful';
 			console.log('Fallback: Copying text command was ' + msg);
 		} catch (err) {
 			console.error('Fallback: Oops, unable to copy', err);
@@ -100,3 +100,14 @@ const copyToClipboard = (text) => {
 		}
 	);
 };
+
+export const checkVersion = (required, current) => {
+	// Returns true when current version is below required
+	return current === '0.0.0'
+		? false
+		: current.localeCompare(required, undefined, {
+				numeric: true,
+				sensitivity: 'case',
+				caseFirst: 'upper'
+		  }) < 0;
+};

+ 174 - 208
src/routes/(app)/+layout.svelte

@@ -1,246 +1,163 @@
 <script lang="ts">
-	import { v4 as uuidv4 } from 'uuid';
+	import toast from 'svelte-french-toast';
 	import { openDB, deleteDB } from 'idb';
 	import { onMount, tick } from 'svelte';
 	import { goto } from '$app/navigation';
 
-	import {
-		config,
-		info,
-		user,
-		showSettings,
-		settings,
-		models,
-		db,
-		chats,
-		chatId,
-		modelfiles
-	} from '$lib/stores';
+	import fileSaver from 'file-saver';
+	const { saveAs } = fileSaver;
+
+	import { getOllamaModels, getOllamaVersion } from '$lib/apis/ollama';
+	import { getModelfiles } from '$lib/apis/modelfiles';
+
+	import { getOpenAIModels } from '$lib/apis/openai';
+
+	import { user, showSettings, settings, models, modelfiles } from '$lib/stores';
+	import { OLLAMA_API_BASE_URL, REQUIRED_OLLAMA_VERSION, WEBUI_API_BASE_URL } from '$lib/constants';
 
 	import SettingsModal from '$lib/components/chat/SettingsModal.svelte';
 	import Sidebar from '$lib/components/layout/Sidebar.svelte';
-	import toast from 'svelte-french-toast';
-	import { OLLAMA_API_BASE_URL, WEBUI_API_BASE_URL } from '$lib/constants';
+	import { checkVersion } from '$lib/utils';
 
-	let requiredOllamaVersion = '0.1.16';
+	let ollamaVersion = '';
 	let loaded = false;
 
+	let DB = null;
+	let localDBChats = [];
+
 	const getModels = async () => {
 		let models = [];
-		const res = await fetch(`${$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL}/tags`, {
-			method: 'GET',
-			headers: {
-				Accept: 'application/json',
-				'Content-Type': 'application/json',
-				...($settings.authHeader && { Authorization: $settings.authHeader }),
-				...($user && { Authorization: `Bearer ${localStorage.token}` })
-			}
-		})
-			.then(async (res) => {
-				if (!res.ok) throw await res.json();
-				return res.json();
-			})
-			.catch((error) => {
+		models.push(
+			...(await getOllamaModels(
+				$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL,
+				localStorage.token
+			).catch((error) => {
+				toast.error(error);
+				return [];
+			}))
+		);
+		// If OpenAI API Key exists
+		if ($settings.OPENAI_API_KEY) {
+			const openAIModels = await getOpenAIModels(
+				$settings.OPENAI_API_BASE_URL ?? 'https://api.openai.com/v1',
+				$settings.OPENAI_API_KEY
+			).catch((error) => {
 				console.log(error);
-				if ('detail' in error) {
-					toast.error(error.detail);
-				} else {
-					toast.error('Server connection failed');
-				}
+				toast.error(error);
 				return null;
 			});
-		console.log(res);
-		models.push(...(res?.models ?? []));
 
-		// If OpenAI API Key exists
-		if ($settings.OPENAI_API_KEY) {
-			// Validate OPENAI_API_KEY
-
-			const API_BASE_URL = $settings.OPENAI_API_BASE_URL ?? 'https://api.openai.com/v1';
-			const openaiModelRes = await fetch(`${API_BASE_URL}/models`, {
-				method: 'GET',
-				headers: {
-					'Content-Type': 'application/json',
-					Authorization: `Bearer ${$settings.OPENAI_API_KEY}`
-				}
-			})
-				.then(async (res) => {
-					if (!res.ok) throw await res.json();
-					return res.json();
-				})
-				.catch((error) => {
-					console.log(error);
-					toast.error(`OpenAI: ${error?.error?.message ?? 'Network Problem'}`);
-					return null;
-				});
-
-			const openAIModels = Array.isArray(openaiModelRes)
-				? openaiModelRes
-				: openaiModelRes?.data ?? null;
-
-			models.push(
-				...(openAIModels
-					? [
-							{ name: 'hr' },
-							...openAIModels
-								.map((model) => ({ name: model.id, external: true }))
-								.filter((model) =>
-									API_BASE_URL.includes('openai') ? model.name.includes('gpt') : true
-								)
-					  ]
-					: [])
-			);
+			models.push(...(openAIModels ? [{ name: 'hr' }, ...openAIModels] : []));
 		}
-
 		return models;
 	};
 
-	const getDB = async () => {
-		const DB = await openDB('Chats', 1, {
-			upgrade(db) {
-				const store = db.createObjectStore('chats', {
-					keyPath: 'id',
-					autoIncrement: true
-				});
-				store.createIndex('timestamp', 'timestamp');
-			}
-		});
-
-		return {
-			db: DB,
-			getChatById: async function (id) {
-				return await this.db.get('chats', id);
-			},
-			getChats: async function () {
-				let chats = await this.db.getAllFromIndex('chats', 'timestamp');
-				chats = chats.map((item, idx) => ({
-					title: chats[chats.length - 1 - idx].title,
-					id: chats[chats.length - 1 - idx].id
-				}));
-				return chats;
-			},
-			exportChats: async function () {
-				let chats = await this.db.getAllFromIndex('chats', 'timestamp');
-				chats = chats.map((item, idx) => chats[chats.length - 1 - idx]);
-				return chats;
-			},
-			addChats: async function (_chats) {
-				for (const chat of _chats) {
-					console.log(chat);
-					await this.addChat(chat);
-				}
-				await chats.set(await this.getChats());
-			},
-			addChat: async function (chat) {
-				await this.db.put('chats', {
-					...chat
-				});
-			},
-			createNewChat: async function (chat) {
-				await this.addChat({ ...chat, timestamp: Date.now() });
-				await chats.set(await this.getChats());
-			},
-			updateChatById: async function (id, updated) {
-				const chat = await this.getChatById(id);
-
-				await this.db.put('chats', {
-					...chat,
-					...updated,
-					timestamp: Date.now()
-				});
-
-				await chats.set(await this.getChats());
-			},
-			deleteChatById: async function (id) {
-				if ($chatId === id) {
-					goto('/');
-					await chatId.set(uuidv4());
-				}
-				await this.db.delete('chats', id);
-				await chats.set(await this.getChats());
-			},
-			deleteAllChat: async function () {
-				const tx = this.db.transaction('chats', 'readwrite');
-				await Promise.all([tx.store.clear(), tx.done]);
-
-				await chats.set(await this.getChats());
-			}
-		};
-	};
-
-	const getOllamaVersion = async () => {
-		const res = await fetch(`${$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL}/version`, {
-			method: 'GET',
-			headers: {
-				Accept: 'application/json',
-				'Content-Type': 'application/json',
-				...($settings.authHeader && { Authorization: $settings.authHeader }),
-				...($user && { Authorization: `Bearer ${localStorage.token}` })
-			}
-		})
-			.then(async (res) => {
-				if (!res.ok) throw await res.json();
-				return res.json();
-			})
-			.catch((error) => {
-				console.log(error);
-				if ('detail' in error) {
-					toast.error(error.detail);
-				} else {
-					toast.error('Server connection failed');
-				}
-				return null;
+	const setOllamaVersion = async (version: string = '') => {
+		if (version === '') {
+			version = await getOllamaVersion(
+				$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL,
+				localStorage.token
+			).catch((error) => {
+				return '';
 			});
+		}
 
-		console.log(res);
-
-		return res?.version ?? '0';
-	};
+		ollamaVersion = version;
 
-	const setOllamaVersion = async (ollamaVersion) => {
-		await info.set({ ...$info, ollama: { version: ollamaVersion } });
-
-		if (
-			ollamaVersion.localeCompare(requiredOllamaVersion, undefined, {
-				numeric: true,
-				sensitivity: 'case',
-				caseFirst: 'upper'
-			}) < 0
-		) {
-			toast.error(`Ollama Version: ${ollamaVersion}`);
+		console.log(ollamaVersion);
+		if (checkVersion(REQUIRED_OLLAMA_VERSION, ollamaVersion)) {
+			toast.error(`Ollama Version: ${ollamaVersion !== '' ? ollamaVersion : 'Not Detected'}`);
 		}
 	};
 
 	onMount(async () => {
-		if ($config && $config.auth && $user === undefined) {
+		if ($user === undefined) {
 			await goto('/auth');
-		}
+		} else if (['user', 'admin'].includes($user.role)) {
+			try {
+				// Check if IndexedDB exists
+				DB = await openDB('Chats', 1);
+
+				if (DB) {
+					const chats = await DB.getAllFromIndex('chats', 'timestamp');
+					localDBChats = chats.map((item, idx) => chats[chats.length - 1 - idx]);
 
-		await settings.set(JSON.parse(localStorage.getItem('settings') ?? '{}'));
+					if (localDBChats.length === 0) {
+						await deleteDB('Chats');
+					}
 
-		await models.set(await getModels());
-		await modelfiles.set(JSON.parse(localStorage.getItem('modelfiles') ?? '[]'));
+					console.log('localdb', localDBChats);
+				}
+
+				console.log(DB);
+			} catch (error) {
+				// IndexedDB Not Found
+				console.log('IDB Not Found');
+			}
 
-		modelfiles.subscribe(async () => {
-			await models.set(await getModels());
-		});
+			console.log();
+			await settings.set(JSON.parse(localStorage.getItem('settings') ?? '{}'));
+			await modelfiles.set(await getModelfiles(localStorage.token));
+			console.log($modelfiles);
 
-		let _db = await getDB();
-		await db.set(_db);
+			modelfiles.subscribe(async () => {
+				// should fetch models
+				await models.set(await getModels());
+			});
 
-		await setOllamaVersion(await getOllamaVersion());
+			await setOllamaVersion();
+			await tick();
+		}
 
-		await tick();
 		loaded = true;
 	});
 </script>
 
 {#if loaded}
 	<div class="app relative">
-		{#if ($info?.ollama?.version ?? '0').localeCompare( requiredOllamaVersion, undefined, { numeric: true, sensitivity: 'case', caseFirst: 'upper' } ) < 0}
-			<div class="absolute w-full h-full flex z-50">
+		{#if !['user', 'admin'].includes($user.role)}
+			<div class="fixed w-full h-full flex z-50">
+				<div
+					class="absolute w-full h-full backdrop-blur-md bg-white/20 dark:bg-gray-900/50 flex justify-center"
+				>
+					<div class="m-auto pb-44 flex flex-col justify-center">
+						<div class="max-w-md">
+							<div class="text-center dark:text-white text-2xl font-medium z-50">
+								Account Activation Pending<br /> Contact Admin for WebUI Access
+							</div>
+
+							<div class=" mt-4 text-center text-sm dark:text-gray-200 w-full">
+								Your account status is currently pending activation. To access the WebUI, please
+								reach out to the administrator. Admins can manage user statuses from the Admin
+								Panel.
+							</div>
+
+							<div class=" mt-6 mx-auto relative group w-fit">
+								<button
+									class="relative z-20 flex px-5 py-2 rounded-full bg-white border border-gray-100 dark:border-none hover:bg-gray-100 transition font-medium text-sm"
+									on:click={async () => {
+										location.href = '/';
+									}}
+								>
+									Check Again
+								</button>
+
+								<button
+									class="text-xs text-center w-full mt-2 text-gray-400 underline"
+									on:click={async () => {
+										localStorage.removeItem('token');
+										location.href = '/auth';
+									}}>Sign Out</button
+								>
+							</div>
+						</div>
+					</div>
+				</div>
+			</div>
+		{:else if checkVersion(REQUIRED_OLLAMA_VERSION, ollamaVersion ?? '0')}
+			<div class="fixed w-full h-full flex z-50">
 				<div
-					class="absolute rounded-xl w-full h-full backdrop-blur bg-gray-900/60 flex justify-center"
+					class="absolute w-full h-full backdrop-blur-md bg-white/20 dark:bg-gray-900/50 flex justify-center"
 				>
 					<div class="m-auto pb-44 flex flex-col justify-center">
 						<div class="max-w-md">
@@ -254,15 +171,16 @@
 								/>We've detected either a connection hiccup or observed that you're using an older
 								version. Ensure you're on the latest Ollama version
 								<br class=" hidden sm:flex" />(version
-								<span class=" dark:text-white font-medium">{requiredOllamaVersion} or higher</span>)
-								or check your connection.
+								<span class=" dark:text-white font-medium">{REQUIRED_OLLAMA_VERSION} or higher</span
+								>) or check your connection.
 							</div>
 
 							<div class=" mt-6 mx-auto relative group w-fit">
 								<button
-									class="relative z-20 flex px-5 py-2 rounded-full bg-gray-100 hover:bg-gray-200 transition font-medium text-sm"
+									class="relative z-20 flex px-5 py-2 rounded-full bg-white border border-gray-100 dark:border-none hover:bg-gray-100 transition font-medium text-sm"
 									on:click={async () => {
-										await setOllamaVersion(await getOllamaVersion());
+										location.href = '/';
+										// await setOllamaVersion();
 									}}
 								>
 									Check Again
@@ -271,7 +189,57 @@
 								<button
 									class="text-xs text-center w-full mt-2 text-gray-400 underline"
 									on:click={async () => {
-										await setOllamaVersion(requiredOllamaVersion);
+										await setOllamaVersion(REQUIRED_OLLAMA_VERSION);
+									}}>Close</button
+								>
+							</div>
+						</div>
+					</div>
+				</div>
+			</div>
+		{:else if localDBChats.length > 0}
+			<div class="fixed w-full h-full flex z-50">
+				<div
+					class="absolute w-full h-full backdrop-blur-md bg-white/20 dark:bg-gray-900/50 flex justify-center"
+				>
+					<div class="m-auto pb-44 flex flex-col justify-center">
+						<div class="max-w-md">
+							<div class="text-center dark:text-white text-2xl font-medium z-50">
+								Important Update<br /> Action Required for Chat Log Storage
+							</div>
+
+							<div class=" mt-4 text-center text-sm dark:text-gray-200 w-full">
+								Saving chat logs directly to your browser's storage is no longer supported. Please
+								take a moment to download and delete your chat logs by clicking the button below.
+								Don't worry, you can easily re-import your chat logs to the backend through <span
+									class="font-semibold dark:text-white">Settings > Chats > Import Chats</span
+								>. This ensures that your valuable conversations are securely saved to your backend
+								database. Thank you!
+							</div>
+
+							<div class=" mt-6 mx-auto relative group w-fit">
+								<button
+									class="relative z-20 flex px-5 py-2 rounded-full bg-white border border-gray-100 dark:border-none hover:bg-gray-100 transition font-medium text-sm"
+									on:click={async () => {
+										let blob = new Blob([JSON.stringify(localDBChats)], {
+											type: 'application/json'
+										});
+										saveAs(blob, `chat-export-${Date.now()}.json`);
+
+										const tx = DB.transaction('chats', 'readwrite');
+										await Promise.all([tx.store.clear(), tx.done]);
+										await deleteDB('Chats');
+
+										localDBChats = [];
+									}}
+								>
+									Download & Delete
+								</button>
+
+								<button
+									class="text-xs text-center w-full mt-2 text-gray-400 underline"
+									on:click={async () => {
+										localDBChats = [];
 									}}>Close</button
 								>
 							</div>
@@ -285,9 +253,7 @@
 			class=" text-gray-700 dark:text-gray-100 bg-white dark:bg-gray-800 min-h-screen overflow-auto flex flex-row"
 		>
 			<Sidebar />
-
 			<SettingsModal bind:show={$showSettings} />
-
 			<slot />
 		</div>
 	</div>

+ 81 - 154
src/routes/(app)/+page.svelte

@@ -2,23 +2,27 @@
 	import { v4 as uuidv4 } from 'uuid';
 	import toast from 'svelte-french-toast';
 
-	import { OLLAMA_API_BASE_URL } from '$lib/constants';
 	import { onMount, tick } from 'svelte';
-	import { splitStream } from '$lib/utils';
 	import { goto } from '$app/navigation';
+	import { page } from '$app/stores';
+
+	import { models, modelfiles, user, settings, db, chats, chatId } from '$lib/stores';
+	import { OLLAMA_API_BASE_URL } from '$lib/constants';
 
-	import { config, models, modelfiles, user, settings, db, chats, chatId } from '$lib/stores';
+	import { generateChatCompletion, generateTitle } from '$lib/apis/ollama';
+	import { copyToClipboard, splitStream } from '$lib/utils';
 
 	import MessageInput from '$lib/components/chat/MessageInput.svelte';
 	import Messages from '$lib/components/chat/Messages.svelte';
 	import ModelSelector from '$lib/components/chat/ModelSelector.svelte';
 	import Navbar from '$lib/components/layout/Navbar.svelte';
-	import { page } from '$app/stores';
+	import { createNewChat, getChatList, updateChatById } from '$lib/apis/chats';
 
 	let stopResponseFlag = false;
 	let autoScroll = true;
 
 	let selectedModels = [''];
+
 	let selectedModelfile = null;
 	$: selectedModelfile =
 		selectedModels.length === 1 &&
@@ -26,10 +30,11 @@
 			? $modelfiles.filter((modelfile) => modelfile.tagName === selectedModels[0])[0]
 			: null;
 
+	let chat = null;
+
 	let title = '';
 	let prompt = '';
 	let files = [];
-
 	let messages = [];
 	let history = {
 		messages: {},
@@ -50,16 +55,8 @@
 		messages = [];
 	}
 
-	$: if (files) {
-		console.log(files);
-	}
-
 	onMount(async () => {
-		await chatId.set(uuidv4());
-
-		chatId.subscribe(async () => {
-			await initNewChat();
-		});
+		await initNewChat();
 	});
 
 	//////////////////////////
@@ -67,6 +64,11 @@
 	//////////////////////////
 
 	const initNewChat = async () => {
+		window.history.replaceState(history.state, '', `/`);
+
+		console.log('initNewChat');
+
+		await chatId.set('');
 		console.log($chatId);
 
 		autoScroll = true;
@@ -82,68 +84,33 @@
 			: $settings.models ?? [''];
 
 		let _settings = JSON.parse(localStorage.getItem('settings') ?? '{}');
-		console.log(_settings);
 		settings.set({
 			..._settings
 		});
 	};
 
-	const copyToClipboard = (text) => {
-		if (!navigator.clipboard) {
-			var textArea = document.createElement('textarea');
-			textArea.value = text;
-
-			// Avoid scrolling to bottom
-			textArea.style.top = '0';
-			textArea.style.left = '0';
-			textArea.style.position = 'fixed';
-
-			document.body.appendChild(textArea);
-			textArea.focus();
-			textArea.select();
-
-			try {
-				var successful = document.execCommand('copy');
-				var msg = successful ? 'successful' : 'unsuccessful';
-				console.log('Fallback: Copying text command was ' + msg);
-			} catch (err) {
-				console.error('Fallback: Oops, unable to copy', err);
-			}
-
-			document.body.removeChild(textArea);
-			return;
-		}
-		navigator.clipboard.writeText(text).then(
-			function () {
-				console.log('Async: Copying to clipboard was successful!');
-			},
-			function (err) {
-				console.error('Async: Could not copy text: ', err);
-			}
-		);
-	};
-
 	//////////////////////////
 	// Ollama functions
 	//////////////////////////
 
-	const sendPrompt = async (userPrompt, parentId, _chatId) => {
+	const sendPrompt = async (prompt, parentId) => {
+		const _chatId = JSON.parse(JSON.stringify($chatId));
 		await Promise.all(
 			selectedModels.map(async (model) => {
 				console.log(model);
 				if ($models.filter((m) => m.name === model)[0].external) {
-					await sendPromptOpenAI(model, userPrompt, parentId, _chatId);
+					await sendPromptOpenAI(model, prompt, parentId, _chatId);
 				} else {
-					await sendPromptOllama(model, userPrompt, parentId, _chatId);
+					await sendPromptOllama(model, prompt, parentId, _chatId);
 				}
 			})
 		);
 
-		await chats.set(await $db.getChats());
+		await chats.set(await getChatList(localStorage.token));
 	};
 
 	const sendPromptOllama = async (model, userPrompt, parentId, _chatId) => {
-		console.log('sendPromptOllama');
+		// Create response message
 		let responseMessageId = uuidv4();
 		let responseMessage = {
 			parentId: parentId,
@@ -154,8 +121,11 @@
 			model: model
 		};
 
+		// Add message to history and Set currentId to messageId
 		history.messages[responseMessageId] = responseMessage;
 		history.currentId = responseMessageId;
+
+		// Append messageId to childrenIds of parent message
 		if (parentId !== null) {
 			history.messages[parentId].childrenIds = [
 				...history.messages[parentId].childrenIds,
@@ -163,17 +133,16 @@
 			];
 		}
 
+		// Wait until history/message have been updated
 		await tick();
+
+		// Scroll down
 		window.scrollTo({ top: document.body.scrollHeight });
 
-		const res = await fetch(`${$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL}/chat`, {
-			method: 'POST',
-			headers: {
-				'Content-Type': 'text/event-stream',
-				...($settings.authHeader && { Authorization: $settings.authHeader }),
-				...($user && { Authorization: `Bearer ${localStorage.token}` })
-			},
-			body: JSON.stringify({
+		const res = await generateChatCompletion(
+			$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL,
+			localStorage.token,
+			{
 				model: model,
 				messages: [
 					$settings.system
@@ -195,20 +164,11 @@
 						})
 					})),
 				options: {
-					seed: $settings.seed ?? undefined,
-					temperature: $settings.temperature ?? undefined,
-					repeat_penalty: $settings.repeat_penalty ?? undefined,
-					top_k: $settings.top_k ?? undefined,
-					top_p: $settings.top_p ?? undefined,
-					num_ctx: $settings.num_ctx ?? undefined,
 					...($settings.options ?? {})
 				},
 				format: $settings.requestFormat ?? undefined
-			})
-		}).catch((err) => {
-			console.log(err);
-			return null;
-		});
+			}
+		);
 
 		if (res && res.ok) {
 			const reader = res.body
@@ -297,23 +257,14 @@
 				if (autoScroll) {
 					window.scrollTo({ top: document.body.scrollHeight });
 				}
+			}
 
-				await $db.updateChatById(_chatId, {
-					title: title === '' ? 'New Chat' : title,
-					models: selectedModels,
-					system: $settings.system ?? undefined,
-					options: {
-						seed: $settings.seed ?? undefined,
-						temperature: $settings.temperature ?? undefined,
-						repeat_penalty: $settings.repeat_penalty ?? undefined,
-						top_k: $settings.top_k ?? undefined,
-						top_p: $settings.top_p ?? undefined,
-						num_ctx: $settings.num_ctx ?? undefined,
-						...($settings.options ?? {})
-					},
+			if ($chatId == _chatId) {
+				chat = await updateChatById(localStorage.token, _chatId, {
 					messages: messages,
 					history: history
 				});
+				await chats.set(await getChatList(localStorage.token));
 			}
 		} else {
 			if (res !== null) {
@@ -339,6 +290,7 @@
 
 		stopResponseFlag = false;
 		await tick();
+
 		if (autoScroll) {
 			window.scrollTo({ top: document.body.scrollHeight });
 		}
@@ -481,23 +433,14 @@
 						if (autoScroll) {
 							window.scrollTo({ top: document.body.scrollHeight });
 						}
+					}
 
-						await $db.updateChatById(_chatId, {
-							title: title === '' ? 'New Chat' : title,
-							models: selectedModels,
-							system: $settings.system ?? undefined,
-							options: {
-								seed: $settings.seed ?? undefined,
-								temperature: $settings.temperature ?? undefined,
-								repeat_penalty: $settings.repeat_penalty ?? undefined,
-								top_k: $settings.top_k ?? undefined,
-								top_p: $settings.top_p ?? undefined,
-								num_ctx: $settings.num_ctx ?? undefined,
-								...($settings.options ?? {})
-							},
+					if ($chatId == _chatId) {
+						chat = await updateChatById(localStorage.token, _chatId, {
 							messages: messages,
 							history: history
 						});
+						await chats.set(await getChatList(localStorage.token));
 					}
 				} else {
 					if (res !== null) {
@@ -542,16 +485,18 @@
 	};
 
 	const submitPrompt = async (userPrompt) => {
-		const _chatId = JSON.parse(JSON.stringify($chatId));
-		console.log('submitPrompt', _chatId);
+		console.log('submitPrompt', $chatId);
 
 		if (selectedModels.includes('')) {
 			toast.error('Model not selected');
 		} else if (messages.length != 0 && messages.at(-1).done != true) {
+			// Response not done
 			console.log('wait');
 		} else {
+			// Reset chat message textarea height
 			document.getElementById('chat-textarea').style.height = '';
 
+			// Create user message
 			let userMessageId = uuidv4();
 			let userMessage = {
 				id: userMessageId,
@@ -562,42 +507,43 @@
 				files: files.length > 0 ? files : undefined
 			};
 
+			// Add message to history and Set currentId to messageId
+			history.messages[userMessageId] = userMessage;
+			history.currentId = userMessageId;
+
+			// Append messageId to childrenIds of parent message
 			if (messages.length !== 0) {
 				history.messages[messages.at(-1).id].childrenIds.push(userMessageId);
 			}
 
-			history.messages[userMessageId] = userMessage;
-			history.currentId = userMessageId;
-
+			// Wait until history/message have been updated
 			await tick();
+
+			// Create new chat if only one message in messages
 			if (messages.length == 1) {
-				await $db.createNewChat({
-					id: _chatId,
+				chat = await createNewChat(localStorage.token, {
+					id: $chatId,
 					title: 'New Chat',
 					models: selectedModels,
 					system: $settings.system ?? undefined,
 					options: {
-						seed: $settings.seed ?? undefined,
-						temperature: $settings.temperature ?? undefined,
-						repeat_penalty: $settings.repeat_penalty ?? undefined,
-						top_k: $settings.top_k ?? undefined,
-						top_p: $settings.top_p ?? undefined,
-						num_ctx: $settings.num_ctx ?? undefined,
 						...($settings.options ?? {})
 					},
 					messages: messages,
-					history: history
+					history: history,
+					timestamp: Date.now()
 				});
+				await chats.set(await getChatList(localStorage.token));
+				await chatId.set(chat.id);
+				await tick();
 			}
 
+			// Reset chat input textarea
 			prompt = '';
 			files = [];
 
-			setTimeout(() => {
-				window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
-			}, 50);
-
-			await sendPrompt(userPrompt, userMessageId, _chatId);
+			// Send prompt
+			await sendPrompt(userPrompt, userMessageId);
 		}
 	};
 
@@ -607,9 +553,7 @@
 	};
 
 	const regenerateResponse = async () => {
-		const _chatId = JSON.parse(JSON.stringify($chatId));
-		console.log('regenerateResponse', _chatId);
-
+		console.log('regenerateResponse');
 		if (messages.length != 0 && messages.at(-1).done == true) {
 			messages.splice(messages.length - 1, 1);
 			messages = messages;
@@ -617,41 +561,21 @@
 			let userMessage = messages.at(-1);
 			let userPrompt = userMessage.content;
 
-			await sendPrompt(userPrompt, userMessage.id, _chatId);
+			await sendPrompt(userPrompt, userMessage.id);
 		}
 	};
 
 	const generateChatTitle = async (_chatId, userPrompt) => {
 		if ($settings.titleAutoGenerate ?? true) {
-			console.log('generateChatTitle');
-
-			const res = await fetch(`${$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL}/generate`, {
-				method: 'POST',
-				headers: {
-					'Content-Type': 'text/event-stream',
-					...($settings.authHeader && { Authorization: $settings.authHeader }),
-					...($user && { Authorization: `Bearer ${localStorage.token}` })
-				},
-				body: JSON.stringify({
-					model: selectedModels[0],
-					prompt: `Generate a brief 3-5 word title for this question, excluding the term 'title.' Then, please reply with only the title: ${userPrompt}`,
-					stream: false
-				})
-			})
-				.then(async (res) => {
-					if (!res.ok) throw await res.json();
-					return res.json();
-				})
-				.catch((error) => {
-					if ('detail' in error) {
-						toast.error(error.detail);
-					}
-					console.log(error);
-					return null;
-				});
-
-			if (res) {
-				await setChatTitle(_chatId, res.response === '' ? 'New Chat' : res.response);
+			const title = await generateTitle(
+				$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL,
+				localStorage.token,
+				selectedModels[0],
+				userPrompt
+			);
+
+			if (title) {
+				await setChatTitle(_chatId, title);
 			}
 		} else {
 			await setChatTitle(_chatId, `${userPrompt}`);
@@ -659,10 +583,12 @@
 	};
 
 	const setChatTitle = async (_chatId, _title) => {
-		await $db.updateChatById(_chatId, { title: _title });
 		if (_chatId === $chatId) {
 			title = _title;
 		}
+
+		chat = await updateChatById(localStorage.token, _chatId, { title: _title });
+		await chats.set(await getChatList(localStorage.token));
 	};
 </script>
 
@@ -672,7 +598,7 @@
 	}}
 />
 
-<Navbar {title} shareEnabled={messages.length > 0} />
+<Navbar {title} shareEnabled={messages.length > 0} {initNewChat} />
 <div class="min-h-screen w-full flex justify-center">
 	<div class=" py-2.5 flex flex-col justify-between w-full">
 		<div class="max-w-2xl mx-auto w-full px-3 md:px-0 mt-10">
@@ -681,6 +607,7 @@
 
 		<div class=" h-full mt-10 mb-32 w-full flex flex-col">
 			<Messages
+				chatId={$chatId}
 				{selectedModels}
 				{selectedModelfile}
 				bind:history

+ 13 - 48
src/routes/admin/+page.svelte → src/routes/(app)/admin/+page.svelte

@@ -6,62 +6,27 @@
 
 	import toast from 'svelte-french-toast';
 
+	import { updateUserRole, getUsers } from '$lib/apis/users';
+
 	let loaded = false;
 	let users = [];
 
-	const updateUserRole = async (id, role) => {
-		const res = await fetch(`${WEBUI_API_BASE_URL}/users/update/role`, {
-			method: 'POST',
-			headers: {
-				'Content-Type': 'application/json',
-				Authorization: `Bearer ${localStorage.token}`
-			},
-			body: JSON.stringify({
-				id: id,
-				role: role
-			})
-		})
-			.then(async (res) => {
-				if (!res.ok) throw await res.json();
-				return res.json();
-			})
-			.catch((error) => {
-				console.log(error);
-				toast.error(error.detail);
-				return null;
-			});
+	const updateRoleHandler = async (id, role) => {
+		const res = await updateUserRole(localStorage.token, id, role).catch((error) => {
+			toast.error(error);
+			return null;
+		});
 
 		if (res) {
-			await getUsers();
+			users = await getUsers(localStorage.token);
 		}
 	};
 
-	const getUsers = async () => {
-		const res = await fetch(`${WEBUI_API_BASE_URL}/users/`, {
-			method: 'GET',
-			headers: {
-				'Content-Type': 'application/json',
-				Authorization: `Bearer ${localStorage.token}`
-			}
-		})
-			.then(async (res) => {
-				if (!res.ok) throw await res.json();
-				return res.json();
-			})
-			.catch((error) => {
-				console.log(error);
-				toast.error(error.detail);
-				return null;
-			});
-
-		users = res ? res : [];
-	};
-
 	onMount(async () => {
-		if ($config === null || !$config.auth || ($config.auth && $user && $user.role !== 'admin')) {
+		if ($user?.role !== 'admin') {
 			await goto('/');
 		} else {
-			await getUsers();
+			users = await getUsers(localStorage.token);
 		}
 		loaded = true;
 	});
@@ -115,11 +80,11 @@
 												class="  dark:text-white underline"
 												on:click={() => {
 													if (user.role === 'user') {
-														updateUserRole(user.id, 'admin');
+														updateRoleHandler(user.id, 'admin');
 													} else if (user.role === 'pending') {
-														updateUserRole(user.id, 'user');
+														updateRoleHandler(user.id, 'user');
 													} else {
-														updateUserRole(user.id, 'pending');
+														updateRoleHandler(user.id, 'pending');
 													}
 												}}>{user.role}</button
 											>

+ 117 - 171
src/routes/(app)/c/[id]/+page.svelte

@@ -2,17 +2,21 @@
 	import { v4 as uuidv4 } from 'uuid';
 	import toast from 'svelte-french-toast';
 
-	import { OLLAMA_API_BASE_URL } from '$lib/constants';
 	import { onMount, tick } from 'svelte';
-	import { convertMessagesToHistory, splitStream } from '$lib/utils';
 	import { goto } from '$app/navigation';
-	import { config, models, modelfiles, user, settings, db, chats, chatId } from '$lib/stores';
+	import { page } from '$app/stores';
+
+	import { models, modelfiles, user, settings, db, chats, chatId } from '$lib/stores';
+	import { OLLAMA_API_BASE_URL } from '$lib/constants';
+
+	import { generateChatCompletion, generateTitle } from '$lib/apis/ollama';
+	import { copyToClipboard, splitStream } from '$lib/utils';
 
 	import MessageInput from '$lib/components/chat/MessageInput.svelte';
 	import Messages from '$lib/components/chat/Messages.svelte';
 	import ModelSelector from '$lib/components/chat/ModelSelector.svelte';
 	import Navbar from '$lib/components/layout/Navbar.svelte';
-	import { page } from '$app/stores';
+	import { createNewChat, getChatById, getChatList, updateChatById } from '$lib/apis/chats';
 
 	let loaded = false;
 	let stopResponseFlag = false;
@@ -27,6 +31,8 @@
 			? $modelfiles.filter((modelfile) => modelfile.tagName === selectedModels[0])[0]
 			: null;
 
+	let chat = null;
+
 	let title = '';
 	let prompt = '';
 	let files = [];
@@ -53,10 +59,8 @@
 
 	$: if ($page.params.id) {
 		(async () => {
-			let chat = await loadChat();
-
-			await tick();
-			if (chat) {
+			if (await loadChat()) {
+				await tick();
 				loaded = true;
 			} else {
 				await goto('/');
@@ -70,94 +74,70 @@
 
 	const loadChat = async () => {
 		await chatId.set($page.params.id);
-		const chat = await $db.getChatById($chatId);
+		chat = await getChatById(localStorage.token, $chatId).catch(async (error) => {
+			await goto('/');
+			return null;
+		});
 
 		if (chat) {
-			console.log(chat);
-
-			selectedModels = (chat?.models ?? undefined) !== undefined ? chat.models : [chat.model ?? ''];
-			history =
-				(chat?.history ?? undefined) !== undefined
-					? chat.history
-					: convertMessagesToHistory(chat.messages);
-			title = chat.title;
-
-			let _settings = JSON.parse(localStorage.getItem('settings') ?? '{}');
-			await settings.set({
-				..._settings,
-				system: chat.system ?? _settings.system,
-				options: chat.options ?? _settings.options
-			});
-			autoScroll = true;
-
-			await tick();
-			if (messages.length > 0) {
-				history.messages[messages.at(-1).id].done = true;
-			}
-			await tick();
+			const chatContent = chat.chat;
+
+			if (chatContent) {
+				console.log(chatContent);
+
+				selectedModels =
+					(chatContent?.models ?? undefined) !== undefined
+						? chatContent.models
+						: [chatContent.model ?? ''];
+				history =
+					(chatContent?.history ?? undefined) !== undefined
+						? chatContent.history
+						: convertMessagesToHistory(chatContent.messages);
+				title = chatContent.title;
+
+				let _settings = JSON.parse(localStorage.getItem('settings') ?? '{}');
+				await settings.set({
+					..._settings,
+					system: chatContent.system ?? _settings.system,
+					options: chatContent.options ?? _settings.options
+				});
+				autoScroll = true;
+				await tick();
 
-			return chat;
-		} else {
-			return null;
-		}
-	};
+				if (messages.length > 0) {
+					history.messages[messages.at(-1).id].done = true;
+				}
+				await tick();
 
-	const copyToClipboard = (text) => {
-		if (!navigator.clipboard) {
-			var textArea = document.createElement('textarea');
-			textArea.value = text;
-
-			// Avoid scrolling to bottom
-			textArea.style.top = '0';
-			textArea.style.left = '0';
-			textArea.style.position = 'fixed';
-
-			document.body.appendChild(textArea);
-			textArea.focus();
-			textArea.select();
-
-			try {
-				var successful = document.execCommand('copy');
-				var msg = successful ? 'successful' : 'unsuccessful';
-				console.log('Fallback: Copying text command was ' + msg);
-			} catch (err) {
-				console.error('Fallback: Oops, unable to copy', err);
+				return true;
+			} else {
+				return null;
 			}
-
-			document.body.removeChild(textArea);
-			return;
 		}
-		navigator.clipboard.writeText(text).then(
-			function () {
-				console.log('Async: Copying to clipboard was successful!');
-			},
-			function (err) {
-				console.error('Async: Could not copy text: ', err);
-			}
-		);
 	};
 
 	//////////////////////////
 	// Ollama functions
 	//////////////////////////
 
-	const sendPrompt = async (userPrompt, parentId, _chatId) => {
+	const sendPrompt = async (prompt, parentId) => {
+		const _chatId = JSON.parse(JSON.stringify($chatId));
 		await Promise.all(
 			selectedModels.map(async (model) => {
 				console.log(model);
 				if ($models.filter((m) => m.name === model)[0].external) {
-					await sendPromptOpenAI(model, userPrompt, parentId, _chatId);
+					await sendPromptOpenAI(model, prompt, parentId, _chatId);
 				} else {
-					await sendPromptOllama(model, userPrompt, parentId, _chatId);
+					await sendPromptOllama(model, prompt, parentId, _chatId);
 				}
 			})
 		);
 
-		await chats.set(await $db.getChats());
+		await chats.set(await getChatList(localStorage.token));
 	};
 
 	const sendPromptOllama = async (model, userPrompt, parentId, _chatId) => {
-		console.log('sendPromptOllama');
+		// Create response message
 		let responseMessageId = uuidv4();
 		let responseMessage = {
 			parentId: parentId,
@@ -168,8 +148,11 @@
 			model: model
 		};
 
+		// Add message to history and Set currentId to messageId
 		history.messages[responseMessageId] = responseMessage;
 		history.currentId = responseMessageId;
+
+		// Append messageId to childrenIds of parent message
 		if (parentId !== null) {
 			history.messages[parentId].childrenIds = [
 				...history.messages[parentId].childrenIds,
@@ -177,17 +160,16 @@
 			];
 		}
 
+		// Wait until history/message have been updated
 		await tick();
+
+		// Scroll down
 		window.scrollTo({ top: document.body.scrollHeight });
 
-		const res = await fetch(`${$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL}/chat`, {
-			method: 'POST',
-			headers: {
-				'Content-Type': 'text/event-stream',
-				...($settings.authHeader && { Authorization: $settings.authHeader }),
-				...($user && { Authorization: `Bearer ${localStorage.token}` })
-			},
-			body: JSON.stringify({
+		const res = await generateChatCompletion(
+			$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL,
+			localStorage.token,
+			{
 				model: model,
 				messages: [
 					$settings.system
@@ -209,20 +191,11 @@
 						})
 					})),
 				options: {
-					seed: $settings.seed ?? undefined,
-					temperature: $settings.temperature ?? undefined,
-					repeat_penalty: $settings.repeat_penalty ?? undefined,
-					top_k: $settings.top_k ?? undefined,
-					top_p: $settings.top_p ?? undefined,
-					num_ctx: $settings.num_ctx ?? undefined,
 					...($settings.options ?? {})
 				},
 				format: $settings.requestFormat ?? undefined
-			})
-		}).catch((err) => {
-			console.log(err);
-			return null;
-		});
+			}
+		);
 
 		if (res && res.ok) {
 			const reader = res.body
@@ -311,23 +284,14 @@
 				if (autoScroll) {
 					window.scrollTo({ top: document.body.scrollHeight });
 				}
+			}
 
-				await $db.updateChatById(_chatId, {
-					title: title === '' ? 'New Chat' : title,
-					models: selectedModels,
-					system: $settings.system ?? undefined,
-					options: {
-						seed: $settings.seed ?? undefined,
-						temperature: $settings.temperature ?? undefined,
-						repeat_penalty: $settings.repeat_penalty ?? undefined,
-						top_k: $settings.top_k ?? undefined,
-						top_p: $settings.top_p ?? undefined,
-						num_ctx: $settings.num_ctx ?? undefined,
-						...($settings.options ?? {})
-					},
+			if ($chatId == _chatId) {
+				chat = await updateChatById(localStorage.token, _chatId, {
 					messages: messages,
 					history: history
 				});
+				await chats.set(await getChatList(localStorage.token));
 			}
 		} else {
 			if (res !== null) {
@@ -353,6 +317,7 @@
 
 		stopResponseFlag = false;
 		await tick();
+
 		if (autoScroll) {
 			window.scrollTo({ top: document.body.scrollHeight });
 		}
@@ -495,23 +460,14 @@
 						if (autoScroll) {
 							window.scrollTo({ top: document.body.scrollHeight });
 						}
+					}
 
-						await $db.updateChatById(_chatId, {
-							title: title === '' ? 'New Chat' : title,
-							models: selectedModels,
-							system: $settings.system ?? undefined,
-							options: {
-								seed: $settings.seed ?? undefined,
-								temperature: $settings.temperature ?? undefined,
-								repeat_penalty: $settings.repeat_penalty ?? undefined,
-								top_k: $settings.top_k ?? undefined,
-								top_p: $settings.top_p ?? undefined,
-								num_ctx: $settings.num_ctx ?? undefined,
-								...($settings.options ?? {})
-							},
+					if ($chatId == _chatId) {
+						chat = await updateChatById(localStorage.token, _chatId, {
 							messages: messages,
 							history: history
 						});
+						await chats.set(await getChatList(localStorage.token));
 					}
 				} else {
 					if (res !== null) {
@@ -556,16 +512,18 @@
 	};
 
 	const submitPrompt = async (userPrompt) => {
-		const _chatId = JSON.parse(JSON.stringify($chatId));
-		console.log('submitPrompt', _chatId);
+		console.log('submitPrompt', $chatId);
 
 		if (selectedModels.includes('')) {
 			toast.error('Model not selected');
 		} else if (messages.length != 0 && messages.at(-1).done != true) {
+			// Response not done
 			console.log('wait');
 		} else {
+			// Reset chat message textarea height
 			document.getElementById('chat-textarea').style.height = '';
 
+			// Create user message
 			let userMessageId = uuidv4();
 			let userMessage = {
 				id: userMessageId,
@@ -576,42 +534,43 @@
 				files: files.length > 0 ? files : undefined
 			};
 
+			// Add message to history and Set currentId to messageId
+			history.messages[userMessageId] = userMessage;
+			history.currentId = userMessageId;
+
+			// Append messageId to childrenIds of parent message
 			if (messages.length !== 0) {
 				history.messages[messages.at(-1).id].childrenIds.push(userMessageId);
 			}
 
-			history.messages[userMessageId] = userMessage;
-			history.currentId = userMessageId;
-
+			// Wait until history/message have been updated
 			await tick();
+
+			// Create new chat if only one message in messages
 			if (messages.length == 1) {
-				await $db.createNewChat({
-					id: _chatId,
+				chat = await createNewChat(localStorage.token, {
+					id: $chatId,
 					title: 'New Chat',
 					models: selectedModels,
 					system: $settings.system ?? undefined,
 					options: {
-						seed: $settings.seed ?? undefined,
-						temperature: $settings.temperature ?? undefined,
-						repeat_penalty: $settings.repeat_penalty ?? undefined,
-						top_k: $settings.top_k ?? undefined,
-						top_p: $settings.top_p ?? undefined,
-						num_ctx: $settings.num_ctx ?? undefined,
 						...($settings.options ?? {})
 					},
 					messages: messages,
-					history: history
+					history: history,
+					timestamp: Date.now()
 				});
+				await chats.set(await getChatList(localStorage.token));
+				await chatId.set(chat.id);
+				await tick();
 			}
 
+			// Reset chat input textarea
 			prompt = '';
 			files = [];
 
-			setTimeout(() => {
-				window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
-			}, 50);
-
-			await sendPrompt(userPrompt, userMessageId, _chatId);
+			// Send prompt
+			await sendPrompt(userPrompt, userMessageId);
 		}
 	};
 
@@ -621,9 +580,7 @@
 	};
 
 	const regenerateResponse = async () => {
-		const _chatId = JSON.parse(JSON.stringify($chatId));
-		console.log('regenerateResponse', _chatId);
-
+		console.log('regenerateResponse');
 		if (messages.length != 0 && messages.at(-1).done == true) {
 			messages.splice(messages.length - 1, 1);
 			messages = messages;
@@ -631,41 +588,21 @@
 			let userMessage = messages.at(-1);
 			let userPrompt = userMessage.content;
 
-			await sendPrompt(userPrompt, userMessage.id, _chatId);
+			await sendPrompt(userPrompt, userMessage.id);
 		}
 	};
 
 	const generateChatTitle = async (_chatId, userPrompt) => {
 		if ($settings.titleAutoGenerate ?? true) {
-			console.log('generateChatTitle');
-
-			const res = await fetch(`${$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL}/generate`, {
-				method: 'POST',
-				headers: {
-					'Content-Type': 'text/event-stream',
-					...($settings.authHeader && { Authorization: $settings.authHeader }),
-					...($user && { Authorization: `Bearer ${localStorage.token}` })
-				},
-				body: JSON.stringify({
-					model: selectedModels[0],
-					prompt: `Generate a brief 3-5 word title for this question, excluding the term 'title.' Then, please reply with only the title: ${userPrompt}`,
-					stream: false
-				})
-			})
-				.then(async (res) => {
-					if (!res.ok) throw await res.json();
-					return res.json();
-				})
-				.catch((error) => {
-					if ('detail' in error) {
-						toast.error(error.detail);
-					}
-					console.log(error);
-					return null;
-				});
-
-			if (res) {
-				await setChatTitle(_chatId, res.response === '' ? 'New Chat' : res.response);
+			const title = await generateTitle(
+				$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL,
+				localStorage.token,
+				selectedModels[0],
+				userPrompt
+			);
+
+			if (title) {
+				await setChatTitle(_chatId, title);
 			}
 		} else {
 			await setChatTitle(_chatId, `${userPrompt}`);
@@ -673,10 +610,12 @@
 	};
 
 	const setChatTitle = async (_chatId, _title) => {
-		await $db.updateChatById(_chatId, { title: _title });
 		if (_chatId === $chatId) {
 			title = _title;
 		}
+
+		chat = await updateChatById(localStorage.token, _chatId, { title: _title });
+		await chats.set(await getChatList(localStorage.token));
 	};
 </script>
 
@@ -687,7 +626,13 @@
 />
 
 {#if loaded}
-	<Navbar {title} shareEnabled={messages.length > 0} />
+	<Navbar
+		{title}
+		shareEnabled={messages.length > 0}
+		initNewChat={() => {
+			goto('/');
+		}}
+	/>
 	<div class="min-h-screen w-full flex justify-center">
 		<div class=" py-2.5 flex flex-col justify-between w-full">
 			<div class="max-w-2xl mx-auto w-full px-3 md:px-0 mt-10">
@@ -696,6 +641,7 @@
 
 			<div class=" h-full mt-10 mb-32 w-full flex flex-col">
 				<Messages
+					chatId={$chatId}
 					{selectedModels}
 					{selectedModelfile}
 					bind:history

+ 115 - 32
src/routes/(app)/modelfiles/+page.svelte

@@ -1,46 +1,41 @@
 <script lang="ts">
-	import { modelfiles, settings, user } from '$lib/stores';
-	import { onMount } from 'svelte';
 	import toast from 'svelte-french-toast';
+	import fileSaver from 'file-saver';
+	const { saveAs } = fileSaver;
 
+	import { onMount } from 'svelte';
+
+	import { modelfiles, settings, user } from '$lib/stores';
 	import { OLLAMA_API_BASE_URL } from '$lib/constants';
+	import { createModel, deleteModel } from '$lib/apis/ollama';
+	import {
+		createNewModelfile,
+		deleteModelfileByTagName,
+		getModelfiles
+	} from '$lib/apis/modelfiles';
+
+	let localModelfiles = [];
 
 	const deleteModelHandler = async (tagName) => {
 		let success = null;
-		const res = await fetch(`${$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL}/delete`, {
-			method: 'DELETE',
-			headers: {
-				'Content-Type': 'text/event-stream',
-				...($settings.authHeader && { Authorization: $settings.authHeader }),
-				...($user && { Authorization: `Bearer ${localStorage.token}` })
-			},
-			body: JSON.stringify({
-				name: tagName
-			})
-		})
-			.then(async (res) => {
-				if (!res.ok) throw await res.json();
-				return res.json();
-			})
-			.then((json) => {
-				console.log(json);
-				toast.success(`Deleted ${tagName}`);
-				success = true;
-				return json;
-			})
-			.catch((err) => {
-				console.log(err);
-				toast.error(err.error);
-				return null;
-			});
+
+		success = await deleteModel(
+			$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL,
+			localStorage.token,
+			tagName
+		);
+
+		if (success) {
+			toast.success(`Deleted ${tagName}`);
+		}
 
 		return success;
 	};
 
-	const deleteModelfilebyTagName = async (tagName) => {
+	const deleteModelfile = async (tagName) => {
 		await deleteModelHandler(tagName);
-		await modelfiles.set($modelfiles.filter((modelfile) => modelfile.tagName != tagName));
-		localStorage.setItem('modelfiles', JSON.stringify($modelfiles));
+		await deleteModelfileByTagName(localStorage.token, tagName);
+		await modelfiles.set(await getModelfiles(localStorage.token));
 	};
 
 	const shareModelfile = async (modelfile) => {
@@ -60,6 +55,21 @@
 			false
 		);
 	};
+
+	const saveModelfiles = async (modelfiles) => {
+		let blob = new Blob([JSON.stringify(modelfiles)], {
+			type: 'application/json'
+		});
+		saveAs(blob, `modelfiles-export-${Date.now()}.json`);
+	};
+
+	onMount(() => {
+		localModelfiles = JSON.parse(localStorage.getItem('modelfiles') ?? '[]');
+
+		if (localModelfiles) {
+			console.log(localModelfiles);
+		}
+	});
 </script>
 
 <div class="min-h-screen w-full flex justify-center dark:text-white">
@@ -167,7 +177,7 @@
 							class="self-center w-fit text-sm px-2 py-2 border dark:border-gray-600 rounded-xl"
 							type="button"
 							on:click={() => {
-								deleteModelfilebyTagName(modelfile.tagName);
+								deleteModelfile(modelfile.tagName);
 							}}
 						>
 							<svg
@@ -189,6 +199,79 @@
 				</div>
 			{/each}
 
+			{#if localModelfiles.length > 0}
+				<hr class=" dark:border-gray-700 my-2.5" />
+
+				<div class=" flex justify-end space-x-4 w-full mb-3">
+					<div class=" self-center text-sm font-medium">
+						{localModelfiles.length} Local Modelfiles Detected
+					</div>
+
+					<div class="flex space-x-1">
+						<button
+							class="self-center w-fit text-sm px-3 py-1 border dark:border-gray-600 rounded-xl flex"
+							on:click={async () => {
+								for (const modelfile of localModelfiles) {
+									await createNewModelfile(localStorage.token, modelfile).catch((error) => {
+										return null;
+									});
+								}
+
+								saveModelfiles(localModelfiles);
+								localStorage.removeItem('modelfiles');
+								localModelfiles = JSON.parse(localStorage.getItem('modelfiles') ?? '[]');
+								await modelfiles.set(await getModelfiles(localStorage.token));
+							}}
+						>
+							<div class=" self-center mr-2 font-medium">Sync All</div>
+
+							<div class=" self-center">
+								<svg
+									xmlns="http://www.w3.org/2000/svg"
+									viewBox="0 0 16 16"
+									fill="currentColor"
+									class="w-3.5 h-3.5"
+								>
+									<path
+										fill-rule="evenodd"
+										d="M13.836 2.477a.75.75 0 0 1 .75.75v3.182a.75.75 0 0 1-.75.75h-3.182a.75.75 0 0 1 0-1.5h1.37l-.84-.841a4.5 4.5 0 0 0-7.08.932.75.75 0 0 1-1.3-.75 6 6 0 0 1 9.44-1.242l.842.84V3.227a.75.75 0 0 1 .75-.75Zm-.911 7.5A.75.75 0 0 1 13.199 11a6 6 0 0 1-9.44 1.241l-.84-.84v1.371a.75.75 0 0 1-1.5 0V9.591a.75.75 0 0 1 .75-.75H5.35a.75.75 0 0 1 0 1.5H3.98l.841.841a4.5 4.5 0 0 0 7.08-.932.75.75 0 0 1 1.025-.273Z"
+										clip-rule="evenodd"
+									/>
+								</svg>
+							</div>
+						</button>
+
+						<button
+							class="self-center w-fit text-sm p-1.5 border dark:border-gray-600 rounded-xl flex"
+							on:click={async () => {
+								saveModelfiles(localModelfiles);
+
+								localStorage.removeItem('modelfiles');
+								localModelfiles = JSON.parse(localStorage.getItem('modelfiles') ?? '[]');
+								await modelfiles.set(await getModelfiles(localStorage.token));
+							}}
+						>
+							<div class=" self-center">
+								<svg
+									xmlns="http://www.w3.org/2000/svg"
+									fill="none"
+									viewBox="0 0 24 24"
+									stroke-width="1.5"
+									stroke="currentColor"
+									class="w-4 h-4"
+								>
+									<path
+										stroke-linecap="round"
+										stroke-linejoin="round"
+										d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
+									/>
+								</svg>
+							</div>
+						</button>
+					</div>
+				</div>
+			{/if}
+
 			<div class=" my-16">
 				<div class=" text-2xl font-semibold mb-6">Made by OllamaHub Community</div>
 

+ 14 - 18
src/routes/(app)/modelfiles/create/+page.svelte

@@ -8,6 +8,8 @@
 	import Advanced from '$lib/components/chat/Settings/Advanced.svelte';
 	import { splitStream } from '$lib/utils';
 	import { onMount, tick } from 'svelte';
+	import { createModel } from '$lib/apis/ollama';
+	import { createNewModelfile, getModelfileByTagName, getModelfiles } from '$lib/apis/modelfiles';
 
 	let loading = false;
 
@@ -93,11 +95,8 @@ SYSTEM """${system}"""`.replace(/^\s*\n/gm, '');
 	};
 
 	const saveModelfile = async (modelfile) => {
-		await modelfiles.set([
-			...$modelfiles.filter((m) => m.tagName !== modelfile.tagName),
-			modelfile
-		]);
-		localStorage.setItem('modelfiles', JSON.stringify($modelfiles));
+		await createNewModelfile(localStorage.token, modelfile);
+		await modelfiles.set(await getModelfiles(localStorage.token));
 	};
 
 	const submitHandler = async () => {
@@ -112,7 +111,10 @@ SYSTEM """${system}"""`.replace(/^\s*\n/gm, '');
 			return success;
 		}
 
-		if ($models.includes(tagName)) {
+		if (
+			$models.map((model) => model.name).includes(tagName) ||
+			(await getModelfileByTagName(localStorage.token, tagName).catch(() => false))
+		) {
 			toast.error(
 				`Uh-oh! It looks like you already have a model named '${tagName}'. Please choose a different name to complete your modelfile.`
 			);
@@ -128,18 +130,12 @@ SYSTEM """${system}"""`.replace(/^\s*\n/gm, '');
 			Object.keys(categories).filter((category) => categories[category]).length > 0 &&
 			!$models.includes(tagName)
 		) {
-			const res = await fetch(`${$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL}/create`, {
-				method: 'POST',
-				headers: {
-					'Content-Type': 'text/event-stream',
-					...($settings.authHeader && { Authorization: $settings.authHeader }),
-					...($user && { Authorization: `Bearer ${localStorage.token}` })
-				},
-				body: JSON.stringify({
-					name: tagName,
-					modelfile: content
-				})
-			});
+			const res = await createModel(
+				$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL,
+				localStorage.token,
+				tagName,
+				content
+			);
 
 			if (res) {
 				const reader = res.body

+ 20 - 28
src/routes/(app)/modelfiles/edit/+page.svelte

@@ -2,14 +2,20 @@
 	import { v4 as uuidv4 } from 'uuid';
 	import { toast } from 'svelte-french-toast';
 	import { goto } from '$app/navigation';
-	import { OLLAMA_API_BASE_URL } from '$lib/constants';
-	import { settings, db, user, config, modelfiles } from '$lib/stores';
 
-	import Advanced from '$lib/components/chat/Settings/Advanced.svelte';
-	import { splitStream } from '$lib/utils';
 	import { onMount } from 'svelte';
 	import { page } from '$app/stores';
 
+	import { settings, db, user, config, modelfiles } from '$lib/stores';
+
+	import { OLLAMA_API_BASE_URL } from '$lib/constants';
+	import { splitStream } from '$lib/utils';
+
+	import { createModel } from '$lib/apis/ollama';
+	import { getModelfiles, updateModelfileByTagName } from '$lib/apis/modelfiles';
+
+	import Advanced from '$lib/components/chat/Settings/Advanced.svelte';
+
 	let loading = false;
 
 	let filesInputElement;
@@ -78,17 +84,9 @@
 		}
 	});
 
-	const saveModelfile = async (modelfile) => {
-		await modelfiles.set(
-			$modelfiles.map((e) => {
-				if (e.tagName === modelfile.tagName) {
-					return modelfile;
-				} else {
-					return e;
-				}
-			})
-		);
-		localStorage.setItem('modelfiles', JSON.stringify($modelfiles));
+	const updateModelfile = async (modelfile) => {
+		await updateModelfileByTagName(localStorage.token, modelfile.tagName, modelfile);
+		await modelfiles.set(await getModelfiles(localStorage.token));
 	};
 
 	const updateHandler = async () => {
@@ -106,18 +104,12 @@
 			content !== '' &&
 			Object.keys(categories).filter((category) => categories[category]).length > 0
 		) {
-			const res = await fetch(`${$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL}/create`, {
-				method: 'POST',
-				headers: {
-					'Content-Type': 'text/event-stream',
-					...($settings.authHeader && { Authorization: $settings.authHeader }),
-					...($user && { Authorization: `Bearer ${localStorage.token}` })
-				},
-				body: JSON.stringify({
-					name: tagName,
-					modelfile: content
-				})
-			});
+			const res = await createModel(
+				$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL,
+				localStorage.token,
+				tagName,
+				content
+			);
 
 			if (res) {
 				const reader = res.body
@@ -178,7 +170,7 @@
 			}
 
 			if (success) {
-				await saveModelfile({
+				await updateModelfile({
 					tagName: tagName,
 					imageUrl: imageUrl,
 					title: title,

+ 27 - 40
src/routes/+layout.svelte

@@ -2,56 +2,39 @@
 	import { onMount, tick } from 'svelte';
 	import { config, user } from '$lib/stores';
 	import { goto } from '$app/navigation';
-	import { WEBUI_API_BASE_URL } from '$lib/constants';
 	import toast, { Toaster } from 'svelte-french-toast';
 
+	import { getBackendConfig } from '$lib/apis';
+	import { getSessionUser } from '$lib/apis/auths';
+
 	import '../app.css';
 	import '../tailwind.css';
 	import 'tippy.js/dist/tippy.css';
+
 	let loaded = false;
 
 	onMount(async () => {
-		const resBackend = await fetch(`${WEBUI_API_BASE_URL}/`, {
-			method: 'GET',
-			headers: {
-				'Content-Type': 'application/json'
-			}
-		})
-			.then(async (res) => {
-				if (!res.ok) throw await res.json();
-				return res.json();
-			})
-			.catch((error) => {
-				console.log(error);
-				return null;
-			});
+		// Check Backend Status
+		const backendConfig = await getBackendConfig();
 
-		console.log(resBackend);
-		await config.set(resBackend);
+		if (backendConfig) {
+			// Save Backend Status to Store
+			await config.set(backendConfig);
+			console.log(backendConfig);
 
-		if ($config) {
-			if ($config.auth) {
+			if ($config) {
 				if (localStorage.token) {
-					const res = await fetch(`${WEBUI_API_BASE_URL}/auths`, {
-						method: 'GET',
-						headers: {
-							'Content-Type': 'application/json',
-							Authorization: `Bearer ${localStorage.token}`
-						}
-					})
-						.then(async (res) => {
-							if (!res.ok) throw await res.json();
-							return res.json();
-						})
-						.catch((error) => {
-							console.log(error);
-							toast.error(error.detail);
-							return null;
-						});
-
-					if (res) {
-						await user.set(res);
+					// Get Session User Info
+					const sessionUser = await getSessionUser(localStorage.token).catch((error) => {
+						toast.error(error);
+						return null;
+					});
+
+					if (sessionUser) {
+						// Save Session User to Store
+						await user.set(sessionUser);
 					} else {
+						// Redirect Invalid Session User to /auth Page
 						localStorage.removeItem('token');
 						await goto('/auth');
 					}
@@ -59,6 +42,9 @@
 					await goto('/auth');
 				}
 			}
+		} else {
+			// Redirect to /error when Backend Not Detected
+			await goto(`/error`);
 		}
 
 		await tick();
@@ -69,8 +55,9 @@
 <svelte:head>
 	<title>Ollama</title>
 </svelte:head>
-<Toaster />
 
-{#if $config !== undefined && loaded}
+{#if loaded}
 	<slot />
 {/if}
+
+<Toaster />

+ 50 - 75
src/routes/auth/+page.svelte

@@ -1,5 +1,6 @@
 <script>
 	import { goto } from '$app/navigation';
+	import { userSignIn, userSignUp } from '$lib/apis/auths';
 	import { WEBUI_API_BASE_URL } from '$lib/constants';
 	import { config, user } from '$lib/stores';
 	import { onMount } from 'svelte';
@@ -12,76 +13,51 @@
 	let email = '';
 	let password = '';
 
-	const signInHandler = async () => {
-		const res = await fetch(`${WEBUI_API_BASE_URL}/auths/signin`, {
-			method: 'POST',
-			headers: {
-				'Content-Type': 'application/json'
-			},
-			body: JSON.stringify({
-				email: email,
-				password: password
-			})
-		})
-			.then(async (res) => {
-				if (!res.ok) throw await res.json();
-				return res.json();
-			})
-			.catch((error) => {
-				console.log(error);
-				toast.error(error.detail);
-				return null;
-			});
-
-		if (res) {
-			console.log(res);
+	const setSessionUser = async (sessionUser) => {
+		if (sessionUser) {
+			console.log(sessionUser);
 			toast.success(`You're now logged in.`);
-			localStorage.token = res.token;
-			await user.set(res);
+			localStorage.token = sessionUser.token;
+			await user.set(sessionUser);
 			goto('/');
 		}
 	};
 
+	const signInHandler = async () => {
+		const sessionUser = await userSignIn(email, password).catch((error) => {
+			toast.error(error);
+			return null;
+		});
+
+		await setSessionUser(sessionUser);
+	};
+
 	const signUpHandler = async () => {
-		const res = await fetch(`${WEBUI_API_BASE_URL}/auths/signup`, {
-			method: 'POST',
-			headers: {
-				'Content-Type': 'application/json'
-			},
-			body: JSON.stringify({
-				name: name,
-				email: email,
-				password: password
-			})
-		})
-			.then(async (res) => {
-				if (!res.ok) throw await res.json();
-				return res.json();
-			})
-			.catch((error) => {
-				console.log(error);
-				toast.error(error.detail);
-				return null;
-			});
-
-		if (res) {
-			console.log(res);
-			toast.success(`Account creation successful."`);
-			localStorage.token = res.token;
-			await user.set(res);
-			goto('/');
+		const sessionUser = await userSignUp(name, email, password).catch((error) => {
+			toast.error(error);
+			return null;
+		});
+
+		await setSessionUser(sessionUser);
+	};
+
+	const submitHandler = async () => {
+		if (mode === 'signin') {
+			await signInHandler();
+		} else {
+			await signUpHandler();
 		}
 	};
 
 	onMount(async () => {
-		if ($config === null || !$config.auth || ($config.auth && $user !== undefined)) {
+		if ($user !== undefined) {
 			await goto('/');
 		}
 		loaded = true;
 	});
 </script>
 
-{#if loaded && $config && $config.auth}
+{#if loaded}
 	<div class="fixed m-10 z-50">
 		<div class="flex space-x-2">
 			<div class=" self-center">
@@ -91,7 +67,7 @@
 	</div>
 
 	<div class=" bg-white min-h-screen w-full flex justify-center font-mona">
-		<div class="hidden lg:flex lg:flex-1 px-10 md:px-16 w-full bg-yellow-50 justify-center">
+		<!-- <div class="hidden lg:flex lg:flex-1 px-10 md:px-16 w-full bg-yellow-50 justify-center">
 			<div class=" my-auto pb-16 text-left">
 				<div>
 					<div class=" font-bold text-yellow-600 text-4xl">
@@ -103,66 +79,65 @@
 					</div>
 				</div>
 			</div>
-		</div>
+		</div> -->
 
-		<div class="w-full max-w-xl px-10 md:px-16 bg-white min-h-screen w-full flex flex-col">
+		<div class="w-full max-w-lg px-10 md:px-16 bg-white min-h-screen flex flex-col">
 			<div class=" my-auto pb-10 w-full">
 				<form
 					class=" flex flex-col justify-center"
 					on:submit|preventDefault={() => {
-						if (mode === 'signin') {
-							signInHandler();
-						} else {
-							signUpHandler();
-						}
+						submitHandler();
 					}}
 				>
-					<div class=" text-2xl md:text-3xl font-semibold">
+					<div class=" text-xl md:text-2xl font-bold">
 						{mode === 'signin' ? 'Sign in' : 'Sign up'} to Ollama Web UI
 					</div>
 
-					<hr class="my-8" />
-
-					<div class="flex flex-col space-y-4">
+					<div class="flex flex-col mt-4">
 						{#if mode === 'signup'}
 							<div>
-								<div class=" text-sm font-bold text-left mb-2">Name</div>
+								<div class=" text-sm font-semibold text-left mb-1">Name</div>
 								<input
 									bind:value={name}
 									type="text"
-									class=" border px-5 py-4 rounded-2xl w-full text-sm"
+									class=" border px-4 py-2.5 rounded-2xl w-full text-sm"
 									autocomplete="name"
+									placeholder="Enter Your Full Name"
 									required
 								/>
 							</div>
+
+							<hr class=" my-3" />
 						{/if}
 
-						<div>
-							<div class=" text-sm font-bold text-left mb-2">Email</div>
+						<div class="mb-2">
+							<div class=" text-sm font-semibold text-left mb-1">Email</div>
 							<input
 								bind:value={email}
 								type="email"
-								class=" border px-5 py-4 rounded-2xl w-full text-sm"
+								class=" border px-4 py-2.5 rounded-2xl w-full text-sm"
 								autocomplete="email"
+								placeholder="Enter Your Email"
 								required
 							/>
 						</div>
 
 						<div>
-							<div class=" text-sm font-bold text-left mb-2">Password</div>
+							<div class=" text-sm font-semibold text-left mb-1">Password</div>
 							<input
 								bind:value={password}
 								type="password"
-								class=" border px-5 py-4 rounded-2xl w-full text-sm"
+								class=" border px-4 py-2.5 rounded-2xl w-full text-sm"
+								placeholder="Enter Your Password"
 								autocomplete="current-password"
 								required
 							/>
 						</div>
 					</div>
 
-					<div class="mt-8">
+					<div class="mt-5">
 						<button
-							class=" bg-gray-900 hover:bg-gray-800 w-full rounded-full text-white font-semibold text-sm py-5 transition"
+							class=" bg-gray-900 hover:bg-gray-800 w-full rounded-full text-white font-semibold text-sm py-3 transition"
 							type="submit"
 						>
 							{mode === 'signin' ? 'Sign In' : 'Create Account'}

+ 55 - 0
src/routes/error/+page.svelte

@@ -0,0 +1,55 @@
+<script>
+	import { goto } from '$app/navigation';
+	import { config } from '$lib/stores';
+	import { onMount } from 'svelte';
+
+	let loaded = false;
+
+	onMount(async () => {
+		if ($config) {
+			await goto('/');
+		}
+
+		loaded = true;
+	});
+</script>
+
+{#if loaded}
+	<div class="absolute w-full h-full flex z-50">
+		<div class="absolute rounded-xl w-full h-full backdrop-blur flex justify-center">
+			<div class="m-auto pb-44 flex flex-col justify-center">
+				<div class="max-w-md">
+					<div class="text-center text-2xl font-medium z-50">Ollama WebUI Backend Required</div>
+
+					<div class=" mt-4 text-center text-sm w-full">
+						Oops! You're using an unsupported method (frontend only). Please serve the WebUI from
+						the backend.
+
+						<br class=" " />
+						<br class=" " />
+						<a
+							class=" font-semibold underline"
+							href="https://github.com/ollama-webui/ollama-webui#how-to-install-"
+							target="_blank">See readme.md for instructions</a
+						>
+						or
+						<a class=" font-semibold underline" href="https://discord.gg/5rJgQTnV4s" target="_blank"
+							>join our Discord for help.</a
+						>
+					</div>
+
+					<div class=" mt-6 mx-auto relative group w-fit">
+						<button
+							class="relative z-20 flex px-5 py-2 rounded-full bg-gray-100 hover:bg-gray-200 transition font-medium text-sm"
+							on:click={() => {
+								location.href = '/';
+							}}
+						>
+							Check Again
+						</button>
+					</div>
+				</div>
+			</div>
+		</div>
+	</div>
+{/if}