diff --git a/CHANGELOG.md b/CHANGELOG.md index d61e40c0..f9e86fe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.108] - 2024-03-02 + +### Added + +- **🎮 Playground Feature (Beta)**: Explore the full potential of the raw API through an intuitive UI with our new playground feature, accessible to admins. Simply click on the bottom name area of the sidebar to access it. The playground feature offers two modes text completion (notebook) and chat completion. As it's in beta, please report any issues you encounter. +- **🛠️ Direct Database Download for Admins**: Admins can now download the database directly from the WebUI via the admin settings. +- **🎨 Additional RAG Settings**: Customize your RAG process with the ability to edit the TOP K value. Navigate to Documents > Settings > General to make changes. +- **🖥️ UI Improvements**: Tooltips now available in the input area and sidebar handle. More tooltips will be added across other parts of the UI. + +### Fixed + +- Resolved input autofocus issue on mobile when the sidebar is open, making it easier to use. +- Corrected numbered list display issue in Safari (#963). +- Restricted user ability to delete chats without proper permissions (#993). + +### Changed + +- **Simplified Ollama Settings**: Ollama settings now don't require the `/api` suffix. You can now utilize the Ollama base URL directly, e.g., `http://localhost:11434`. Also, an `OLLAMA_BASE_URL` environment variable has been added. +- **Database Renaming**: Starting from this release, `ollama.db` will be automatically renamed to `webui.db`. + ## [0.1.107] - 2024-03-01 ### Added diff --git a/backend/apps/ollama/main.py b/backend/apps/ollama/main.py index bc797f08..7965ff32 100644 --- a/backend/apps/ollama/main.py +++ b/backend/apps/ollama/main.py @@ -11,7 +11,7 @@ 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, get_admin_user -from config import OLLAMA_API_BASE_URL, WEBUI_AUTH +from config import OLLAMA_BASE_URL, WEBUI_AUTH app = FastAPI() app.add_middleware( @@ -22,7 +22,7 @@ app.add_middleware( allow_headers=["*"], ) -app.state.OLLAMA_API_BASE_URL = OLLAMA_API_BASE_URL +app.state.OLLAMA_BASE_URL = OLLAMA_BASE_URL # TARGET_SERVER_URL = OLLAMA_API_BASE_URL @@ -32,7 +32,7 @@ REQUEST_POOL = [] @app.get("/url") async def get_ollama_api_url(user=Depends(get_admin_user)): - return {"OLLAMA_API_BASE_URL": app.state.OLLAMA_API_BASE_URL} + return {"OLLAMA_BASE_URL": app.state.OLLAMA_BASE_URL} class UrlUpdateForm(BaseModel): @@ -41,8 +41,8 @@ class UrlUpdateForm(BaseModel): @app.post("/url/update") async def update_ollama_api_url(form_data: UrlUpdateForm, user=Depends(get_admin_user)): - app.state.OLLAMA_API_BASE_URL = form_data.url - return {"OLLAMA_API_BASE_URL": app.state.OLLAMA_API_BASE_URL} + app.state.OLLAMA_BASE_URL = form_data.url + return {"OLLAMA_BASE_URL": app.state.OLLAMA_BASE_URL} @app.get("/cancel/{request_id}") @@ -57,7 +57,7 @@ async def cancel_ollama_request(request_id: str, user=Depends(get_current_user)) @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}" + target_url = f"{app.state.OLLAMA_BASE_URL}/{path}" body = await request.body() headers = dict(request.headers) @@ -91,7 +91,13 @@ async def proxy(path: str, request: Request, user=Depends(get_current_user)): def stream_content(): try: - if path in ["chat"]: + if path == "generate": + data = json.loads(body.decode("utf-8")) + + if not ("stream" in data and data["stream"] == False): + yield json.dumps({"id": request_id, "done": False}) + "\n" + + elif path == "chat": yield json.dumps({"id": request_id, "done": False}) + "\n" for chunk in r.iter_content(chunk_size=8192): @@ -103,7 +109,8 @@ async def proxy(path: str, request: Request, user=Depends(get_current_user)): finally: if hasattr(r, "close"): r.close() - REQUEST_POOL.remove(request_id) + if request_id in REQUEST_POOL: + REQUEST_POOL.remove(request_id) r = requests.request( method=request.method, diff --git a/backend/apps/rag/main.py b/backend/apps/rag/main.py index 83c10233..2a8b2a49 100644 --- a/backend/apps/rag/main.py +++ b/backend/apps/rag/main.py @@ -79,6 +79,8 @@ app.state.CHUNK_SIZE = CHUNK_SIZE app.state.CHUNK_OVERLAP = CHUNK_OVERLAP app.state.RAG_TEMPLATE = RAG_TEMPLATE app.state.RAG_EMBEDDING_MODEL = RAG_EMBEDDING_MODEL +app.state.TOP_K = 4 + app.state.sentence_transformer_ef = ( embedding_functions.SentenceTransformerEmbeddingFunction( model_name=app.state.RAG_EMBEDDING_MODEL, @@ -210,23 +212,33 @@ async def get_rag_template(user=Depends(get_current_user)): } -class RAGTemplateForm(BaseModel): - template: str +@app.get("/query/settings") +async def get_query_settings(user=Depends(get_admin_user)): + return { + "status": True, + "template": app.state.RAG_TEMPLATE, + "k": app.state.TOP_K, + } -@app.post("/template/update") -async def update_rag_template(form_data: RAGTemplateForm, user=Depends(get_admin_user)): - # TODO: check template requirements - app.state.RAG_TEMPLATE = ( - form_data.template if form_data.template != "" else RAG_TEMPLATE - ) +class QuerySettingsForm(BaseModel): + k: Optional[int] = None + template: Optional[str] = None + + +@app.post("/query/settings/update") +async def update_query_settings( + form_data: QuerySettingsForm, user=Depends(get_admin_user) +): + app.state.RAG_TEMPLATE = form_data.template if form_data.template else RAG_TEMPLATE + app.state.TOP_K = form_data.k if form_data.k else 4 return {"status": True, "template": app.state.RAG_TEMPLATE} class QueryDocForm(BaseModel): collection_name: str query: str - k: Optional[int] = 4 + k: Optional[int] = None @app.post("/query/doc") @@ -240,7 +252,10 @@ def query_doc( name=form_data.collection_name, embedding_function=app.state.sentence_transformer_ef, ) - result = collection.query(query_texts=[form_data.query], n_results=form_data.k) + result = collection.query( + query_texts=[form_data.query], + n_results=form_data.k if form_data.k else app.state.TOP_K, + ) return result except Exception as e: print(e) @@ -253,7 +268,7 @@ def query_doc( class QueryCollectionsForm(BaseModel): collection_names: List[str] query: str - k: Optional[int] = 4 + k: Optional[int] = None def merge_and_sort_query_results(query_results, k): @@ -317,13 +332,16 @@ def query_collection( ) result = collection.query( - query_texts=[form_data.query], n_results=form_data.k + query_texts=[form_data.query], + n_results=form_data.k if form_data.k else app.state.TOP_K, ) results.append(result) except: pass - return merge_and_sort_query_results(results, form_data.k) + return merge_and_sort_query_results( + results, form_data.k if form_data.k else app.state.TOP_K + ) @app.post("/web") @@ -423,7 +441,9 @@ def get_loader(filename: str, file_content_type: str, file_path: str): "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ] or file_ext in ["xls", "xlsx"]: loader = UnstructuredExcelLoader(file_path) - elif file_ext in known_source_ext or (file_content_type and file_content_type.find("text/") >= 0): + elif file_ext in known_source_ext or ( + file_content_type and file_content_type.find("text/") >= 0 + ): loader = TextLoader(file_path) else: loader = TextLoader(file_path) diff --git a/backend/apps/web/internal/db.py b/backend/apps/web/internal/db.py index 1f8c3bf7..d0aa9969 100644 --- a/backend/apps/web/internal/db.py +++ b/backend/apps/web/internal/db.py @@ -1,6 +1,16 @@ from peewee import * from config import DATA_DIR +import os -DB = SqliteDatabase(f"{DATA_DIR}/ollama.db") +# Check if the file exists +if os.path.exists(f"{DATA_DIR}/ollama.db"): + # Rename the file + os.rename(f"{DATA_DIR}/ollama.db", f"{DATA_DIR}/webui.db") + print("File renamed successfully.") +else: + pass + + +DB = SqliteDatabase(f"{DATA_DIR}/webui.db") DB.connect() diff --git a/backend/apps/web/routers/chats.py b/backend/apps/web/routers/chats.py index 1ce537ec..0c0ac1ce 100644 --- a/backend/apps/web/routers/chats.py +++ b/backend/apps/web/routers/chats.py @@ -271,6 +271,16 @@ async def delete_all_chat_tags_by_id(id: str, user=Depends(get_current_user)): @router.delete("/", response_model=bool) -async def delete_all_user_chats(user=Depends(get_current_user)): +async def delete_all_user_chats(request: Request, user=Depends(get_current_user)): + + if ( + user.role == "user" + and not request.app.state.USER_PERMISSIONS["chat"]["deletion"] + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + result = Chats.delete_chats_by_user_id(user.id) return result diff --git a/backend/apps/web/routers/utils.py b/backend/apps/web/routers/utils.py index 86e1a9e5..6356bf45 100644 --- a/backend/apps/web/routers/utils.py +++ b/backend/apps/web/routers/utils.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, UploadFile, File, BackgroundTasks from fastapi import Depends, HTTPException, status -from starlette.responses import StreamingResponse +from starlette.responses import StreamingResponse, FileResponse + from pydantic import BaseModel @@ -9,6 +10,8 @@ import os import aiohttp import json + +from utils.utils import get_admin_user from utils.misc import calculate_sha256, get_gravatar_url from config import OLLAMA_API_BASE_URL, DATA_DIR, UPLOAD_DIR @@ -172,3 +175,13 @@ async def get_gravatar( email: str, ): return get_gravatar_url(email) + + +@router.get("/db/download") +async def download_db(user=Depends(get_admin_user)): + + return FileResponse( + f"{DATA_DIR}/webui.db", + media_type="application/octet-stream", + filename="webui.db", + ) diff --git a/backend/config.py b/backend/config.py index effcd246..cd1a2702 100644 --- a/backend/config.py +++ b/backend/config.py @@ -211,6 +211,17 @@ if ENV == "prod": if OLLAMA_API_BASE_URL == "/ollama/api": OLLAMA_API_BASE_URL = "http://host.docker.internal:11434/api" + +OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "") + +if OLLAMA_BASE_URL == "": + OLLAMA_BASE_URL = ( + OLLAMA_API_BASE_URL[:-4] + if OLLAMA_API_BASE_URL.endswith("/api") + else OLLAMA_API_BASE_URL + ) + + #################################### # OPENAI_API #################################### @@ -226,7 +237,7 @@ if OPENAI_API_BASE_URL == "": # WEBUI #################################### -ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", True) +ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "True").lower() == "true" DEFAULT_MODELS = os.environ.get("DEFAULT_MODELS", None) diff --git a/package.json b/package.json index 0e95102d..cb16b347 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.1.107", + "version": "0.1.108", "private": true, "scripts": { "dev": "vite dev --host", diff --git a/src/app.css b/src/app.css index 091db396..63b34465 100644 --- a/src/app.css +++ b/src/app.css @@ -28,6 +28,21 @@ math { @apply rounded-lg; } +ol > li { + counter-increment: list-number; + display: block; + margin-bottom: 0; + margin-top: 0; + min-height: 28px; +} + +.prose ol > li::before { + content: counters(list-number, '.') '.'; + padding-right: 0.5rem; + color: var(--tw-prose-counters); + font-weight: 400; +} + ::-webkit-scrollbar-thumb { --tw-border-opacity: 1; background-color: rgba(217, 217, 227, 0.8); diff --git a/src/lib/apis/chats/index.ts b/src/lib/apis/chats/index.ts index aadf3769..35b259d5 100644 --- a/src/lib/apis/chats/index.ts +++ b/src/lib/apis/chats/index.ts @@ -439,7 +439,7 @@ export const deleteAllChats = async (token: string) => { return json; }) .catch((err) => { - error = err; + error = err.detail; console.log(err); return null; diff --git a/src/lib/apis/ollama/index.ts b/src/lib/apis/ollama/index.ts index 5fc8a5fe..0c96b2ab 100644 --- a/src/lib/apis/ollama/index.ts +++ b/src/lib/apis/ollama/index.ts @@ -29,7 +29,7 @@ export const getOllamaAPIUrl = async (token: string = '') => { throw error; } - return res.OLLAMA_API_BASE_URL; + return res.OLLAMA_BASE_URL; }; export const updateOllamaAPIUrl = async (token: string = '', url: string) => { @@ -64,13 +64,13 @@ export const updateOllamaAPIUrl = async (token: string = '', url: string) => { throw error; } - return res.OLLAMA_API_BASE_URL; + return res.OLLAMA_BASE_URL; }; export const getOllamaVersion = async (token: string = '') => { let error = null; - const res = await fetch(`${OLLAMA_API_BASE_URL}/version`, { + const res = await fetch(`${OLLAMA_API_BASE_URL}/api/version`, { method: 'GET', headers: { Accept: 'application/json', @@ -102,7 +102,7 @@ export const getOllamaVersion = async (token: string = '') => { export const getOllamaModels = async (token: string = '') => { let error = null; - const res = await fetch(`${OLLAMA_API_BASE_URL}/tags`, { + const res = await fetch(`${OLLAMA_API_BASE_URL}/api/tags`, { method: 'GET', headers: { Accept: 'application/json', @@ -148,7 +148,7 @@ export const generateTitle = async ( console.log(template); - const res = await fetch(`${OLLAMA_API_BASE_URL}/generate`, { + const res = await fetch(`${OLLAMA_API_BASE_URL}/api/generate`, { method: 'POST', headers: { 'Content-Type': 'text/event-stream', @@ -186,7 +186,7 @@ export const generatePrompt = async (token: string = '', model: string, conversa conversation = '[no existing conversation]'; } - const res = await fetch(`${OLLAMA_API_BASE_URL}/generate`, { + const res = await fetch(`${OLLAMA_API_BASE_URL}/api/generate`, { method: 'POST', headers: { 'Content-Type': 'text/event-stream', @@ -217,11 +217,37 @@ export const generatePrompt = async (token: string = '', model: string, conversa return res; }; +export const generateTextCompletion = async (token: string = '', model: string, text: string) => { + let error = null; + + const res = await fetch(`${OLLAMA_API_BASE_URL}/api/generate`, { + method: 'POST', + headers: { + 'Content-Type': 'text/event-stream', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + model: model, + prompt: text, + stream: true + }) + }).catch((err) => { + error = err; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const generateChatCompletion = async (token: string = '', body: object) => { let controller = new AbortController(); let error = null; - const res = await fetch(`${OLLAMA_API_BASE_URL}/chat`, { + const res = await fetch(`${OLLAMA_API_BASE_URL}/api/chat`, { signal: controller.signal, method: 'POST', headers: { @@ -265,7 +291,7 @@ export const cancelChatCompletion = async (token: string = '', requestId: string export const createModel = async (token: string, tagName: string, content: string) => { let error = null; - const res = await fetch(`${OLLAMA_API_BASE_URL}/create`, { + const res = await fetch(`${OLLAMA_API_BASE_URL}/api/create`, { method: 'POST', headers: { 'Content-Type': 'text/event-stream', @@ -290,7 +316,7 @@ export const createModel = async (token: string, tagName: string, content: strin export const deleteModel = async (token: string, tagName: string) => { let error = null; - const res = await fetch(`${OLLAMA_API_BASE_URL}/delete`, { + const res = await fetch(`${OLLAMA_API_BASE_URL}/api/delete`, { method: 'DELETE', headers: { 'Content-Type': 'text/event-stream', @@ -324,7 +350,7 @@ export const deleteModel = async (token: string, tagName: string) => { export const pullModel = async (token: string, tagName: string) => { let error = null; - const res = await fetch(`${OLLAMA_API_BASE_URL}/pull`, { + const res = await fetch(`${OLLAMA_API_BASE_URL}/api/pull`, { method: 'POST', headers: { 'Content-Type': 'text/event-stream', diff --git a/src/lib/apis/rag/index.ts b/src/lib/apis/rag/index.ts index ed36f014..4e8e9b14 100644 --- a/src/lib/apis/rag/index.ts +++ b/src/lib/apis/rag/index.ts @@ -85,17 +85,49 @@ export const getRAGTemplate = async (token: string) => { return res?.template ?? ''; }; -export const updateRAGTemplate = async (token: string, template: string) => { +export const getQuerySettings = async (token: string) => { let error = null; - const res = await fetch(`${RAG_API_BASE_URL}/template/update`, { + const res = await fetch(`${RAG_API_BASE_URL}/query/settings`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +type QuerySettings = { + k: number | null; + template: string | null; +}; + +export const updateQuerySettings = async (token: string, settings: QuerySettings) => { + let error = null; + + const res = await fetch(`${RAG_API_BASE_URL}/query/settings/update`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ - template: template + ...settings }) }) .then(async (res) => { @@ -183,7 +215,7 @@ export const queryDoc = async ( token: string, collection_name: string, query: string, - k: number + k: number | null = null ) => { let error = null; diff --git a/src/lib/apis/utils/index.ts b/src/lib/apis/utils/index.ts index ed4d4e02..bcb55407 100644 --- a/src/lib/apis/utils/index.ts +++ b/src/lib/apis/utils/index.ts @@ -21,3 +21,35 @@ export const getGravatarUrl = async (email: string) => { return res; }; + +export const downloadDatabase = async (token: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/utils/db/download`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then((response) => { + if (!response.ok) { + throw new Error('Network response was not ok'); + } + return response.blob(); + }) + .then((blob) => { + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'webui.db'; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + }) + .catch((err) => { + console.log(err); + error = err; + return null; + }); +}; diff --git a/src/lib/components/admin/Settings/Database.svelte b/src/lib/components/admin/Settings/Database.svelte new file mode 100644 index 00000000..1b725c4a --- /dev/null +++ b/src/lib/components/admin/Settings/Database.svelte @@ -0,0 +1,63 @@ + + +
{ + saveHandler(); + }} +> +
+
+
Database
+ +
+ + + +
+
+
+ + +
diff --git a/src/lib/components/admin/SettingsModal.svelte b/src/lib/components/admin/SettingsModal.svelte index 67f6be88..0a3878e0 100644 --- a/src/lib/components/admin/SettingsModal.svelte +++ b/src/lib/components/admin/SettingsModal.svelte @@ -1,5 +1,6 @@ + +
+ +
diff --git a/src/lib/components/documents/Settings/General.svelte b/src/lib/components/documents/Settings/General.svelte index 9bf496a2..28f3e71a 100644 --- a/src/lib/components/documents/Settings/General.svelte +++ b/src/lib/components/documents/Settings/General.svelte @@ -2,10 +2,10 @@ import { getDocs } from '$lib/apis/documents'; import { getChunkParams, - getRAGTemplate, + getQuerySettings, scanDocs, updateChunkParams, - updateRAGTemplate + updateQuerySettings } from '$lib/apis/rag'; import { documents } from '$lib/stores'; import { onMount } from 'svelte'; @@ -18,7 +18,10 @@ let chunkSize = 0; let chunkOverlap = 0; - let template = ''; + let querySettings = { + template: '', + k: 4 + }; const scanHandler = async () => { loading = true; @@ -33,7 +36,7 @@ const submitHandler = async () => { const res = await updateChunkParams(localStorage.token, chunkSize, chunkOverlap); - await updateRAGTemplate(localStorage.token, template); + querySettings = await updateQuerySettings(localStorage.token, querySettings); }; onMount(async () => { @@ -44,7 +47,7 @@ chunkOverlap = res.chunk_overlap; } - template = await getRAGTemplate(localStorage.token); + querySettings = await getQuerySettings(localStorage.token); }); @@ -156,10 +159,44 @@ +
Query Params
+ +
+
+
Top K
+ +
+ +
+
+ + +
+
RAG Template