feat: multi-user support w/ RBAC

This commit is contained in:
Timothy J. Baek 2023-11-18 16:47:12 -08:00
parent 31e38df0a5
commit 921eef03b3
21 changed files with 1815 additions and 66 deletions

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 import extract_token_from_auth_header
from config import OLLAMA_API_BASE_URL, OLLAMA_WEBUI_AUTH
app = Flask(__name__) app = Flask(__name__)
CORS( CORS(
@ -28,6 +31,21 @@ def proxy(path):
data = request.get_data() data = request.get_data()
headers = dict(request.headers) headers = dict(request.headers)
if OLLAMA_WEBUI_AUTH:
if "Authorization" in headers:
token = extract_token_from_auth_header(headers["Authorization"])
user = Users.get_user_by_token(token)
if user:
print(user)
pass
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,

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

@ -0,0 +1,25 @@
from fastapi import FastAPI, Request, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from apps.web.routers import auths
from config import OLLAMA_WEBUI_VERSION, OLLAMA_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.get("/")
async def get_status():
return {"status": True, "version": OLLAMA_WEBUI_VERSION, "auth": OLLAMA_WEBUI_AUTH}

View file

@ -0,0 +1,102 @@
from pydantic import BaseModel
from typing import List, Union, Optional
import time
import uuid
from apps.web.models.users import UserModel, Users
from 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
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 = "user"
) -> 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,76 @@
from pydantic import BaseModel
from typing import List, Union, Optional
from pymongo import ReturnDocument
import time
from utils import decode_token
from config import DB
####################
# User DB Schema
####################
class UserModel(BaseModel):
id: str
name: str
email: str
role: str = "user"
created_at: int # timestamp in epoch
####################
# Forms
####################
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 = "user"
) -> Optional[UserModel]:
user = UserModel(
**{
"id": id,
"name": name,
"email": email,
"role": role,
"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) -> Optional[UserModel]:
return [
UserModel(**user)
for user in list(self.table.find({}, {"_id": False}))
.skip(skip)
.limit(limit)
]
Users = UsersTable(DB)

View file

@ -0,0 +1,107 @@
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 constants import ERROR_MESSAGES
from utils import (
get_password_hash,
bearer_scheme,
create_token,
)
from apps.web.models.auths import (
SigninForm,
SignupForm,
UserResponse,
SigninResponse,
Auths,
)
from apps.web.models.users import Users
import config
router = APIRouter()
DB = config.DB
############################
# 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,
}
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
)
############################
# 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,
}
else:
raise HTTPException(400, detail=ERROR_MESSAGES.DEFAULT())
############################
# 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:
hashed = get_password_hash(form_data.password)
user = Auths.insert_new_auth(form_data.email, hashed, form_data.name)
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,
}
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

@ -1,11 +1,22 @@
import sys
import os
from dotenv import load_dotenv, find_dotenv from dotenv import load_dotenv, find_dotenv
from pymongo import MongoClient
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 +24,42 @@ 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"
####################################
# OLLAMA_WEBUI_VERSION
####################################
OLLAMA_WEBUI_VERSION = os.environ.get("OLLAMA_WEBUI_VERSION", "v1.0.0-alpha.9")
####################################
# OLLAMA_WEBUI_AUTH
####################################
OLLAMA_WEBUI_AUTH = (
True if os.environ.get("OLLAMA_WEBUI_AUTH", "TRUE") == "TRUE" else False
)
if OLLAMA_WEBUI_AUTH:
####################################
# OLLAMA_WEBUI_DB
####################################
OLLAMA_WEBUI_DB_URL = os.environ.get(
"OLLAMA_WEBUI_DB_URL", "mongodb://root:root@localhost:27017/"
)
DB_CLIENT = MongoClient(f"{OLLAMA_WEBUI_DB_URL}?authSource=admin")
DB = DB_CLIENT["ollama-webui"]
####################################
# OLLAMA_WEBUI_JWT_SECRET_KEY
####################################
OLLAMA_WEBUI_JWT_SECRET_KEY = os.environ.get(
"OLLAMA_WEBUI_JWT_SECRET_KEY", "t0p-s3cr3t"
)
if ENV == "prod":
if OLLAMA_WEBUI_JWT_SECRET_KEY == "":
OLLAMA_WEBUI_JWT_SECRET_KEY = str(b64encode(token_bytes(32)).decode())

13
backend/constants.py Normal file
View file

@ -0,0 +1,13 @@
from enum import Enum
class MESSAGES(str, Enum):
DEFAULT = lambda msg="": f"{msg if msg else ''}"
class ERROR_MESSAGES(str, Enum):
DEFAULT = lambda err="": f"Something went wrong :/\n{err if err else ''}"
UNAUTHORIZED = "401 Unauthorized"
NOT_FOUND = "We could not find what you're looking for :/"
USER_NOT_FOUND = "We could not find what you're looking for :/"
MALICIOUS = "Unusual activities detected, please try again in a few minutes."

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

68
backend/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.OLLAMA_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,15 @@ services:
restart: unless-stopped restart: unless-stopped
image: ollama/ollama:latest image: ollama/ollama:latest
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 +41,12 @@ services:
container_name: ollama-webui container_name: ollama-webui
depends_on: depends_on:
- ollama - ollama
- 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"
- "OLLAMA_WEBUI_DB_URL=mongodb://root:example@ollama-webui-db:27017/"
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

@ -2,9 +2,10 @@
import sha256 from 'js-sha256'; 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, user } from '$lib/stores';
export let show = false; export let show = false;
export let saveSettings: Function; export let saveSettings: Function;
@ -119,7 +120,8 @@
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',
...($user && { Authorization: `Bearer ${localStorage.token}` })
}, },
body: JSON.stringify({ body: JSON.stringify({
name: modelTag name: modelTag
@ -175,7 +177,8 @@
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',
...($user && { Authorization: `Bearer ${localStorage.token}` })
}, },
body: JSON.stringify({ body: JSON.stringify({
name: deleteModelTag name: deleteModelTag
@ -992,7 +995,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,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import { user } from '$lib/stores';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
let show = false; let show = false;
@ -22,9 +24,9 @@
let chatTitleEditIdx = null; let chatTitleEditIdx = null;
let chatTitle = ''; let chatTitle = '';
let _chats = chats.map((item, idx) => chats[chats.length - 1 - idx]); let showDropdown = false;
onMount(() => {}); let _chats = chats.map((item, idx) => chats[chats.length - 1 - idx]);
$: if (chats) { $: if (chats) {
_chats = chats.map((item, idx) => chats[chats.length - 1 - idx]); _chats = chats.map((item, idx) => chats[chats.length - 1 - idx]);
@ -98,6 +100,7 @@
<button <button
class="flex-grow flex justify-between rounded-md px-3 py-1.5 my-2 hover:bg-gray-900 transition" class="flex-grow flex justify-between rounded-md px-3 py-1.5 my-2 hover:bg-gray-900 transition"
on:click={() => { on:click={() => {
// goto('/');
createNewChat(); createNewChat();
}} }}
> >
@ -163,6 +166,7 @@
? 'bg-gray-900' ? 'bg-gray-900'
: ''} transition whitespace-nowrap text-ellipsis" : ''} transition whitespace-nowrap text-ellipsis"
on:click={() => { on:click={() => {
// goto(`/c/${chat.id}`);
if (chat.id !== chatTitleEditIdx) { if (chat.id !== chatTitleEditIdx) {
chatTitleEditIdx = null; chatTitleEditIdx = null;
chatTitle = ''; chatTitle = '';
@ -380,35 +384,127 @@
</div> </div>
<div class=" self-center">Clear conversations</div> <div class=" self-center">Clear conversations</div>
</button> </button>
<button
class=" flex rounded-md py-3 px-3.5 w-full hover:bg-gray-900 transition" {#if $user !== undefined}
on:click={() => { <button
openSettings(); class=" flex rounded-md py-3 px-3.5 w-full hover:bg-gray-900 transition"
}} on:focus={() => {
> showDropdown = true;
<div class=" self-center mr-3"> }}
<svg on:focusout={() => {
xmlns="http://www.w3.org/2000/svg" setTimeout(() => {
fill="none" showDropdown = false;
viewBox="0 0 24 24" }, 100);
stroke-width="1.5" }}
stroke="currentColor" >
class="w-5 h-5" <div class=" self-center mr-3">
<img src="/user.png" class=" max-w-[30px] object-cover rounded-full" />
</div>
<div class=" self-center font-semibold">{$user.name}</div>
</button>
{#if showDropdown}
<div
id="dropdownDots"
class="absolute z-10 bottom-[4.5rem] rounded-lg shadow w-[240px] bg-gray-900"
> >
<path <div class="py-2 w-full">
stroke-linecap="round" <button
stroke-linejoin="round" class="flex py-2.5 px-3.5 w-full hover:bg-gray-800 transition"
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" on:click={() => {
/> openSettings();
<path }}
stroke-linecap="round" >
stroke-linejoin="round" <div class=" self-center mr-3">
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" <svg
/> xmlns="http://www.w3.org/2000/svg"
</svg> fill="none"
</div> viewBox="0 0 24 24"
<div class=" self-center font-medium">Settings</div> stroke-width="1.5"
</button> 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=" dark: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 = '/';
}}
>
<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={() => {
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>
{/if}
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,14 +1,16 @@
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 ? browser
? `http://${location.hostname}:11434/api` ? `http://${location.hostname}:8080/ollama/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

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

@ -0,0 +1,4 @@
import { writable } from 'svelte/store';
export const config = writable(undefined);
export const user = writable(undefined);

View file

@ -0,0 +1,12 @@
<script>
import { config, user } from '$lib/stores';
import { goto } from '$app/navigation';
if ($config && $config.auth && $user === undefined) {
goto('/auth');
}
</script>
{#if $config !== undefined}
<slot />
{/if}

View file

@ -10,17 +10,17 @@
import 'katex/dist/katex.min.css'; import 'katex/dist/katex.min.css';
import toast from 'svelte-french-toast'; import toast from 'svelte-french-toast';
import { API_BASE_URL as BUILD_TIME_API_BASE_URL } from '$lib/constants'; import { OLLAMA_API_BASE_URL as BUILD_TIME_API_BASE_URL } from '$lib/constants';
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import Navbar from '$lib/components/layout/Navbar.svelte'; import Navbar from '$lib/components/layout/Navbar.svelte';
import SettingsModal from '$lib/components/chat/SettingsModal.svelte'; import SettingsModal from '$lib/components/chat/SettingsModal.svelte';
import Suggestions from '$lib/components/chat/Suggestions.svelte'; import Suggestions from '$lib/components/chat/Suggestions.svelte';
import { user } from '$lib/stores';
let API_BASE_URL = BUILD_TIME_API_BASE_URL; let API_BASE_URL = BUILD_TIME_API_BASE_URL;
let db; let db;
// let selectedModel = '';
let selectedModels = ['']; let selectedModels = [''];
let settings = { let settings = {
system: null, system: null,
@ -619,7 +619,8 @@
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(settings.authHeader && { Authorization: settings.authHeader }) ...(settings.authHeader && { Authorization: settings.authHeader }),
...($user && { Authorization: `Bearer ${localStorage.token}` })
} }
}) })
.then(async (res) => { .then(async (res) => {
@ -628,7 +629,11 @@
}) })
.catch((error) => { .catch((error) => {
console.log(error); console.log(error);
toast.error('Server connection failed'); if ('detail' in error) {
toast.error(error.detail);
} else {
toast.error('Server connection failed');
}
return null; return null;
}); });
@ -687,13 +692,6 @@
} }
}) })
); );
// if (selectedModel.includes('gpt-')) {
// await sendPromptOpenAI(userPrompt, parentId);
// } else {
// await sendPromptOllama(userPrompt, parentId);
// }
console.log(history); console.log(history);
}; };
@ -724,7 +722,8 @@
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
...(settings.authHeader && { Authorization: settings.authHeader }) ...(settings.authHeader && { Authorization: settings.authHeader }),
...($user && { Authorization: `Bearer ${localStorage.token}` })
}, },
body: JSON.stringify({ body: JSON.stringify({
model: model, model: model,
@ -779,6 +778,8 @@
responseMessage.content += data.response; responseMessage.content += data.response;
messages = messages; messages = messages;
} }
} else if ('detail' in data) {
throw data;
} else { } else {
responseMessage.done = true; responseMessage.done = true;
responseMessage.context = data.context; responseMessage.context = data.context;
@ -791,6 +792,10 @@
} }
} catch (error) { } catch (error) {
console.log(error); console.log(error);
if ('detail' in error) {
toast.error(error.detail);
}
break;
} }
if (autoScroll) { if (autoScroll) {
@ -817,7 +822,7 @@
window.scrollTo({ top: document.body.scrollHeight }); window.scrollTo({ top: document.body.scrollHeight });
} }
if (messages.length == 2) { if (messages.length == 2 && messages.at(1).content !== '') {
await generateChatTitle(chatId, userPrompt); await generateChatTitle(chatId, userPrompt);
} }
}; };
@ -1034,7 +1039,8 @@
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
...(settings.authHeader && { Authorization: settings.authHeader }) ...(settings.authHeader && { Authorization: settings.authHeader }),
...($user && { Authorization: `Bearer ${localStorage.token}` })
}, },
body: JSON.stringify({ body: JSON.stringify({
model: selectedModels[0], model: selectedModels[0],
@ -1047,6 +1053,9 @@
return res.json(); return res.json();
}) })
.catch((error) => { .catch((error) => {
if ('detail' in error) {
toast.error(error.detail);
}
console.log(error); console.log(error);
return null; return null;
}); });

View file

View file

@ -1,13 +1,71 @@
<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 webBackendStatus = 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(webBackendStatus);
await config.set(webBackendStatus);
if (webBackendStatus) {
if (webBackendStatus.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;
});
await user.set(res);
} else {
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}

1091
src/routes/auth/+page.svelte Normal file

File diff suppressed because it is too large Load diff

Binary file not shown.