diff --git a/.env.example b/.env.example index 05854cd0..2d782fce 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 00000000..3dd5eaf6 --- /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/.github/workflows/format-build-frontend.yaml b/.github/workflows/format-build-frontend.yaml index 121266bf..eec1305e 100644 --- a/.github/workflows/format-build-frontend.yaml +++ b/.github/workflows/format-build-frontend.yaml @@ -29,6 +29,9 @@ jobs: - name: Format Frontend run: npm run format + - name: Run i18next + run: npm run i18n:parse + - name: Check for Changes After Format run: git diff --exit-code diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index dfceaacc..32c33165 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -53,3 +53,134 @@ jobs: name: compose-logs path: compose-logs.txt if-no-files-found: ignore + + migration_test: + name: Run Migration Tests + runs-on: ubuntu-latest + services: + postgres: + image: postgres + env: + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 +# mysql: +# image: mysql +# env: +# MYSQL_ROOT_PASSWORD: mysql +# MYSQL_DATABASE: mysql +# options: >- +# --health-cmd "mysqladmin ping -h localhost" +# --health-interval 10s +# --health-timeout 5s +# --health-retries 5 +# ports: +# - 3306:3306 + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Set up uv + uses: yezz123/setup-uv@v4 + with: + uv-venv: venv + + - name: Activate virtualenv + run: | + . venv/bin/activate + echo PATH=$PATH >> $GITHUB_ENV + + - name: Install dependencies + run: | + uv pip install -r backend/requirements.txt + + - name: Test backend with SQLite + id: sqlite + env: + WEBUI_SECRET_KEY: secret-key + GLOBAL_LOG_LEVEL: debug + run: | + cd backend + uvicorn main:app --port "8080" --forwarded-allow-ips '*' & + UVICORN_PID=$! + # Wait up to 20 seconds for the server to start + for i in {1..20}; do + curl -s http://localhost:8080/api/config > /dev/null && break + sleep 1 + if [ $i -eq 20 ]; then + echo "Server failed to start" + kill -9 $UVICORN_PID + exit 1 + fi + done + # Check that the server is still running after 5 seconds + sleep 5 + if ! kill -0 $UVICORN_PID; then + echo "Server has stopped" + exit 1 + fi + + + - name: Test backend with Postgres + if: success() || steps.sqlite.conclusion == 'failure' + env: + WEBUI_SECRET_KEY: secret-key + GLOBAL_LOG_LEVEL: debug + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres + run: | + cd backend + uvicorn main:app --port "8081" --forwarded-allow-ips '*' & + UVICORN_PID=$! + # Wait up to 20 seconds for the server to start + for i in {1..20}; do + curl -s http://localhost:8081/api/config > /dev/null && break + sleep 1 + if [ $i -eq 20 ]; then + echo "Server failed to start" + kill -9 $UVICORN_PID + exit 1 + fi + done + # Check that the server is still running after 5 seconds + sleep 5 + if ! kill -0 $UVICORN_PID; then + echo "Server has stopped" + exit 1 + fi + +# - name: Test backend with MySQL +# if: success() || steps.sqlite.conclusion == 'failure' || steps.postgres.conclusion == 'failure' +# env: +# WEBUI_SECRET_KEY: secret-key +# GLOBAL_LOG_LEVEL: debug +# DATABASE_URL: mysql://root:mysql@localhost:3306/mysql +# run: | +# cd backend +# uvicorn main:app --port "8083" --forwarded-allow-ips '*' & +# UVICORN_PID=$! +# # Wait up to 20 seconds for the server to start +# for i in {1..20}; do +# curl -s http://localhost:8083/api/config > /dev/null && break +# sleep 1 +# if [ $i -eq 20 ]; then +# echo "Server failed to start" +# kill -9 $UVICORN_PID +# exit 1 +# fi +# done +# # Check that the server is still running after 5 seconds +# sleep 5 +# if ! kill -0 $UVICORN_PID; then +# echo "Server has stopped" +# exit 1 +# fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 62e960ab..0567919f 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 c43cd8cb..faee1ac3 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 fd72b203..88cecc94 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/openai/main.py b/backend/apps/openai/main.py index d9e37830..b5d1e68d 100644 --- a/backend/apps/openai/main.py +++ b/backend/apps/openai/main.py @@ -171,6 +171,7 @@ async def fetch_url(url, key): def merge_models_lists(model_lists): + log.info(f"merge_models_lists {model_lists}") merged_list = [] for idx, models in enumerate(model_lists): @@ -199,14 +200,16 @@ async def get_all_models(): ] responses = await asyncio.gather(*tasks) + log.info(f"get_all_models:responses() {responses}") + models = { "data": merge_models_lists( list( map( lambda response: ( response["data"] - if response and "data" in response - else None + if (response and "data" in response) + else (response if isinstance(response, list) else None) ), responses, ) diff --git a/backend/apps/rag/main.py b/backend/apps/rag/main.py index a33a2965..a3e3c113 100644 --- a/backend/apps/rag/main.py +++ b/backend/apps/rag/main.py @@ -28,9 +28,15 @@ from langchain_community.document_loaders import ( UnstructuredXMLLoader, UnstructuredRSTLoader, UnstructuredExcelLoader, + YoutubeLoader, ) from langchain.text_splitter import RecursiveCharacterTextSplitter +import validators +import urllib.parse +import socket + + from pydantic import BaseModel from typing import Optional import mimetypes @@ -84,6 +90,7 @@ from config import ( CHUNK_SIZE, CHUNK_OVERLAP, RAG_TEMPLATE, + ENABLE_LOCAL_WEB_FETCH, ) from constants import ERROR_MESSAGES @@ -175,7 +182,7 @@ class CollectionNameForm(BaseModel): collection_name: Optional[str] = "test" -class StoreWebForm(CollectionNameForm): +class UrlForm(CollectionNameForm): url: str @@ -391,16 +398,16 @@ def query_doc_handler( return query_doc_with_hybrid_search( collection_name=form_data.collection_name, query=form_data.query, - embeddings_function=app.state.EMBEDDING_FUNCTION, - reranking_function=app.state.sentence_transformer_rf, + embedding_function=app.state.EMBEDDING_FUNCTION, k=form_data.k if form_data.k else app.state.TOP_K, + reranking_function=app.state.sentence_transformer_rf, r=form_data.r if form_data.r else app.state.RELEVANCE_THRESHOLD, ) else: return query_doc( collection_name=form_data.collection_name, query=form_data.query, - embeddings_function=app.state.EMBEDDING_FUNCTION, + embedding_function=app.state.EMBEDDING_FUNCTION, k=form_data.k if form_data.k else app.state.TOP_K, ) except Exception as e: @@ -429,16 +436,16 @@ def query_collection_handler( return query_collection_with_hybrid_search( collection_names=form_data.collection_names, query=form_data.query, - embeddings_function=app.state.EMBEDDING_FUNCTION, - reranking_function=app.state.sentence_transformer_rf, + embedding_function=app.state.EMBEDDING_FUNCTION, k=form_data.k if form_data.k else app.state.TOP_K, + reranking_function=app.state.sentence_transformer_rf, r=form_data.r if form_data.r else app.state.RELEVANCE_THRESHOLD, ) else: return query_collection( collection_names=form_data.collection_names, query=form_data.query, - embeddings_function=app.state.EMBEDDING_FUNCTION, + embedding_function=app.state.EMBEDDING_FUNCTION, k=form_data.k if form_data.k else app.state.TOP_K, ) @@ -450,11 +457,10 @@ def query_collection_handler( ) -@app.post("/web") -def store_web(form_data: StoreWebForm, user=Depends(get_current_user)): - # "https://www.gutenberg.org/files/1727/1727-h/1727-h.htm" +@app.post("/youtube") +def store_youtube_video(form_data: UrlForm, user=Depends(get_current_user)): try: - loader = WebBaseLoader(form_data.url) + loader = YoutubeLoader.from_youtube_url(form_data.url, add_video_info=False) data = loader.load() collection_name = form_data.collection_name @@ -475,6 +481,62 @@ def store_web(form_data: StoreWebForm, user=Depends(get_current_user)): ) +@app.post("/web") +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) + 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), + ) + + +def get_web_loader(url: str): + # Check if the URL is valid + if isinstance(validators.url(url), validators.ValidationError): + raise ValueError(ERROR_MESSAGES.INVALID_URL) + if not ENABLE_LOCAL_WEB_FETCH: + # Local web fetch is disabled, filter out any URLs that resolve to private IP addresses + parsed_url = urllib.parse.urlparse(url) + # Get IPv4 and IPv6 addresses + ipv4_addresses, ipv6_addresses = resolve_hostname(parsed_url.hostname) + # Check if any of the resolved addresses are private + # This is technically still vulnerable to DNS rebinding attacks, as we don't control WebBaseLoader + for ip in ipv4_addresses: + if validators.ipv4(ip, private=True): + raise ValueError(ERROR_MESSAGES.INVALID_URL) + for ip in ipv6_addresses: + if validators.ipv6(ip, private=True): + raise ValueError(ERROR_MESSAGES.INVALID_URL) + return WebBaseLoader(url) + + +def resolve_hostname(hostname): + # Get address information + addr_info = socket.getaddrinfo(hostname, None) + + # Extract IP addresses from address information + ipv4_addresses = [info[4][0] for info in addr_info if info[0] == socket.AF_INET] + ipv6_addresses = [info[4][0] for info in addr_info if info[0] == socket.AF_INET6] + + return ipv4_addresses, ipv6_addresses + + def store_data_in_vector_db(data, collection_name, overwrite: bool = False) -> bool: text_splitter = RecursiveCharacterTextSplitter( diff --git a/backend/apps/rag/utils.py b/backend/apps/rag/utils.py index eb9d5c84..b1142e85 100644 --- a/backend/apps/rag/utils.py +++ b/backend/apps/rag/utils.py @@ -35,6 +35,7 @@ def query_doc( try: collection = CHROMA_CLIENT.get_collection(name=collection_name) query_embeddings = embedding_function(query) + result = collection.query( query_embeddings=[query_embeddings], n_results=k, @@ -52,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) @@ -76,9 +77,9 @@ def query_doc_with_hybrid_search( compressor = RerankCompressor( embedding_function=embedding_function, + top_n=k, reranking_function=reranking_function, r_score=r, - top_n=k, ) compression_retriever = ContextualCompressionRetriever( @@ -91,6 +92,7 @@ def query_doc_with_hybrid_search( "documents": [[d.page_content for d in result]], "metadatas": [[d.metadata for d in result]], } + log.info(f"query_doc_with_hybrid_search:result {result}") return result except Exception as e: @@ -167,7 +169,6 @@ def query_collection_with_hybrid_search( reranking_function, r: float, ): - results = [] for collection_name in collection_names: try: @@ -182,7 +183,6 @@ def query_collection_with_hybrid_search( results.append(result) except: pass - return merge_and_sort_query_results(results, k=k, reverse=True) @@ -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( @@ -443,13 +447,15 @@ class ChromaRetriever(BaseRetriever): metadatas = results["metadatas"][0] documents = results["documents"][0] - return [ - Document( - metadata=metadatas[idx], - page_content=documents[idx], + results = [] + for idx in range(len(ids)): + results.append( + Document( + metadata=metadatas[idx], + page_content=documents[idx], + ) ) - for idx in range(len(ids)) - ] + return results import operator @@ -465,9 +471,9 @@ from sentence_transformers import util class RerankCompressor(BaseDocumentCompressor): embedding_function: Any + top_n: int reranking_function: Any r_score: float - top_n: int class Config: extra = Extra.forbid @@ -479,7 +485,9 @@ class RerankCompressor(BaseDocumentCompressor): query: str, callbacks: Optional[Callbacks] = None, ) -> Sequence[Document]: - if self.reranking_function: + reranking = self.reranking_function is not None + + if reranking: scores = self.reranking_function.predict( [(query, doc.page_content) for doc in documents] ) @@ -496,9 +504,7 @@ class RerankCompressor(BaseDocumentCompressor): (d, s) for d, s in docs_with_scores if s >= self.r_score ] - reverse = self.reranking_function is not None - result = sorted(docs_with_scores, key=operator.itemgetter(1), reverse=reverse) - + result = sorted(docs_with_scores, key=operator.itemgetter(1), reverse=True) final_results = [] for doc, doc_score in result[: self.top_n]: metadata = doc.metadata diff --git a/backend/apps/web/models/auths.py b/backend/apps/web/models/auths.py index 9c4e5ffe..dfa0c439 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 1a127a77..450dd918 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 321b2603..01fddb73 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 6f1020ec..59f6c21b 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 f864062d..9208a845 100644 --- a/backend/config.py +++ b/backend/config.py @@ -168,7 +168,11 @@ except: STATIC_DIR = str(Path(os.getenv("STATIC_DIR", "./static")).resolve()) -shutil.copyfile(f"{FRONTEND_BUILD_DIR}/favicon.png", f"{STATIC_DIR}/favicon.png") +frontend_favicon = f"{FRONTEND_BUILD_DIR}/favicon.png" +if os.path.exists(frontend_favicon): + shutil.copyfile(frontend_favicon, f"{STATIC_DIR}/favicon.png") +else: + logging.warning(f"Frontend favicon not found at {frontend_favicon}") #################################### # CUSTOM_NAME @@ -363,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?", + }, ] ) @@ -516,6 +531,8 @@ RAG_TEMPLATE = os.environ.get("RAG_TEMPLATE", DEFAULT_RAG_TEMPLATE) RAG_OPENAI_API_BASE_URL = os.getenv("RAG_OPENAI_API_BASE_URL", OPENAI_API_BASE_URL) RAG_OPENAI_API_KEY = os.getenv("RAG_OPENAI_API_KEY", OPENAI_API_KEY) +ENABLE_LOCAL_WEB_FETCH = os.getenv("ENABLE_LOCAL_WEB_FETCH", "False").lower() == "true" + #################################### # Transcribe #################################### diff --git a/backend/constants.py b/backend/constants.py index a2694575..3fdf506f 100644 --- a/backend/constants.py +++ b/backend/constants.py @@ -71,3 +71,7 @@ class ERROR_MESSAGES(str, Enum): EMPTY_CONTENT = "The content provided is empty. Please ensure that there is text or data present before proceeding." DB_NOT_SQLITE = "This feature is only available when running with SQLite databases." + + INVALID_URL = ( + "Oops! The URL you provided is invalid. Please double-check and try again." + ) diff --git a/backend/data/config.json b/backend/data/config.json index 604ffb03..6c0ad2b9 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/dev.sh b/backend/dev.sh old mode 100644 new mode 100755 diff --git a/backend/main.py b/backend/main.py index 1b277262..e37ac324 100644 --- a/backend/main.py +++ b/backend/main.py @@ -311,18 +311,23 @@ 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"}], } app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") app.mount("/cache", StaticFiles(directory=CACHE_DIR), name="cache") -app.mount( - "/", - SPAStaticFiles(directory=FRONTEND_BUILD_DIR, html=True), - name="spa-static-files", -) +if os.path.exists(FRONTEND_BUILD_DIR): + app.mount( + "/", + SPAStaticFiles(directory=FRONTEND_BUILD_DIR, html=True), + name="spa-static-files", + ) +else: + log.warning( + f"Frontend build directory not found at '{FRONTEND_BUILD_DIR}'. Serving API only." + ) @app.on_event("shutdown") diff --git a/backend/requirements.txt b/backend/requirements.txt index 336cae17..ce01cf50 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,59 +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.17 -litellm[proxy]==1.35.17 +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 +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 00000000..519af1db 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 00000000..918a92aa --- /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 00000000..519af1db Binary files /dev/null and b/backend/utils/logo.png differ diff --git a/cypress/e2e/chat.cy.ts b/cypress/e2e/chat.cy.ts index fce78627..afb29960 100644 --- a/cypress/e2e/chat.cy.ts +++ b/cypress/e2e/chat.cy.ts @@ -21,14 +21,14 @@ describe('Settings', () => { // Click on the model selector cy.get('button[aria-label="Select a model"]').click(); // Select the first model - cy.get('div[role="option"][data-value]').first().click(); + cy.get('div[role="menuitem"]').first().click(); }); it('user can perform text chat', () => { // Click on the model selector cy.get('button[aria-label="Select a model"]').click(); // Select the first model - cy.get('div[role="option"][data-value]').first().click(); + cy.get('div[role="menuitem"]').first().click(); // Type a message cy.get('#chat-textarea').type('Hi, what can you do? A single sentence only please.', { force: true diff --git a/package-lock.json b/package-lock.json index 913c55b7..d3a8b513 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 c3812072..e80e88ef 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 efeeff33..26feb29b 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 5dfa3d3a..a9d163f8 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 19e4d8fc..a72dbe47 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); } } } @@ -73,7 +71,11 @@ async function* streamLargeDeltasAsRandomChunks( const chunkSize = Math.min(Math.floor(Math.random() * 3) + 1, content.length); const chunk = content.slice(0, chunkSize); yield { done: false, value: chunk }; - await sleep(5); + // Do not sleep if the tab is hidden + // Timers are throttled to 1s in hidden tabs + if (document?.visibilityState !== 'hidden') { + await sleep(5); + } content = content.slice(chunkSize); } } diff --git a/src/lib/components/ChangelogModal.svelte b/src/lib/components/ChangelogModal.svelte index fbfa6b0a..1113b25e 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 00000000..4b505d02 --- /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 7b726214..923ab576 100644 --- a/src/lib/components/admin/SettingsModal.svelte +++ b/src/lib/components/admin/SettingsModal.svelte @@ -15,7 +15,7 @@
-
+
{$i18n.t('Admin Settings')}
-
{$i18n.t('Name')} - {$i18n.t('Created At')} + {$i18n.t('Created at')} @@ -96,7 +96,7 @@
- +
{:else} -
{user.name} has no conversations.
+
+ {user.name} + {$i18n.t('has no conversations.')} +
{/if}
diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index 7911b020..7d377d48 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -1,27 +1,34 @@ {#if filteredItems.length > 0 || prompt.split(' ')?.at(0)?.substring(1).startsWith('http')} @@ -132,7 +143,30 @@ {/each} - {#if prompt.split(' ')?.at(0)?.substring(1).startsWith('http')} + {#if prompt.split(' ')?.at(0)?.substring(1).startsWith('https://www.youtube.com')} + + {:else if prompt.split(' ')?.at(0)?.substring(1).startsWith('http')} - {/each} + + {/each} +
-
+ {/if} {/if} diff --git a/src/lib/components/chat/MessageInput/Suggestions.svelte b/src/lib/components/chat/MessageInput/Suggestions.svelte index 13a8e1d7..f094d17a 100644 --- a/src/lib/components/chat/MessageInput/Suggestions.svelte +++ b/src/lib/components/chat/MessageInput/Suggestions.svelte @@ -1,56 +1,116 @@ -
-
+{#if prompts.length > 0} +
+ + Suggested +
+{/if} + +
+
{#each prompts as prompt, promptIdx} -
+
{/each} + +
+ + diff --git a/src/lib/components/chat/Messages.svelte b/src/lib/components/chat/Messages.svelte index 2da91c50..3ef99594 100644 --- a/src/lib/components/chat/Messages.svelte +++ b/src/lib/components/chat/Messages.svelte @@ -12,7 +12,7 @@ import Placeholder from './Messages/Placeholder.svelte'; import Spinner from '../common/Spinner.svelte'; import { imageGenerations } from '$lib/apis/images'; - import { copyToClipboard } from '$lib/utils'; + import { copyToClipboard, findWordIndices } from '$lib/utils'; const i18n = getContext('i18n'); @@ -22,6 +22,8 @@ export let continueGeneration: Function; export let regenerateResponse: Function; + export let prompt; + export let suggestionPrompts; export let processing = ''; export let bottomPadding = false; export let autoScroll; @@ -236,108 +238,111 @@ history: history }); }; - - // const messageDeleteHandler = async (messageId) => { - // const message = history.messages[messageId]; - // const parentId = message.parentId; - // const childrenIds = message.childrenIds ?? []; - // const grandchildrenIds = []; - - // // Iterate through childrenIds to find grandchildrenIds - // for (const childId of childrenIds) { - // const childMessage = history.messages[childId]; - // const grandChildrenIds = childMessage.childrenIds ?? []; - - // for (const grandchildId of grandchildrenIds) { - // const childMessage = history.messages[grandchildId]; - // childMessage.parentId = parentId; - // } - // grandchildrenIds.push(...grandChildrenIds); - // } - - // history.messages[parentId].childrenIds.push(...grandchildrenIds); - // history.messages[parentId].childrenIds = history.messages[parentId].childrenIds.filter( - // (id) => id !== messageId - // ); - - // // Select latest message - // let currentMessageId = grandchildrenIds.at(-1); - // if (currentMessageId) { - // let messageChildrenIds = history.messages[currentMessageId].childrenIds; - // while (messageChildrenIds.length !== 0) { - // currentMessageId = messageChildrenIds.at(-1); - // messageChildrenIds = history.messages[currentMessageId].childrenIds; - // } - // history.currentId = currentMessageId; - // } - - // await updateChatById(localStorage.token, chatId, { messages, history }); - // }; -{#if messages.length == 0} - -{:else} -
- {#key chatId} - {#each messages as message, messageIdx} -
-
- {#if message.role === 'user'} - messageDeleteHandler(message.id)} - user={$user} - {readOnly} - {message} - isFirstMessage={messageIdx === 0} - siblings={message.parentId !== null - ? history.messages[message.parentId]?.childrenIds ?? [] - : Object.values(history.messages) - .filter((message) => message.parentId === null) - .map((message) => message.id) ?? []} - {confirmEditMessage} - {showPreviousMessage} - {showNextMessage} - copyToClipboard={copyToClipboardWithToast} - /> - {:else} - { - console.log('save', e); +
+ {#if messages.length == 0} + { + let text = p; - const message = e.detail; - history.messages[message.id] = message; - await updateChatById(localStorage.token, chatId, { - messages: messages, - history: history - }); - }} - /> - {/if} + if (p.includes('{{CLIPBOARD}}')) { + const clipboardText = await navigator.clipboard.readText().catch((err) => { + toast.error($i18n.t('Failed to read clipboard contents')); + return '{{CLIPBOARD}}'; + }); + + text = p.replaceAll('{{CLIPBOARD}}', clipboardText); + } + + prompt = text; + + await tick(); + + const chatInputElement = document.getElementById('chat-textarea'); + if (chatInputElement) { + prompt = p; + + chatInputElement.style.height = ''; + chatInputElement.style.height = Math.min(chatInputElement.scrollHeight, 200) + 'px'; + chatInputElement.focus(); + + const words = findWordIndices(prompt); + + if (words.length > 0) { + const word = words.at(0); + chatInputElement.setSelectionRange(word?.startIndex, word.endIndex + 1); + } + } + + await tick(); + }} + /> + {:else} +
+ {#key chatId} + {#each messages as message, messageIdx} +
+
+ {#if message.role === 'user'} + messageDeleteHandler(message.id)} + user={$user} + {readOnly} + {message} + isFirstMessage={messageIdx === 0} + siblings={message.parentId !== null + ? history.messages[message.parentId]?.childrenIds ?? [] + : Object.values(history.messages) + .filter((message) => message.parentId === null) + .map((message) => message.id) ?? []} + {confirmEditMessage} + {showPreviousMessage} + {showNextMessage} + copyToClipboard={copyToClipboardWithToast} + /> + {:else} + { + console.log('save', e); + + const message = e.detail; + history.messages[message.id] = message; + await updateChatById(localStorage.token, chatId, { + messages: messages, + history: history + }); + }} + /> + {/if} +
-
- {/each} + {/each} - {#if bottomPadding} -
- {/if} - {/key} -
-{/if} + {#if bottomPadding} +
+ {/if} + {/key} +
+ {/if} +
diff --git a/src/lib/components/chat/Messages/CodeBlock.svelte b/src/lib/components/chat/Messages/CodeBlock.svelte index c5290547..18ee9f54 100644 --- a/src/lib/components/chat/Messages/CodeBlock.svelte +++ b/src/lib/components/chat/Messages/CodeBlock.svelte @@ -31,7 +31,9 @@ >
-
{@html highlightedCode || code}
diff --git a/src/lib/components/chat/Messages/Placeholder.svelte b/src/lib/components/chat/Messages/Placeholder.svelte index 7e0a98cb..5035904d 100644 --- a/src/lib/components/chat/Messages/Placeholder.svelte +++ b/src/lib/components/chat/Messages/Placeholder.svelte @@ -3,11 +3,19 @@ import { user } from '$lib/stores'; import { onMount, getContext } from 'svelte'; + import { blur, fade } from 'svelte/transition'; + + import Suggestions from '../MessageInput/Suggestions.svelte'; + const i18n = getContext('i18n'); export let models = []; export let modelfiles = []; + export let submitPrompt; + export let suggestionPrompts; + + let mounted = false; let modelfile = null; let selectedModelIdx = 0; @@ -17,12 +25,16 @@ $: if (models.length > 0) { selectedModelIdx = models.length - 1; } + + onMount(() => { + mounted = true; + }); -{#if models.length > 0} -
-
-
+{#key mounted} +
+
+
{#each models as model, modelIdx}
-
- {#if modelfile} - - {modelfile.title} - -
- {modelfile.desc} -
- {#if modelfile.user} - - {/if} - {:else} -
{$i18n.t('Hello, {{name}}', { name: $user.name })}
-
{$i18n.t('How can I help you today?')}
- {/if} +
+
+
+ {#if modelfile} + {modelfile.title} + {:else} + {$i18n.t('Hello, {{name}}', { name: $user.name })} + {/if} +
+ +
+ {#if modelfile} +
+ {modelfile.desc} +
+ {#if modelfile.user} + + {/if} + {:else} +
+ {$i18n.t('How can I help you today?')} +
+ {/if} +
+
+
+ +
+
-{/if} +{/key} diff --git a/src/lib/components/chat/Messages/ResponseMessage.svelte b/src/lib/components/chat/Messages/ResponseMessage.svelte index 23397603..4d87f929 100644 --- a/src/lib/components/chat/Messages/ResponseMessage.svelte +++ b/src/lib/components/chat/Messages/ResponseMessage.svelte @@ -137,20 +137,22 @@ .getElementById(`message-${message.id}`) ?.getElementsByClassName('chat-assistant'); - for (const element of chatMessageElements) { - auto_render(element, { - // customised options - // • auto-render specific keys, e.g.: - delimiters: [ - { left: '$$', right: '$$', display: false }, - { left: '$ ', right: ' $', display: false }, - { left: '\\(', right: '\\)', display: false }, - { left: '\\[', right: '\\]', display: false }, - { left: '[ ', right: ' ]', display: false } - ], - // • rendering keys, e.g.: - throwOnError: false - }); + if (chatMessageElements) { + for (const element of chatMessageElements) { + auto_render(element, { + // customised options + // • auto-render specific keys, e.g.: + delimiters: [ + { left: '$$', right: '$$', display: false }, + { left: '$ ', right: ' $', display: false }, + { left: '\\(', right: '\\)', display: false }, + { left: '\\[', right: '\\]', display: false }, + { left: '[ ', right: ' ]', display: false } + ], + // • rendering keys, e.g.: + throwOnError: false + }); + } } }; @@ -325,7 +327,8 @@ {#key message.id}
@@ -377,7 +380,7 @@
-
- -
+{#if showSetDefault} +
+ +
+{/if} diff --git a/src/lib/components/chat/ModelSelector/Selector.svelte b/src/lib/components/chat/ModelSelector/Selector.svelte index 720ee55e..98c63fc0 100644 --- a/src/lib/components/chat/ModelSelector/Selector.svelte +++ b/src/lib/components/chat/ModelSelector/Selector.svelte @@ -1,5 +1,5 @@ - { searchValue = ''; window.setTimeout(() => document.getElementById('model-search-input')?.focus(), 0); }} - selected={items.find((item) => item.value === value) ?? ''} - onSelectedChange={(selectedItem) => { - value = selectedItem.value; - }} > - - - - - +
+ {#if selectedModel} + {selectedModel.label} + {:else} + {placeholder} + {/if} + +
+ + @@ -214,12 +220,13 @@
{/if} -
+
{#each filteredItems as item} - { + value = item.value; + }} >
@@ -287,7 +294,7 @@
{/if} - + {:else}
@@ -385,5 +392,16 @@ {/each}
- - + + + + diff --git a/src/lib/components/chat/Settings/Advanced/AdvancedParams.svelte b/src/lib/components/chat/Settings/Advanced/AdvancedParams.svelte index 06e69068..b577e504 100644 --- a/src/lib/components/chat/Settings/Advanced/AdvancedParams.svelte +++ b/src/lib/components/chat/Settings/Advanced/AdvancedParams.svelte @@ -492,8 +492,8 @@
diff --git a/src/lib/components/chat/Settings/Interface.svelte b/src/lib/components/chat/Settings/Interface.svelte index 0ed558b8..87a8d7ec 100644 --- a/src/lib/components/chat/Settings/Interface.svelte +++ b/src/lib/components/chat/Settings/Interface.svelte @@ -106,6 +106,7 @@ responseAutoCopy = settings.responseAutoCopy ?? false; showUsername = settings.showUsername ?? false; fullScreenMode = settings.fullScreenMode ?? false; + splitLargeChunks = settings.splitLargeChunks ?? false; }); diff --git a/src/lib/components/chat/SettingsModal.svelte b/src/lib/components/chat/SettingsModal.svelte index 95becea1..d3403dc8 100644 --- a/src/lib/components/chat/SettingsModal.svelte +++ b/src/lib/components/chat/SettingsModal.svelte @@ -35,8 +35,8 @@ -
-
+
+
{$i18n.t('Settings')}
-
-
+
{$i18n.t('Share Chat')}
-
{#if chat} -
+
{#if chat.share_id} -
-
+
+
{$i18n.t('Keyboard shortcuts')}
-
diff --git a/src/lib/components/common/Modal.svelte b/src/lib/components/common/Modal.svelte index 6d8b8299..8c694d2a 100644 --- a/src/lib/components/common/Modal.svelte +++ b/src/lib/components/common/Modal.svelte @@ -51,7 +51,7 @@ bind:this={modalElement} class=" fixed top-0 right-0 left-0 bottom-0 bg-black/60 w-full min-h-screen h-screen flex justify-center z-[9999] overflow-hidden overscroll-contain" in:fade={{ duration: 10 }} - on:click={() => { + on:mousedown={() => { show = false; }} > @@ -60,7 +60,7 @@ size )} mx-2 bg-gray-50 dark:bg-gray-900 shadow-3xl" in:flyAndScale - on:click={(e) => { + on:mousedown={(e) => { e.stopPropagation(); }} > diff --git a/src/lib/components/common/Tags.svelte b/src/lib/components/common/Tags.svelte index c8e12063..c3eb3c99 100644 --- a/src/lib/components/common/Tags.svelte +++ b/src/lib/components/common/Tags.svelte @@ -1,6 +1,9 @@ + + + + diff --git a/src/lib/components/icons/XMark.svelte b/src/lib/components/icons/XMark.svelte new file mode 100644 index 00000000..b75be506 --- /dev/null +++ b/src/lib/components/icons/XMark.svelte @@ -0,0 +1,15 @@ + + + + + diff --git a/src/lib/components/layout/Navbar.svelte b/src/lib/components/layout/Navbar.svelte index bce1e5dd..57b09a19 100644 --- a/src/lib/components/layout/Navbar.svelte +++ b/src/lib/components/layout/Navbar.svelte @@ -9,6 +9,7 @@ import ModelSelector from '../chat/ModelSelector.svelte'; import Tooltip from '../common/Tooltip.svelte'; import Menu from './Navbar/Menu.svelte'; + import { page } from '$app/stores'; const i18n = getContext('i18n'); @@ -27,22 +28,19 @@