forked from open-webui/open-webui
		
	Merge pull request #123 from ollama-webui/dev
feat: ui fix/improvements/refac
This commit is contained in:
		
						commit
						897547274e
					
				
					 32 changed files with 4180 additions and 2329 deletions
				
			
		|  | @ -22,6 +22,9 @@ ARG OLLAMA_API_BASE_URL='/ollama/api' | |||
| 
 | ||||
| ENV ENV=prod | ||||
| ENV OLLAMA_API_BASE_URL $OLLAMA_API_BASE_URL | ||||
| ENV WEBUI_AUTH "" | ||||
| ENV WEBUI_DB_URL "" | ||||
| ENV WEBUI_JWT_SECRET_KEY "SECRET_KEY" | ||||
| 
 | ||||
| WORKDIR /app | ||||
| COPY --from=build /app/build /app/build | ||||
|  |  | |||
|  | @ -1,4 +1,4 @@ | |||
| from flask import Flask, request, Response | ||||
| from flask import Flask, request, Response, jsonify | ||||
| from flask_cors import CORS | ||||
| 
 | ||||
| 
 | ||||
|  | @ -6,7 +6,10 @@ import requests | |||
| import json | ||||
| 
 | ||||
| 
 | ||||
| from config import OLLAMA_API_BASE_URL | ||||
| from apps.web.models.users import Users | ||||
| from constants import ERROR_MESSAGES | ||||
| from utils.utils import extract_token_from_auth_header | ||||
| from config import OLLAMA_API_BASE_URL, WEBUI_AUTH | ||||
| 
 | ||||
| app = Flask(__name__) | ||||
| CORS( | ||||
|  | @ -22,12 +25,40 @@ 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(target_url) | ||||
|     print(path) | ||||
| 
 | ||||
|     # Get data from the original request | ||||
|     data = request.get_data() | ||||
|     headers = dict(request.headers) | ||||
| 
 | ||||
|     # Basic RBAC support | ||||
|     if WEBUI_AUTH: | ||||
|         if "Authorization" in headers: | ||||
|             token = extract_token_from_auth_header(headers["Authorization"]) | ||||
|             user = Users.get_user_by_token(token) | ||||
|             if user: | ||||
|                 # Only user and admin roles can access | ||||
|                 if user.role in ["user", "admin"]: | ||||
|                     if path in ["pull", "delete", "push", "copy", "create"]: | ||||
|                         # Only admin role can perform actions above | ||||
|                         if user.role == "admin": | ||||
|                             pass | ||||
|                         else: | ||||
|                             return ( | ||||
|                                 jsonify({"detail": ERROR_MESSAGES.ACCESS_PROHIBITED}), | ||||
|                                 401, | ||||
|                             ) | ||||
|                     else: | ||||
|                         pass | ||||
|                 else: | ||||
|                     return jsonify({"detail": ERROR_MESSAGES.ACCESS_PROHIBITED}), 401 | ||||
|             else: | ||||
|                 return jsonify({"detail": ERROR_MESSAGES.UNAUTHORIZED}), 401 | ||||
|         else: | ||||
|             return jsonify({"detail": ERROR_MESSAGES.UNAUTHORIZED}), 401 | ||||
|     else: | ||||
|         pass | ||||
| 
 | ||||
|     # Make a request to the target server | ||||
|     target_response = requests.request( | ||||
|         method=request.method, | ||||
|  |  | |||
							
								
								
									
										26
									
								
								backend/apps/web/main.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								backend/apps/web/main.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,26 @@ | |||
| from fastapi import FastAPI, Request, Depends, HTTPException | ||||
| from fastapi.middleware.cors import CORSMiddleware | ||||
| 
 | ||||
| from apps.web.routers import auths, users | ||||
| from config import WEBUI_VERSION, WEBUI_AUTH | ||||
| 
 | ||||
| app = FastAPI() | ||||
| 
 | ||||
| origins = ["*"] | ||||
| 
 | ||||
| app.add_middleware( | ||||
|     CORSMiddleware, | ||||
|     allow_origins=origins, | ||||
|     allow_credentials=True, | ||||
|     allow_methods=["*"], | ||||
|     allow_headers=["*"], | ||||
| ) | ||||
| 
 | ||||
| 
 | ||||
| app.include_router(auths.router, prefix="/auths", tags=["auths"]) | ||||
| app.include_router(users.router, prefix="/users", tags=["users"]) | ||||
| 
 | ||||
| 
 | ||||
| @app.get("/") | ||||
| async def get_status(): | ||||
|     return {"status": True, "version": WEBUI_VERSION, "auth": WEBUI_AUTH} | ||||
							
								
								
									
										103
									
								
								backend/apps/web/models/auths.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								backend/apps/web/models/auths.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,103 @@ | |||
| from pydantic import BaseModel | ||||
| from typing import List, Union, Optional | ||||
| import time | ||||
| import uuid | ||||
| 
 | ||||
| 
 | ||||
| from apps.web.models.users import UserModel, Users | ||||
| from utils.utils import ( | ||||
|     verify_password, | ||||
|     get_password_hash, | ||||
|     bearer_scheme, | ||||
|     create_token, | ||||
| ) | ||||
| 
 | ||||
| import config | ||||
| 
 | ||||
| DB = config.DB | ||||
| 
 | ||||
| #################### | ||||
| # DB MODEL | ||||
| #################### | ||||
| 
 | ||||
| 
 | ||||
| class AuthModel(BaseModel): | ||||
|     id: str | ||||
|     email: str | ||||
|     password: str | ||||
|     active: bool = True | ||||
| 
 | ||||
| 
 | ||||
| #################### | ||||
| # Forms | ||||
| #################### | ||||
| 
 | ||||
| 
 | ||||
| class Token(BaseModel): | ||||
|     token: str | ||||
|     token_type: str | ||||
| 
 | ||||
| 
 | ||||
| class UserResponse(BaseModel): | ||||
|     id: str | ||||
|     email: str | ||||
|     name: str | ||||
|     role: str | ||||
|     profile_image_url: str | ||||
| 
 | ||||
| 
 | ||||
| class SigninResponse(Token, UserResponse): | ||||
|     pass | ||||
| 
 | ||||
| 
 | ||||
| class SigninForm(BaseModel): | ||||
|     email: str | ||||
|     password: str | ||||
| 
 | ||||
| 
 | ||||
| class SignupForm(BaseModel): | ||||
|     name: str | ||||
|     email: str | ||||
|     password: str | ||||
| 
 | ||||
| 
 | ||||
| class AuthsTable: | ||||
|     def __init__(self, db): | ||||
|         self.db = db | ||||
|         self.table = db.auths | ||||
| 
 | ||||
|     def insert_new_auth( | ||||
|         self, email: str, password: str, name: str, role: str = "pending" | ||||
|     ) -> Optional[UserModel]: | ||||
|         print("insert_new_auth") | ||||
| 
 | ||||
|         id = str(uuid.uuid4()) | ||||
| 
 | ||||
|         auth = AuthModel( | ||||
|             **{"id": id, "email": email, "password": password, "active": True} | ||||
|         ) | ||||
|         result = self.table.insert_one(auth.model_dump()) | ||||
|         user = Users.insert_new_user(id, name, email, role) | ||||
| 
 | ||||
|         print(result, user) | ||||
|         if result and user: | ||||
|             return user | ||||
|         else: | ||||
|             return None | ||||
| 
 | ||||
|     def authenticate_user(self, email: str, password: str) -> Optional[UserModel]: | ||||
|         print("authenticate_user") | ||||
| 
 | ||||
|         auth = self.table.find_one({"email": email, "active": True}) | ||||
| 
 | ||||
|         if auth: | ||||
|             if verify_password(password, auth["password"]): | ||||
|                 user = self.db.users.find_one({"id": auth["id"]}) | ||||
|                 return UserModel(**user) | ||||
|             else: | ||||
|                 return None | ||||
|         else: | ||||
|             return None | ||||
| 
 | ||||
| 
 | ||||
| Auths = AuthsTable(DB) | ||||
							
								
								
									
										97
									
								
								backend/apps/web/models/users.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								backend/apps/web/models/users.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,97 @@ | |||
| from pydantic import BaseModel | ||||
| 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 | ||||
| 
 | ||||
| #################### | ||||
| # User DB Schema | ||||
| #################### | ||||
| 
 | ||||
| 
 | ||||
| class UserModel(BaseModel): | ||||
|     id: str | ||||
|     name: str | ||||
|     email: str | ||||
|     role: str = "pending" | ||||
|     profile_image_url: str = "/user.png" | ||||
|     created_at: int  # timestamp in epoch | ||||
| 
 | ||||
| 
 | ||||
| #################### | ||||
| # Forms | ||||
| #################### | ||||
| 
 | ||||
| 
 | ||||
| class UserRoleUpdateForm(BaseModel): | ||||
|     id: str | ||||
|     role: str | ||||
| 
 | ||||
| 
 | ||||
| class UsersTable: | ||||
|     def __init__(self, db): | ||||
|         self.db = db | ||||
|         self.table = db.users | ||||
| 
 | ||||
|     def insert_new_user( | ||||
|         self, id: str, name: str, email: str, role: str = "pending" | ||||
|     ) -> Optional[UserModel]: | ||||
|         user = UserModel( | ||||
|             **{ | ||||
|                 "id": id, | ||||
|                 "name": name, | ||||
|                 "email": email, | ||||
|                 "role": role, | ||||
|                 "profile_image_url": get_gravatar_url(email), | ||||
|                 "created_at": int(time.time()), | ||||
|             } | ||||
|         ) | ||||
|         result = self.table.insert_one(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}) | ||||
| 
 | ||||
|         if user: | ||||
|             return UserModel(**user) | ||||
|         else: | ||||
|             return None | ||||
| 
 | ||||
|     def get_user_by_token(self, token: str) -> Optional[UserModel]: | ||||
|         data = decode_token(token) | ||||
| 
 | ||||
|         if data != None and "email" in data: | ||||
|             return self.get_user_by_email(data["email"]) | ||||
|         else: | ||||
|             return None | ||||
| 
 | ||||
|     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) | ||||
|             ) | ||||
|         ] | ||||
| 
 | ||||
|     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) | ||||
| 
 | ||||
|     def update_user_role_by_id(self, id: str, role: str) -> Optional[UserModel]: | ||||
|         return self.update_user_by_id(id, {"role": role}) | ||||
| 
 | ||||
| 
 | ||||
| Users = UsersTable(DB) | ||||
							
								
								
									
										111
									
								
								backend/apps/web/routers/auths.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								backend/apps/web/routers/auths.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,111 @@ | |||
| from fastapi import Response | ||||
| from fastapi import Depends, FastAPI, HTTPException, status | ||||
| from datetime import datetime, timedelta | ||||
| from typing import List, Union | ||||
| 
 | ||||
| from fastapi import APIRouter | ||||
| from pydantic import BaseModel | ||||
| import time | ||||
| import uuid | ||||
| 
 | ||||
| from apps.web.models.auths import ( | ||||
|     SigninForm, | ||||
|     SignupForm, | ||||
|     UserResponse, | ||||
|     SigninResponse, | ||||
|     Auths, | ||||
| ) | ||||
| from apps.web.models.users import Users | ||||
| 
 | ||||
| 
 | ||||
| from utils.utils import ( | ||||
|     get_password_hash, | ||||
|     bearer_scheme, | ||||
|     create_token, | ||||
| ) | ||||
| from utils.misc import get_gravatar_url | ||||
| from constants import ERROR_MESSAGES | ||||
| 
 | ||||
| 
 | ||||
| router = APIRouter() | ||||
| 
 | ||||
| ############################ | ||||
| # GetSessionUser | ||||
| ############################ | ||||
| 
 | ||||
| 
 | ||||
| @router.get("/", response_model=UserResponse) | ||||
| async def get_session_user(cred=Depends(bearer_scheme)): | ||||
|     token = cred.credentials | ||||
|     user = Users.get_user_by_token(token) | ||||
|     if user: | ||||
|         return { | ||||
|             "id": user.id, | ||||
|             "email": user.email, | ||||
|             "name": user.name, | ||||
|             "role": user.role, | ||||
|             "profile_image_url": user.profile_image_url, | ||||
|         } | ||||
|     else: | ||||
|         raise HTTPException( | ||||
|             status_code=status.HTTP_401_UNAUTHORIZED, | ||||
|             detail=ERROR_MESSAGES.INVALID_TOKEN, | ||||
|         ) | ||||
| 
 | ||||
| 
 | ||||
| ############################ | ||||
| # SignIn | ||||
| ############################ | ||||
| 
 | ||||
| 
 | ||||
| @router.post("/signin", response_model=SigninResponse) | ||||
| async def signin(form_data: SigninForm): | ||||
|     user = Auths.authenticate_user(form_data.email.lower(), form_data.password) | ||||
|     if user: | ||||
|         token = create_token(data={"email": user.email}) | ||||
| 
 | ||||
|         return { | ||||
|             "token": token, | ||||
|             "token_type": "Bearer", | ||||
|             "id": user.id, | ||||
|             "email": user.email, | ||||
|             "name": user.name, | ||||
|             "role": user.role, | ||||
|             "profile_image_url": user.profile_image_url, | ||||
|         } | ||||
|     else: | ||||
|         raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) | ||||
| 
 | ||||
| 
 | ||||
| ############################ | ||||
| # SignUp | ||||
| ############################ | ||||
| 
 | ||||
| 
 | ||||
| @router.post("/signup", response_model=SigninResponse) | ||||
| async def signup(form_data: SignupForm): | ||||
|     if not Users.get_user_by_email(form_data.email.lower()): | ||||
|         try: | ||||
|             role = "admin" if Users.get_num_users() == 0 else "pending" | ||||
|             hashed = get_password_hash(form_data.password) | ||||
|             user = Auths.insert_new_auth(form_data.email, hashed, form_data.name, role) | ||||
| 
 | ||||
|             if user: | ||||
|                 token = create_token(data={"email": user.email}) | ||||
|                 # response.set_cookie(key='token', value=token, httponly=True) | ||||
| 
 | ||||
|                 return { | ||||
|                     "token": token, | ||||
|                     "token_type": "Bearer", | ||||
|                     "id": user.id, | ||||
|                     "email": user.email, | ||||
|                     "name": user.name, | ||||
|                     "role": user.role, | ||||
|                     "profile_image_url": user.profile_image_url, | ||||
|                 } | ||||
|             else: | ||||
|                 raise HTTPException(500, detail=ERROR_MESSAGES.DEFAULT(err)) | ||||
|         except Exception as err: | ||||
|             raise HTTPException(500, detail=ERROR_MESSAGES.DEFAULT(err)) | ||||
|     else: | ||||
|         raise HTTPException(400, detail=ERROR_MESSAGES.DEFAULT()) | ||||
							
								
								
									
										75
									
								
								backend/apps/web/routers/users.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								backend/apps/web/routers/users.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,75 @@ | |||
| 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 time | ||||
| import uuid | ||||
| 
 | ||||
| from apps.web.models.users import UserModel, UserRoleUpdateForm, Users | ||||
| 
 | ||||
| from utils.utils import ( | ||||
|     get_password_hash, | ||||
|     bearer_scheme, | ||||
|     create_token, | ||||
| ) | ||||
| from constants import ERROR_MESSAGES | ||||
| 
 | ||||
| router = APIRouter() | ||||
| 
 | ||||
| ############################ | ||||
| # GetUsers | ||||
| ############################ | ||||
| 
 | ||||
| 
 | ||||
| @router.get("/", response_model=List[UserModel]) | ||||
| async def get_users(skip: int = 0, limit: int = 50, cred=Depends(bearer_scheme)): | ||||
|     token = cred.credentials | ||||
|     user = Users.get_user_by_token(token) | ||||
| 
 | ||||
|     if user: | ||||
|         if user.role == "admin": | ||||
|             return Users.get_users(skip, limit) | ||||
|         else: | ||||
|             raise HTTPException( | ||||
|                 status_code=status.HTTP_403_FORBIDDEN, | ||||
|                 detail=ERROR_MESSAGES.ACCESS_PROHIBITED, | ||||
|             ) | ||||
|     else: | ||||
|         raise HTTPException( | ||||
|             status_code=status.HTTP_401_UNAUTHORIZED, | ||||
|             detail=ERROR_MESSAGES.INVALID_TOKEN, | ||||
|         ) | ||||
| 
 | ||||
| 
 | ||||
| ############################ | ||||
| # UpdateUserRole | ||||
| ############################ | ||||
| 
 | ||||
| 
 | ||||
| @router.post("/update/role", response_model=Optional[UserModel]) | ||||
| async def update_user_role(form_data: UserRoleUpdateForm, cred=Depends(bearer_scheme)): | ||||
|     token = cred.credentials | ||||
|     user = Users.get_user_by_token(token) | ||||
| 
 | ||||
|     if user: | ||||
|         if user.role == "admin": | ||||
|             if user.id != form_data.id: | ||||
|                 return Users.update_user_role_by_id(form_data.id, form_data.role) | ||||
|             else: | ||||
|                 raise HTTPException( | ||||
|                     status_code=status.HTTP_403_FORBIDDEN, | ||||
|                     detail=ERROR_MESSAGES.ACTION_PROHIBITED, | ||||
|                 ) | ||||
|         else: | ||||
|             raise HTTPException( | ||||
|                 status_code=status.HTTP_403_FORBIDDEN, | ||||
|                 detail=ERROR_MESSAGES.ACCESS_PROHIBITED, | ||||
|             ) | ||||
|     else: | ||||
|         raise HTTPException( | ||||
|             status_code=status.HTTP_401_UNAUTHORIZED, | ||||
|             detail=ERROR_MESSAGES.INVALID_TOKEN, | ||||
|         ) | ||||
|  | @ -1,11 +1,24 @@ | |||
| import sys | ||||
| import os | ||||
| 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 (dev,test,prod) | ||||
| #################################### | ||||
| 
 | ||||
| ENV = os.environ.get("ENV", "dev") | ||||
| 
 | ||||
| #################################### | ||||
| # OLLAMA_API_BASE_URL | ||||
| #################################### | ||||
| 
 | ||||
| OLLAMA_API_BASE_URL = os.environ.get( | ||||
|     "OLLAMA_API_BASE_URL", "http://localhost:11434/api" | ||||
| ) | ||||
|  | @ -13,3 +26,41 @@ OLLAMA_API_BASE_URL = os.environ.get( | |||
| if ENV == "prod": | ||||
|     if OLLAMA_API_BASE_URL == "/ollama/api": | ||||
|         OLLAMA_API_BASE_URL = "http://host.docker.internal:11434/api" | ||||
| 
 | ||||
| #################################### | ||||
| # WEBUI_VERSION | ||||
| #################################### | ||||
| 
 | ||||
| WEBUI_VERSION = os.environ.get("WEBUI_VERSION", "v1.0.0-alpha.11") | ||||
| 
 | ||||
| #################################### | ||||
| # WEBUI_AUTH | ||||
| #################################### | ||||
| 
 | ||||
| 
 | ||||
| WEBUI_AUTH = True if os.environ.get("WEBUI_AUTH", "TRUE") == "TRUE" else False | ||||
| 
 | ||||
| 
 | ||||
| #################################### | ||||
| # WEBUI_DB | ||||
| #################################### | ||||
| 
 | ||||
| 
 | ||||
| WEBUI_DB_URL = os.environ.get("WEBUI_DB_URL", "mongodb://root:root@localhost:27017/") | ||||
| 
 | ||||
| if WEBUI_AUTH and WEBUI_DB_URL == "": | ||||
|     raise ValueError(ERROR_MESSAGES.ENV_VAR_NOT_FOUND) | ||||
| 
 | ||||
| 
 | ||||
| DB_CLIENT = MongoClient(f"{WEBUI_DB_URL}?authSource=admin") | ||||
| DB = DB_CLIENT["ollama-webui"] | ||||
| 
 | ||||
| 
 | ||||
| #################################### | ||||
| # WEBUI_JWT_SECRET_KEY | ||||
| #################################### | ||||
| 
 | ||||
| WEBUI_JWT_SECRET_KEY = os.environ.get("WEBUI_JWT_SECRET_KEY", "t0p-s3cr3t") | ||||
| 
 | ||||
| if WEBUI_AUTH and WEBUI_JWT_SECRET_KEY == "": | ||||
|     raise ValueError(ERROR_MESSAGES.ENV_VAR_NOT_FOUND) | ||||
|  |  | |||
							
								
								
									
										24
									
								
								backend/constants.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								backend/constants.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,24 @@ | |||
| from enum import Enum | ||||
| 
 | ||||
| 
 | ||||
| class MESSAGES(str, Enum): | ||||
|     DEFAULT = lambda msg="": f"{msg if msg else ''}" | ||||
| 
 | ||||
| 
 | ||||
| class ERROR_MESSAGES(str, Enum): | ||||
|     def __str__(self) -> str: | ||||
|         return super().__str__() | ||||
| 
 | ||||
|     DEFAULT = lambda err="": f"Something went wrong :/\n{err if err else ''}" | ||||
|     ENV_VAR_NOT_FOUND = "Required environment variable not found. Terminating now." | ||||
|     INVALID_TOKEN = ( | ||||
|         "Your session has expired or the token is invalid. Please sign in again." | ||||
|     ) | ||||
|     INVALID_CRED = "The email or password provided is incorrect. Please check for typos and try logging in again." | ||||
|     UNAUTHORIZED = "401 Unauthorized" | ||||
|     ACCESS_PROHIBITED = "You do not have permission to access this resource. Please contact your administrator for assistance." | ||||
|     ACTION_PROHIBITED = ( | ||||
|         "The requested action has been restricted as a security measure." | ||||
|     ) | ||||
|     USER_NOT_FOUND = "We could not find what you're looking for :/" | ||||
|     MALICIOUS = "Unusual activities detected, please try again in a few minutes." | ||||
|  | @ -1,16 +1,14 @@ | |||
| import time | ||||
| import sys | ||||
| 
 | ||||
| from fastapi import FastAPI, Request | ||||
| from fastapi.staticfiles import StaticFiles | ||||
| 
 | ||||
| from fastapi import HTTPException | ||||
| from starlette.exceptions import HTTPException as StarletteHTTPException | ||||
| 
 | ||||
| from fastapi.middleware.wsgi import WSGIMiddleware | ||||
| from fastapi.middleware.cors import CORSMiddleware | ||||
| from starlette.exceptions import HTTPException as StarletteHTTPException | ||||
| 
 | ||||
| from apps.ollama.main import app as ollama_app | ||||
| from apps.web.main import app as webui_app | ||||
| 
 | ||||
| import time | ||||
| 
 | ||||
| 
 | ||||
| class SPAStaticFiles(StaticFiles): | ||||
|  | @ -47,5 +45,6 @@ async def check_url(request: Request, call_next): | |||
|     return response | ||||
| 
 | ||||
| 
 | ||||
| app.mount("/api/v1", webui_app) | ||||
| app.mount("/ollama/api", WSGIMiddleware(ollama_app)) | ||||
| app.mount("/", SPAStaticFiles(directory="../build", html=True), name="spa-static-files") | ||||
|  |  | |||
							
								
								
									
										15
									
								
								backend/utils/misc.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								backend/utils/misc.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,15 @@ | |||
| import hashlib | ||||
| 
 | ||||
| 
 | ||||
| def get_gravatar_url(email): | ||||
|     # Trim leading and trailing whitespace from | ||||
|     # an email address and force all characters | ||||
|     # to lower case | ||||
|     address = str(email).strip().lower() | ||||
| 
 | ||||
|     # Create a SHA256 hash of the final string | ||||
|     hash_object = hashlib.sha256(address.encode()) | ||||
|     hash_hex = hash_object.hexdigest() | ||||
| 
 | ||||
|     # Grab the actual image URL | ||||
|     return f"https://www.gravatar.com/avatar/{hash_hex}?d=mp" | ||||
							
								
								
									
										68
									
								
								backend/utils/utils.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								backend/utils/utils.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,68 @@ | |||
| from fastapi.security import HTTPBasicCredentials, HTTPBearer | ||||
| from pydantic import BaseModel | ||||
| from typing import Union, Optional | ||||
| 
 | ||||
| from passlib.context import CryptContext | ||||
| from datetime import datetime, timedelta | ||||
| import requests | ||||
| import jwt | ||||
| 
 | ||||
| import config | ||||
| 
 | ||||
| JWT_SECRET_KEY = config.WEBUI_JWT_SECRET_KEY | ||||
| ALGORITHM = "HS256" | ||||
| 
 | ||||
| ############## | ||||
| # Auth Utils | ||||
| ############## | ||||
| 
 | ||||
| bearer_scheme = HTTPBearer() | ||||
| pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") | ||||
| 
 | ||||
| 
 | ||||
| def verify_password(plain_password, hashed_password): | ||||
|     return ( | ||||
|         pwd_context.verify(plain_password, hashed_password) if hashed_password else None | ||||
|     ) | ||||
| 
 | ||||
| 
 | ||||
| def get_password_hash(password): | ||||
|     return pwd_context.hash(password) | ||||
| 
 | ||||
| 
 | ||||
| def create_token(data: dict, expires_delta: Union[timedelta, None] = None) -> str: | ||||
|     payload = data.copy() | ||||
| 
 | ||||
|     if expires_delta: | ||||
|         expire = datetime.utcnow() + expires_delta | ||||
|         payload.update({"exp": expire}) | ||||
| 
 | ||||
|     encoded_jwt = jwt.encode(payload, JWT_SECRET_KEY, algorithm=ALGORITHM) | ||||
|     return encoded_jwt | ||||
| 
 | ||||
| 
 | ||||
| def decode_token(token: str) -> Optional[dict]: | ||||
|     try: | ||||
|         decoded = jwt.decode(token, JWT_SECRET_KEY, options={"verify_signature": False}) | ||||
|         return decoded | ||||
|     except Exception as e: | ||||
|         return None | ||||
| 
 | ||||
| 
 | ||||
| def extract_token_from_auth_header(auth_header: str): | ||||
|     return auth_header[len("Bearer ") :] | ||||
| 
 | ||||
| 
 | ||||
| def verify_token(request): | ||||
|     try: | ||||
|         bearer = request.headers["authorization"] | ||||
|         if bearer: | ||||
|             token = bearer[len("Bearer ") :] | ||||
|             decoded = jwt.decode( | ||||
|                 token, JWT_SECRET_KEY, options={"verify_signature": False} | ||||
|             ) | ||||
|             return decoded | ||||
|         else: | ||||
|             return None | ||||
|     except Exception as e: | ||||
|         return None | ||||
							
								
								
									
										17
									
								
								compose.yaml
									
										
									
									
									
								
							
							
						
						
									
										17
									
								
								compose.yaml
									
										
									
									
									
								
							|  | @ -22,6 +22,17 @@ services: | |||
|     restart: unless-stopped | ||||
|     image: ollama/ollama:latest | ||||
| 
 | ||||
| 
 | ||||
|   # Uncomment below for WIP: Auth support | ||||
|   # ollama-webui-db: | ||||
|   #   image: mongo | ||||
|   #   container_name: ollama-webui-db | ||||
|   #   restart: always | ||||
|   #   # Make sure to change the username/password! | ||||
|   #   environment: | ||||
|   #     MONGO_INITDB_ROOT_USERNAME: root | ||||
|   #     MONGO_INITDB_ROOT_PASSWORD: example | ||||
| 
 | ||||
|   ollama-webui: | ||||
|     build: | ||||
|       context: . | ||||
|  | @ -32,10 +43,16 @@ services: | |||
|     container_name: ollama-webui | ||||
|     depends_on: | ||||
|       - ollama | ||||
|       # Uncomment below for WIP: Auth support | ||||
|       # - ollama-webui-db | ||||
|     ports: | ||||
|       - 3000:8080 | ||||
|     environment: | ||||
|       - "OLLAMA_API_BASE_URL=http://ollama:11434/api" | ||||
|       # Uncomment below for WIP: Auth support | ||||
|       # - "WEBUI_AUTH=TRUE" | ||||
|       # - "WEBUI_DB_URL=mongodb://root:example@ollama-webui-db:27017/" | ||||
|       # - "WEBUI_JWT_SECRET_KEY=SECRET_KEY" | ||||
|     extra_hosts: | ||||
|       - host.docker.internal:host-gateway | ||||
|     restart: unless-stopped | ||||
|  |  | |||
|  | @ -4,8 +4,13 @@ | |||
| 	font-display: swap; | ||||
| } | ||||
| 
 | ||||
| @font-face { | ||||
| 	font-family: 'Mona Sans'; | ||||
| 	src: url('/assets/fonts/Mona-Sans.woff2'); | ||||
| 	font-display: swap; | ||||
| } | ||||
| 
 | ||||
| html { | ||||
| 	@apply bg-gray-800; | ||||
| 	word-break: break-word; | ||||
| } | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										282
									
								
								src/lib/components/chat/MessageInput.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										282
									
								
								src/lib/components/chat/MessageInput.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,282 @@ | |||
| <script lang="ts"> | ||||
| 	import { settings } from '$lib/stores'; | ||||
| 	import Suggestions from './MessageInput/Suggestions.svelte'; | ||||
| 
 | ||||
| 	export let submitPrompt: Function; | ||||
| 	export let stopResponse: Function; | ||||
| 
 | ||||
| 	export let suggestions = 'true'; | ||||
| 	export let autoScroll = true; | ||||
| 
 | ||||
| 	export let fileUploadEnabled = false; | ||||
| 	export let speechRecognitionEnabled = true; | ||||
| 	export let speechRecognitionListening = false; | ||||
| 
 | ||||
| 	export let prompt = ''; | ||||
| 	export let messages = []; | ||||
| 
 | ||||
| 	let speechRecognition; | ||||
| 
 | ||||
| 	const speechRecognitionHandler = () => { | ||||
| 		// Check if SpeechRecognition is supported | ||||
| 
 | ||||
| 		if (speechRecognitionListening) { | ||||
| 			speechRecognition.stop(); | ||||
| 		} else { | ||||
| 			if ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window) { | ||||
| 				// Create a SpeechRecognition object | ||||
| 				speechRecognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)(); | ||||
| 
 | ||||
| 				// Set continuous to true for continuous recognition | ||||
| 				speechRecognition.continuous = true; | ||||
| 
 | ||||
| 				// Set the timeout for turning off the recognition after inactivity (in milliseconds) | ||||
| 				const inactivityTimeout = 3000; // 3 seconds | ||||
| 
 | ||||
| 				let timeoutId; | ||||
| 				// Start recognition | ||||
| 				speechRecognition.start(); | ||||
| 				speechRecognitionListening = true; | ||||
| 
 | ||||
| 				// Event triggered when speech is recognized | ||||
| 				speechRecognition.onresult = function (event) { | ||||
| 					// Clear the inactivity timeout | ||||
| 					clearTimeout(timeoutId); | ||||
| 
 | ||||
| 					// Handle recognized speech | ||||
| 					console.log(event); | ||||
| 					const transcript = event.results[Object.keys(event.results).length - 1][0].transcript; | ||||
| 					prompt = `${prompt}${transcript}`; | ||||
| 
 | ||||
| 					// Restart the inactivity timeout | ||||
| 					timeoutId = setTimeout(() => { | ||||
| 						console.log('Speech recognition turned off due to inactivity.'); | ||||
| 						speechRecognition.stop(); | ||||
| 					}, inactivityTimeout); | ||||
| 				}; | ||||
| 
 | ||||
| 				// Event triggered when recognition is ended | ||||
| 				speechRecognition.onend = function () { | ||||
| 					// Restart recognition after it ends | ||||
| 					console.log('recognition ended'); | ||||
| 					speechRecognitionListening = false; | ||||
| 					if (prompt !== '' && $settings?.speechAutoSend === true) { | ||||
| 						submitPrompt(prompt); | ||||
| 					} | ||||
| 				}; | ||||
| 
 | ||||
| 				// Event triggered when an error occurs | ||||
| 				speechRecognition.onerror = function (event) { | ||||
| 					console.log(event); | ||||
| 					toast.error(`Speech recognition error: ${event.error}`); | ||||
| 					speechRecognitionListening = false; | ||||
| 				}; | ||||
| 			} else { | ||||
| 				toast.error('SpeechRecognition API is not supported in this browser.'); | ||||
| 			} | ||||
| 		} | ||||
| 	}; | ||||
| </script> | ||||
| 
 | ||||
| <div class="fixed bottom-0 w-full"> | ||||
| 	<div class="  pt-5"> | ||||
| 		<div class="max-w-3xl px-2.5 pt-2.5 -mb-0.5 mx-auto inset-x-0"> | ||||
| 			{#if messages.length == 0 && suggestions !== 'false'} | ||||
| 				<Suggestions {submitPrompt} /> | ||||
| 			{/if} | ||||
| 
 | ||||
| 			{#if autoScroll === false && messages.length > 0} | ||||
| 				<div class=" flex justify-center mb-4"> | ||||
| 					<button | ||||
| 						class=" bg-white/20 p-1.5 rounded-full" | ||||
| 						on:click={() => { | ||||
| 							autoScroll = true; | ||||
| 							window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); | ||||
| 						}} | ||||
| 					> | ||||
| 						<svg | ||||
| 							xmlns="http://www.w3.org/2000/svg" | ||||
| 							viewBox="0 0 20 20" | ||||
| 							fill="currentColor" | ||||
| 							class="w-5 h-5" | ||||
| 						> | ||||
| 							<path | ||||
| 								fill-rule="evenodd" | ||||
| 								d="M10 3a.75.75 0 01.75.75v10.638l3.96-4.158a.75.75 0 111.08 1.04l-5.25 5.5a.75.75 0 01-1.08 0l-5.25-5.5a.75.75 0 111.08-1.04l3.96 4.158V3.75A.75.75 0 0110 3z" | ||||
| 								clip-rule="evenodd" | ||||
| 							/> | ||||
| 						</svg> | ||||
| 					</button> | ||||
| 				</div> | ||||
| 			{/if} | ||||
| 
 | ||||
| 			<div class="bg-gradient-to-t from-white dark:from-gray-800 from-40% pb-2"> | ||||
| 				<form | ||||
| 					class=" flex relative w-full" | ||||
| 					on:submit|preventDefault={() => { | ||||
| 						submitPrompt(prompt); | ||||
| 					}} | ||||
| 				> | ||||
| 					<textarea | ||||
| 						id="chat-textarea" | ||||
| 						class="rounded-xl dark:bg-gray-800 dark:text-gray-100 outline-none border dark:border-gray-600 w-full py-3 | ||||
|                         {fileUploadEnabled ? 'pl-12' : 'pl-5'} {speechRecognitionEnabled | ||||
| 							? 'pr-20' | ||||
| 							: 'pr-12'} resize-none" | ||||
| 						placeholder={speechRecognitionListening ? 'Listening...' : 'Send a message'} | ||||
| 						bind:value={prompt} | ||||
| 						on:keypress={(e) => { | ||||
| 							if (e.keyCode == 13 && !e.shiftKey) { | ||||
| 								e.preventDefault(); | ||||
| 							} | ||||
| 							if (prompt !== '' && e.keyCode == 13 && !e.shiftKey) { | ||||
| 								submitPrompt(prompt); | ||||
| 							} | ||||
| 						}} | ||||
| 						rows="1" | ||||
| 						on:input={(e) => { | ||||
| 							e.target.style.height = ''; | ||||
| 							e.target.style.height = Math.min(e.target.scrollHeight, 200) + 2 + 'px'; | ||||
| 						}} | ||||
| 					/> | ||||
| 
 | ||||
| 					{#if fileUploadEnabled} | ||||
| 						<div class=" absolute left-0 bottom-0"> | ||||
| 							<div class="pl-2.5 pb-[9px]"> | ||||
| 								<button | ||||
| 									class="  text-gray-600 dark:text-gray-200 transition rounded-lg p-1.5" | ||||
| 									type="button" | ||||
| 									on:click={() => { | ||||
| 										console.log('file'); | ||||
| 									}} | ||||
| 								> | ||||
| 									<svg | ||||
| 										xmlns="http://www.w3.org/2000/svg" | ||||
| 										viewBox="0 0 20 20" | ||||
| 										fill="currentColor" | ||||
| 										class="w-5 h-5" | ||||
| 									> | ||||
| 										<path | ||||
| 											fill-rule="evenodd" | ||||
| 											d="M15.621 4.379a3 3 0 00-4.242 0l-7 7a3 3 0 004.241 4.243h.001l.497-.5a.75.75 0 011.064 1.057l-.498.501-.002.002a4.5 4.5 0 01-6.364-6.364l7-7a4.5 4.5 0 016.368 6.36l-3.455 3.553A2.625 2.625 0 119.52 9.52l3.45-3.451a.75.75 0 111.061 1.06l-3.45 3.451a1.125 1.125 0 001.587 1.595l3.454-3.553a3 3 0 000-4.242z" | ||||
| 											clip-rule="evenodd" | ||||
| 										/> | ||||
| 									</svg> | ||||
| 								</button> | ||||
| 							</div> | ||||
| 						</div> | ||||
| 					{/if} | ||||
| 
 | ||||
| 					<div class=" absolute right-0 bottom-0"> | ||||
| 						<div class="pr-2.5 pb-[9px]"> | ||||
| 							{#if messages.length == 0 || messages.at(-1).done == true} | ||||
| 								{#if speechRecognitionEnabled} | ||||
| 									<button | ||||
| 										class=" text-gray-600 dark:text-gray-300 transition rounded-lg p-1 mr-0.5" | ||||
| 										type="button" | ||||
| 										on:click={() => { | ||||
| 											speechRecognitionHandler(); | ||||
| 										}} | ||||
| 									> | ||||
| 										{#if speechRecognitionListening} | ||||
| 											<svg | ||||
| 												class=" w-5 h-5 translate-y-[0.5px]" | ||||
| 												fill="currentColor" | ||||
| 												viewBox="0 0 24 24" | ||||
| 												xmlns="http://www.w3.org/2000/svg" | ||||
| 												><style> | ||||
| 													.spinner_qM83 { | ||||
| 														animation: spinner_8HQG 1.05s infinite; | ||||
| 													} | ||||
| 													.spinner_oXPr { | ||||
| 														animation-delay: 0.1s; | ||||
| 													} | ||||
| 													.spinner_ZTLf { | ||||
| 														animation-delay: 0.2s; | ||||
| 													} | ||||
| 													@keyframes spinner_8HQG { | ||||
| 														0%, | ||||
| 														57.14% { | ||||
| 															animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1); | ||||
| 															transform: translate(0); | ||||
| 														} | ||||
| 														28.57% { | ||||
| 															animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33); | ||||
| 															transform: translateY(-6px); | ||||
| 														} | ||||
| 														100% { | ||||
| 															transform: translate(0); | ||||
| 														} | ||||
| 													} | ||||
| 												</style><circle class="spinner_qM83" cx="4" cy="12" r="2.5" /><circle | ||||
| 													class="spinner_qM83 spinner_oXPr" | ||||
| 													cx="12" | ||||
| 													cy="12" | ||||
| 													r="2.5" | ||||
| 												/><circle class="spinner_qM83 spinner_ZTLf" cx="20" cy="12" r="2.5" /></svg | ||||
| 											> | ||||
| 										{:else} | ||||
| 											<svg | ||||
| 												xmlns="http://www.w3.org/2000/svg" | ||||
| 												viewBox="0 0 20 20" | ||||
| 												fill="currentColor" | ||||
| 												class="w-5 h-5 translate-y-[0.5px]" | ||||
| 											> | ||||
| 												<path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" /> | ||||
| 												<path | ||||
| 													d="M5.5 9.643a.75.75 0 00-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-1.5v-1.546A6.001 6.001 0 0016 10v-.357a.75.75 0 00-1.5 0V10a4.5 4.5 0 01-9 0v-.357z" | ||||
| 												/> | ||||
| 											</svg> | ||||
| 										{/if} | ||||
| 									</button> | ||||
| 								{/if} | ||||
| 								<button | ||||
| 									class="{prompt !== '' | ||||
| 										? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 ' | ||||
| 										: 'text-white bg-gray-100 dark:text-gray-800 dark:bg-gray-600 disabled'} transition rounded-lg p-1" | ||||
| 									type="submit" | ||||
| 									disabled={prompt === ''} | ||||
| 								> | ||||
| 									<svg | ||||
| 										xmlns="http://www.w3.org/2000/svg" | ||||
| 										viewBox="0 0 20 20" | ||||
| 										fill="currentColor" | ||||
| 										class="w-5 h-5" | ||||
| 									> | ||||
| 										<path | ||||
| 											fill-rule="evenodd" | ||||
| 											d="M10 17a.75.75 0 01-.75-.75V5.612L5.29 9.77a.75.75 0 01-1.08-1.04l5.25-5.5a.75.75 0 011.08 0l5.25 5.5a.75.75 0 11-1.08 1.04l-3.96-4.158V16.25A.75.75 0 0110 17z" | ||||
| 											clip-rule="evenodd" | ||||
| 										/> | ||||
| 									</svg> | ||||
| 								</button> | ||||
| 							{:else} | ||||
| 								<button | ||||
| 									class="bg-white hover:bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-800 transition rounded-lg p-1.5" | ||||
| 									on:click={stopResponse} | ||||
| 								> | ||||
| 									<svg | ||||
| 										xmlns="http://www.w3.org/2000/svg" | ||||
| 										viewBox="0 0 24 24" | ||||
| 										fill="currentColor" | ||||
| 										class="w-5 h-5" | ||||
| 									> | ||||
| 										<path | ||||
| 											fill-rule="evenodd" | ||||
| 											d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm6-2.438c0-.724.588-1.312 1.313-1.312h4.874c.725 0 1.313.588 1.313 1.313v4.874c0 .725-.588 1.313-1.313 1.313H9.564a1.312 1.312 0 01-1.313-1.313V9.564z" | ||||
| 											clip-rule="evenodd" | ||||
| 										/> | ||||
| 									</svg> | ||||
| 								</button> | ||||
| 							{/if} | ||||
| 						</div> | ||||
| 					</div> | ||||
| 				</form> | ||||
| 
 | ||||
| 				<div class="mt-1.5 text-xs text-gray-500 text-center"> | ||||
| 					LLMs can make mistakes. Verify important information. | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
| </div> | ||||
							
								
								
									
										698
									
								
								src/lib/components/chat/Messages.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										698
									
								
								src/lib/components/chat/Messages.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,698 @@ | |||
| <script lang="ts"> | ||||
| 	import { marked } from 'marked'; | ||||
| 
 | ||||
| 	import { v4 as uuidv4 } from 'uuid'; | ||||
| 	import hljs from 'highlight.js'; | ||||
| 	import 'highlight.js/styles/github-dark.min.css'; | ||||
| 	import auto_render from 'katex/dist/contrib/auto-render.mjs'; | ||||
| 	import 'katex/dist/katex.min.css'; | ||||
| 
 | ||||
| 	import { config, db, settings, user } from '$lib/stores'; | ||||
| 	import { tick } from 'svelte'; | ||||
| 
 | ||||
| 	import toast from 'svelte-french-toast'; | ||||
| 
 | ||||
| 	export let sendPrompt: Function; | ||||
| 	export let regenerateResponse: Function; | ||||
| 
 | ||||
| 	export let autoScroll; | ||||
| 	export let history = {}; | ||||
| 	export let messages = []; | ||||
| 
 | ||||
| 	$: if (messages && messages.length > 0 && (messages.at(-1).done ?? false)) { | ||||
| 		(async () => { | ||||
| 			await tick(); | ||||
| 			renderLatex(); | ||||
| 			hljs.highlightAll(); | ||||
| 			createCopyCodeBlockButton(); | ||||
| 		})(); | ||||
| 	} | ||||
| 
 | ||||
| 	const createCopyCodeBlockButton = () => { | ||||
| 		// use a class selector if available | ||||
| 		let blocks = document.querySelectorAll('pre'); | ||||
| 
 | ||||
| 		blocks.forEach((block) => { | ||||
| 			// only add button if browser supports Clipboard API | ||||
| 
 | ||||
| 			if (navigator.clipboard && block.childNodes.length < 2) { | ||||
| 				let code = block.querySelector('code'); | ||||
| 				code.style.borderTopRightRadius = 0; | ||||
| 				code.style.borderTopLeftRadius = 0; | ||||
| 
 | ||||
| 				let topBarDiv = document.createElement('div'); | ||||
| 				topBarDiv.style.backgroundColor = '#202123'; | ||||
| 				topBarDiv.style.overflowX = 'auto'; | ||||
| 				topBarDiv.style.display = 'flex'; | ||||
| 				topBarDiv.style.justifyContent = 'space-between'; | ||||
| 				topBarDiv.style.padding = '0 1rem'; | ||||
| 				topBarDiv.style.paddingTop = '4px'; | ||||
| 				topBarDiv.style.borderTopRightRadius = '8px'; | ||||
| 				topBarDiv.style.borderTopLeftRadius = '8px'; | ||||
| 
 | ||||
| 				let langDiv = document.createElement('div'); | ||||
| 
 | ||||
| 				let codeClassNames = code?.className.split(' '); | ||||
| 				langDiv.textContent = | ||||
| 					codeClassNames[0] === 'hljs' ? codeClassNames[1].slice(9) : codeClassNames[0].slice(9); | ||||
| 				langDiv.style.color = 'white'; | ||||
| 				langDiv.style.margin = '4px'; | ||||
| 				langDiv.style.fontSize = '0.75rem'; | ||||
| 
 | ||||
| 				let button = document.createElement('button'); | ||||
| 				button.textContent = 'Copy Code'; | ||||
| 				button.style.background = 'none'; | ||||
| 				button.style.fontSize = '0.75rem'; | ||||
| 				button.style.border = 'none'; | ||||
| 				button.style.margin = '4px'; | ||||
| 				button.style.cursor = 'pointer'; | ||||
| 				button.style.color = '#ddd'; | ||||
| 				button.addEventListener('click', () => copyCode(block, button)); | ||||
| 
 | ||||
| 				topBarDiv.appendChild(langDiv); | ||||
| 				topBarDiv.appendChild(button); | ||||
| 
 | ||||
| 				block.prepend(topBarDiv); | ||||
| 
 | ||||
| 				// button.addEventListener('click', async () => { | ||||
| 				// 	await copyCode(block, button); | ||||
| 				// }); | ||||
| 			} | ||||
| 		}); | ||||
| 
 | ||||
| 		async function copyCode(block, button) { | ||||
| 			let code = block.querySelector('code'); | ||||
| 			let text = code.innerText; | ||||
| 
 | ||||
| 			await navigator.clipboard.writeText(text); | ||||
| 
 | ||||
| 			// visual feedback that task is completed | ||||
| 			button.innerText = 'Copied!'; | ||||
| 
 | ||||
| 			setTimeout(() => { | ||||
| 				button.innerText = 'Copy Code'; | ||||
| 			}, 1000); | ||||
| 		} | ||||
| 	}; | ||||
| 
 | ||||
| 	const renderLatex = () => { | ||||
| 		let chatMessageElements = document.getElementsByClassName('chat-assistant'); | ||||
| 		// let lastChatMessageElement = chatMessageElements[chatMessageElements.length - 1]; | ||||
| 
 | ||||
| 		for (const element of chatMessageElements) { | ||||
| 			auto_render(element, { | ||||
| 				// customised options | ||||
| 				// • auto-render specific keys, e.g.: | ||||
| 				delimiters: [ | ||||
| 					{ left: '$$', right: '$$', display: true }, | ||||
| 					{ left: '$', right: '$', display: true }, | ||||
| 					{ left: '\\(', right: '\\)', display: true }, | ||||
| 					{ left: '\\[', right: '\\]', display: true } | ||||
| 				], | ||||
| 				// • rendering keys, e.g.: | ||||
| 				throwOnError: false | ||||
| 			}); | ||||
| 		} | ||||
| 	}; | ||||
| 
 | ||||
| 	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!'); | ||||
| 				toast.success('Copying to clipboard was successful!'); | ||||
| 			}, | ||||
| 			function (err) { | ||||
| 				console.error('Async: Could not copy text: ', err); | ||||
| 			} | ||||
| 		); | ||||
| 	}; | ||||
| 
 | ||||
| 	const editMessageHandler = async (messageId) => { | ||||
| 		// let editMessage = history.messages[messageId]; | ||||
| 		history.messages[messageId].edit = true; | ||||
| 		history.messages[messageId].editedContent = history.messages[messageId].content; | ||||
| 	}; | ||||
| 
 | ||||
| 	const confirmEditMessage = async (messageId) => { | ||||
| 		history.messages[messageId].edit = false; | ||||
| 
 | ||||
| 		let userPrompt = history.messages[messageId].editedContent; | ||||
| 		let userMessageId = uuidv4(); | ||||
| 
 | ||||
| 		let userMessage = { | ||||
| 			id: userMessageId, | ||||
| 			parentId: history.messages[messageId].parentId, | ||||
| 			childrenIds: [], | ||||
| 			role: 'user', | ||||
| 			content: userPrompt | ||||
| 		}; | ||||
| 
 | ||||
| 		let messageParentId = history.messages[messageId].parentId; | ||||
| 
 | ||||
| 		if (messageParentId !== null) { | ||||
| 			history.messages[messageParentId].childrenIds = [ | ||||
| 				...history.messages[messageParentId].childrenIds, | ||||
| 				userMessageId | ||||
| 			]; | ||||
| 		} | ||||
| 
 | ||||
| 		history.messages[userMessageId] = userMessage; | ||||
| 		history.currentId = userMessageId; | ||||
| 
 | ||||
| 		await tick(); | ||||
| 		await sendPrompt(userPrompt, userMessageId); | ||||
| 	}; | ||||
| 
 | ||||
| 	const cancelEditMessage = (messageId) => { | ||||
| 		history.messages[messageId].edit = false; | ||||
| 		history.messages[messageId].editedContent = undefined; | ||||
| 	}; | ||||
| 
 | ||||
| 	const rateMessage = async (messageIdx, rating) => { | ||||
| 		messages = messages.map((message, idx) => { | ||||
| 			if (messageIdx === idx) { | ||||
| 				message.rating = rating; | ||||
| 			} | ||||
| 			return message; | ||||
| 		}); | ||||
| 
 | ||||
| 		$db.updateChatById(chatId, { | ||||
| 			messages: messages, | ||||
| 			history: history | ||||
| 		}); | ||||
| 	}; | ||||
| 
 | ||||
| 	const showPreviousMessage = async (message) => { | ||||
| 		if (message.parentId !== null) { | ||||
| 			let messageId = | ||||
| 				history.messages[message.parentId].childrenIds[ | ||||
| 					Math.max(history.messages[message.parentId].childrenIds.indexOf(message.id) - 1, 0) | ||||
| 				]; | ||||
| 
 | ||||
| 			if (message.id !== messageId) { | ||||
| 				let messageChildrenIds = history.messages[messageId].childrenIds; | ||||
| 
 | ||||
| 				while (messageChildrenIds.length !== 0) { | ||||
| 					messageId = messageChildrenIds.at(-1); | ||||
| 					messageChildrenIds = history.messages[messageId].childrenIds; | ||||
| 				} | ||||
| 
 | ||||
| 				history.currentId = messageId; | ||||
| 			} | ||||
| 		} else { | ||||
| 			let childrenIds = Object.values(history.messages) | ||||
| 				.filter((message) => message.parentId === null) | ||||
| 				.map((message) => message.id); | ||||
| 			let messageId = childrenIds[Math.max(childrenIds.indexOf(message.id) - 1, 0)]; | ||||
| 
 | ||||
| 			if (message.id !== messageId) { | ||||
| 				let messageChildrenIds = history.messages[messageId].childrenIds; | ||||
| 
 | ||||
| 				while (messageChildrenIds.length !== 0) { | ||||
| 					messageId = messageChildrenIds.at(-1); | ||||
| 					messageChildrenIds = history.messages[messageId].childrenIds; | ||||
| 				} | ||||
| 
 | ||||
| 				history.currentId = messageId; | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		await tick(); | ||||
| 
 | ||||
| 		autoScroll = window.innerHeight + window.scrollY >= document.body.offsetHeight - 40; | ||||
| 
 | ||||
| 		setTimeout(() => { | ||||
| 			window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); | ||||
| 		}, 100); | ||||
| 	}; | ||||
| 
 | ||||
| 	const showNextMessage = async (message) => { | ||||
| 		if (message.parentId !== null) { | ||||
| 			let messageId = | ||||
| 				history.messages[message.parentId].childrenIds[ | ||||
| 					Math.min( | ||||
| 						history.messages[message.parentId].childrenIds.indexOf(message.id) + 1, | ||||
| 						history.messages[message.parentId].childrenIds.length - 1 | ||||
| 					) | ||||
| 				]; | ||||
| 
 | ||||
| 			if (message.id !== messageId) { | ||||
| 				let messageChildrenIds = history.messages[messageId].childrenIds; | ||||
| 
 | ||||
| 				while (messageChildrenIds.length !== 0) { | ||||
| 					messageId = messageChildrenIds.at(-1); | ||||
| 					messageChildrenIds = history.messages[messageId].childrenIds; | ||||
| 				} | ||||
| 
 | ||||
| 				history.currentId = messageId; | ||||
| 			} | ||||
| 		} else { | ||||
| 			let childrenIds = Object.values(history.messages) | ||||
| 				.filter((message) => message.parentId === null) | ||||
| 				.map((message) => message.id); | ||||
| 			let messageId = | ||||
| 				childrenIds[Math.min(childrenIds.indexOf(message.id) + 1, childrenIds.length - 1)]; | ||||
| 
 | ||||
| 			if (message.id !== messageId) { | ||||
| 				let messageChildrenIds = history.messages[messageId].childrenIds; | ||||
| 
 | ||||
| 				while (messageChildrenIds.length !== 0) { | ||||
| 					messageId = messageChildrenIds.at(-1); | ||||
| 					messageChildrenIds = history.messages[messageId].childrenIds; | ||||
| 				} | ||||
| 
 | ||||
| 				history.currentId = messageId; | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		await tick(); | ||||
| 
 | ||||
| 		autoScroll = window.innerHeight + window.scrollY >= document.body.offsetHeight - 40; | ||||
| 		setTimeout(() => { | ||||
| 			window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); | ||||
| 		}, 100); | ||||
| 	}; | ||||
| </script> | ||||
| 
 | ||||
| {#if messages.length == 0} | ||||
| 	<div class="m-auto text-center max-w-md pb-56 px-2"> | ||||
| 		<div class="flex justify-center mt-8"> | ||||
| 			<img src="/ollama.png" class=" w-16 invert-[10%] dark:invert-[100%] rounded-full" /> | ||||
| 		</div> | ||||
| 		<div class=" mt-1 text-2xl text-gray-800 dark:text-gray-100 font-semibold"> | ||||
| 			How can I help you today? | ||||
| 		</div> | ||||
| 	</div> | ||||
| {:else} | ||||
| 	{#each messages as message, messageIdx} | ||||
| 		<div class=" w-full"> | ||||
| 			<div class="flex justify-between px-5 mb-3 max-w-3xl mx-auto rounded-lg group"> | ||||
| 				<div class=" flex w-full"> | ||||
| 					<div class=" mr-4"> | ||||
| 						{#if message.role === 'user'} | ||||
| 							{#if $config === null} | ||||
| 								<img | ||||
| 									src="{$settings.gravatarUrl ? $settings.gravatarUrl : '/user'}.png" | ||||
| 									class=" max-w-[28px] object-cover rounded-full" | ||||
| 									alt="User profile" | ||||
| 								/> | ||||
| 							{:else} | ||||
| 								<img | ||||
| 									src={$user ? $user.profile_image_url : '/user.png'} | ||||
| 									class=" max-w-[28px] object-cover rounded-full" | ||||
| 									alt="User profile" | ||||
| 								/> | ||||
| 							{/if} | ||||
| 						{:else} | ||||
| 							<img | ||||
| 								src="/favicon.png" | ||||
| 								class=" max-w-[28px] object-cover rounded-full" | ||||
| 								alt="Ollama profile" | ||||
| 							/> | ||||
| 						{/if} | ||||
| 					</div> | ||||
| 
 | ||||
| 					<div class="w-full"> | ||||
| 						<div class=" self-center font-bold mb-0.5"> | ||||
| 							{#if message.role === 'user'} | ||||
| 								You | ||||
| 							{:else} | ||||
| 								Ollama <span class=" text-gray-500 text-sm font-medium" | ||||
| 									>{message.model ? ` ${message.model}` : ''}</span | ||||
| 								> | ||||
| 							{/if} | ||||
| 						</div> | ||||
| 
 | ||||
| 						{#if message.role !== 'user' && message.content === ''} | ||||
| 							<div class="w-full mt-3"> | ||||
| 								<div class="animate-pulse flex w-full"> | ||||
| 									<div class="space-y-2 w-full"> | ||||
| 										<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded mr-14" /> | ||||
| 
 | ||||
| 										<div class="grid grid-cols-3 gap-4"> | ||||
| 											<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-2" /> | ||||
| 											<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-1" /> | ||||
| 										</div> | ||||
| 										<div class="grid grid-cols-4 gap-4"> | ||||
| 											<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-1" /> | ||||
| 											<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-2" /> | ||||
| 											<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-1 mr-4" /> | ||||
| 										</div> | ||||
| 
 | ||||
| 										<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded" /> | ||||
| 									</div> | ||||
| 								</div> | ||||
| 							</div> | ||||
| 						{:else} | ||||
| 							<div | ||||
| 								class="prose chat-{message.role} w-full max-w-full dark:prose-invert prose-headings:my-0 prose-p:my-0 prose-p:-mb-4 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-img:my-0 prose-ul:-my-4 prose-ol:-my-4 prose-li:-my-3 prose-ul:-mb-6 prose-ol:-mb-6 prose-li:-mb-4 whitespace-pre-line" | ||||
| 							> | ||||
| 								{#if message.role == 'user'} | ||||
| 									{#if message?.edit === true} | ||||
| 										<div class=" w-full"> | ||||
| 											<textarea | ||||
| 												class=" bg-transparent outline-none w-full resize-none" | ||||
| 												bind:value={history.messages[message.id].editedContent} | ||||
| 												on:input={(e) => { | ||||
| 													e.target.style.height = ''; | ||||
| 													e.target.style.height = `${e.target.scrollHeight}px`; | ||||
| 												}} | ||||
| 												on:focus={(e) => { | ||||
| 													e.target.style.height = ''; | ||||
| 													e.target.style.height = `${e.target.scrollHeight}px`; | ||||
| 												}} | ||||
| 											/> | ||||
| 
 | ||||
| 											<div class=" flex justify-end space-x-2 text-sm font-medium"> | ||||
| 												<button | ||||
| 													class="px-4 py-2.5 bg-emerald-600 hover:bg-emerald-700 text-gray-100 transition rounded-lg" | ||||
| 													on:click={() => { | ||||
| 														confirmEditMessage(message.id); | ||||
| 													}} | ||||
| 												> | ||||
| 													Save & Submit | ||||
| 												</button> | ||||
| 
 | ||||
| 												<button | ||||
| 													class=" px-4 py-2.5 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-100 transition outline outline-1 outline-gray-200 dark:outline-gray-600 rounded-lg" | ||||
| 													on:click={() => { | ||||
| 														cancelEditMessage(message.id); | ||||
| 													}} | ||||
| 												> | ||||
| 													Cancel | ||||
| 												</button> | ||||
| 											</div> | ||||
| 										</div> | ||||
| 									{:else} | ||||
| 										<div class="w-full"> | ||||
| 											{message.content} | ||||
| 
 | ||||
| 											<div class=" flex justify-start space-x-1"> | ||||
| 												{#if message.parentId !== null && message.parentId in history.messages && (history.messages[message.parentId]?.childrenIds.length ?? 0) > 1} | ||||
| 													<div class="flex self-center"> | ||||
| 														<button | ||||
| 															class="self-center" | ||||
| 															on:click={() => { | ||||
| 																showPreviousMessage(message); | ||||
| 															}} | ||||
| 														> | ||||
| 															<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="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" | ||||
| 																	clip-rule="evenodd" | ||||
| 																/> | ||||
| 															</svg> | ||||
| 														</button> | ||||
| 
 | ||||
| 														<div class="text-xs font-bold self-center"> | ||||
| 															{history.messages[message.parentId].childrenIds.indexOf(message.id) + | ||||
| 																1} / {history.messages[message.parentId].childrenIds.length} | ||||
| 														</div> | ||||
| 
 | ||||
| 														<button | ||||
| 															class="self-center" | ||||
| 															on:click={() => { | ||||
| 																showNextMessage(message); | ||||
| 															}} | ||||
| 														> | ||||
| 															<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="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" | ||||
| 																	clip-rule="evenodd" | ||||
| 																/> | ||||
| 															</svg> | ||||
| 														</button> | ||||
| 													</div> | ||||
| 												{:else if message.parentId === null && Object.values(history.messages).filter((message) => message.parentId === null).length > 1} | ||||
| 													<div class="flex self-center"> | ||||
| 														<button | ||||
| 															class="self-center" | ||||
| 															on:click={() => { | ||||
| 																showPreviousMessage(message); | ||||
| 															}} | ||||
| 														> | ||||
| 															<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="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" | ||||
| 																	clip-rule="evenodd" | ||||
| 																/> | ||||
| 															</svg> | ||||
| 														</button> | ||||
| 
 | ||||
| 														<div class="text-xs font-bold self-center"> | ||||
| 															{Object.values(history.messages) | ||||
| 																.filter((message) => message.parentId === null) | ||||
| 																.map((message) => message.id) | ||||
| 																.indexOf(message.id) + 1} / {Object.values(history.messages).filter( | ||||
| 																(message) => message.parentId === null | ||||
| 															).length} | ||||
| 														</div> | ||||
| 
 | ||||
| 														<button | ||||
| 															class="self-center" | ||||
| 															on:click={() => { | ||||
| 																showNextMessage(message); | ||||
| 															}} | ||||
| 														> | ||||
| 															<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="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" | ||||
| 																	clip-rule="evenodd" | ||||
| 																/> | ||||
| 															</svg> | ||||
| 														</button> | ||||
| 													</div> | ||||
| 												{/if} | ||||
| 
 | ||||
| 												<button | ||||
| 													class="invisible group-hover:visible p-1 rounded dark:hover:bg-gray-800 transition" | ||||
| 													on:click={() => { | ||||
| 														editMessageHandler(message.id); | ||||
| 													}} | ||||
| 												> | ||||
| 													<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="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125" | ||||
| 														/> | ||||
| 													</svg> | ||||
| 												</button> | ||||
| 											</div> | ||||
| 										</div> | ||||
| 									{/if} | ||||
| 								{:else} | ||||
| 									<div class="w-full"> | ||||
| 										{@html marked(message.content.replace('\\\\', '\\\\\\'))} | ||||
| 
 | ||||
| 										{#if message.done} | ||||
| 											<div class=" flex justify-start space-x-1 -mt-2"> | ||||
| 												{#if message.parentId !== null && message.parentId in history.messages && (history.messages[message.parentId]?.childrenIds.length ?? 0) > 1} | ||||
| 													<div class="flex self-center"> | ||||
| 														<button | ||||
| 															class="self-center" | ||||
| 															on:click={() => { | ||||
| 																showPreviousMessage(message); | ||||
| 															}} | ||||
| 														> | ||||
| 															<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="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" | ||||
| 																	clip-rule="evenodd" | ||||
| 																/> | ||||
| 															</svg> | ||||
| 														</button> | ||||
| 
 | ||||
| 														<div class="text-xs font-bold self-center"> | ||||
| 															{history.messages[message.parentId].childrenIds.indexOf(message.id) + | ||||
| 																1} / {history.messages[message.parentId].childrenIds.length} | ||||
| 														</div> | ||||
| 
 | ||||
| 														<button | ||||
| 															class="self-center" | ||||
| 															on:click={() => { | ||||
| 																showNextMessage(message); | ||||
| 															}} | ||||
| 														> | ||||
| 															<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="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" | ||||
| 																	clip-rule="evenodd" | ||||
| 																/> | ||||
| 															</svg> | ||||
| 														</button> | ||||
| 													</div> | ||||
| 												{/if} | ||||
| 												<button | ||||
| 													class="{messageIdx + 1 === messages.length | ||||
| 														? 'visible' | ||||
| 														: 'invisible group-hover:visible'} p-1 rounded dark:hover:bg-gray-800 transition" | ||||
| 													on:click={() => { | ||||
| 														copyToClipboard(message.content); | ||||
| 													}} | ||||
| 												> | ||||
| 													<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="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" | ||||
| 														/> | ||||
| 													</svg> | ||||
| 												</button> | ||||
| 
 | ||||
| 												<button | ||||
| 													class="{messageIdx + 1 === messages.length | ||||
| 														? 'visible' | ||||
| 														: 'invisible group-hover:visible'} p-1 rounded dark:hover:bg-gray-800 transition" | ||||
| 													on:click={() => { | ||||
| 														rateMessage(messageIdx, 1); | ||||
| 													}} | ||||
| 												> | ||||
| 													<svg | ||||
| 														stroke="currentColor" | ||||
| 														fill="none" | ||||
| 														stroke-width="2" | ||||
| 														viewBox="0 0 24 24" | ||||
| 														stroke-linecap="round" | ||||
| 														stroke-linejoin="round" | ||||
| 														class="w-4 h-4" | ||||
| 														xmlns="http://www.w3.org/2000/svg" | ||||
| 														><path | ||||
| 															d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3" | ||||
| 														/></svg | ||||
| 													> | ||||
| 												</button> | ||||
| 												<button | ||||
| 													class="{messageIdx + 1 === messages.length | ||||
| 														? 'visible' | ||||
| 														: 'invisible group-hover:visible'} p-1 rounded dark:hover:bg-gray-800 transition" | ||||
| 													on:click={() => { | ||||
| 														rateMessage(messageIdx, -1); | ||||
| 													}} | ||||
| 												> | ||||
| 													<svg | ||||
| 														stroke="currentColor" | ||||
| 														fill="none" | ||||
| 														stroke-width="2" | ||||
| 														viewBox="0 0 24 24" | ||||
| 														stroke-linecap="round" | ||||
| 														stroke-linejoin="round" | ||||
| 														class="w-4 h-4" | ||||
| 														xmlns="http://www.w3.org/2000/svg" | ||||
| 														><path | ||||
| 															d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17" | ||||
| 														/></svg | ||||
| 													> | ||||
| 												</button> | ||||
| 
 | ||||
| 												{#if messageIdx + 1 === messages.length} | ||||
| 													<button | ||||
| 														type="button" | ||||
| 														class="{messageIdx + 1 === messages.length | ||||
| 															? 'visible' | ||||
| 															: 'invisible group-hover:visible'} p-1 rounded dark:hover:bg-gray-800 transition" | ||||
| 														on:click={regenerateResponse} | ||||
| 													> | ||||
| 														<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="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" | ||||
| 															/> | ||||
| 														</svg> | ||||
| 													</button> | ||||
| 												{/if} | ||||
| 											</div> | ||||
| 										{/if} | ||||
| 									</div> | ||||
| 								{/if} | ||||
| 							</div> | ||||
| 						{/if} | ||||
| 					</div> | ||||
| 					<!-- {} --> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	{/each} | ||||
| {/if} | ||||
							
								
								
									
										117
									
								
								src/lib/components/chat/ModelSelector.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								src/lib/components/chat/ModelSelector.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,117 @@ | |||
| <script lang="ts"> | ||||
| 	import { models, showSettings, settings } from '$lib/stores'; | ||||
| 	import toast from 'svelte-french-toast'; | ||||
| 
 | ||||
| 	export let selectedModels = ['']; | ||||
| 	export let disabled = false; | ||||
| 
 | ||||
| 	const saveDefaultModel = () => { | ||||
| 		settings.set({ ...$settings, models: selectedModels }); | ||||
| 		localStorage.setItem('settings', JSON.stringify($settings)); | ||||
| 		toast.success('Default model updated'); | ||||
| 	}; | ||||
| </script> | ||||
| 
 | ||||
| <div class="flex flex-col my-2"> | ||||
| 	{#each selectedModels as selectedModel, selectedModelIdx} | ||||
| 		<div class="flex"> | ||||
| 			<select | ||||
| 				id="models" | ||||
| 				class="outline-none bg-transparent text-lg font-semibold rounded-lg block w-full placeholder-gray-400" | ||||
| 				bind:value={selectedModel} | ||||
| 				{disabled} | ||||
| 			> | ||||
| 				<option class=" text-gray-700" value="" selected>Select a model</option> | ||||
| 
 | ||||
| 				{#each $models as model} | ||||
| 					{#if model.name === 'hr'} | ||||
| 						<hr /> | ||||
| 					{:else} | ||||
| 						<option value={model.name} class="text-gray-700 text-lg">{model.name}</option> | ||||
| 					{/if} | ||||
| 				{/each} | ||||
| 			</select> | ||||
| 
 | ||||
| 			{#if selectedModelIdx === 0} | ||||
| 				<button | ||||
| 					class="  self-center {selectedModelIdx === 0 | ||||
| 						? 'mr-3' | ||||
| 						: 'mr-7'} disabled:text-gray-600 disabled:hover:text-gray-600" | ||||
| 					disabled={selectedModels.length === 3 || disabled} | ||||
| 					on:click={() => { | ||||
| 						if (selectedModels.length < 3) { | ||||
| 							selectedModels = [...selectedModels, '']; | ||||
| 						} | ||||
| 					}} | ||||
| 				> | ||||
| 					<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="M12 6v12m6-6H6" /> | ||||
| 					</svg> | ||||
| 				</button> | ||||
| 			{:else} | ||||
| 				<button | ||||
| 					class="  self-center disabled:text-gray-600 disabled:hover:text-gray-600 {selectedModelIdx === | ||||
| 					0 | ||||
| 						? 'mr-3' | ||||
| 						: 'mr-7'}" | ||||
| 					{disabled} | ||||
| 					on:click={() => { | ||||
| 						selectedModels.splice(selectedModelIdx, 1); | ||||
| 						selectedModels = selectedModels; | ||||
| 					}} | ||||
| 				> | ||||
| 					<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="M19.5 12h-15" /> | ||||
| 					</svg> | ||||
| 				</button> | ||||
| 			{/if} | ||||
| 
 | ||||
| 			{#if selectedModelIdx === 0} | ||||
| 				<button | ||||
| 					class=" self-center dark:hover:text-gray-300" | ||||
| 					on:click={async () => { | ||||
| 						await showSettings.set(true); | ||||
| 					}} | ||||
| 				> | ||||
| 					<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="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z" | ||||
| 						/> | ||||
| 						<path | ||||
| 							stroke-linecap="round" | ||||
| 							stroke-linejoin="round" | ||||
| 							d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" | ||||
| 						/> | ||||
| 					</svg> | ||||
| 				</button> | ||||
| 			{/if} | ||||
| 		</div> | ||||
| 	{/each} | ||||
| </div> | ||||
| 
 | ||||
| <div class="text-left mt-1.5 text-xs text-gray-500"> | ||||
| 	<button on:click={saveDefaultModel}> Set as default</button> | ||||
| </div> | ||||
|  | @ -1,14 +1,20 @@ | |||
| <script lang="ts"> | ||||
| 	import sha256 from 'js-sha256'; | ||||
| 	import Modal from '../common/Modal.svelte'; | ||||
| 
 | ||||
| 	import { WEB_UI_VERSION, API_BASE_URL as BUILD_TIME_API_BASE_URL } from '$lib/constants'; | ||||
| 	import { WEB_UI_VERSION, OLLAMA_API_BASE_URL as BUILD_TIME_API_BASE_URL } from '$lib/constants'; | ||||
| 	import toast from 'svelte-french-toast'; | ||||
| 	import { onMount } from 'svelte'; | ||||
| 	import { config, models, settings, user } from '$lib/stores'; | ||||
| 	import { splitStream, getGravatarURL } from '$lib/utils'; | ||||
| 
 | ||||
| 	export let show = false; | ||||
| 	export let saveSettings: Function; | ||||
| 	export let getModelTags: Function; | ||||
| 
 | ||||
| 	const saveSettings = async (updated) => { | ||||
| 		console.log(updated); | ||||
| 		await settings.set({ ...$settings, ...updated }); | ||||
| 		await models.set(await getModels()); | ||||
| 		localStorage.setItem('settings', JSON.stringify($settings)); | ||||
| 	}; | ||||
| 
 | ||||
| 	let selectedTab = 'general'; | ||||
| 
 | ||||
|  | @ -32,6 +38,7 @@ | |||
| 	let pullProgress = null; | ||||
| 
 | ||||
| 	// Addons | ||||
| 	let titleAutoGenerate = true; | ||||
| 	let speechAutoSend = false; | ||||
| 	let gravatarEmail = ''; | ||||
| 	let OPENAI_API_KEY = ''; | ||||
|  | @ -41,42 +48,16 @@ | |||
| 	let authType = 'Basic'; | ||||
| 	let authContent = ''; | ||||
| 
 | ||||
| 	function getGravatarURL(email) { | ||||
| 		// Trim leading and trailing whitespace from | ||||
| 		// an email address and force all characters | ||||
| 		// to lower case | ||||
| 		const address = String(email).trim().toLowerCase(); | ||||
| 
 | ||||
| 		// Create a SHA256 hash of the final string | ||||
| 		const hash = sha256(address); | ||||
| 
 | ||||
| 		// Grab the actual image URL | ||||
| 		return `https://www.gravatar.com/avatar/${hash}`; | ||||
| 	} | ||||
| 
 | ||||
| 	const splitStream = (splitOn) => { | ||||
| 		let buffer = ''; | ||||
| 		return new TransformStream({ | ||||
| 			transform(chunk, controller) { | ||||
| 				buffer += chunk; | ||||
| 				const parts = buffer.split(splitOn); | ||||
| 				parts.slice(0, -1).forEach((part) => controller.enqueue(part)); | ||||
| 				buffer = parts[parts.length - 1]; | ||||
| 			}, | ||||
| 			flush(controller) { | ||||
| 				if (buffer) controller.enqueue(buffer); | ||||
| 			} | ||||
| 		}); | ||||
| 	}; | ||||
| 
 | ||||
| 	const checkOllamaConnection = async () => { | ||||
| 		if (API_BASE_URL === '') { | ||||
| 			API_BASE_URL = BUILD_TIME_API_BASE_URL; | ||||
| 		} | ||||
| 		const res = await getModelTags(API_BASE_URL, 'ollama'); | ||||
| 		const _models = await getModels(API_BASE_URL, 'ollama'); | ||||
| 
 | ||||
| 		if (res) { | ||||
| 		if (_models.length > 0) { | ||||
| 			toast.success('Server connection verified'); | ||||
| 			await models.set(_models); | ||||
| 
 | ||||
| 			saveSettings({ | ||||
| 				API_BASE_URL: API_BASE_URL | ||||
| 			}); | ||||
|  | @ -111,6 +92,11 @@ | |||
| 		saveSettings({ speechAutoSend: speechAutoSend }); | ||||
| 	}; | ||||
| 
 | ||||
| 	const toggleTitleAutoGenerate = async () => { | ||||
| 		titleAutoGenerate = !titleAutoGenerate; | ||||
| 		saveSettings({ titleAutoGenerate: titleAutoGenerate }); | ||||
| 	}; | ||||
| 
 | ||||
| 	const toggleAuthHeader = async () => { | ||||
| 		authEnabled = !authEnabled; | ||||
| 	}; | ||||
|  | @ -119,7 +105,9 @@ | |||
| 		const res = await fetch(`${API_BASE_URL}/pull`, { | ||||
| 			method: 'POST', | ||||
| 			headers: { | ||||
| 				'Content-Type': 'text/event-stream' | ||||
| 				'Content-Type': 'text/event-stream', | ||||
| 				...($settings.authHeader && { Authorization: $settings.authHeader }), | ||||
| 				...($user && { Authorization: `Bearer ${localStorage.token}` }) | ||||
| 			}, | ||||
| 			body: JSON.stringify({ | ||||
| 				name: modelTag | ||||
|  | @ -147,8 +135,12 @@ | |||
| 						if (data.error) { | ||||
| 							throw data.error; | ||||
| 						} | ||||
| 
 | ||||
| 						if (data.detail) { | ||||
| 							throw data.detail; | ||||
| 						} | ||||
| 						if (data.status) { | ||||
| 							if (!data.status.includes('downloading')) { | ||||
| 							if (!data.digest) { | ||||
| 								toast.success(data.status); | ||||
| 							} else { | ||||
| 								digest = data.digest; | ||||
|  | @ -168,14 +160,16 @@ | |||
| 		} | ||||
| 
 | ||||
| 		modelTag = ''; | ||||
| 		await getModelTags(); | ||||
| 		models.set(await getModels()); | ||||
| 	}; | ||||
| 
 | ||||
| 	const deleteModelHandler = async () => { | ||||
| 		const res = await fetch(`${API_BASE_URL}/delete`, { | ||||
| 			method: 'DELETE', | ||||
| 			headers: { | ||||
| 				'Content-Type': 'text/event-stream' | ||||
| 				'Content-Type': 'text/event-stream', | ||||
| 				...($settings.authHeader && { Authorization: $settings.authHeader }), | ||||
| 				...($user && { Authorization: `Bearer ${localStorage.token}` }) | ||||
| 			}, | ||||
| 			body: JSON.stringify({ | ||||
| 				name: deleteModelTag | ||||
|  | @ -203,6 +197,10 @@ | |||
| 						if (data.error) { | ||||
| 							throw data.error; | ||||
| 						} | ||||
| 						if (data.detail) { | ||||
| 							throw data.detail; | ||||
| 						} | ||||
| 
 | ||||
| 						if (data.status) { | ||||
| 						} | ||||
| 					} else { | ||||
|  | @ -216,7 +214,7 @@ | |||
| 		} | ||||
| 
 | ||||
| 		deleteModelTag = ''; | ||||
| 		await getModelTags(); | ||||
| 		models.set(await getModels()); | ||||
| 	}; | ||||
| 
 | ||||
| 	$: if (show) { | ||||
|  | @ -234,11 +232,76 @@ | |||
| 		top_k = settings.top_k ?? 40; | ||||
| 		top_p = settings.top_p ?? 0.9; | ||||
| 
 | ||||
| 		titleAutoGenerate = settings.titleAutoGenerate ?? true; | ||||
| 		speechAutoSend = settings.speechAutoSend ?? false; | ||||
| 		gravatarEmail = settings.gravatarEmail ?? ''; | ||||
| 		OPENAI_API_KEY = settings.OPENAI_API_KEY ?? ''; | ||||
| 	} | ||||
| 
 | ||||
| 	const getModels = async (url = '', type = 'all') => { | ||||
| 		let models = []; | ||||
| 		const res = await fetch(`${url ? url : $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 ?? [])); | ||||
| 
 | ||||
| 		// If OpenAI API Key exists | ||||
| 		if (type === 'all' && $settings.OPENAI_API_KEY) { | ||||
| 			// Validate OPENAI_API_KEY | ||||
| 			const openaiModelRes = await fetch(`https://api.openai.com/v1/models`, { | ||||
| 				method: 'GET', | ||||
| 				headers: { | ||||
| 					'Content-Type': 'application/json', | ||||
| 					Authorization: `Bearer ${$settings.OPENAI_API_KEY}` | ||||
| 				} | ||||
| 			}) | ||||
| 				.then(async (res) => { | ||||
| 					if (!res.ok) throw await res.json(); | ||||
| 					return res.json(); | ||||
| 				}) | ||||
| 				.catch((error) => { | ||||
| 					console.log(error); | ||||
| 					toast.error(`OpenAI: ${error?.error?.message ?? 'Network Problem'}`); | ||||
| 					return null; | ||||
| 				}); | ||||
| 
 | ||||
| 			const openAIModels = openaiModelRes?.data ?? null; | ||||
| 
 | ||||
| 			models.push( | ||||
| 				...(openAIModels | ||||
| 					? [ | ||||
| 							{ name: 'hr' }, | ||||
| 							...openAIModels | ||||
| 								.map((model) => ({ name: model.id, label: 'OpenAI' })) | ||||
| 								.filter((model) => model.name.includes('gpt')) | ||||
| 					  ] | ||||
| 					: []) | ||||
| 			); | ||||
| 		} | ||||
| 
 | ||||
| 		return models; | ||||
| 	}; | ||||
| 
 | ||||
| 	onMount(() => { | ||||
| 		let settings = JSON.parse(localStorage.getItem('settings') ?? '{}'); | ||||
| 
 | ||||
|  | @ -378,31 +441,33 @@ | |||
| 					<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 === | ||||
| 					'auth' | ||||
| 						? 'bg-gray-200 dark:bg-gray-700' | ||||
| 						: ' hover:bg-gray-300 dark:hover:bg-gray-800'}" | ||||
| 					on:click={() => { | ||||
| 						selectedTab = 'auth'; | ||||
| 					}} | ||||
| 				> | ||||
| 					<div class=" self-center mr-2"> | ||||
| 						<svg | ||||
| 							xmlns="http://www.w3.org/2000/svg" | ||||
| 							viewBox="0 0 24 24" | ||||
| 							fill="currentColor" | ||||
| 							class="w-4 h-4" | ||||
| 						> | ||||
| 							<path | ||||
| 								fill-rule="evenodd" | ||||
| 								d="M12.516 2.17a.75.75 0 00-1.032 0 11.209 11.209 0 01-7.877 3.08.75.75 0 00-.722.515A12.74 12.74 0 002.25 9.75c0 5.942 4.064 10.933 9.563 12.348a.749.749 0 00.374 0c5.499-1.415 9.563-6.406 9.563-12.348 0-1.39-.223-2.73-.635-3.985a.75.75 0 00-.722-.516l-.143.001c-2.996 0-5.717-1.17-7.734-3.08zm3.094 8.016a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z" | ||||
| 								clip-rule="evenodd" | ||||
| 							/> | ||||
| 						</svg> | ||||
| 					</div> | ||||
| 					<div class=" self-center">Authentication</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 === | ||||
| 						'auth' | ||||
| 							? 'bg-gray-200 dark:bg-gray-700' | ||||
| 							: ' hover:bg-gray-300 dark:hover:bg-gray-800'}" | ||||
| 						on:click={() => { | ||||
| 							selectedTab = 'auth'; | ||||
| 						}} | ||||
| 					> | ||||
| 						<div class=" self-center mr-2"> | ||||
| 							<svg | ||||
| 								xmlns="http://www.w3.org/2000/svg" | ||||
| 								viewBox="0 0 24 24" | ||||
| 								fill="currentColor" | ||||
| 								class="w-4 h-4" | ||||
| 							> | ||||
| 								<path | ||||
| 									fill-rule="evenodd" | ||||
| 									d="M12.516 2.17a.75.75 0 00-1.032 0 11.209 11.209 0 01-7.877 3.08.75.75 0 00-.722.515A12.74 12.74 0 002.25 9.75c0 5.942 4.064 10.933 9.563 12.348a.749.749 0 00.374 0c5.499-1.415 9.563-6.406 9.563-12.348 0-1.39-.223-2.73-.635-3.985a.75.75 0 00-.722-.516l-.143.001c-2.996 0-5.717-1.17-7.734-3.08zm3.094 8.016a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z" | ||||
| 									clip-rule="evenodd" | ||||
| 								/> | ||||
| 							</svg> | ||||
| 						</div> | ||||
| 						<div class=" self-center">Authentication</div> | ||||
| 					</button> | ||||
| 				{/if} | ||||
| 
 | ||||
| 				<button | ||||
| 					class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab === | ||||
|  | @ -793,6 +858,28 @@ | |||
| 						}} | ||||
| 					> | ||||
| 						<div class=" space-y-3"> | ||||
| 							<div> | ||||
| 								<div class=" py-1 flex w-full justify-between"> | ||||
| 									<div class=" self-center text-sm font-medium">Title Auto Generation</div> | ||||
| 
 | ||||
| 									<button | ||||
| 										class="p-1 px-3 text-xs flex rounded transition" | ||||
| 										on:click={() => { | ||||
| 											toggleTitleAutoGenerate(); | ||||
| 										}} | ||||
| 										type="button" | ||||
| 									> | ||||
| 										{#if titleAutoGenerate === true} | ||||
| 											<span class="ml-2 self-center">On</span> | ||||
| 										{:else} | ||||
| 											<span class="ml-2 self-center">Off</span> | ||||
| 										{/if} | ||||
| 									</button> | ||||
| 								</div> | ||||
| 							</div> | ||||
| 
 | ||||
| 							<hr class=" dark:border-gray-700" /> | ||||
| 
 | ||||
| 							<div> | ||||
| 								<div class=" py-1 flex w-full justify-between"> | ||||
| 									<div class=" self-center text-sm font-medium">Voice Input Auto-Send</div> | ||||
|  | @ -992,7 +1079,7 @@ | |||
| 								<div class=" mb-2.5 text-sm font-medium">Ollama Web UI Version</div> | ||||
| 								<div class="flex w-full"> | ||||
| 									<div class="flex-1 text-xs text-gray-700 dark:text-gray-200"> | ||||
| 										{WEB_UI_VERSION} | ||||
| 										{$config && $config.version ? $config.version : WEB_UI_VERSION} | ||||
| 									</div> | ||||
| 								</div> | ||||
| 							</div> | ||||
|  |  | |||
|  | @ -1,47 +1,10 @@ | |||
| <script lang="ts"> | ||||
| 	import { onMount } from 'svelte'; | ||||
| 	import { v4 as uuidv4 } from 'uuid'; | ||||
| 
 | ||||
| 	let show = false; | ||||
| 	let navElement; | ||||
| 	let importFileInputElement; | ||||
| 	let importFiles; | ||||
| 	import { goto } from '$app/navigation'; | ||||
| 	import { chatId } from '$lib/stores'; | ||||
| 
 | ||||
| 	export let selectedChatId = ''; | ||||
| 	export let title: string = 'Ollama Web UI'; | ||||
| 	export let chats = []; | ||||
| 
 | ||||
| 	export let createNewChat: Function; | ||||
| 	export let loadChat: Function; | ||||
| 	export let deleteChat: Function; | ||||
| 	export let editChatTitle: Function; | ||||
| 	export let importChatHistory: Function; | ||||
| 	export let exportChatHistory: Function; | ||||
| 	export let deleteChatHistory: Function; | ||||
| 	export let openSettings: Function; | ||||
| 
 | ||||
| 	let chatTitleEditIdx = null; | ||||
| 	let chatTitle = ''; | ||||
| 
 | ||||
| 	let _chats = chats.map((item, idx) => chats[chats.length - 1 - idx]); | ||||
| 
 | ||||
| 	onMount(() => {}); | ||||
| 
 | ||||
| 	$: if (chats) { | ||||
| 		_chats = chats.map((item, idx) => chats[chats.length - 1 - idx]); | ||||
| 	} | ||||
| 
 | ||||
| 	$: if (importFiles) { | ||||
| 		console.log(importFiles); | ||||
| 
 | ||||
| 		let reader = new FileReader(); | ||||
| 		reader.onload = (event) => { | ||||
| 			let chats = JSON.parse(event.target.result); | ||||
| 			console.log(chats); | ||||
| 			importChatHistory(chats); | ||||
| 		}; | ||||
| 
 | ||||
| 		reader.readAsText(importFiles[0]); | ||||
| 	} | ||||
| </script> | ||||
| 
 | ||||
| <div | ||||
|  | @ -54,8 +17,10 @@ | |||
| 					<div class="pr-2"> | ||||
| 						<button | ||||
| 							class=" cursor-pointer p-1 flex dark:hover:bg-gray-700 rounded-lg transition" | ||||
| 							on:click={() => { | ||||
| 								createNewChat(); | ||||
| 							on:click={async () => { | ||||
| 								console.log('newChat'); | ||||
| 								goto('/'); | ||||
| 								await chatId.set(uuidv4()); | ||||
| 							}} | ||||
| 						> | ||||
| 							<div class=" m-auto self-center"> | ||||
|  | @ -85,360 +50,3 @@ | |||
| 		</nav> | ||||
| 	</div> | ||||
| </div> | ||||
| 
 | ||||
| <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 | ||||
|         " | ||||
| > | ||||
| 	<div class="py-2.5 my-auto flex flex-col justify-between h-screen"> | ||||
| 		<div class="px-2.5 flex justify-center space-x-2"> | ||||
| 			<button | ||||
| 				class="flex-grow flex justify-between rounded-md px-3 py-1.5 my-2 hover:bg-gray-900 transition" | ||||
| 				on:click={() => { | ||||
| 					createNewChat(); | ||||
| 				}} | ||||
| 			> | ||||
| 				<div class="flex self-center"> | ||||
| 					<div class="self-center mr-3.5"> | ||||
| 						<img src="/ollama.png" class=" w-5 invert-[100%] rounded-full" /> | ||||
| 					</div> | ||||
| 
 | ||||
| 					<div class=" self-center font-medium text-sm">New Chat</div> | ||||
| 				</div> | ||||
| 
 | ||||
| 				<div class="self-center"> | ||||
| 					<svg | ||||
| 						xmlns="http://www.w3.org/2000/svg" | ||||
| 						viewBox="0 0 20 20" | ||||
| 						fill="currentColor" | ||||
| 						class="w-4 h-4" | ||||
| 					> | ||||
| 						<path | ||||
| 							d="M5.433 13.917l1.262-3.155A4 4 0 017.58 9.42l6.92-6.918a2.121 2.121 0 013 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 01-.65-.65z" | ||||
| 						/> | ||||
| 						<path | ||||
| 							d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0010 3H4.75A2.75 2.75 0 002 5.75v9.5A2.75 2.75 0 004.75 18h9.5A2.75 2.75 0 0017 15.25V10a.75.75 0 00-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5z" | ||||
| 						/> | ||||
| 					</svg> | ||||
| 				</div> | ||||
| 			</button> | ||||
| 
 | ||||
| 			<!-- <button | ||||
| 				class=" cursor-pointer w-12 rounded-md flex" | ||||
| 				on:click={() => { | ||||
| 					show = !show; | ||||
| 				}} | ||||
| 			> | ||||
| 				<div class=" m-auto self-center"> | ||||
| 					<svg | ||||
| 						xmlns="http://www.w3.org/2000/svg" | ||||
| 						viewBox="0 0 20 20" | ||||
| 						fill="currentColor" | ||||
| 						class="w-5 h-5" | ||||
| 					> | ||||
| 						<path | ||||
| 							fill-rule="evenodd" | ||||
| 							d="M3 4.25A2.25 2.25 0 015.25 2h5.5A2.25 2.25 0 0113 4.25v2a.75.75 0 01-1.5 0v-2a.75.75 0 00-.75-.75h-5.5a.75.75 0 00-.75.75v11.5c0 .414.336.75.75.75h5.5a.75.75 0 00.75-.75v-2a.75.75 0 011.5 0v2A2.25 2.25 0 0110.75 18h-5.5A2.25 2.25 0 013 15.75V4.25z" | ||||
| 							clip-rule="evenodd" | ||||
| 						/> | ||||
| 						<path | ||||
| 							fill-rule="evenodd" | ||||
| 							d="M19 10a.75.75 0 00-.75-.75H8.704l1.048-.943a.75.75 0 10-1.004-1.114l-2.5 2.25a.75.75 0 000 1.114l2.5 2.25a.75.75 0 101.004-1.114l-1.048-.943h9.546A.75.75 0 0019 10z" | ||||
| 							clip-rule="evenodd" | ||||
| 						/> | ||||
| 					</svg> | ||||
| 				</div> | ||||
| 			</button> --> | ||||
| 		</div> | ||||
| 
 | ||||
| 		<div class="pl-2.5 my-3 flex-1 flex flex-col space-y-1 overflow-y-auto"> | ||||
| 			{#each _chats as chat, i} | ||||
| 				<div class=" w-full pr-2 relative"> | ||||
| 					<button | ||||
| 						class=" w-full flex justify-between rounded-md px-3 py-2 hover:bg-gray-900 {chat.id === | ||||
| 						selectedChatId | ||||
| 							? 'bg-gray-900' | ||||
| 							: ''} transition whitespace-nowrap text-ellipsis" | ||||
| 						on:click={() => { | ||||
| 							if (chat.id !== chatTitleEditIdx) { | ||||
| 								chatTitleEditIdx = null; | ||||
| 								chatTitle = ''; | ||||
| 							} | ||||
| 
 | ||||
| 							loadChat(chat.id); | ||||
| 						}} | ||||
| 					> | ||||
| 						<div class=" flex self-center flex-1"> | ||||
| 							<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-4 h-4" | ||||
| 								> | ||||
| 									<path | ||||
| 										stroke-linecap="round" | ||||
| 										stroke-linejoin="round" | ||||
| 										d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" | ||||
| 									/> | ||||
| 								</svg> | ||||
| 							</div> | ||||
| 							<div | ||||
| 								class=" text-left self-center overflow-hidden {chat.id === selectedChatId | ||||
| 									? 'w-[120px]' | ||||
| 									: 'w-[180px]'} " | ||||
| 							> | ||||
| 								{#if chatTitleEditIdx === chat.id} | ||||
| 									<input bind:value={chatTitle} class=" bg-transparent w-full" /> | ||||
| 								{:else} | ||||
| 									{chat.title} | ||||
| 								{/if} | ||||
| 							</div> | ||||
| 						</div> | ||||
| 					</button> | ||||
| 
 | ||||
| 					{#if chat.id === selectedChatId} | ||||
| 						<div class=" absolute right-[22px] top-[10px]"> | ||||
| 							{#if chatTitleEditIdx === chat.id} | ||||
| 								<div class="flex self-center space-x-1.5"> | ||||
| 									<button | ||||
| 										class=" self-center hover:text-white transition" | ||||
| 										on:click={() => { | ||||
| 											editChatTitle(chat.id, chatTitle); | ||||
| 											chatTitleEditIdx = null; | ||||
| 											chatTitle = ''; | ||||
| 										}} | ||||
| 									> | ||||
| 										<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=" self-center hover:text-white transition" | ||||
| 										on:click={() => { | ||||
| 											chatTitleEditIdx = null; | ||||
| 											chatTitle = ''; | ||||
| 										}} | ||||
| 									> | ||||
| 										<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> | ||||
| 							{:else} | ||||
| 								<div class="flex self-center space-x-1.5"> | ||||
| 									<button | ||||
| 										class=" self-center hover:text-white transition" | ||||
| 										on:click={() => { | ||||
| 											chatTitle = chat.title; | ||||
| 											chatTitleEditIdx = chat.id; | ||||
| 											// editChatTitle(chat.id, 'a'); | ||||
| 										}} | ||||
| 									> | ||||
| 										<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="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125" | ||||
| 											/> | ||||
| 										</svg> | ||||
| 									</button> | ||||
| 									<button | ||||
| 										class=" self-center hover:text-white transition" | ||||
| 										on:click={() => { | ||||
| 											deleteChat(chat.id); | ||||
| 										}} | ||||
| 									> | ||||
| 										<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> | ||||
| 									</button> | ||||
| 								</div> | ||||
| 							{/if} | ||||
| 						</div> | ||||
| 					{/if} | ||||
| 				</div> | ||||
| 			{/each} | ||||
| 		</div> | ||||
| 
 | ||||
| 		<div class="px-2.5"> | ||||
| 			<hr class=" border-gray-800 mb-2 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(); | ||||
| 							// importChatHistory(); | ||||
| 						}} | ||||
| 					> | ||||
| 						<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={() => { | ||||
| 							exportChatHistory(); | ||||
| 						}} | ||||
| 					> | ||||
| 						<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> | ||||
| 				<button | ||||
| 					class=" flex rounded-md py-3 px-3.5 w-full hover:bg-gray-900 transition" | ||||
| 					on:click={() => { | ||||
| 						deleteChatHistory(); | ||||
| 					}} | ||||
| 				> | ||||
| 					<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="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> | ||||
| 					<div class=" self-center">Clear conversations</div> | ||||
| 				</button> | ||||
| 				<button | ||||
| 					class=" flex rounded-md py-3 px-3.5 w-full hover:bg-gray-900 transition" | ||||
| 					on:click={() => { | ||||
| 						openSettings(); | ||||
| 					}} | ||||
| 				> | ||||
| 					<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="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z" | ||||
| 							/> | ||||
| 							<path | ||||
| 								stroke-linecap="round" | ||||
| 								stroke-linejoin="round" | ||||
| 								d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" | ||||
| 							/> | ||||
| 						</svg> | ||||
| 					</div> | ||||
| 					<div class=" self-center font-medium">Settings</div> | ||||
| 				</button> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
| 
 | ||||
| 	<div | ||||
| 		class="fixed left-0 top-[50dvh] z-40 -translate-y-1/2 transition-transform translate-x-[255px] md:translate-x-[260px] rotate-0" | ||||
| 	> | ||||
| 		<button | ||||
| 			class=" group" | ||||
| 			on:click={() => { | ||||
| 				show = !show; | ||||
| 			}} | ||||
| 			><span class="" data-state="closed" | ||||
| 				><div | ||||
| 					class="flex h-[72px] w-8 items-center justify-center opacity-20 group-hover:opacity-100 transition" | ||||
| 				> | ||||
| 					<div class="flex h-6 w-6 flex-col items-center"> | ||||
| 						<div | ||||
| 							class="h-3 w-1 rounded-full bg-[#0f0f0f] dark:bg-white rotate-0 translate-y-[0.15rem] {show | ||||
| 								? 'group-hover:rotate-[15deg]' | ||||
| 								: 'group-hover:rotate-[-15deg]'}" | ||||
| 						/> | ||||
| 						<div | ||||
| 							class="h-3 w-1 rounded-full bg-[#0f0f0f] dark:bg-white rotate-0 translate-y-[-0.15rem] {show | ||||
| 								? 'group-hover:rotate-[-15deg]' | ||||
| 								: 'group-hover:rotate-[15deg]'}" | ||||
| 						/> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			</span> | ||||
| 		</button> | ||||
| 	</div> | ||||
| </div> | ||||
|  |  | |||
							
								
								
									
										559
									
								
								src/lib/components/layout/Sidebar.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										559
									
								
								src/lib/components/layout/Sidebar.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,559 @@ | |||
| <script lang="ts"> | ||||
| 	import { v4 as uuidv4 } from 'uuid'; | ||||
| 
 | ||||
| 	import fileSaver from 'file-saver'; | ||||
| 	const { saveAs } = fileSaver; | ||||
| 
 | ||||
| 	import { goto, invalidateAll } from '$app/navigation'; | ||||
| 	import { page } from '$app/stores'; | ||||
| 	import { user, db, chats, showSettings, chatId } from '$lib/stores'; | ||||
| 	import { onMount } from 'svelte'; | ||||
| 
 | ||||
| 	let show = false; | ||||
| 	let navElement; | ||||
| 	let importFileInputElement; | ||||
| 	let importFiles; | ||||
| 
 | ||||
| 	let title: string = 'Ollama Web UI'; | ||||
| 
 | ||||
| 	let chatTitleEditIdx = null; | ||||
| 	let chatTitle = ''; | ||||
| 
 | ||||
| 	let showDropdown = false; | ||||
| 
 | ||||
| 	onMount(async () => { | ||||
| 		if (window.innerWidth > 1280) { | ||||
| 			show = true; | ||||
| 		} | ||||
| 
 | ||||
| 		await chats.set(await $db.getChats()); | ||||
| 	}); | ||||
| 
 | ||||
| 	const loadChat = async (id) => { | ||||
| 		goto(`/c/${id}`); | ||||
| 	}; | ||||
| 
 | ||||
| 	const editChatTitle = async (id, _title) => { | ||||
| 		await $db.updateChatById(id, { | ||||
| 			title: _title | ||||
| 		}); | ||||
| 		title = _title; | ||||
| 	}; | ||||
| 
 | ||||
| 	const deleteChat = async (id) => { | ||||
| 		goto('/'); | ||||
| 		$db.deleteChatById(id); | ||||
| 	}; | ||||
| 
 | ||||
| 	const deleteChatHistory = async () => { | ||||
| 		await $db.deleteAllChat(); | ||||
| 	}; | ||||
| 
 | ||||
| 	const importChats = async (chatHistory) => { | ||||
| 		await $db.addChats(chatHistory); | ||||
| 	}; | ||||
| 
 | ||||
| 	const exportChats = async () => { | ||||
| 		let blob = new Blob([JSON.stringify(await $db.exportChats())], { type: 'application/json' }); | ||||
| 		saveAs(blob, `chat-export-${Date.now()}.json`); | ||||
| 	}; | ||||
| 
 | ||||
| 	$: 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 | ||||
|         " | ||||
| > | ||||
| 	<div class="py-2.5 my-auto flex flex-col justify-between h-screen"> | ||||
| 		<div class="px-2.5 flex justify-center space-x-2"> | ||||
| 			<button | ||||
| 				class="flex-grow flex justify-between rounded-md px-3 py-1.5 my-2 hover:bg-gray-900 transition" | ||||
| 				on:click={async () => { | ||||
| 					goto('/'); | ||||
| 
 | ||||
| 					await chatId.set(uuidv4()); | ||||
| 					// createNewChat(); | ||||
| 				}} | ||||
| 			> | ||||
| 				<div class="flex self-center"> | ||||
| 					<div class="self-center mr-3.5"> | ||||
| 						<img src="/ollama.png" class=" w-5 invert-[100%] rounded-full" /> | ||||
| 					</div> | ||||
| 
 | ||||
| 					<div class=" self-center font-medium text-sm">New Chat</div> | ||||
| 				</div> | ||||
| 
 | ||||
| 				<div class="self-center"> | ||||
| 					<svg | ||||
| 						xmlns="http://www.w3.org/2000/svg" | ||||
| 						viewBox="0 0 20 20" | ||||
| 						fill="currentColor" | ||||
| 						class="w-4 h-4" | ||||
| 					> | ||||
| 						<path | ||||
| 							d="M5.433 13.917l1.262-3.155A4 4 0 017.58 9.42l6.92-6.918a2.121 2.121 0 013 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 01-.65-.65z" | ||||
| 						/> | ||||
| 						<path | ||||
| 							d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0010 3H4.75A2.75 2.75 0 002 5.75v9.5A2.75 2.75 0 004.75 18h9.5A2.75 2.75 0 0017 15.25V10a.75.75 0 00-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5z" | ||||
| 						/> | ||||
| 					</svg> | ||||
| 				</div> | ||||
| 			</button> | ||||
| 
 | ||||
| 			<!-- <button | ||||
| 				class=" cursor-pointer w-12 rounded-md flex" | ||||
| 				on:click={() => { | ||||
| 					show = !show; | ||||
| 				}} | ||||
| 			> | ||||
| 				<div class=" m-auto self-center"> | ||||
| 					<svg | ||||
| 						xmlns="http://www.w3.org/2000/svg" | ||||
| 						viewBox="0 0 20 20" | ||||
| 						fill="currentColor" | ||||
| 						class="w-5 h-5" | ||||
| 					> | ||||
| 						<path | ||||
| 							fill-rule="evenodd" | ||||
| 							d="M3 4.25A2.25 2.25 0 015.25 2h5.5A2.25 2.25 0 0113 4.25v2a.75.75 0 01-1.5 0v-2a.75.75 0 00-.75-.75h-5.5a.75.75 0 00-.75.75v11.5c0 .414.336.75.75.75h5.5a.75.75 0 00.75-.75v-2a.75.75 0 011.5 0v2A2.25 2.25 0 0110.75 18h-5.5A2.25 2.25 0 013 15.75V4.25z" | ||||
| 							clip-rule="evenodd" | ||||
| 						/> | ||||
| 						<path | ||||
| 							fill-rule="evenodd" | ||||
| 							d="M19 10a.75.75 0 00-.75-.75H8.704l1.048-.943a.75.75 0 10-1.004-1.114l-2.5 2.25a.75.75 0 000 1.114l2.5 2.25a.75.75 0 101.004-1.114l-1.048-.943h9.546A.75.75 0 0019 10z" | ||||
| 							clip-rule="evenodd" | ||||
| 						/> | ||||
| 					</svg> | ||||
| 				</div> | ||||
| 			</button> --> | ||||
| 		</div> | ||||
| 
 | ||||
| 		<div class="pl-2.5 my-3 flex-1 flex flex-col space-y-1 overflow-y-auto"> | ||||
| 			{#each $chats as chat, i} | ||||
| 				<div class=" w-full pr-2 relative"> | ||||
| 					<button | ||||
| 						class=" w-full flex justify-between rounded-md px-3 py-2 hover:bg-gray-900 {chat.id === | ||||
| 						$chatId | ||||
| 							? 'bg-gray-900' | ||||
| 							: ''} transition whitespace-nowrap text-ellipsis" | ||||
| 						on:click={() => { | ||||
| 							// goto(`/c/${chat.id}`); | ||||
| 							if (chat.id !== chatTitleEditIdx) { | ||||
| 								chatTitleEditIdx = null; | ||||
| 								chatTitle = ''; | ||||
| 							} | ||||
| 
 | ||||
| 							if (chat.id !== $chatId) { | ||||
| 								loadChat(chat.id); | ||||
| 							} | ||||
| 						}} | ||||
| 					> | ||||
| 						<div class=" flex self-center flex-1"> | ||||
| 							<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-4 h-4" | ||||
| 								> | ||||
| 									<path | ||||
| 										stroke-linecap="round" | ||||
| 										stroke-linejoin="round" | ||||
| 										d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" | ||||
| 									/> | ||||
| 								</svg> | ||||
| 							</div> | ||||
| 							<div | ||||
| 								class=" text-left self-center overflow-hidden {chat.id === $chatId | ||||
| 									? 'w-[120px]' | ||||
| 									: 'w-[180px]'} " | ||||
| 							> | ||||
| 								{#if chatTitleEditIdx === chat.id} | ||||
| 									<input bind:value={chatTitle} class=" bg-transparent w-full" /> | ||||
| 								{:else} | ||||
| 									{chat.title} | ||||
| 								{/if} | ||||
| 							</div> | ||||
| 						</div> | ||||
| 					</button> | ||||
| 
 | ||||
| 					{#if chat.id === $chatId} | ||||
| 						<div class=" absolute right-[22px] top-[10px]"> | ||||
| 							{#if chatTitleEditIdx === chat.id} | ||||
| 								<div class="flex self-center space-x-1.5"> | ||||
| 									<button | ||||
| 										class=" self-center hover:text-white transition" | ||||
| 										on:click={() => { | ||||
| 											editChatTitle(chat.id, chatTitle); | ||||
| 											chatTitleEditIdx = null; | ||||
| 											chatTitle = ''; | ||||
| 										}} | ||||
| 									> | ||||
| 										<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=" self-center hover:text-white transition" | ||||
| 										on:click={() => { | ||||
| 											chatTitleEditIdx = null; | ||||
| 											chatTitle = ''; | ||||
| 										}} | ||||
| 									> | ||||
| 										<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> | ||||
| 							{:else} | ||||
| 								<div class="flex self-center space-x-1.5"> | ||||
| 									<button | ||||
| 										class=" self-center hover:text-white transition" | ||||
| 										on:click={() => { | ||||
| 											chatTitle = chat.title; | ||||
| 											chatTitleEditIdx = chat.id; | ||||
| 											// editChatTitle(chat.id, 'a'); | ||||
| 										}} | ||||
| 									> | ||||
| 										<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="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125" | ||||
| 											/> | ||||
| 										</svg> | ||||
| 									</button> | ||||
| 									<button | ||||
| 										class=" self-center hover:text-white transition" | ||||
| 										on:click={() => { | ||||
| 											deleteChat(chat.id); | ||||
| 										}} | ||||
| 									> | ||||
| 										<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> | ||||
| 									</button> | ||||
| 								</div> | ||||
| 							{/if} | ||||
| 						</div> | ||||
| 					{/if} | ||||
| 				</div> | ||||
| 			{/each} | ||||
| 		</div> | ||||
| 
 | ||||
| 		<div class="px-2.5"> | ||||
| 			<hr class=" border-gray-800 mb-2 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> | ||||
| 				<button | ||||
| 					class=" flex rounded-md py-3 px-3.5 w-full hover:bg-gray-900 transition" | ||||
| 					on:click={() => { | ||||
| 						deleteChatHistory(); | ||||
| 					}} | ||||
| 				> | ||||
| 					<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="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> | ||||
| 					<div class=" self-center">Clear conversations</div> | ||||
| 				</button> | ||||
| 
 | ||||
| 				{#if $user !== undefined} | ||||
| 					<button | ||||
| 						class=" flex rounded-md py-3 px-3.5 w-full hover:bg-gray-900 transition" | ||||
| 						on:click={() => { | ||||
| 							showDropdown = !showDropdown; | ||||
| 						}} | ||||
| 						on:focusout={() => { | ||||
| 							setTimeout(() => { | ||||
| 								showDropdown = false; | ||||
| 							}, 150); | ||||
| 						}} | ||||
| 					> | ||||
| 						<div class=" self-center mr-3"> | ||||
| 							<img | ||||
| 								src={$user.profile_image_url} | ||||
| 								class=" max-w-[30px] object-cover rounded-full" | ||||
| 								alt="User profile" | ||||
| 							/> | ||||
| 						</div> | ||||
| 						<div class=" self-center font-semibold">{$user.name}</div> | ||||
| 					</button> | ||||
| 
 | ||||
| 					{#if showDropdown} | ||||
| 						<div | ||||
| 							id="dropdownDots" | ||||
| 							class="absolute z-10 bottom-[70px] 4.5rem rounded-lg shadow w-[240px] bg-gray-900" | ||||
| 						> | ||||
| 							<div class="py-2 w-full"> | ||||
| 								{#if $user.role === 'admin'} | ||||
| 									<button | ||||
| 										class="flex py-2.5 px-3.5 w-full hover:bg-gray-800 transition" | ||||
| 										on:click={() => { | ||||
| 											goto('/admin'); | ||||
| 										}} | ||||
| 									> | ||||
| 										<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="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" | ||||
| 												/> | ||||
| 											</svg> | ||||
| 										</div> | ||||
| 										<div class=" self-center font-medium">Admin Panel</div> | ||||
| 									</button> | ||||
| 								{/if} | ||||
| 
 | ||||
| 								<button | ||||
| 									class="flex py-2.5 px-3.5 w-full hover:bg-gray-800 transition" | ||||
| 									on:click={async () => { | ||||
| 										await showSettings.set(true); | ||||
| 									}} | ||||
| 								> | ||||
| 									<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="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z" | ||||
| 											/> | ||||
| 											<path | ||||
| 												stroke-linecap="round" | ||||
| 												stroke-linejoin="round" | ||||
| 												d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" | ||||
| 											/> | ||||
| 										</svg> | ||||
| 									</div> | ||||
| 									<div class=" self-center font-medium">Settings</div> | ||||
| 								</button> | ||||
| 							</div> | ||||
| 
 | ||||
| 							<hr class=" border-gray-700 m-0 p-0" /> | ||||
| 
 | ||||
| 							<div class="py-2 w-full"> | ||||
| 								<button | ||||
| 									class="flex py-2.5 px-3.5 w-full hover:bg-gray-800 transition" | ||||
| 									on:click={() => { | ||||
| 										localStorage.removeItem('token'); | ||||
| 										location.href = '/auth'; | ||||
| 									}} | ||||
| 								> | ||||
| 									<div class=" self-center mr-3"> | ||||
| 										<svg | ||||
| 											xmlns="http://www.w3.org/2000/svg" | ||||
| 											viewBox="0 0 20 20" | ||||
| 											fill="currentColor" | ||||
| 											class="w-5 h-5" | ||||
| 										> | ||||
| 											<path | ||||
| 												fill-rule="evenodd" | ||||
| 												d="M3 4.25A2.25 2.25 0 015.25 2h5.5A2.25 2.25 0 0113 4.25v2a.75.75 0 01-1.5 0v-2a.75.75 0 00-.75-.75h-5.5a.75.75 0 00-.75.75v11.5c0 .414.336.75.75.75h5.5a.75.75 0 00.75-.75v-2a.75.75 0 011.5 0v2A2.25 2.25 0 0110.75 18h-5.5A2.25 2.25 0 013 15.75V4.25z" | ||||
| 												clip-rule="evenodd" | ||||
| 											/> | ||||
| 											<path | ||||
| 												fill-rule="evenodd" | ||||
| 												d="M6 10a.75.75 0 01.75-.75h9.546l-1.048-.943a.75.75 0 111.004-1.114l2.5 2.25a.75.75 0 010 1.114l-2.5 2.25a.75.75 0 11-1.004-1.114l1.048-.943H6.75A.75.75 0 016 10z" | ||||
| 												clip-rule="evenodd" | ||||
| 											/> | ||||
| 										</svg> | ||||
| 									</div> | ||||
| 									<div class=" self-center font-medium">Sign Out</div> | ||||
| 								</button> | ||||
| 							</div> | ||||
| 						</div> | ||||
| 					{/if} | ||||
| 				{:else} | ||||
| 					<button | ||||
| 						class=" flex rounded-md py-3 px-3.5 w-full hover:bg-gray-900 transition" | ||||
| 						on:click={async () => { | ||||
| 							await showSettings.set(true); | ||||
| 						}} | ||||
| 					> | ||||
| 						<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="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z" | ||||
| 								/> | ||||
| 								<path | ||||
| 									stroke-linecap="round" | ||||
| 									stroke-linejoin="round" | ||||
| 									d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" | ||||
| 								/> | ||||
| 							</svg> | ||||
| 						</div> | ||||
| 						<div class=" self-center font-medium">Settings</div> | ||||
| 					</button> | ||||
| 				{/if} | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
| 
 | ||||
| 	<div | ||||
| 		class="fixed left-0 top-[50dvh] z-40 -translate-y-1/2 transition-transform translate-x-[255px] md:translate-x-[260px] rotate-0" | ||||
| 	> | ||||
| 		<button | ||||
| 			class=" group" | ||||
| 			on:click={() => { | ||||
| 				show = !show; | ||||
| 			}} | ||||
| 			><span class="" data-state="closed" | ||||
| 				><div | ||||
| 					class="flex h-[72px] w-8 items-center justify-center opacity-20 group-hover:opacity-100 transition" | ||||
| 				> | ||||
| 					<div class="flex h-6 w-6 flex-col items-center"> | ||||
| 						<div | ||||
| 							class="h-3 w-1 rounded-full bg-[#0f0f0f] dark:bg-white rotate-0 translate-y-[0.15rem] {show | ||||
| 								? 'group-hover:rotate-[15deg]' | ||||
| 								: 'group-hover:rotate-[-15deg]'}" | ||||
| 						/> | ||||
| 						<div | ||||
| 							class="h-3 w-1 rounded-full bg-[#0f0f0f] dark:bg-white rotate-0 translate-y-[-0.15rem] {show | ||||
| 								? 'group-hover:rotate-[-15deg]' | ||||
| 								: 'group-hover:rotate-[15deg]'}" | ||||
| 						/> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			</span> | ||||
| 		</button> | ||||
| 	</div> | ||||
| </div> | ||||
|  | @ -1,14 +1,18 @@ | |||
| import { browser } from '$app/environment'; | ||||
| import { dev, browser } from '$app/environment'; | ||||
| import { PUBLIC_API_BASE_URL } from '$env/static/public'; | ||||
| 
 | ||||
| export const API_BASE_URL = | ||||
| export const OLLAMA_API_BASE_URL = | ||||
| 	PUBLIC_API_BASE_URL === '' | ||||
| 		? browser | ||||
| 		? dev | ||||
| 			? `http://${location.hostname}:8080/ollama/api` | ||||
| 			: browser | ||||
| 			? `http://${location.hostname}:11434/api` | ||||
| 			: `http://localhost:11434/api` | ||||
| 		: PUBLIC_API_BASE_URL; | ||||
| 
 | ||||
| export const WEB_UI_VERSION = 'v1.0.0-alpha.8'; | ||||
| export const WEBUI_API_BASE_URL = dev ? `http://${location.hostname}:8080/api/v1` : `/api/v1`; | ||||
| 
 | ||||
| export const WEB_UI_VERSION = 'v1.0.0-alpha-static'; | ||||
| 
 | ||||
| // Source: https://kit.svelte.dev/docs/modules#$env-static-public
 | ||||
| // This feature, akin to $env/static/private, exclusively incorporates environment variables
 | ||||
|  |  | |||
							
								
								
									
										13
									
								
								src/lib/stores/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src/lib/stores/index.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,13 @@ | |||
| import { writable } from 'svelte/store'; | ||||
| 
 | ||||
| // Backend
 | ||||
| export const config = writable(undefined); | ||||
| export const user = writable(undefined); | ||||
| 
 | ||||
| // Frontend
 | ||||
| export const db = writable(undefined); | ||||
| export const chatId = writable(''); | ||||
| export const chats = writable([]); | ||||
| export const models = writable([]); | ||||
| export const settings = writable({}); | ||||
| export const showSettings = writable(false); | ||||
							
								
								
									
										67
									
								
								src/lib/utils/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								src/lib/utils/index.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,67 @@ | |||
| import { v4 as uuidv4 } from 'uuid'; | ||||
| import sha256 from 'js-sha256'; | ||||
| 
 | ||||
| //////////////////////////
 | ||||
| // Helper functions
 | ||||
| //////////////////////////
 | ||||
| 
 | ||||
| export const splitStream = (splitOn) => { | ||||
| 	let buffer = ''; | ||||
| 	return new TransformStream({ | ||||
| 		transform(chunk, controller) { | ||||
| 			buffer += chunk; | ||||
| 			const parts = buffer.split(splitOn); | ||||
| 			parts.slice(0, -1).forEach((part) => controller.enqueue(part)); | ||||
| 			buffer = parts[parts.length - 1]; | ||||
| 		}, | ||||
| 		flush(controller) { | ||||
| 			if (buffer) controller.enqueue(buffer); | ||||
| 		} | ||||
| 	}); | ||||
| }; | ||||
| 
 | ||||
| export const convertMessagesToHistory = (messages) => { | ||||
| 	let history = { | ||||
| 		messages: {}, | ||||
| 		currentId: null | ||||
| 	}; | ||||
| 
 | ||||
| 	let parentMessageId = null; | ||||
| 	let messageId = null; | ||||
| 
 | ||||
| 	for (const message of messages) { | ||||
| 		messageId = uuidv4(); | ||||
| 
 | ||||
| 		if (parentMessageId !== null) { | ||||
| 			history.messages[parentMessageId].childrenIds = [ | ||||
| 				...history.messages[parentMessageId].childrenIds, | ||||
| 				messageId | ||||
| 			]; | ||||
| 		} | ||||
| 
 | ||||
| 		history.messages[messageId] = { | ||||
| 			...message, | ||||
| 			id: messageId, | ||||
| 			parentId: parentMessageId, | ||||
| 			childrenIds: [] | ||||
| 		}; | ||||
| 
 | ||||
| 		parentMessageId = messageId; | ||||
| 	} | ||||
| 
 | ||||
| 	history.currentId = messageId; | ||||
| 	return history; | ||||
| }; | ||||
| 
 | ||||
| export const getGravatarURL = (email) => { | ||||
| 	// Trim leading and trailing whitespace from
 | ||||
| 	// an email address and force all characters
 | ||||
| 	// to lower case
 | ||||
| 	const address = String(email).trim().toLowerCase(); | ||||
| 
 | ||||
| 	// Create a SHA256 hash of the final string
 | ||||
| 	const hash = sha256(address); | ||||
| 
 | ||||
| 	// Grab the actual image URL
 | ||||
| 	return `https://www.gravatar.com/avatar/${hash}`; | ||||
| }; | ||||
							
								
								
									
										221
									
								
								src/routes/(app)/+layout.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										221
									
								
								src/routes/(app)/+layout.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,221 @@ | |||
| <script lang="ts"> | ||||
| 	import { openDB, deleteDB } from 'idb'; | ||||
| 	import { onMount, tick } from 'svelte'; | ||||
| 	import { goto } from '$app/navigation'; | ||||
| 
 | ||||
| 	import { config, user, showSettings, settings, models, db, chats } from '$lib/stores'; | ||||
| 
 | ||||
| 	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 } from '$lib/constants'; | ||||
| 
 | ||||
| 	let loaded = false; | ||||
| 
 | ||||
| 	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 ?? [])); | ||||
| 
 | ||||
| 		// If OpenAI API Key exists | ||||
| 		if ($settings.OPENAI_API_KEY) { | ||||
| 			// Validate OPENAI_API_KEY | ||||
| 			const openaiModelRes = await fetch(`https://api.openai.com/v1/models`, { | ||||
| 				method: 'GET', | ||||
| 				headers: { | ||||
| 					'Content-Type': 'application/json', | ||||
| 					Authorization: `Bearer ${$settings.OPENAI_API_KEY}` | ||||
| 				} | ||||
| 			}) | ||||
| 				.then(async (res) => { | ||||
| 					if (!res.ok) throw await res.json(); | ||||
| 					return res.json(); | ||||
| 				}) | ||||
| 				.catch((error) => { | ||||
| 					console.log(error); | ||||
| 					toast.error(`OpenAI: ${error?.error?.message ?? 'Network Problem'}`); | ||||
| 					return null; | ||||
| 				}); | ||||
| 
 | ||||
| 			const openAIModels = openaiModelRes?.data ?? null; | ||||
| 
 | ||||
| 			models.push( | ||||
| 				...(openAIModels | ||||
| 					? [ | ||||
| 							{ name: 'hr' }, | ||||
| 							...openAIModels | ||||
| 								.map((model) => ({ name: model.id, label: 'OpenAI' })) | ||||
| 								.filter((model) => model.name.includes('gpt')) | ||||
| 					  ] | ||||
| 					: []) | ||||
| 			); | ||||
| 		} | ||||
| 
 | ||||
| 		return models; | ||||
| 	}; | ||||
| 
 | ||||
| 	const getDB = async () => { | ||||
| 		const _db = await openDB('Chats', 1, { | ||||
| 			upgrade(db) { | ||||
| 				const store = db.createObjectStore('chats', { | ||||
| 					keyPath: 'id', | ||||
| 					autoIncrement: true | ||||
| 				}); | ||||
| 				store.createIndex('timestamp', 'timestamp'); | ||||
| 			} | ||||
| 		}); | ||||
| 
 | ||||
| 		return { | ||||
| 			db: _db, | ||||
| 			getChatById: async function (id) { | ||||
| 				return await this.db.get('chats', id); | ||||
| 			}, | ||||
| 			getChats: async function () { | ||||
| 				let chats = await this.db.getAllFromIndex('chats', 'timestamp'); | ||||
| 				chats = chats.map((item, idx) => ({ | ||||
| 					title: chats[chats.length - 1 - idx].title, | ||||
| 					id: chats[chats.length - 1 - idx].id | ||||
| 				})); | ||||
| 				return chats; | ||||
| 			}, | ||||
| 			exportChats: async function () { | ||||
| 				let chats = await this.db.getAllFromIndex('chats', 'timestamp'); | ||||
| 				chats = chats.map((item, idx) => chats[chats.length - 1 - idx]); | ||||
| 				return chats; | ||||
| 			}, | ||||
| 			addChats: async function (_chats) { | ||||
| 				for (const chat of _chats) { | ||||
| 					console.log(chat); | ||||
| 					await this.addChat(chat); | ||||
| 				} | ||||
| 				await chats.set(await this.getChats()); | ||||
| 			}, | ||||
| 			addChat: async function (chat) { | ||||
| 				await this.db.put('chats', { | ||||
| 					...chat | ||||
| 				}); | ||||
| 			}, | ||||
| 			createNewChat: async function (chat) { | ||||
| 				await this.addChat({ ...chat, timestamp: Date.now() }); | ||||
| 				await chats.set(await this.getChats()); | ||||
| 			}, | ||||
| 			updateChatById: async function (id, updated) { | ||||
| 				const chat = await this.getChatById(id); | ||||
| 
 | ||||
| 				await this.db.put('chats', { | ||||
| 					...chat, | ||||
| 					...updated, | ||||
| 					timestamp: Date.now() | ||||
| 				}); | ||||
| 
 | ||||
| 				await chats.set(await this.getChats()); | ||||
| 			}, | ||||
| 			deleteChatById: async function (id) { | ||||
| 				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()); | ||||
| 			} | ||||
| 		}; | ||||
| 	}; | ||||
| 
 | ||||
| 	onMount(async () => { | ||||
| 		if ($config && $config.auth && $user === undefined) { | ||||
| 			await goto('/auth'); | ||||
| 		} | ||||
| 
 | ||||
| 		await settings.set(JSON.parse(localStorage.getItem('settings') ?? JSON.stringify($settings))); | ||||
| 
 | ||||
| 		let _models = await getModels(); | ||||
| 		await models.set(_models); | ||||
| 		let _db = await getDB(); | ||||
| 		await db.set(_db); | ||||
| 
 | ||||
| 		await tick(); | ||||
| 		loaded = true; | ||||
| 	}); | ||||
| </script> | ||||
| 
 | ||||
| {#if loaded} | ||||
| 	<div class="app"> | ||||
| 		<div | ||||
| 			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> | ||||
| {/if} | ||||
| 
 | ||||
| <style> | ||||
| 	.loading { | ||||
| 		display: inline-block; | ||||
| 		clip-path: inset(0 1ch 0 0); | ||||
| 		animation: l 1s steps(3) infinite; | ||||
| 		letter-spacing: -0.5px; | ||||
| 	} | ||||
| 
 | ||||
| 	@keyframes l { | ||||
| 		to { | ||||
| 			clip-path: inset(0 -1ch 0 0); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	pre[class*='language-'] { | ||||
| 		position: relative; | ||||
| 		overflow: auto; | ||||
| 
 | ||||
| 		/* make space  */ | ||||
| 		margin: 5px 0; | ||||
| 		padding: 1.75rem 0 1.75rem 1rem; | ||||
| 		border-radius: 10px; | ||||
| 	} | ||||
| 
 | ||||
| 	pre[class*='language-'] button { | ||||
| 		position: absolute; | ||||
| 		top: 5px; | ||||
| 		right: 5px; | ||||
| 
 | ||||
| 		font-size: 0.9rem; | ||||
| 		padding: 0.15rem; | ||||
| 		background-color: #828282; | ||||
| 
 | ||||
| 		border: ridge 1px #7b7b7c; | ||||
| 		border-radius: 5px; | ||||
| 		text-shadow: #c4c4c4 0 0 2px; | ||||
| 	} | ||||
| 
 | ||||
| 	pre[class*='language-'] button:hover { | ||||
| 		cursor: pointer; | ||||
| 		background-color: #bcbabb; | ||||
| 	} | ||||
| </style> | ||||
							
								
								
									
										481
									
								
								src/routes/(app)/+page.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										481
									
								
								src/routes/(app)/+page.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,481 @@ | |||
| <script lang="ts"> | ||||
| 	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 { config, user, settings, db, chats, chatId } from '$lib/stores'; | ||||
| 
 | ||||
| 	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'; | ||||
| 
 | ||||
| 	let stopResponseFlag = false; | ||||
| 	let autoScroll = true; | ||||
| 
 | ||||
| 	let selectedModels = ['']; | ||||
| 
 | ||||
| 	let title = ''; | ||||
| 	let prompt = ''; | ||||
| 
 | ||||
| 	let messages = []; | ||||
| 	let history = { | ||||
| 		messages: {}, | ||||
| 		currentId: null | ||||
| 	}; | ||||
| 
 | ||||
| 	$: if (history.currentId !== null) { | ||||
| 		let _messages = []; | ||||
| 
 | ||||
| 		let currentMessage = history.messages[history.currentId]; | ||||
| 		while (currentMessage !== null) { | ||||
| 			_messages.unshift({ ...currentMessage }); | ||||
| 			currentMessage = | ||||
| 				currentMessage.parentId !== null ? history.messages[currentMessage.parentId] : null; | ||||
| 		} | ||||
| 		messages = _messages; | ||||
| 	} | ||||
| 
 | ||||
| 	onMount(async () => { | ||||
| 		await chatId.set(uuidv4()); | ||||
| 
 | ||||
| 		chatId.subscribe(async () => { | ||||
| 			await initNewChat(); | ||||
| 		}); | ||||
| 	}); | ||||
| 
 | ||||
| 	////////////////////////// | ||||
| 	// Web functions | ||||
| 	////////////////////////// | ||||
| 
 | ||||
| 	const initNewChat = async () => { | ||||
| 		console.log($chatId); | ||||
| 
 | ||||
| 		autoScroll = true; | ||||
| 
 | ||||
| 		title = ''; | ||||
| 		messages = []; | ||||
| 		history = { | ||||
| 			messages: {}, | ||||
| 			currentId: null | ||||
| 		}; | ||||
| 		selectedModels = $settings.models ?? ['']; | ||||
| 	}; | ||||
| 
 | ||||
| 	////////////////////////// | ||||
| 	// Ollama functions | ||||
| 	////////////////////////// | ||||
| 
 | ||||
| 	const sendPrompt = async (userPrompt, parentId) => { | ||||
| 		await Promise.all( | ||||
| 			selectedModels.map(async (model) => { | ||||
| 				if (model.includes('gpt-')) { | ||||
| 					await sendPromptOpenAI(model, userPrompt, parentId); | ||||
| 				} else { | ||||
| 					await sendPromptOllama(model, userPrompt, parentId); | ||||
| 				} | ||||
| 			}) | ||||
| 		); | ||||
| 
 | ||||
| 		await chats.set(await $db.getChats()); | ||||
| 	}; | ||||
| 
 | ||||
| 	const sendPromptOllama = async (model, userPrompt, parentId) => { | ||||
| 		console.log('sendPromptOllama'); | ||||
| 		let responseMessageId = uuidv4(); | ||||
| 
 | ||||
| 		let responseMessage = { | ||||
| 			parentId: parentId, | ||||
| 			id: responseMessageId, | ||||
| 			childrenIds: [], | ||||
| 			role: 'assistant', | ||||
| 			content: '', | ||||
| 			model: model | ||||
| 		}; | ||||
| 
 | ||||
| 		history.messages[responseMessageId] = responseMessage; | ||||
| 		history.currentId = responseMessageId; | ||||
| 		if (parentId !== null) { | ||||
| 			history.messages[parentId].childrenIds = [ | ||||
| 				...history.messages[parentId].childrenIds, | ||||
| 				responseMessageId | ||||
| 			]; | ||||
| 		} | ||||
| 
 | ||||
| 		window.scrollTo({ top: document.body.scrollHeight }); | ||||
| 
 | ||||
| 		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: model, | ||||
| 				prompt: userPrompt, | ||||
| 				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 | ||||
| 				}, | ||||
| 				format: $settings.requestFormat ?? undefined, | ||||
| 				context: | ||||
| 					history.messages[parentId] !== null && | ||||
| 					history.messages[parentId].parentId in history.messages | ||||
| 						? history.messages[history.messages[parentId].parentId]?.context ?? undefined | ||||
| 						: undefined | ||||
| 			}) | ||||
| 		}); | ||||
| 
 | ||||
| 		const reader = res.body | ||||
| 			.pipeThrough(new TextDecoderStream()) | ||||
| 			.pipeThrough(splitStream('\n')) | ||||
| 			.getReader(); | ||||
| 
 | ||||
| 		while (true) { | ||||
| 			const { value, done } = await reader.read(); | ||||
| 			if (done || stopResponseFlag) { | ||||
| 				if (stopResponseFlag) { | ||||
| 					responseMessage.done = true; | ||||
| 					messages = messages; | ||||
| 				} | ||||
| 
 | ||||
| 				break; | ||||
| 			} | ||||
| 
 | ||||
| 			try { | ||||
| 				let lines = value.split('\n'); | ||||
| 
 | ||||
| 				for (const line of lines) { | ||||
| 					if (line !== '') { | ||||
| 						console.log(line); | ||||
| 						let data = JSON.parse(line); | ||||
| 						if (data.done == false) { | ||||
| 							if (responseMessage.content == '' && data.response == '\n') { | ||||
| 								continue; | ||||
| 							} else { | ||||
| 								responseMessage.content += data.response; | ||||
| 								messages = messages; | ||||
| 							} | ||||
| 						} else if ('detail' in data) { | ||||
| 							throw data; | ||||
| 						} else { | ||||
| 							responseMessage.done = true; | ||||
| 							responseMessage.context = data.context; | ||||
| 							messages = messages; | ||||
| 						} | ||||
| 					} | ||||
| 				} | ||||
| 			} catch (error) { | ||||
| 				console.log(error); | ||||
| 				if ('detail' in error) { | ||||
| 					toast.error(error.detail); | ||||
| 				} | ||||
| 				break; | ||||
| 			} | ||||
| 
 | ||||
| 			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 | ||||
| 				}, | ||||
| 				messages: messages, | ||||
| 				history: history | ||||
| 			}); | ||||
| 		} | ||||
| 
 | ||||
| 		stopResponseFlag = false; | ||||
| 		await tick(); | ||||
| 		if (autoScroll) { | ||||
| 			window.scrollTo({ top: document.body.scrollHeight }); | ||||
| 		} | ||||
| 
 | ||||
| 		if (messages.length == 2 && messages.at(1).content !== '') { | ||||
| 			window.history.replaceState(history.state, '', `/c/${$chatId}`); | ||||
| 			await generateChatTitle($chatId, userPrompt); | ||||
| 		} | ||||
| 	}; | ||||
| 
 | ||||
| 	const sendPromptOpenAI = async (model, userPrompt, parentId) => { | ||||
| 		if ($settings.OPENAI_API_KEY) { | ||||
| 			if (models) { | ||||
| 				let responseMessageId = uuidv4(); | ||||
| 
 | ||||
| 				let responseMessage = { | ||||
| 					parentId: parentId, | ||||
| 					id: responseMessageId, | ||||
| 					childrenIds: [], | ||||
| 					role: 'assistant', | ||||
| 					content: '', | ||||
| 					model: model | ||||
| 				}; | ||||
| 
 | ||||
| 				history.messages[responseMessageId] = responseMessage; | ||||
| 				history.currentId = responseMessageId; | ||||
| 				if (parentId !== null) { | ||||
| 					history.messages[parentId].childrenIds = [ | ||||
| 						...history.messages[parentId].childrenIds, | ||||
| 						responseMessageId | ||||
| 					]; | ||||
| 				} | ||||
| 
 | ||||
| 				window.scrollTo({ top: document.body.scrollHeight }); | ||||
| 
 | ||||
| 				const res = await fetch(`https://api.openai.com/v1/chat/completions`, { | ||||
| 					method: 'POST', | ||||
| 					headers: { | ||||
| 						'Content-Type': 'application/json', | ||||
| 						Authorization: `Bearer ${$settings.OPENAI_API_KEY}` | ||||
| 					}, | ||||
| 					body: JSON.stringify({ | ||||
| 						model: model, | ||||
| 						stream: true, | ||||
| 						messages: [ | ||||
| 							$settings.system | ||||
| 								? { | ||||
| 										role: 'system', | ||||
| 										content: $settings.system | ||||
| 								  } | ||||
| 								: undefined, | ||||
| 							...messages | ||||
| 						] | ||||
| 							.filter((message) => message) | ||||
| 							.map((message) => ({ role: message.role, content: message.content })), | ||||
| 						temperature: $settings.temperature ?? undefined, | ||||
| 						top_p: $settings.top_p ?? undefined, | ||||
| 						frequency_penalty: $settings.repeat_penalty ?? undefined | ||||
| 					}) | ||||
| 				}); | ||||
| 
 | ||||
| 				const reader = res.body | ||||
| 					.pipeThrough(new TextDecoderStream()) | ||||
| 					.pipeThrough(splitStream('\n')) | ||||
| 					.getReader(); | ||||
| 
 | ||||
| 				while (true) { | ||||
| 					const { value, done } = await reader.read(); | ||||
| 					if (done || stopResponseFlag) { | ||||
| 						if (stopResponseFlag) { | ||||
| 							responseMessage.done = true; | ||||
| 							messages = messages; | ||||
| 						} | ||||
| 
 | ||||
| 						break; | ||||
| 					} | ||||
| 
 | ||||
| 					try { | ||||
| 						let lines = value.split('\n'); | ||||
| 
 | ||||
| 						for (const line of lines) { | ||||
| 							if (line !== '') { | ||||
| 								console.log(line); | ||||
| 								if (line === 'data: [DONE]') { | ||||
| 									responseMessage.done = true; | ||||
| 									messages = messages; | ||||
| 								} else { | ||||
| 									let data = JSON.parse(line.replace(/^data: /, '')); | ||||
| 									console.log(data); | ||||
| 
 | ||||
| 									if (responseMessage.content == '' && data.choices[0].delta.content == '\n') { | ||||
| 										continue; | ||||
| 									} else { | ||||
| 										responseMessage.content += data.choices[0].delta.content ?? ''; | ||||
| 										messages = messages; | ||||
| 									} | ||||
| 								} | ||||
| 							} | ||||
| 						} | ||||
| 					} catch (error) { | ||||
| 						console.log(error); | ||||
| 					} | ||||
| 
 | ||||
| 					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 | ||||
| 						}, | ||||
| 						messages: messages, | ||||
| 						history: history | ||||
| 					}); | ||||
| 				} | ||||
| 
 | ||||
| 				stopResponseFlag = false; | ||||
| 
 | ||||
| 				await tick(); | ||||
| 				if (autoScroll) { | ||||
| 					window.scrollTo({ top: document.body.scrollHeight }); | ||||
| 				} | ||||
| 
 | ||||
| 				if (messages.length == 2) { | ||||
| 					window.history.replaceState(history.state, '', `/c/${$chatId}`); | ||||
| 					await setChatTitle($chatId, userPrompt); | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	}; | ||||
| 
 | ||||
| 	const submitPrompt = async (userPrompt) => { | ||||
| 		console.log('submitPrompt'); | ||||
| 
 | ||||
| 		if (selectedModels.includes('')) { | ||||
| 			toast.error('Model not selected'); | ||||
| 		} else if (messages.length != 0 && messages.at(-1).done != true) { | ||||
| 			console.log('wait'); | ||||
| 		} else { | ||||
| 			document.getElementById('chat-textarea').style.height = ''; | ||||
| 
 | ||||
| 			let userMessageId = uuidv4(); | ||||
| 			let userMessage = { | ||||
| 				id: userMessageId, | ||||
| 				parentId: messages.length !== 0 ? messages.at(-1).id : null, | ||||
| 				childrenIds: [], | ||||
| 				role: 'user', | ||||
| 				content: userPrompt | ||||
| 			}; | ||||
| 
 | ||||
| 			if (messages.length !== 0) { | ||||
| 				history.messages[messages.at(-1).id].childrenIds.push(userMessageId); | ||||
| 			} | ||||
| 
 | ||||
| 			history.messages[userMessageId] = userMessage; | ||||
| 			history.currentId = userMessageId; | ||||
| 
 | ||||
| 			prompt = ''; | ||||
| 
 | ||||
| 			if (messages.length == 0) { | ||||
| 				await $db.createNewChat({ | ||||
| 					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 | ||||
| 					}, | ||||
| 					messages: messages, | ||||
| 					history: history | ||||
| 				}); | ||||
| 			} | ||||
| 
 | ||||
| 			setTimeout(() => { | ||||
| 				window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); | ||||
| 			}, 50); | ||||
| 
 | ||||
| 			await sendPrompt(userPrompt, userMessageId); | ||||
| 		} | ||||
| 	}; | ||||
| 
 | ||||
| 	const stopResponse = () => { | ||||
| 		stopResponseFlag = true; | ||||
| 		console.log('stopResponse'); | ||||
| 	}; | ||||
| 
 | ||||
| 	const regenerateResponse = async () => { | ||||
| 		console.log('regenerateResponse'); | ||||
| 		if (messages.length != 0 && messages.at(-1).done == true) { | ||||
| 			messages.splice(messages.length - 1, 1); | ||||
| 			messages = messages; | ||||
| 
 | ||||
| 			let userMessage = messages.at(-1); | ||||
| 			let userPrompt = userMessage.content; | ||||
| 
 | ||||
| 			await sendPrompt(userPrompt, userMessage.id); | ||||
| 		} | ||||
| 	}; | ||||
| 
 | ||||
| 	const generateChatTitle = async (_chatId, userPrompt) => { | ||||
| 		if ($settings.titleAutoGenerate ?? true) { | ||||
| 			console.log('generateChatTitle'); | ||||
| 
 | ||||
| 			const res = await fetch(`${$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL}/generate`, { | ||||
| 				method: 'POST', | ||||
| 				headers: { | ||||
| 					'Content-Type': 'text/event-stream', | ||||
| 					...($settings.authHeader && { Authorization: $settings.authHeader }), | ||||
| 					...($user && { Authorization: `Bearer ${localStorage.token}` }) | ||||
| 				}, | ||||
| 				body: JSON.stringify({ | ||||
| 					model: selectedModels[0], | ||||
| 					prompt: `Generate a brief 3-5 word title for this question, excluding the term 'title.' Then, please reply with only the title: ${userPrompt}`, | ||||
| 					stream: false | ||||
| 				}) | ||||
| 			}) | ||||
| 				.then(async (res) => { | ||||
| 					if (!res.ok) throw await res.json(); | ||||
| 					return res.json(); | ||||
| 				}) | ||||
| 				.catch((error) => { | ||||
| 					if ('detail' in error) { | ||||
| 						toast.error(error.detail); | ||||
| 					} | ||||
| 					console.log(error); | ||||
| 					return null; | ||||
| 				}); | ||||
| 
 | ||||
| 			if (res) { | ||||
| 				await setChatTitle(_chatId, res.response === '' ? 'New Chat' : res.response); | ||||
| 			} | ||||
| 		} else { | ||||
| 			await setChatTitle(_chatId, `${userPrompt}`); | ||||
| 		} | ||||
| 	}; | ||||
| 
 | ||||
| 	const setChatTitle = async (_chatId, _title) => { | ||||
| 		await $db.updateChatById(_chatId, { title: _title }); | ||||
| 		if (_chatId === $chatId) { | ||||
| 			title = _title; | ||||
| 		} | ||||
| 	}; | ||||
| </script> | ||||
| 
 | ||||
| <svelte:window | ||||
| 	on:scroll={(e) => { | ||||
| 		autoScroll = window.innerHeight + window.scrollY >= document.body.offsetHeight - 40; | ||||
| 	}} | ||||
| /> | ||||
| 
 | ||||
| <Navbar {title} /> | ||||
| <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"> | ||||
| 			<ModelSelector bind:selectedModels disabled={messages.length > 0} /> | ||||
| 		</div> | ||||
| 
 | ||||
| 		<div class=" h-full mt-10 mb-32 w-full flex flex-col"> | ||||
| 			<Messages bind:history bind:messages bind:autoScroll {sendPrompt} {regenerateResponse} /> | ||||
| 		</div> | ||||
| 	</div> | ||||
| 
 | ||||
| 	<MessageInput bind:prompt bind:autoScroll {messages} {submitPrompt} {stopResponse} /> | ||||
| </div> | ||||
							
								
								
									
										519
									
								
								src/routes/(app)/c/[id]/+page.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										519
									
								
								src/routes/(app)/c/[id]/+page.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,519 @@ | |||
| <script lang="ts"> | ||||
| 	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, user, settings, db, chats, chatId } from '$lib/stores'; | ||||
| 
 | ||||
| 	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'; | ||||
| 
 | ||||
| 	let loaded = false; | ||||
| 	let stopResponseFlag = false; | ||||
| 	let autoScroll = true; | ||||
| 
 | ||||
| 	// let chatId = $page.params.id; | ||||
| 	let selectedModels = ['']; | ||||
| 
 | ||||
| 	let title = ''; | ||||
| 	let prompt = ''; | ||||
| 
 | ||||
| 	let messages = []; | ||||
| 	let history = { | ||||
| 		messages: {}, | ||||
| 		currentId: null | ||||
| 	}; | ||||
| 
 | ||||
| 	$: if (history.currentId !== null) { | ||||
| 		let _messages = []; | ||||
| 
 | ||||
| 		let currentMessage = history.messages[history.currentId]; | ||||
| 		while (currentMessage !== null) { | ||||
| 			_messages.unshift({ ...currentMessage }); | ||||
| 			currentMessage = | ||||
| 				currentMessage.parentId !== null ? history.messages[currentMessage.parentId] : null; | ||||
| 		} | ||||
| 		messages = _messages; | ||||
| 	} | ||||
| 
 | ||||
| 	// onMount(async () => { | ||||
| 	// 	let chat = await loadChat(); | ||||
| 
 | ||||
| 	// 	await tick(); | ||||
| 	// 	if (chat) { | ||||
| 	// 		loaded = true; | ||||
| 	// 	} else { | ||||
| 	// 		await goto('/'); | ||||
| 	// 	} | ||||
| 	// }); | ||||
| 
 | ||||
| 	$: if ($page.params.id) { | ||||
| 		(async () => { | ||||
| 			let chat = await loadChat(); | ||||
| 
 | ||||
| 			await tick(); | ||||
| 			if (chat) { | ||||
| 				loaded = true; | ||||
| 			} else { | ||||
| 				await goto('/'); | ||||
| 			} | ||||
| 		})(); | ||||
| 	} | ||||
| 
 | ||||
| 	////////////////////////// | ||||
| 	// Web functions | ||||
| 	////////////////////////// | ||||
| 
 | ||||
| 	const loadChat = async () => { | ||||
| 		await chatId.set($page.params.id); | ||||
| 		const chat = await $db.getChatById($chatId); | ||||
| 
 | ||||
| 		if (chat) { | ||||
| 			console.log(chat); | ||||
| 
 | ||||
| 			selectedModels = (chat?.models ?? undefined) !== undefined ? chat.models : [chat.model ?? '']; | ||||
| 			history = | ||||
| 				(chat?.history ?? undefined) !== undefined | ||||
| 					? chat.history | ||||
| 					: convertMessagesToHistory(chat.messages); | ||||
| 			title = chat.title; | ||||
| 
 | ||||
| 			await settings.set({ | ||||
| 				...$settings, | ||||
| 				system: chat.system ?? $settings.system, | ||||
| 				options: chat.options ?? $settings.options | ||||
| 			}); | ||||
| 			autoScroll = true; | ||||
| 
 | ||||
| 			await tick(); | ||||
| 			if (messages.length > 0) { | ||||
| 				history.messages[messages.at(-1).id].done = true; | ||||
| 			} | ||||
| 			await tick(); | ||||
| 
 | ||||
| 			return chat; | ||||
| 		} else { | ||||
| 			return null; | ||||
| 		} | ||||
| 	}; | ||||
| 
 | ||||
| 	////////////////////////// | ||||
| 	// Ollama functions | ||||
| 	////////////////////////// | ||||
| 
 | ||||
| 	const sendPrompt = async (userPrompt, parentId) => { | ||||
| 		await Promise.all( | ||||
| 			selectedModels.map(async (model) => { | ||||
| 				if (model.includes('gpt-')) { | ||||
| 					await sendPromptOpenAI(model, userPrompt, parentId); | ||||
| 				} else { | ||||
| 					await sendPromptOllama(model, userPrompt, parentId); | ||||
| 				} | ||||
| 			}) | ||||
| 		); | ||||
| 
 | ||||
| 		await chats.set(await $db.getChats()); | ||||
| 	}; | ||||
| 
 | ||||
| 	const sendPromptOllama = async (model, userPrompt, parentId) => { | ||||
| 		let responseMessageId = uuidv4(); | ||||
| 
 | ||||
| 		let responseMessage = { | ||||
| 			parentId: parentId, | ||||
| 			id: responseMessageId, | ||||
| 			childrenIds: [], | ||||
| 			role: 'assistant', | ||||
| 			content: '', | ||||
| 			model: model | ||||
| 		}; | ||||
| 
 | ||||
| 		history.messages[responseMessageId] = responseMessage; | ||||
| 		history.currentId = responseMessageId; | ||||
| 		if (parentId !== null) { | ||||
| 			history.messages[parentId].childrenIds = [ | ||||
| 				...history.messages[parentId].childrenIds, | ||||
| 				responseMessageId | ||||
| 			]; | ||||
| 		} | ||||
| 
 | ||||
| 		window.scrollTo({ top: document.body.scrollHeight }); | ||||
| 
 | ||||
| 		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: model, | ||||
| 				prompt: userPrompt, | ||||
| 				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 | ||||
| 				}, | ||||
| 				format: $settings.requestFormat ?? undefined, | ||||
| 				context: | ||||
| 					history.messages[parentId] !== null && | ||||
| 					history.messages[parentId].parentId in history.messages | ||||
| 						? history.messages[history.messages[parentId].parentId]?.context ?? undefined | ||||
| 						: undefined | ||||
| 			}) | ||||
| 		}); | ||||
| 
 | ||||
| 		const reader = res.body | ||||
| 			.pipeThrough(new TextDecoderStream()) | ||||
| 			.pipeThrough(splitStream('\n')) | ||||
| 			.getReader(); | ||||
| 
 | ||||
| 		while (true) { | ||||
| 			const { value, done } = await reader.read(); | ||||
| 			if (done || stopResponseFlag) { | ||||
| 				if (stopResponseFlag) { | ||||
| 					responseMessage.done = true; | ||||
| 					messages = messages; | ||||
| 				} | ||||
| 
 | ||||
| 				break; | ||||
| 			} | ||||
| 
 | ||||
| 			try { | ||||
| 				let lines = value.split('\n'); | ||||
| 
 | ||||
| 				for (const line of lines) { | ||||
| 					if (line !== '') { | ||||
| 						console.log(line); | ||||
| 						let data = JSON.parse(line); | ||||
| 						if (data.done == false) { | ||||
| 							if (responseMessage.content == '' && data.response == '\n') { | ||||
| 								continue; | ||||
| 							} else { | ||||
| 								responseMessage.content += data.response; | ||||
| 								messages = messages; | ||||
| 							} | ||||
| 						} else if ('detail' in data) { | ||||
| 							throw data; | ||||
| 						} else { | ||||
| 							responseMessage.done = true; | ||||
| 							responseMessage.context = data.context; | ||||
| 							messages = messages; | ||||
| 						} | ||||
| 					} | ||||
| 				} | ||||
| 			} catch (error) { | ||||
| 				console.log(error); | ||||
| 				if ('detail' in error) { | ||||
| 					toast.error(error.detail); | ||||
| 				} | ||||
| 				break; | ||||
| 			} | ||||
| 
 | ||||
| 			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 | ||||
| 				}, | ||||
| 				messages: messages, | ||||
| 				history: history | ||||
| 			}); | ||||
| 		} | ||||
| 
 | ||||
| 		stopResponseFlag = false; | ||||
| 		await tick(); | ||||
| 		if (autoScroll) { | ||||
| 			window.scrollTo({ top: document.body.scrollHeight }); | ||||
| 		} | ||||
| 
 | ||||
| 		if (messages.length == 2 && messages.at(1).content !== '') { | ||||
| 			window.history.replaceState(history.state, '', `/c/${$chatId}`); | ||||
| 			await generateChatTitle($chatId, userPrompt); | ||||
| 		} | ||||
| 	}; | ||||
| 
 | ||||
| 	const sendPromptOpenAI = async (model, userPrompt, parentId) => { | ||||
| 		if ($settings.OPENAI_API_KEY) { | ||||
| 			if (models) { | ||||
| 				let responseMessageId = uuidv4(); | ||||
| 
 | ||||
| 				let responseMessage = { | ||||
| 					parentId: parentId, | ||||
| 					id: responseMessageId, | ||||
| 					childrenIds: [], | ||||
| 					role: 'assistant', | ||||
| 					content: '', | ||||
| 					model: model | ||||
| 				}; | ||||
| 
 | ||||
| 				history.messages[responseMessageId] = responseMessage; | ||||
| 				history.currentId = responseMessageId; | ||||
| 				if (parentId !== null) { | ||||
| 					history.messages[parentId].childrenIds = [ | ||||
| 						...history.messages[parentId].childrenIds, | ||||
| 						responseMessageId | ||||
| 					]; | ||||
| 				} | ||||
| 
 | ||||
| 				window.scrollTo({ top: document.body.scrollHeight }); | ||||
| 
 | ||||
| 				const res = await fetch(`https://api.openai.com/v1/chat/completions`, { | ||||
| 					method: 'POST', | ||||
| 					headers: { | ||||
| 						'Content-Type': 'application/json', | ||||
| 						Authorization: `Bearer ${$settings.OPENAI_API_KEY}` | ||||
| 					}, | ||||
| 					body: JSON.stringify({ | ||||
| 						model: model, | ||||
| 						stream: true, | ||||
| 						messages: [ | ||||
| 							$settings.system | ||||
| 								? { | ||||
| 										role: 'system', | ||||
| 										content: $settings.system | ||||
| 								  } | ||||
| 								: undefined, | ||||
| 							...messages | ||||
| 						] | ||||
| 							.filter((message) => message) | ||||
| 							.map((message) => ({ role: message.role, content: message.content })), | ||||
| 						temperature: $settings.temperature ?? undefined, | ||||
| 						top_p: $settings.top_p ?? undefined, | ||||
| 						frequency_penalty: $settings.repeat_penalty ?? undefined | ||||
| 					}) | ||||
| 				}); | ||||
| 
 | ||||
| 				const reader = res.body | ||||
| 					.pipeThrough(new TextDecoderStream()) | ||||
| 					.pipeThrough(splitStream('\n')) | ||||
| 					.getReader(); | ||||
| 
 | ||||
| 				while (true) { | ||||
| 					const { value, done } = await reader.read(); | ||||
| 					if (done || stopResponseFlag) { | ||||
| 						if (stopResponseFlag) { | ||||
| 							responseMessage.done = true; | ||||
| 							messages = messages; | ||||
| 						} | ||||
| 
 | ||||
| 						break; | ||||
| 					} | ||||
| 
 | ||||
| 					try { | ||||
| 						let lines = value.split('\n'); | ||||
| 
 | ||||
| 						for (const line of lines) { | ||||
| 							if (line !== '') { | ||||
| 								console.log(line); | ||||
| 								if (line === 'data: [DONE]') { | ||||
| 									responseMessage.done = true; | ||||
| 									messages = messages; | ||||
| 								} else { | ||||
| 									let data = JSON.parse(line.replace(/^data: /, '')); | ||||
| 									console.log(data); | ||||
| 
 | ||||
| 									if (responseMessage.content == '' && data.choices[0].delta.content == '\n') { | ||||
| 										continue; | ||||
| 									} else { | ||||
| 										responseMessage.content += data.choices[0].delta.content ?? ''; | ||||
| 										messages = messages; | ||||
| 									} | ||||
| 								} | ||||
| 							} | ||||
| 						} | ||||
| 					} catch (error) { | ||||
| 						console.log(error); | ||||
| 					} | ||||
| 
 | ||||
| 					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 | ||||
| 						}, | ||||
| 						messages: messages, | ||||
| 						history: history | ||||
| 					}); | ||||
| 				} | ||||
| 
 | ||||
| 				stopResponseFlag = false; | ||||
| 
 | ||||
| 				await tick(); | ||||
| 				if (autoScroll) { | ||||
| 					window.scrollTo({ top: document.body.scrollHeight }); | ||||
| 				} | ||||
| 
 | ||||
| 				if (messages.length == 2) { | ||||
| 					window.history.replaceState(history.state, '', `/c/${$chatId}`); | ||||
| 					await setChatTitle($chatId, userPrompt); | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	}; | ||||
| 
 | ||||
| 	const submitPrompt = async (userPrompt) => { | ||||
| 		console.log('submitPrompt'); | ||||
| 
 | ||||
| 		if (selectedModels.includes('')) { | ||||
| 			toast.error('Model not selected'); | ||||
| 		} else if (messages.length != 0 && messages.at(-1).done != true) { | ||||
| 			console.log('wait'); | ||||
| 		} else { | ||||
| 			document.getElementById('chat-textarea').style.height = ''; | ||||
| 
 | ||||
| 			let userMessageId = uuidv4(); | ||||
| 			let userMessage = { | ||||
| 				id: userMessageId, | ||||
| 				parentId: messages.length !== 0 ? messages.at(-1).id : null, | ||||
| 				childrenIds: [], | ||||
| 				role: 'user', | ||||
| 				content: userPrompt | ||||
| 			}; | ||||
| 
 | ||||
| 			if (messages.length !== 0) { | ||||
| 				history.messages[messages.at(-1).id].childrenIds.push(userMessageId); | ||||
| 			} | ||||
| 
 | ||||
| 			history.messages[userMessageId] = userMessage; | ||||
| 			history.currentId = userMessageId; | ||||
| 
 | ||||
| 			prompt = ''; | ||||
| 
 | ||||
| 			if (messages.length == 0) { | ||||
| 				await $db.createNewChat({ | ||||
| 					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 | ||||
| 					}, | ||||
| 					messages: messages, | ||||
| 					history: history | ||||
| 				}); | ||||
| 			} | ||||
| 
 | ||||
| 			setTimeout(() => { | ||||
| 				window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); | ||||
| 			}, 50); | ||||
| 
 | ||||
| 			await sendPrompt(userPrompt, userMessageId); | ||||
| 		} | ||||
| 	}; | ||||
| 
 | ||||
| 	const stopResponse = () => { | ||||
| 		stopResponseFlag = true; | ||||
| 		console.log('stopResponse'); | ||||
| 	}; | ||||
| 
 | ||||
| 	const regenerateResponse = async () => { | ||||
| 		console.log('regenerateResponse'); | ||||
| 		if (messages.length != 0 && messages.at(-1).done == true) { | ||||
| 			messages.splice(messages.length - 1, 1); | ||||
| 			messages = messages; | ||||
| 
 | ||||
| 			let userMessage = messages.at(-1); | ||||
| 			let userPrompt = userMessage.content; | ||||
| 
 | ||||
| 			await sendPrompt(userPrompt, userMessage.id); | ||||
| 		} | ||||
| 	}; | ||||
| 
 | ||||
| 	const generateChatTitle = async (_chatId, userPrompt) => { | ||||
| 		if ($settings.titleAutoGenerate ?? true) { | ||||
| 			console.log('generateChatTitle'); | ||||
| 
 | ||||
| 			const res = await fetch(`${$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL}/generate`, { | ||||
| 				method: 'POST', | ||||
| 				headers: { | ||||
| 					'Content-Type': 'text/event-stream', | ||||
| 					...($settings.authHeader && { Authorization: $settings.authHeader }), | ||||
| 					...($user && { Authorization: `Bearer ${localStorage.token}` }) | ||||
| 				}, | ||||
| 				body: JSON.stringify({ | ||||
| 					model: selectedModels[0], | ||||
| 					prompt: `Generate a brief 3-5 word title for this question, excluding the term 'title.' Then, please reply with only the title: ${userPrompt}`, | ||||
| 					stream: false | ||||
| 				}) | ||||
| 			}) | ||||
| 				.then(async (res) => { | ||||
| 					if (!res.ok) throw await res.json(); | ||||
| 					return res.json(); | ||||
| 				}) | ||||
| 				.catch((error) => { | ||||
| 					if ('detail' in error) { | ||||
| 						toast.error(error.detail); | ||||
| 					} | ||||
| 					console.log(error); | ||||
| 					return null; | ||||
| 				}); | ||||
| 
 | ||||
| 			if (res) { | ||||
| 				await setChatTitle(_chatId, res.response === '' ? 'New Chat' : res.response); | ||||
| 			} | ||||
| 		} else { | ||||
| 			await setChatTitle(_chatId, `${userPrompt}`); | ||||
| 		} | ||||
| 	}; | ||||
| 
 | ||||
| 	const setChatTitle = async (_chatId, _title) => { | ||||
| 		await $db.updateChatById(_chatId, { title: _title }); | ||||
| 		if (_chatId === $chatId) { | ||||
| 			title = _title; | ||||
| 		} | ||||
| 	}; | ||||
| </script> | ||||
| 
 | ||||
| <svelte:window | ||||
| 	on:scroll={(e) => { | ||||
| 		autoScroll = window.innerHeight + window.scrollY >= document.body.offsetHeight - 40; | ||||
| 	}} | ||||
| /> | ||||
| 
 | ||||
| {#if loaded} | ||||
| 	<Navbar {title} /> | ||||
| 	<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"> | ||||
| 				<ModelSelector bind:selectedModels disabled={messages.length > 0} /> | ||||
| 			</div> | ||||
| 
 | ||||
| 			<div class=" h-full mt-10 mb-32 w-full flex flex-col"> | ||||
| 				<Messages bind:history bind:messages bind:autoScroll {sendPrompt} {regenerateResponse} /> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 
 | ||||
| 		<MessageInput bind:prompt bind:autoScroll {messages} {submitPrompt} {stopResponse} /> | ||||
| 	</div> | ||||
| {/if} | ||||
|  | @ -1,13 +1,76 @@ | |||
| <script> | ||||
| 	import { Toaster } from 'svelte-french-toast'; | ||||
| 	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 '../app.css'; | ||||
| 	import '../tailwind.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; | ||||
| 			}); | ||||
| 
 | ||||
| 		console.log(resBackend); | ||||
| 		await config.set(resBackend); | ||||
| 
 | ||||
| 		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); | ||||
| 							return null; | ||||
| 						}); | ||||
| 
 | ||||
| 					if (res) { | ||||
| 						await user.set(res); | ||||
| 					} else { | ||||
| 						localStorage.removeItem('token'); | ||||
| 						await goto('/auth'); | ||||
| 					} | ||||
| 				} else { | ||||
| 					await goto('/auth'); | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		await tick(); | ||||
| 		loaded = true; | ||||
| 	}); | ||||
| </script> | ||||
| 
 | ||||
| <svelte:head> | ||||
| 	<title>Ollama</title> | ||||
| </svelte:head> | ||||
| 
 | ||||
| <slot /> | ||||
| <Toaster /> | ||||
| 
 | ||||
| {#if $config !== undefined && loaded} | ||||
| 	<slot /> | ||||
| {/if} | ||||
|  |  | |||
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
							
								
								
									
										154
									
								
								src/routes/admin/+page.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										154
									
								
								src/routes/admin/+page.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,154 @@ | |||
| <script> | ||||
| 	import { WEBUI_API_BASE_URL } from '$lib/constants'; | ||||
| 	import { config, user } from '$lib/stores'; | ||||
| 	import { goto } from '$app/navigation'; | ||||
| 	import { onMount } from 'svelte'; | ||||
| 
 | ||||
| 	import toast from 'svelte-french-toast'; | ||||
| 
 | ||||
| 	let loaded = false; | ||||
| 	let users = []; | ||||
| 
 | ||||
| 	const updateUserRole = async (id, role) => { | ||||
| 		const res = await fetch(`${WEBUI_API_BASE_URL}/users/update/role`, { | ||||
| 			method: 'POST', | ||||
| 			headers: { | ||||
| 				'Content-Type': 'application/json', | ||||
| 				Authorization: `Bearer ${localStorage.token}` | ||||
| 			}, | ||||
| 			body: JSON.stringify({ | ||||
| 				id: id, | ||||
| 				role: role | ||||
| 			}) | ||||
| 		}) | ||||
| 			.then(async (res) => { | ||||
| 				if (!res.ok) throw await res.json(); | ||||
| 				return res.json(); | ||||
| 			}) | ||||
| 			.catch((error) => { | ||||
| 				console.log(error); | ||||
| 				toast.error(error.detail); | ||||
| 				return null; | ||||
| 			}); | ||||
| 
 | ||||
| 		if (res) { | ||||
| 			await getUsers(); | ||||
| 		} | ||||
| 	}; | ||||
| 
 | ||||
| 	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')) { | ||||
| 			await goto('/'); | ||||
| 		} else { | ||||
| 			await getUsers(); | ||||
| 		} | ||||
| 		loaded = true; | ||||
| 	}); | ||||
| </script> | ||||
| 
 | ||||
| <div | ||||
| 	class=" bg-white dark:bg-gray-800 dark:text-gray-100 min-h-screen w-full flex justify-center font-mona" | ||||
| > | ||||
| 	{#if loaded} | ||||
| 		<div class="w-full max-w-3xl px-10 md:px-16 min-h-screen flex flex-col"> | ||||
| 			<div class="py-10 w-full"> | ||||
| 				<div class=" flex flex-col justify-center"> | ||||
| 					<div class=" text-2xl font-semibold">Users ({users.length})</div> | ||||
| 					<div class=" text-gray-500 text-xs font-medium mt-1"> | ||||
| 						Click on the user role cell in the table to change a user's role. | ||||
| 					</div> | ||||
| 
 | ||||
| 					<hr class=" my-3 dark:border-gray-600" /> | ||||
| 
 | ||||
| 					<div class="scrollbar-hidden relative overflow-x-auto whitespace-nowrap"> | ||||
| 						<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400"> | ||||
| 							<thead | ||||
| 								class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400" | ||||
| 							> | ||||
| 								<tr> | ||||
| 									<th scope="col" class="px-6 py-3"> Name </th> | ||||
| 									<th scope="col" class="px-6 py-3"> Email </th> | ||||
| 									<th scope="col" class="px-6 py-3"> Role </th> | ||||
| 									<!-- <th scope="col" class="px-6 py-3"> Action </th> --> | ||||
| 								</tr> | ||||
| 							</thead> | ||||
| 							<tbody> | ||||
| 								{#each users as user} | ||||
| 									<tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700"> | ||||
| 										<th | ||||
| 											scope="row" | ||||
| 											class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white" | ||||
| 										> | ||||
| 											<div class="flex flex-row"> | ||||
| 												<img | ||||
| 													class=" rounded-full max-w-[30px] max-h-[30px] object-cover mr-4" | ||||
| 													src={user.profile_image_url} | ||||
| 												/> | ||||
| 
 | ||||
| 												<div class=" font-semibold md:self-center">{user.name}</div> | ||||
| 											</div> | ||||
| 										</th> | ||||
| 										<td class="px-6 py-4"> {user.email} </td> | ||||
| 										<td class="px-6 py-4"> | ||||
| 											<button | ||||
| 												class="  dark:text-white underline" | ||||
| 												on:click={() => { | ||||
| 													if (user.role === 'user') { | ||||
| 														updateUserRole(user.id, 'admin'); | ||||
| 													} else if (user.role === 'pending') { | ||||
| 														updateUserRole(user.id, 'user'); | ||||
| 													} else { | ||||
| 														updateUserRole(user.id, 'pending'); | ||||
| 													} | ||||
| 												}}>{user.role}</button | ||||
| 											> | ||||
| 										</td> | ||||
| 										<!-- <td class="px-6 py-4 text-center"> | ||||
| 											<button class="  text-white underline"> Edit </button> | ||||
| 										</td> --> | ||||
| 									</tr> | ||||
| 								{/each} | ||||
| 							</tbody> | ||||
| 						</table> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	{/if} | ||||
| </div> | ||||
| 
 | ||||
| <style> | ||||
| 	.font-mona { | ||||
| 		font-family: 'Mona Sans'; | ||||
| 	} | ||||
| 
 | ||||
| 	.scrollbar-hidden::-webkit-scrollbar { | ||||
| 		display: none; /* for Chrome, Safari and Opera */ | ||||
| 	} | ||||
| 
 | ||||
| 	.scrollbar-hidden { | ||||
| 		-ms-overflow-style: none; /* IE and Edge */ | ||||
| 		scrollbar-width: none; /* Firefox */ | ||||
| 	} | ||||
| </style> | ||||
							
								
								
									
										199
									
								
								src/routes/auth/+page.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										199
									
								
								src/routes/auth/+page.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,199 @@ | |||
| <script> | ||||
| 	import { goto } from '$app/navigation'; | ||||
| 	import { WEBUI_API_BASE_URL } from '$lib/constants'; | ||||
| 	import { config, user } from '$lib/stores'; | ||||
| 	import { onMount } from 'svelte'; | ||||
| 	import toast from 'svelte-french-toast'; | ||||
| 
 | ||||
| 	let loaded = false; | ||||
| 	let mode = 'signin'; | ||||
| 
 | ||||
| 	let name = ''; | ||||
| 	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); | ||||
| 			toast.success(`You're now logged in.`); | ||||
| 			localStorage.token = res.token; | ||||
| 			await user.set(res); | ||||
| 			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); | ||||
| 				return null; | ||||
| 			}); | ||||
| 
 | ||||
| 		if (res) { | ||||
| 			console.log(res); | ||||
| 			toast.success(`Account creation successful."`); | ||||
| 			localStorage.token = res.token; | ||||
| 			await user.set(res); | ||||
| 			goto('/'); | ||||
| 		} | ||||
| 	}; | ||||
| 
 | ||||
| 	onMount(async () => { | ||||
| 		if ($config === null || !$config.auth || ($config.auth && $user !== undefined)) { | ||||
| 			await goto('/'); | ||||
| 		} | ||||
| 		loaded = true; | ||||
| 	}); | ||||
| </script> | ||||
| 
 | ||||
| {#if loaded && $config && $config.auth} | ||||
| 	<div class="fixed m-10 z-50"> | ||||
| 		<div class="flex space-x-2"> | ||||
| 			<div class=" self-center"> | ||||
| 				<img src="/ollama.png" class=" w-8" /> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</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=" my-auto pb-16 text-left"> | ||||
| 				<div> | ||||
| 					<div class=" font-bold text-yellow-600 text-4xl"> | ||||
| 						Get up and running with <br />large language models, locally. | ||||
| 					</div> | ||||
| 
 | ||||
| 					<div class="mt-2 text-yellow-600 text-xl"> | ||||
| 						Run Llama 2, Code Llama, and other models. Customize and create your own. | ||||
| 					</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=" my-auto pb-10 w-full"> | ||||
| 				<form | ||||
| 					class=" flex flex-col justify-center" | ||||
| 					on:submit|preventDefault={() => { | ||||
| 						if (mode === 'signin') { | ||||
| 							signInHandler(); | ||||
| 						} else { | ||||
| 							signUpHandler(); | ||||
| 						} | ||||
| 					}} | ||||
| 				> | ||||
| 					<div class=" text-2xl md:text-3xl font-semibold"> | ||||
| 						{mode === 'signin' ? 'Sign in' : 'Sign up'} to Ollama Web UI | ||||
| 					</div> | ||||
| 
 | ||||
| 					<hr class="my-8" /> | ||||
| 
 | ||||
| 					<div class="flex flex-col space-y-4"> | ||||
| 						{#if mode === 'signup'} | ||||
| 							<div> | ||||
| 								<div class=" text-sm font-bold text-left mb-2">Name</div> | ||||
| 								<input | ||||
| 									bind:value={name} | ||||
| 									type="text" | ||||
| 									class=" border px-5 py-4 rounded-2xl w-full text-sm" | ||||
| 									autocomplete="name" | ||||
| 									required | ||||
| 								/> | ||||
| 							</div> | ||||
| 						{/if} | ||||
| 
 | ||||
| 						<div> | ||||
| 							<div class=" text-sm font-bold text-left mb-2">Email</div> | ||||
| 							<input | ||||
| 								bind:value={email} | ||||
| 								type="email" | ||||
| 								class=" border px-5 py-4 rounded-2xl w-full text-sm" | ||||
| 								autocomplete="email" | ||||
| 								required | ||||
| 							/> | ||||
| 						</div> | ||||
| 
 | ||||
| 						<div> | ||||
| 							<div class=" text-sm font-bold text-left mb-2">Password</div> | ||||
| 							<input | ||||
| 								bind:value={password} | ||||
| 								type="password" | ||||
| 								class=" border px-5 py-4 rounded-2xl w-full text-sm" | ||||
| 								autocomplete="current-password" | ||||
| 								required | ||||
| 							/> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 
 | ||||
| 					<div class="mt-8"> | ||||
| 						<button | ||||
| 							class=" bg-gray-900 hover:bg-gray-800 w-full rounded-full text-white font-semibold text-sm py-5 transition" | ||||
| 							type="submit" | ||||
| 						> | ||||
| 							{mode === 'signin' ? 'Sign In' : 'Create Account'} | ||||
| 						</button> | ||||
| 
 | ||||
| 						<div class=" mt-4 text-sm text-center"> | ||||
| 							{mode === 'signin' ? `Don't have an account?` : `Already have an account?`} | ||||
| 
 | ||||
| 							<button | ||||
| 								class=" font-medium underline" | ||||
| 								type="button" | ||||
| 								on:click={() => { | ||||
| 									if (mode === 'signin') { | ||||
| 										mode = 'signup'; | ||||
| 									} else { | ||||
| 										mode = 'signin'; | ||||
| 									} | ||||
| 								}} | ||||
| 							> | ||||
| 								{mode === 'signin' ? `Sign up` : `Sign In`} | ||||
| 							</button> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 				</form> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
| {/if} | ||||
| 
 | ||||
| <style> | ||||
| 	.font-mona { | ||||
| 		font-family: 'Mona Sans'; | ||||
| 	} | ||||
| </style> | ||||
							
								
								
									
										
											BIN
										
									
								
								static/assets/fonts/Mona-Sans.woff2
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								static/assets/fonts/Mona-Sans.woff2
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Timothy Jaeryang Baek
						Timothy Jaeryang Baek