diff --git a/.env.example b/.env.example index 05854cd0f..2d782fce1 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,8 @@ OPENAI_API_KEY='' # DO NOT TRACK SCARF_NO_ANALYTICS=true DO_NOT_TRACK=true +ANONYMIZED_TELEMETRY=false # Use locally bundled version of the LiteLLM cost map json # to avoid repetitive startup connections -LITELLM_LOCAL_MODEL_COST_MAP="True" +LITELLM_LOCAL_MODEL_COST_MAP="True" \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..3dd5eaf6b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: +- package-ecosystem: pip + directory: "/backend" + schedule: + interval: daily + time: "13:00" + groups: + python-packages: + patterns: + - "*" diff --git a/CHANGELOG.md b/CHANGELOG.md index 62e960ab6..0567919fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ 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.123] - 2024-05-02 + +### Added + +- **🎨 New Landing Page Design**: Refreshed design for a more modern look and optimized use of screen space. +- **📹 Youtube RAG Pipeline**: Introduces dedicated RAG pipeline for Youtube videos, enabling interaction with video transcriptions directly. +- **🔧 Enhanced Admin Panel**: Streamlined user management with options to add users directly or in bulk via CSV import. +- **👥 '@' Model Integration**: Easily switch to specific models during conversations; old collaborative chat feature phased out. +- **🌐 Language Enhancements**: Swedish translation added, plus improvements to German, Spanish, and the addition of Doge translation. + +### Fixed + +- **🗑️ Delete Chat Shortcut**: Addressed issue where shortcut wasn't functioning. +- **🖼️ Modal Closing Bug**: Resolved unexpected closure of modal when dragging from within. +- **✏️ Edit Button Styling**: Fixed styling inconsistency with edit buttons. +- **🌐 Image Generation Compatibility Issue**: Rectified image generation compatibility issue with third-party APIs. +- **📱 iOS PWA Icon Fix**: Corrected iOS PWA home screen icon shape. +- **🔍 Scroll Gesture Bug**: Adjusted gesture sensitivity to prevent accidental activation when scrolling through code on mobile; now requires scrolling from the leftmost side to open the sidebar. + +### Changed + +- **🔄 Unlimited Context Length**: Advanced settings now allow unlimited max context length (previously limited to 16000). +- **👑 Super Admin Assignment**: The first signup is automatically assigned a super admin role, unchangeable by other admins. +- **🛡️ Admin User Restrictions**: User action buttons from the admin panel are now disabled for users with admin roles. +- **🔝 Default Model Selector**: Set as default model option now exclusively available on the landing page. + ## [0.1.122] - 2024-04-27 ### Added diff --git a/Dockerfile b/Dockerfile index c43cd8cb3..faee1ac32 100644 --- a/Dockerfile +++ b/Dockerfile @@ -51,7 +51,8 @@ ENV OLLAMA_BASE_URL="/ollama" \ ENV OPENAI_API_KEY="" \ WEBUI_SECRET_KEY="" \ SCARF_NO_ANALYTICS=true \ - DO_NOT_TRACK=true + DO_NOT_TRACK=true \ + ANONYMIZED_TELEMETRY=false # Use locally bundled version of the LiteLLM cost map json # to avoid repetitive startup connections @@ -74,6 +75,10 @@ ENV HF_HOME="/app/backend/data/cache/embedding/models" WORKDIR /app/backend +ENV HOME /root +RUN mkdir -p $HOME/.cache/chroma +RUN echo -n 00000000-0000-0000-0000-000000000000 > $HOME/.cache/chroma/telemetry_user_id + RUN if [ "$USE_OLLAMA" = "true" ]; then \ apt-get update && \ # Install pandoc and netcat @@ -129,4 +134,4 @@ COPY ./backend . EXPOSE 8080 -CMD [ "bash", "start.sh"] \ No newline at end of file +CMD [ "bash", "start.sh"] diff --git a/backend/apps/images/main.py b/backend/apps/images/main.py index fd72b203c..88cecc940 100644 --- a/backend/apps/images/main.py +++ b/backend/apps/images/main.py @@ -24,6 +24,7 @@ from utils.misc import calculate_sha256 from typing import Optional from pydantic import BaseModel from pathlib import Path +import mimetypes import uuid import base64 import json @@ -315,38 +316,50 @@ class GenerateImageForm(BaseModel): def save_b64_image(b64_str): - image_id = str(uuid.uuid4()) - file_path = IMAGE_CACHE_DIR.joinpath(f"{image_id}.png") - try: - # Split the base64 string to get the actual image data - img_data = base64.b64decode(b64_str) + header, encoded = b64_str.split(",", 1) + mime_type = header.split(";")[0] - # Write the image data to a file + img_data = base64.b64decode(encoded) + + image_id = str(uuid.uuid4()) + image_format = mimetypes.guess_extension(mime_type) + + image_filename = f"{image_id}{image_format}" + file_path = IMAGE_CACHE_DIR / f"{image_filename}" with open(file_path, "wb") as f: f.write(img_data) - - return image_id + return image_filename except Exception as e: - log.error(f"Error saving image: {e}") + log.exception(f"Error saving image: {e}") return None def save_url_image(url): image_id = str(uuid.uuid4()) - file_path = IMAGE_CACHE_DIR.joinpath(f"{image_id}.png") - try: r = requests.get(url) r.raise_for_status() + if r.headers["content-type"].split("/")[0] == "image": - with open(file_path, "wb") as image_file: - image_file.write(r.content) + mime_type = r.headers["content-type"] + image_format = mimetypes.guess_extension(mime_type) + + if not image_format: + raise ValueError("Could not determine image type from MIME type") + + file_path = IMAGE_CACHE_DIR.joinpath(f"{image_id}{image_format}") + with open(file_path, "wb") as image_file: + for chunk in r.iter_content(chunk_size=8192): + image_file.write(chunk) + return image_id, image_format + else: + log.error(f"Url does not point to an image.") + return None, None - return image_id except Exception as e: log.exception(f"Error saving image: {e}") - return None + return None, None @app.post("/generations") @@ -385,8 +398,8 @@ def generate_image( images = [] for image in res["data"]: - image_id = save_b64_image(image["b64_json"]) - images.append({"url": f"/cache/image/generations/{image_id}.png"}) + image_filename = save_b64_image(image["b64_json"]) + images.append({"url": f"/cache/image/generations/{image_filename}"}) file_body_path = IMAGE_CACHE_DIR.joinpath(f"{image_id}.json") with open(file_body_path, "w") as f: @@ -422,8 +435,10 @@ def generate_image( images = [] for image in res["data"]: - image_id = save_url_image(image["url"]) - images.append({"url": f"/cache/image/generations/{image_id}.png"}) + image_id, image_format = save_url_image(image["url"]) + images.append( + {"url": f"/cache/image/generations/{image_id}{image_format}"} + ) file_body_path = IMAGE_CACHE_DIR.joinpath(f"{image_id}.json") with open(file_body_path, "w") as f: @@ -460,8 +475,8 @@ def generate_image( images = [] for image in res["images"]: - image_id = save_b64_image(image) - images.append({"url": f"/cache/image/generations/{image_id}.png"}) + image_filename = save_b64_image(image) + images.append({"url": f"/cache/image/generations/{image_filename}"}) file_body_path = IMAGE_CACHE_DIR.joinpath(f"{image_id}.json") with open(file_body_path, "w") as f: diff --git a/backend/apps/rag/main.py b/backend/apps/rag/main.py index f147152b7..a3e3c1134 100644 --- a/backend/apps/rag/main.py +++ b/backend/apps/rag/main.py @@ -28,6 +28,7 @@ from langchain_community.document_loaders import ( UnstructuredXMLLoader, UnstructuredRSTLoader, UnstructuredExcelLoader, + YoutubeLoader, ) from langchain.text_splitter import RecursiveCharacterTextSplitter @@ -181,7 +182,7 @@ class CollectionNameForm(BaseModel): collection_name: Optional[str] = "test" -class StoreWebForm(CollectionNameForm): +class UrlForm(CollectionNameForm): url: str @@ -456,8 +457,32 @@ def query_collection_handler( ) +@app.post("/youtube") +def store_youtube_video(form_data: UrlForm, user=Depends(get_current_user)): + try: + loader = YoutubeLoader.from_youtube_url(form_data.url, add_video_info=False) + data = loader.load() + + collection_name = form_data.collection_name + if collection_name == "": + collection_name = calculate_sha256_string(form_data.url)[:63] + + store_data_in_vector_db(data, collection_name, overwrite=True) + return { + "status": True, + "collection_name": collection_name, + "filename": form_data.url, + } + except Exception as e: + log.exception(e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(e), + ) + + @app.post("/web") -def store_web(form_data: StoreWebForm, user=Depends(get_current_user)): +def store_web(form_data: UrlForm, user=Depends(get_current_user)): # "https://www.gutenberg.org/files/1727/1727-h/1727-h.htm" try: loader = get_web_loader(form_data.url) diff --git a/backend/apps/rag/utils.py b/backend/apps/rag/utils.py index 10f1f7bed..b1142e855 100644 --- a/backend/apps/rag/utils.py +++ b/backend/apps/rag/utils.py @@ -53,7 +53,7 @@ def query_doc_with_hybrid_search( embedding_function, k: int, reranking_function, - r: int, + r: float, ): try: collection = CHROMA_CLIENT.get_collection(name=collection_name) @@ -321,8 +321,12 @@ def rag_messages( context_string = "" for context in relevant_contexts: - items = context["documents"][0] - context_string += "\n\n".join(items) + try: + if "documents" in context: + items = [item for item in context["documents"][0] if item is not None] + context_string += "\n\n".join(items) + except Exception as e: + log.exception(e) context_string = context_string.strip() ra_content = rag_template( diff --git a/backend/apps/web/models/auths.py b/backend/apps/web/models/auths.py index 9c4e5ffed..dfa0c4395 100644 --- a/backend/apps/web/models/auths.py +++ b/backend/apps/web/models/auths.py @@ -89,6 +89,10 @@ class SignupForm(BaseModel): profile_image_url: Optional[str] = "/user.png" +class AddUserForm(SignupForm): + role: Optional[str] = "pending" + + class AuthsTable: def __init__(self, db): self.db = db diff --git a/backend/apps/web/models/users.py b/backend/apps/web/models/users.py index 1a127a778..450dd9187 100644 --- a/backend/apps/web/models/users.py +++ b/backend/apps/web/models/users.py @@ -123,6 +123,13 @@ class UsersTable: def get_num_users(self) -> Optional[int]: return User.select().count() + def get_first_user(self) -> UserModel: + try: + user = User.select().order_by(User.created_at).first() + return UserModel(**model_to_dict(user)) + except: + return None + def update_user_role_by_id(self, id: str, role: str) -> Optional[UserModel]: try: query = User.update(role=role).where(User.id == id) diff --git a/backend/apps/web/routers/auths.py b/backend/apps/web/routers/auths.py index 321b26034..01fddb73a 100644 --- a/backend/apps/web/routers/auths.py +++ b/backend/apps/web/routers/auths.py @@ -1,16 +1,19 @@ import logging -from fastapi import Request +from fastapi import Request, UploadFile, File from fastapi import Depends, HTTPException, status from fastapi import APIRouter from pydantic import BaseModel import re import uuid +import csv + from apps.web.models.auths import ( SigninForm, SignupForm, + AddUserForm, UpdateProfileForm, UpdatePasswordForm, UserResponse, @@ -205,6 +208,51 @@ async def signup(request: Request, form_data: SignupForm): raise HTTPException(500, detail=ERROR_MESSAGES.DEFAULT(err)) +############################ +# AddUser +############################ + + +@router.post("/add", response_model=SigninResponse) +async def add_user(form_data: AddUserForm, user=Depends(get_admin_user)): + + if not validate_email_format(form_data.email.lower()): + raise HTTPException( + status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT + ) + + if Users.get_user_by_email(form_data.email.lower()): + raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN) + + try: + + print(form_data) + hashed = get_password_hash(form_data.password) + user = Auths.insert_new_auth( + form_data.email.lower(), + hashed, + form_data.name, + form_data.profile_image_url, + form_data.role, + ) + + if user: + token = create_token(data={"id": user.id}) + return { + "token": token, + "token_type": "Bearer", + "id": user.id, + "email": user.email, + "name": user.name, + "role": user.role, + "profile_image_url": user.profile_image_url, + } + else: + raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR) + except Exception as err: + raise HTTPException(500, detail=ERROR_MESSAGES.DEFAULT(err)) + + ############################ # ToggleSignUp ############################ diff --git a/backend/apps/web/routers/users.py b/backend/apps/web/routers/users.py index 6f1020ec7..59f6c21b7 100644 --- a/backend/apps/web/routers/users.py +++ b/backend/apps/web/routers/users.py @@ -58,7 +58,7 @@ async def update_user_permissions( @router.post("/update/role", response_model=Optional[UserModel]) async def update_user_role(form_data: UserRoleUpdateForm, user=Depends(get_admin_user)): - if user.id != form_data.id: + if user.id != form_data.id and form_data.id != Users.get_first_user().id: return Users.update_user_role_by_id(form_data.id, form_data.role) raise HTTPException( diff --git a/backend/config.py b/backend/config.py index 09880b12e..9208a845c 100644 --- a/backend/config.py +++ b/backend/config.py @@ -367,6 +367,17 @@ DEFAULT_PROMPT_SUGGESTIONS = ( "title": ["Show me a code snippet", "of a website's sticky header"], "content": "Show me a code snippet of a website's sticky header in CSS and JavaScript.", }, + { + "title": [ + "Explain options trading", + "if I'm familiar with buying and selling stocks", + ], + "content": "Explain options trading in simple terms if I'm familiar with buying and selling stocks.", + }, + { + "title": ["Overcome procrastination", "give me tips"], + "content": "Could you start by asking me about instances when I procrastinate the most and then give me some suggestions to overcome it?", + }, ] ) diff --git a/backend/data/config.json b/backend/data/config.json index 604ffb036..6c0ad2b9f 100644 --- a/backend/data/config.json +++ b/backend/data/config.json @@ -18,6 +18,18 @@ { "title": ["Show me a code snippet", "of a website's sticky header"], "content": "Show me a code snippet of a website's sticky header in CSS and JavaScript." + }, + { + "title": ["Explain options trading", "if I'm familiar with buying and selling stocks"], + "content": "Explain options trading in simple terms if I'm familiar with buying and selling stocks." + }, + { + "title": ["Overcome procrastination", "give me tips"], + "content": "Could you start by asking me about instances when I procrastinate the most and then give me some suggestions to overcome it?" + }, + { + "title": ["Grammar check", "rewrite it for better readability "], + "content": "Check the following sentence for grammar and clarity: \"[sentence]\". Rewrite it for better readability while maintaining its original meaning." } ] } diff --git a/backend/main.py b/backend/main.py index 91cce711b..e37ac324b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -311,7 +311,7 @@ async def get_manifest_json(): "background_color": "#343541", "theme_color": "#343541", "orientation": "portrait-primary", - "icons": [{"src": "/favicon.png", "type": "image/png", "sizes": "844x884"}], + "icons": [{"src": "/static/logo.png", "type": "image/png", "sizes": "500x500"}], } diff --git a/backend/requirements.txt b/backend/requirements.txt index eb509c6ed..ce01cf508 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,60 +1,62 @@ -fastapi -uvicorn[standard] -pydantic -python-multipart +fastapi==0.109.2 +uvicorn[standard]==0.22.0 +pydantic==2.7.1 +python-multipart==0.0.9 -flask -flask_cors +Flask==3.0.3 +Flask-Cors==4.0.0 -python-socketio -python-jose -passlib[bcrypt] -uuid +python-socketio==5.11.2 +python-jose==3.3.0 +passlib[bcrypt]==1.7.4 +uuid==1.30 -requests -aiohttp -peewee -peewee-migrate -psycopg2-binary -pymysql -bcrypt +requests==2.31.0 +aiohttp==3.9.5 +peewee==3.17.3 +peewee-migrate==1.12.2 +psycopg2-binary==2.9.9 +PyMySQL==1.1.0 +bcrypt==4.1.2 litellm==1.35.28 litellm[proxy]==1.35.28 -boto3 +boto3==1.34.95 -argon2-cffi -apscheduler -google-generativeai +argon2-cffi==23.1.0 +APScheduler==3.10.4 +google-generativeai==0.5.2 -langchain -langchain-chroma -langchain-community -fake_useragent -chromadb -sentence_transformers -pypdf -docx2txt -unstructured -markdown -pypandoc -pandas -openpyxl -pyxlsb -xlrd -validators +langchain==0.1.16 +langchain-community==0.0.34 +langchain-chroma==0.1.0 -opencv-python-headless -rapidocr-onnxruntime +fake-useragent==1.5.1 +chromadb==0.4.24 +sentence-transformers==2.7.0 +pypdf==4.2.0 +docx2txt==0.8 +unstructured==0.11.8 +Markdown==3.6 +pypandoc==1.13 +pandas==2.2.2 +openpyxl==3.1.2 +pyxlsb==1.0.10 +xlrd==2.0.1 +validators==0.28.1 -fpdf2 -rank_bm25 +opencv-python-headless==4.9.0.80 +rapidocr-onnxruntime==1.2.3 -faster-whisper +fpdf2==2.7.8 +rank-bm25==0.2.2 -PyJWT -pyjwt[crypto] +faster-whisper==1.0.1 -black -langfuse +PyJWT==2.8.0 +PyJWT[crypto]==2.8.0 + +black==24.4.2 +langfuse==2.27.3 +youtube-transcript-api diff --git a/backend/static/logo.png b/backend/static/logo.png new file mode 100644 index 000000000..519af1db6 Binary files /dev/null and b/backend/static/logo.png differ diff --git a/backend/static/user-import.csv b/backend/static/user-import.csv new file mode 100644 index 000000000..918a92aad --- /dev/null +++ b/backend/static/user-import.csv @@ -0,0 +1 @@ +Name,Email,Password,Role diff --git a/backend/utils/logo.png b/backend/utils/logo.png new file mode 100644 index 000000000..519af1db6 Binary files /dev/null and b/backend/utils/logo.png differ diff --git a/package-lock.json b/package-lock.json index 913c55b78..d3a8b513c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { "name": "open-webui", - "version": "0.1.122", + "version": "0.1.123", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.1.122", + "version": "0.1.123", "dependencies": { "@sveltejs/adapter-node": "^1.3.1", "async": "^3.2.5", "bits-ui": "^0.19.7", "dayjs": "^1.11.10", + "eventsource-parser": "^1.1.2", "file-saver": "^2.0.5", "highlight.js": "^11.9.0", "i18next": "^23.10.0", @@ -3167,6 +3168,14 @@ "integrity": "sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==", "dev": true }, + "node_modules/eventsource-parser": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-1.1.2.tgz", + "integrity": "sha512-v0eOBUbiaFojBu2s2NPBfYUoRR9GjcDNvCXVaqEf5vVfpIAh9f8RCo4vXTP8c63QRKCFwoLpMpTdPwwhEKVgzA==", + "engines": { + "node": ">=14.18" + } + }, "node_modules/execa": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", diff --git a/package.json b/package.json index c38120727..e80e88efc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.1.122", + "version": "0.1.123", "private": true, "scripts": { "dev": "vite dev --host", @@ -49,6 +49,7 @@ "async": "^3.2.5", "bits-ui": "^0.19.7", "dayjs": "^1.11.10", + "eventsource-parser": "^1.1.2", "file-saver": "^2.0.5", "highlight.js": "^11.9.0", "i18next": "^23.10.0", diff --git a/src/lib/apis/auths/index.ts b/src/lib/apis/auths/index.ts index efeeff333..26feb29b6 100644 --- a/src/lib/apis/auths/index.ts +++ b/src/lib/apis/auths/index.ts @@ -95,6 +95,45 @@ export const userSignUp = async ( return res; }; +export const addUser = async ( + token: string, + name: string, + email: string, + password: string, + role: string = 'pending' +) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/auths/add`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + }, + body: JSON.stringify({ + name: name, + email: email, + password: password, + role: role + }) + }) + .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; +}; + export const updateUserProfile = async (token: string, name: string, profileImageUrl: string) => { let error = null; diff --git a/src/lib/apis/rag/index.ts b/src/lib/apis/rag/index.ts index 5dfa3d3aa..a9d163f87 100644 --- a/src/lib/apis/rag/index.ts +++ b/src/lib/apis/rag/index.ts @@ -221,6 +221,37 @@ export const uploadWebToVectorDB = async (token: string, collection_name: string return res; }; +export const uploadYoutubeTranscriptionToVectorDB = async (token: string, url: string) => { + let error = null; + + const res = await fetch(`${RAG_API_BASE_URL}/youtube`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + url: url + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.log(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const queryDoc = async ( token: string, collection_name: string, diff --git a/src/lib/apis/streaming/index.ts b/src/lib/apis/streaming/index.ts index aad42b2b6..a72dbe47d 100644 --- a/src/lib/apis/streaming/index.ts +++ b/src/lib/apis/streaming/index.ts @@ -1,15 +1,22 @@ +import { EventSourceParserStream } from 'eventsource-parser/stream'; +import type { ParsedEvent } from 'eventsource-parser'; + type TextStreamUpdate = { done: boolean; value: string; }; -// createOpenAITextStream takes a ReadableStreamDefaultReader from an SSE response, +// createOpenAITextStream takes a responseBody with a SSE response, // and returns an async generator that emits delta updates with large deltas chunked into random sized chunks export async function createOpenAITextStream( - messageStream: ReadableStreamDefaultReader, + responseBody: ReadableStream, splitLargeDeltas: boolean ): Promise> { - let iterator = openAIStreamToIterator(messageStream); + const eventStream = responseBody + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new EventSourceParserStream()) + .getReader(); + let iterator = openAIStreamToIterator(eventStream); if (splitLargeDeltas) { iterator = streamLargeDeltasAsRandomChunks(iterator); } @@ -17,7 +24,7 @@ export async function createOpenAITextStream( } async function* openAIStreamToIterator( - reader: ReadableStreamDefaultReader + reader: ReadableStreamDefaultReader ): AsyncGenerator { while (true) { const { value, done } = await reader.read(); @@ -25,31 +32,22 @@ async function* openAIStreamToIterator( yield { done: true, value: '' }; break; } - const lines = value.split('\n'); - for (let line of lines) { - if (line.endsWith('\r')) { - // Remove trailing \r - line = line.slice(0, -1); - } - if (line !== '') { - console.log(line); - if (line === 'data: [DONE]') { - yield { done: true, value: '' }; - } else if (line.startsWith(':')) { - // Events starting with : are comments https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#event_stream_format - // OpenRouter sends heartbeats like ": OPENROUTER PROCESSING" - continue; - } else { - try { - const data = JSON.parse(line.replace(/^data: /, '')); - console.log(data); + if (!value) { + continue; + } + const data = value.data; + if (data.startsWith('[DONE]')) { + yield { done: true, value: '' }; + break; + } - yield { done: false, value: data.choices?.[0]?.delta?.content ?? '' }; - } catch (e) { - console.error('Error extracting delta from SSE event:', e); - } - } - } + try { + const parsedData = JSON.parse(data); + console.log(parsedData); + + yield { done: false, value: parsedData.choices?.[0]?.delta?.content ?? '' }; + } catch (e) { + console.error('Error extracting delta from SSE event:', e); } } } diff --git a/src/lib/components/ChangelogModal.svelte b/src/lib/components/ChangelogModal.svelte index fbfa6b0a0..1113b25e4 100644 --- a/src/lib/components/ChangelogModal.svelte +++ b/src/lib/components/ChangelogModal.svelte @@ -22,7 +22,7 @@ -
+
{$i18n.t('What’s New in')} @@ -59,7 +59,7 @@
-
+
{#if changelog} diff --git a/src/lib/components/admin/AddUserModal.svelte b/src/lib/components/admin/AddUserModal.svelte new file mode 100644 index 000000000..4b505d022 --- /dev/null +++ b/src/lib/components/admin/AddUserModal.svelte @@ -0,0 +1,334 @@ + + + +
+
+
{$i18n.t('Add User')}
+ +
+ +
+
+
{ + submitHandler(); + }} + > +
+ + + +
+
+ {#if tab === ''} +
+
{$i18n.t('Role')}
+ +
+ +
+
+ +
+
{$i18n.t('Name')}
+ +
+ +
+
+ +
+ +
+
{$i18n.t('Email')}
+ +
+ +
+
+ +
+
{$i18n.t('Password')}
+ +
+ +
+
+ {:else if tab === 'import'} +
+
+ + + +
+ +
+ ⓘ {$i18n.t( + 'Ensure your CSV file includes 4 columns in this order: Name, Email, Password, Role.' + )} + + Click here to download user import template file. + +
+
+ {/if} +
+ +
+ +
+
+
+
+
+
+ + diff --git a/src/lib/components/admin/SettingsModal.svelte b/src/lib/components/admin/SettingsModal.svelte index 7b7262146..923ab576a 100644 --- a/src/lib/components/admin/SettingsModal.svelte +++ b/src/lib/components/admin/SettingsModal.svelte @@ -15,7 +15,7 @@
-
+
{$i18n.t('Admin Settings')}
-
import { toast } from 'svelte-sonner'; import { onMount, tick, getContext } from 'svelte'; - import { settings } from '$lib/stores'; + import { modelfiles, settings, showSidebar } from '$lib/stores'; import { blobToFile, calculateSHA256, findWordIndices } from '$lib/utils'; + import { + uploadDocToVectorDB, + uploadWebToVectorDB, + uploadYoutubeTranscriptionToVectorDB + } from '$lib/apis/rag'; + import { SUPPORTED_FILE_TYPE, SUPPORTED_FILE_EXTENSIONS, WEBUI_BASE_URL } from '$lib/constants'; + + import { transcribeAudio } from '$lib/apis/audio'; + import Prompts from './MessageInput/PromptCommands.svelte'; import Suggestions from './MessageInput/Suggestions.svelte'; - import { uploadDocToVectorDB, uploadWebToVectorDB } from '$lib/apis/rag'; import AddFilesPlaceholder from '../AddFilesPlaceholder.svelte'; - import { SUPPORTED_FILE_TYPE, SUPPORTED_FILE_EXTENSIONS } from '$lib/constants'; import Documents from './MessageInput/Documents.svelte'; import Models from './MessageInput/Models.svelte'; - import { transcribeAudio } from '$lib/apis/audio'; import Tooltip from '../common/Tooltip.svelte'; - import Page from '../../../routes/(app)/+page.svelte'; + import XMark from '$lib/components/icons/XMark.svelte'; const i18n = getContext('i18n'); export let submitPrompt: Function; export let stopResponse: Function; - export let suggestionPrompts = []; export let autoScroll = true; + export let selectedModel = ''; + let chatTextAreaElement: HTMLTextAreaElement; let filesInputElement; @@ -291,7 +298,36 @@ } }; + const uploadYoutubeTranscription = async (url) => { + console.log(url); + + const doc = { + type: 'doc', + name: url, + collection_name: '', + upload_status: false, + url: url, + error: '' + }; + + try { + files = [...files, doc]; + const res = await uploadYoutubeTranscriptionToVectorDB(localStorage.token, url); + + if (res) { + doc.upload_status = true; + doc.collection_name = res.collection_name; + files = files; + } + } catch (e) { + // Remove the failed doc from the files array + files = files.filter((f) => f.name !== url); + toast.error(e); + } + }; + onMount(() => { + console.log(document.getElementById('sidebar')); window.setTimeout(() => chatTextAreaElement?.focus(), 0); const dropZone = document.querySelector('body'); @@ -390,142 +426,252 @@
{/if} -
-
-
-
- {#if autoScroll === false && messages.length > 0} -
- -
- {/if} -
+ + + + +
+ {/if} +
+ +
+ {#if prompt.charAt(0) === '/'} + + {:else if prompt.charAt(0) === '#'} + { + console.log(e); + uploadYoutubeTranscription(e.detail); + }} + on:url={(e) => { + console.log(e); + uploadWeb(e.detail); + }} + on:select={(e) => { + console.log(e); + files = [ + ...files, + { + type: e?.detail?.type ?? 'doc', + ...e.detail, + upload_status: true + } + ]; + }} + /> + {/if} -
- {#if prompt.charAt(0) === '/'} - - {:else if prompt.charAt(0) === '#'} - { - console.log(e); - uploadWeb(e.detail); - }} - on:select={(e) => { - console.log(e); - files = [ - ...files, - { - type: e?.detail?.type ?? 'doc', - ...e.detail, - upload_status: true - } - ]; - }} - /> - {:else if prompt.charAt(0) === '@'} { + selectedModel = e.detail; + chatTextAreaElement?.focus(); + }} /> - {/if} - {#if messages.length == 0 && suggestionPrompts.length !== 0} - - {/if} + {#if selectedModel !== ''} +
+
+ model profile modelfile.tagName === selectedModel.id) + ?.imageUrl ?? + ($i18n.language === 'dg-DG' + ? `/doge.png` + : `${WEBUI_BASE_URL}/static/favicon.png`)} + /> +
+ Talking to {selectedModel.name} +
+
+
+ +
+
+ {/if} +
-
-
-
-
- { - if (inputFiles && inputFiles.length > 0) { - const _inputFiles = Array.from(inputFiles); - _inputFiles.forEach((file) => { - if (['image/gif', 'image/jpeg', 'image/png'].includes(file['type'])) { - let reader = new FileReader(); - reader.onload = (event) => { - files = [ - ...files, - { - type: 'image', - url: `${event.target.result}` - } - ]; - inputFiles = null; + +
+
+
+ { + if (inputFiles && inputFiles.length > 0) { + const _inputFiles = Array.from(inputFiles); + _inputFiles.forEach((file) => { + if (['image/gif', 'image/jpeg', 'image/png'].includes(file['type'])) { + let reader = new FileReader(); + reader.onload = (event) => { + files = [ + ...files, + { + type: 'image', + url: `${event.target.result}` + } + ]; + inputFiles = null; + filesInputElement.value = ''; + }; + reader.readAsDataURL(file); + } else if ( + SUPPORTED_FILE_TYPE.includes(file['type']) || + SUPPORTED_FILE_EXTENSIONS.includes(file.name.split('.').at(-1)) + ) { + uploadDoc(file); filesInputElement.value = ''; - }; - reader.readAsDataURL(file); - } else if ( - SUPPORTED_FILE_TYPE.includes(file['type']) || - SUPPORTED_FILE_EXTENSIONS.includes(file.name.split('.').at(-1)) - ) { - uploadDoc(file); - filesInputElement.value = ''; - } else { - toast.error( - $i18n.t( - `Unknown File Type '{{file_type}}', but accepting and treating as plain text`, - { file_type: file['type'] } - ) - ); - uploadDoc(file); - filesInputElement.value = ''; - } - }); - } else { - toast.error($i18n.t(`File not found.`)); - } - }} - /> -
{ - submitPrompt(prompt, user); - }} - > - {#if files.length > 0} -
- {#each files as file, fileIdx} -
- {#if file.type === 'image'} - input - {:else if file.type === 'doc'} -
-
- {#if file.upload_status} + } else { + toast.error( + $i18n.t( + `Unknown File Type '{{file_type}}', but accepting and treating as plain text`, + { file_type: file['type'] } + ) + ); + uploadDoc(file); + filesInputElement.value = ''; + } + }); + } else { + toast.error($i18n.t(`File not found.`)); + } + }} + /> + { + submitPrompt(prompt, user); + }} + > + {#if files.length > 0} +
+ {#each files as file, fileIdx} +
+ {#if file.type === 'image'} + input + {:else if file.type === 'doc'} +
+
+ {#if file.upload_status} + + + + + {:else} + + {/if} +
+ +
+
+ {file.name} +
+ +
{$i18n.t('Document')}
+
+
+ {:else if file.type === 'collection'} +
+
- {:else} +
+ +
+
+ {file?.title ?? `#${file.name}`} +
+ +
{$i18n.t('Collection')}
+
+
+ {/if} + +
+ +
+
+ {/each} +
+ {/if} + +
+ {#if fileUploadEnabled} +
+ + + +
+ {/if} + +