Merge remote-tracking branch 'upstream/dev' into feat/add-i18n

This commit is contained in:
Ased Mammad 2024-03-06 01:25:29 +03:30
commit 25e0f0de42
26 changed files with 1445 additions and 668 deletions

View file

@ -32,7 +32,7 @@ assignees: ''
**Confirmation:** **Confirmation:**
- [ ] I have read and followed all the instructions provided in the README.md. - [ ] I have read and followed all the instructions provided in the README.md.
- [ ] I have reviewed the troubleshooting.md document. - [ ] I am on the latest version of both Open WebUI and Ollama.
- [ ] I have included the browser console logs. - [ ] I have included the browser console logs.
- [ ] I have included the Docker container logs. - [ ] I have included the Docker container logs.

View file

@ -3,16 +3,23 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from fastapi.concurrency import run_in_threadpool from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel, ConfigDict
import random
import requests import requests
import json import json
import uuid import uuid
from pydantic import BaseModel import aiohttp
import asyncio
from apps.web.models.users import Users from apps.web.models.users import Users
from constants import ERROR_MESSAGES from constants import ERROR_MESSAGES
from utils.utils import decode_token, get_current_user, get_admin_user from utils.utils import decode_token, get_current_user, get_admin_user
from config import OLLAMA_BASE_URL, WEBUI_AUTH from config import OLLAMA_BASE_URL, WEBUI_AUTH
from typing import Optional, List, Union
app = FastAPI() app = FastAPI()
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@ -23,26 +30,44 @@ app.add_middleware(
) )
app.state.OLLAMA_BASE_URL = OLLAMA_BASE_URL app.state.OLLAMA_BASE_URL = OLLAMA_BASE_URL
app.state.OLLAMA_BASE_URLS = [OLLAMA_BASE_URL]
# TARGET_SERVER_URL = OLLAMA_API_BASE_URL app.state.MODELS = {}
REQUEST_POOL = [] REQUEST_POOL = []
@app.get("/url") # TODO: Implement a more intelligent load balancing mechanism for distributing requests among multiple backend instances.
async def get_ollama_api_url(user=Depends(get_admin_user)): # Current implementation uses a simple round-robin approach (random.choice). Consider incorporating algorithms like weighted round-robin,
return {"OLLAMA_BASE_URL": app.state.OLLAMA_BASE_URL} # least connections, or least response time for better resource utilization and performance optimization.
@app.middleware("http")
async def check_url(request: Request, call_next):
if len(app.state.MODELS) == 0:
await get_all_models()
else:
pass
response = await call_next(request)
return response
@app.get("/urls")
async def get_ollama_api_urls(user=Depends(get_admin_user)):
return {"OLLAMA_BASE_URLS": app.state.OLLAMA_BASE_URLS}
class UrlUpdateForm(BaseModel): class UrlUpdateForm(BaseModel):
url: str urls: List[str]
@app.post("/url/update") @app.post("/urls/update")
async def update_ollama_api_url(form_data: UrlUpdateForm, user=Depends(get_admin_user)): async def update_ollama_api_url(form_data: UrlUpdateForm, user=Depends(get_admin_user)):
app.state.OLLAMA_BASE_URL = form_data.url app.state.OLLAMA_BASE_URLS = form_data.urls
return {"OLLAMA_BASE_URL": app.state.OLLAMA_BASE_URL}
print(app.state.OLLAMA_BASE_URLS)
return {"OLLAMA_BASE_URLS": app.state.OLLAMA_BASE_URLS}
@app.get("/cancel/{request_id}") @app.get("/cancel/{request_id}")
@ -55,9 +80,806 @@ async def cancel_ollama_request(request_id: str, user=Depends(get_current_user))
raise HTTPException(status_code=401, detail=ERROR_MESSAGES.ACCESS_PROHIBITED) raise HTTPException(status_code=401, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
async def fetch_url(url):
try:
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
return await response.json()
except Exception as e:
# Handle connection error here
print(f"Connection error: {e}")
return None
def merge_models_lists(model_lists):
merged_models = {}
for idx, model_list in enumerate(model_lists):
for model in model_list:
digest = model["digest"]
if digest not in merged_models:
model["urls"] = [idx]
merged_models[digest] = model
else:
merged_models[digest]["urls"].append(idx)
return list(merged_models.values())
# user=Depends(get_current_user)
async def get_all_models():
print("get_all_models")
tasks = [fetch_url(f"{url}/api/tags") for url in app.state.OLLAMA_BASE_URLS]
responses = await asyncio.gather(*tasks)
responses = list(filter(lambda x: x is not None, responses))
models = {
"models": merge_models_lists(
map(lambda response: response["models"], responses)
)
}
app.state.MODELS = {model["model"]: model for model in models["models"]}
return models
@app.get("/api/tags")
@app.get("/api/tags/{url_idx}")
async def get_ollama_tags(
url_idx: Optional[int] = None, user=Depends(get_current_user)
):
if url_idx == None:
return await get_all_models()
else:
url = app.state.OLLAMA_BASE_URLS[url_idx]
try:
r = requests.request(method="GET", url=f"{url}/api/tags")
r.raise_for_status()
return r.json()
except Exception as e:
print(e)
error_detail = "Open WebUI: Server Connection Error"
if r is not None:
try:
res = r.json()
if "error" in res:
error_detail = f"Ollama: {res['error']}"
except:
error_detail = f"Ollama: {e}"
raise HTTPException(
status_code=r.status_code if r else 500,
detail=error_detail,
)
@app.get("/api/version")
@app.get("/api/version/{url_idx}")
async def get_ollama_versions(url_idx: Optional[int] = None):
if url_idx == None:
# returns lowest version
tasks = [fetch_url(f"{url}/api/version") for url in app.state.OLLAMA_BASE_URLS]
responses = await asyncio.gather(*tasks)
responses = list(filter(lambda x: x is not None, responses))
lowest_version = min(
responses, key=lambda x: tuple(map(int, x["version"].split(".")))
)
return {"version": lowest_version["version"]}
else:
url = app.state.OLLAMA_BASE_URLS[url_idx]
try:
r = requests.request(method="GET", url=f"{url}/api/version")
r.raise_for_status()
return r.json()
except Exception as e:
print(e)
error_detail = "Open WebUI: Server Connection Error"
if r is not None:
try:
res = r.json()
if "error" in res:
error_detail = f"Ollama: {res['error']}"
except:
error_detail = f"Ollama: {e}"
raise HTTPException(
status_code=r.status_code if r else 500,
detail=error_detail,
)
class ModelNameForm(BaseModel):
name: str
@app.post("/api/pull")
@app.post("/api/pull/{url_idx}")
async def pull_model(
form_data: ModelNameForm, url_idx: int = 0, user=Depends(get_admin_user)
):
url = app.state.OLLAMA_BASE_URLS[url_idx]
print(url)
r = None
def get_request():
nonlocal url
nonlocal r
try:
def stream_content():
for chunk in r.iter_content(chunk_size=8192):
yield chunk
r = requests.request(
method="POST",
url=f"{url}/api/pull",
data=form_data.model_dump_json(exclude_none=True),
stream=True,
)
r.raise_for_status()
return StreamingResponse(
stream_content(),
status_code=r.status_code,
headers=dict(r.headers),
)
except Exception as e:
raise e
try:
return await run_in_threadpool(get_request)
except Exception as e:
print(e)
error_detail = "Open WebUI: Server Connection Error"
if r is not None:
try:
res = r.json()
if "error" in res:
error_detail = f"Ollama: {res['error']}"
except:
error_detail = f"Ollama: {e}"
raise HTTPException(
status_code=r.status_code if r else 500,
detail=error_detail,
)
class PushModelForm(BaseModel):
name: str
insecure: Optional[bool] = None
stream: Optional[bool] = None
@app.delete("/api/push")
@app.delete("/api/push/{url_idx}")
async def push_model(
form_data: PushModelForm,
url_idx: Optional[int] = None,
user=Depends(get_admin_user),
):
if url_idx == None:
if form_data.name in app.state.MODELS:
url_idx = app.state.MODELS[form_data.name]["urls"][0]
else:
raise HTTPException(
status_code=400,
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.name),
)
url = app.state.OLLAMA_BASE_URLS[url_idx]
print(url)
r = None
def get_request():
nonlocal url
nonlocal r
try:
def stream_content():
for chunk in r.iter_content(chunk_size=8192):
yield chunk
r = requests.request(
method="POST",
url=f"{url}/api/push",
data=form_data.model_dump_json(exclude_none=True),
)
r.raise_for_status()
return StreamingResponse(
stream_content(),
status_code=r.status_code,
headers=dict(r.headers),
)
except Exception as e:
raise e
try:
return await run_in_threadpool(get_request)
except Exception as e:
print(e)
error_detail = "Open WebUI: Server Connection Error"
if r is not None:
try:
res = r.json()
if "error" in res:
error_detail = f"Ollama: {res['error']}"
except:
error_detail = f"Ollama: {e}"
raise HTTPException(
status_code=r.status_code if r else 500,
detail=error_detail,
)
class CreateModelForm(BaseModel):
name: str
modelfile: Optional[str] = None
stream: Optional[bool] = None
path: Optional[str] = None
@app.post("/api/create")
@app.post("/api/create/{url_idx}")
async def create_model(
form_data: CreateModelForm, url_idx: int = 0, user=Depends(get_admin_user)
):
print(form_data)
url = app.state.OLLAMA_BASE_URLS[url_idx]
print(url)
r = None
def get_request():
nonlocal url
nonlocal r
try:
def stream_content():
for chunk in r.iter_content(chunk_size=8192):
yield chunk
r = requests.request(
method="POST",
url=f"{url}/api/create",
data=form_data.model_dump_json(exclude_none=True),
stream=True,
)
r.raise_for_status()
print(r)
return StreamingResponse(
stream_content(),
status_code=r.status_code,
headers=dict(r.headers),
)
except Exception as e:
raise e
try:
return await run_in_threadpool(get_request)
except Exception as e:
print(e)
error_detail = "Open WebUI: Server Connection Error"
if r is not None:
try:
res = r.json()
if "error" in res:
error_detail = f"Ollama: {res['error']}"
except:
error_detail = f"Ollama: {e}"
raise HTTPException(
status_code=r.status_code if r else 500,
detail=error_detail,
)
class CopyModelForm(BaseModel):
source: str
destination: str
@app.post("/api/copy")
@app.post("/api/copy/{url_idx}")
async def copy_model(
form_data: CopyModelForm,
url_idx: Optional[int] = None,
user=Depends(get_admin_user),
):
if url_idx == None:
if form_data.source in app.state.MODELS:
url_idx = app.state.MODELS[form_data.source]["urls"][0]
else:
raise HTTPException(
status_code=400,
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.source),
)
url = app.state.OLLAMA_BASE_URLS[url_idx]
print(url)
try:
r = requests.request(
method="POST",
url=f"{url}/api/copy",
data=form_data.model_dump_json(exclude_none=True),
)
r.raise_for_status()
print(r.text)
return True
except Exception as e:
print(e)
error_detail = "Open WebUI: Server Connection Error"
if r is not None:
try:
res = r.json()
if "error" in res:
error_detail = f"Ollama: {res['error']}"
except:
error_detail = f"Ollama: {e}"
raise HTTPException(
status_code=r.status_code if r else 500,
detail=error_detail,
)
@app.delete("/api/delete")
@app.delete("/api/delete/{url_idx}")
async def delete_model(
form_data: ModelNameForm,
url_idx: Optional[int] = None,
user=Depends(get_admin_user),
):
if url_idx == None:
if form_data.name in app.state.MODELS:
url_idx = app.state.MODELS[form_data.name]["urls"][0]
else:
raise HTTPException(
status_code=400,
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.name),
)
url = app.state.OLLAMA_BASE_URLS[url_idx]
print(url)
try:
r = requests.request(
method="DELETE",
url=f"{url}/api/delete",
data=form_data.model_dump_json(exclude_none=True),
)
r.raise_for_status()
print(r.text)
return True
except Exception as e:
print(e)
error_detail = "Open WebUI: Server Connection Error"
if r is not None:
try:
res = r.json()
if "error" in res:
error_detail = f"Ollama: {res['error']}"
except:
error_detail = f"Ollama: {e}"
raise HTTPException(
status_code=r.status_code if r else 500,
detail=error_detail,
)
@app.post("/api/show")
async def show_model_info(form_data: ModelNameForm, user=Depends(get_current_user)):
if form_data.name not in app.state.MODELS:
raise HTTPException(
status_code=400,
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.name),
)
url_idx = random.choice(app.state.MODELS[form_data.name]["urls"])
url = app.state.OLLAMA_BASE_URLS[url_idx]
print(url)
try:
r = requests.request(
method="POST",
url=f"{url}/api/show",
data=form_data.model_dump_json(exclude_none=True),
)
r.raise_for_status()
return r.json()
except Exception as e:
print(e)
error_detail = "Open WebUI: Server Connection Error"
if r is not None:
try:
res = r.json()
if "error" in res:
error_detail = f"Ollama: {res['error']}"
except:
error_detail = f"Ollama: {e}"
raise HTTPException(
status_code=r.status_code if r else 500,
detail=error_detail,
)
class GenerateEmbeddingsForm(BaseModel):
model: str
prompt: str
options: Optional[dict] = None
keep_alive: Optional[Union[int, str]] = None
@app.post("/api/embeddings")
@app.post("/api/embeddings/{url_idx}")
async def generate_embeddings(
form_data: GenerateEmbeddingsForm,
url_idx: Optional[int] = None,
user=Depends(get_current_user),
):
if url_idx == None:
if form_data.model in app.state.MODELS:
url_idx = random.choice(app.state.MODELS[form_data.model]["urls"])
else:
raise HTTPException(
status_code=400,
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model),
)
url = app.state.OLLAMA_BASE_URLS[url_idx]
print(url)
try:
r = requests.request(
method="POST",
url=f"{url}/api/embeddings",
data=form_data.model_dump_json(exclude_none=True),
)
r.raise_for_status()
return r.json()
except Exception as e:
print(e)
error_detail = "Open WebUI: Server Connection Error"
if r is not None:
try:
res = r.json()
if "error" in res:
error_detail = f"Ollama: {res['error']}"
except:
error_detail = f"Ollama: {e}"
raise HTTPException(
status_code=r.status_code if r else 500,
detail=error_detail,
)
class GenerateCompletionForm(BaseModel):
model: str
prompt: str
images: Optional[List[str]] = None
format: Optional[str] = None
options: Optional[dict] = None
system: Optional[str] = None
template: Optional[str] = None
context: Optional[str] = None
stream: Optional[bool] = True
raw: Optional[bool] = None
keep_alive: Optional[Union[int, str]] = None
@app.post("/api/generate")
@app.post("/api/generate/{url_idx}")
async def generate_completion(
form_data: GenerateCompletionForm,
url_idx: Optional[int] = None,
user=Depends(get_current_user),
):
if url_idx == None:
if form_data.model in app.state.MODELS:
url_idx = random.choice(app.state.MODELS[form_data.model]["urls"])
else:
raise HTTPException(
status_code=400,
detail="error_detail",
)
url = app.state.OLLAMA_BASE_URLS[url_idx]
print(url)
r = None
def get_request():
nonlocal form_data
nonlocal r
request_id = str(uuid.uuid4())
try:
REQUEST_POOL.append(request_id)
def stream_content():
try:
if form_data.stream:
yield json.dumps({"id": request_id, "done": False}) + "\n"
for chunk in r.iter_content(chunk_size=8192):
if request_id in REQUEST_POOL:
yield chunk
else:
print("User: canceled request")
break
finally:
if hasattr(r, "close"):
r.close()
if request_id in REQUEST_POOL:
REQUEST_POOL.remove(request_id)
r = requests.request(
method="POST",
url=f"{url}/api/generate",
data=form_data.model_dump_json(exclude_none=True),
stream=True,
)
r.raise_for_status()
return StreamingResponse(
stream_content(),
status_code=r.status_code,
headers=dict(r.headers),
)
except Exception as e:
raise e
try:
return await run_in_threadpool(get_request)
except Exception as e:
error_detail = "Open WebUI: Server Connection Error"
if r is not None:
try:
res = r.json()
if "error" in res:
error_detail = f"Ollama: {res['error']}"
except:
error_detail = f"Ollama: {e}"
raise HTTPException(
status_code=r.status_code if r else 500,
detail=error_detail,
)
class ChatMessage(BaseModel):
role: str
content: str
images: Optional[List[str]] = None
class GenerateChatCompletionForm(BaseModel):
model: str
messages: List[ChatMessage]
format: Optional[str] = None
options: Optional[dict] = None
template: Optional[str] = None
stream: Optional[bool] = True
keep_alive: Optional[Union[int, str]] = None
@app.post("/api/chat")
@app.post("/api/chat/{url_idx}")
async def generate_chat_completion(
form_data: GenerateChatCompletionForm,
url_idx: Optional[int] = None,
user=Depends(get_current_user),
):
if url_idx == None:
if form_data.model in app.state.MODELS:
url_idx = random.choice(app.state.MODELS[form_data.model]["urls"])
else:
raise HTTPException(
status_code=400,
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model),
)
url = app.state.OLLAMA_BASE_URLS[url_idx]
print(url)
r = None
print(form_data.model_dump_json(exclude_none=True))
def get_request():
nonlocal form_data
nonlocal r
request_id = str(uuid.uuid4())
try:
REQUEST_POOL.append(request_id)
def stream_content():
try:
if form_data.stream:
yield json.dumps({"id": request_id, "done": False}) + "\n"
for chunk in r.iter_content(chunk_size=8192):
if request_id in REQUEST_POOL:
yield chunk
else:
print("User: canceled request")
break
finally:
if hasattr(r, "close"):
r.close()
if request_id in REQUEST_POOL:
REQUEST_POOL.remove(request_id)
r = requests.request(
method="POST",
url=f"{url}/api/chat",
data=form_data.model_dump_json(exclude_none=True),
stream=True,
)
r.raise_for_status()
return StreamingResponse(
stream_content(),
status_code=r.status_code,
headers=dict(r.headers),
)
except Exception as e:
raise e
try:
return await run_in_threadpool(get_request)
except Exception as e:
error_detail = "Open WebUI: Server Connection Error"
if r is not None:
try:
res = r.json()
if "error" in res:
error_detail = f"Ollama: {res['error']}"
except:
error_detail = f"Ollama: {e}"
raise HTTPException(
status_code=r.status_code if r else 500,
detail=error_detail,
)
# TODO: we should update this part once Ollama supports other types
class OpenAIChatMessage(BaseModel):
role: str
content: str
model_config = ConfigDict(extra="allow")
class OpenAIChatCompletionForm(BaseModel):
model: str
messages: List[OpenAIChatMessage]
model_config = ConfigDict(extra="allow")
@app.post("/v1/chat/completions")
@app.post("/v1/chat/completions/{url_idx}")
async def generate_openai_chat_completion(
form_data: OpenAIChatCompletionForm,
url_idx: Optional[int] = None,
user=Depends(get_current_user),
):
if url_idx == None:
if form_data.model in app.state.MODELS:
url_idx = random.choice(app.state.MODELS[form_data.model]["urls"])
else:
raise HTTPException(
status_code=400,
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model),
)
url = app.state.OLLAMA_BASE_URLS[url_idx]
print(url)
r = None
def get_request():
nonlocal form_data
nonlocal r
request_id = str(uuid.uuid4())
try:
REQUEST_POOL.append(request_id)
def stream_content():
try:
if form_data.stream:
yield json.dumps(
{"request_id": request_id, "done": False}
) + "\n"
for chunk in r.iter_content(chunk_size=8192):
if request_id in REQUEST_POOL:
yield chunk
else:
print("User: canceled request")
break
finally:
if hasattr(r, "close"):
r.close()
if request_id in REQUEST_POOL:
REQUEST_POOL.remove(request_id)
r = requests.request(
method="POST",
url=f"{url}/v1/chat/completions",
data=form_data.model_dump_json(exclude_none=True),
stream=True,
)
r.raise_for_status()
return StreamingResponse(
stream_content(),
status_code=r.status_code,
headers=dict(r.headers),
)
except Exception as e:
raise e
try:
return await run_in_threadpool(get_request)
except Exception as e:
error_detail = "Open WebUI: Server Connection Error"
if r is not None:
try:
res = r.json()
if "error" in res:
error_detail = f"Ollama: {res['error']}"
except:
error_detail = f"Ollama: {e}"
raise HTTPException(
status_code=r.status_code if r else 500,
detail=error_detail,
)
@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"]) @app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"])
async def proxy(path: str, request: Request, user=Depends(get_current_user)): async def deprecated_proxy(path: str, request: Request, user=Depends(get_current_user)):
target_url = f"{app.state.OLLAMA_BASE_URL}/{path}" url = app.state.OLLAMA_BASE_URLS[0]
target_url = f"{url}/{path}"
body = await request.body() body = await request.body()
headers = dict(request.headers) headers = dict(request.headers)

View file

@ -1,127 +0,0 @@
from fastapi import FastAPI, Request, Response, HTTPException, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
import requests
import json
from pydantic import BaseModel
from apps.web.models.users import Users
from constants import ERROR_MESSAGES
from utils.utils import decode_token, get_current_user
from config import OLLAMA_API_BASE_URL, WEBUI_AUTH
import aiohttp
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.state.OLLAMA_API_BASE_URL = OLLAMA_API_BASE_URL
# TARGET_SERVER_URL = OLLAMA_API_BASE_URL
@app.get("/url")
async def get_ollama_api_url(user=Depends(get_current_user)):
if user and user.role == "admin":
return {"OLLAMA_API_BASE_URL": app.state.OLLAMA_API_BASE_URL}
else:
raise HTTPException(status_code=401, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
class UrlUpdateForm(BaseModel):
url: str
@app.post("/url/update")
async def update_ollama_api_url(
form_data: UrlUpdateForm, user=Depends(get_current_user)
):
if user and user.role == "admin":
app.state.OLLAMA_API_BASE_URL = form_data.url
return {"OLLAMA_API_BASE_URL": app.state.OLLAMA_API_BASE_URL}
else:
raise HTTPException(status_code=401, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
# async def fetch_sse(method, target_url, body, headers):
# async with aiohttp.ClientSession() as session:
# try:
# async with session.request(
# method, target_url, data=body, headers=headers
# ) as response:
# print(response.status)
# async for line in response.content:
# yield line
# except Exception as e:
# print(e)
# error_detail = "Open WebUI: Server Connection Error"
# yield json.dumps({"error": error_detail, "message": str(e)}).encode()
@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"])
async def proxy(path: str, request: Request, user=Depends(get_current_user)):
target_url = f"{app.state.OLLAMA_API_BASE_URL}/{path}"
print(target_url)
body = await request.body()
headers = dict(request.headers)
if user.role in ["user", "admin"]:
if path in ["pull", "delete", "push", "copy", "create"]:
if user.role != "admin":
raise HTTPException(
status_code=401, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
)
else:
raise HTTPException(status_code=401, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
headers.pop("Host", None)
headers.pop("Authorization", None)
headers.pop("Origin", None)
headers.pop("Referer", None)
session = aiohttp.ClientSession()
response = None
try:
response = await session.request(
request.method, target_url, data=body, headers=headers
)
print(response)
if not response.ok:
data = await response.json()
print(data)
response.raise_for_status()
async def generate():
async for line in response.content:
print(line)
yield line
await session.close()
return StreamingResponse(generate(), response.status)
except Exception as e:
print(e)
error_detail = "Open WebUI: Server Connection Error"
if response is not None:
try:
res = await response.json()
if "error" in res:
error_detail = f"Ollama: {res['error']}"
except:
error_detail = f"Ollama: {e}"
await session.close()
raise HTTPException(
status_code=response.status if response else 500,
detail=error_detail,
)

View file

@ -108,7 +108,7 @@ class StoreWebForm(CollectionNameForm):
url: str url: str
def store_data_in_vector_db(data, collection_name) -> bool: def store_data_in_vector_db(data, collection_name, overwrite: bool = False) -> bool:
text_splitter = RecursiveCharacterTextSplitter( text_splitter = RecursiveCharacterTextSplitter(
chunk_size=app.state.CHUNK_SIZE, chunk_overlap=app.state.CHUNK_OVERLAP chunk_size=app.state.CHUNK_SIZE, chunk_overlap=app.state.CHUNK_OVERLAP
) )
@ -118,6 +118,12 @@ def store_data_in_vector_db(data, collection_name) -> bool:
metadatas = [doc.metadata for doc in docs] metadatas = [doc.metadata for doc in docs]
try: try:
if overwrite:
for collection in CHROMA_CLIENT.list_collections():
if collection_name == collection.name:
print(f"deleting existing collection {collection_name}")
CHROMA_CLIENT.delete_collection(name=collection_name)
collection = CHROMA_CLIENT.create_collection( collection = CHROMA_CLIENT.create_collection(
name=collection_name, name=collection_name,
embedding_function=app.state.sentence_transformer_ef, embedding_function=app.state.sentence_transformer_ef,
@ -355,7 +361,7 @@ def store_web(form_data: StoreWebForm, user=Depends(get_current_user)):
if collection_name == "": if collection_name == "":
collection_name = calculate_sha256_string(form_data.url)[:63] collection_name = calculate_sha256_string(form_data.url)[:63]
store_data_in_vector_db(data, collection_name) store_data_in_vector_db(data, collection_name, overwrite=True)
return { return {
"status": True, "status": True,
"collection_name": collection_name, "collection_name": collection_name,

View file

@ -48,3 +48,5 @@ class ERROR_MESSAGES(str, Enum):
lambda err="": f"Invalid format. Please use the correct format{err if err else ''}" lambda err="": f"Invalid format. Please use the correct format{err if err else ''}"
) )
RATE_LIMIT_EXCEEDED = "API rate limit exceeded" RATE_LIMIT_EXCEEDED = "API rate limit exceeded"
MODEL_NOT_FOUND = lambda name="": f"Model '{name}' was not found"

View file

@ -104,7 +104,7 @@ async def auth_middleware(request: Request, call_next):
app.mount("/api/v1", webui_app) app.mount("/api/v1", webui_app)
app.mount("/litellm/api", litellm_app) app.mount("/litellm/api", litellm_app)
app.mount("/ollama/api", ollama_app) app.mount("/ollama", ollama_app)
app.mount("/openai/api", openai_app) app.mount("/openai/api", openai_app)
app.mount("/images/api/v1", images_app) app.mount("/images/api/v1", images_app)
@ -125,6 +125,14 @@ async def get_app_config():
} }
@app.get("/api/version")
async def get_app_config():
return {
"version": VERSION,
}
@app.get("/api/changelog") @app.get("/api/changelog")
async def get_app_changelog(): async def get_app_changelog():
return CHANGELOG return CHANGELOG

View file

@ -22,6 +22,7 @@ google-generativeai
langchain langchain
langchain-community langchain-community
fake_useragent
chromadb chromadb
sentence_transformers sentence_transformers
pypdf pypdf

View file

@ -1,9 +1,9 @@
import { OLLAMA_API_BASE_URL } from '$lib/constants'; import { OLLAMA_API_BASE_URL } from '$lib/constants';
export const getOllamaAPIUrl = async (token: string = '') => { export const getOllamaUrls = async (token: string = '') => {
let error = null; let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/url`, { const res = await fetch(`${OLLAMA_API_BASE_URL}/urls`, {
method: 'GET', method: 'GET',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
@ -29,13 +29,13 @@ export const getOllamaAPIUrl = async (token: string = '') => {
throw error; throw error;
} }
return res.OLLAMA_BASE_URL; return res.OLLAMA_BASE_URLS;
}; };
export const updateOllamaAPIUrl = async (token: string = '', url: string) => { export const updateOllamaUrls = async (token: string = '', urls: string[]) => {
let error = null; let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/url/update`, { const res = await fetch(`${OLLAMA_API_BASE_URL}/urls/update`, {
method: 'POST', method: 'POST',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
@ -43,7 +43,7 @@ export const updateOllamaAPIUrl = async (token: string = '', url: string) => {
...(token && { authorization: `Bearer ${token}` }) ...(token && { authorization: `Bearer ${token}` })
}, },
body: JSON.stringify({ body: JSON.stringify({
url: url urls: urls
}) })
}) })
.then(async (res) => { .then(async (res) => {
@ -64,7 +64,7 @@ export const updateOllamaAPIUrl = async (token: string = '', url: string) => {
throw error; throw error;
} }
return res.OLLAMA_BASE_URL; return res.OLLAMA_BASE_URLS;
}; };
export const getOllamaVersion = async (token: string = '') => { export const getOllamaVersion = async (token: string = '') => {
@ -151,7 +151,8 @@ export const generateTitle = async (
const res = await fetch(`${OLLAMA_API_BASE_URL}/api/generate`, { const res = await fetch(`${OLLAMA_API_BASE_URL}/api/generate`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'text/event-stream', Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
}, },
body: JSON.stringify({ body: JSON.stringify({
@ -189,7 +190,8 @@ export const generatePrompt = async (token: string = '', model: string, conversa
const res = await fetch(`${OLLAMA_API_BASE_URL}/api/generate`, { const res = await fetch(`${OLLAMA_API_BASE_URL}/api/generate`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'text/event-stream', Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
}, },
body: JSON.stringify({ body: JSON.stringify({
@ -223,7 +225,8 @@ export const generateTextCompletion = async (token: string = '', model: string,
const res = await fetch(`${OLLAMA_API_BASE_URL}/api/generate`, { const res = await fetch(`${OLLAMA_API_BASE_URL}/api/generate`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'text/event-stream', Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
}, },
body: JSON.stringify({ body: JSON.stringify({
@ -251,7 +254,8 @@ export const generateChatCompletion = async (token: string = '', body: object) =
signal: controller.signal, signal: controller.signal,
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'text/event-stream', Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
}, },
body: JSON.stringify(body) body: JSON.stringify(body)
@ -294,7 +298,8 @@ export const createModel = async (token: string, tagName: string, content: strin
const res = await fetch(`${OLLAMA_API_BASE_URL}/api/create`, { const res = await fetch(`${OLLAMA_API_BASE_URL}/api/create`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'text/event-stream', Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
}, },
body: JSON.stringify({ body: JSON.stringify({
@ -313,19 +318,23 @@ export const createModel = async (token: string, tagName: string, content: strin
return res; return res;
}; };
export const deleteModel = async (token: string, tagName: string) => { export const deleteModel = async (token: string, tagName: string, urlIdx: string | null = null) => {
let error = null; let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/api/delete`, { const res = await fetch(
method: 'DELETE', `${OLLAMA_API_BASE_URL}/api/delete${urlIdx !== null ? `/${urlIdx}` : ''}`,
headers: { {
'Content-Type': 'text/event-stream', method: 'DELETE',
Authorization: `Bearer ${token}` headers: {
}, Accept: 'application/json',
body: JSON.stringify({ 'Content-Type': 'application/json',
name: tagName Authorization: `Bearer ${token}`
}) },
}) body: JSON.stringify({
name: tagName
})
}
)
.then(async (res) => { .then(async (res) => {
if (!res.ok) throw await res.json(); if (!res.ok) throw await res.json();
return res.json(); return res.json();
@ -336,7 +345,12 @@ export const deleteModel = async (token: string, tagName: string) => {
}) })
.catch((err) => { .catch((err) => {
console.log(err); console.log(err);
error = err.error; error = err;
if ('detail' in err) {
error = err.detail;
}
return null; return null;
}); });
@ -347,13 +361,14 @@ export const deleteModel = async (token: string, tagName: string) => {
return res; return res;
}; };
export const pullModel = async (token: string, tagName: string) => { export const pullModel = async (token: string, tagName: string, urlIdx: string | null = null) => {
let error = null; let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/api/pull`, { const res = await fetch(`${OLLAMA_API_BASE_URL}/api/pull${urlIdx !== null ? `/${urlIdx}` : ''}`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'text/event-stream', Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
}, },
body: JSON.stringify({ body: JSON.stringify({

View file

@ -21,7 +21,7 @@
export let suggestionPrompts = []; export let suggestionPrompts = [];
export let autoScroll = true; export let autoScroll = true;
let chatTextAreaElement:HTMLTextAreaElement
let filesInputElement; let filesInputElement;
let promptsElement; let promptsElement;
@ -45,11 +45,9 @@
let speechRecognition; let speechRecognition;
$: if (prompt) { $: if (prompt) {
const chatInput = document.getElementById('chat-textarea'); if (chatTextAreaElement) {
chatTextAreaElement.style.height = '';
if (chatInput) { chatTextAreaElement.style.height = Math.min(chatTextAreaElement.scrollHeight, 200) + 'px';
chatInput.style.height = '';
chatInput.style.height = Math.min(chatInput.scrollHeight, 200) + 'px';
} }
} }
@ -88,9 +86,7 @@
if (res) { if (res) {
prompt = res.text; prompt = res.text;
await tick(); await tick();
chatTextAreaElement?.focus();
const inputElement = document.getElementById('chat-textarea');
inputElement?.focus();
if (prompt !== '' && $settings?.speechAutoSend === true) { if (prompt !== '' && $settings?.speechAutoSend === true) {
submitPrompt(prompt, user); submitPrompt(prompt, user);
@ -193,8 +189,7 @@
prompt = `${prompt}${transcript}`; prompt = `${prompt}${transcript}`;
await tick(); await tick();
const inputElement = document.getElementById('chat-textarea'); chatTextAreaElement?.focus();
inputElement?.focus();
// Restart the inactivity timeout // Restart the inactivity timeout
timeoutId = setTimeout(() => { timeoutId = setTimeout(() => {
@ -296,8 +291,7 @@
}; };
onMount(() => { onMount(() => {
const chatInput = document.getElementById('chat-textarea'); window.setTimeout(() => chatTextAreaElement?.focus(), 0);
window.setTimeout(() => chatInput?.focus(), 0);
const dropZone = document.querySelector('body'); const dropZone = document.querySelector('body');
@ -671,6 +665,7 @@
<textarea <textarea
id="chat-textarea" id="chat-textarea"
bind:this={chatTextAreaElement}
class=" dark:bg-gray-900 dark:text-gray-100 outline-none w-full py-3 px-3 {fileUploadEnabled class=" dark:bg-gray-900 dark:text-gray-100 outline-none w-full py-3 px-3 {fileUploadEnabled
? '' ? ''
: ' pl-4'} rounded-xl resize-none h-[48px]" : ' pl-4'} rounded-xl resize-none h-[48px]"

View file

@ -42,7 +42,7 @@
let edit = false; let edit = false;
let editedContent = ''; let editedContent = '';
let editTextAreaElement: HTMLTextAreaElement;
let tooltipInstance = null; let tooltipInstance = null;
let sentencesAudio = {}; let sentencesAudio = {};
@ -249,10 +249,9 @@
editedContent = message.content; editedContent = message.content;
await tick(); await tick();
const editElement = document.getElementById(`message-edit-${message.id}`);
editElement.style.height = ''; editTextAreaElement.style.height = '';
editElement.style.height = `${editElement.scrollHeight}px`; editTextAreaElement.style.height = `${editTextAreaElement.scrollHeight}px`;
}; };
const editMessageConfirmHandler = async () => { const editMessageConfirmHandler = async () => {
@ -343,6 +342,7 @@
<div class=" w-full"> <div class=" w-full">
<textarea <textarea
id="message-edit-{message.id}" id="message-edit-{message.id}"
bind:this={editTextAreaElement}
class=" bg-transparent outline-none w-full resize-none" class=" bg-transparent outline-none w-full resize-none"
bind:value={editedContent} bind:value={editedContent}
on:input={(e) => { on:input={(e) => {

View file

@ -22,18 +22,17 @@
let edit = false; let edit = false;
let editedContent = ''; let editedContent = '';
let messageEditTextAreaElement: HTMLTextAreaElement;
const editMessageHandler = async () => { const editMessageHandler = async () => {
edit = true; edit = true;
editedContent = message.content; editedContent = message.content;
await tick(); await tick();
const editElement = document.getElementById(`message-edit-${message.id}`);
editElement.style.height = ''; messageEditTextAreaElement.style.height = '';
editElement.style.height = `${editElement.scrollHeight}px`; messageEditTextAreaElement.style.height = `${messageEditTextAreaElement.scrollHeight}px`;
editElement?.focus(); messageEditTextAreaElement?.focus();
}; };
const editMessageConfirmHandler = async () => { const editMessageConfirmHandler = async () => {
@ -168,10 +167,11 @@
<div class=" w-full"> <div class=" w-full">
<textarea <textarea
id="message-edit-{message.id}" id="message-edit-{message.id}"
bind:this={messageEditTextAreaElement}
class=" bg-transparent outline-none w-full resize-none" class=" bg-transparent outline-none w-full resize-none"
bind:value={editedContent} bind:value={editedContent}
on:input={(e) => { on:input={(e) => {
e.target.style.height = `${e.target.scrollHeight}px`; messageEditTextAreaElement.style.height = `${messageEditTextAreaElement.scrollHeight}px`;
}} }}
/> />

View file

@ -17,6 +17,7 @@
let name = ''; let name = '';
let showJWTToken = false; let showJWTToken = false;
let JWTTokenCopied = false; let JWTTokenCopied = false;
let profileImageInputElement: HTMLInputElement;
const submitHandler = async () => { const submitHandler = async () => {
const updatedUser = await updateUserProfile(localStorage.token, name, profileImageUrl).catch( const updatedUser = await updateUserProfile(localStorage.token, name, profileImageUrl).catch(
@ -42,11 +43,12 @@
<div class=" space-y-3 pr-1.5 overflow-y-scroll max-h-80"> <div class=" space-y-3 pr-1.5 overflow-y-scroll max-h-80">
<input <input
id="profile-image-input" id="profile-image-input"
bind:this={profileImageInputElement}
type="file" type="file"
hidden hidden
accept="image/*" accept="image/*"
on:change={(e) => { on:change={(e) => {
const files = e?.target?.files ?? []; const files = profileImageInputElement.files ?? [];
let reader = new FileReader(); let reader = new FileReader();
reader.onload = (event) => { reader.onload = (event) => {
let originalImageUrl = `${event.target.result}`; let originalImageUrl = `${event.target.result}`;
@ -88,7 +90,7 @@
// Display the compressed image // Display the compressed image
profileImageUrl = compressedSrc; profileImageUrl = compressedSrc;
e.target.files = null; profileImageInputElement.files = null;
}; };
}; };
@ -109,9 +111,7 @@
<button <button
class="relative rounded-full dark:bg-gray-700" class="relative rounded-full dark:bg-gray-700"
type="button" type="button"
on:click={() => { on:click={profileImageInputElement.click}
document.getElementById('profile-image-input')?.click();
}}
> >
<img <img
src={profileImageUrl !== '' ? profileImageUrl : '/user.png'} src={profileImageUrl !== '' ? profileImageUrl : '/user.png'}

View file

@ -24,6 +24,7 @@
let saveChatHistory = true; let saveChatHistory = true;
let importFiles; let importFiles;
let showDeleteConfirm = false; let showDeleteConfirm = false;
let chatImportInputElement: HTMLInputElement;
$: if (importFiles) { $: if (importFiles) {
console.log(importFiles); console.log(importFiles);
@ -161,12 +162,17 @@
<hr class=" dark:border-gray-700" /> <hr class=" dark:border-gray-700" />
<div class="flex flex-col"> <div class="flex flex-col">
<input id="chat-import-input" bind:files={importFiles} type="file" accept=".json" hidden /> <input
id="chat-import-input"
bind:this={chatImportInputElement}
bind:files={importFiles}
type="file"
accept=".json"
hidden
/>
<button <button
class=" flex rounded-md py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition" class=" flex rounded-md py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
on:click={() => { on:click={chatImportInputElement.click}
document.getElementById('chat-import-input').click();
}}
> >
<div class=" self-center mr-3"> <div class=" self-center mr-3">
<svg <svg

View file

@ -3,7 +3,7 @@
import { createEventDispatcher, onMount, getContext } from 'svelte'; import { createEventDispatcher, onMount, getContext } from 'svelte';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
import { getOllamaAPIUrl, getOllamaVersion, updateOllamaAPIUrl } from '$lib/apis/ollama'; import { getOllamaUrls, getOllamaVersion, updateOllamaUrls } from '$lib/apis/ollama';
import { getOpenAIKey, getOpenAIUrl, updateOpenAIKey, updateOpenAIUrl } from '$lib/apis/openai'; import { getOpenAIKey, getOpenAIUrl, updateOpenAIKey, updateOpenAIUrl } from '$lib/apis/openai';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
@ -12,7 +12,8 @@
export let getModels: Function; export let getModels: Function;
// External // External
let API_BASE_URL = ''; let OLLAMA_BASE_URL = '';
let OLLAMA_BASE_URLS = [''];
let OPENAI_API_KEY = ''; let OPENAI_API_KEY = '';
let OPENAI_API_BASE_URL = ''; let OPENAI_API_BASE_URL = '';
@ -27,8 +28,8 @@
await models.set(await getModels()); await models.set(await getModels());
}; };
const updateOllamaAPIUrlHandler = async () => { const updateOllamaUrlsHandler = async () => {
API_BASE_URL = await updateOllamaAPIUrl(localStorage.token, API_BASE_URL); OLLAMA_BASE_URLS = await updateOllamaUrls(localStorage.token, OLLAMA_BASE_URLS);
const ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => { const ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => {
toast.error(error); toast.error(error);
@ -43,7 +44,7 @@
onMount(async () => { onMount(async () => {
if ($user.role === 'admin') { if ($user.role === 'admin') {
API_BASE_URL = await getOllamaAPIUrl(localStorage.token); OLLAMA_BASE_URLS = await getOllamaUrls(localStorage.token);
OPENAI_API_BASE_URL = await getOpenAIUrl(localStorage.token); OPENAI_API_BASE_URL = await getOpenAIUrl(localStorage.token);
OPENAI_API_KEY = await getOpenAIKey(localStorage.token); OPENAI_API_KEY = await getOpenAIKey(localStorage.token);
} }
@ -55,11 +56,6 @@
on:submit|preventDefault={() => { on:submit|preventDefault={() => {
updateOpenAIHandler(); updateOpenAIHandler();
dispatch('save'); dispatch('save');
// saveSettings({
// OPENAI_API_KEY: OPENAI_API_KEY !== '' ? OPENAI_API_KEY : undefined,
// OPENAI_API_BASE_URL: OPENAI_API_BASE_URL !== '' ? OPENAI_API_BASE_URL : undefined
// });
}} }}
> >
<div class=" pr-1.5 overflow-y-scroll max-h-[20.5rem] space-y-3"> <div class=" pr-1.5 overflow-y-scroll max-h-[20.5rem] space-y-3">
@ -116,35 +112,82 @@
<hr class=" dark:border-gray-700" /> <hr class=" dark:border-gray-700" />
<div> <div>
<div class=" mb-2.5 text-sm font-medium">{$i18n.t('Ollama API URL')}</div> <div class=" mb-2.5 text-sm font-medium">Ollama Base URL</div>
<div class="flex w-full"> <div class="flex w-full gap-1.5">
<div class="flex-1 mr-2"> <div class="flex-1 flex flex-col gap-2">
<input {#each OLLAMA_BASE_URLS as url, idx}
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none" <div class="flex gap-1.5">
placeholder="Enter URL (e.g. http://localhost:11434)" <input
bind:value={API_BASE_URL} class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
/> placeholder="Enter URL (e.g. http://localhost:11434)"
bind:value={url}
/>
<div class="self-center flex items-center">
{#if idx === 0}
<button
class="px-1"
on:click={() => {
OLLAMA_BASE_URLS = [...OLLAMA_BASE_URLS, ''];
}}
type="button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z"
/>
</svg>
</button>
{:else}
<button
class="px-1"
on:click={() => {
OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter((url, urlIdx) => idx !== urlIdx);
}}
type="button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path d="M3.75 7.25a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5h-8.5Z" />
</svg>
</button>
{/if}
</div>
</div>
{/each}
</div> </div>
<button
class="px-3 bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-700 rounded transition" <div class="">
on:click={() => { <button
updateOllamaAPIUrlHandler(); class="p-2.5 bg-gray-200 hover:bg-gray-300 dark:bg-gray-850 dark:hover:bg-gray-800 rounded-lg transition"
}} on:click={() => {
type="button" updateOllamaUrlsHandler();
> }}
<svg type="button"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
> >
<path <svg
fill-rule="evenodd" xmlns="http://www.w3.org/2000/svg"
d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z" viewBox="0 0 20 20"
clip-rule="evenodd" fill="currentColor"
/> class="w-4 h-4"
</svg> >
</button> <path
fill-rule="evenodd"
d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
</div> </div>
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500"> <div class="mt-2 text-xs text-gray-400 dark:text-gray-500">

View file

@ -2,7 +2,13 @@
import queue from 'async/queue'; import queue from 'async/queue';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { createModel, deleteModel, getOllamaVersion, pullModel } from '$lib/apis/ollama'; import {
createModel,
deleteModel,
getOllamaUrls,
getOllamaVersion,
pullModel
} from '$lib/apis/ollama';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants'; import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { WEBUI_NAME, models, user } from '$lib/stores'; import { WEBUI_NAME, models, user } from '$lib/stores';
import { splitStream } from '$lib/utils'; import { splitStream } from '$lib/utils';
@ -15,7 +21,7 @@
let showLiteLLM = false; let showLiteLLM = false;
let showLiteLLMParams = false; let showLiteLLMParams = false;
let modelUploadInputElement: HTMLInputElement;
let liteLLMModelInfo = []; let liteLLMModelInfo = [];
let liteLLMModel = ''; let liteLLMModel = '';
@ -29,6 +35,9 @@
$: liteLLMModelName = liteLLMModel; $: liteLLMModelName = liteLLMModel;
// Models // Models
let OLLAMA_URLS = [];
let selectedOllamaUrlIdx: string | null = null;
let showExperimentalOllama = false; let showExperimentalOllama = false;
let ollamaVersion = ''; let ollamaVersion = '';
const MAX_PARALLEL_DOWNLOADS = 3; const MAX_PARALLEL_DOWNLOADS = 3;
@ -246,9 +255,11 @@
}; };
const deleteModelHandler = async () => { const deleteModelHandler = async () => {
const res = await deleteModel(localStorage.token, deleteModelTag).catch((error) => { const res = await deleteModel(localStorage.token, deleteModelTag, selectedOllamaUrlIdx).catch(
toast.error(error); (error) => {
}); toast.error(error);
}
);
if (res) { if (res) {
toast.success($i18n.t(`Deleted {{deleteModelTag}}`, { deleteModelTag })); toast.success($i18n.t(`Deleted {{deleteModelTag}}`, { deleteModelTag }));
@ -259,10 +270,12 @@
}; };
const pullModelHandlerProcessor = async (opts: { modelName: string; callback: Function }) => { const pullModelHandlerProcessor = async (opts: { modelName: string; callback: Function }) => {
const res = await pullModel(localStorage.token, opts.modelName).catch((error) => { const res = await pullModel(localStorage.token, opts.modelName, selectedOllamaUrlIdx).catch(
opts.callback({ success: false, error, modelName: opts.modelName }); (error) => {
return null; opts.callback({ success: false, error, modelName: opts.modelName });
}); return null;
}
);
if (res) { if (res) {
const reader = res.body const reader = res.body
@ -368,6 +381,15 @@
}; };
onMount(async () => { onMount(async () => {
OLLAMA_URLS = await getOllamaUrls(localStorage.token).catch((error) => {
toast.error(error);
return [];
});
if (OLLAMA_URLS.length > 1) {
selectedOllamaUrlIdx = 0;
}
ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => false); ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => false);
liteLLMModelInfo = await getLiteLLMModelInfo(localStorage.token); liteLLMModelInfo = await getLiteLLMModelInfo(localStorage.token);
}); });
@ -377,52 +399,137 @@
<div class=" space-y-3 pr-1.5 overflow-y-scroll h-[23rem]"> <div class=" space-y-3 pr-1.5 overflow-y-scroll h-[23rem]">
{#if ollamaVersion} {#if ollamaVersion}
<div class="space-y-2 pr-1.5"> <div class="space-y-2 pr-1.5">
<div> <div class="text-sm font-medium">{$i18n.t('Manage Ollama Models')}</div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Manage Ollama Models')}</div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Pull a model from Ollama.com')}</div> {#if OLLAMA_URLS.length > 1}
<div class="flex w-full"> <div class="flex-1 pb-1">
<div class="flex-1 mr-2"> <select
<input class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none" bind:value={selectedOllamaUrlIdx}
placeholder="Enter model tag (e.g. mistral:7b)" placeholder="Select an Ollama instance"
bind:value={modelTag}
/>
</div>
<button
class="px-3 bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded transition"
on:click={() => {
pullModelHandler();
}}
disabled={modelTransferring}
> >
{#if modelTransferring} {#each OLLAMA_URLS as url, idx}
<div class="self-center"> <option value={idx} class="bg-gray-100 dark:bg-gray-700">{url}</option>
<svg {/each}
class=" w-4 h-4" </select>
viewBox="0 0 24 24" </div>
fill="currentColor" {/if}
xmlns="http://www.w3.org/2000/svg"
><style> <div class="space-y-2">
.spinner_ajPY { <div>
transform-origin: center; <div class=" mb-2 text-sm font-medium">{$i18n.t('Pull a model from Ollama.com')}</div>
animation: spinner_AtaB 0.75s infinite linear; <div class="flex w-full">
} <div class="flex-1 mr-2">
@keyframes spinner_AtaB { <input
100% { class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
transform: rotate(360deg); placeholder="Enter model tag (e.g. mistral:7b)"
bind:value={modelTag}
/>
</div>
<button
class="px-2.5 bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition"
on:click={() => {
pullModelHandler();
}}
disabled={modelTransferring}
>
{#if modelTransferring}
<div class="self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
} }
} @keyframes spinner_AtaB {
</style><path 100% {
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z" transform: rotate(360deg);
opacity=".25" }
/><path }
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z" </style><path
class="spinner_ajPY" d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
/></svg opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
</div>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
> >
<path
d="M8.75 2.75a.75.75 0 0 0-1.5 0v5.69L5.03 6.22a.75.75 0 0 0-1.06 1.06l3.5 3.5a.75.75 0 0 0 1.06 0l3.5-3.5a.75.75 0 0 0-1.06-1.06L8.75 8.44V2.75Z"
/>
<path
d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
/>
</svg>
{/if}
</button>
</div>
<div class="mt-2 mb-1 text-xs text-gray-400 dark:text-gray-500">
To access the available model names for downloading, <a
class=" text-gray-500 dark:text-gray-300 font-medium underline"
href="https://ollama.com/library"
target="_blank">click here.</a
>
</div>
{#if Object.keys(modelDownloadStatus).length > 0}
{#each Object.keys(modelDownloadStatus) as model}
<div class="flex flex-col">
<div class="font-medium mb-1">{model}</div>
<div class="">
<div
class="dark:bg-gray-600 bg-gray-500 text-xs font-medium text-gray-100 text-center p-0.5 leading-none rounded-full"
style="width: {Math.max(15, modelDownloadStatus[model].pullProgress ?? 0)}%"
>
{modelDownloadStatus[model].pullProgress ?? 0}%
</div>
<div class="mt-1 text-xs dark:text-gray-500" style="font-size: 0.5rem;">
{modelDownloadStatus[model].digest}
</div>
</div>
</div> </div>
{:else} {/each}
{/if}
</div>
<div>
<div class=" mb-2 text-sm font-medium">Delete a model</div>
<div class="flex w-full">
<div class="flex-1 mr-2">
<select
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
bind:value={deleteModelTag}
placeholder="Select a model"
>
{#if !deleteModelTag}
<option value="" disabled selected>Select a model</option>
{/if}
{#each $models.filter((m) => m.size != null) as model}
<option value={model.name} class="bg-gray-100 dark:bg-gray-700"
>{model.name + ' (' + (model.size / 1024 ** 3).toFixed(1) + ' GB)'}</option
>
{/each}
</select>
</div>
<button
class="px-2.5 bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition"
on:click={() => {
deleteModelHandler();
}}
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16" viewBox="0 0 16 16"
@ -430,263 +537,192 @@
class="w-4 h-4" class="w-4 h-4"
> >
<path <path
d="M8.75 2.75a.75.75 0 0 0-1.5 0v5.69L5.03 6.22a.75.75 0 0 0-1.06 1.06l3.5 3.5a.75.75 0 0 0 1.06 0l3.5-3.5a.75.75 0 0 0-1.06-1.06L8.75 8.44V2.75Z" fill-rule="evenodd"
/> d="M5 3.25V4H2.75a.75.75 0 0 0 0 1.5h.3l.815 8.15A1.5 1.5 0 0 0 5.357 15h5.285a1.5 1.5 0 0 0 1.493-1.35l.815-8.15h.3a.75.75 0 0 0 0-1.5H11v-.75A2.25 2.25 0 0 0 8.75 1h-1.5A2.25 2.25 0 0 0 5 3.25Zm2.25-.75a.75.75 0 0 0-.75.75V4h3v-.75a.75.75 0 0 0-.75-.75h-1.5ZM6.05 6a.75.75 0 0 1 .787.713l.275 5.5a.75.75 0 0 1-1.498.075l-.275-5.5A.75.75 0 0 1 6.05 6Zm3.9 0a.75.75 0 0 1 .712.787l-.275 5.5a.75.75 0 0 1-1.498-.075l.275-5.5a.75.75 0 0 1 .786-.711Z"
<path clip-rule="evenodd"
d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
/> />
</svg> </svg>
{/if}
</button>
</div>
<div class="mt-2 mb-1 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('To access the available model names for downloading,')}
<a
class=" text-gray-500 dark:text-gray-300 font-medium underline"
href="https://ollama.com/library"
target="_blank">{$i18n.t('click here.')}</a
>
</div>
{#if Object.keys(modelDownloadStatus).length > 0}
{#each Object.keys(modelDownloadStatus) as model}
<div class="flex flex-col">
<div class="font-medium mb-1">{model}</div>
<div class="">
<div
class="dark:bg-gray-600 bg-gray-500 text-xs font-medium text-gray-100 text-center p-0.5 leading-none rounded-full"
style="width: {Math.max(15, modelDownloadStatus[model].pullProgress ?? 0)}%"
>
{modelDownloadStatus[model].pullProgress ?? 0}%
</div>
<div class="mt-1 text-xs dark:text-gray-500" style="font-size: 0.5rem;">
{modelDownloadStatus[model].digest}
</div>
</div>
</div>
{/each}
{/if}
</div>
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Delete a model')}</div>
<div class="flex w-full">
<div class="flex-1 mr-2">
<select
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
bind:value={deleteModelTag}
placeholder={$i18n.t('Select a model')}
>
{#if !deleteModelTag}
<option value="" disabled selected>{$i18n.t('Select a model')}</option>
{/if}
{#each $models.filter((m) => m.size != null) as model}
<option value={model.name} class="bg-gray-100 dark:bg-gray-700"
>{model.name + ' (' + (model.size / 1024 ** 3).toFixed(1) + ' GB)'}</option
>
{/each}
</select>
</div>
<button
class="px-3 bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded transition"
on:click={() => {
deleteModelHandler();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M5 3.25V4H2.75a.75.75 0 0 0 0 1.5h.3l.815 8.15A1.5 1.5 0 0 0 5.357 15h5.285a1.5 1.5 0 0 0 1.493-1.35l.815-8.15h.3a.75.75 0 0 0 0-1.5H11v-.75A2.25 2.25 0 0 0 8.75 1h-1.5A2.25 2.25 0 0 0 5 3.25Zm2.25-.75a.75.75 0 0 0-.75.75V4h3v-.75a.75.75 0 0 0-.75-.75h-1.5ZM6.05 6a.75.75 0 0 1 .787.713l.275 5.5a.75.75 0 0 1-1.498.075l-.275-5.5A.75.75 0 0 1 6.05 6Zm3.9 0a.75.75 0 0 1 .712.787l-.275 5.5a.75.75 0 0 1-1.498-.075l.275-5.5a.75.75 0 0 1 .786-.711Z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
</div>
<div>
<div class="flex justify-between items-center text-xs">
<div class=" text-sm font-medium">{$i18n.t('Experimental')}</div>
<button
class=" text-xs font-medium text-gray-500"
type="button"
on:click={() => {
showExperimentalOllama = !showExperimentalOllama;
}}>{showExperimentalOllama ? $i18n.t('Show') : $i18n.t('Hide')}</button
>
</div>
</div>
{#if showExperimentalOllama}
<form
on:submit|preventDefault={() => {
uploadModelHandler();
}}
>
<div class=" mb-2 flex w-full justify-between">
<div class=" text-sm font-medium">{$i18n.t('Upload a GGUF model')}</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
if (modelUploadMode === 'file') {
modelUploadMode = 'url';
} else {
modelUploadMode = 'file';
}
}}
type="button"
>
{#if modelUploadMode === 'file'}
<span class="ml-2 self-center">{$i18n.t('File Mode')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('URL Mode')}</span>
{/if}
</button> </button>
</div> </div>
</div>
<div class="flex w-full mb-1.5"> <div class="pt-1">
<div class="flex flex-col w-full"> <div class="flex justify-between items-center text-xs">
{#if modelUploadMode === 'file'} <div class=" text-sm font-medium">Experimental</div>
<div class="flex-1 {modelInputFile && modelInputFile.length > 0 ? 'mr-2' : ''}"> <button
<input class=" text-xs font-medium text-gray-500"
id="model-upload-input" type="button"
type="file" on:click={() => {
bind:files={modelInputFile} showExperimentalOllama = !showExperimentalOllama;
on:change={() => { }}>{showExperimentalOllama ? 'Hide' : 'Show'}</button
console.log(modelInputFile); >
}} </div>
accept=".gguf" </div>
required
hidden
/>
<button {#if showExperimentalOllama}
type="button" <form
class="w-full rounded text-left py-2 px-4 dark:text-gray-300 dark:bg-gray-850" on:submit|preventDefault={() => {
on:click={() => { uploadModelHandler();
document.getElementById('model-upload-input').click(); }}
}} >
> <div class=" mb-2 flex w-full justify-between">
{#if modelInputFile && modelInputFile.length > 0} <div class=" text-sm font-medium">Upload a GGUF model</div>
{modelInputFile[0].name}
{:else} <button
Click here to select class="p-1 px-3 text-xs flex rounded transition"
{/if} on:click={() => {
</button> if (modelUploadMode === 'file') {
</div> modelUploadMode = 'url';
{:else} } else {
<div class="flex-1 {modelFileUrl !== '' ? 'mr-2' : ''}"> modelUploadMode = 'file';
<input }
class="w-full rounded text-left py-2 px-4 dark:text-gray-300 dark:bg-gray-850 outline-none {modelFileUrl !== }}
'' type="button"
? 'mr-2' >
: ''}" {#if modelUploadMode === 'file'}
type="url" <span class="ml-2 self-center">File Mode</span>
required {:else}
bind:value={modelFileUrl} <span class="ml-2 self-center">URL Mode</span>
placeholder="Type Hugging Face Resolve (Download) URL" {/if}
/> </button>
</div> </div>
<div class="flex w-full mb-1.5">
<div class="flex flex-col w-full">
{#if modelUploadMode === 'file'}
<div class="flex-1 {modelInputFile && modelInputFile.length > 0 ? 'mr-2' : ''}">
<input
id="model-upload-input"
bind:this={modelUploadInputElement}
type="file"
bind:files={modelInputFile}
on:change={() => {
console.log(modelInputFile);
}}
accept=".gguf"
required
hidden
/>
<button
type="button"
class="w-full rounded-lg text-left py-2 px-4 dark:text-gray-300 dark:bg-gray-850"
on:click={modelUploadInputElement.click}
>
{#if modelInputFile && modelInputFile.length > 0}
{modelInputFile[0].name}
{:else}
Click here to select
{/if}
</button>
</div>
{:else}
<div class="flex-1 {modelFileUrl !== '' ? 'mr-2' : ''}">
<input
class="w-full rounded-lg text-left py-2 px-4 dark:text-gray-300 dark:bg-gray-850 outline-none {modelFileUrl !==
''
? 'mr-2'
: ''}"
type="url"
required
bind:value={modelFileUrl}
placeholder="Type Hugging Face Resolve (Download) URL"
/>
</div>
{/if}
</div>
{#if (modelUploadMode === 'file' && modelInputFile && modelInputFile.length > 0) || (modelUploadMode === 'url' && modelFileUrl !== '')}
<button
class="px-3 text-gray-100 bg-emerald-600 hover:bg-emerald-700 disabled:bg-gray-700 disabled:cursor-not-allowed rounded transition"
type="submit"
disabled={modelTransferring}
>
{#if modelTransferring}
<div class="self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
</div>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M7.25 10.25a.75.75 0 0 0 1.5 0V4.56l2.22 2.22a.75.75 0 1 0 1.06-1.06l-3.5-3.5a.75.75 0 0 0-1.06 0l-3.5 3.5a.75.75 0 0 0 1.06 1.06l2.22-2.22v5.69Z"
/>
<path
d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
/>
</svg>
{/if}
</button>
{/if} {/if}
</div> </div>
{#if (modelUploadMode === 'file' && modelInputFile && modelInputFile.length > 0) || (modelUploadMode === 'url' && modelFileUrl !== '')} {#if (modelUploadMode === 'file' && modelInputFile && modelInputFile.length > 0) || (modelUploadMode === 'url' && modelFileUrl !== '')}
<button
class="px-3 text-gray-100 bg-emerald-600 hover:bg-emerald-700 disabled:bg-gray-700 disabled:cursor-not-allowed rounded transition"
type="submit"
disabled={modelTransferring}
>
{#if modelTransferring}
<div class="self-center">
<svg
class=" w-4 h-4"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
><style>
.spinner_ajPY {
transform-origin: center;
animation: spinner_AtaB 0.75s infinite linear;
}
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style><path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/><path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/></svg
>
</div>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M7.25 10.25a.75.75 0 0 0 1.5 0V4.56l2.22 2.22a.75.75 0 1 0 1.06-1.06l-3.5-3.5a.75.75 0 0 0-1.06 0l-3.5 3.5a.75.75 0 0 0 1.06 1.06l2.22-2.22v5.69Z"
/>
<path
d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
/>
</svg>
{/if}
</button>
{/if}
</div>
{#if (modelUploadMode === 'file' && modelInputFile && modelInputFile.length > 0) || (modelUploadMode === 'url' && modelFileUrl !== '')}
<div>
<div> <div>
<div class=" my-2.5 text-sm font-medium">{$i18n.t('Modelfile Content')}</div> <div>
<textarea <div class=" my-2.5 text-sm font-medium">Modelfile Content</div>
bind:value={modelFileContent} <textarea
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none resize-none" bind:value={modelFileContent}
rows="6" class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none resize-none"
/> rows="6"
</div> />
</div>
{/if}
<div class=" mt-1 text-xs text-gray-400 dark:text-gray-500">
To access the GGUF models available for downloading, <a
class=" text-gray-500 dark:text-gray-300 font-medium underline"
href="https://huggingface.co/models?search=gguf"
target="_blank">{$i18n.t('click here.')}</a
>
</div>
{#if uploadProgress !== null}
<div class="mt-2">
<div class=" mb-2 text-xs">{$i18n.t('Upload Progress')}</div>
<div class="w-full rounded-full dark:bg-gray-800">
<div
class="dark:bg-gray-600 bg-gray-500 text-xs font-medium text-gray-100 text-center p-0.5 leading-none rounded-full"
style="width: {Math.max(15, uploadProgress ?? 0)}%"
>
{uploadProgress ?? 0}%
</div> </div>
</div> </div>
<div class="mt-1 text-xs dark:text-gray-500" style="font-size: 0.5rem;"> {/if}
{modelFileDigest} <div class=" mt-1 text-xs text-gray-400 dark:text-gray-500">
</div> To access the GGUF models available for downloading, <a
class=" text-gray-500 dark:text-gray-300 font-medium underline"
href="https://huggingface.co/models?search=gguf"
target="_blank">click here.</a
>
</div> </div>
{/if}
</form> {#if uploadProgress !== null}
{/if} <div class="mt-2">
<div class=" mb-2 text-xs">Upload Progress</div>
<div class="w-full rounded-full dark:bg-gray-800">
<div
class="dark:bg-gray-600 bg-gray-500 text-xs font-medium text-gray-100 text-center p-0.5 leading-none rounded-full"
style="width: {Math.max(15, uploadProgress ?? 0)}%"
>
{uploadProgress ?? 0}%
</div>
</div>
<div class="mt-1 text-xs dark:text-gray-500" style="font-size: 0.5rem;">
{modelFileDigest}
</div>
</div>
{/if}
</form>
{/if}
</div>
</div> </div>
<hr class=" dark:border-gray-700 my-2" /> <hr class=" dark:border-gray-700 my-2" />
{/if} {/if}
@ -704,7 +740,7 @@
type="button" type="button"
on:click={() => { on:click={() => {
showLiteLLMParams = !showLiteLLMParams; showLiteLLMParams = !showLiteLLMParams;
}}>{showLiteLLMParams ? $i18n.t('Advanced') : $i18n.t('Default')}</button }}>{showLiteLLMParams ? 'Hide Additional Params' : 'Show Additional Params'}</button
> >
</div> </div>
</div> </div>
@ -713,7 +749,7 @@
<div class="flex w-full mb-1.5"> <div class="flex w-full mb-1.5">
<div class="flex-1 mr-2"> <div class="flex-1 mr-2">
<input <input
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none" class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
placeholder="Enter LiteLLM Model (litellm_params.model)" placeholder="Enter LiteLLM Model (litellm_params.model)"
bind:value={liteLLMModel} bind:value={liteLLMModel}
autocomplete="off" autocomplete="off"
@ -721,7 +757,7 @@
</div> </div>
<button <button
class="px-3 bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded transition" class="px-2.5 bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition"
on:click={() => { on:click={() => {
addLiteLLMModelHandler(); addLiteLLMModelHandler();
}} }}
@ -745,7 +781,7 @@
<div class="flex w-full"> <div class="flex w-full">
<div class="flex-1"> <div class="flex-1">
<input <input
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none" class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
placeholder="Enter Model Name (model_name)" placeholder="Enter Model Name (model_name)"
bind:value={liteLLMModelName} bind:value={liteLLMModelName}
autocomplete="off" autocomplete="off"
@ -759,7 +795,7 @@
<div class="flex w-full"> <div class="flex w-full">
<div class="flex-1"> <div class="flex-1">
<input <input
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none" class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
placeholder="Enter LiteLLM API Base URL (litellm_params.api_base)" placeholder="Enter LiteLLM API Base URL (litellm_params.api_base)"
bind:value={liteLLMAPIBase} bind:value={liteLLMAPIBase}
autocomplete="off" autocomplete="off"
@ -773,7 +809,7 @@
<div class="flex w-full"> <div class="flex w-full">
<div class="flex-1"> <div class="flex-1">
<input <input
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none" class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
placeholder="Enter LiteLLM API Key (litellm_params.api_key)" placeholder="Enter LiteLLM API Key (litellm_params.api_key)"
bind:value={liteLLMAPIKey} bind:value={liteLLMAPIKey}
autocomplete="off" autocomplete="off"
@ -787,7 +823,7 @@
<div class="flex w-full"> <div class="flex w-full">
<div class="flex-1"> <div class="flex-1">
<input <input
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none" class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
placeholder="Enter LiteLLM API RPM (litellm_params.rpm)" placeholder="Enter LiteLLM API RPM (litellm_params.rpm)"
bind:value={liteLLMRPM} bind:value={liteLLMRPM}
autocomplete="off" autocomplete="off"
@ -814,7 +850,7 @@
<div class="flex w-full"> <div class="flex w-full">
<div class="flex-1 mr-2"> <div class="flex-1 mr-2">
<select <select
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none" class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
bind:value={deleteLiteLLMModelId} bind:value={deleteLiteLLMModelId}
placeholder={$i18n.t('Select a model')} placeholder={$i18n.t('Select a model')}
> >
@ -829,7 +865,7 @@
</select> </select>
</div> </div>
<button <button
class="px-3 bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded transition" class="px-2.5 bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition"
on:click={() => { on:click={() => {
deleteLiteLLMModelHandler(); deleteLiteLLMModelHandler();
}} }}

View file

@ -29,6 +29,6 @@
}); });
</script> </script>
<div bind:this={tooltipElement}> <div bind:this={tooltipElement} aria-label={content}>
<slot /> <slot />
</div> </div>

View file

@ -17,7 +17,7 @@
export let show = false; export let show = false;
export let selectedDoc; export let selectedDoc;
let uploadDocInputElement: HTMLInputElement;
let inputFiles; let inputFiles;
let tags = []; let tags = [];
@ -71,7 +71,7 @@
} }
inputFiles = null; inputFiles = null;
document.getElementById('upload-doc-input').value = ''; uploadDocInputElement.value = '';
} else { } else {
toast.error($i18n.t(`File not found.`)); toast.error($i18n.t(`File not found.`));
} }
@ -128,14 +128,19 @@
}} }}
> >
<div class="mb-3 w-full"> <div class="mb-3 w-full">
<input id="upload-doc-input" hidden bind:files={inputFiles} type="file" multiple /> <input
id="upload-doc-input"
bind:this={uploadDocInputElement}
hidden
bind:files={inputFiles}
type="file"
multiple
/>
<button <button
class="w-full text-sm font-medium py-3 bg-gray-850 hover:bg-gray-800 text-center rounded-xl" class="w-full text-sm font-medium py-3 bg-gray-850 hover:bg-gray-800 text-center rounded-xl"
type="button" type="button"
on:click={() => { on:click={uploadDocInputElement.click}
document.getElementById('upload-doc-input')?.click();
}}
> >
{#if inputFiles} {#if inputFiles}
{inputFiles.length > 0 ? `${inputFiles.length}` : ''} document(s) selected. {inputFiles.length > 0 ? `${inputFiles.length}` : ''} document(s) selected.

View file

@ -4,12 +4,11 @@
const i18n = getContext('i18n'); const i18n = getContext('i18n');
export let messages = []; export let messages = [];
let textAreaElement: HTMLTextAreaElement;
onMount(() => { onMount(() => {
messages.forEach((message, idx) => { messages.forEach((message, idx) => {
let textareaElement = document.getElementById(`${message.role}-${idx}-textarea`); textAreaElement.style.height = '';
textareaElement.style.height = ''; textAreaElement.style.height = textAreaElement.scrollHeight + 'px';
textareaElement.style.height = textareaElement.scrollHeight + 'px';
}); });
}); });
</script> </script>
@ -29,18 +28,19 @@
<div class="flex-1"> <div class="flex-1">
<textarea <textarea
id="{message.role}-{idx}-textarea" id="{message.role}-{idx}-textarea"
bind:this={textAreaElement}
class="w-full bg-transparent outline-none rounded-lg p-2 text-sm resize-none overflow-hidden" class="w-full bg-transparent outline-none rounded-lg p-2 text-sm resize-none overflow-hidden"
placeholder={$i18n.t( placeholder={$i18n.t(
`Enter ${message.role === 'user' ? 'a user' : 'an assistant'} message here` `Enter ${message.role === 'user' ? 'a user' : 'an assistant'} message here`
)} )}
rows="1" rows="1"
on:input={(e) => { on:input={(e) => {
e.target.style.height = ''; textAreaElement.style.height = '';
e.target.style.height = e.target.scrollHeight + 'px'; textAreaElement.style.height = textAreaElement.scrollHeight + 'px';
}} }}
on:focus={(e) => { on:focus={(e) => {
e.target.style.height = ''; textAreaElement.style.height = '';
e.target.style.height = e.target.scrollHeight + 'px'; textAreaElement.style.height = textAreaElement.scrollHeight + 'px';
// e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px'; // e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
}} }}

View file

@ -7,7 +7,7 @@ export const WEBUI_BASE_URL = dev ? `http://${location.hostname}:8080` : ``;
export const WEBUI_API_BASE_URL = `${WEBUI_BASE_URL}/api/v1`; export const WEBUI_API_BASE_URL = `${WEBUI_BASE_URL}/api/v1`;
export const LITELLM_API_BASE_URL = `${WEBUI_BASE_URL}/litellm/api`; export const LITELLM_API_BASE_URL = `${WEBUI_BASE_URL}/litellm/api`;
export const OLLAMA_API_BASE_URL = `${WEBUI_BASE_URL}/ollama/api`; export const OLLAMA_API_BASE_URL = `${WEBUI_BASE_URL}/ollama`;
export const OPENAI_API_BASE_URL = `${WEBUI_BASE_URL}/openai/api`; export const OPENAI_API_BASE_URL = `${WEBUI_BASE_URL}/openai/api`;
export const AUDIO_API_BASE_URL = `${WEBUI_BASE_URL}/audio/api/v1`; export const AUDIO_API_BASE_URL = `${WEBUI_BASE_URL}/audio/api/v1`;
export const IMAGES_API_BASE_URL = `${WEBUI_BASE_URL}/images/api/v1`; export const IMAGES_API_BASE_URL = `${WEBUI_BASE_URL}/images/api/v1`;

View file

@ -34,12 +34,13 @@
import Sidebar from '$lib/components/layout/Sidebar.svelte'; import Sidebar from '$lib/components/layout/Sidebar.svelte';
import ShortcutsModal from '$lib/components/chat/ShortcutsModal.svelte'; import ShortcutsModal from '$lib/components/chat/ShortcutsModal.svelte';
import ChangelogModal from '$lib/components/ChangelogModal.svelte'; import ChangelogModal from '$lib/components/ChangelogModal.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
let ollamaVersion = ''; let ollamaVersion = '';
let loaded = false; let loaded = false;
let showShortcutsButtonElement: HTMLButtonElement;
let DB = null; let DB = null;
let localDBChats = []; let localDBChats = [];
@ -186,7 +187,7 @@
if (isCtrlPressed && event.key === '/') { if (isCtrlPressed && event.key === '/') {
event.preventDefault(); event.preventDefault();
console.log('showShortcuts'); console.log('showShortcuts');
document.getElementById('show-shortcuts-button')?.click(); showShortcutsButtonElement.click();
} }
}); });
@ -203,15 +204,18 @@
{#if loaded} {#if loaded}
<div class=" hidden lg:flex fixed bottom-0 right-0 px-3 py-3 z-10"> <div class=" hidden lg:flex fixed bottom-0 right-0 px-3 py-3 z-10">
<button <Tooltip content="help" placement="left">
id="show-shortcuts-button" <button
class="text-gray-600 dark:text-gray-300 bg-gray-300/20 w-6 h-6 flex items-center justify-center text-xs rounded-full" id="show-shortcuts-button"
on:click={() => { bind:this={showShortcutsButtonElement}
showShortcuts = !showShortcuts; class="text-gray-600 dark:text-gray-300 bg-gray-300/20 w-6 h-6 flex items-center justify-center text-xs rounded-full"
}} on:click={() => {
> showShortcuts = !showShortcuts;
? }}
</button> >
?
</button>
</Tooltip>
</div> </div>
<ShortcutsModal bind:show={showShortcuts} /> <ShortcutsModal bind:show={showShortcuts} />

View file

@ -45,7 +45,7 @@
let stopResponseFlag = false; let stopResponseFlag = false;
let autoScroll = true; let autoScroll = true;
let processing = ''; let processing = '';
let messagesContainerElement: HTMLDivElement;
let currentRequestId = null; let currentRequestId = null;
let selectedModels = ['']; let selectedModels = [''];
@ -143,8 +143,7 @@
}; };
const scrollToBottom = () => { const scrollToBottom = () => {
const element = document.getElementById('messages-container'); messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
element.scrollTop = element.scrollHeight;
}; };
////////////////////////// //////////////////////////
@ -837,8 +836,11 @@
<div <div
class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0" class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0"
id="messages-container" id="messages-container"
bind:this={messagesContainerElement}
on:scroll={(e) => { on:scroll={(e) => {
autoScroll = e.target.scrollHeight - e.target.scrollTop <= e.target.clientHeight + 50; autoScroll =
messagesContainerElement.scrollHeight - messagesContainerElement.scrollTop <=
messagesContainerElement.clientHeight + 50;
}} }}
> >
<div <div
@ -846,10 +848,7 @@
? 'max-w-full' ? 'max-w-full'
: 'max-w-2xl md:px-0'} mx-auto w-full px-4" : 'max-w-2xl md:px-0'} mx-auto w-full px-4"
> >
<ModelSelector <ModelSelector bind:selectedModels />
bind:selectedModels
disabled={messages.length > 0 && !selectedModels.includes('')}
/>
</div> </div>
<div class=" h-full w-full flex flex-col py-8"> <div class=" h-full w-full flex flex-col py-8">

View file

@ -47,7 +47,7 @@
let stopResponseFlag = false; let stopResponseFlag = false;
let autoScroll = true; let autoScroll = true;
let processing = ''; let processing = '';
let messagesContainerElement: HTMLDivElement;
let currentRequestId = null; let currentRequestId = null;
// let chatId = $page.params.id; // let chatId = $page.params.id;
@ -162,8 +162,7 @@
}; };
const scrollToBottom = () => { const scrollToBottom = () => {
const element = document.getElementById('messages-container'); messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
element.scrollTop = element.scrollHeight;
}; };
////////////////////////// //////////////////////////
@ -865,8 +864,11 @@
<div <div
class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0" class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0"
id="messages-container" id="messages-container"
bind:this={messagesContainerElement}
on:scroll={(e) => { on:scroll={(e) => {
autoScroll = e.target.scrollHeight - e.target.scrollTop <= e.target.clientHeight + 50; autoScroll =
messagesContainerElement.scrollHeight - messagesContainerElement.scrollTop <=
messagesContainerElement.clientHeight + 50;
}} }}
> >
<div <div
@ -874,10 +876,7 @@
? 'max-w-full' ? 'max-w-full'
: 'max-w-2xl md:px-0'} mx-auto w-full px-4" : 'max-w-2xl md:px-0'} mx-auto w-full px-4"
> >
<ModelSelector <ModelSelector bind:selectedModels />
bind:selectedModels
disabled={messages.length > 0 && !selectedModels.includes('')}
/>
</div> </div>
<div class=" h-full w-full flex flex-col py-8"> <div class=" h-full w-full flex flex-col py-8">

View file

@ -24,7 +24,7 @@
let inputFiles = ''; let inputFiles = '';
let query = ''; let query = '';
let documentsImportInputElement: HTMLInputElement;
let tags = []; let tags = [];
let showSettingsModal = false; let showSettingsModal = false;
@ -527,6 +527,7 @@
<div class="flex space-x-2"> <div class="flex space-x-2">
<input <input
id="documents-import-input" id="documents-import-input"
bind:this={documentsImportInputElement}
bind:files={importFiles} bind:files={importFiles}
type="file" type="file"
accept=".json" accept=".json"
@ -561,9 +562,7 @@
<button <button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition" class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
on:click={async () => { on:click={documentsImportInputElement.click}
document.getElementById('documents-import-input')?.click();
}}
> >
<div class=" self-center mr-2 font-medium">{$i18n.t('Import Documents Mapping')}</div> <div class=" self-center mr-2 font-medium">{$i18n.t('Import Documents Mapping')}</div>

View file

@ -18,11 +18,14 @@
let localModelfiles = []; let localModelfiles = [];
let importFiles; let importFiles;
let modelfilesImportInputElement: HTMLInputElement;
const deleteModelHandler = async (tagName) => { const deleteModelHandler = async (tagName) => {
let success = null; let success = null;
success = await deleteModel(localStorage.token, tagName); success = await deleteModel(localStorage.token, tagName).catch((err) => {
toast.error(err);
return null;
});
if (success) { if (success) {
toast.success($i18n.t(`Deleted {tagName}`, { tagName })); toast.success($i18n.t(`Deleted {tagName}`, { tagName }));
@ -237,6 +240,7 @@
<div class="flex space-x-1"> <div class="flex space-x-1">
<input <input
id="modelfiles-import-input" id="modelfiles-import-input"
bind:this={modelfilesImportInputElement}
bind:files={importFiles} bind:files={importFiles}
type="file" type="file"
accept=".json" accept=".json"
@ -264,9 +268,7 @@
<button <button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition" class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
on:click={async () => { on:click={modelfilesImportInputElement.click}
document.getElementById('modelfiles-import-input')?.click();
}}
> >
<div class=" self-center mr-2 font-medium">{$i18n.t('Import Modelfiles')}</div> <div class=" self-center mr-2 font-medium">{$i18n.t('Import Modelfiles')}</div>

View file

@ -1,4 +1,4 @@
<script> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { onMount, tick, getContext } from 'svelte'; import { onMount, tick, getContext } from 'svelte';
@ -23,15 +23,17 @@
let mode = 'chat'; let mode = 'chat';
let loaded = false; let loaded = false;
let text = ''; let text = '';
let selectedModelId = ''; let selectedModelId = '';
let loading = false; let loading = false;
let currentRequestId; let currentRequestId = null;
let stopResponseFlag = false; let stopResponseFlag = false;
let messagesContainerElement: HTMLDivElement;
let textCompletionAreaElement: HTMLTextAreaElement;
let system = ''; let system = '';
let messages = [ let messages = [
{ {
@ -41,13 +43,7 @@
]; ];
const scrollToBottom = () => { const scrollToBottom = () => {
let element; const element = mode === 'chat' ? messagesContainerElement : textCompletionAreaElement;
if (mode === 'chat') {
element = document.getElementById('messages-container');
} else {
element = document.getElementById('text-completion-textarea');
}
if (element) { if (element) {
element.scrollTop = element?.scrollHeight; element.scrollTop = element?.scrollHeight;
@ -98,6 +94,10 @@
while (true) { while (true) {
const { value, done } = await reader.read(); const { value, done } = await reader.read();
if (done || stopResponseFlag) { if (done || stopResponseFlag) {
if (stopResponseFlag) {
await cancelChatCompletion(localStorage.token, currentRequestId);
}
currentRequestId = null; currentRequestId = null;
break; break;
} }
@ -114,7 +114,11 @@
let data = JSON.parse(line.replace(/^data: /, '')); let data = JSON.parse(line.replace(/^data: /, ''));
console.log(data); console.log(data);
text += data.choices[0].delta.content ?? ''; if ('request_id' in data) {
currentRequestId = data.request_id;
} else {
text += data.choices[0].delta.content ?? '';
}
} }
} }
} }
@ -152,16 +156,6 @@
: `${OLLAMA_API_BASE_URL}/v1` : `${OLLAMA_API_BASE_URL}/v1`
); );
// const [res, controller] = await generateChatCompletion(localStorage.token, {
// model: selectedModelId,
// messages: [
// {
// role: 'assistant',
// content: text
// }
// ]
// });
let responseMessage; let responseMessage;
if (messages.at(-1)?.role === 'assistant') { if (messages.at(-1)?.role === 'assistant') {
responseMessage = messages.at(-1); responseMessage = messages.at(-1);
@ -186,6 +180,11 @@
while (true) { while (true) {
const { value, done } = await reader.read(); const { value, done } = await reader.read();
if (done || stopResponseFlag) { if (done || stopResponseFlag) {
if (stopResponseFlag) {
await cancelChatCompletion(localStorage.token, currentRequestId);
}
currentRequestId = null;
break; break;
} }
@ -202,17 +201,21 @@
let data = JSON.parse(line.replace(/^data: /, '')); let data = JSON.parse(line.replace(/^data: /, ''));
console.log(data); console.log(data);
if (responseMessage.content == '' && data.choices[0].delta.content == '\n') { if ('request_id' in data) {
continue; currentRequestId = data.request_id;
} else { } else {
textareaElement.style.height = textareaElement.scrollHeight + 'px'; if (responseMessage.content == '' && data.choices[0].delta.content == '\n') {
continue;
} else {
textareaElement.style.height = textareaElement.scrollHeight + 'px';
responseMessage.content += data.choices[0].delta.content ?? ''; responseMessage.content += data.choices[0].delta.content ?? '';
messages = messages; messages = messages;
textareaElement.style.height = textareaElement.scrollHeight + 'px'; textareaElement.style.height = textareaElement.scrollHeight + 'px';
await tick(); await tick();
}
} }
} }
} }
@ -223,48 +226,6 @@
scrollToBottom(); scrollToBottom();
} }
// while (true) {
// const { value, done } = await reader.read();
// if (done || stopResponseFlag) {
// if (stopResponseFlag) {
// await cancelChatCompletion(localStorage.token, currentRequestId);
// }
// currentRequestId = null;
// break;
// }
// try {
// let lines = value.split('\n');
// for (const line of lines) {
// if (line !== '') {
// console.log(line);
// let data = JSON.parse(line);
// if ('detail' in data) {
// throw data;
// }
// if ('id' in data) {
// console.log(data);
// currentRequestId = data.id;
// } else {
// if (data.done == false) {
// text += data.message.content;
// } else {
// console.log('done');
// }
// }
// }
// }
// } catch (error) {
// console.log(error);
// }
// scrollToBottom();
// }
} }
}; };
@ -422,12 +383,14 @@
<div <div
class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0" class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0"
id="messages-container" id="messages-container"
bind:this={messagesContainerElement}
> >
<div class=" h-full w-full flex flex-col"> <div class=" h-full w-full flex flex-col">
<div class="flex-1 p-1"> <div class="flex-1 p-1">
{#if mode === 'complete'} {#if mode === 'complete'}
<textarea <textarea
id="text-completion-textarea" id="text-completion-textarea"
bind:this={textCompletionAreaElement}
class="w-full h-full p-3 bg-transparent outline outline-1 outline-gray-200 dark:outline-gray-800 resize-none rounded-lg text-sm" class="w-full h-full p-3 bg-transparent outline outline-1 outline-gray-200 dark:outline-gray-800 resize-none rounded-lg text-sm"
bind:value={text} bind:value={text}
placeholder={$i18n.t("You're a helpful assistant.")} placeholder={$i18n.t("You're a helpful assistant.")}

View file

@ -13,7 +13,7 @@
let importFiles = ''; let importFiles = '';
let query = ''; let query = '';
let promptsImportInputElement: HTMLInputElement;
const sharePrompt = async (prompt) => { const sharePrompt = async (prompt) => {
toast.success($i18n.t('Redirecting you to OpenWebUI Community')); toast.success($i18n.t('Redirecting you to OpenWebUI Community'));
@ -210,6 +210,7 @@
<div class="flex space-x-2"> <div class="flex space-x-2">
<input <input
id="prompts-import-input" id="prompts-import-input"
bind:this={promptsImportInputElement}
bind:files={importFiles} bind:files={importFiles}
type="file" type="file"
accept=".json" accept=".json"
@ -243,9 +244,7 @@
<button <button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition" class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
on:click={async () => { on:click={promptsImportInputElement.click}
document.getElementById('prompts-import-input')?.click();
}}
> >
<div class=" self-center mr-2 font-medium">{$i18n.t('Import Prompts')}</div> <div class=" self-center mr-2 font-medium">{$i18n.t('Import Prompts')}</div>
@ -268,7 +267,7 @@
<button <button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition" class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
on:click={async () => { on:click={async () => {
// document.getElementById('modelfiles-import-input')?.click(); // promptsImportInputElement.click();
let blob = new Blob([JSON.stringify($prompts)], { let blob = new Blob([JSON.stringify($prompts)], {
type: 'application/json' type: 'application/json'
}); });