Merge pull request #216 from ollama-webui/dev

feat: full backend support (including auth/rbac)
This commit is contained in:
Timothy Jaeryang Baek 2023-12-27 16:59:09 -05:00 committed by GitHub
commit fbdae0f769
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 2633 additions and 1269 deletions

16
.dockerignore Normal file
View file

@ -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

115
README.md
View file

@ -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. - ⚙️ **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. - 🔒 **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. This command will install both Ollama and Ollama Web UI on your system.
#### Enable GPU #### Enable GPU
Use the additional Docker Compose file designed to enable GPU support by running the following command: Use the additional Docker Compose file designed to enable GPU support by running the following command:
```bash ```bash
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build
``` ```
#### Expose Ollama API outside the container stack #### Expose Ollama API outside the container stack
Deploy the service with an additional Docker Compose file designed for API exposure: Deploy the service with an additional Docker Compose file designed for API exposure:
```bash ```bash
docker compose -f docker-compose.yml -f docker-compose.api.yml up -d --build 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 🐳 #### 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: 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 ```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: Alternatively, if you prefer to build the container yourself, use the following command:
```bash ```bash
docker build -t ollama-webui . 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! 😄 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: Change `OLLAMA_API_BASE_URL` environment variable to match the external Ollama Server url:
```bash ```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: Alternatively, if you prefer to build the container yourself, use the following command:
```bash ```bash
docker build -t ollama-webui . 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 ## 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. 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
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. 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.
**Warning: Backend Dependency for Proper Functionality**
### TL;DR 🚀 ### TL;DR 🚀
@ -166,86 +174,6 @@ sh start.sh
You should have the Ollama Web UI up and running at http://localhost:8080/. Enjoy! 😄 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 ## 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). 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: Here are some exciting tasks on our roadmap:
- 📚 **RAG Integration**: Experience first-class retrieval augmented generation support, enabling chat with your documents. - 📚 **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. - 🧪 **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. - 📈 **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. - 📚 **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 🤍 ### 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 📜 ## License 📜

View file

@ -1,22 +1,22 @@
# Ollama Web UI Troubleshooting Guide # Ollama Web UI Troubleshooting Guide
## Ollama WebUI: Server Connection Error
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.
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
```
## Connection Errors ## 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/). 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: If you encounter difficulties connecting to the Ollama server, please follow these steps to diagnose and resolve the issue:
**1. Verify Ollama Server Configuration** **1. Check Ollama URL Format**
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:
```bash
OLLAMA_HOST=0.0.0.0 OLLAMA_ORIGINS=* ollama serve
```
This configuration allows Ollama to accept connections from any source.
**2. Check Ollama URL Format**
Ensure that the Ollama URL is correctly formatted in the application settings. Follow these steps: 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. 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. 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
backend/.dockerignore Normal file
View file

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

5
backend/.gitignore vendored
View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
from fastapi import FastAPI, Request, Depends, HTTPException from fastapi import FastAPI, Request, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware 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 from config import WEBUI_VERSION, WEBUI_AUTH
app = FastAPI() app = FastAPI()
@ -19,6 +19,10 @@ app.add_middleware(
app.include_router(auths.router, prefix="/auths", tags=["auths"]) app.include_router(auths.router, prefix="/auths", tags=["auths"])
app.include_router(users.router, prefix="/users", tags=["users"]) 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"]) app.include_router(utils.router, prefix="/utils", tags=["utils"])

View file

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

View file

@ -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)

View file

@ -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)

View file

@ -1,25 +1,38 @@
from pydantic import BaseModel from pydantic import BaseModel
from peewee import *
from playhouse.shortcuts import model_to_dict
from typing import List, Union, Optional from typing import List, Union, Optional
from pymongo import ReturnDocument
import time import time
from utils.utils import decode_token from utils.utils import decode_token
from utils.misc import get_gravatar_url from utils.misc import get_gravatar_url
from config import DB from apps.web.internal.db import DB
#################### ####################
# User DB Schema # 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): class UserModel(BaseModel):
id: str id: str
name: str name: str
email: str email: str
role: str = "pending" role: str = "pending"
profile_image_url: str = "/user.png" 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: class UsersTable:
def __init__(self, db): def __init__(self, db):
self.db = db self.db = db
self.table = db.users self.db.create_tables([User])
def insert_new_user( def insert_new_user(
self, id: str, name: str, email: str, role: str = "pending" self, id: str, name: str, email: str, role: str = "pending"
@ -47,22 +60,27 @@ class UsersTable:
"email": email, "email": email,
"role": role, "role": role,
"profile_image_url": get_gravatar_url(email), "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: if result:
return user return user
else: else:
return None return None
def get_user_by_email(self, email: str) -> Optional[UserModel]: def get_user_by_id(self, id: str) -> Optional[UserModel]:
user = self.table.find_one({"email": email}, {"_id": False}) try:
user = User.get(User.id == id)
return UserModel(**model_to_dict(user))
except:
return None
if user: def get_user_by_email(self, email: str) -> Optional[UserModel]:
return UserModel(**user) try:
else: user = User.get(User.email == email)
return UserModel(**model_to_dict(user))
except:
return None return None
def get_user_by_token(self, token: str) -> Optional[UserModel]: 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]: def get_users(self, skip: int = 0, limit: int = 50) -> List[UserModel]:
return [ return [
UserModel(**user) UserModel(**model_to_dict(user))
for user in list( for user in User.select().limit(limit).offset(skip)
self.table.find({}, {"_id": False}).skip(skip).limit(limit)
)
] ]
def get_num_users(self) -> Optional[int]: def get_num_users(self) -> Optional[int]:
return self.table.count_documents({}) return User.select().count()
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)
def update_user_role_by_id(self, id: str, role: str) -> Optional[UserModel]: 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) Users = UsersTable(DB)

View file

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

View file

@ -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,
)

View file

@ -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,
)

View file

@ -1,9 +1,10 @@
from dotenv import load_dotenv, find_dotenv from dotenv import load_dotenv, find_dotenv
from pymongo import MongoClient
from constants import ERROR_MESSAGES from constants import ERROR_MESSAGES
from secrets import token_bytes from secrets import token_bytes
from base64 import b64encode from base64 import b64encode
import os import os
load_dotenv(find_dotenv("../.env")) load_dotenv(find_dotenv("../.env"))
@ -30,30 +31,13 @@ if ENV == "prod":
# WEBUI_VERSION # WEBUI_VERSION
#################################### ####################################
WEBUI_VERSION = os.environ.get("WEBUI_VERSION", "v1.0.0-alpha.40") WEBUI_VERSION = os.environ.get("WEBUI_VERSION", "v1.0.0-alpha.42")
#################################### ####################################
# WEBUI_AUTH # WEBUI_AUTH (Required for security)
#################################### ####################################
WEBUI_AUTH = True
WEBUI_AUTH = True if os.environ.get("WEBUI_AUTH", "FALSE") == "TRUE" else False
####################################
# WEBUI_DB (Deprecated, Should be removed)
####################################
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_JWT_SECRET_KEY # WEBUI_JWT_SECRET_KEY

View file

@ -11,6 +11,11 @@ class ERROR_MESSAGES(str, Enum):
DEFAULT = lambda err="": f"Something went wrong :/\n{err if err else ''}" DEFAULT = lambda err="": f"Something went wrong :/\n{err if err else ''}"
ENV_VAR_NOT_FOUND = "Required environment variable not found. Terminating now." 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 = ( INVALID_TOKEN = (
"Your session has expired or the token is invalid. Please sign in again." "Your session has expired or the token is invalid. Please sign in again."
) )
@ -20,5 +25,6 @@ class ERROR_MESSAGES(str, Enum):
ACTION_PROHIBITED = ( ACTION_PROHIBITED = (
"The requested action has been restricted as a security measure." "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 :/" USER_NOT_FOUND = "We could not find what you're looking for :/"
MALICIOUS = "Unusual activities detected, please try again in a few minutes." MALICIOUS = "Unusual activities detected, please try again in a few minutes."

1
backend/data/readme.txt Normal file
View file

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

View file

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

View file

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

View file

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

2
run.sh
View file

@ -1,5 +1,5 @@
docker stop ollama-webui || true docker stop ollama-webui || true
docker rm ollama-webui || true docker rm ollama-webui || true
docker build -t ollama-webui . 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 docker image prune -f

View file

@ -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
src/lib/apis/chats/index.ts Normal file
View file

@ -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
src/lib/apis/index.ts Normal file
View file

@ -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;
};

View file

@ -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;
};

View file

@ -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;
};

View file

@ -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));
};

View file

@ -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 : [];
};

View file

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

View file

@ -1,18 +1,23 @@
<script lang="ts"> <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 { import {
WEB_UI_VERSION, WEB_UI_VERSION,
OLLAMA_API_BASE_URL, OLLAMA_API_BASE_URL,
WEBUI_API_BASE_URL, WEBUI_API_BASE_URL,
WEBUI_BASE_URL WEBUI_BASE_URL
} from '$lib/constants'; } 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 Advanced from './Settings/Advanced.svelte';
import { stringify } from 'postcss'; import Modal from '../common/Modal.svelte';
export let show = false; export let show = false;
@ -74,11 +79,48 @@
let OPENAI_API_KEY = ''; let OPENAI_API_KEY = '';
let OPENAI_API_BASE_URL = ''; 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 // Auth
let authEnabled = false; let authEnabled = false;
let authType = 'Basic'; let authType = 'Basic';
let authContent = ''; let authContent = '';
// About
let ollamaVersion = '';
const checkOllamaConnection = async () => { const checkOllamaConnection = async () => {
if (API_BASE_URL === '') { if (API_BASE_URL === '') {
API_BASE_URL = OLLAMA_API_BASE_URL; API_BASE_URL = OLLAMA_API_BASE_URL;
@ -553,7 +595,7 @@
return models; return models;
}; };
onMount(() => { onMount(async () => {
let settings = JSON.parse(localStorage.getItem('settings') ?? '{}'); let settings = JSON.parse(localStorage.getItem('settings') ?? '{}');
console.log(settings); console.log(settings);
@ -586,6 +628,13 @@
authType = settings.authHeader.split(' ')[0]; authType = settings.authHeader.split(' ')[0];
authContent = settings.authHeader.split(' ')[1]; authContent = settings.authHeader.split(' ')[1];
} }
ollamaVersion = await getOllamaVersion(
API_BASE_URL ?? OLLAMA_API_BASE_URL,
localStorage.token
).catch((error) => {
return '';
});
}); });
</script> </script>
@ -741,6 +790,32 @@
<div class=" self-center">Add-ons</div> <div class=" self-center">Add-ons</div>
</button> </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)} {#if !$config || ($config && !$config.auth)}
<button <button
class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab === 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> </div>
<hr class=" dark:border-gray-700" /> <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 <form
on:submit|preventDefault={() => { on:submit|preventDefault={() => {
uploadModelHandler(); uploadModelHandler();
}} }}
> >
<div class=" mb-2 flex w-full justify-between"> <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 <button
class="p-1 px-3 text-xs flex rounded transition" class="p-1 px-3 text-xs flex rounded transition"
@ -1252,51 +1379,6 @@
</div> </div>
{/if} {/if}
</form> </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>
</div> </div>
{:else if selectedTab === 'external'} {:else if selectedTab === 'external'}
@ -1472,6 +1554,150 @@
</button> </button>
</div> </div>
</form> </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'} {:else if selectedTab === 'auth'}
<form <form
class="flex flex-col h-full justify-between space-y-3 text-sm" 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=" mb-2.5 text-sm font-medium">Ollama Version</div>
<div class="flex w-full"> <div class="flex w-full">
<div class="flex-1 text-xs text-gray-700 dark:text-gray-200"> <div class="flex-1 text-xs text-gray-700 dark:text-gray-200">
{$info?.ollama?.version ?? 'N/A'} {ollamaVersion ?? 'N/A'}
</div> </div>
</div> </div>
</div> </div>

View file

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

View file

@ -6,32 +6,28 @@
import { goto, invalidateAll } from '$app/navigation'; import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores'; 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 { onMount } from 'svelte';
import { deleteChatById, getChatList, updateChatById } from '$lib/apis/chats';
let show = false; let show = false;
let navElement; let navElement;
let importFileInputElement;
let importFiles;
let title: string = 'Ollama Web UI'; let title: string = 'Ollama Web UI';
let search = ''; let search = '';
let chatDeleteId = null; let chatDeleteId = null;
let chatTitleEditId = null; let chatTitleEditId = null;
let chatTitle = ''; let chatTitle = '';
let showDropdown = false; let showDropdown = false;
let showDeleteHistoryConfirm = false;
onMount(async () => { onMount(async () => {
if (window.innerWidth > 1280) { if (window.innerWidth > 1280) {
show = true; show = true;
} }
await chats.set(await $db.getChats()); await chats.set(await getChatList(localStorage.token));
}); });
const loadChat = async (id) => { const loadChat = async (id) => {
@ -39,49 +35,27 @@
}; };
const editChatTitle = async (id, _title) => { const editChatTitle = async (id, _title) => {
await $db.updateChatById(id, { title = _title;
await updateChatById(localStorage.token, id, {
title: _title title: _title
}); });
title = _title; await chats.set(await getChatList(localStorage.token));
}; };
const deleteChat = async (id) => { const deleteChat = async (id) => {
goto('/'); goto('/');
$db.deleteChatById(id);
await deleteChatById(localStorage.token, id);
await chats.set(await getChatList(localStorage.token));
}; };
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`);
};
$: 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> </script>
<div <div
bind:this={navElement} bind:this={navElement}
class="h-screen {show 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"> <div class="py-2.5 my-auto flex flex-col justify-between h-screen">
@ -91,8 +65,11 @@
on:click={async () => { on:click={async () => {
goto('/'); goto('/');
await chatId.set(uuidv4()); const newChatButton = document.getElementById('new-chat-button');
// createNewChat();
if (newChatButton) {
newChatButton.click();
}
}} }}
> >
<div class="flex self-center"> <div class="flex self-center">
@ -121,39 +98,41 @@
</button> </button>
</div> </div>
<div class="px-2.5 flex justify-center my-1"> {#if $user?.role === 'admin'}
<button <div class="px-2.5 flex justify-center my-1">
class="flex-grow flex space-x-3 rounded-md px-3 py-2 hover:bg-gray-900 transition" <button
on:click={async () => { class="flex-grow flex space-x-3 rounded-md px-3 py-2 hover:bg-gray-900 transition"
goto('/modelfiles'); on:click={async () => {
}} goto('/modelfiles');
> }}
<div class="self-center"> >
<svg <div class="self-center">
xmlns="http://www.w3.org/2000/svg" <svg
fill="none" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" fill="none"
stroke-width="1.5" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="1.5"
class="w-4 h-4" stroke="currentColor"
> class="w-4 h-4"
<path >
stroke-linecap="round" <path
stroke-linejoin="round" stroke-linecap="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" 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> </svg>
</div>
<div class="flex self-center"> <div class="flex self-center">
<div class=" self-center font-medium text-sm">Modelfiles</div> <div class=" self-center font-medium text-sm">Modelfiles</div>
</div> </div>
</button> </button>
</div> </div>
{/if}
<div class="px-2.5 mt-1 mb-2 flex justify-center space-x-2"> <div class="px-2.5 mt-1 mb-2 flex justify-center space-x-2">
<div class="flex w-full"> <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 <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@ -169,7 +148,7 @@
</div> </div>
<input <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" placeholder="Search"
bind:value={search} bind:value={search}
/> />
@ -394,148 +373,9 @@
</div> </div>
<div class="px-2.5"> <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 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} {#if $user !== undefined}
<button <button
class=" flex rounded-md py-3 px-3.5 w-full hover:bg-gray-900 transition" class=" flex rounded-md py-3 px-3.5 w-full hover:bg-gray-900 transition"

View file

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

View file

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

View file

@ -66,9 +66,9 @@ export const getGravatarURL = (email) => {
return `https://www.gravatar.com/avatar/${hash}`; return `https://www.gravatar.com/avatar/${hash}`;
}; };
const copyToClipboard = (text) => { export const copyToClipboard = (text) => {
if (!navigator.clipboard) { if (!navigator.clipboard) {
var textArea = document.createElement('textarea'); const textArea = document.createElement('textarea');
textArea.value = text; textArea.value = text;
// Avoid scrolling to bottom // Avoid scrolling to bottom
@ -81,8 +81,8 @@ const copyToClipboard = (text) => {
textArea.select(); textArea.select();
try { try {
var successful = document.execCommand('copy'); const successful = document.execCommand('copy');
var msg = successful ? 'successful' : 'unsuccessful'; const msg = successful ? 'successful' : 'unsuccessful';
console.log('Fallback: Copying text command was ' + msg); console.log('Fallback: Copying text command was ' + msg);
} catch (err) { } catch (err) {
console.error('Fallback: Oops, unable to copy', 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;
};

View file

@ -1,246 +1,163 @@
<script lang="ts"> <script lang="ts">
import { v4 as uuidv4 } from 'uuid'; import toast from 'svelte-french-toast';
import { openDB, deleteDB } from 'idb'; import { openDB, deleteDB } from 'idb';
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { import fileSaver from 'file-saver';
config, const { saveAs } = fileSaver;
info,
user, import { getOllamaModels, getOllamaVersion } from '$lib/apis/ollama';
showSettings, import { getModelfiles } from '$lib/apis/modelfiles';
settings,
models, import { getOpenAIModels } from '$lib/apis/openai';
db,
chats, import { user, showSettings, settings, models, modelfiles } from '$lib/stores';
chatId, import { OLLAMA_API_BASE_URL, REQUIRED_OLLAMA_VERSION, WEBUI_API_BASE_URL } from '$lib/constants';
modelfiles
} from '$lib/stores';
import SettingsModal from '$lib/components/chat/SettingsModal.svelte'; import SettingsModal from '$lib/components/chat/SettingsModal.svelte';
import Sidebar from '$lib/components/layout/Sidebar.svelte'; import Sidebar from '$lib/components/layout/Sidebar.svelte';
import toast from 'svelte-french-toast'; import { checkVersion } from '$lib/utils';
import { OLLAMA_API_BASE_URL, WEBUI_API_BASE_URL } from '$lib/constants';
let requiredOllamaVersion = '0.1.16'; let ollamaVersion = '';
let loaded = false; let loaded = false;
let DB = null;
let localDBChats = [];
const getModels = async () => { const getModels = async () => {
let models = []; let models = [];
const res = await fetch(`${$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL}/tags`, { models.push(
method: 'GET', ...(await getOllamaModels(
headers: { $settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL,
Accept: 'application/json', localStorage.token
'Content-Type': 'application/json', ).catch((error) => {
...($settings.authHeader && { Authorization: $settings.authHeader }), toast.error(error);
...($user && { Authorization: `Bearer ${localStorage.token}` }) return [];
} }))
}) );
.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;
});
console.log(res);
models.push(...(res?.models ?? []));
// If OpenAI API Key exists // If OpenAI API Key exists
if ($settings.OPENAI_API_KEY) { if ($settings.OPENAI_API_KEY) {
// Validate 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);
toast.error(error);
return null;
});
const API_BASE_URL = $settings.OPENAI_API_BASE_URL ?? 'https://api.openai.com/v1'; models.push(...(openAIModels ? [{ name: 'hr' }, ...openAIModels] : []));
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
)
]
: [])
);
} }
return models; return models;
}; };
const getDB = async () => { const setOllamaVersion = async (version: string = '') => {
const DB = await openDB('Chats', 1, { if (version === '') {
upgrade(db) { version = await getOllamaVersion(
const store = db.createObjectStore('chats', { $settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL,
keyPath: 'id', localStorage.token
autoIncrement: true ).catch((error) => {
}); return '';
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;
}); });
}
console.log(res); ollamaVersion = version;
return res?.version ?? '0'; console.log(ollamaVersion);
}; if (checkVersion(REQUIRED_OLLAMA_VERSION, ollamaVersion)) {
toast.error(`Ollama Version: ${ollamaVersion !== '' ? ollamaVersion : 'Not Detected'}`);
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}`);
} }
}; };
onMount(async () => { onMount(async () => {
if ($config && $config.auth && $user === undefined) { if ($user === undefined) {
await goto('/auth'); 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]);
if (localDBChats.length === 0) {
await deleteDB('Chats');
}
console.log('localdb', localDBChats);
}
console.log(DB);
} catch (error) {
// IndexedDB Not Found
console.log('IDB Not Found');
}
console.log();
await settings.set(JSON.parse(localStorage.getItem('settings') ?? '{}'));
await modelfiles.set(await getModelfiles(localStorage.token));
console.log($modelfiles);
modelfiles.subscribe(async () => {
// should fetch models
await models.set(await getModels());
});
await setOllamaVersion();
await tick();
} }
await settings.set(JSON.parse(localStorage.getItem('settings') ?? '{}'));
await models.set(await getModels());
await modelfiles.set(JSON.parse(localStorage.getItem('modelfiles') ?? '[]'));
modelfiles.subscribe(async () => {
await models.set(await getModels());
});
let _db = await getDB();
await db.set(_db);
await setOllamaVersion(await getOllamaVersion());
await tick();
loaded = true; loaded = true;
}); });
</script> </script>
{#if loaded} {#if loaded}
<div class="app relative"> <div class="app relative">
{#if ($info?.ollama?.version ?? '0').localeCompare( requiredOllamaVersion, undefined, { numeric: true, sensitivity: 'case', caseFirst: 'upper' } ) < 0} {#if !['user', 'admin'].includes($user.role)}
<div class="absolute w-full h-full flex z-50"> <div class="fixed w-full h-full flex z-50">
<div <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">
<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 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="m-auto pb-44 flex flex-col justify-center">
<div class="max-w-md"> <div class="max-w-md">
@ -254,15 +171,16 @@
/>We've detected either a connection hiccup or observed that you're using an older />We've detected either a connection hiccup or observed that you're using an older
version. Ensure you're on the latest Ollama version version. Ensure you're on the latest Ollama version
<br class=" hidden sm:flex" />(version <br class=" hidden sm:flex" />(version
<span class=" dark:text-white font-medium">{requiredOllamaVersion} or higher</span>) <span class=" dark:text-white font-medium">{REQUIRED_OLLAMA_VERSION} or higher</span
or check your connection. >) or check your connection.
</div> </div>
<div class=" mt-6 mx-auto relative group w-fit"> <div class=" mt-6 mx-auto relative group w-fit">
<button <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 () => { on:click={async () => {
await setOllamaVersion(await getOllamaVersion()); location.href = '/';
// await setOllamaVersion();
}} }}
> >
Check Again Check Again
@ -271,7 +189,57 @@
<button <button
class="text-xs text-center w-full mt-2 text-gray-400 underline" class="text-xs text-center w-full mt-2 text-gray-400 underline"
on:click={async () => { 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 }}>Close</button
> >
</div> </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" class=" text-gray-700 dark:text-gray-100 bg-white dark:bg-gray-800 min-h-screen overflow-auto flex flex-row"
> >
<Sidebar /> <Sidebar />
<SettingsModal bind:show={$showSettings} /> <SettingsModal bind:show={$showSettings} />
<slot /> <slot />
</div> </div>
</div> </div>

View file

@ -2,23 +2,27 @@
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import toast from 'svelte-french-toast'; import toast from 'svelte-french-toast';
import { OLLAMA_API_BASE_URL } from '$lib/constants';
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import { splitStream } from '$lib/utils';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { config, models, modelfiles, user, settings, db, chats, chatId } from '$lib/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 MessageInput from '$lib/components/chat/MessageInput.svelte';
import Messages from '$lib/components/chat/Messages.svelte'; import Messages from '$lib/components/chat/Messages.svelte';
import ModelSelector from '$lib/components/chat/ModelSelector.svelte'; import ModelSelector from '$lib/components/chat/ModelSelector.svelte';
import Navbar from '$lib/components/layout/Navbar.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 stopResponseFlag = false;
let autoScroll = true; let autoScroll = true;
let selectedModels = ['']; let selectedModels = [''];
let selectedModelfile = null; let selectedModelfile = null;
$: selectedModelfile = $: selectedModelfile =
selectedModels.length === 1 && selectedModels.length === 1 &&
@ -26,10 +30,11 @@
? $modelfiles.filter((modelfile) => modelfile.tagName === selectedModels[0])[0] ? $modelfiles.filter((modelfile) => modelfile.tagName === selectedModels[0])[0]
: null; : null;
let chat = null;
let title = ''; let title = '';
let prompt = ''; let prompt = '';
let files = []; let files = [];
let messages = []; let messages = [];
let history = { let history = {
messages: {}, messages: {},
@ -50,16 +55,8 @@
messages = []; messages = [];
} }
$: if (files) {
console.log(files);
}
onMount(async () => { onMount(async () => {
await chatId.set(uuidv4()); await initNewChat();
chatId.subscribe(async () => {
await initNewChat();
});
}); });
////////////////////////// //////////////////////////
@ -67,6 +64,11 @@
////////////////////////// //////////////////////////
const initNewChat = async () => { const initNewChat = async () => {
window.history.replaceState(history.state, '', `/`);
console.log('initNewChat');
await chatId.set('');
console.log($chatId); console.log($chatId);
autoScroll = true; autoScroll = true;
@ -82,68 +84,33 @@
: $settings.models ?? ['']; : $settings.models ?? [''];
let _settings = JSON.parse(localStorage.getItem('settings') ?? '{}'); let _settings = JSON.parse(localStorage.getItem('settings') ?? '{}');
console.log(_settings);
settings.set({ settings.set({
..._settings ..._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 // Ollama functions
////////////////////////// //////////////////////////
const sendPrompt = async (userPrompt, parentId, _chatId) => { const sendPrompt = async (prompt, parentId) => {
const _chatId = JSON.parse(JSON.stringify($chatId));
await Promise.all( await Promise.all(
selectedModels.map(async (model) => { selectedModels.map(async (model) => {
console.log(model); console.log(model);
if ($models.filter((m) => m.name === model)[0].external) { if ($models.filter((m) => m.name === model)[0].external) {
await sendPromptOpenAI(model, userPrompt, parentId, _chatId); await sendPromptOpenAI(model, prompt, parentId, _chatId);
} else { } 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) => { const sendPromptOllama = async (model, userPrompt, parentId, _chatId) => {
console.log('sendPromptOllama'); // Create response message
let responseMessageId = uuidv4(); let responseMessageId = uuidv4();
let responseMessage = { let responseMessage = {
parentId: parentId, parentId: parentId,
@ -154,8 +121,11 @@
model: model model: model
}; };
// Add message to history and Set currentId to messageId
history.messages[responseMessageId] = responseMessage; history.messages[responseMessageId] = responseMessage;
history.currentId = responseMessageId; history.currentId = responseMessageId;
// Append messageId to childrenIds of parent message
if (parentId !== null) { if (parentId !== null) {
history.messages[parentId].childrenIds = [ history.messages[parentId].childrenIds = [
...history.messages[parentId].childrenIds, ...history.messages[parentId].childrenIds,
@ -163,17 +133,16 @@
]; ];
} }
// Wait until history/message have been updated
await tick(); await tick();
// Scroll down
window.scrollTo({ top: document.body.scrollHeight }); window.scrollTo({ top: document.body.scrollHeight });
const res = await fetch(`${$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL}/chat`, { const res = await generateChatCompletion(
method: 'POST', $settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL,
headers: { localStorage.token,
'Content-Type': 'text/event-stream', {
...($settings.authHeader && { Authorization: $settings.authHeader }),
...($user && { Authorization: `Bearer ${localStorage.token}` })
},
body: JSON.stringify({
model: model, model: model,
messages: [ messages: [
$settings.system $settings.system
@ -195,20 +164,11 @@
}) })
})), })),
options: { 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 ?? {}) ...($settings.options ?? {})
}, },
format: $settings.requestFormat ?? undefined format: $settings.requestFormat ?? undefined
}) }
}).catch((err) => { );
console.log(err);
return null;
});
if (res && res.ok) { if (res && res.ok) {
const reader = res.body const reader = res.body
@ -297,23 +257,14 @@
if (autoScroll) { if (autoScroll) {
window.scrollTo({ top: document.body.scrollHeight }); window.scrollTo({ top: document.body.scrollHeight });
} }
}
await $db.updateChatById(_chatId, { if ($chatId == _chatId) {
title: title === '' ? 'New Chat' : title, chat = await updateChatById(localStorage.token, _chatId, {
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, messages: messages,
history: history history: history
}); });
await chats.set(await getChatList(localStorage.token));
} }
} else { } else {
if (res !== null) { if (res !== null) {
@ -339,6 +290,7 @@
stopResponseFlag = false; stopResponseFlag = false;
await tick(); await tick();
if (autoScroll) { if (autoScroll) {
window.scrollTo({ top: document.body.scrollHeight }); window.scrollTo({ top: document.body.scrollHeight });
} }
@ -481,23 +433,14 @@
if (autoScroll) { if (autoScroll) {
window.scrollTo({ top: document.body.scrollHeight }); window.scrollTo({ top: document.body.scrollHeight });
} }
}
await $db.updateChatById(_chatId, { if ($chatId == _chatId) {
title: title === '' ? 'New Chat' : title, chat = await updateChatById(localStorage.token, _chatId, {
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, messages: messages,
history: history history: history
}); });
await chats.set(await getChatList(localStorage.token));
} }
} else { } else {
if (res !== null) { if (res !== null) {
@ -542,16 +485,18 @@
}; };
const submitPrompt = async (userPrompt) => { const submitPrompt = async (userPrompt) => {
const _chatId = JSON.parse(JSON.stringify($chatId)); console.log('submitPrompt', $chatId);
console.log('submitPrompt', _chatId);
if (selectedModels.includes('')) { if (selectedModels.includes('')) {
toast.error('Model not selected'); toast.error('Model not selected');
} else if (messages.length != 0 && messages.at(-1).done != true) { } else if (messages.length != 0 && messages.at(-1).done != true) {
// Response not done
console.log('wait'); console.log('wait');
} else { } else {
// Reset chat message textarea height
document.getElementById('chat-textarea').style.height = ''; document.getElementById('chat-textarea').style.height = '';
// Create user message
let userMessageId = uuidv4(); let userMessageId = uuidv4();
let userMessage = { let userMessage = {
id: userMessageId, id: userMessageId,
@ -562,42 +507,43 @@
files: files.length > 0 ? files : undefined 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) { if (messages.length !== 0) {
history.messages[messages.at(-1).id].childrenIds.push(userMessageId); history.messages[messages.at(-1).id].childrenIds.push(userMessageId);
} }
history.messages[userMessageId] = userMessage; // Wait until history/message have been updated
history.currentId = userMessageId;
await tick(); await tick();
// Create new chat if only one message in messages
if (messages.length == 1) { if (messages.length == 1) {
await $db.createNewChat({ chat = await createNewChat(localStorage.token, {
id: _chatId, id: $chatId,
title: 'New Chat', title: 'New Chat',
models: selectedModels, models: selectedModels,
system: $settings.system ?? undefined, system: $settings.system ?? undefined,
options: { 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 ?? {}) ...($settings.options ?? {})
}, },
messages: messages, 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 = ''; prompt = '';
files = []; files = [];
setTimeout(() => { // Send prompt
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); await sendPrompt(userPrompt, userMessageId);
}, 50);
await sendPrompt(userPrompt, userMessageId, _chatId);
} }
}; };
@ -607,9 +553,7 @@
}; };
const regenerateResponse = async () => { const regenerateResponse = async () => {
const _chatId = JSON.parse(JSON.stringify($chatId)); console.log('regenerateResponse');
console.log('regenerateResponse', _chatId);
if (messages.length != 0 && messages.at(-1).done == true) { if (messages.length != 0 && messages.at(-1).done == true) {
messages.splice(messages.length - 1, 1); messages.splice(messages.length - 1, 1);
messages = messages; messages = messages;
@ -617,41 +561,21 @@
let userMessage = messages.at(-1); let userMessage = messages.at(-1);
let userPrompt = userMessage.content; let userPrompt = userMessage.content;
await sendPrompt(userPrompt, userMessage.id, _chatId); await sendPrompt(userPrompt, userMessage.id);
} }
}; };
const generateChatTitle = async (_chatId, userPrompt) => { const generateChatTitle = async (_chatId, userPrompt) => {
if ($settings.titleAutoGenerate ?? true) { if ($settings.titleAutoGenerate ?? true) {
console.log('generateChatTitle'); const title = await generateTitle(
$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL,
localStorage.token,
selectedModels[0],
userPrompt
);
const res = await fetch(`${$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL}/generate`, { if (title) {
method: 'POST', await setChatTitle(_chatId, title);
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);
} }
} else { } else {
await setChatTitle(_chatId, `${userPrompt}`); await setChatTitle(_chatId, `${userPrompt}`);
@ -659,10 +583,12 @@
}; };
const setChatTitle = async (_chatId, _title) => { const setChatTitle = async (_chatId, _title) => {
await $db.updateChatById(_chatId, { title: _title });
if (_chatId === $chatId) { if (_chatId === $chatId) {
title = _title; title = _title;
} }
chat = await updateChatById(localStorage.token, _chatId, { title: _title });
await chats.set(await getChatList(localStorage.token));
}; };
</script> </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="min-h-screen w-full flex justify-center">
<div class=" py-2.5 flex flex-col justify-between w-full"> <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"> <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"> <div class=" h-full mt-10 mb-32 w-full flex flex-col">
<Messages <Messages
chatId={$chatId}
{selectedModels} {selectedModels}
{selectedModelfile} {selectedModelfile}
bind:history bind:history

View file

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

View file

@ -2,17 +2,21 @@
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import toast from 'svelte-french-toast'; import toast from 'svelte-french-toast';
import { OLLAMA_API_BASE_URL } from '$lib/constants';
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import { convertMessagesToHistory, splitStream } from '$lib/utils';
import { goto } from '$app/navigation'; 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 MessageInput from '$lib/components/chat/MessageInput.svelte';
import Messages from '$lib/components/chat/Messages.svelte'; import Messages from '$lib/components/chat/Messages.svelte';
import ModelSelector from '$lib/components/chat/ModelSelector.svelte'; import ModelSelector from '$lib/components/chat/ModelSelector.svelte';
import Navbar from '$lib/components/layout/Navbar.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 loaded = false;
let stopResponseFlag = false; let stopResponseFlag = false;
@ -27,6 +31,8 @@
? $modelfiles.filter((modelfile) => modelfile.tagName === selectedModels[0])[0] ? $modelfiles.filter((modelfile) => modelfile.tagName === selectedModels[0])[0]
: null; : null;
let chat = null;
let title = ''; let title = '';
let prompt = ''; let prompt = '';
let files = []; let files = [];
@ -53,10 +59,8 @@
$: if ($page.params.id) { $: if ($page.params.id) {
(async () => { (async () => {
let chat = await loadChat(); if (await loadChat()) {
await tick();
await tick();
if (chat) {
loaded = true; loaded = true;
} else { } else {
await goto('/'); await goto('/');
@ -70,94 +74,70 @@
const loadChat = async () => { const loadChat = async () => {
await chatId.set($page.params.id); 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) { if (chat) {
console.log(chat); const chatContent = chat.chat;
selectedModels = (chat?.models ?? undefined) !== undefined ? chat.models : [chat.model ?? '']; if (chatContent) {
history = console.log(chatContent);
(chat?.history ?? undefined) !== undefined
? chat.history
: convertMessagesToHistory(chat.messages);
title = chat.title;
let _settings = JSON.parse(localStorage.getItem('settings') ?? '{}'); selectedModels =
await settings.set({ (chatContent?.models ?? undefined) !== undefined
..._settings, ? chatContent.models
system: chat.system ?? _settings.system, : [chatContent.model ?? ''];
options: chat.options ?? _settings.options history =
}); (chatContent?.history ?? undefined) !== undefined
autoScroll = true; ? chatContent.history
: convertMessagesToHistory(chatContent.messages);
title = chatContent.title;
await tick(); let _settings = JSON.parse(localStorage.getItem('settings') ?? '{}');
if (messages.length > 0) { await settings.set({
history.messages[messages.at(-1).id].done = true; ..._settings,
system: chatContent.system ?? _settings.system,
options: chatContent.options ?? _settings.options
});
autoScroll = true;
await tick();
if (messages.length > 0) {
history.messages[messages.at(-1).id].done = true;
}
await tick();
return true;
} else {
return null;
} }
await tick();
return chat;
} else {
return null;
} }
}; };
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 // Ollama functions
////////////////////////// //////////////////////////
const sendPrompt = async (userPrompt, parentId, _chatId) => { const sendPrompt = async (prompt, parentId) => {
const _chatId = JSON.parse(JSON.stringify($chatId));
await Promise.all( await Promise.all(
selectedModels.map(async (model) => { selectedModels.map(async (model) => {
console.log(model); console.log(model);
if ($models.filter((m) => m.name === model)[0].external) { if ($models.filter((m) => m.name === model)[0].external) {
await sendPromptOpenAI(model, userPrompt, parentId, _chatId); await sendPromptOpenAI(model, prompt, parentId, _chatId);
} else { } 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) => { const sendPromptOllama = async (model, userPrompt, parentId, _chatId) => {
console.log('sendPromptOllama'); // Create response message
let responseMessageId = uuidv4(); let responseMessageId = uuidv4();
let responseMessage = { let responseMessage = {
parentId: parentId, parentId: parentId,
@ -168,8 +148,11 @@
model: model model: model
}; };
// Add message to history and Set currentId to messageId
history.messages[responseMessageId] = responseMessage; history.messages[responseMessageId] = responseMessage;
history.currentId = responseMessageId; history.currentId = responseMessageId;
// Append messageId to childrenIds of parent message
if (parentId !== null) { if (parentId !== null) {
history.messages[parentId].childrenIds = [ history.messages[parentId].childrenIds = [
...history.messages[parentId].childrenIds, ...history.messages[parentId].childrenIds,
@ -177,17 +160,16 @@
]; ];
} }
// Wait until history/message have been updated
await tick(); await tick();
// Scroll down
window.scrollTo({ top: document.body.scrollHeight }); window.scrollTo({ top: document.body.scrollHeight });
const res = await fetch(`${$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL}/chat`, { const res = await generateChatCompletion(
method: 'POST', $settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL,
headers: { localStorage.token,
'Content-Type': 'text/event-stream', {
...($settings.authHeader && { Authorization: $settings.authHeader }),
...($user && { Authorization: `Bearer ${localStorage.token}` })
},
body: JSON.stringify({
model: model, model: model,
messages: [ messages: [
$settings.system $settings.system
@ -209,20 +191,11 @@
}) })
})), })),
options: { 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 ?? {}) ...($settings.options ?? {})
}, },
format: $settings.requestFormat ?? undefined format: $settings.requestFormat ?? undefined
}) }
}).catch((err) => { );
console.log(err);
return null;
});
if (res && res.ok) { if (res && res.ok) {
const reader = res.body const reader = res.body
@ -311,23 +284,14 @@
if (autoScroll) { if (autoScroll) {
window.scrollTo({ top: document.body.scrollHeight }); window.scrollTo({ top: document.body.scrollHeight });
} }
}
await $db.updateChatById(_chatId, { if ($chatId == _chatId) {
title: title === '' ? 'New Chat' : title, chat = await updateChatById(localStorage.token, _chatId, {
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, messages: messages,
history: history history: history
}); });
await chats.set(await getChatList(localStorage.token));
} }
} else { } else {
if (res !== null) { if (res !== null) {
@ -353,6 +317,7 @@
stopResponseFlag = false; stopResponseFlag = false;
await tick(); await tick();
if (autoScroll) { if (autoScroll) {
window.scrollTo({ top: document.body.scrollHeight }); window.scrollTo({ top: document.body.scrollHeight });
} }
@ -495,23 +460,14 @@
if (autoScroll) { if (autoScroll) {
window.scrollTo({ top: document.body.scrollHeight }); window.scrollTo({ top: document.body.scrollHeight });
} }
}
await $db.updateChatById(_chatId, { if ($chatId == _chatId) {
title: title === '' ? 'New Chat' : title, chat = await updateChatById(localStorage.token, _chatId, {
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, messages: messages,
history: history history: history
}); });
await chats.set(await getChatList(localStorage.token));
} }
} else { } else {
if (res !== null) { if (res !== null) {
@ -556,16 +512,18 @@
}; };
const submitPrompt = async (userPrompt) => { const submitPrompt = async (userPrompt) => {
const _chatId = JSON.parse(JSON.stringify($chatId)); console.log('submitPrompt', $chatId);
console.log('submitPrompt', _chatId);
if (selectedModels.includes('')) { if (selectedModels.includes('')) {
toast.error('Model not selected'); toast.error('Model not selected');
} else if (messages.length != 0 && messages.at(-1).done != true) { } else if (messages.length != 0 && messages.at(-1).done != true) {
// Response not done
console.log('wait'); console.log('wait');
} else { } else {
// Reset chat message textarea height
document.getElementById('chat-textarea').style.height = ''; document.getElementById('chat-textarea').style.height = '';
// Create user message
let userMessageId = uuidv4(); let userMessageId = uuidv4();
let userMessage = { let userMessage = {
id: userMessageId, id: userMessageId,
@ -576,42 +534,43 @@
files: files.length > 0 ? files : undefined 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) { if (messages.length !== 0) {
history.messages[messages.at(-1).id].childrenIds.push(userMessageId); history.messages[messages.at(-1).id].childrenIds.push(userMessageId);
} }
history.messages[userMessageId] = userMessage; // Wait until history/message have been updated
history.currentId = userMessageId;
await tick(); await tick();
// Create new chat if only one message in messages
if (messages.length == 1) { if (messages.length == 1) {
await $db.createNewChat({ chat = await createNewChat(localStorage.token, {
id: _chatId, id: $chatId,
title: 'New Chat', title: 'New Chat',
models: selectedModels, models: selectedModels,
system: $settings.system ?? undefined, system: $settings.system ?? undefined,
options: { 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 ?? {}) ...($settings.options ?? {})
}, },
messages: messages, 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 = ''; prompt = '';
files = []; files = [];
setTimeout(() => { // Send prompt
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); await sendPrompt(userPrompt, userMessageId);
}, 50);
await sendPrompt(userPrompt, userMessageId, _chatId);
} }
}; };
@ -621,9 +580,7 @@
}; };
const regenerateResponse = async () => { const regenerateResponse = async () => {
const _chatId = JSON.parse(JSON.stringify($chatId)); console.log('regenerateResponse');
console.log('regenerateResponse', _chatId);
if (messages.length != 0 && messages.at(-1).done == true) { if (messages.length != 0 && messages.at(-1).done == true) {
messages.splice(messages.length - 1, 1); messages.splice(messages.length - 1, 1);
messages = messages; messages = messages;
@ -631,41 +588,21 @@
let userMessage = messages.at(-1); let userMessage = messages.at(-1);
let userPrompt = userMessage.content; let userPrompt = userMessage.content;
await sendPrompt(userPrompt, userMessage.id, _chatId); await sendPrompt(userPrompt, userMessage.id);
} }
}; };
const generateChatTitle = async (_chatId, userPrompt) => { const generateChatTitle = async (_chatId, userPrompt) => {
if ($settings.titleAutoGenerate ?? true) { if ($settings.titleAutoGenerate ?? true) {
console.log('generateChatTitle'); const title = await generateTitle(
$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL,
localStorage.token,
selectedModels[0],
userPrompt
);
const res = await fetch(`${$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL}/generate`, { if (title) {
method: 'POST', await setChatTitle(_chatId, title);
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);
} }
} else { } else {
await setChatTitle(_chatId, `${userPrompt}`); await setChatTitle(_chatId, `${userPrompt}`);
@ -673,10 +610,12 @@
}; };
const setChatTitle = async (_chatId, _title) => { const setChatTitle = async (_chatId, _title) => {
await $db.updateChatById(_chatId, { title: _title });
if (_chatId === $chatId) { if (_chatId === $chatId) {
title = _title; title = _title;
} }
chat = await updateChatById(localStorage.token, _chatId, { title: _title });
await chats.set(await getChatList(localStorage.token));
}; };
</script> </script>
@ -687,7 +626,13 @@
/> />
{#if loaded} {#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="min-h-screen w-full flex justify-center">
<div class=" py-2.5 flex flex-col justify-between w-full"> <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"> <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"> <div class=" h-full mt-10 mb-32 w-full flex flex-col">
<Messages <Messages
chatId={$chatId}
{selectedModels} {selectedModels}
{selectedModelfile} {selectedModelfile}
bind:history bind:history

View file

@ -1,46 +1,41 @@
<script lang="ts"> <script lang="ts">
import { modelfiles, settings, user } from '$lib/stores';
import { onMount } from 'svelte';
import toast from 'svelte-french-toast'; 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 { 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) => { const deleteModelHandler = async (tagName) => {
let success = null; let success = null;
const res = await fetch(`${$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL}/delete`, {
method: 'DELETE', success = await deleteModel(
headers: { $settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL,
'Content-Type': 'text/event-stream', localStorage.token,
...($settings.authHeader && { Authorization: $settings.authHeader }), tagName
...($user && { Authorization: `Bearer ${localStorage.token}` }) );
},
body: JSON.stringify({ if (success) {
name: tagName toast.success(`Deleted ${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;
});
return success; return success;
}; };
const deleteModelfilebyTagName = async (tagName) => { const deleteModelfile = async (tagName) => {
await deleteModelHandler(tagName); await deleteModelHandler(tagName);
await modelfiles.set($modelfiles.filter((modelfile) => modelfile.tagName != tagName)); await deleteModelfileByTagName(localStorage.token, tagName);
localStorage.setItem('modelfiles', JSON.stringify($modelfiles)); await modelfiles.set(await getModelfiles(localStorage.token));
}; };
const shareModelfile = async (modelfile) => { const shareModelfile = async (modelfile) => {
@ -60,6 +55,21 @@
false 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> </script>
<div class="min-h-screen w-full flex justify-center dark:text-white"> <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" class="self-center w-fit text-sm px-2 py-2 border dark:border-gray-600 rounded-xl"
type="button" type="button"
on:click={() => { on:click={() => {
deleteModelfilebyTagName(modelfile.tagName); deleteModelfile(modelfile.tagName);
}} }}
> >
<svg <svg
@ -189,6 +199,79 @@
</div> </div>
{/each} {/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=" my-16">
<div class=" text-2xl font-semibold mb-6">Made by OllamaHub Community</div> <div class=" text-2xl font-semibold mb-6">Made by OllamaHub Community</div>

View file

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

View file

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

View file

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

View file

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

View file

@ -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}