Browse Source

Merge branch 'dev' into feat/add-i18n

Ased Mammad 1 year ago
parent
commit
7031aa14e8

+ 0 - 1
.dockerignore

@@ -7,7 +7,6 @@ node_modules
 /package
 .env
 .env.*
-!.env.example
 vite.config.js.timestamp-*
 vite.config.ts.timestamp-*
 __pycache__

+ 2 - 2
.env.example

@@ -1,6 +1,6 @@
 # Ollama URL for the backend to connect
-# The path '/ollama/api' will be redirected to the specified backend URL
-OLLAMA_API_BASE_URL='http://localhost:11434/api'
+# The path '/ollama' will be redirected to the specified backend URL
+OLLAMA_BASE_URL='http://localhost:11434'
 
 OPENAI_API_BASE_URL=''
 OPENAI_API_KEY=''

+ 12 - 2
.github/workflows/build-release.yml

@@ -19,24 +19,34 @@ jobs:
           echo "No changes to package.json"
           exit 1
         }
-
+    
     - name: Get version number from package.json
       id: get_version
       run: |
         VERSION=$(jq -r '.version' package.json)
         echo "::set-output name=version::$VERSION"
 
+    - name: Extract latest CHANGELOG entry
+      id: changelog
+      run: |
+        CHANGELOG_CONTENT=$(awk '/^## \[/{n++} n==1' CHANGELOG.md)
+        echo "CHANGELOG_CONTENT<<EOF" 
+        echo "$CHANGELOG_CONTENT"
+        echo "EOF" 
+        echo "::set-output name=content::${CHANGELOG_CONTENT}"
+
     - name: Create GitHub release
       uses: actions/github-script@v5
       with:
         github-token: ${{ secrets.GITHUB_TOKEN }}
         script: |
+          const changelog = `${{ steps.changelog.outputs.content }}`;
           const release = await github.rest.repos.createRelease({
             owner: context.repo.owner,
             repo: context.repo.repo,
             tag_name: `v${{ steps.get_version.outputs.version }}`,
             name: `v${{ steps.get_version.outputs.version }}`,
-            body: 'Automatically created new release',
+            body: changelog,
           })
           console.log(`Created release ${release.data.html_url}`)
 

+ 32 - 0
CHANGELOG.md

@@ -5,6 +5,38 @@ 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.110] - 2024-03-06
+
+### Added
+
+- **🌐 Multiple OpenAI Servers Support**: Enjoy seamless integration with multiple OpenAI-compatible APIs, now supported natively.
+
+### Fixed
+
+- **🔍 OCR Issue**: Resolved PDF parsing issue caused by OCR malfunction.
+- **🚫 RAG Issue**: Fixed the RAG functionality, ensuring it operates smoothly.
+- **📄 "Add Docs" Model Button**: Addressed the non-functional behavior of the "Add Docs" model button.
+
+## [0.1.109] - 2024-03-06
+
+### Added
+
+- **🔄 Multiple Ollama Servers Support**: Enjoy enhanced scalability and performance with support for multiple Ollama servers in a single WebUI. Load balancing features are now available, providing improved efficiency (#788, #278).
+- **🔧 Support for Claude 3 and Gemini**: Responding to user requests, we've expanded our toolset to include Claude 3 and Gemini, offering a wider range of functionalities within our platform (#1064).
+- **🔍 OCR Functionality for PDF Loader**: We've augmented our PDF loader with Optical Character Recognition (OCR) capabilities. Now, extract text from scanned documents and images within PDFs, broadening the scope of content processing (#1050).
+
+### Fixed
+
+- **🛠️ RAG Collection**: Implemented a dynamic mechanism to recreate RAG collections, ensuring users have up-to-date and accurate data (#1031).
+- **📝 User Agent Headers**: Fixed issue of RAG web requests being sent with empty user_agent headers, reducing rejections from certain websites. Realistic headers are now utilized for these requests (#1024).
+- **⏹️ Playground Cancel Functionality**: Introducing a new "Cancel" option for stopping Ollama generation in the Playground, enhancing user control and usability (#1006).
+- **🔤 Typographical Error in 'ASSISTANT' Field**: Corrected a typographical error in the 'ASSISTANT' field within the GGUF model upload template for accuracy and consistency (#1061).
+
+### Changed
+
+- **🔄 Refactored Message Deletion Logic**: Streamlined message deletion process for improved efficiency and user experience, simplifying interactions within the platform (#1004).
+- **⚠️ Deprecation of `OLLAMA_API_BASE_URL`**: Deprecated `OLLAMA_API_BASE_URL` environment variable; recommend using `OLLAMA_BASE_URL` instead. Refer to our documentation for further details.
+
 ## [0.1.108] - 2024-03-02
 
 ### Added

+ 3 - 1
Dockerfile

@@ -20,7 +20,7 @@ FROM python:3.11-slim-bookworm as base
 ENV ENV=prod
 ENV PORT ""
 
-ENV OLLAMA_API_BASE_URL "/ollama/api"
+ENV OLLAMA_BASE_URL "/ollama"
 
 ENV OPENAI_API_BASE_URL ""
 ENV OPENAI_API_KEY ""
@@ -53,6 +53,8 @@ WORKDIR /app/backend
 # install python dependencies
 COPY ./backend/requirements.txt ./requirements.txt
 
+RUN apt-get update && apt-get install ffmpeg libsm6 libxext6  -y
+
 RUN pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir
 RUN pip3 install -r requirements.txt --no-cache-dir
 

+ 3 - 3
README.md

@@ -95,10 +95,10 @@ Don't forget to explore our sibling project, [Open WebUI Community](https://open
 
 - **If Ollama is on a Different Server**, use this command:
 
-- To connect to Ollama on another server, change the `OLLAMA_API_BASE_URL` to the server's URL:
+- To connect to Ollama on another server, change the `OLLAMA_BASE_URL` to the server's URL:
 
   ```bash
-  docker run -d -p 3000:8080 -e OLLAMA_API_BASE_URL=https://example.com/api -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:main
+  docker run -d -p 3000:8080 -e OLLAMA_BASE_URL=https://example.com -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:main
   ```
 
 - After installation, you can access Open WebUI at [http://localhost:3000](http://localhost:3000). Enjoy! 😄
@@ -110,7 +110,7 @@ If you're experiencing connection issues, it’s often due to the WebUI docker c
 **Example Docker Command**:
 
 ```bash
-docker run -d --network=host -v open-webui:/app/backend/data -e OLLAMA_API_BASE_URL=http://127.0.0.1:11434/api --name open-webui --restart always ghcr.io/open-webui/open-webui:main
+docker run -d --network=host -v open-webui:/app/backend/data -e OLLAMA_BASE_URL=http://127.0.0.1:11434 --name open-webui --restart always ghcr.io/open-webui/open-webui:main
 ```
 
 ### Other Installation Methods

+ 4 - 4
TROUBLESHOOTING.md

@@ -4,7 +4,7 @@
 
 The Open WebUI system is designed to streamline interactions between the client (your browser) and the Ollama API. At the heart of this design is a backend reverse proxy, enhancing security and resolving CORS issues.
 
-- **How it Works**: The Open WebUI is designed to interact with the Ollama API through a specific route. When a request is made from the WebUI to Ollama, it is not directly sent to the Ollama API. Initially, the request is sent to the Open WebUI backend via `/ollama/api` route. From there, the backend is responsible for forwarding the request to the Ollama API. This forwarding is accomplished by using the route specified in the `OLLAMA_API_BASE_URL` environment variable. Therefore, a request made to `/ollama/api` in the WebUI is effectively the same as making a request to `OLLAMA_API_BASE_URL` in the backend. For instance, a request to `/ollama/api/tags` in the WebUI is equivalent to `OLLAMA_API_BASE_URL/tags` in the backend.
+- **How it Works**: The Open WebUI is designed to interact with the Ollama API through a specific route. When a request is made from the WebUI to Ollama, it is not directly sent to the Ollama API. Initially, the request is sent to the Open WebUI backend via `/ollama` route. From there, the backend is responsible for forwarding the request to the Ollama API. This forwarding is accomplished by using the route specified in the `OLLAMA_BASE_URL` environment variable. Therefore, a request made to `/ollama` in the WebUI is effectively the same as making a request to `OLLAMA_BASE_URL` in the backend. For instance, a request to `/ollama/api/tags` in the WebUI is equivalent to `OLLAMA_BASE_URL/api/tags` in the backend.
 
 - **Security Benefits**: This design prevents direct exposure of the Ollama API to the frontend, safeguarding against potential CORS (Cross-Origin Resource Sharing) issues and unauthorized access. Requiring authentication to access the Ollama API further enhances this security layer.
 
@@ -15,7 +15,7 @@ If you're experiencing connection issues, it’s often due to the WebUI docker c
 **Example Docker Command**:
 
 ```bash
-docker run -d --network=host -v open-webui:/app/backend/data -e OLLAMA_API_BASE_URL=http://127.0.0.1:11434/api --name open-webui --restart always ghcr.io/open-webui/open-webui:main
+docker run -d --network=host -v open-webui:/app/backend/data -e OLLAMA_BASE_URL=http://127.0.0.1:11434 --name open-webui --restart always ghcr.io/open-webui/open-webui:main
 ```
 
 ### General Connection Errors
@@ -25,8 +25,8 @@ docker run -d --network=host -v open-webui:/app/backend/data -e OLLAMA_API_BASE_
 **Troubleshooting Steps**:
 
 1. **Verify Ollama URL Format**:
-   - When running the Web UI container, ensure the `OLLAMA_API_BASE_URL` is correctly set, including the `/api` suffix. (e.g., `http://192.168.1.1:11434/api` for different host setups).
+   - When running the Web UI container, ensure the `OLLAMA_BASE_URL` is correctly set. (e.g., `http://192.168.1.1:11434` for different host setups).
    - In the Open WebUI, navigate to "Settings" > "General".
-   - Confirm that the Ollama Server URL is correctly set to `[OLLAMA URL]/api` (e.g., `http://localhost:11434/api`), including the `/api` suffix.
+   - Confirm that the Ollama Server URL is correctly set to `[OLLAMA URL]` (e.g., `http://localhost:11434`).
 
 By following these enhanced troubleshooting steps, connection issues should be effectively resolved. For further assistance or queries, feel free to reach out to us on our community Discord.

+ 14 - 14
backend/apps/ollama/main.py

@@ -15,7 +15,7 @@ import asyncio
 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_BASE_URL, WEBUI_AUTH
+from config import OLLAMA_BASE_URLS
 
 from typing import Optional, List, Union
 
@@ -29,8 +29,7 @@ app.add_middleware(
     allow_headers=["*"],
 )
 
-app.state.OLLAMA_BASE_URL = OLLAMA_BASE_URL
-app.state.OLLAMA_BASE_URLS = [OLLAMA_BASE_URL]
+app.state.OLLAMA_BASE_URLS = OLLAMA_BASE_URLS
 app.state.MODELS = {}
 
 
@@ -223,7 +222,7 @@ async def pull_model(
             r = requests.request(
                 method="POST",
                 url=f"{url}/api/pull",
-                data=form_data.model_dump_json(exclude_none=True),
+                data=form_data.model_dump_json(exclude_none=True).encode(),
                 stream=True,
             )
 
@@ -295,7 +294,7 @@ async def push_model(
             r = requests.request(
                 method="POST",
                 url=f"{url}/api/push",
-                data=form_data.model_dump_json(exclude_none=True),
+                data=form_data.model_dump_json(exclude_none=True).encode(),
             )
 
             r.raise_for_status()
@@ -357,7 +356,7 @@ async def create_model(
             r = requests.request(
                 method="POST",
                 url=f"{url}/api/create",
-                data=form_data.model_dump_json(exclude_none=True),
+                data=form_data.model_dump_json(exclude_none=True).encode(),
                 stream=True,
             )
 
@@ -420,7 +419,7 @@ async def copy_model(
         r = requests.request(
             method="POST",
             url=f"{url}/api/copy",
-            data=form_data.model_dump_json(exclude_none=True),
+            data=form_data.model_dump_json(exclude_none=True).encode(),
         )
         r.raise_for_status()
 
@@ -467,7 +466,7 @@ async def delete_model(
         r = requests.request(
             method="DELETE",
             url=f"{url}/api/delete",
-            data=form_data.model_dump_json(exclude_none=True),
+            data=form_data.model_dump_json(exclude_none=True).encode(),
         )
         r.raise_for_status()
 
@@ -507,7 +506,7 @@ async def show_model_info(form_data: ModelNameForm, user=Depends(get_current_use
         r = requests.request(
             method="POST",
             url=f"{url}/api/show",
-            data=form_data.model_dump_json(exclude_none=True),
+            data=form_data.model_dump_json(exclude_none=True).encode(),
         )
         r.raise_for_status()
 
@@ -559,7 +558,7 @@ async def generate_embeddings(
         r = requests.request(
             method="POST",
             url=f"{url}/api/embeddings",
-            data=form_data.model_dump_json(exclude_none=True),
+            data=form_data.model_dump_json(exclude_none=True).encode(),
         )
         r.raise_for_status()
 
@@ -645,7 +644,7 @@ async def generate_completion(
             r = requests.request(
                 method="POST",
                 url=f"{url}/api/generate",
-                data=form_data.model_dump_json(exclude_none=True),
+                data=form_data.model_dump_json(exclude_none=True).encode(),
                 stream=True,
             )
 
@@ -715,7 +714,7 @@ async def generate_chat_completion(
 
     r = None
 
-    print(form_data.model_dump_json(exclude_none=True))
+    print(form_data.model_dump_json(exclude_none=True).encode())
 
     def get_request():
         nonlocal form_data
@@ -745,7 +744,7 @@ async def generate_chat_completion(
             r = requests.request(
                 method="POST",
                 url=f"{url}/api/chat",
-                data=form_data.model_dump_json(exclude_none=True),
+                data=form_data.model_dump_json(exclude_none=True).encode(),
                 stream=True,
             )
 
@@ -757,6 +756,7 @@ async def generate_chat_completion(
                 headers=dict(r.headers),
             )
         except Exception as e:
+            print(e)
             raise e
 
     try:
@@ -844,7 +844,7 @@ async def generate_openai_chat_completion(
             r = requests.request(
                 method="POST",
                 url=f"{url}/v1/chat/completions",
-                data=form_data.model_dump_json(exclude_none=True),
+                data=form_data.model_dump_json(exclude_none=True).encode(),
                 stream=True,
             )
 

+ 173 - 83
backend/apps/openai/main.py

@@ -3,7 +3,10 @@ from fastapi.middleware.cors import CORSMiddleware
 from fastapi.responses import StreamingResponse, JSONResponse, FileResponse
 
 import requests
+import aiohttp
+import asyncio
 import json
+
 from pydantic import BaseModel
 
 
@@ -15,7 +18,9 @@ from utils.utils import (
     get_verified_user,
     get_admin_user,
 )
-from config import OPENAI_API_BASE_URL, OPENAI_API_KEY, CACHE_DIR
+from config import OPENAI_API_BASE_URLS, OPENAI_API_KEYS, CACHE_DIR
+from typing import List, Optional
+
 
 import hashlib
 from pathlib import Path
@@ -29,116 +34,207 @@ app.add_middleware(
     allow_headers=["*"],
 )
 
-app.state.OPENAI_API_BASE_URL = OPENAI_API_BASE_URL
-app.state.OPENAI_API_KEY = OPENAI_API_KEY
+app.state.OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS
+app.state.OPENAI_API_KEYS = OPENAI_API_KEYS
+
+app.state.MODELS = {}
+
 
+@app.middleware("http")
+async def check_url(request: Request, call_next):
+    if len(app.state.MODELS) == 0:
+        await get_all_models()
+    else:
+        pass
 
-class UrlUpdateForm(BaseModel):
-    url: str
+    response = await call_next(request)
+    return response
 
 
-class KeyUpdateForm(BaseModel):
-    key: str
+class UrlsUpdateForm(BaseModel):
+    urls: List[str]
 
 
-@app.get("/url")
-async def get_openai_url(user=Depends(get_admin_user)):
-    return {"OPENAI_API_BASE_URL": app.state.OPENAI_API_BASE_URL}
+class KeysUpdateForm(BaseModel):
+    keys: List[str]
 
 
-@app.post("/url/update")
-async def update_openai_url(form_data: UrlUpdateForm, user=Depends(get_admin_user)):
-    app.state.OPENAI_API_BASE_URL = form_data.url
-    return {"OPENAI_API_BASE_URL": app.state.OPENAI_API_BASE_URL}
+@app.get("/urls")
+async def get_openai_urls(user=Depends(get_admin_user)):
+    return {"OPENAI_API_BASE_URLS": app.state.OPENAI_API_BASE_URLS}
 
 
-@app.get("/key")
-async def get_openai_key(user=Depends(get_admin_user)):
-    return {"OPENAI_API_KEY": app.state.OPENAI_API_KEY}
+@app.post("/urls/update")
+async def update_openai_urls(form_data: UrlsUpdateForm, user=Depends(get_admin_user)):
+    app.state.OPENAI_API_BASE_URLS = form_data.urls
+    return {"OPENAI_API_BASE_URLS": app.state.OPENAI_API_BASE_URLS}
 
 
-@app.post("/key/update")
-async def update_openai_key(form_data: KeyUpdateForm, user=Depends(get_admin_user)):
-    app.state.OPENAI_API_KEY = form_data.key
-    return {"OPENAI_API_KEY": app.state.OPENAI_API_KEY}
+@app.get("/keys")
+async def get_openai_keys(user=Depends(get_admin_user)):
+    return {"OPENAI_API_KEYS": app.state.OPENAI_API_KEYS}
+
+
+@app.post("/keys/update")
+async def update_openai_key(form_data: KeysUpdateForm, user=Depends(get_admin_user)):
+    app.state.OPENAI_API_KEYS = form_data.keys
+    return {"OPENAI_API_KEYS": app.state.OPENAI_API_KEYS}
 
 
 @app.post("/audio/speech")
 async def speech(request: Request, user=Depends(get_verified_user)):
-    target_url = f"{app.state.OPENAI_API_BASE_URL}/audio/speech"
+    idx = None
+    try:
+        idx = app.state.OPENAI_API_BASE_URLS.index("https://api.openai.com/v1")
+        body = await request.body()
+        name = hashlib.sha256(body).hexdigest()
+
+        SPEECH_CACHE_DIR = Path(CACHE_DIR).joinpath("./audio/speech/")
+        SPEECH_CACHE_DIR.mkdir(parents=True, exist_ok=True)
+        file_path = SPEECH_CACHE_DIR.joinpath(f"{name}.mp3")
+        file_body_path = SPEECH_CACHE_DIR.joinpath(f"{name}.json")
+
+        # Check if the file already exists in the cache
+        if file_path.is_file():
+            return FileResponse(file_path)
+
+        headers = {}
+        headers["Authorization"] = f"Bearer {app.state.OPENAI_API_KEYS[idx]}"
+        headers["Content-Type"] = "application/json"
+
+        try:
+            r = requests.post(
+                url=f"{app.state.OPENAI_API_BASE_URLS[idx]}/audio/speech",
+                data=body,
+                headers=headers,
+                stream=True,
+            )
 
-    if app.state.OPENAI_API_KEY == "":
-        raise HTTPException(status_code=401, detail=ERROR_MESSAGES.API_KEY_NOT_FOUND)
+            r.raise_for_status()
 
-    body = await request.body()
+            # Save the streaming content to a file
+            with open(file_path, "wb") as f:
+                for chunk in r.iter_content(chunk_size=8192):
+                    f.write(chunk)
 
-    name = hashlib.sha256(body).hexdigest()
+            with open(file_body_path, "w") as f:
+                json.dump(json.loads(body.decode("utf-8")), f)
 
-    SPEECH_CACHE_DIR = Path(CACHE_DIR).joinpath("./audio/speech/")
-    SPEECH_CACHE_DIR.mkdir(parents=True, exist_ok=True)
-    file_path = SPEECH_CACHE_DIR.joinpath(f"{name}.mp3")
-    file_body_path = SPEECH_CACHE_DIR.joinpath(f"{name}.json")
+            # Return the saved file
+            return FileResponse(file_path)
 
-    # Check if the file already exists in the cache
-    if file_path.is_file():
-        return FileResponse(file_path)
+        except Exception as e:
+            print(e)
+            error_detail = "Open WebUI: Server Connection Error"
+            if r is not None:
+                try:
+                    res = r.json()
+                    if "error" in res:
+                        error_detail = f"External: {res['error']}"
+                except:
+                    error_detail = f"External: {e}"
+
+            raise HTTPException(status_code=r.status_code, detail=error_detail)
+
+    except ValueError:
+        raise HTTPException(status_code=401, detail=ERROR_MESSAGES.OPENAI_NOT_FOUND)
 
-    headers = {}
-    headers["Authorization"] = f"Bearer {app.state.OPENAI_API_KEY}"
-    headers["Content-Type"] = "application/json"
 
+async def fetch_url(url, key):
     try:
-        print("openai")
-        r = requests.post(
-            url=target_url,
-            data=body,
-            headers=headers,
-            stream=True,
+        headers = {"Authorization": f"Bearer {key}"}
+        async with aiohttp.ClientSession() as session:
+            async with session.get(url, headers=headers) as response:
+                return await response.json()
+    except Exception as e:
+        # Handle connection error here
+        print(f"Connection error: {e}")
+        return None
+
+
+def merge_models_lists(model_lists):
+    merged_list = []
+
+    for idx, models in enumerate(model_lists):
+        merged_list.extend(
+            [
+                {**model, "urlIdx": idx}
+                for model in models
+                if "api.openai.com" not in app.state.OPENAI_API_BASE_URLS[idx]
+                or "gpt" in model["id"]
+            ]
         )
 
-        r.raise_for_status()
+    return merged_list
 
-        # Save the streaming content to a file
-        with open(file_path, "wb") as f:
-            for chunk in r.iter_content(chunk_size=8192):
-                f.write(chunk)
 
-        with open(file_body_path, "w") as f:
-            json.dump(json.loads(body.decode("utf-8")), f)
+async def get_all_models():
+    print("get_all_models")
+    tasks = [
+        fetch_url(f"{url}/models", app.state.OPENAI_API_KEYS[idx])
+        for idx, url in enumerate(app.state.OPENAI_API_BASE_URLS)
+    ]
+    responses = await asyncio.gather(*tasks)
+    responses = list(filter(lambda x: x is not None and "error" not in x, responses))
+    models = {
+        "data": merge_models_lists(
+            list(map(lambda response: response["data"], responses))
+        )
+    }
+    app.state.MODELS = {model["id"]: model for model in models["data"]}
 
-        # Return the saved file
-        return FileResponse(file_path)
+    return models
 
-    except Exception as e:
-        print(e)
-        error_detail = "Open WebUI: Server Connection Error"
-        if r is not None:
-            try:
-                res = r.json()
-                if "error" in res:
-                    error_detail = f"External: {res['error']}"
-            except:
-                error_detail = f"External: {e}"
 
-        raise HTTPException(status_code=r.status_code, detail=error_detail)
+# , user=Depends(get_current_user)
+@app.get("/models")
+@app.get("/models/{url_idx}")
+async def get_models(url_idx: Optional[int] = None):
+    if url_idx == None:
+        return await get_all_models()
+    else:
+        url = app.state.OPENAI_API_BASE_URLS[url_idx]
+        try:
+            r = requests.request(method="GET", url=f"{url}/models")
+            r.raise_for_status()
+
+            response_data = r.json()
+            if "api.openai.com" in url:
+                response_data["data"] = list(
+                    filter(lambda model: "gpt" in model["id"], response_data["data"])
+                )
+
+            return response_data
+        except Exception as e:
+            print(e)
+            error_detail = "Open WebUI: Server Connection Error"
+            if r is not None:
+                try:
+                    res = r.json()
+                    if "error" in res:
+                        error_detail = f"External: {res['error']}"
+                except:
+                    error_detail = f"External: {e}"
+
+            raise HTTPException(
+                status_code=r.status_code if r else 500,
+                detail=error_detail,
+            )
 
 
 @app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"])
 async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
-    target_url = f"{app.state.OPENAI_API_BASE_URL}/{path}"
-    print(target_url, app.state.OPENAI_API_KEY)
-
-    if app.state.OPENAI_API_KEY == "":
-        raise HTTPException(status_code=401, detail=ERROR_MESSAGES.API_KEY_NOT_FOUND)
+    idx = 0
 
     body = await request.body()
-
     # TODO: Remove below after gpt-4-vision fix from Open AI
     # Try to decode the body of the request from bytes to a UTF-8 string (Require add max_token to fix gpt-4-vision)
     try:
         body = body.decode("utf-8")
         body = json.loads(body)
 
+        idx = app.state.MODELS[body.get("model")]["urlIdx"]
+
         # Check if the model is "gpt-4-vision-preview" and set "max_tokens" to 4000
         # This is a workaround until OpenAI fixes the issue with this model
         if body.get("model") == "gpt-4-vision-preview":
@@ -158,8 +254,16 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
     except json.JSONDecodeError as e:
         print("Error loading request body into a dictionary:", e)
 
+    url = app.state.OPENAI_API_BASE_URLS[idx]
+    key = app.state.OPENAI_API_KEYS[idx]
+
+    target_url = f"{url}/{path}"
+
+    if key == "":
+        raise HTTPException(status_code=401, detail=ERROR_MESSAGES.API_KEY_NOT_FOUND)
+
     headers = {}
-    headers["Authorization"] = f"Bearer {app.state.OPENAI_API_KEY}"
+    headers["Authorization"] = f"Bearer {key}"
     headers["Content-Type"] = "application/json"
 
     try:
@@ -181,21 +285,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
                 headers=dict(r.headers),
             )
         else:
-            # For non-SSE, read the response and return it
-            # response_data = (
-            #     r.json()
-            #     if r.headers.get("Content-Type", "")
-            #     == "application/json"
-            #     else r.text
-            # )
-
             response_data = r.json()
-
-            if "api.openai.com" in app.state.OPENAI_API_BASE_URL and path == "models":
-                response_data["data"] = list(
-                    filter(lambda model: "gpt" in model["id"], response_data["data"])
-                )
-
             return response_data
     except Exception as e:
         print(e)

+ 1 - 1
backend/apps/rag/main.py

@@ -425,7 +425,7 @@ def get_loader(filename: str, file_content_type: str, file_path: str):
     ]
 
     if file_ext == "pdf":
-        loader = PyPDFLoader(file_path)
+        loader = PyPDFLoader(file_path, extract_images=True)
     elif file_ext == "csv":
         loader = CSVLoader(file_path)
     elif file_ext == "rst":

+ 3 - 3
backend/apps/web/routers/utils.py

@@ -14,7 +14,7 @@ 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
+from config import OLLAMA_BASE_URLS, DATA_DIR, UPLOAD_DIR
 from constants import ERROR_MESSAGES
 
 
@@ -75,7 +75,7 @@ async def download_file_stream(url, file_path, file_name, chunk_size=1024 * 1024
                     hashed = calculate_sha256(file)
                     file.seek(0)
 
-                    url = f"{OLLAMA_API_BASE_URL}/blobs/sha256:{hashed}"
+                    url = f"{OLLAMA_BASE_URLS[0]}/blobs/sha256:{hashed}"
                     response = requests.post(url, data=file)
 
                     if response.ok:
@@ -147,7 +147,7 @@ def upload(file: UploadFile = File(...)):
                     hashed = calculate_sha256(f)
                     f.seek(0)
 
-                    url = f"{OLLAMA_API_BASE_URL}/blobs/sha256:{hashed}"
+                    url = f"{OLLAMA_BASE_URLS[0]}/blobs/sha256:{hashed}"
                     response = requests.post(url, data=f)
 
                     if response.ok:

+ 27 - 6
backend/config.py

@@ -200,27 +200,32 @@ if not os.path.exists(LITELLM_CONFIG_PATH):
 
 
 ####################################
-# OLLAMA_API_BASE_URL
+# OLLAMA_BASE_URL
 ####################################
 
 OLLAMA_API_BASE_URL = os.environ.get(
     "OLLAMA_API_BASE_URL", "http://localhost:11434/api"
 )
 
-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 ENV == "prod":
+    if OLLAMA_BASE_URL == "/ollama":
+        OLLAMA_BASE_URL = "http://host.docker.internal:11434"
 
-OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "")
 
-if OLLAMA_BASE_URL == "":
+if OLLAMA_BASE_URL == "" and OLLAMA_API_BASE_URL != "":
     OLLAMA_BASE_URL = (
         OLLAMA_API_BASE_URL[:-4]
         if OLLAMA_API_BASE_URL.endswith("/api")
         else OLLAMA_API_BASE_URL
     )
 
+OLLAMA_BASE_URLS = os.environ.get("OLLAMA_BASE_URLS", "")
+OLLAMA_BASE_URLS = OLLAMA_BASE_URLS if OLLAMA_BASE_URLS != "" else OLLAMA_BASE_URL
+
+OLLAMA_BASE_URLS = [url.strip() for url in OLLAMA_BASE_URLS.split(";")]
+
 
 ####################################
 # OPENAI_API
@@ -229,9 +234,25 @@ if OLLAMA_BASE_URL == "":
 OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
 OPENAI_API_BASE_URL = os.environ.get("OPENAI_API_BASE_URL", "")
 
+if OPENAI_API_KEY == "":
+    OPENAI_API_KEY = "none"
+
 if OPENAI_API_BASE_URL == "":
     OPENAI_API_BASE_URL = "https://api.openai.com/v1"
 
+OPENAI_API_KEYS = os.environ.get("OPENAI_API_KEYS", "")
+OPENAI_API_KEYS = OPENAI_API_KEYS if OPENAI_API_KEYS != "" else OPENAI_API_KEY
+
+OPENAI_API_KEYS = [url.strip() for url in OPENAI_API_KEYS.split(";")]
+
+
+OPENAI_API_BASE_URLS = os.environ.get("OPENAI_API_BASE_URLS", "")
+OPENAI_API_BASE_URLS = (
+    OPENAI_API_BASE_URLS if OPENAI_API_BASE_URLS != "" else OPENAI_API_BASE_URL
+)
+
+OPENAI_API_BASE_URLS = [url.strip() for url in OPENAI_API_BASE_URL.split(";")]
+
 
 ####################################
 # WEBUI

+ 2 - 0
backend/constants.py

@@ -41,6 +41,7 @@ class ERROR_MESSAGES(str, Enum):
     NOT_FOUND = "We could not find what you're looking for :/"
     USER_NOT_FOUND = "We could not find what you're looking for :/"
     API_KEY_NOT_FOUND = "Oops! It looks like there's a hiccup. The API key is missing. Please make sure to provide a valid API key to access this feature."
+
     MALICIOUS = "Unusual activities detected, please try again in a few minutes."
 
     PANDOC_NOT_INSTALLED = "Pandoc is not installed on the server. Please contact your administrator for assistance."
@@ -50,3 +51,4 @@ class ERROR_MESSAGES(str, Enum):
     RATE_LIMIT_EXCEEDED = "API rate limit exceeded"
 
     MODEL_NOT_FOUND = lambda name="": f"Model '{name}' was not found"
+    OPENAI_NOT_FOUND = lambda name="": f"OpenAI API was not found"

+ 3 - 0
backend/requirements.txt

@@ -35,6 +35,9 @@ openpyxl
 pyxlsb
 xlrd
 
+opencv-python-headless
+rapidocr-onnxruntime
+
 faster-whisper
 
 PyJWT

+ 2 - 2
docker-compose.yaml

@@ -14,7 +14,7 @@ services:
     build:
       context: .
       args:
-        OLLAMA_API_BASE_URL: '/ollama/api'
+        OLLAMA_BASE_URL: '/ollama'
       dockerfile: Dockerfile
     image: ghcr.io/open-webui/open-webui:main
     container_name: open-webui
@@ -25,7 +25,7 @@ services:
     ports:
       - ${OPEN_WEBUI_PORT-3000}:8080
     environment:
-      - 'OLLAMA_API_BASE_URL=http://ollama:11434/api'
+      - 'OLLAMA_BASE_URL=http://ollama:11434'
       - 'WEBUI_SECRET_KEY='
     extra_hosts:
       - host.docker.internal:host-gateway

+ 1 - 1
kubernetes/helm/templates/webui-deployment.yaml

@@ -40,7 +40,7 @@ spec:
         - name: data
           mountPath: /app/backend/data
         env:
-        - name: OLLAMA_API_BASE_URL
+        - name: OLLAMA_BASE_URL
           value: {{ include "ollama.url" . | quote }}
         tty: true
       {{- with .Values.webui.nodeSelector }}

+ 2 - 2
kubernetes/manifest/base/webui-deployment.yaml

@@ -26,8 +26,8 @@ spec:
             cpu: "1000m"
             memory: "1Gi"
         env:
-        - name: OLLAMA_API_BASE_URL
-          value: "http://ollama-service.open-webui.svc.cluster.local:11434/api"
+        - name: OLLAMA_BASE_URL
+          value: "http://ollama-service.open-webui.svc.cluster.local:11434"
         tty: true
         volumeMounts:
         - name: webui-volume

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
 	"name": "open-webui",
-	"version": "0.1.108",
+	"version": "0.1.110",
 	"private": true,
 	"scripts": {
 		"dev": "vite dev --host",

+ 4 - 0
src/app.css

@@ -43,6 +43,10 @@ ol > li {
 	font-weight: 400;
 }
 
+li p {
+	display: inline;
+}
+
 ::-webkit-scrollbar-thumb {
 	--tw-border-opacity: 1;
 	background-color: rgba(217, 217, 227, 0.8);

+ 14 - 14
src/lib/apis/openai/index.ts

@@ -1,9 +1,9 @@
 import { OPENAI_API_BASE_URL } from '$lib/constants';
 
-export const getOpenAIUrl = async (token: string = '') => {
+export const getOpenAIUrls = async (token: string = '') => {
 	let error = null;
 
-	const res = await fetch(`${OPENAI_API_BASE_URL}/url`, {
+	const res = await fetch(`${OPENAI_API_BASE_URL}/urls`, {
 		method: 'GET',
 		headers: {
 			Accept: 'application/json',
@@ -29,13 +29,13 @@ export const getOpenAIUrl = async (token: string = '') => {
 		throw error;
 	}
 
-	return res.OPENAI_API_BASE_URL;
+	return res.OPENAI_API_BASE_URLS;
 };
 
-export const updateOpenAIUrl = async (token: string = '', url: string) => {
+export const updateOpenAIUrls = async (token: string = '', urls: string[]) => {
 	let error = null;
 
-	const res = await fetch(`${OPENAI_API_BASE_URL}/url/update`, {
+	const res = await fetch(`${OPENAI_API_BASE_URL}/urls/update`, {
 		method: 'POST',
 		headers: {
 			Accept: 'application/json',
@@ -43,7 +43,7 @@ export const updateOpenAIUrl = async (token: string = '', url: string) => {
 			...(token && { authorization: `Bearer ${token}` })
 		},
 		body: JSON.stringify({
-			url: url
+			urls: urls
 		})
 	})
 		.then(async (res) => {
@@ -64,13 +64,13 @@ export const updateOpenAIUrl = async (token: string = '', url: string) => {
 		throw error;
 	}
 
-	return res.OPENAI_API_BASE_URL;
+	return res.OPENAI_API_BASE_URLS;
 };
 
-export const getOpenAIKey = async (token: string = '') => {
+export const getOpenAIKeys = async (token: string = '') => {
 	let error = null;
 
-	const res = await fetch(`${OPENAI_API_BASE_URL}/key`, {
+	const res = await fetch(`${OPENAI_API_BASE_URL}/keys`, {
 		method: 'GET',
 		headers: {
 			Accept: 'application/json',
@@ -96,13 +96,13 @@ export const getOpenAIKey = async (token: string = '') => {
 		throw error;
 	}
 
-	return res.OPENAI_API_KEY;
+	return res.OPENAI_API_KEYS;
 };
 
-export const updateOpenAIKey = async (token: string = '', key: string) => {
+export const updateOpenAIKeys = async (token: string = '', keys: string[]) => {
 	let error = null;
 
-	const res = await fetch(`${OPENAI_API_BASE_URL}/key/update`, {
+	const res = await fetch(`${OPENAI_API_BASE_URL}/keys/update`, {
 		method: 'POST',
 		headers: {
 			Accept: 'application/json',
@@ -110,7 +110,7 @@ export const updateOpenAIKey = async (token: string = '', key: string) => {
 			...(token && { authorization: `Bearer ${token}` })
 		},
 		body: JSON.stringify({
-			key: key
+			keys: keys
 		})
 	})
 		.then(async (res) => {
@@ -131,7 +131,7 @@ export const updateOpenAIKey = async (token: string = '', key: string) => {
 		throw error;
 	}
 
-	return res.OPENAI_API_KEY;
+	return res.OPENAI_API_KEYS;
 };
 
 export const getOpenAIModels = async (token: string = '') => {

+ 117 - 72
src/lib/components/chat/Messages.svelte

@@ -225,33 +225,80 @@
 		}, 100);
 	};
 
-	// TODO: change delete behaviour
-	// const deleteMessageAndDescendants = async (messageId: string) => {
-	// 	if (history.messages[messageId]) {
-	// 		history.messages[messageId].deleted = true;
+	const messageDeleteHandler = async (messageId) => {
+		const messageToDelete = history.messages[messageId];
+		const messageParentId = messageToDelete.parentId;
+		const messageChildrenIds = messageToDelete.childrenIds ?? [];
+		const hasSibling = messageChildrenIds.some(
+			(childId) => history.messages[childId]?.childrenIds?.length > 0
+		);
+		messageChildrenIds.forEach((childId) => {
+			const child = history.messages[childId];
+			if (child && child.childrenIds) {
+				if (child.childrenIds.length === 0 && !hasSibling) {
+					// if last prompt/response pair
+					history.messages[messageParentId].childrenIds = [];
+					history.currentId = messageParentId;
+				} else {
+					child.childrenIds.forEach((grandChildId) => {
+						if (history.messages[grandChildId]) {
+							history.messages[grandChildId].parentId = messageParentId;
+							history.messages[messageParentId].childrenIds.push(grandChildId);
+						}
+					});
+				}
+			}
+			// remove response
+			history.messages[messageParentId].childrenIds = history.messages[
+				messageParentId
+			].childrenIds.filter((id) => id !== childId);
+		});
+		// remove prompt
+		history.messages[messageParentId].childrenIds = history.messages[
+			messageParentId
+		].childrenIds.filter((id) => id !== messageId);
+		await updateChatById(localStorage.token, chatId, {
+			messages: messages,
+			history: history
+		});
+	};
+
+	// const messageDeleteHandler = async (messageId) => {
+	// 	const message = history.messages[messageId];
+	// 	const parentId = message.parentId;
+	// 	const childrenIds = message.childrenIds ?? [];
+	// 	const grandchildrenIds = [];
 
-	// 		for (const childId of history.messages[messageId].childrenIds) {
-	// 			await deleteMessageAndDescendants(childId);
+	// 	// 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);
 	// 	}
-	// };
-
-	// const triggerDeleteMessageRecursive = async (messageId: string) => {
-	// 	await deleteMessageAndDescendants(messageId);
-	// 	await updateChatById(localStorage.token, chatId, { history });
-	// 	await chats.set(await getChatList(localStorage.token));
-	// };
 
-	const messageDeleteHandler = async (messageId) => {
-		if (history.messages[messageId]) {
-			history.messages[messageId].deleted = true;
+	// 	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;
+	// 	}
 
-			for (const childId of history.messages[messageId].childrenIds) {
-				history.messages[childId].deleted = true;
-			}
-		}
-		await updateChatById(localStorage.token, chatId, { history });
-	};
+	// 	await updateChatById(localStorage.token, chatId, { messages, history });
+	// };
 </script>
 
 {#if messages.length == 0}
@@ -260,57 +307,55 @@
 	<div class=" pb-10">
 		{#key chatId}
 			{#each messages as message, messageIdx}
-				{#if !message.deleted}
-					<div class=" w-full">
-						<div
-							class="flex flex-col justify-between px-5 mb-3 {$settings?.fullScreenMode ?? null
-								? 'max-w-full'
-								: 'max-w-3xl'} mx-auto rounded-lg group"
-						>
-							{#if message.role === 'user'}
-								<UserMessage
-									on:delete={() => messageDeleteHandler(message.id)}
-									user={$user}
-									{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}
-								/>
-							{:else}
-								<ResponseMessage
-									{message}
-									modelfiles={selectedModelfiles}
-									siblings={history.messages[message.parentId]?.childrenIds ?? []}
-									isLastMessage={messageIdx + 1 === messages.length}
-									{confirmEditResponseMessage}
-									{showPreviousMessage}
-									{showNextMessage}
-									{rateMessage}
-									{copyToClipboard}
-									{continueGeneration}
-									{regenerateResponse}
-									on:save={async (e) => {
-										console.log('save', e);
-
-										const message = e.detail;
-										history.messages[message.id] = message;
-										await updateChatById(localStorage.token, chatId, {
-											messages: messages,
-											history: history
-										});
-									}}
-								/>
-							{/if}
-						</div>
+				<div class=" w-full">
+					<div
+						class="flex flex-col justify-between px-5 mb-3 {$settings?.fullScreenMode ?? null
+							? 'max-w-full'
+							: 'max-w-3xl'} mx-auto rounded-lg group"
+					>
+						{#if message.role === 'user'}
+							<UserMessage
+								on:delete={() => messageDeleteHandler(message.id)}
+								user={$user}
+								{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}
+							/>
+						{:else}
+							<ResponseMessage
+								{message}
+								modelfiles={selectedModelfiles}
+								siblings={history.messages[message.parentId]?.childrenIds ?? []}
+								isLastMessage={messageIdx + 1 === messages.length}
+								{confirmEditResponseMessage}
+								{showPreviousMessage}
+								{showNextMessage}
+								{rateMessage}
+								{copyToClipboard}
+								{continueGeneration}
+								{regenerateResponse}
+								on:save={async (e) => {
+									console.log('save', e);
+
+									const message = e.detail;
+									history.messages[message.id] = message;
+									await updateChatById(localStorage.token, chatId, {
+										messages: messages,
+										history: history
+									});
+								}}
+							/>
+						{/if}
 					</div>
-				{/if}
+				</div>
 			{/each}
 
 			{#if bottomPadding}

+ 246 - 220
src/lib/components/chat/Messages/ResponseMessage.svelte

@@ -24,6 +24,7 @@
 	import CodeBlock from './CodeBlock.svelte';
 	import Image from '$lib/components/common/Image.svelte';
 	import { WEBUI_BASE_URL } from '$lib/constants';
+	import Tooltip from '$lib/components/common/Tooltip.svelte';
 
 	export let modelfiles = [];
 	export let message;
@@ -346,6 +347,7 @@
 									class=" bg-transparent outline-none w-full resize-none"
 									bind:value={editedContent}
 									on:input={(e) => {
+										e.target.style.height = '';
 										e.target.style.height = `${e.target.scrollHeight}px`;
 									}}
 								/>
@@ -464,145 +466,15 @@
 											</div>
 										{/if}
 
-										<button
-											class="{isLastMessage
-												? 'visible'
-												: 'invisible group-hover:visible'} p-1 rounded dark:hover:text-white hover:text-black transition"
-											on:click={() => {
-												editMessageHandler();
-											}}
-										>
-											<svg
-												xmlns="http://www.w3.org/2000/svg"
-												fill="none"
-												viewBox="0 0 24 24"
-												stroke-width="1.5"
-												stroke="currentColor"
-												class="w-4 h-4"
-											>
-												<path
-													stroke-linecap="round"
-													stroke-linejoin="round"
-													d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"
-												/>
-											</svg>
-										</button>
-
-										<button
-											class="{isLastMessage
-												? 'visible'
-												: 'invisible group-hover:visible'} p-1 rounded dark:hover:text-white hover:text-black transition copy-response-button"
-											on:click={() => {
-												copyToClipboard(message.content);
-											}}
-										>
-											<svg
-												xmlns="http://www.w3.org/2000/svg"
-												fill="none"
-												viewBox="0 0 24 24"
-												stroke-width="1.5"
-												stroke="currentColor"
-												class="w-4 h-4"
-											>
-												<path
-													stroke-linecap="round"
-													stroke-linejoin="round"
-													d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
-												/>
-											</svg>
-										</button>
-
-										<button
-											class="{isLastMessage
-												? 'visible'
-												: 'invisible group-hover:visible'} p-1 rounded {message.rating === 1
-												? 'bg-gray-100 dark:bg-gray-800'
-												: ''} dark:hover:text-white hover:text-black transition"
-											on:click={() => {
-												rateMessage(message.id, 1);
-											}}
-										>
-											<svg
-												stroke="currentColor"
-												fill="none"
-												stroke-width="2"
-												viewBox="0 0 24 24"
-												stroke-linecap="round"
-												stroke-linejoin="round"
-												class="w-4 h-4"
-												xmlns="http://www.w3.org/2000/svg"
-												><path
-													d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"
-												/></svg
-											>
-										</button>
-										<button
-											class="{isLastMessage
-												? 'visible'
-												: 'invisible group-hover:visible'} p-1 rounded {message.rating === -1
-												? 'bg-gray-100 dark:bg-gray-800'
-												: ''} dark:hover:text-white hover:text-black transition"
-											on:click={() => {
-												rateMessage(message.id, -1);
-											}}
-										>
-											<svg
-												stroke="currentColor"
-												fill="none"
-												stroke-width="2"
-												viewBox="0 0 24 24"
-												stroke-linecap="round"
-												stroke-linejoin="round"
-												class="w-4 h-4"
-												xmlns="http://www.w3.org/2000/svg"
-												><path
-													d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"
-												/></svg
+										<Tooltip content="Edit" placement="bottom">
+											<button
+												class="{isLastMessage
+													? 'visible'
+													: 'invisible group-hover:visible'} p-1 rounded dark:hover:text-white hover:text-black transition"
+												on:click={() => {
+													editMessageHandler();
+												}}
 											>
-										</button>
-
-										<button
-											id="speak-button-{message.id}"
-											class="{isLastMessage
-												? 'visible'
-												: 'invisible group-hover:visible'} p-1 rounded dark:hover:text-white hover:text-black transition"
-											on:click={() => {
-												if (!loadingSpeech) {
-													toggleSpeakMessage(message);
-												}
-											}}
-										>
-											{#if loadingSpeech}
-												<svg
-													class=" w-4 h-4"
-													fill="currentColor"
-													viewBox="0 0 24 24"
-													xmlns="http://www.w3.org/2000/svg"
-													><style>
-														.spinner_S1WN {
-															animation: spinner_MGfb 0.8s linear infinite;
-															animation-delay: -0.8s;
-														}
-														.spinner_Km9P {
-															animation-delay: -0.65s;
-														}
-														.spinner_JApP {
-															animation-delay: -0.5s;
-														}
-														@keyframes spinner_MGfb {
-															93.75%,
-															100% {
-																opacity: 0.2;
-															}
-														}
-													</style><circle class="spinner_S1WN" cx="4" cy="12" r="3" /><circle
-														class="spinner_S1WN spinner_Km9P"
-														cx="12"
-														cy="12"
-														r="3"
-													/><circle class="spinner_S1WN spinner_JApP" cx="20" cy="12" r="3" /></svg
-												>
-											{:else if speaking}
 												<svg
 													xmlns="http://www.w3.org/2000/svg"
 													fill="none"
@@ -614,10 +486,21 @@
 													<path
 														stroke-linecap="round"
 														stroke-linejoin="round"
-														d="M17.25 9.75 19.5 12m0 0 2.25 2.25M19.5 12l2.25-2.25M19.5 12l-2.25 2.25m-10.5-6 4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z"
+														d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"
 													/>
 												</svg>
-											{:else}
+											</button>
+										</Tooltip>
+
+										<Tooltip content="Copy" placement="bottom">
+											<button
+												class="{isLastMessage
+													? 'visible'
+													: 'invisible group-hover:visible'} p-1 rounded dark:hover:text-white hover:text-black transition copy-response-button"
+												on:click={() => {
+													copyToClipboard(message.content);
+												}}
+											>
 												<svg
 													xmlns="http://www.w3.org/2000/svg"
 													fill="none"
@@ -629,24 +512,79 @@
 													<path
 														stroke-linecap="round"
 														stroke-linejoin="round"
-														d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z"
+														d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
 													/>
 												</svg>
-											{/if}
-										</button>
+											</button>
+										</Tooltip>
 
-										{#if $config.images}
+										<Tooltip content="Good Response" placement="bottom">
+											<button
+												class="{isLastMessage
+													? 'visible'
+													: 'invisible group-hover:visible'} p-1 rounded {message.rating === 1
+													? 'bg-gray-100 dark:bg-gray-800'
+													: ''} dark:hover:text-white hover:text-black transition"
+												on:click={() => {
+													rateMessage(message.id, 1);
+												}}
+											>
+												<svg
+													stroke="currentColor"
+													fill="none"
+													stroke-width="2"
+													viewBox="0 0 24 24"
+													stroke-linecap="round"
+													stroke-linejoin="round"
+													class="w-4 h-4"
+													xmlns="http://www.w3.org/2000/svg"
+													><path
+														d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"
+													/></svg
+												>
+											</button>
+										</Tooltip>
+
+										<Tooltip content="Bad Response" placement="bottom">
 											<button
+												class="{isLastMessage
+													? 'visible'
+													: 'invisible group-hover:visible'} p-1 rounded {message.rating === -1
+													? 'bg-gray-100 dark:bg-gray-800'
+													: ''} dark:hover:text-white hover:text-black transition"
+												on:click={() => {
+													rateMessage(message.id, -1);
+												}}
+											>
+												<svg
+													stroke="currentColor"
+													fill="none"
+													stroke-width="2"
+													viewBox="0 0 24 24"
+													stroke-linecap="round"
+													stroke-linejoin="round"
+													class="w-4 h-4"
+													xmlns="http://www.w3.org/2000/svg"
+													><path
+														d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"
+													/></svg
+												>
+											</button>
+										</Tooltip>
+
+										<Tooltip content="Read Aloud" placement="bottom">
+											<button
+												id="speak-button-{message.id}"
 												class="{isLastMessage
 													? 'visible'
 													: 'invisible group-hover:visible'} p-1 rounded dark:hover:text-white hover:text-black transition"
 												on:click={() => {
-													if (!generatingImage) {
-														generateImage(message);
+													if (!loadingSpeech) {
+														toggleSpeakMessage(message);
 													}
 												}}
 											>
-												{#if generatingImage}
+												{#if loadingSpeech}
 													<svg
 														class=" w-4 h-4"
 														fill="currentColor"
@@ -681,6 +619,21 @@
 															r="3"
 														/></svg
 													>
+												{:else if speaking}
+													<svg
+														xmlns="http://www.w3.org/2000/svg"
+														fill="none"
+														viewBox="0 0 24 24"
+														stroke-width="1.5"
+														stroke="currentColor"
+														class="w-4 h-4"
+													>
+														<path
+															stroke-linecap="round"
+															stroke-linejoin="round"
+															d="M17.25 9.75 19.5 12m0 0 2.25 2.25M19.5 12l2.25-2.25M19.5 12l-2.25 2.25m-10.5-6 4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z"
+														/>
+													</svg>
 												{:else}
 													<svg
 														xmlns="http://www.w3.org/2000/svg"
@@ -693,93 +646,166 @@
 														<path
 															stroke-linecap="round"
 															stroke-linejoin="round"
-															d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"
+															d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z"
 														/>
 													</svg>
 												{/if}
 											</button>
+										</Tooltip>
+
+										{#if $config.images}
+											<Tooltip content="Generate Image" placement="bottom">
+												<button
+													class="{isLastMessage
+														? 'visible'
+														: 'invisible group-hover:visible'} p-1 rounded dark:hover:text-white hover:text-black transition"
+													on:click={() => {
+														if (!generatingImage) {
+															generateImage(message);
+														}
+													}}
+												>
+													{#if generatingImage}
+														<svg
+															class=" w-4 h-4"
+															fill="currentColor"
+															viewBox="0 0 24 24"
+															xmlns="http://www.w3.org/2000/svg"
+															><style>
+																.spinner_S1WN {
+																	animation: spinner_MGfb 0.8s linear infinite;
+																	animation-delay: -0.8s;
+																}
+																.spinner_Km9P {
+																	animation-delay: -0.65s;
+																}
+																.spinner_JApP {
+																	animation-delay: -0.5s;
+																}
+																@keyframes spinner_MGfb {
+																	93.75%,
+																	100% {
+																		opacity: 0.2;
+																	}
+																}
+															</style><circle class="spinner_S1WN" cx="4" cy="12" r="3" /><circle
+																class="spinner_S1WN spinner_Km9P"
+																cx="12"
+																cy="12"
+																r="3"
+															/><circle
+																class="spinner_S1WN spinner_JApP"
+																cx="20"
+																cy="12"
+																r="3"
+															/></svg
+														>
+													{:else}
+														<svg
+															xmlns="http://www.w3.org/2000/svg"
+															fill="none"
+															viewBox="0 0 24 24"
+															stroke-width="1.5"
+															stroke="currentColor"
+															class="w-4 h-4"
+														>
+															<path
+																stroke-linecap="round"
+																stroke-linejoin="round"
+																d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"
+															/>
+														</svg>
+													{/if}
+												</button>
+											</Tooltip>
 										{/if}
 
 										{#if message.info}
-											<button
-												class=" {isLastMessage
-													? 'visible'
-													: 'invisible group-hover:visible'} p-1 rounded dark:hover:text-white hover:text-black transition whitespace-pre-wrap"
-												on:click={() => {
-													console.log(message);
-												}}
-												id="info-{message.id}"
-											>
-												<svg
-													xmlns="http://www.w3.org/2000/svg"
-													fill="none"
-													viewBox="0 0 24 24"
-													stroke-width="1.5"
-													stroke="currentColor"
-													class="w-4 h-4"
+											<Tooltip content="Generation Info" placement="bottom">
+												<button
+													class=" {isLastMessage
+														? 'visible'
+														: 'invisible group-hover:visible'} p-1 rounded dark:hover:text-white hover:text-black transition whitespace-pre-wrap"
+													on:click={() => {
+														console.log(message);
+													}}
+													id="info-{message.id}"
 												>
-													<path
-														stroke-linecap="round"
-														stroke-linejoin="round"
-														d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
-													/>
-												</svg>
-											</button>
+													<svg
+														xmlns="http://www.w3.org/2000/svg"
+														fill="none"
+														viewBox="0 0 24 24"
+														stroke-width="1.5"
+														stroke="currentColor"
+														class="w-4 h-4"
+													>
+														<path
+															stroke-linecap="round"
+															stroke-linejoin="round"
+															d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
+														/>
+													</svg>
+												</button>
+											</Tooltip>
 										{/if}
 
 										{#if isLastMessage}
-											<button
-												type="button"
-												class="{isLastMessage
-													? 'visible'
-													: 'invisible group-hover:visible'} p-1 rounded dark:hover:text-white hover:text-black transition regenerate-response-button"
-												on:click={() => {
-													continueGeneration();
-												}}
-											>
-												<svg
-													xmlns="http://www.w3.org/2000/svg"
-													fill="none"
-													viewBox="0 0 24 24"
-													stroke-width="1.5"
-													stroke="currentColor"
-													class="w-4 h-4"
+											<Tooltip content="Continue Response" placement="bottom">
+												<button
+													type="button"
+													class="{isLastMessage
+														? 'visible'
+														: 'invisible group-hover:visible'} p-1 rounded dark:hover:text-white hover:text-black transition regenerate-response-button"
+													on:click={() => {
+														continueGeneration();
+													}}
 												>
-													<path
-														stroke-linecap="round"
-														stroke-linejoin="round"
-														d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
-													/>
-													<path
-														stroke-linecap="round"
-														stroke-linejoin="round"
-														d="M15.91 11.672a.375.375 0 0 1 0 .656l-5.603 3.113a.375.375 0 0 1-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112Z"
-													/>
-												</svg>
-											</button>
+													<svg
+														xmlns="http://www.w3.org/2000/svg"
+														fill="none"
+														viewBox="0 0 24 24"
+														stroke-width="1.5"
+														stroke="currentColor"
+														class="w-4 h-4"
+													>
+														<path
+															stroke-linecap="round"
+															stroke-linejoin="round"
+															d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
+														/>
+														<path
+															stroke-linecap="round"
+															stroke-linejoin="round"
+															d="M15.91 11.672a.375.375 0 0 1 0 .656l-5.603 3.113a.375.375 0 0 1-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112Z"
+														/>
+													</svg>
+												</button>
+											</Tooltip>
 
-											<button
-												type="button"
-												class="{isLastMessage
-													? 'visible'
-													: 'invisible group-hover:visible'} p-1 rounded dark:hover:text-white hover:text-black transition regenerate-response-button"
-												on:click={regenerateResponse}
-											>
-												<svg
-													xmlns="http://www.w3.org/2000/svg"
-													fill="none"
-													viewBox="0 0 24 24"
-													stroke-width="1.5"
-													stroke="currentColor"
-													class="w-4 h-4"
+											<Tooltip content="Regenerate" placement="bottom">
+												<button
+													type="button"
+													class="{isLastMessage
+														? 'visible'
+														: 'invisible group-hover:visible'} p-1 rounded dark:hover:text-white hover:text-black transition regenerate-response-button"
+													on:click={regenerateResponse}
 												>
-													<path
-														stroke-linecap="round"
-														stroke-linejoin="round"
-														d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
-													/>
-												</svg>
-											</button>
+													<svg
+														xmlns="http://www.w3.org/2000/svg"
+														fill="none"
+														viewBox="0 0 24 24"
+														stroke-width="1.5"
+														stroke="currentColor"
+														class="w-4 h-4"
+													>
+														<path
+															stroke-linecap="round"
+															stroke-linejoin="round"
+															d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
+														/>
+													</svg>
+												</button>
+											</Tooltip>
 										{/if}
 									</div>
 								{/if}

+ 54 - 46
src/lib/components/chat/Messages/UserMessage.svelte

@@ -5,6 +5,7 @@
 	import Name from './Name.svelte';
 	import ProfileImage from './ProfileImage.svelte';
 	import { modelfiles, settings } from '$lib/stores';
+	import Tooltip from '$lib/components/common/Tooltip.svelte';
 
 	const i18n = getContext('i18n');
 
@@ -171,7 +172,8 @@
 						class=" bg-transparent outline-none w-full resize-none"
 						bind:value={editedContent}
 						on:input={(e) => {
-							messageEditTextAreaElement.style.height = `${messageEditTextAreaElement.scrollHeight}px`;
+							e.target.style.height = '';
+							e.target.style.height = `${e.target.scrollHeight}px`;
 						}}
 					/>
 
@@ -248,55 +250,35 @@
 							</div>
 						{/if}
 
-						<button
-							class="invisible group-hover:visible p-1 rounded dark:hover:text-white hover:text-black transition edit-user-message-button"
-							on:click={() => {
-								editMessageHandler();
-							}}
-						>
-							<svg
-								xmlns="http://www.w3.org/2000/svg"
-								fill="none"
-								viewBox="0 0 24 24"
-								stroke-width="1.5"
-								stroke="currentColor"
-								class="w-4 h-4"
-							>
-								<path
-									stroke-linecap="round"
-									stroke-linejoin="round"
-									d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"
-								/>
-							</svg>
-						</button>
-
-						<button
-							class="invisible group-hover:visible p-1 rounded dark:hover:text-white hover:text-black transition"
-							on:click={() => {
-								copyToClipboard(message.content);
-							}}
-						>
-							<svg
-								xmlns="http://www.w3.org/2000/svg"
-								fill="none"
-								viewBox="0 0 24 24"
-								stroke-width="1.5"
-								stroke="currentColor"
-								class="w-4 h-4"
+						<Tooltip content="Edit" placement="bottom">
+							<button
+								class="invisible group-hover:visible p-1 rounded dark:hover:text-white hover:text-black transition edit-user-message-button"
+								on:click={() => {
+									editMessageHandler();
+								}}
 							>
-								<path
-									stroke-linecap="round"
-									stroke-linejoin="round"
-									d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
-								/>
-							</svg>
-						</button>
+								<svg
+									xmlns="http://www.w3.org/2000/svg"
+									fill="none"
+									viewBox="0 0 24 24"
+									stroke-width="1.5"
+									stroke="currentColor"
+									class="w-4 h-4"
+								>
+									<path
+										stroke-linecap="round"
+										stroke-linejoin="round"
+										d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"
+									/>
+								</svg>
+							</button>
+						</Tooltip>
 
-						{#if !isFirstMessage}
+						<Tooltip content="Copy" placement="bottom">
 							<button
 								class="invisible group-hover:visible p-1 rounded dark:hover:text-white hover:text-black transition"
 								on:click={() => {
-									deleteMessageHandler();
+									copyToClipboard(message.content);
 								}}
 							>
 								<svg
@@ -310,10 +292,36 @@
 									<path
 										stroke-linecap="round"
 										stroke-linejoin="round"
-										d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
+										d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
 									/>
 								</svg>
 							</button>
+						</Tooltip>
+
+						{#if !isFirstMessage}
+							<Tooltip content="Delete" placement="bottom">
+								<button
+									class="invisible group-hover:visible p-1 rounded dark:hover:text-white hover:text-black transition"
+									on:click={() => {
+										deleteMessageHandler();
+									}}
+								>
+									<svg
+										xmlns="http://www.w3.org/2000/svg"
+										fill="none"
+										viewBox="0 0 24 24"
+										stroke-width="1.5"
+										stroke="currentColor"
+										class="w-4 h-4"
+									>
+										<path
+											stroke-linecap="round"
+											stroke-linejoin="round"
+											d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
+										/>
+									</svg>
+								</button>
+							</Tooltip>
 						{/if}
 					</div>
 				</div>

+ 79 - 35
src/lib/components/chat/Settings/Connections.svelte

@@ -4,7 +4,12 @@
 	const dispatch = createEventDispatcher();
 
 	import { getOllamaUrls, getOllamaVersion, updateOllamaUrls } from '$lib/apis/ollama';
-	import { getOpenAIKey, getOpenAIUrl, updateOpenAIKey, updateOpenAIUrl } from '$lib/apis/openai';
+	import {
+		getOpenAIKeys,
+		getOpenAIUrls,
+		updateOpenAIKeys,
+		updateOpenAIUrls
+	} from '$lib/apis/openai';
 	import { toast } from 'svelte-sonner';
 
 	const i18n = getContext('i18n');
@@ -18,12 +23,14 @@
 	let OPENAI_API_KEY = '';
 	let OPENAI_API_BASE_URL = '';
 
+	let OPENAI_API_KEYS = [''];
+	let OPENAI_API_BASE_URLS = [''];
+
 	let showOpenAI = false;
-	let showLiteLLM = false;
 
 	const updateOpenAIHandler = async () => {
-		OPENAI_API_BASE_URL = await updateOpenAIUrl(localStorage.token, OPENAI_API_BASE_URL);
-		OPENAI_API_KEY = await updateOpenAIKey(localStorage.token, OPENAI_API_KEY);
+		OPENAI_API_BASE_URLS = await updateOpenAIUrls(localStorage.token, OPENAI_API_BASE_URLS);
+		OPENAI_API_KEYS = await updateOpenAIKeys(localStorage.token, OPENAI_API_KEYS);
 
 		await models.set(await getModels());
 	};
@@ -45,8 +52,8 @@
 	onMount(async () => {
 		if ($user.role === 'admin') {
 			OLLAMA_BASE_URLS = await getOllamaUrls(localStorage.token);
-			OPENAI_API_BASE_URL = await getOpenAIUrl(localStorage.token);
-			OPENAI_API_KEY = await getOpenAIKey(localStorage.token);
+			OPENAI_API_BASE_URLS = await getOpenAIUrls(localStorage.token);
+			OPENAI_API_KEYS = await getOpenAIKeys(localStorage.token);
 		}
 	});
 </script>
@@ -73,37 +80,74 @@
 				</div>
 
 				{#if showOpenAI}
-					<div>
-						<div class=" mb-2.5 text-sm font-medium">{$i18n.t('API Key')}</div>
-						<div class="flex w-full">
-							<div class="flex-1">
-								<input
-									class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
-									placeholder={$i18n.t('Enter OpenAI API Key')}
-									bind:value={OPENAI_API_KEY}
-									autocomplete="off"
-								/>
-							</div>
-						</div>
-					</div>
+					<div class="flex flex-col gap-1">
+						{#each OPENAI_API_BASE_URLS as url, idx}
+							<div class="flex w-full gap-2">
+								<div class="flex-1">
+									<input
+										class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
+										placeholder={$i18n.t('API Base URL')}
+										bind:value={url}
+										autocomplete="off"
+									/>
+								</div>
 
-					<div>
-						<div class=" mb-2.5 text-sm font-medium">{$i18n.t('API Base URL')}</div>
-						<div class="flex w-full">
-							<div class="flex-1">
-								<input
-									class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
-									placeholder="Enter OpenAI API Base URL"
-									bind:value={OPENAI_API_BASE_URL}
-									autocomplete="off"
-								/>
+								<div class="flex-1">
+									<input
+										class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
+										placeholder={$i18n.t('API Key')}
+										bind:value={OPENAI_API_KEYS[idx]}
+										autocomplete="off"
+									/>
+								</div>
+								<div class="self-center flex items-center">
+									{#if idx === 0}
+										<button
+											class="px-1"
+											on:click={() => {
+												OPENAI_API_BASE_URLS = [...OPENAI_API_BASE_URLS, ''];
+												OPENAI_API_KEYS = [...OPENAI_API_KEYS, ''];
+											}}
+											type="button"
+										>
+											<svg
+												xmlns="http://www.w3.org/2000/svg"
+												viewBox="0 0 16 16"
+												fill="currentColor"
+												class="w-4 h-4"
+											>
+												<path
+													d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z"
+												/>
+											</svg>
+										</button>
+									{:else}
+										<button
+											class="px-1"
+											on:click={() => {
+												OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS.filter(
+													(url, urlIdx) => idx !== urlIdx
+												);
+												OPENAI_API_KEYS = OPENAI_API_KEYS.filter((key, keyIdx) => idx !== keyIdx);
+											}}
+											type="button"
+										>
+											<svg
+												xmlns="http://www.w3.org/2000/svg"
+												viewBox="0 0 16 16"
+												fill="currentColor"
+												class="w-4 h-4"
+											>
+												<path d="M3.75 7.25a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5h-8.5Z" />
+											</svg>
+										</button>
+									{/if}
+								</div>
 							</div>
-						</div>
-						<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
-							WebUI will make requests to <span class=" text-gray-200"
-								>'{OPENAI_API_BASE_URL}/chat'</span
-							>
-						</div>
+							<div class=" mb-1 text-xs text-gray-400 dark:text-gray-500">
+								WebUI will make requests to <span class=" text-gray-200">'{url}/models'</span>
+							</div>
+						{/each}
 					</div>
 				{/if}
 			</div>

+ 3 - 3
src/lib/components/chat/Settings/Models.svelte

@@ -56,7 +56,7 @@
 	let modelUploadMode = 'file';
 	let modelInputFile = '';
 	let modelFileUrl = '';
-	let modelFileContent = `TEMPLATE """{{ .System }}\nUSER: {{ .Prompt }}\nASSSISTANT: """\nPARAMETER num_ctx 4096\nPARAMETER stop "</s>"\nPARAMETER stop "USER:"\nPARAMETER stop "ASSSISTANT:"`;
+	let modelFileContent = `TEMPLATE """{{ .System }}\nUSER: {{ .Prompt }}\nASSISTANT: """\nPARAMETER num_ctx 4096\nPARAMETER stop "</s>"\nPARAMETER stop "USER:"\nPARAMETER stop "ASSISTANT:"`;
 	let modelFileDigest = '';
 	let uploadProgress = null;
 
@@ -517,7 +517,7 @@
 									{#if !deleteModelTag}
 										<option value="" disabled selected>Select a model</option>
 									{/if}
-									{#each $models.filter((m) => m.size != null) as model}
+									{#each $models.filter((m) => m.size != null && (selectedOllamaUrlIdx === null ? true : (m?.urls ?? []).includes(selectedOllamaUrlIdx))) as model}
 										<option value={model.name} class="bg-gray-100 dark:bg-gray-700"
 											>{model.name + ' (' + (model.size / 1024 ** 3).toFixed(1) + ' GB)'}</option
 										>
@@ -599,7 +599,7 @@
 												on:change={() => {
 													console.log(modelInputFile);
 												}}
-												accept=".gguf"
+												accept=".gguf,.safetensors"
 												required
 												hidden
 											/>

+ 3 - 1
src/lib/components/documents/AddDocModal.svelte

@@ -140,7 +140,9 @@
 						<button
 							class="w-full text-sm font-medium py-3 bg-gray-850 hover:bg-gray-800 text-center rounded-xl"
 							type="button"
-							on:click={uploadDocInputElement.click}
+							on:click={() => {
+								uploadDocInputElement.click();
+							}}
 						>
 							{#if inputFiles}
 								{inputFiles.length > 0 ? `${inputFiles.length}` : ''} document(s) selected.

+ 0 - 5
src/lib/constants.ts

@@ -90,8 +90,3 @@ export const SUPPORTED_FILE_EXTENSIONS = [
 // This feature, akin to $env/static/private, exclusively incorporates environment variables
 // that are prefixed with config.kit.env.publicPrefix (usually set to PUBLIC_).
 // Consequently, these variables can be securely exposed to client-side code.
-
-// Example of the .env configuration:
-// OLLAMA_API_BASE_URL="http://localhost:11434/api"
-// # Public
-// PUBLIC_API_BASE_URL=$OLLAMA_API_BASE_URL

+ 0 - 3
src/routes/(app)/+layout.svelte

@@ -99,14 +99,11 @@
 					if (localDBChats.length === 0) {
 						await deleteDB('Chats');
 					}
-
-					console.log('localdb', localDBChats);
 				}
 
 				console.log(DB);
 			} catch (error) {
 				// IndexedDB Not Found
-				console.log('IDB Not Found');
 			}
 
 			console.log();

+ 2 - 2
src/routes/(app)/+page.svelte

@@ -344,7 +344,7 @@
 						content: $settings.system
 				  }
 				: undefined,
-			...messages.filter((message) => !message.deleted)
+			...messages
 		]
 			.filter((message) => message)
 			.map((message, idx, arr) => ({
@@ -558,7 +558,7 @@
 								content: $settings.system
 						  }
 						: undefined,
-					...messages.filter((message) => !message.deleted)
+					...messages
 				]
 					.filter((message) => message)
 					.map((message, idx, arr) => ({

+ 2 - 2
src/routes/(app)/c/[id]/+page.svelte

@@ -354,7 +354,7 @@
 						content: $settings.system
 				  }
 				: undefined,
-			...messages.filter((message) => !message.deleted)
+			...messages
 		]
 			.filter((message) => message)
 			.map((message, idx, arr) => ({
@@ -568,7 +568,7 @@
 								content: $settings.system
 						  }
 						: undefined,
-					...messages.filter((message) => !message.deleted)
+					...messages
 				]
 					.filter((message) => message)
 					.map((message, idx, arr) => ({