forked from open-webui/open-webui
		
	main #2
					 26 changed files with 1445 additions and 668 deletions
				
			
		
							
								
								
									
										2
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
										
									
									
										vendored
									
									
								
							|  | @ -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. | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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) | ||||||
|  |  | ||||||
|  | @ -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, |  | ||||||
|         ) |  | ||||||
|  | @ -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, | ||||||
|  |  | ||||||
|  | @ -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" | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -22,6 +22,7 @@ google-generativeai | ||||||
| 
 | 
 | ||||||
| langchain | langchain | ||||||
| langchain-community | langchain-community | ||||||
|  | fake_useragent | ||||||
| chromadb | chromadb | ||||||
| sentence_transformers | sentence_transformers | ||||||
| pypdf | pypdf | ||||||
|  |  | ||||||
|  | @ -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( | ||||||
|  | 		`${OLLAMA_API_BASE_URL}/api/delete${urlIdx !== null ? `/${urlIdx}` : ''}`, | ||||||
|  | 		{ | ||||||
| 			method: 'DELETE', | 			method: 'DELETE', | ||||||
| 			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({ | ||||||
| 				name: tagName | 				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({ | ||||||
|  |  | ||||||
|  | @ -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]" | ||||||
|  |  | ||||||
|  | @ -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) => { | ||||||
|  |  | ||||||
|  | @ -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`; | ||||||
| 						}} | 						}} | ||||||
| 					/> | 					/> | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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'} | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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,19 +112,65 @@ | ||||||
| 		<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"> | ||||||
|  | 					{#each OLLAMA_BASE_URLS as url, idx} | ||||||
|  | 						<div class="flex gap-1.5"> | ||||||
| 							<input | 							<input | ||||||
| 						class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 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 URL (e.g. http://localhost:11434)" | 								placeholder="Enter URL (e.g. http://localhost:11434)" | ||||||
| 						bind:value={API_BASE_URL} | 								bind:value={url} | ||||||
| 							/> | 							/> | ||||||
| 				</div> | 
 | ||||||
|  | 							<div class="self-center flex items-center"> | ||||||
|  | 								{#if idx === 0} | ||||||
| 									<button | 									<button | ||||||
| 					class="px-3 bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-700 rounded transition" | 										class="px-1" | ||||||
| 										on:click={() => { | 										on:click={() => { | ||||||
| 						updateOllamaAPIUrlHandler(); | 											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 class=""> | ||||||
|  | 					<button | ||||||
|  | 						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={() => { | ||||||
|  | 							updateOllamaUrlsHandler(); | ||||||
| 						}} | 						}} | ||||||
| 						type="button" | 						type="button" | ||||||
| 					> | 					> | ||||||
|  | @ -146,6 +188,7 @@ | ||||||
| 						</svg> | 						</svg> | ||||||
| 					</button> | 					</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"> | ||||||
| 				{$i18n.t('Trouble accessing Ollama?')} | 				{$i18n.t('Trouble accessing Ollama?')} | ||||||
|  |  | ||||||
|  | @ -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( | ||||||
|  | 			(error) => { | ||||||
| 				toast.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( | ||||||
|  | 			(error) => { | ||||||
| 				opts.callback({ success: false, error, modelName: opts.modelName }); | 				opts.callback({ success: false, error, modelName: opts.modelName }); | ||||||
| 				return null; | 				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,20 +399,35 @@ | ||||||
| 	<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> |  | ||||||
| 
 | 
 | ||||||
|  | 				{#if OLLAMA_URLS.length > 1} | ||||||
|  | 					<div class="flex-1 pb-1"> | ||||||
|  | 						<select | ||||||
|  | 							class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none" | ||||||
|  | 							bind:value={selectedOllamaUrlIdx} | ||||||
|  | 							placeholder="Select an Ollama instance" | ||||||
|  | 						> | ||||||
|  | 							{#each OLLAMA_URLS as url, idx} | ||||||
|  | 								<option value={idx} class="bg-gray-100 dark:bg-gray-700">{url}</option> | ||||||
|  | 							{/each} | ||||||
|  | 						</select> | ||||||
|  | 					</div> | ||||||
|  | 				{/if} | ||||||
|  | 
 | ||||||
|  | 				<div class="space-y-2"> | ||||||
|  | 					<div> | ||||||
| 						<div class=" mb-2 text-sm font-medium">{$i18n.t('Pull a model from Ollama.com')}</div> | 						<div class=" mb-2 text-sm font-medium">{$i18n.t('Pull a model from Ollama.com')}</div> | ||||||
| 						<div class="flex w-full"> | 						<div class="flex w-full"> | ||||||
| 							<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 model tag (e.g. mistral:7b)" | 									placeholder="Enter model tag (e.g. mistral:7b)" | ||||||
| 									bind:value={modelTag} | 									bind:value={modelTag} | ||||||
| 								/> | 								/> | ||||||
| 							</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={() => { | ||||||
| 									pullModelHandler(); | 									pullModelHandler(); | ||||||
| 								}} | 								}} | ||||||
|  | @ -441,11 +478,10 @@ | ||||||
| 						</div> | 						</div> | ||||||
| 
 | 
 | ||||||
| 						<div class="mt-2 mb-1 text-xs text-gray-400 dark:text-gray-500"> | 						<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,')} | 							To access the available model names for downloading, <a | ||||||
| 						<a |  | ||||||
| 								class=" text-gray-500 dark:text-gray-300 font-medium underline" | 								class=" text-gray-500 dark:text-gray-300 font-medium underline" | ||||||
| 								href="https://ollama.com/library" | 								href="https://ollama.com/library" | ||||||
| 							target="_blank">{$i18n.t('click here.')}</a | 								target="_blank">click here.</a | ||||||
| 							> | 							> | ||||||
| 						</div> | 						</div> | ||||||
| 
 | 
 | ||||||
|  | @ -470,16 +506,16 @@ | ||||||
| 					</div> | 					</div> | ||||||
| 
 | 
 | ||||||
| 					<div> | 					<div> | ||||||
| 					<div class=" mb-2 text-sm font-medium">{$i18n.t('Delete a model')}</div> | 						<div class=" mb-2 text-sm font-medium">Delete a model</div> | ||||||
| 						<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={deleteModelTag} | 									bind:value={deleteModelTag} | ||||||
| 								placeholder={$i18n.t('Select a model')} | 									placeholder="Select a model" | ||||||
| 								> | 								> | ||||||
| 									{#if !deleteModelTag} | 									{#if !deleteModelTag} | ||||||
| 									<option value="" disabled selected>{$i18n.t('Select a model')}</option> | 										<option value="" disabled selected>Select a model</option> | ||||||
| 									{/if} | 									{/if} | ||||||
| 									{#each $models.filter((m) => m.size != null) as model} | 									{#each $models.filter((m) => m.size != null) as model} | ||||||
| 										<option value={model.name} class="bg-gray-100 dark:bg-gray-700" | 										<option value={model.name} class="bg-gray-100 dark:bg-gray-700" | ||||||
|  | @ -489,7 +525,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={() => { | ||||||
| 									deleteModelHandler(); | 									deleteModelHandler(); | ||||||
| 								}} | 								}} | ||||||
|  | @ -510,15 +546,15 @@ | ||||||
| 						</div> | 						</div> | ||||||
| 					</div> | 					</div> | ||||||
| 
 | 
 | ||||||
| 				<div> | 					<div class="pt-1"> | ||||||
| 						<div class="flex justify-between items-center text-xs"> | 						<div class="flex justify-between items-center text-xs"> | ||||||
| 						<div class=" text-sm font-medium">{$i18n.t('Experimental')}</div> | 							<div class=" text-sm font-medium">Experimental</div> | ||||||
| 							<button | 							<button | ||||||
| 								class=" text-xs font-medium text-gray-500" | 								class=" text-xs font-medium text-gray-500" | ||||||
| 								type="button" | 								type="button" | ||||||
| 								on:click={() => { | 								on:click={() => { | ||||||
| 									showExperimentalOllama = !showExperimentalOllama; | 									showExperimentalOllama = !showExperimentalOllama; | ||||||
| 							}}>{showExperimentalOllama ? $i18n.t('Show') : $i18n.t('Hide')}</button | 								}}>{showExperimentalOllama ? 'Hide' : 'Show'}</button | ||||||
| 							> | 							> | ||||||
| 						</div> | 						</div> | ||||||
| 					</div> | 					</div> | ||||||
|  | @ -530,7 +566,7 @@ | ||||||
| 							}} | 							}} | ||||||
| 						> | 						> | ||||||
| 							<div class=" mb-2 flex w-full justify-between"> | 							<div class=" mb-2 flex w-full justify-between"> | ||||||
| 							<div class="  text-sm font-medium">{$i18n.t('Upload a GGUF model')}</div> | 								<div class="  text-sm font-medium">Upload a GGUF model</div> | ||||||
| 
 | 
 | ||||||
| 								<button | 								<button | ||||||
| 									class="p-1 px-3 text-xs flex rounded transition" | 									class="p-1 px-3 text-xs flex rounded transition" | ||||||
|  | @ -544,9 +580,9 @@ | ||||||
| 									type="button" | 									type="button" | ||||||
| 								> | 								> | ||||||
| 									{#if modelUploadMode === 'file'} | 									{#if modelUploadMode === 'file'} | ||||||
| 									<span class="ml-2 self-center">{$i18n.t('File Mode')}</span> | 										<span class="ml-2 self-center">File Mode</span> | ||||||
| 									{:else} | 									{:else} | ||||||
| 									<span class="ml-2 self-center">{$i18n.t('URL Mode')}</span> | 										<span class="ml-2 self-center">URL Mode</span> | ||||||
| 									{/if} | 									{/if} | ||||||
| 								</button> | 								</button> | ||||||
| 							</div> | 							</div> | ||||||
|  | @ -557,6 +593,7 @@ | ||||||
| 										<div class="flex-1 {modelInputFile && modelInputFile.length > 0 ? 'mr-2' : ''}"> | 										<div class="flex-1 {modelInputFile && modelInputFile.length > 0 ? 'mr-2' : ''}"> | ||||||
| 											<input | 											<input | ||||||
| 												id="model-upload-input" | 												id="model-upload-input" | ||||||
|  | 												bind:this={modelUploadInputElement} | ||||||
| 												type="file" | 												type="file" | ||||||
| 												bind:files={modelInputFile} | 												bind:files={modelInputFile} | ||||||
| 												on:change={() => { | 												on:change={() => { | ||||||
|  | @ -569,10 +606,8 @@ | ||||||
| 
 | 
 | ||||||
| 											<button | 											<button | ||||||
| 												type="button" | 												type="button" | ||||||
| 											class="w-full rounded text-left py-2 px-4 dark:text-gray-300 dark:bg-gray-850" | 												class="w-full rounded-lg text-left py-2 px-4 dark:text-gray-300 dark:bg-gray-850" | ||||||
| 											on:click={() => { | 												on:click={modelUploadInputElement.click} | ||||||
| 												document.getElementById('model-upload-input').click(); |  | ||||||
| 											}} |  | ||||||
| 											> | 											> | ||||||
| 												{#if modelInputFile && modelInputFile.length > 0} | 												{#if modelInputFile && modelInputFile.length > 0} | ||||||
| 													{modelInputFile[0].name} | 													{modelInputFile[0].name} | ||||||
|  | @ -584,7 +619,7 @@ | ||||||
| 									{:else} | 									{:else} | ||||||
| 										<div class="flex-1 {modelFileUrl !== '' ? 'mr-2' : ''}"> | 										<div class="flex-1 {modelFileUrl !== '' ? 'mr-2' : ''}"> | ||||||
| 											<input | 											<input | ||||||
| 											class="w-full rounded text-left py-2 px-4 dark:text-gray-300 dark:bg-gray-850 outline-none {modelFileUrl !== | 												class="w-full rounded-lg text-left py-2 px-4 dark:text-gray-300 dark:bg-gray-850 outline-none {modelFileUrl !== | ||||||
| 												'' | 												'' | ||||||
| 													? 'mr-2' | 													? 'mr-2' | ||||||
| 													: ''}" | 													: ''}" | ||||||
|  | @ -651,7 +686,7 @@ | ||||||
| 							{#if (modelUploadMode === 'file' && modelInputFile && modelInputFile.length > 0) || (modelUploadMode === 'url' && modelFileUrl !== '')} | 							{#if (modelUploadMode === 'file' && modelInputFile && modelInputFile.length > 0) || (modelUploadMode === 'url' && modelFileUrl !== '')} | ||||||
| 								<div> | 								<div> | ||||||
| 									<div> | 									<div> | ||||||
| 									<div class=" my-2.5 text-sm font-medium">{$i18n.t('Modelfile Content')}</div> | 										<div class=" my-2.5 text-sm font-medium">Modelfile Content</div> | ||||||
| 										<textarea | 										<textarea | ||||||
| 											bind:value={modelFileContent} | 											bind:value={modelFileContent} | ||||||
| 											class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none resize-none" | 											class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none resize-none" | ||||||
|  | @ -664,13 +699,13 @@ | ||||||
| 								To access the GGUF models available for downloading, <a | 								To access the GGUF models available for downloading, <a | ||||||
| 									class=" text-gray-500 dark:text-gray-300 font-medium underline" | 									class=" text-gray-500 dark:text-gray-300 font-medium underline" | ||||||
| 									href="https://huggingface.co/models?search=gguf" | 									href="https://huggingface.co/models?search=gguf" | ||||||
| 								target="_blank">{$i18n.t('click here.')}</a | 									target="_blank">click here.</a | ||||||
| 								> | 								> | ||||||
| 							</div> | 							</div> | ||||||
| 
 | 
 | ||||||
| 							{#if uploadProgress !== null} | 							{#if uploadProgress !== null} | ||||||
| 								<div class="mt-2"> | 								<div class="mt-2"> | ||||||
| 								<div class=" mb-2 text-xs">{$i18n.t('Upload Progress')}</div> | 									<div class=" mb-2 text-xs">Upload Progress</div> | ||||||
| 
 | 
 | ||||||
| 									<div class="w-full rounded-full dark:bg-gray-800"> | 									<div class="w-full rounded-full dark:bg-gray-800"> | ||||||
| 										<div | 										<div | ||||||
|  | @ -688,6 +723,7 @@ | ||||||
| 						</form> | 						</form> | ||||||
| 					{/if} | 					{/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(); | ||||||
| 								}} | 								}} | ||||||
|  |  | ||||||
|  | @ -29,6 +29,6 @@ | ||||||
| 	}); | 	}); | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <div bind:this={tooltipElement}> | <div bind:this={tooltipElement} aria-label={content}> | ||||||
| 	<slot /> | 	<slot /> | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
|  | @ -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. | ||||||
|  |  | ||||||
|  | @ -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'; | ||||||
| 					}} | 					}} | ||||||
|  |  | ||||||
|  | @ -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`; | ||||||
|  |  | ||||||
|  | @ -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,8 +204,10 @@ | ||||||
| 
 | 
 | ||||||
| {#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"> | ||||||
|  | 		<Tooltip content="help" placement="left"> | ||||||
| 			<button | 			<button | ||||||
| 				id="show-shortcuts-button" | 				id="show-shortcuts-button" | ||||||
|  | 				bind:this={showShortcutsButtonElement} | ||||||
| 				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" | 				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={() => { | 				on:click={() => { | ||||||
| 					showShortcuts = !showShortcuts; | 					showShortcuts = !showShortcuts; | ||||||
|  | @ -212,6 +215,7 @@ | ||||||
| 			> | 			> | ||||||
| 				? | 				? | ||||||
| 			</button> | 			</button> | ||||||
|  | 		</Tooltip> | ||||||
| 	</div> | 	</div> | ||||||
| 
 | 
 | ||||||
| 	<ShortcutsModal bind:show={showShortcuts} /> | 	<ShortcutsModal bind:show={showShortcuts} /> | ||||||
|  |  | ||||||
|  | @ -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"> | ||||||
|  |  | ||||||
|  | @ -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"> | ||||||
|  |  | ||||||
|  | @ -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> | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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> | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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,10 +114,14 @@ | ||||||
| 								let data = JSON.parse(line.replace(/^data: /, '')); | 								let data = JSON.parse(line.replace(/^data: /, '')); | ||||||
| 								console.log(data); | 								console.log(data); | ||||||
| 
 | 
 | ||||||
|  | 								if ('request_id' in data) { | ||||||
|  | 									currentRequestId = data.request_id; | ||||||
|  | 								} else { | ||||||
| 									text += data.choices[0].delta.content ?? ''; | 									text += data.choices[0].delta.content ?? ''; | ||||||
| 								} | 								} | ||||||
| 							} | 							} | ||||||
| 						} | 						} | ||||||
|  | 					} | ||||||
| 				} catch (error) { | 				} catch (error) { | ||||||
| 					console.log(error); | 					console.log(error); | ||||||
| 				} | 				} | ||||||
|  | @ -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,6 +201,9 @@ | ||||||
| 								let data = JSON.parse(line.replace(/^data: /, '')); | 								let data = JSON.parse(line.replace(/^data: /, '')); | ||||||
| 								console.log(data); | 								console.log(data); | ||||||
| 
 | 
 | ||||||
|  | 								if ('request_id' in data) { | ||||||
|  | 									currentRequestId = data.request_id; | ||||||
|  | 								} else { | ||||||
| 									if (responseMessage.content == '' && data.choices[0].delta.content == '\n') { | 									if (responseMessage.content == '' && data.choices[0].delta.content == '\n') { | ||||||
| 										continue; | 										continue; | ||||||
| 									} else { | 									} else { | ||||||
|  | @ -217,54 +219,13 @@ | ||||||
| 								} | 								} | ||||||
| 							} | 							} | ||||||
| 						} | 						} | ||||||
|  | 					} | ||||||
| 				} catch (error) { | 				} catch (error) { | ||||||
| 					console.log(error); | 					console.log(error); | ||||||
| 				} | 				} | ||||||
| 
 | 
 | ||||||
| 				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.")} | ||||||
|  |  | ||||||
|  | @ -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' | ||||||
| 							}); | 							}); | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue