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

View file

@ -1,22 +1,22 @@
# 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
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:
**1. Verify Ollama Server Configuration**
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**
**1. Check Ollama URL Format**
Ensure that the Ollama URL is correctly formatted in the application settings. Follow these steps:
@ -28,33 +28,3 @@ Ensure that the Ollama URL is correctly formatted in the application settings. F
It is crucial to include the `/api` at the end of the URL to ensure that the Ollama Web UI can communicate with the server.
By following these troubleshooting steps, you should be able to identify and resolve connection issues with your Ollama server configuration. If you require further assistance or have additional questions, please don't hesitate to reach out or refer to our documentation for comprehensive guidance.
## Running ollama-webui as a container on Apple Silicon Mac
If you are running Docker on a M{1..3} based Mac and have taken the steps to run an x86 container, add "--platform linux/amd64" to the docker run command to prevent a warning.
Example:
```bash
docker run -d -p 3000:8080 -e OLLAMA_API_BASE_URL=http://example.com:11434/api --name ollama-webui --restart always ghcr.io/ollama-webui/ollama-webui:main
```
Becomes
```
docker run --platform linux/amd64 -d -p 3000:8080 -e OLLAMA_API_BASE_URL=http://example.com:11434/api --name ollama-webui --restart always ghcr.io/ollama-webui/ollama-webui:main
```
## Running ollama-webui as a container on WSL Ubuntu
If you're running ollama-webui via docker on WSL Ubuntu and have chosen to install webui and ollama separately, you might encounter connection issues. This is often due to the docker container being unable to reach the Ollama server at 127.0.0.1:11434. To resolve this, you can use the `--network=host` flag in the docker command. When done so port would be changed from 3000 to 8080, and the link would be: http://localhost:8080.
Here's an example of the command you should run:
```bash
docker run -d --network=host -e OLLAMA_API_BASE_URL=http://127.0.0.1:11434/api --name ollama-webui --restart always ghcr.io/ollama-webui/ollama-webui:main
```
## References
[Change Docker Desktop Settings on Mac](https://docs.docker.com/desktop/settings/mac/) Search for "x86" in that page.
[Run x86 (Intel) and ARM based images on Apple Silicon (M1) Macs?](https://forums.docker.com/t/run-x86-intel-and-arm-based-images-on-apple-silicon-m1-macs/117123)

7
backend/.dockerignore Normal file
View file

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

3
backend/.gitignore vendored
View file

@ -2,3 +2,6 @@ __pycache__
.env
_old
uploads
.ipynb_checkpoints
*.db
_test

View file

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

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

View file

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

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

View file

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

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 pymongo import MongoClient
from constants import ERROR_MESSAGES
from secrets import token_bytes
from base64 import b64encode
import os
load_dotenv(find_dotenv("../.env"))
@ -30,30 +31,13 @@ if ENV == "prod":
# WEBUI_VERSION
####################################
WEBUI_VERSION = os.environ.get("WEBUI_VERSION", "v1.0.0-alpha.40")
WEBUI_VERSION = os.environ.get("WEBUI_VERSION", "v1.0.0-alpha.42")
####################################
# WEBUI_AUTH
# WEBUI_AUTH (Required for security)
####################################
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_AUTH = True
####################################
# 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 ''}"
ENV_VAR_NOT_FOUND = "Required environment variable not found. Terminating now."
CREATE_USER_ERROR = "Oops! Something went wrong while creating your account. Please try again later. If the issue persists, contact support for assistance."
EMAIL_TAKEN = "Uh-oh! This email is already registered. Sign in with your existing account or choose another email to start anew."
USERNAME_TAKEN = (
"Uh-oh! This username is already registered. Please choose another username."
)
INVALID_TOKEN = (
"Your session has expired or the token is invalid. Please sign in again."
)
@ -20,5 +25,6 @@ class ERROR_MESSAGES(str, Enum):
ACTION_PROHIBITED = (
"The requested action has been restricted as a security measure."
)
NOT_FOUND = "We could not find what you're looking for :/"
USER_NOT_FOUND = "We could not find what you're looking for :/"
MALICIOUS = "Unusual activities detected, please try again in a few minutes."

1
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,7 +13,7 @@ uuid
requests
aiohttp
pymongo
peewee
bcrypt
PyJWT

View file

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

2
run.sh
View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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}