Merge pull request #123 from ollama-webui/dev

feat: ui fix/improvements/refac
This commit is contained in:
Timothy Jaeryang Baek 2023-11-22 16:26:15 -05:00 committed by GitHub
commit 897547274e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 4180 additions and 2329 deletions

View file

@ -22,6 +22,9 @@ ARG OLLAMA_API_BASE_URL='/ollama/api'
ENV ENV=prod ENV ENV=prod
ENV OLLAMA_API_BASE_URL $OLLAMA_API_BASE_URL 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 WORKDIR /app
COPY --from=build /app/build /app/build COPY --from=build /app/build /app/build

View file

@ -1,4 +1,4 @@
from flask import Flask, request, Response from flask import Flask, request, Response, jsonify
from flask_cors import CORS from flask_cors import CORS
@ -6,7 +6,10 @@ import requests
import json 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__) app = Flask(__name__)
CORS( CORS(
@ -22,12 +25,40 @@ TARGET_SERVER_URL = OLLAMA_API_BASE_URL
def proxy(path): def proxy(path):
# Combine the base URL of the target server with the requested path # Combine the base URL of the target server with the requested path
target_url = f"{TARGET_SERVER_URL}/{path}" target_url = f"{TARGET_SERVER_URL}/{path}"
print(target_url) print(path)
# Get data from the original request # Get data from the original request
data = request.get_data() data = request.get_data()
headers = dict(request.headers) 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 # Make a request to the target server
target_response = requests.request( target_response = requests.request(
method=request.method, method=request.method,

26
backend/apps/web/main.py Normal file
View file

@ -0,0 +1,26 @@
from fastapi import FastAPI, Request, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from apps.web.routers import auths, users
from config import WEBUI_VERSION, WEBUI_AUTH
app = FastAPI()
origins = ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auths.router, prefix="/auths", tags=["auths"])
app.include_router(users.router, prefix="/users", tags=["users"])
@app.get("/")
async def get_status():
return {"status": True, "version": WEBUI_VERSION, "auth": WEBUI_AUTH}

View file

@ -0,0 +1,103 @@
from pydantic import BaseModel
from typing import List, Union, Optional
import time
import uuid
from apps.web.models.users import UserModel, Users
from utils.utils import (
verify_password,
get_password_hash,
bearer_scheme,
create_token,
)
import config
DB = config.DB
####################
# DB MODEL
####################
class AuthModel(BaseModel):
id: str
email: str
password: str
active: bool = True
####################
# Forms
####################
class Token(BaseModel):
token: str
token_type: str
class UserResponse(BaseModel):
id: str
email: str
name: str
role: str
profile_image_url: str
class SigninResponse(Token, UserResponse):
pass
class SigninForm(BaseModel):
email: str
password: str
class SignupForm(BaseModel):
name: str
email: str
password: str
class AuthsTable:
def __init__(self, db):
self.db = db
self.table = db.auths
def insert_new_auth(
self, email: str, password: str, name: str, role: str = "pending"
) -> Optional[UserModel]:
print("insert_new_auth")
id = str(uuid.uuid4())
auth = AuthModel(
**{"id": id, "email": email, "password": password, "active": True}
)
result = self.table.insert_one(auth.model_dump())
user = Users.insert_new_user(id, name, email, role)
print(result, user)
if result and user:
return user
else:
return None
def authenticate_user(self, email: str, password: str) -> Optional[UserModel]:
print("authenticate_user")
auth = self.table.find_one({"email": email, "active": True})
if auth:
if verify_password(password, auth["password"]):
user = self.db.users.find_one({"id": auth["id"]})
return UserModel(**user)
else:
return None
else:
return None
Auths = AuthsTable(DB)

View file

@ -0,0 +1,97 @@
from pydantic import BaseModel
from typing import List, Union, Optional
from pymongo import ReturnDocument
import time
from utils.utils import decode_token
from utils.misc import get_gravatar_url
from config import DB
####################
# User DB Schema
####################
class UserModel(BaseModel):
id: str
name: str
email: str
role: str = "pending"
profile_image_url: str = "/user.png"
created_at: int # timestamp in epoch
####################
# Forms
####################
class UserRoleUpdateForm(BaseModel):
id: str
role: str
class UsersTable:
def __init__(self, db):
self.db = db
self.table = db.users
def insert_new_user(
self, id: str, name: str, email: str, role: str = "pending"
) -> Optional[UserModel]:
user = UserModel(
**{
"id": id,
"name": name,
"email": email,
"role": role,
"profile_image_url": get_gravatar_url(email),
"created_at": int(time.time()),
}
)
result = self.table.insert_one(user.model_dump())
if result:
return user
else:
return None
def get_user_by_email(self, email: str) -> Optional[UserModel]:
user = self.table.find_one({"email": email}, {"_id": False})
if user:
return UserModel(**user)
else:
return None
def get_user_by_token(self, token: str) -> Optional[UserModel]:
data = decode_token(token)
if data != None and "email" in data:
return self.get_user_by_email(data["email"])
else:
return None
def get_users(self, skip: int = 0, limit: int = 50) -> List[UserModel]:
return [
UserModel(**user)
for user in list(
self.table.find({}, {"_id": False}).skip(skip).limit(limit)
)
]
def get_num_users(self) -> Optional[int]:
return self.table.count_documents({})
def update_user_by_id(self, id: str, updated: dict) -> Optional[UserModel]:
user = self.table.find_one_and_update(
{"id": id}, {"$set": updated}, return_document=ReturnDocument.AFTER
)
return UserModel(**user)
def update_user_role_by_id(self, id: str, role: str) -> Optional[UserModel]:
return self.update_user_by_id(id, {"role": role})
Users = UsersTable(DB)

View file

@ -0,0 +1,111 @@
from fastapi import Response
from fastapi import Depends, FastAPI, HTTPException, status
from datetime import datetime, timedelta
from typing import List, Union
from fastapi import APIRouter
from pydantic import BaseModel
import time
import uuid
from apps.web.models.auths import (
SigninForm,
SignupForm,
UserResponse,
SigninResponse,
Auths,
)
from apps.web.models.users import Users
from utils.utils import (
get_password_hash,
bearer_scheme,
create_token,
)
from utils.misc import get_gravatar_url
from constants import ERROR_MESSAGES
router = APIRouter()
############################
# GetSessionUser
############################
@router.get("/", response_model=UserResponse)
async def get_session_user(cred=Depends(bearer_scheme)):
token = cred.credentials
user = Users.get_user_by_token(token)
if user:
return {
"id": user.id,
"email": user.email,
"name": user.name,
"role": user.role,
"profile_image_url": user.profile_image_url,
}
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.INVALID_TOKEN,
)
############################
# SignIn
############################
@router.post("/signin", response_model=SigninResponse)
async def signin(form_data: SigninForm):
user = Auths.authenticate_user(form_data.email.lower(), form_data.password)
if user:
token = create_token(data={"email": user.email})
return {
"token": token,
"token_type": "Bearer",
"id": user.id,
"email": user.email,
"name": user.name,
"role": user.role,
"profile_image_url": user.profile_image_url,
}
else:
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
############################
# SignUp
############################
@router.post("/signup", response_model=SigninResponse)
async def signup(form_data: SignupForm):
if not Users.get_user_by_email(form_data.email.lower()):
try:
role = "admin" if Users.get_num_users() == 0 else "pending"
hashed = get_password_hash(form_data.password)
user = Auths.insert_new_auth(form_data.email, hashed, form_data.name, role)
if user:
token = create_token(data={"email": user.email})
# response.set_cookie(key='token', value=token, httponly=True)
return {
"token": token,
"token_type": "Bearer",
"id": user.id,
"email": user.email,
"name": user.name,
"role": user.role,
"profile_image_url": user.profile_image_url,
}
else:
raise HTTPException(500, detail=ERROR_MESSAGES.DEFAULT(err))
except Exception as err:
raise HTTPException(500, detail=ERROR_MESSAGES.DEFAULT(err))
else:
raise HTTPException(400, detail=ERROR_MESSAGES.DEFAULT())

View file

@ -0,0 +1,75 @@
from fastapi import Response
from fastapi import Depends, FastAPI, HTTPException, status
from datetime import datetime, timedelta
from typing import List, Union, Optional
from fastapi import APIRouter
from pydantic import BaseModel
import time
import uuid
from apps.web.models.users import UserModel, UserRoleUpdateForm, Users
from utils.utils import (
get_password_hash,
bearer_scheme,
create_token,
)
from constants import ERROR_MESSAGES
router = APIRouter()
############################
# GetUsers
############################
@router.get("/", response_model=List[UserModel])
async def get_users(skip: int = 0, limit: int = 50, cred=Depends(bearer_scheme)):
token = cred.credentials
user = Users.get_user_by_token(token)
if user:
if user.role == "admin":
return Users.get_users(skip, limit)
else:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.INVALID_TOKEN,
)
############################
# UpdateUserRole
############################
@router.post("/update/role", response_model=Optional[UserModel])
async def update_user_role(form_data: UserRoleUpdateForm, cred=Depends(bearer_scheme)):
token = cred.credentials
user = Users.get_user_by_token(token)
if user:
if user.role == "admin":
if user.id != form_data.id:
return Users.update_user_role_by_id(form_data.id, form_data.role)
else:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
)
else:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.INVALID_TOKEN,
)

View file

@ -1,11 +1,24 @@
import sys
import os
from dotenv import load_dotenv, find_dotenv 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()) load_dotenv(find_dotenv())
####################################
# ENV (dev,test,prod)
####################################
ENV = os.environ.get("ENV", "dev") ENV = os.environ.get("ENV", "dev")
####################################
# OLLAMA_API_BASE_URL
####################################
OLLAMA_API_BASE_URL = os.environ.get( OLLAMA_API_BASE_URL = os.environ.get(
"OLLAMA_API_BASE_URL", "http://localhost:11434/api" "OLLAMA_API_BASE_URL", "http://localhost:11434/api"
) )
@ -13,3 +26,41 @@ OLLAMA_API_BASE_URL = os.environ.get(
if ENV == "prod": if ENV == "prod":
if OLLAMA_API_BASE_URL == "/ollama/api": if OLLAMA_API_BASE_URL == "/ollama/api":
OLLAMA_API_BASE_URL = "http://host.docker.internal:11434/api" OLLAMA_API_BASE_URL = "http://host.docker.internal:11434/api"
####################################
# WEBUI_VERSION
####################################
WEBUI_VERSION = os.environ.get("WEBUI_VERSION", "v1.0.0-alpha.11")
####################################
# WEBUI_AUTH
####################################
WEBUI_AUTH = True if os.environ.get("WEBUI_AUTH", "TRUE") == "TRUE" else False
####################################
# WEBUI_DB
####################################
WEBUI_DB_URL = os.environ.get("WEBUI_DB_URL", "mongodb://root:root@localhost:27017/")
if WEBUI_AUTH and WEBUI_DB_URL == "":
raise ValueError(ERROR_MESSAGES.ENV_VAR_NOT_FOUND)
DB_CLIENT = MongoClient(f"{WEBUI_DB_URL}?authSource=admin")
DB = DB_CLIENT["ollama-webui"]
####################################
# WEBUI_JWT_SECRET_KEY
####################################
WEBUI_JWT_SECRET_KEY = os.environ.get("WEBUI_JWT_SECRET_KEY", "t0p-s3cr3t")
if WEBUI_AUTH and WEBUI_JWT_SECRET_KEY == "":
raise ValueError(ERROR_MESSAGES.ENV_VAR_NOT_FOUND)

24
backend/constants.py Normal file
View file

@ -0,0 +1,24 @@
from enum import Enum
class MESSAGES(str, Enum):
DEFAULT = lambda msg="": f"{msg if msg else ''}"
class ERROR_MESSAGES(str, Enum):
def __str__(self) -> str:
return super().__str__()
DEFAULT = lambda err="": f"Something went wrong :/\n{err if err else ''}"
ENV_VAR_NOT_FOUND = "Required environment variable not found. Terminating now."
INVALID_TOKEN = (
"Your session has expired or the token is invalid. Please sign in again."
)
INVALID_CRED = "The email or password provided is incorrect. Please check for typos and try logging in again."
UNAUTHORIZED = "401 Unauthorized"
ACCESS_PROHIBITED = "You do not have permission to access this resource. Please contact your administrator for assistance."
ACTION_PROHIBITED = (
"The requested action has been restricted as a security measure."
)
USER_NOT_FOUND = "We could not find what you're looking for :/"
MALICIOUS = "Unusual activities detected, please try again in a few minutes."

View file

@ -1,16 +1,14 @@
import time
import sys
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi import HTTPException from fastapi import HTTPException
from starlette.exceptions import HTTPException as StarletteHTTPException
from fastapi.middleware.wsgi import WSGIMiddleware from fastapi.middleware.wsgi import WSGIMiddleware
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from starlette.exceptions import HTTPException as StarletteHTTPException
from apps.ollama.main import app as ollama_app from apps.ollama.main import app as ollama_app
from apps.web.main import app as webui_app
import time
class SPAStaticFiles(StaticFiles): class SPAStaticFiles(StaticFiles):
@ -47,5 +45,6 @@ async def check_url(request: Request, call_next):
return response return response
app.mount("/api/v1", webui_app)
app.mount("/ollama/api", WSGIMiddleware(ollama_app)) app.mount("/ollama/api", WSGIMiddleware(ollama_app))
app.mount("/", SPAStaticFiles(directory="../build", html=True), name="spa-static-files") app.mount("/", SPAStaticFiles(directory="../build", html=True), name="spa-static-files")

15
backend/utils/misc.py Normal file
View file

@ -0,0 +1,15 @@
import hashlib
def get_gravatar_url(email):
# Trim leading and trailing whitespace from
# an email address and force all characters
# to lower case
address = str(email).strip().lower()
# Create a SHA256 hash of the final string
hash_object = hashlib.sha256(address.encode())
hash_hex = hash_object.hexdigest()
# Grab the actual image URL
return f"https://www.gravatar.com/avatar/{hash_hex}?d=mp"

68
backend/utils/utils.py Normal file
View file

@ -0,0 +1,68 @@
from fastapi.security import HTTPBasicCredentials, HTTPBearer
from pydantic import BaseModel
from typing import Union, Optional
from passlib.context import CryptContext
from datetime import datetime, timedelta
import requests
import jwt
import config
JWT_SECRET_KEY = config.WEBUI_JWT_SECRET_KEY
ALGORITHM = "HS256"
##############
# Auth Utils
##############
bearer_scheme = HTTPBearer()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password, hashed_password):
return (
pwd_context.verify(plain_password, hashed_password) if hashed_password else None
)
def get_password_hash(password):
return pwd_context.hash(password)
def create_token(data: dict, expires_delta: Union[timedelta, None] = None) -> str:
payload = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
payload.update({"exp": expire})
encoded_jwt = jwt.encode(payload, JWT_SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def decode_token(token: str) -> Optional[dict]:
try:
decoded = jwt.decode(token, JWT_SECRET_KEY, options={"verify_signature": False})
return decoded
except Exception as e:
return None
def extract_token_from_auth_header(auth_header: str):
return auth_header[len("Bearer ") :]
def verify_token(request):
try:
bearer = request.headers["authorization"]
if bearer:
token = bearer[len("Bearer ") :]
decoded = jwt.decode(
token, JWT_SECRET_KEY, options={"verify_signature": False}
)
return decoded
else:
return None
except Exception as e:
return None

View file

@ -22,6 +22,17 @@ services:
restart: unless-stopped restart: unless-stopped
image: ollama/ollama:latest 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: ollama-webui:
build: build:
context: . context: .
@ -32,10 +43,16 @@ services:
container_name: ollama-webui container_name: ollama-webui
depends_on: depends_on:
- ollama - ollama
# Uncomment below for WIP: Auth support
# - ollama-webui-db
ports: ports:
- 3000:8080 - 3000:8080
environment: environment:
- "OLLAMA_API_BASE_URL=http://ollama:11434/api" - "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: extra_hosts:
- host.docker.internal:host-gateway - host.docker.internal:host-gateway
restart: unless-stopped restart: unless-stopped

View file

@ -4,8 +4,13 @@
font-display: swap; font-display: swap;
} }
@font-face {
font-family: 'Mona Sans';
src: url('/assets/fonts/Mona-Sans.woff2');
font-display: swap;
}
html { html {
@apply bg-gray-800;
word-break: break-word; word-break: break-word;
} }

View file

@ -0,0 +1,282 @@
<script lang="ts">
import { settings } from '$lib/stores';
import Suggestions from './MessageInput/Suggestions.svelte';
export let submitPrompt: Function;
export let stopResponse: Function;
export let suggestions = 'true';
export let autoScroll = true;
export let fileUploadEnabled = false;
export let speechRecognitionEnabled = true;
export let speechRecognitionListening = false;
export let prompt = '';
export let messages = [];
let speechRecognition;
const speechRecognitionHandler = () => {
// Check if SpeechRecognition is supported
if (speechRecognitionListening) {
speechRecognition.stop();
} else {
if ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window) {
// Create a SpeechRecognition object
speechRecognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
// Set continuous to true for continuous recognition
speechRecognition.continuous = true;
// Set the timeout for turning off the recognition after inactivity (in milliseconds)
const inactivityTimeout = 3000; // 3 seconds
let timeoutId;
// Start recognition
speechRecognition.start();
speechRecognitionListening = true;
// Event triggered when speech is recognized
speechRecognition.onresult = function (event) {
// Clear the inactivity timeout
clearTimeout(timeoutId);
// Handle recognized speech
console.log(event);
const transcript = event.results[Object.keys(event.results).length - 1][0].transcript;
prompt = `${prompt}${transcript}`;
// Restart the inactivity timeout
timeoutId = setTimeout(() => {
console.log('Speech recognition turned off due to inactivity.');
speechRecognition.stop();
}, inactivityTimeout);
};
// Event triggered when recognition is ended
speechRecognition.onend = function () {
// Restart recognition after it ends
console.log('recognition ended');
speechRecognitionListening = false;
if (prompt !== '' && $settings?.speechAutoSend === true) {
submitPrompt(prompt);
}
};
// Event triggered when an error occurs
speechRecognition.onerror = function (event) {
console.log(event);
toast.error(`Speech recognition error: ${event.error}`);
speechRecognitionListening = false;
};
} else {
toast.error('SpeechRecognition API is not supported in this browser.');
}
}
};
</script>
<div class="fixed bottom-0 w-full">
<div class=" pt-5">
<div class="max-w-3xl px-2.5 pt-2.5 -mb-0.5 mx-auto inset-x-0">
{#if messages.length == 0 && suggestions !== 'false'}
<Suggestions {submitPrompt} />
{/if}
{#if autoScroll === false && messages.length > 0}
<div class=" flex justify-center mb-4">
<button
class=" bg-white/20 p-1.5 rounded-full"
on:click={() => {
autoScroll = true;
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
fill-rule="evenodd"
d="M10 3a.75.75 0 01.75.75v10.638l3.96-4.158a.75.75 0 111.08 1.04l-5.25 5.5a.75.75 0 01-1.08 0l-5.25-5.5a.75.75 0 111.08-1.04l3.96 4.158V3.75A.75.75 0 0110 3z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
{/if}
<div class="bg-gradient-to-t from-white dark:from-gray-800 from-40% pb-2">
<form
class=" flex relative w-full"
on:submit|preventDefault={() => {
submitPrompt(prompt);
}}
>
<textarea
id="chat-textarea"
class="rounded-xl dark:bg-gray-800 dark:text-gray-100 outline-none border dark:border-gray-600 w-full py-3
{fileUploadEnabled ? 'pl-12' : 'pl-5'} {speechRecognitionEnabled
? 'pr-20'
: 'pr-12'} resize-none"
placeholder={speechRecognitionListening ? 'Listening...' : 'Send a message'}
bind:value={prompt}
on:keypress={(e) => {
if (e.keyCode == 13 && !e.shiftKey) {
e.preventDefault();
}
if (prompt !== '' && e.keyCode == 13 && !e.shiftKey) {
submitPrompt(prompt);
}
}}
rows="1"
on:input={(e) => {
e.target.style.height = '';
e.target.style.height = Math.min(e.target.scrollHeight, 200) + 2 + 'px';
}}
/>
{#if fileUploadEnabled}
<div class=" absolute left-0 bottom-0">
<div class="pl-2.5 pb-[9px]">
<button
class=" text-gray-600 dark:text-gray-200 transition rounded-lg p-1.5"
type="button"
on:click={() => {
console.log('file');
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
fill-rule="evenodd"
d="M15.621 4.379a3 3 0 00-4.242 0l-7 7a3 3 0 004.241 4.243h.001l.497-.5a.75.75 0 011.064 1.057l-.498.501-.002.002a4.5 4.5 0 01-6.364-6.364l7-7a4.5 4.5 0 016.368 6.36l-3.455 3.553A2.625 2.625 0 119.52 9.52l3.45-3.451a.75.75 0 111.061 1.06l-3.45 3.451a1.125 1.125 0 001.587 1.595l3.454-3.553a3 3 0 000-4.242z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
</div>
{/if}
<div class=" absolute right-0 bottom-0">
<div class="pr-2.5 pb-[9px]">
{#if messages.length == 0 || messages.at(-1).done == true}
{#if speechRecognitionEnabled}
<button
class=" text-gray-600 dark:text-gray-300 transition rounded-lg p-1 mr-0.5"
type="button"
on:click={() => {
speechRecognitionHandler();
}}
>
{#if speechRecognitionListening}
<svg
class=" w-5 h-5 translate-y-[0.5px]"
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_qM83 {
animation: spinner_8HQG 1.05s infinite;
}
.spinner_oXPr {
animation-delay: 0.1s;
}
.spinner_ZTLf {
animation-delay: 0.2s;
}
@keyframes spinner_8HQG {
0%,
57.14% {
animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1);
transform: translate(0);
}
28.57% {
animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33);
transform: translateY(-6px);
}
100% {
transform: translate(0);
}
}
</style><circle class="spinner_qM83" cx="4" cy="12" r="2.5" /><circle
class="spinner_qM83 spinner_oXPr"
cx="12"
cy="12"
r="2.5"
/><circle class="spinner_qM83 spinner_ZTLf" cx="20" cy="12" r="2.5" /></svg
>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5 translate-y-[0.5px]"
>
<path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
<path
d="M5.5 9.643a.75.75 0 00-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-1.5v-1.546A6.001 6.001 0 0016 10v-.357a.75.75 0 00-1.5 0V10a4.5 4.5 0 01-9 0v-.357z"
/>
</svg>
{/if}
</button>
{/if}
<button
class="{prompt !== ''
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
: 'text-white bg-gray-100 dark:text-gray-800 dark:bg-gray-600 disabled'} transition rounded-lg p-1"
type="submit"
disabled={prompt === ''}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
fill-rule="evenodd"
d="M10 17a.75.75 0 01-.75-.75V5.612L5.29 9.77a.75.75 0 01-1.08-1.04l5.25-5.5a.75.75 0 011.08 0l5.25 5.5a.75.75 0 11-1.08 1.04l-3.96-4.158V16.25A.75.75 0 0110 17z"
clip-rule="evenodd"
/>
</svg>
</button>
{:else}
<button
class="bg-white hover:bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-800 transition rounded-lg p-1.5"
on:click={stopResponse}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-5 h-5"
>
<path
fill-rule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm6-2.438c0-.724.588-1.312 1.313-1.312h4.874c.725 0 1.313.588 1.313 1.313v4.874c0 .725-.588 1.313-1.313 1.313H9.564a1.312 1.312 0 01-1.313-1.313V9.564z"
clip-rule="evenodd"
/>
</svg>
</button>
{/if}
</div>
</div>
</form>
<div class="mt-1.5 text-xs text-gray-500 text-center">
LLMs can make mistakes. Verify important information.
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,698 @@
<script lang="ts">
import { marked } from 'marked';
import { v4 as uuidv4 } from 'uuid';
import hljs from 'highlight.js';
import 'highlight.js/styles/github-dark.min.css';
import auto_render from 'katex/dist/contrib/auto-render.mjs';
import 'katex/dist/katex.min.css';
import { config, db, settings, user } from '$lib/stores';
import { tick } from 'svelte';
import toast from 'svelte-french-toast';
export let sendPrompt: Function;
export let regenerateResponse: Function;
export let autoScroll;
export let history = {};
export let messages = [];
$: if (messages && messages.length > 0 && (messages.at(-1).done ?? false)) {
(async () => {
await tick();
renderLatex();
hljs.highlightAll();
createCopyCodeBlockButton();
})();
}
const createCopyCodeBlockButton = () => {
// use a class selector if available
let blocks = document.querySelectorAll('pre');
blocks.forEach((block) => {
// only add button if browser supports Clipboard API
if (navigator.clipboard && block.childNodes.length < 2) {
let code = block.querySelector('code');
code.style.borderTopRightRadius = 0;
code.style.borderTopLeftRadius = 0;
let topBarDiv = document.createElement('div');
topBarDiv.style.backgroundColor = '#202123';
topBarDiv.style.overflowX = 'auto';
topBarDiv.style.display = 'flex';
topBarDiv.style.justifyContent = 'space-between';
topBarDiv.style.padding = '0 1rem';
topBarDiv.style.paddingTop = '4px';
topBarDiv.style.borderTopRightRadius = '8px';
topBarDiv.style.borderTopLeftRadius = '8px';
let langDiv = document.createElement('div');
let codeClassNames = code?.className.split(' ');
langDiv.textContent =
codeClassNames[0] === 'hljs' ? codeClassNames[1].slice(9) : codeClassNames[0].slice(9);
langDiv.style.color = 'white';
langDiv.style.margin = '4px';
langDiv.style.fontSize = '0.75rem';
let button = document.createElement('button');
button.textContent = 'Copy Code';
button.style.background = 'none';
button.style.fontSize = '0.75rem';
button.style.border = 'none';
button.style.margin = '4px';
button.style.cursor = 'pointer';
button.style.color = '#ddd';
button.addEventListener('click', () => copyCode(block, button));
topBarDiv.appendChild(langDiv);
topBarDiv.appendChild(button);
block.prepend(topBarDiv);
// button.addEventListener('click', async () => {
// await copyCode(block, button);
// });
}
});
async function copyCode(block, button) {
let code = block.querySelector('code');
let text = code.innerText;
await navigator.clipboard.writeText(text);
// visual feedback that task is completed
button.innerText = 'Copied!';
setTimeout(() => {
button.innerText = 'Copy Code';
}, 1000);
}
};
const renderLatex = () => {
let chatMessageElements = document.getElementsByClassName('chat-assistant');
// let lastChatMessageElement = chatMessageElements[chatMessageElements.length - 1];
for (const element of chatMessageElements) {
auto_render(element, {
// customised options
// • auto-render specific keys, e.g.:
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '$', right: '$', display: true },
{ left: '\\(', right: '\\)', display: true },
{ left: '\\[', right: '\\]', display: true }
],
// • rendering keys, e.g.:
throwOnError: false
});
}
};
const copyToClipboard = (text) => {
if (!navigator.clipboard) {
var textArea = document.createElement('textarea');
textArea.value = text;
// Avoid scrolling to bottom
textArea.style.top = '0';
textArea.style.left = '0';
textArea.style.position = 'fixed';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
var successful = document.execCommand('copy');
var msg = successful ? 'successful' : 'unsuccessful';
console.log('Fallback: Copying text command was ' + msg);
} catch (err) {
console.error('Fallback: Oops, unable to copy', err);
}
document.body.removeChild(textArea);
return;
}
navigator.clipboard.writeText(text).then(
function () {
console.log('Async: Copying to clipboard was successful!');
toast.success('Copying to clipboard was successful!');
},
function (err) {
console.error('Async: Could not copy text: ', err);
}
);
};
const editMessageHandler = async (messageId) => {
// let editMessage = history.messages[messageId];
history.messages[messageId].edit = true;
history.messages[messageId].editedContent = history.messages[messageId].content;
};
const confirmEditMessage = async (messageId) => {
history.messages[messageId].edit = false;
let userPrompt = history.messages[messageId].editedContent;
let userMessageId = uuidv4();
let userMessage = {
id: userMessageId,
parentId: history.messages[messageId].parentId,
childrenIds: [],
role: 'user',
content: userPrompt
};
let messageParentId = history.messages[messageId].parentId;
if (messageParentId !== null) {
history.messages[messageParentId].childrenIds = [
...history.messages[messageParentId].childrenIds,
userMessageId
];
}
history.messages[userMessageId] = userMessage;
history.currentId = userMessageId;
await tick();
await sendPrompt(userPrompt, userMessageId);
};
const cancelEditMessage = (messageId) => {
history.messages[messageId].edit = false;
history.messages[messageId].editedContent = undefined;
};
const rateMessage = async (messageIdx, rating) => {
messages = messages.map((message, idx) => {
if (messageIdx === idx) {
message.rating = rating;
}
return message;
});
$db.updateChatById(chatId, {
messages: messages,
history: history
});
};
const showPreviousMessage = async (message) => {
if (message.parentId !== null) {
let messageId =
history.messages[message.parentId].childrenIds[
Math.max(history.messages[message.parentId].childrenIds.indexOf(message.id) - 1, 0)
];
if (message.id !== messageId) {
let messageChildrenIds = history.messages[messageId].childrenIds;
while (messageChildrenIds.length !== 0) {
messageId = messageChildrenIds.at(-1);
messageChildrenIds = history.messages[messageId].childrenIds;
}
history.currentId = messageId;
}
} else {
let childrenIds = Object.values(history.messages)
.filter((message) => message.parentId === null)
.map((message) => message.id);
let messageId = childrenIds[Math.max(childrenIds.indexOf(message.id) - 1, 0)];
if (message.id !== messageId) {
let messageChildrenIds = history.messages[messageId].childrenIds;
while (messageChildrenIds.length !== 0) {
messageId = messageChildrenIds.at(-1);
messageChildrenIds = history.messages[messageId].childrenIds;
}
history.currentId = messageId;
}
}
await tick();
autoScroll = window.innerHeight + window.scrollY >= document.body.offsetHeight - 40;
setTimeout(() => {
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
}, 100);
};
const showNextMessage = async (message) => {
if (message.parentId !== null) {
let messageId =
history.messages[message.parentId].childrenIds[
Math.min(
history.messages[message.parentId].childrenIds.indexOf(message.id) + 1,
history.messages[message.parentId].childrenIds.length - 1
)
];
if (message.id !== messageId) {
let messageChildrenIds = history.messages[messageId].childrenIds;
while (messageChildrenIds.length !== 0) {
messageId = messageChildrenIds.at(-1);
messageChildrenIds = history.messages[messageId].childrenIds;
}
history.currentId = messageId;
}
} else {
let childrenIds = Object.values(history.messages)
.filter((message) => message.parentId === null)
.map((message) => message.id);
let messageId =
childrenIds[Math.min(childrenIds.indexOf(message.id) + 1, childrenIds.length - 1)];
if (message.id !== messageId) {
let messageChildrenIds = history.messages[messageId].childrenIds;
while (messageChildrenIds.length !== 0) {
messageId = messageChildrenIds.at(-1);
messageChildrenIds = history.messages[messageId].childrenIds;
}
history.currentId = messageId;
}
}
await tick();
autoScroll = window.innerHeight + window.scrollY >= document.body.offsetHeight - 40;
setTimeout(() => {
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
}, 100);
};
</script>
{#if messages.length == 0}
<div class="m-auto text-center max-w-md pb-56 px-2">
<div class="flex justify-center mt-8">
<img src="/ollama.png" class=" w-16 invert-[10%] dark:invert-[100%] rounded-full" />
</div>
<div class=" mt-1 text-2xl text-gray-800 dark:text-gray-100 font-semibold">
How can I help you today?
</div>
</div>
{:else}
{#each messages as message, messageIdx}
<div class=" w-full">
<div class="flex justify-between px-5 mb-3 max-w-3xl mx-auto rounded-lg group">
<div class=" flex w-full">
<div class=" mr-4">
{#if message.role === 'user'}
{#if $config === null}
<img
src="{$settings.gravatarUrl ? $settings.gravatarUrl : '/user'}.png"
class=" max-w-[28px] object-cover rounded-full"
alt="User profile"
/>
{:else}
<img
src={$user ? $user.profile_image_url : '/user.png'}
class=" max-w-[28px] object-cover rounded-full"
alt="User profile"
/>
{/if}
{:else}
<img
src="/favicon.png"
class=" max-w-[28px] object-cover rounded-full"
alt="Ollama profile"
/>
{/if}
</div>
<div class="w-full">
<div class=" self-center font-bold mb-0.5">
{#if message.role === 'user'}
You
{:else}
Ollama <span class=" text-gray-500 text-sm font-medium"
>{message.model ? ` ${message.model}` : ''}</span
>
{/if}
</div>
{#if message.role !== 'user' && message.content === ''}
<div class="w-full mt-3">
<div class="animate-pulse flex w-full">
<div class="space-y-2 w-full">
<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded mr-14" />
<div class="grid grid-cols-3 gap-4">
<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-2" />
<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-1" />
</div>
<div class="grid grid-cols-4 gap-4">
<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-1" />
<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-2" />
<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-1 mr-4" />
</div>
<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded" />
</div>
</div>
</div>
{:else}
<div
class="prose chat-{message.role} w-full max-w-full dark:prose-invert prose-headings:my-0 prose-p:my-0 prose-p:-mb-4 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-img:my-0 prose-ul:-my-4 prose-ol:-my-4 prose-li:-my-3 prose-ul:-mb-6 prose-ol:-mb-6 prose-li:-mb-4 whitespace-pre-line"
>
{#if message.role == 'user'}
{#if message?.edit === true}
<div class=" w-full">
<textarea
class=" bg-transparent outline-none w-full resize-none"
bind:value={history.messages[message.id].editedContent}
on:input={(e) => {
e.target.style.height = '';
e.target.style.height = `${e.target.scrollHeight}px`;
}}
on:focus={(e) => {
e.target.style.height = '';
e.target.style.height = `${e.target.scrollHeight}px`;
}}
/>
<div class=" flex justify-end space-x-2 text-sm font-medium">
<button
class="px-4 py-2.5 bg-emerald-600 hover:bg-emerald-700 text-gray-100 transition rounded-lg"
on:click={() => {
confirmEditMessage(message.id);
}}
>
Save & Submit
</button>
<button
class=" px-4 py-2.5 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-100 transition outline outline-1 outline-gray-200 dark:outline-gray-600 rounded-lg"
on:click={() => {
cancelEditMessage(message.id);
}}
>
Cancel
</button>
</div>
</div>
{:else}
<div class="w-full">
{message.content}
<div class=" flex justify-start space-x-1">
{#if message.parentId !== null && message.parentId in history.messages && (history.messages[message.parentId]?.childrenIds.length ?? 0) > 1}
<div class="flex self-center">
<button
class="self-center"
on:click={() => {
showPreviousMessage(message);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z"
clip-rule="evenodd"
/>
</svg>
</button>
<div class="text-xs font-bold self-center">
{history.messages[message.parentId].childrenIds.indexOf(message.id) +
1} / {history.messages[message.parentId].childrenIds.length}
</div>
<button
class="self-center"
on:click={() => {
showNextMessage(message);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
{:else if message.parentId === null && Object.values(history.messages).filter((message) => message.parentId === null).length > 1}
<div class="flex self-center">
<button
class="self-center"
on:click={() => {
showPreviousMessage(message);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z"
clip-rule="evenodd"
/>
</svg>
</button>
<div class="text-xs font-bold self-center">
{Object.values(history.messages)
.filter((message) => message.parentId === null)
.map((message) => message.id)
.indexOf(message.id) + 1} / {Object.values(history.messages).filter(
(message) => message.parentId === null
).length}
</div>
<button
class="self-center"
on:click={() => {
showNextMessage(message);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
{/if}
<button
class="invisible group-hover:visible p-1 rounded dark:hover:bg-gray-800 transition"
on:click={() => {
editMessageHandler(message.id);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"
/>
</svg>
</button>
</div>
</div>
{/if}
{:else}
<div class="w-full">
{@html marked(message.content.replace('\\\\', '\\\\\\'))}
{#if message.done}
<div class=" flex justify-start space-x-1 -mt-2">
{#if message.parentId !== null && message.parentId in history.messages && (history.messages[message.parentId]?.childrenIds.length ?? 0) > 1}
<div class="flex self-center">
<button
class="self-center"
on:click={() => {
showPreviousMessage(message);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z"
clip-rule="evenodd"
/>
</svg>
</button>
<div class="text-xs font-bold self-center">
{history.messages[message.parentId].childrenIds.indexOf(message.id) +
1} / {history.messages[message.parentId].childrenIds.length}
</div>
<button
class="self-center"
on:click={() => {
showNextMessage(message);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
{/if}
<button
class="{messageIdx + 1 === messages.length
? 'visible'
: 'invisible group-hover:visible'} p-1 rounded dark:hover:bg-gray-800 transition"
on:click={() => {
copyToClipboard(message.content);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
/>
</svg>
</button>
<button
class="{messageIdx + 1 === messages.length
? 'visible'
: 'invisible group-hover:visible'} p-1 rounded dark:hover:bg-gray-800 transition"
on:click={() => {
rateMessage(messageIdx, 1);
}}
>
<svg
stroke="currentColor"
fill="none"
stroke-width="2"
viewBox="0 0 24 24"
stroke-linecap="round"
stroke-linejoin="round"
class="w-4 h-4"
xmlns="http://www.w3.org/2000/svg"
><path
d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"
/></svg
>
</button>
<button
class="{messageIdx + 1 === messages.length
? 'visible'
: 'invisible group-hover:visible'} p-1 rounded dark:hover:bg-gray-800 transition"
on:click={() => {
rateMessage(messageIdx, -1);
}}
>
<svg
stroke="currentColor"
fill="none"
stroke-width="2"
viewBox="0 0 24 24"
stroke-linecap="round"
stroke-linejoin="round"
class="w-4 h-4"
xmlns="http://www.w3.org/2000/svg"
><path
d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"
/></svg
>
</button>
{#if messageIdx + 1 === messages.length}
<button
type="button"
class="{messageIdx + 1 === messages.length
? 'visible'
: 'invisible group-hover:visible'} p-1 rounded dark:hover:bg-gray-800 transition"
on:click={regenerateResponse}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
{/if}
</div>
{/if}
</div>
{/if}
</div>
{/if}
</div>
<!-- {} -->
</div>
</div>
</div>
{/each}
{/if}

View file

@ -0,0 +1,117 @@
<script lang="ts">
import { models, showSettings, settings } from '$lib/stores';
import toast from 'svelte-french-toast';
export let selectedModels = [''];
export let disabled = false;
const saveDefaultModel = () => {
settings.set({ ...$settings, models: selectedModels });
localStorage.setItem('settings', JSON.stringify($settings));
toast.success('Default model updated');
};
</script>
<div class="flex flex-col my-2">
{#each selectedModels as selectedModel, selectedModelIdx}
<div class="flex">
<select
id="models"
class="outline-none bg-transparent text-lg font-semibold rounded-lg block w-full placeholder-gray-400"
bind:value={selectedModel}
{disabled}
>
<option class=" text-gray-700" value="" selected>Select a model</option>
{#each $models as model}
{#if model.name === 'hr'}
<hr />
{:else}
<option value={model.name} class="text-gray-700 text-lg">{model.name}</option>
{/if}
{/each}
</select>
{#if selectedModelIdx === 0}
<button
class=" self-center {selectedModelIdx === 0
? 'mr-3'
: 'mr-7'} disabled:text-gray-600 disabled:hover:text-gray-600"
disabled={selectedModels.length === 3 || disabled}
on:click={() => {
if (selectedModels.length < 3) {
selectedModels = [...selectedModels, ''];
}
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12m6-6H6" />
</svg>
</button>
{:else}
<button
class=" self-center disabled:text-gray-600 disabled:hover:text-gray-600 {selectedModelIdx ===
0
? 'mr-3'
: 'mr-7'}"
{disabled}
on:click={() => {
selectedModels.splice(selectedModelIdx, 1);
selectedModels = selectedModels;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12h-15" />
</svg>
</button>
{/if}
{#if selectedModelIdx === 0}
<button
class=" self-center dark:hover:text-gray-300"
on:click={async () => {
await showSettings.set(true);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</button>
{/if}
</div>
{/each}
</div>
<div class="text-left mt-1.5 text-xs text-gray-500">
<button on:click={saveDefaultModel}> Set as default</button>
</div>

View file

@ -1,14 +1,20 @@
<script lang="ts"> <script lang="ts">
import sha256 from 'js-sha256';
import Modal from '../common/Modal.svelte'; 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 toast from 'svelte-french-toast';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { config, models, settings, user } from '$lib/stores';
import { splitStream, getGravatarURL } from '$lib/utils';
export let show = false; 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'; let selectedTab = 'general';
@ -32,6 +38,7 @@
let pullProgress = null; let pullProgress = null;
// Addons // Addons
let titleAutoGenerate = true;
let speechAutoSend = false; let speechAutoSend = false;
let gravatarEmail = ''; let gravatarEmail = '';
let OPENAI_API_KEY = ''; let OPENAI_API_KEY = '';
@ -41,42 +48,16 @@
let authType = 'Basic'; let authType = 'Basic';
let authContent = ''; 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 () => { const checkOllamaConnection = async () => {
if (API_BASE_URL === '') { if (API_BASE_URL === '') {
API_BASE_URL = BUILD_TIME_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'); toast.success('Server connection verified');
await models.set(_models);
saveSettings({ saveSettings({
API_BASE_URL: API_BASE_URL API_BASE_URL: API_BASE_URL
}); });
@ -111,6 +92,11 @@
saveSettings({ speechAutoSend: speechAutoSend }); saveSettings({ speechAutoSend: speechAutoSend });
}; };
const toggleTitleAutoGenerate = async () => {
titleAutoGenerate = !titleAutoGenerate;
saveSettings({ titleAutoGenerate: titleAutoGenerate });
};
const toggleAuthHeader = async () => { const toggleAuthHeader = async () => {
authEnabled = !authEnabled; authEnabled = !authEnabled;
}; };
@ -119,7 +105,9 @@
const res = await fetch(`${API_BASE_URL}/pull`, { const res = await fetch(`${API_BASE_URL}/pull`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'text/event-stream' 'Content-Type': 'text/event-stream',
...($settings.authHeader && { Authorization: $settings.authHeader }),
...($user && { Authorization: `Bearer ${localStorage.token}` })
}, },
body: JSON.stringify({ body: JSON.stringify({
name: modelTag name: modelTag
@ -147,8 +135,12 @@
if (data.error) { if (data.error) {
throw data.error; throw data.error;
} }
if (data.detail) {
throw data.detail;
}
if (data.status) { if (data.status) {
if (!data.status.includes('downloading')) { if (!data.digest) {
toast.success(data.status); toast.success(data.status);
} else { } else {
digest = data.digest; digest = data.digest;
@ -168,14 +160,16 @@
} }
modelTag = ''; modelTag = '';
await getModelTags(); models.set(await getModels());
}; };
const deleteModelHandler = async () => { const deleteModelHandler = async () => {
const res = await fetch(`${API_BASE_URL}/delete`, { const res = await fetch(`${API_BASE_URL}/delete`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'Content-Type': 'text/event-stream' 'Content-Type': 'text/event-stream',
...($settings.authHeader && { Authorization: $settings.authHeader }),
...($user && { Authorization: `Bearer ${localStorage.token}` })
}, },
body: JSON.stringify({ body: JSON.stringify({
name: deleteModelTag name: deleteModelTag
@ -203,6 +197,10 @@
if (data.error) { if (data.error) {
throw data.error; throw data.error;
} }
if (data.detail) {
throw data.detail;
}
if (data.status) { if (data.status) {
} }
} else { } else {
@ -216,7 +214,7 @@
} }
deleteModelTag = ''; deleteModelTag = '';
await getModelTags(); models.set(await getModels());
}; };
$: if (show) { $: if (show) {
@ -234,11 +232,76 @@
top_k = settings.top_k ?? 40; top_k = settings.top_k ?? 40;
top_p = settings.top_p ?? 0.9; top_p = settings.top_p ?? 0.9;
titleAutoGenerate = settings.titleAutoGenerate ?? true;
speechAutoSend = settings.speechAutoSend ?? false; speechAutoSend = settings.speechAutoSend ?? false;
gravatarEmail = settings.gravatarEmail ?? ''; gravatarEmail = settings.gravatarEmail ?? '';
OPENAI_API_KEY = settings.OPENAI_API_KEY ?? ''; 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(() => { onMount(() => {
let settings = JSON.parse(localStorage.getItem('settings') ?? '{}'); let settings = JSON.parse(localStorage.getItem('settings') ?? '{}');
@ -378,6 +441,7 @@
<div class=" self-center">Add-ons</div> <div class=" self-center">Add-ons</div>
</button> </button>
{#if !$config || ($config && !$config.auth)}
<button <button
class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab === class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
'auth' 'auth'
@ -403,6 +467,7 @@
</div> </div>
<div class=" self-center">Authentication</div> <div class=" self-center">Authentication</div>
</button> </button>
{/if}
<button <button
class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab === class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
@ -793,6 +858,28 @@
}} }}
> >
<div class=" space-y-3"> <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>
<div class=" py-1 flex w-full justify-between"> <div class=" py-1 flex w-full justify-between">
<div class=" self-center text-sm font-medium">Voice Input Auto-Send</div> <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=" mb-2.5 text-sm font-medium">Ollama Web UI Version</div>
<div class="flex w-full"> <div class="flex w-full">
<div class="flex-1 text-xs text-gray-700 dark:text-gray-200"> <div class="flex-1 text-xs text-gray-700 dark:text-gray-200">
{WEB_UI_VERSION} {$config && $config.version ? $config.version : WEB_UI_VERSION}
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,47 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { v4 as uuidv4 } from 'uuid';
let show = false; import { goto } from '$app/navigation';
let navElement; import { chatId } from '$lib/stores';
let importFileInputElement;
let importFiles;
export let selectedChatId = '';
export let title: string = 'Ollama Web UI'; 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> </script>
<div <div
@ -54,8 +17,10 @@
<div class="pr-2"> <div class="pr-2">
<button <button
class=" cursor-pointer p-1 flex dark:hover:bg-gray-700 rounded-lg transition" class=" cursor-pointer p-1 flex dark:hover:bg-gray-700 rounded-lg transition"
on:click={() => { on:click={async () => {
createNewChat(); console.log('newChat');
goto('/');
await chatId.set(uuidv4());
}} }}
> >
<div class=" m-auto self-center"> <div class=" m-auto self-center">
@ -85,360 +50,3 @@
</nav> </nav>
</div> </div>
</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>

View file

@ -0,0 +1,559 @@
<script lang="ts">
import { v4 as uuidv4 } from 'uuid';
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores';
import { user, db, chats, showSettings, chatId } from '$lib/stores';
import { onMount } from 'svelte';
let show = false;
let navElement;
let importFileInputElement;
let importFiles;
let title: string = 'Ollama Web UI';
let chatTitleEditIdx = null;
let chatTitle = '';
let showDropdown = false;
onMount(async () => {
if (window.innerWidth > 1280) {
show = true;
}
await chats.set(await $db.getChats());
});
const loadChat = async (id) => {
goto(`/c/${id}`);
};
const editChatTitle = async (id, _title) => {
await $db.updateChatById(id, {
title: _title
});
title = _title;
};
const deleteChat = async (id) => {
goto('/');
$db.deleteChatById(id);
};
const deleteChatHistory = async () => {
await $db.deleteAllChat();
};
const importChats = async (chatHistory) => {
await $db.addChats(chatHistory);
};
const exportChats = async () => {
let blob = new Blob([JSON.stringify(await $db.exportChats())], { type: 'application/json' });
saveAs(blob, `chat-export-${Date.now()}.json`);
};
$: if (importFiles) {
console.log(importFiles);
let reader = new FileReader();
reader.onload = (event) => {
let chats = JSON.parse(event.target.result);
console.log(chats);
importChats(chats);
};
reader.readAsText(importFiles[0]);
}
</script>
<div
bind:this={navElement}
class="h-screen {show
? ''
: '-translate-x-[260px]'} w-[260px] fixed top-0 left-0 z-40 transition bg-[#0a0a0a] text-gray-200 shadow-2xl text-sm
"
>
<div class="py-2.5 my-auto flex flex-col justify-between h-screen">
<div class="px-2.5 flex justify-center space-x-2">
<button
class="flex-grow flex justify-between rounded-md px-3 py-1.5 my-2 hover:bg-gray-900 transition"
on:click={async () => {
goto('/');
await chatId.set(uuidv4());
// createNewChat();
}}
>
<div class="flex self-center">
<div class="self-center mr-3.5">
<img src="/ollama.png" class=" w-5 invert-[100%] rounded-full" />
</div>
<div class=" self-center font-medium text-sm">New Chat</div>
</div>
<div class="self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M5.433 13.917l1.262-3.155A4 4 0 017.58 9.42l6.92-6.918a2.121 2.121 0 013 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 01-.65-.65z"
/>
<path
d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0010 3H4.75A2.75 2.75 0 002 5.75v9.5A2.75 2.75 0 004.75 18h9.5A2.75 2.75 0 0017 15.25V10a.75.75 0 00-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5z"
/>
</svg>
</div>
</button>
<!-- <button
class=" cursor-pointer w-12 rounded-md flex"
on:click={() => {
show = !show;
}}
>
<div class=" m-auto self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
fill-rule="evenodd"
d="M3 4.25A2.25 2.25 0 015.25 2h5.5A2.25 2.25 0 0113 4.25v2a.75.75 0 01-1.5 0v-2a.75.75 0 00-.75-.75h-5.5a.75.75 0 00-.75.75v11.5c0 .414.336.75.75.75h5.5a.75.75 0 00.75-.75v-2a.75.75 0 011.5 0v2A2.25 2.25 0 0110.75 18h-5.5A2.25 2.25 0 013 15.75V4.25z"
clip-rule="evenodd"
/>
<path
fill-rule="evenodd"
d="M19 10a.75.75 0 00-.75-.75H8.704l1.048-.943a.75.75 0 10-1.004-1.114l-2.5 2.25a.75.75 0 000 1.114l2.5 2.25a.75.75 0 101.004-1.114l-1.048-.943h9.546A.75.75 0 0019 10z"
clip-rule="evenodd"
/>
</svg>
</div>
</button> -->
</div>
<div class="pl-2.5 my-3 flex-1 flex flex-col space-y-1 overflow-y-auto">
{#each $chats as chat, i}
<div class=" w-full pr-2 relative">
<button
class=" w-full flex justify-between rounded-md px-3 py-2 hover:bg-gray-900 {chat.id ===
$chatId
? 'bg-gray-900'
: ''} transition whitespace-nowrap text-ellipsis"
on:click={() => {
// goto(`/c/${chat.id}`);
if (chat.id !== chatTitleEditIdx) {
chatTitleEditIdx = null;
chatTitle = '';
}
if (chat.id !== $chatId) {
loadChat(chat.id);
}
}}
>
<div class=" flex self-center flex-1">
<div class=" self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
/>
</svg>
</div>
<div
class=" text-left self-center overflow-hidden {chat.id === $chatId
? 'w-[120px]'
: 'w-[180px]'} "
>
{#if chatTitleEditIdx === chat.id}
<input bind:value={chatTitle} class=" bg-transparent w-full" />
{:else}
{chat.title}
{/if}
</div>
</div>
</button>
{#if chat.id === $chatId}
<div class=" absolute right-[22px] top-[10px]">
{#if chatTitleEditIdx === chat.id}
<div class="flex self-center space-x-1.5">
<button
class=" self-center hover:text-white transition"
on:click={() => {
editChatTitle(chat.id, chatTitle);
chatTitleEditIdx = null;
chatTitle = '';
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
clip-rule="evenodd"
/>
</svg>
</button>
<button
class=" self-center hover:text-white transition"
on:click={() => {
chatTitleEditIdx = null;
chatTitle = '';
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div>
{:else}
<div class="flex self-center space-x-1.5">
<button
class=" self-center hover:text-white transition"
on:click={() => {
chatTitle = chat.title;
chatTitleEditIdx = chat.id;
// editChatTitle(chat.id, 'a');
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"
/>
</svg>
</button>
<button
class=" self-center hover:text-white transition"
on:click={() => {
deleteChat(chat.id);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
</button>
</div>
{/if}
</div>
{/if}
</div>
{/each}
</div>
<div class="px-2.5">
<hr class=" border-gray-800 mb-2 w-full" />
<div class="flex flex-col">
<div class="flex">
<input bind:this={importFileInputElement} bind:files={importFiles} type="file" hidden />
<button
class=" flex rounded-md py-3 px-3.5 w-full hover:bg-gray-900 transition"
on:click={() => {
importFileInputElement.click();
// importChats();
}}
>
<div class=" self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m6.75 12l-3-3m0 0l-3 3m3-3v6m-1.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
</div>
<div class=" self-center">Import</div>
</button>
<button
class=" flex rounded-md py-3 px-3.5 w-full hover:bg-gray-900 transition"
on:click={() => {
exportChats();
}}
>
<div class=" self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m.75 12l3 3m0 0l3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
</div>
<div class=" self-center">Export</div>
</button>
</div>
<button
class=" flex rounded-md py-3 px-3.5 w-full hover:bg-gray-900 transition"
on:click={() => {
deleteChatHistory();
}}
>
<div class=" self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
</div>
<div class=" self-center">Clear conversations</div>
</button>
{#if $user !== undefined}
<button
class=" flex rounded-md py-3 px-3.5 w-full hover:bg-gray-900 transition"
on:click={() => {
showDropdown = !showDropdown;
}}
on:focusout={() => {
setTimeout(() => {
showDropdown = false;
}, 150);
}}
>
<div class=" self-center mr-3">
<img
src={$user.profile_image_url}
class=" max-w-[30px] object-cover rounded-full"
alt="User profile"
/>
</div>
<div class=" self-center font-semibold">{$user.name}</div>
</button>
{#if showDropdown}
<div
id="dropdownDots"
class="absolute z-10 bottom-[70px] 4.5rem rounded-lg shadow w-[240px] bg-gray-900"
>
<div class="py-2 w-full">
{#if $user.role === 'admin'}
<button
class="flex py-2.5 px-3.5 w-full hover:bg-gray-800 transition"
on:click={() => {
goto('/admin');
}}
>
<div class=" self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
<div class=" self-center font-medium">Admin Panel</div>
</button>
{/if}
<button
class="flex py-2.5 px-3.5 w-full hover:bg-gray-800 transition"
on:click={async () => {
await showSettings.set(true);
}}
>
<div class=" self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
<div class=" self-center font-medium">Settings</div>
</button>
</div>
<hr class=" border-gray-700 m-0 p-0" />
<div class="py-2 w-full">
<button
class="flex py-2.5 px-3.5 w-full hover:bg-gray-800 transition"
on:click={() => {
localStorage.removeItem('token');
location.href = '/auth';
}}
>
<div class=" self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
fill-rule="evenodd"
d="M3 4.25A2.25 2.25 0 015.25 2h5.5A2.25 2.25 0 0113 4.25v2a.75.75 0 01-1.5 0v-2a.75.75 0 00-.75-.75h-5.5a.75.75 0 00-.75.75v11.5c0 .414.336.75.75.75h5.5a.75.75 0 00.75-.75v-2a.75.75 0 011.5 0v2A2.25 2.25 0 0110.75 18h-5.5A2.25 2.25 0 013 15.75V4.25z"
clip-rule="evenodd"
/>
<path
fill-rule="evenodd"
d="M6 10a.75.75 0 01.75-.75h9.546l-1.048-.943a.75.75 0 111.004-1.114l2.5 2.25a.75.75 0 010 1.114l-2.5 2.25a.75.75 0 11-1.004-1.114l1.048-.943H6.75A.75.75 0 016 10z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center font-medium">Sign Out</div>
</button>
</div>
</div>
{/if}
{:else}
<button
class=" flex rounded-md py-3 px-3.5 w-full hover:bg-gray-900 transition"
on:click={async () => {
await showSettings.set(true);
}}
>
<div class=" self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
<div class=" self-center font-medium">Settings</div>
</button>
{/if}
</div>
</div>
</div>
<div
class="fixed left-0 top-[50dvh] z-40 -translate-y-1/2 transition-transform translate-x-[255px] md:translate-x-[260px] rotate-0"
>
<button
class=" group"
on:click={() => {
show = !show;
}}
><span class="" data-state="closed"
><div
class="flex h-[72px] w-8 items-center justify-center opacity-20 group-hover:opacity-100 transition"
>
<div class="flex h-6 w-6 flex-col items-center">
<div
class="h-3 w-1 rounded-full bg-[#0f0f0f] dark:bg-white rotate-0 translate-y-[0.15rem] {show
? 'group-hover:rotate-[15deg]'
: 'group-hover:rotate-[-15deg]'}"
/>
<div
class="h-3 w-1 rounded-full bg-[#0f0f0f] dark:bg-white rotate-0 translate-y-[-0.15rem] {show
? 'group-hover:rotate-[-15deg]'
: 'group-hover:rotate-[15deg]'}"
/>
</div>
</div>
</span>
</button>
</div>
</div>

View file

@ -1,14 +1,18 @@
import { browser } from '$app/environment'; import { dev, browser } from '$app/environment';
import { PUBLIC_API_BASE_URL } from '$env/static/public'; import { PUBLIC_API_BASE_URL } from '$env/static/public';
export const API_BASE_URL = export const OLLAMA_API_BASE_URL =
PUBLIC_API_BASE_URL === '' PUBLIC_API_BASE_URL === ''
? browser ? dev
? `http://${location.hostname}:8080/ollama/api`
: browser
? `http://${location.hostname}:11434/api` ? `http://${location.hostname}:11434/api`
: `http://localhost:11434/api` : `http://localhost:11434/api`
: PUBLIC_API_BASE_URL; : 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 // Source: https://kit.svelte.dev/docs/modules#$env-static-public
// This feature, akin to $env/static/private, exclusively incorporates environment variables // This feature, akin to $env/static/private, exclusively incorporates environment variables

13
src/lib/stores/index.ts Normal file
View file

@ -0,0 +1,13 @@
import { writable } from 'svelte/store';
// Backend
export const config = writable(undefined);
export const user = writable(undefined);
// Frontend
export const db = writable(undefined);
export const chatId = writable('');
export const chats = writable([]);
export const models = writable([]);
export const settings = writable({});
export const showSettings = writable(false);

67
src/lib/utils/index.ts Normal file
View file

@ -0,0 +1,67 @@
import { v4 as uuidv4 } from 'uuid';
import sha256 from 'js-sha256';
//////////////////////////
// Helper functions
//////////////////////////
export const splitStream = (splitOn) => {
let buffer = '';
return new TransformStream({
transform(chunk, controller) {
buffer += chunk;
const parts = buffer.split(splitOn);
parts.slice(0, -1).forEach((part) => controller.enqueue(part));
buffer = parts[parts.length - 1];
},
flush(controller) {
if (buffer) controller.enqueue(buffer);
}
});
};
export const convertMessagesToHistory = (messages) => {
let history = {
messages: {},
currentId: null
};
let parentMessageId = null;
let messageId = null;
for (const message of messages) {
messageId = uuidv4();
if (parentMessageId !== null) {
history.messages[parentMessageId].childrenIds = [
...history.messages[parentMessageId].childrenIds,
messageId
];
}
history.messages[messageId] = {
...message,
id: messageId,
parentId: parentMessageId,
childrenIds: []
};
parentMessageId = messageId;
}
history.currentId = messageId;
return history;
};
export const getGravatarURL = (email) => {
// Trim leading and trailing whitespace from
// an email address and force all characters
// to lower case
const address = String(email).trim().toLowerCase();
// Create a SHA256 hash of the final string
const hash = sha256(address);
// Grab the actual image URL
return `https://www.gravatar.com/avatar/${hash}`;
};

View file

@ -0,0 +1,221 @@
<script lang="ts">
import { openDB, deleteDB } from 'idb';
import { onMount, tick } from 'svelte';
import { goto } from '$app/navigation';
import { config, user, showSettings, settings, models, db, chats } from '$lib/stores';
import SettingsModal from '$lib/components/chat/SettingsModal.svelte';
import Sidebar from '$lib/components/layout/Sidebar.svelte';
import toast from 'svelte-french-toast';
import { OLLAMA_API_BASE_URL } from '$lib/constants';
let loaded = false;
const getModels = async () => {
let models = [];
const res = await fetch(`${$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL}/tags`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...($settings.authHeader && { Authorization: $settings.authHeader }),
...($user && { Authorization: `Bearer ${localStorage.token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((error) => {
console.log(error);
if ('detail' in error) {
toast.error(error.detail);
} else {
toast.error('Server connection failed');
}
return null;
});
console.log(res);
models.push(...(res?.models ?? []));
// If OpenAI API Key exists
if ($settings.OPENAI_API_KEY) {
// Validate OPENAI_API_KEY
const openaiModelRes = await fetch(`https://api.openai.com/v1/models`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${$settings.OPENAI_API_KEY}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((error) => {
console.log(error);
toast.error(`OpenAI: ${error?.error?.message ?? 'Network Problem'}`);
return null;
});
const openAIModels = openaiModelRes?.data ?? null;
models.push(
...(openAIModels
? [
{ name: 'hr' },
...openAIModels
.map((model) => ({ name: model.id, label: 'OpenAI' }))
.filter((model) => model.name.includes('gpt'))
]
: [])
);
}
return models;
};
const getDB = async () => {
const _db = await openDB('Chats', 1, {
upgrade(db) {
const store = db.createObjectStore('chats', {
keyPath: 'id',
autoIncrement: true
});
store.createIndex('timestamp', 'timestamp');
}
});
return {
db: _db,
getChatById: async function (id) {
return await this.db.get('chats', id);
},
getChats: async function () {
let chats = await this.db.getAllFromIndex('chats', 'timestamp');
chats = chats.map((item, idx) => ({
title: chats[chats.length - 1 - idx].title,
id: chats[chats.length - 1 - idx].id
}));
return chats;
},
exportChats: async function () {
let chats = await this.db.getAllFromIndex('chats', 'timestamp');
chats = chats.map((item, idx) => chats[chats.length - 1 - idx]);
return chats;
},
addChats: async function (_chats) {
for (const chat of _chats) {
console.log(chat);
await this.addChat(chat);
}
await chats.set(await this.getChats());
},
addChat: async function (chat) {
await this.db.put('chats', {
...chat
});
},
createNewChat: async function (chat) {
await this.addChat({ ...chat, timestamp: Date.now() });
await chats.set(await this.getChats());
},
updateChatById: async function (id, updated) {
const chat = await this.getChatById(id);
await this.db.put('chats', {
...chat,
...updated,
timestamp: Date.now()
});
await chats.set(await this.getChats());
},
deleteChatById: async function (id) {
await this.db.delete('chats', id);
await chats.set(await this.getChats());
},
deleteAllChat: async function () {
const tx = this.db.transaction('chats', 'readwrite');
await Promise.all([tx.store.clear(), tx.done]);
await chats.set(await this.getChats());
}
};
};
onMount(async () => {
if ($config && $config.auth && $user === undefined) {
await goto('/auth');
}
await settings.set(JSON.parse(localStorage.getItem('settings') ?? JSON.stringify($settings)));
let _models = await getModels();
await models.set(_models);
let _db = await getDB();
await db.set(_db);
await tick();
loaded = true;
});
</script>
{#if loaded}
<div class="app">
<div
class=" text-gray-700 dark:text-gray-100 bg-white dark:bg-gray-800 min-h-screen overflow-auto flex flex-row"
>
<Sidebar />
<SettingsModal bind:show={$showSettings} />
<slot />
</div>
</div>
{/if}
<style>
.loading {
display: inline-block;
clip-path: inset(0 1ch 0 0);
animation: l 1s steps(3) infinite;
letter-spacing: -0.5px;
}
@keyframes l {
to {
clip-path: inset(0 -1ch 0 0);
}
}
pre[class*='language-'] {
position: relative;
overflow: auto;
/* make space */
margin: 5px 0;
padding: 1.75rem 0 1.75rem 1rem;
border-radius: 10px;
}
pre[class*='language-'] button {
position: absolute;
top: 5px;
right: 5px;
font-size: 0.9rem;
padding: 0.15rem;
background-color: #828282;
border: ridge 1px #7b7b7c;
border-radius: 5px;
text-shadow: #c4c4c4 0 0 2px;
}
pre[class*='language-'] button:hover {
cursor: pointer;
background-color: #bcbabb;
}
</style>

View file

@ -0,0 +1,481 @@
<script lang="ts">
import { v4 as uuidv4 } from 'uuid';
import toast from 'svelte-french-toast';
import { OLLAMA_API_BASE_URL } from '$lib/constants';
import { onMount, tick } from 'svelte';
import { splitStream } from '$lib/utils';
import { goto } from '$app/navigation';
import { config, user, settings, db, chats, chatId } from '$lib/stores';
import MessageInput from '$lib/components/chat/MessageInput.svelte';
import Messages from '$lib/components/chat/Messages.svelte';
import ModelSelector from '$lib/components/chat/ModelSelector.svelte';
import Navbar from '$lib/components/layout/Navbar.svelte';
let stopResponseFlag = false;
let autoScroll = true;
let selectedModels = [''];
let title = '';
let prompt = '';
let messages = [];
let history = {
messages: {},
currentId: null
};
$: if (history.currentId !== null) {
let _messages = [];
let currentMessage = history.messages[history.currentId];
while (currentMessage !== null) {
_messages.unshift({ ...currentMessage });
currentMessage =
currentMessage.parentId !== null ? history.messages[currentMessage.parentId] : null;
}
messages = _messages;
}
onMount(async () => {
await chatId.set(uuidv4());
chatId.subscribe(async () => {
await initNewChat();
});
});
//////////////////////////
// Web functions
//////////////////////////
const initNewChat = async () => {
console.log($chatId);
autoScroll = true;
title = '';
messages = [];
history = {
messages: {},
currentId: null
};
selectedModels = $settings.models ?? [''];
};
//////////////////////////
// Ollama functions
//////////////////////////
const sendPrompt = async (userPrompt, parentId) => {
await Promise.all(
selectedModels.map(async (model) => {
if (model.includes('gpt-')) {
await sendPromptOpenAI(model, userPrompt, parentId);
} else {
await sendPromptOllama(model, userPrompt, parentId);
}
})
);
await chats.set(await $db.getChats());
};
const sendPromptOllama = async (model, userPrompt, parentId) => {
console.log('sendPromptOllama');
let responseMessageId = uuidv4();
let responseMessage = {
parentId: parentId,
id: responseMessageId,
childrenIds: [],
role: 'assistant',
content: '',
model: model
};
history.messages[responseMessageId] = responseMessage;
history.currentId = responseMessageId;
if (parentId !== null) {
history.messages[parentId].childrenIds = [
...history.messages[parentId].childrenIds,
responseMessageId
];
}
window.scrollTo({ top: document.body.scrollHeight });
const res = await fetch(`${$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL}/generate`, {
method: 'POST',
headers: {
'Content-Type': 'text/event-stream',
...($settings.authHeader && { Authorization: $settings.authHeader }),
...($user && { Authorization: `Bearer ${localStorage.token}` })
},
body: JSON.stringify({
model: model,
prompt: userPrompt,
system: $settings.system ?? undefined,
options: {
seed: $settings.seed ?? undefined,
temperature: $settings.temperature ?? undefined,
repeat_penalty: $settings.repeat_penalty ?? undefined,
top_k: $settings.top_k ?? undefined,
top_p: $settings.top_p ?? undefined
},
format: $settings.requestFormat ?? undefined,
context:
history.messages[parentId] !== null &&
history.messages[parentId].parentId in history.messages
? history.messages[history.messages[parentId].parentId]?.context ?? undefined
: undefined
})
});
const reader = res.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(splitStream('\n'))
.getReader();
while (true) {
const { value, done } = await reader.read();
if (done || stopResponseFlag) {
if (stopResponseFlag) {
responseMessage.done = true;
messages = messages;
}
break;
}
try {
let lines = value.split('\n');
for (const line of lines) {
if (line !== '') {
console.log(line);
let data = JSON.parse(line);
if (data.done == false) {
if (responseMessage.content == '' && data.response == '\n') {
continue;
} else {
responseMessage.content += data.response;
messages = messages;
}
} else if ('detail' in data) {
throw data;
} else {
responseMessage.done = true;
responseMessage.context = data.context;
messages = messages;
}
}
}
} catch (error) {
console.log(error);
if ('detail' in error) {
toast.error(error.detail);
}
break;
}
if (autoScroll) {
window.scrollTo({ top: document.body.scrollHeight });
}
await $db.updateChatById($chatId, {
title: title === '' ? 'New Chat' : title,
models: selectedModels,
system: $settings.system ?? undefined,
options: {
seed: $settings.seed ?? undefined,
temperature: $settings.temperature ?? undefined,
repeat_penalty: $settings.repeat_penalty ?? undefined,
top_k: $settings.top_k ?? undefined,
top_p: $settings.top_p ?? undefined
},
messages: messages,
history: history
});
}
stopResponseFlag = false;
await tick();
if (autoScroll) {
window.scrollTo({ top: document.body.scrollHeight });
}
if (messages.length == 2 && messages.at(1).content !== '') {
window.history.replaceState(history.state, '', `/c/${$chatId}`);
await generateChatTitle($chatId, userPrompt);
}
};
const sendPromptOpenAI = async (model, userPrompt, parentId) => {
if ($settings.OPENAI_API_KEY) {
if (models) {
let responseMessageId = uuidv4();
let responseMessage = {
parentId: parentId,
id: responseMessageId,
childrenIds: [],
role: 'assistant',
content: '',
model: model
};
history.messages[responseMessageId] = responseMessage;
history.currentId = responseMessageId;
if (parentId !== null) {
history.messages[parentId].childrenIds = [
...history.messages[parentId].childrenIds,
responseMessageId
];
}
window.scrollTo({ top: document.body.scrollHeight });
const res = await fetch(`https://api.openai.com/v1/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${$settings.OPENAI_API_KEY}`
},
body: JSON.stringify({
model: model,
stream: true,
messages: [
$settings.system
? {
role: 'system',
content: $settings.system
}
: undefined,
...messages
]
.filter((message) => message)
.map((message) => ({ role: message.role, content: message.content })),
temperature: $settings.temperature ?? undefined,
top_p: $settings.top_p ?? undefined,
frequency_penalty: $settings.repeat_penalty ?? undefined
})
});
const reader = res.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(splitStream('\n'))
.getReader();
while (true) {
const { value, done } = await reader.read();
if (done || stopResponseFlag) {
if (stopResponseFlag) {
responseMessage.done = true;
messages = messages;
}
break;
}
try {
let lines = value.split('\n');
for (const line of lines) {
if (line !== '') {
console.log(line);
if (line === 'data: [DONE]') {
responseMessage.done = true;
messages = messages;
} else {
let data = JSON.parse(line.replace(/^data: /, ''));
console.log(data);
if (responseMessage.content == '' && data.choices[0].delta.content == '\n') {
continue;
} else {
responseMessage.content += data.choices[0].delta.content ?? '';
messages = messages;
}
}
}
}
} catch (error) {
console.log(error);
}
if (autoScroll) {
window.scrollTo({ top: document.body.scrollHeight });
}
await $db.updateChatById($chatId, {
title: title === '' ? 'New Chat' : title,
models: selectedModels,
system: $settings.system ?? undefined,
options: {
seed: $settings.seed ?? undefined,
temperature: $settings.temperature ?? undefined,
repeat_penalty: $settings.repeat_penalty ?? undefined,
top_k: $settings.top_k ?? undefined,
top_p: $settings.top_p ?? undefined
},
messages: messages,
history: history
});
}
stopResponseFlag = false;
await tick();
if (autoScroll) {
window.scrollTo({ top: document.body.scrollHeight });
}
if (messages.length == 2) {
window.history.replaceState(history.state, '', `/c/${$chatId}`);
await setChatTitle($chatId, userPrompt);
}
}
}
};
const submitPrompt = async (userPrompt) => {
console.log('submitPrompt');
if (selectedModels.includes('')) {
toast.error('Model not selected');
} else if (messages.length != 0 && messages.at(-1).done != true) {
console.log('wait');
} else {
document.getElementById('chat-textarea').style.height = '';
let userMessageId = uuidv4();
let userMessage = {
id: userMessageId,
parentId: messages.length !== 0 ? messages.at(-1).id : null,
childrenIds: [],
role: 'user',
content: userPrompt
};
if (messages.length !== 0) {
history.messages[messages.at(-1).id].childrenIds.push(userMessageId);
}
history.messages[userMessageId] = userMessage;
history.currentId = userMessageId;
prompt = '';
if (messages.length == 0) {
await $db.createNewChat({
id: $chatId,
title: 'New Chat',
models: selectedModels,
system: $settings.system ?? undefined,
options: {
seed: $settings.seed ?? undefined,
temperature: $settings.temperature ?? undefined,
repeat_penalty: $settings.repeat_penalty ?? undefined,
top_k: $settings.top_k ?? undefined,
top_p: $settings.top_p ?? undefined
},
messages: messages,
history: history
});
}
setTimeout(() => {
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
}, 50);
await sendPrompt(userPrompt, userMessageId);
}
};
const stopResponse = () => {
stopResponseFlag = true;
console.log('stopResponse');
};
const regenerateResponse = async () => {
console.log('regenerateResponse');
if (messages.length != 0 && messages.at(-1).done == true) {
messages.splice(messages.length - 1, 1);
messages = messages;
let userMessage = messages.at(-1);
let userPrompt = userMessage.content;
await sendPrompt(userPrompt, userMessage.id);
}
};
const generateChatTitle = async (_chatId, userPrompt) => {
if ($settings.titleAutoGenerate ?? true) {
console.log('generateChatTitle');
const res = await fetch(`${$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL}/generate`, {
method: 'POST',
headers: {
'Content-Type': 'text/event-stream',
...($settings.authHeader && { Authorization: $settings.authHeader }),
...($user && { Authorization: `Bearer ${localStorage.token}` })
},
body: JSON.stringify({
model: selectedModels[0],
prompt: `Generate a brief 3-5 word title for this question, excluding the term 'title.' Then, please reply with only the title: ${userPrompt}`,
stream: false
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((error) => {
if ('detail' in error) {
toast.error(error.detail);
}
console.log(error);
return null;
});
if (res) {
await setChatTitle(_chatId, res.response === '' ? 'New Chat' : res.response);
}
} else {
await setChatTitle(_chatId, `${userPrompt}`);
}
};
const setChatTitle = async (_chatId, _title) => {
await $db.updateChatById(_chatId, { title: _title });
if (_chatId === $chatId) {
title = _title;
}
};
</script>
<svelte:window
on:scroll={(e) => {
autoScroll = window.innerHeight + window.scrollY >= document.body.offsetHeight - 40;
}}
/>
<Navbar {title} />
<div class="min-h-screen w-full flex justify-center">
<div class=" py-2.5 flex flex-col justify-between w-full">
<div class="max-w-2xl mx-auto w-full px-3 md:px-0 mt-10">
<ModelSelector bind:selectedModels disabled={messages.length > 0} />
</div>
<div class=" h-full mt-10 mb-32 w-full flex flex-col">
<Messages bind:history bind:messages bind:autoScroll {sendPrompt} {regenerateResponse} />
</div>
</div>
<MessageInput bind:prompt bind:autoScroll {messages} {submitPrompt} {stopResponse} />
</div>

View file

@ -0,0 +1,519 @@
<script lang="ts">
import { v4 as uuidv4 } from 'uuid';
import toast from 'svelte-french-toast';
import { OLLAMA_API_BASE_URL } from '$lib/constants';
import { onMount, tick } from 'svelte';
import { convertMessagesToHistory, splitStream } from '$lib/utils';
import { goto } from '$app/navigation';
import { config, user, settings, db, chats, chatId } from '$lib/stores';
import MessageInput from '$lib/components/chat/MessageInput.svelte';
import Messages from '$lib/components/chat/Messages.svelte';
import ModelSelector from '$lib/components/chat/ModelSelector.svelte';
import Navbar from '$lib/components/layout/Navbar.svelte';
import { page } from '$app/stores';
let loaded = false;
let stopResponseFlag = false;
let autoScroll = true;
// let chatId = $page.params.id;
let selectedModels = [''];
let title = '';
let prompt = '';
let messages = [];
let history = {
messages: {},
currentId: null
};
$: if (history.currentId !== null) {
let _messages = [];
let currentMessage = history.messages[history.currentId];
while (currentMessage !== null) {
_messages.unshift({ ...currentMessage });
currentMessage =
currentMessage.parentId !== null ? history.messages[currentMessage.parentId] : null;
}
messages = _messages;
}
// onMount(async () => {
// let chat = await loadChat();
// await tick();
// if (chat) {
// loaded = true;
// } else {
// await goto('/');
// }
// });
$: if ($page.params.id) {
(async () => {
let chat = await loadChat();
await tick();
if (chat) {
loaded = true;
} else {
await goto('/');
}
})();
}
//////////////////////////
// Web functions
//////////////////////////
const loadChat = async () => {
await chatId.set($page.params.id);
const chat = await $db.getChatById($chatId);
if (chat) {
console.log(chat);
selectedModels = (chat?.models ?? undefined) !== undefined ? chat.models : [chat.model ?? ''];
history =
(chat?.history ?? undefined) !== undefined
? chat.history
: convertMessagesToHistory(chat.messages);
title = chat.title;
await settings.set({
...$settings,
system: chat.system ?? $settings.system,
options: chat.options ?? $settings.options
});
autoScroll = true;
await tick();
if (messages.length > 0) {
history.messages[messages.at(-1).id].done = true;
}
await tick();
return chat;
} else {
return null;
}
};
//////////////////////////
// Ollama functions
//////////////////////////
const sendPrompt = async (userPrompt, parentId) => {
await Promise.all(
selectedModels.map(async (model) => {
if (model.includes('gpt-')) {
await sendPromptOpenAI(model, userPrompt, parentId);
} else {
await sendPromptOllama(model, userPrompt, parentId);
}
})
);
await chats.set(await $db.getChats());
};
const sendPromptOllama = async (model, userPrompt, parentId) => {
let responseMessageId = uuidv4();
let responseMessage = {
parentId: parentId,
id: responseMessageId,
childrenIds: [],
role: 'assistant',
content: '',
model: model
};
history.messages[responseMessageId] = responseMessage;
history.currentId = responseMessageId;
if (parentId !== null) {
history.messages[parentId].childrenIds = [
...history.messages[parentId].childrenIds,
responseMessageId
];
}
window.scrollTo({ top: document.body.scrollHeight });
const res = await fetch(`${$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL}/generate`, {
method: 'POST',
headers: {
'Content-Type': 'text/event-stream',
...($settings.authHeader && { Authorization: $settings.authHeader }),
...($user && { Authorization: `Bearer ${localStorage.token}` })
},
body: JSON.stringify({
model: model,
prompt: userPrompt,
system: $settings.system ?? undefined,
options: {
seed: $settings.seed ?? undefined,
temperature: $settings.temperature ?? undefined,
repeat_penalty: $settings.repeat_penalty ?? undefined,
top_k: $settings.top_k ?? undefined,
top_p: $settings.top_p ?? undefined
},
format: $settings.requestFormat ?? undefined,
context:
history.messages[parentId] !== null &&
history.messages[parentId].parentId in history.messages
? history.messages[history.messages[parentId].parentId]?.context ?? undefined
: undefined
})
});
const reader = res.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(splitStream('\n'))
.getReader();
while (true) {
const { value, done } = await reader.read();
if (done || stopResponseFlag) {
if (stopResponseFlag) {
responseMessage.done = true;
messages = messages;
}
break;
}
try {
let lines = value.split('\n');
for (const line of lines) {
if (line !== '') {
console.log(line);
let data = JSON.parse(line);
if (data.done == false) {
if (responseMessage.content == '' && data.response == '\n') {
continue;
} else {
responseMessage.content += data.response;
messages = messages;
}
} else if ('detail' in data) {
throw data;
} else {
responseMessage.done = true;
responseMessage.context = data.context;
messages = messages;
}
}
}
} catch (error) {
console.log(error);
if ('detail' in error) {
toast.error(error.detail);
}
break;
}
if (autoScroll) {
window.scrollTo({ top: document.body.scrollHeight });
}
await $db.updateChatById($chatId, {
title: title === '' ? 'New Chat' : title,
models: selectedModels,
system: $settings.system ?? undefined,
options: {
seed: $settings.seed ?? undefined,
temperature: $settings.temperature ?? undefined,
repeat_penalty: $settings.repeat_penalty ?? undefined,
top_k: $settings.top_k ?? undefined,
top_p: $settings.top_p ?? undefined
},
messages: messages,
history: history
});
}
stopResponseFlag = false;
await tick();
if (autoScroll) {
window.scrollTo({ top: document.body.scrollHeight });
}
if (messages.length == 2 && messages.at(1).content !== '') {
window.history.replaceState(history.state, '', `/c/${$chatId}`);
await generateChatTitle($chatId, userPrompt);
}
};
const sendPromptOpenAI = async (model, userPrompt, parentId) => {
if ($settings.OPENAI_API_KEY) {
if (models) {
let responseMessageId = uuidv4();
let responseMessage = {
parentId: parentId,
id: responseMessageId,
childrenIds: [],
role: 'assistant',
content: '',
model: model
};
history.messages[responseMessageId] = responseMessage;
history.currentId = responseMessageId;
if (parentId !== null) {
history.messages[parentId].childrenIds = [
...history.messages[parentId].childrenIds,
responseMessageId
];
}
window.scrollTo({ top: document.body.scrollHeight });
const res = await fetch(`https://api.openai.com/v1/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${$settings.OPENAI_API_KEY}`
},
body: JSON.stringify({
model: model,
stream: true,
messages: [
$settings.system
? {
role: 'system',
content: $settings.system
}
: undefined,
...messages
]
.filter((message) => message)
.map((message) => ({ role: message.role, content: message.content })),
temperature: $settings.temperature ?? undefined,
top_p: $settings.top_p ?? undefined,
frequency_penalty: $settings.repeat_penalty ?? undefined
})
});
const reader = res.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(splitStream('\n'))
.getReader();
while (true) {
const { value, done } = await reader.read();
if (done || stopResponseFlag) {
if (stopResponseFlag) {
responseMessage.done = true;
messages = messages;
}
break;
}
try {
let lines = value.split('\n');
for (const line of lines) {
if (line !== '') {
console.log(line);
if (line === 'data: [DONE]') {
responseMessage.done = true;
messages = messages;
} else {
let data = JSON.parse(line.replace(/^data: /, ''));
console.log(data);
if (responseMessage.content == '' && data.choices[0].delta.content == '\n') {
continue;
} else {
responseMessage.content += data.choices[0].delta.content ?? '';
messages = messages;
}
}
}
}
} catch (error) {
console.log(error);
}
if (autoScroll) {
window.scrollTo({ top: document.body.scrollHeight });
}
await $db.updateChatById($chatId, {
title: title === '' ? 'New Chat' : title,
models: selectedModels,
system: $settings.system ?? undefined,
options: {
seed: $settings.seed ?? undefined,
temperature: $settings.temperature ?? undefined,
repeat_penalty: $settings.repeat_penalty ?? undefined,
top_k: $settings.top_k ?? undefined,
top_p: $settings.top_p ?? undefined
},
messages: messages,
history: history
});
}
stopResponseFlag = false;
await tick();
if (autoScroll) {
window.scrollTo({ top: document.body.scrollHeight });
}
if (messages.length == 2) {
window.history.replaceState(history.state, '', `/c/${$chatId}`);
await setChatTitle($chatId, userPrompt);
}
}
}
};
const submitPrompt = async (userPrompt) => {
console.log('submitPrompt');
if (selectedModels.includes('')) {
toast.error('Model not selected');
} else if (messages.length != 0 && messages.at(-1).done != true) {
console.log('wait');
} else {
document.getElementById('chat-textarea').style.height = '';
let userMessageId = uuidv4();
let userMessage = {
id: userMessageId,
parentId: messages.length !== 0 ? messages.at(-1).id : null,
childrenIds: [],
role: 'user',
content: userPrompt
};
if (messages.length !== 0) {
history.messages[messages.at(-1).id].childrenIds.push(userMessageId);
}
history.messages[userMessageId] = userMessage;
history.currentId = userMessageId;
prompt = '';
if (messages.length == 0) {
await $db.createNewChat({
id: $chatId,
title: 'New Chat',
models: selectedModels,
system: $settings.system ?? undefined,
options: {
seed: $settings.seed ?? undefined,
temperature: $settings.temperature ?? undefined,
repeat_penalty: $settings.repeat_penalty ?? undefined,
top_k: $settings.top_k ?? undefined,
top_p: $settings.top_p ?? undefined
},
messages: messages,
history: history
});
}
setTimeout(() => {
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
}, 50);
await sendPrompt(userPrompt, userMessageId);
}
};
const stopResponse = () => {
stopResponseFlag = true;
console.log('stopResponse');
};
const regenerateResponse = async () => {
console.log('regenerateResponse');
if (messages.length != 0 && messages.at(-1).done == true) {
messages.splice(messages.length - 1, 1);
messages = messages;
let userMessage = messages.at(-1);
let userPrompt = userMessage.content;
await sendPrompt(userPrompt, userMessage.id);
}
};
const generateChatTitle = async (_chatId, userPrompt) => {
if ($settings.titleAutoGenerate ?? true) {
console.log('generateChatTitle');
const res = await fetch(`${$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL}/generate`, {
method: 'POST',
headers: {
'Content-Type': 'text/event-stream',
...($settings.authHeader && { Authorization: $settings.authHeader }),
...($user && { Authorization: `Bearer ${localStorage.token}` })
},
body: JSON.stringify({
model: selectedModels[0],
prompt: `Generate a brief 3-5 word title for this question, excluding the term 'title.' Then, please reply with only the title: ${userPrompt}`,
stream: false
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((error) => {
if ('detail' in error) {
toast.error(error.detail);
}
console.log(error);
return null;
});
if (res) {
await setChatTitle(_chatId, res.response === '' ? 'New Chat' : res.response);
}
} else {
await setChatTitle(_chatId, `${userPrompt}`);
}
};
const setChatTitle = async (_chatId, _title) => {
await $db.updateChatById(_chatId, { title: _title });
if (_chatId === $chatId) {
title = _title;
}
};
</script>
<svelte:window
on:scroll={(e) => {
autoScroll = window.innerHeight + window.scrollY >= document.body.offsetHeight - 40;
}}
/>
{#if loaded}
<Navbar {title} />
<div class="min-h-screen w-full flex justify-center">
<div class=" py-2.5 flex flex-col justify-between w-full">
<div class="max-w-2xl mx-auto w-full px-3 md:px-0 mt-10">
<ModelSelector bind:selectedModels disabled={messages.length > 0} />
</div>
<div class=" h-full mt-10 mb-32 w-full flex flex-col">
<Messages bind:history bind:messages bind:autoScroll {sendPrompt} {regenerateResponse} />
</div>
</div>
<MessageInput bind:prompt bind:autoScroll {messages} {submitPrompt} {stopResponse} />
</div>
{/if}

View file

@ -1,13 +1,76 @@
<script> <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 '../app.css';
import '../tailwind.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> </script>
<svelte:head> <svelte:head>
<title>Ollama</title> <title>Ollama</title>
</svelte:head> </svelte:head>
<slot />
<Toaster /> <Toaster />
{#if $config !== undefined && loaded}
<slot />
{/if}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,154 @@
<script>
import { WEBUI_API_BASE_URL } from '$lib/constants';
import { config, user } from '$lib/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import toast from 'svelte-french-toast';
let loaded = false;
let users = [];
const updateUserRole = async (id, role) => {
const res = await fetch(`${WEBUI_API_BASE_URL}/users/update/role`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.token}`
},
body: JSON.stringify({
id: id,
role: role
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((error) => {
console.log(error);
toast.error(error.detail);
return null;
});
if (res) {
await getUsers();
}
};
const getUsers = async () => {
const res = await fetch(`${WEBUI_API_BASE_URL}/users/`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((error) => {
console.log(error);
toast.error(error.detail);
return null;
});
users = res ? res : [];
};
onMount(async () => {
if ($config === null || !$config.auth || ($config.auth && $user && $user.role !== 'admin')) {
await goto('/');
} else {
await getUsers();
}
loaded = true;
});
</script>
<div
class=" bg-white dark:bg-gray-800 dark:text-gray-100 min-h-screen w-full flex justify-center font-mona"
>
{#if loaded}
<div class="w-full max-w-3xl px-10 md:px-16 min-h-screen flex flex-col">
<div class="py-10 w-full">
<div class=" flex flex-col justify-center">
<div class=" text-2xl font-semibold">Users ({users.length})</div>
<div class=" text-gray-500 text-xs font-medium mt-1">
Click on the user role cell in the table to change a user's role.
</div>
<hr class=" my-3 dark:border-gray-600" />
<div class="scrollbar-hidden relative overflow-x-auto whitespace-nowrap">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<thead
class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400"
>
<tr>
<th scope="col" class="px-6 py-3"> Name </th>
<th scope="col" class="px-6 py-3"> Email </th>
<th scope="col" class="px-6 py-3"> Role </th>
<!-- <th scope="col" class="px-6 py-3"> Action </th> -->
</tr>
</thead>
<tbody>
{#each users as user}
<tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700">
<th
scope="row"
class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white"
>
<div class="flex flex-row">
<img
class=" rounded-full max-w-[30px] max-h-[30px] object-cover mr-4"
src={user.profile_image_url}
/>
<div class=" font-semibold md:self-center">{user.name}</div>
</div>
</th>
<td class="px-6 py-4"> {user.email} </td>
<td class="px-6 py-4">
<button
class=" dark:text-white underline"
on:click={() => {
if (user.role === 'user') {
updateUserRole(user.id, 'admin');
} else if (user.role === 'pending') {
updateUserRole(user.id, 'user');
} else {
updateUserRole(user.id, 'pending');
}
}}>{user.role}</button
>
</td>
<!-- <td class="px-6 py-4 text-center">
<button class=" text-white underline"> Edit </button>
</td> -->
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
</div>
{/if}
</div>
<style>
.font-mona {
font-family: 'Mona Sans';
}
.scrollbar-hidden::-webkit-scrollbar {
display: none; /* for Chrome, Safari and Opera */
}
.scrollbar-hidden {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
</style>

View file

@ -0,0 +1,199 @@
<script>
import { goto } from '$app/navigation';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import { config, user } from '$lib/stores';
import { onMount } from 'svelte';
import toast from 'svelte-french-toast';
let loaded = false;
let mode = 'signin';
let name = '';
let email = '';
let password = '';
const signInHandler = async () => {
const res = await fetch(`${WEBUI_API_BASE_URL}/auths/signin`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: email,
password: password
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((error) => {
console.log(error);
toast.error(error.detail);
return null;
});
if (res) {
console.log(res);
toast.success(`You're now logged in.`);
localStorage.token = res.token;
await user.set(res);
goto('/');
}
};
const signUpHandler = async () => {
const res = await fetch(`${WEBUI_API_BASE_URL}/auths/signup`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: name,
email: email,
password: password
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((error) => {
console.log(error);
toast.error(error.detail);
return null;
});
if (res) {
console.log(res);
toast.success(`Account creation successful."`);
localStorage.token = res.token;
await user.set(res);
goto('/');
}
};
onMount(async () => {
if ($config === null || !$config.auth || ($config.auth && $user !== undefined)) {
await goto('/');
}
loaded = true;
});
</script>
{#if loaded && $config && $config.auth}
<div class="fixed m-10 z-50">
<div class="flex space-x-2">
<div class=" self-center">
<img src="/ollama.png" class=" w-8" />
</div>
</div>
</div>
<div class=" bg-white min-h-screen w-full flex justify-center font-mona">
<div class="hidden lg:flex lg:flex-1 px-10 md:px-16 w-full bg-yellow-50 justify-center">
<div class=" my-auto pb-16 text-left">
<div>
<div class=" font-bold text-yellow-600 text-4xl">
Get up and running with <br />large language models, locally.
</div>
<div class="mt-2 text-yellow-600 text-xl">
Run Llama 2, Code Llama, and other models. Customize and create your own.
</div>
</div>
</div>
</div>
<div class="w-full max-w-xl px-10 md:px-16 bg-white min-h-screen w-full flex flex-col">
<div class=" my-auto pb-10 w-full">
<form
class=" flex flex-col justify-center"
on:submit|preventDefault={() => {
if (mode === 'signin') {
signInHandler();
} else {
signUpHandler();
}
}}
>
<div class=" text-2xl md:text-3xl font-semibold">
{mode === 'signin' ? 'Sign in' : 'Sign up'} to Ollama Web UI
</div>
<hr class="my-8" />
<div class="flex flex-col space-y-4">
{#if mode === 'signup'}
<div>
<div class=" text-sm font-bold text-left mb-2">Name</div>
<input
bind:value={name}
type="text"
class=" border px-5 py-4 rounded-2xl w-full text-sm"
autocomplete="name"
required
/>
</div>
{/if}
<div>
<div class=" text-sm font-bold text-left mb-2">Email</div>
<input
bind:value={email}
type="email"
class=" border px-5 py-4 rounded-2xl w-full text-sm"
autocomplete="email"
required
/>
</div>
<div>
<div class=" text-sm font-bold text-left mb-2">Password</div>
<input
bind:value={password}
type="password"
class=" border px-5 py-4 rounded-2xl w-full text-sm"
autocomplete="current-password"
required
/>
</div>
</div>
<div class="mt-8">
<button
class=" bg-gray-900 hover:bg-gray-800 w-full rounded-full text-white font-semibold text-sm py-5 transition"
type="submit"
>
{mode === 'signin' ? 'Sign In' : 'Create Account'}
</button>
<div class=" mt-4 text-sm text-center">
{mode === 'signin' ? `Don't have an account?` : `Already have an account?`}
<button
class=" font-medium underline"
type="button"
on:click={() => {
if (mode === 'signin') {
mode = 'signup';
} else {
mode = 'signin';
}
}}
>
{mode === 'signin' ? `Sign up` : `Sign In`}
</button>
</div>
</div>
</form>
</div>
</div>
</div>
{/if}
<style>
.font-mona {
font-family: 'Mona Sans';
}
</style>

Binary file not shown.