Unverified Commit 89754727 authored by Timothy Jaeryang Baek's avatar Timothy Jaeryang Baek Committed by GitHub
Browse files

Merge pull request #123 from ollama-webui/dev

feat: ui fix/improvements/refac
parents 6a983c26 d36364f8
...@@ -22,6 +22,9 @@ ARG OLLAMA_API_BASE_URL='/ollama/api' ...@@ -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
......
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 ...@@ -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 ...@@ -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,
......
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}
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)
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)
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())
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,
)
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( ...@@ -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)
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."
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): ...@@ -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")
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"
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
...@@ -22,6 +22,17 @@ services: ...@@ -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: ...@@ -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
......
...@@ -4,8 +4,13 @@ ...@@ -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;
} }
......
<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>
This diff is collapsed.
<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>
<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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -216,7 +214,7 @@
} }
deleteModelTag = ''; deleteModelTag = '';
await getModelTags(); models.set(await getModels());
}; };
$: if (show) { $: if (show) {
...@@ -234,11 +232,76 @@ ...@@ -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,31 +441,33 @@ ...@@ -378,31 +441,33 @@
<div class=" self-center">Add-ons</div> <div class=" self-center">Add-ons</div>
</button> </button>
<button {#if !$config || ($config && !$config.auth)}
class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab === <button
'auth' class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
? 'bg-gray-200 dark:bg-gray-700' 'auth'
: ' hover:bg-gray-300 dark:hover:bg-gray-800'}" ? 'bg-gray-200 dark:bg-gray-700'
on:click={() => { : ' hover:bg-gray-300 dark:hover:bg-gray-800'}"
selectedTab = 'auth'; on:click={() => {
}} selectedTab = 'auth';
> }}
<div class=" self-center mr-2"> >
<svg <div class=" self-center mr-2">
xmlns="http://www.w3.org/2000/svg" <svg
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
fill="currentColor" viewBox="0 0 24 24"
class="w-4 h-4" fill="currentColor"
> class="w-4 h-4"
<path >
fill-rule="evenodd" <path
d="M12.516 2.17a.75.75 0 00-1.032 0 11.209 11.209 0 01-7.877 3.08.75.75 0 00-.722.515A12.74 12.74 0 002.25 9.75c0 5.942 4.064 10.933 9.563 12.348a.749.749 0 00.374 0c5.499-1.415 9.563-6.406 9.563-12.348 0-1.39-.223-2.73-.635-3.985a.75.75 0 00-.722-.516l-.143.001c-2.996 0-5.717-1.17-7.734-3.08zm3.094 8.016a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z" fill-rule="evenodd"
clip-rule="evenodd" d="M12.516 2.17a.75.75 0 00-1.032 0 11.209 11.209 0 01-7.877 3.08.75.75 0 00-.722.515A12.74 12.74 0 002.25 9.75c0 5.942 4.064 10.933 9.563 12.348a.749.749 0 00.374 0c5.499-1.415 9.563-6.406 9.563-12.348 0-1.39-.223-2.73-.635-3.985a.75.75 0 00-.722-.516l-.143.001c-2.996 0-5.717-1.17-7.734-3.08zm3.094 8.016a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z"
/> clip-rule="evenodd"
</svg> />
</div> </svg>
<div class=" self-center">Authentication</div> </div>
</button> <div class=" self-center">Authentication</div>
</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 @@ ...@@ -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 @@ ...@@ -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>
......
<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 @@ ...@@ -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 @@ ...@@ -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>
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment