Browse Source

Merge pull request #123 from ollama-webui/dev

feat: ui fix/improvements/refac
Timothy Jaeryang Baek 1 year ago
parent
commit
897547274e

+ 3 - 0
Dockerfile

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

+ 34 - 3
backend/apps/ollama/main.py

@@ -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 - 0
backend/apps/web/main.py

@@ -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 - 0
backend/apps/web/models/auths.py

@@ -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 - 0
backend/apps/web/models/users.py

@@ -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 - 0
backend/apps/web/routers/auths.py

@@ -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 - 0
backend/apps/web/routers/users.py

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

+ 53 - 2
backend/config.py

@@ -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 - 0
backend/constants.py

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

+ 5 - 6
backend/main.py

@@ -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 - 0
backend/utils/misc.py

@@ -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 - 0
backend/utils/utils.py

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

+ 6 - 1
src/app.css

@@ -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 - 0
src/lib/components/chat/MessageInput.svelte

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

+ 0 - 0
src/lib/components/chat/Suggestions.svelte → src/lib/components/chat/MessageInput/Suggestions.svelte


+ 698 - 0
src/lib/components/chat/Messages.svelte

@@ -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 - 0
src/lib/components/chat/ModelSelector.svelte

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

+ 152 - 65
src/lib/components/chat/SettingsModal.svelte

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

+ 7 - 399
src/lib/components/layout/Navbar.svelte

@@ -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 - 0
src/lib/components/layout/Sidebar.svelte

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

+ 8 - 4
src/lib/constants.ts

@@ -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 - 0
src/lib/stores/index.ts

@@ -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 - 0
src/lib/utils/index.ts

@@ -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 - 0
src/routes/(app)/+layout.svelte

@@ -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 - 0
src/routes/(app)/+page.svelte

@@ -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 - 0
src/routes/(app)/c/[id]/+page.svelte

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

+ 66 - 3
src/routes/+layout.svelte

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

+ 0 - 1846
src/routes/+page.svelte

@@ -1,1846 +0,0 @@
-<script lang="ts">
-	import { openDB, deleteDB } from 'idb';
-	import { v4 as uuidv4 } from 'uuid';
-	import { marked } from 'marked';
-	import fileSaver from 'file-saver';
-	const { saveAs } = fileSaver;
-	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 toast from 'svelte-french-toast';
-
-	import { API_BASE_URL as BUILD_TIME_API_BASE_URL } from '$lib/constants';
-	import { onMount, tick } from 'svelte';
-
-	import Navbar from '$lib/components/layout/Navbar.svelte';
-	import SettingsModal from '$lib/components/chat/SettingsModal.svelte';
-	import Suggestions from '$lib/components/chat/Suggestions.svelte';
-
-	let API_BASE_URL = BUILD_TIME_API_BASE_URL;
-	let db;
-
-	// let selectedModel = '';
-	let selectedModels = [''];
-	let settings = {
-		system: null,
-		temperature: null
-	};
-
-	let fileUploadEnabled = false;
-
-	let speechRecognition;
-	let speechRecognitionEnabled = true;
-	let speechRecognitionListening = false;
-
-	let models = [];
-	let chats = [];
-
-	let chatId = uuidv4();
-	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;
-	}
-
-	let showSettings = false;
-	let stopResponseFlag = false;
-	let autoScroll = true;
-	let suggestions = ''; // $page.url.searchParams.get('suggestions');
-
-	onMount(async () => {
-		await Promise.all([await createNewChat(true), await setDBandLoadChats()]);
-	});
-
-	//////////////////////////
-	// Helper functions
-	//////////////////////////
-
-	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 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 createCopyCodeBlockButton = () => {
-		// use a class selector if available
-		let blocks = document.querySelectorAll('pre');
-		console.log(blocks);
-
-		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 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.');
-			}
-		}
-	};
-
-	//////////////////////////
-	// Web functions
-	//////////////////////////
-
-	const createNewChat = async (init = false) => {
-		if (init || messages.length > 0) {
-			chatId = uuidv4();
-			autoScroll = true;
-
-			title = '';
-			messages = [];
-			history = {
-				messages: {},
-				currentId: null
-			};
-
-			settings = JSON.parse(localStorage.getItem('settings') ?? JSON.stringify(settings));
-
-			API_BASE_URL = settings?.API_BASE_URL ?? BUILD_TIME_API_BASE_URL;
-			console.log(API_BASE_URL);
-
-			if (models.length === 0) {
-				await getModelTags();
-			}
-
-			// selectedModel =
-			// 	settings.model && models.map((model) => model.name).includes(settings.model)
-			// 		? settings.model
-			// 		: '';
-
-			selectedModels = settings.models ?? [''];
-
-			console.log(chatId);
-		}
-	};
-
-	const setDBandLoadChats = async () => {
-		db = await openDB('Chats', 1, {
-			upgrade(db) {
-				const store = db.createObjectStore('chats', {
-					keyPath: 'id',
-					autoIncrement: true
-				});
-				store.createIndex('timestamp', 'timestamp');
-			}
-		});
-
-		chats = await db.getAllFromIndex('chats', 'timestamp');
-	};
-
-	const saveDefaultModel = () => {
-		settings.models = selectedModels;
-		localStorage.setItem('settings', JSON.stringify(settings));
-		toast.success('Default model updated');
-	};
-
-	const saveSettings = async (updated) => {
-		console.log(updated);
-		settings = { ...settings, ...updated };
-		localStorage.setItem('settings', JSON.stringify(settings));
-		API_BASE_URL = updated?.API_BASE_URL ?? API_BASE_URL;
-		await getModelTags();
-	};
-
-	const loadChat = async (id) => {
-		const chat = await db.get('chats', id);
-		console.log(chat);
-		if (chatId !== chat.id) {
-			if ('history' in chat && chat.history !== undefined) {
-				history = chat.history;
-			} else {
-				let _history = {
-					messages: {},
-					currentId: null
-				};
-
-				let parentMessageId = null;
-				let messageId = null;
-
-				for (const message of chat.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;
-
-				history = _history;
-			}
-
-			if ('models' in chat && chat.models !== undefined) {
-				selectedModels = chat.models ?? selectedModels;
-			} else {
-				selectedModels = [chat.model ?? ''];
-			}
-
-			console.log(history);
-
-			title = chat.title;
-			chatId = chat.id;
-			settings.system = chat.system ?? settings.system;
-			settings.temperature = chat.temperature ?? settings.temperature;
-			autoScroll = true;
-
-			await tick();
-
-			if (messages.length > 0) {
-				history.messages[messages.at(-1).id].done = true;
-			}
-
-			renderLatex();
-			hljs.highlightAll();
-			createCopyCodeBlockButton();
-		}
-	};
-
-	const editChatTitle = async (id, _title) => {
-		const chat = await db.get('chats', id);
-		console.log(chat);
-
-		await db.put('chats', {
-			...chat,
-			title: _title
-		});
-
-		title = _title;
-		chats = await db.getAllFromIndex('chats', 'timestamp');
-	};
-
-	const deleteChat = async (id) => {
-		createNewChat();
-
-		const chat = await db.delete('chats', id);
-		console.log(chat);
-		chats = await db.getAllFromIndex('chats', 'timestamp');
-	};
-
-	const deleteChatHistory = async () => {
-		const tx = db.transaction('chats', 'readwrite');
-		await Promise.all([tx.store.clear(), tx.done]);
-		chats = await db.getAllFromIndex('chats', 'timestamp');
-	};
-
-	const importChatHistory = async (chatHistory) => {
-		for (const chat of chatHistory) {
-			console.log(chat);
-
-			await db.put('chats', {
-				id: chat.id,
-				model: chat.model,
-				models: chat.models,
-				system: chat.system,
-				options: chat.options,
-				title: chat.title,
-				timestamp: chat.timestamp,
-				messages: chat.messages,
-				history: chat.history
-			});
-		}
-		chats = await db.getAllFromIndex('chats', 'timestamp');
-
-		console.log(chats);
-	};
-
-	const exportChatHistory = async () => {
-		chats = await db.getAllFromIndex('chats', 'timestamp');
-		let blob = new Blob([JSON.stringify(chats)], { type: 'application/json' });
-		saveAs(blob, `chat-export-${Date.now()}.json`);
-	};
-
-	const openSettings = async () => {
-		showSettings = true;
-	};
-
-	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;
-		});
-
-		await db.put('chats', {
-			id: chatId,
-			title: title === '' ? 'New Chat' : title,
-			models: selectedModels,
-			system: settings.system,
-			options: {
-				temperature: settings.temperature
-			},
-			timestamp: Date.now(),
-			messages: messages,
-			history: history
-		});
-
-		console.log(messages);
-	};
-
-	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();
-
-		renderLatex();
-		hljs.highlightAll();
-		createCopyCodeBlockButton();
-	};
-
-	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();
-
-		renderLatex();
-		hljs.highlightAll();
-		createCopyCodeBlockButton();
-	};
-
-	//////////////////////////
-	// Ollama functions
-	//////////////////////////
-
-	const getModelTags = async (url = null, type = 'all') => {
-		const res = await fetch(`${url === null ? API_BASE_URL : url}/tags`, {
-			method: 'GET',
-			headers: {
-				Accept: 'application/json',
-				'Content-Type': 'application/json',
-				...(settings.authHeader && { Authorization: settings.authHeader })
-			}
-		})
-			.then(async (res) => {
-				if (!res.ok) throw await res.json();
-				return res.json();
-			})
-			.catch((error) => {
-				console.log(error);
-				toast.error('Server connection failed');
-				return null;
-			});
-
-		console.log(res);
-
-		if (type === 'all') {
-			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;
-
-				if (openaiModels) {
-					models = [
-						...(res?.models ?? []),
-						{ name: 'hr' },
-
-						...openaiModels
-							.map((model) => ({ name: model.id, label: 'OpenAI' }))
-							.filter((model) => model.name.includes('gpt'))
-					];
-				} else {
-					models = res?.models ?? [];
-				}
-			} else {
-				models = res?.models ?? [];
-			}
-
-			return models;
-		} else {
-			return res?.models ?? null;
-		}
-	};
-
-	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);
-				}
-			})
-		);
-
-		// if (selectedModel.includes('gpt-')) {
-		// 	await sendPromptOpenAI(userPrompt, parentId);
-		// } else {
-		// 	await sendPromptOllama(userPrompt, parentId);
-		// }
-
-		console.log(history);
-	};
-
-	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(`${API_BASE_URL}/generate`, {
-			method: 'POST',
-			headers: {
-				'Content-Type': 'text/event-stream',
-				...(settings.authHeader && { Authorization: settings.authHeader })
-			},
-			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;
-					hljs.highlightAll();
-					createCopyCodeBlockButton();
-					renderLatex();
-				}
-
-				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 {
-							responseMessage.done = true;
-							responseMessage.context = data.context;
-							messages = messages;
-							hljs.highlightAll();
-							createCopyCodeBlockButton();
-							renderLatex();
-						}
-					}
-				}
-			} catch (error) {
-				console.log(error);
-			}
-
-			if (autoScroll) {
-				window.scrollTo({ top: document.body.scrollHeight });
-			}
-
-			await db.put('chats', {
-				id: chatId,
-				title: title === '' ? 'New Chat' : title,
-				models: selectedModels,
-				system: settings.system,
-				options: {
-					temperature: settings.temperature
-				},
-				timestamp: Date.now(),
-				messages: messages,
-				history: history
-			});
-		}
-
-		stopResponseFlag = false;
-		await tick();
-		if (autoScroll) {
-			window.scrollTo({ top: document.body.scrollHeight });
-		}
-
-		if (messages.length == 2) {
-			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.put('chats', {
-						id: chatId,
-						title: title === '' ? 'New Chat' : title,
-						models: selectedModels,
-
-						system: settings.system,
-						options: {
-							temperature: settings.temperature
-						},
-						timestamp: Date.now(),
-						messages: messages,
-						history: history
-					});
-				}
-
-				stopResponseFlag = false;
-
-				hljs.highlightAll();
-				createCopyCodeBlockButton();
-				renderLatex();
-
-				await tick();
-				if (autoScroll) {
-					window.scrollTo({ top: document.body.scrollHeight });
-				}
-
-				if (messages.length == 2) {
-					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.put('chats', {
-					id: chatId,
-					models: selectedModels,
-					system: settings.system,
-					options: {
-						temperature: settings.temperature
-					},
-					title: 'New Chat',
-					timestamp: Date.now(),
-					messages: messages,
-					history: history
-				});
-				chats = await db.getAllFromIndex('chats', 'timestamp');
-			}
-
-			setTimeout(() => {
-				window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
-			}, 50);
-
-			await sendPrompt(userPrompt, userMessageId);
-
-			chats = await db.getAllFromIndex('chats', 'timestamp');
-		}
-	};
-
-	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);
-
-			chats = await db.getAllFromIndex('chats', 'timestamp');
-		}
-	};
-
-	const stopResponse = () => {
-		stopResponseFlag = true;
-		console.log('stopResponse');
-	};
-
-	const generateChatTitle = async (_chatId, userPrompt) => {
-		console.log('generateChatTitle');
-
-		const res = await fetch(`${API_BASE_URL}/generate`, {
-			method: 'POST',
-			headers: {
-				'Content-Type': 'text/event-stream',
-				...(settings.authHeader && { Authorization: settings.authHeader })
-			},
-			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) => {
-				console.log(error);
-				return null;
-			});
-
-		if (res) {
-			await setChatTitle(_chatId, res.response === '' ? 'New Chat' : res.response);
-		}
-	};
-
-	const setChatTitle = async (_chatId, _title) => {
-		const chat = await db.get('chats', _chatId);
-		await db.put('chats', { ...chat, title: _title });
-		if (chat.id === chatId) {
-			title = _title;
-		}
-	};
-</script>
-
-<svelte:window
-	on:scroll={(e) => {
-		autoScroll = window.innerHeight + window.scrollY >= document.body.offsetHeight - 40;
-	}}
-/>
-
-<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"
-	>
-		<Navbar
-			selectedChatId={chatId}
-			{chats}
-			{title}
-			{loadChat}
-			{editChatTitle}
-			{deleteChat}
-			{createNewChat}
-			{importChatHistory}
-			{exportChatHistory}
-			{deleteChatHistory}
-			{openSettings}
-		/>
-
-		<SettingsModal bind:show={showSettings} {saveSettings} {getModelTags} />
-
-		<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">
-					<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={messages.length != 0}
-								>
-									<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 || messages.length != 0}
-										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={messages.length != 0}
-										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={() => {
-											openSettings();
-										}}
-									>
-										<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>
-				</div>
-
-				<div class=" h-full mt-10 mb-32 w-full flex flex-col">
-					{#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">
-											<img
-												src="{message.role == 'user'
-													? settings.gravatarUrl
-														? settings.gravatarUrl
-														: '/user'
-													: '/favicon'}.png"
-												class=" max-w-[28px] object-cover rounded-full"
-											/>
-										</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
-																			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}
-				</div>
-			</div>
-
-			<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={() => {
-										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>
-		</div>
-	</div>
-</div>
-
-<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>

+ 154 - 0
src/routes/admin/+page.svelte

@@ -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 - 0
src/routes/auth/+page.svelte

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