diff --git a/.github/workflows/docker-build.yaml b/.github/workflows/docker-build.yaml index da40f56ff..0e62be3d9 100644 --- a/.github/workflows/docker-build.yaml +++ b/.github/workflows/docker-build.yaml @@ -11,8 +11,6 @@ on: env: REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - FULL_IMAGE_NAME: ghcr.io/${{ github.repository }} jobs: build-main-image: @@ -28,6 +26,15 @@ jobs: - linux/arm64 steps: + # GitHub Packages requires the entire repository name to be in lowercase + # although the repository owner has a lowercase username, this prevents some people from running actions after forking + - name: Set repository and image name to lowercase + run: | + echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV} + echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV} + env: + IMAGE_NAME: '${{ github.repository }}' + - name: Prepare run: | platform=${{ matrix.platform }} @@ -116,6 +123,15 @@ jobs: - linux/arm64 steps: + # GitHub Packages requires the entire repository name to be in lowercase + # although the repository owner has a lowercase username, this prevents some people from running actions after forking + - name: Set repository and image name to lowercase + run: | + echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV} + echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV} + env: + IMAGE_NAME: '${{ github.repository }}' + - name: Prepare run: | platform=${{ matrix.platform }} @@ -207,6 +223,15 @@ jobs: - linux/arm64 steps: + # GitHub Packages requires the entire repository name to be in lowercase + # although the repository owner has a lowercase username, this prevents some people from running actions after forking + - name: Set repository and image name to lowercase + run: | + echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV} + echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV} + env: + IMAGE_NAME: '${{ github.repository }}' + - name: Prepare run: | platform=${{ matrix.platform }} @@ -289,6 +314,15 @@ jobs: runs-on: ubuntu-latest needs: [ build-main-image ] steps: + # GitHub Packages requires the entire repository name to be in lowercase + # although the repository owner has a lowercase username, this prevents some people from running actions after forking + - name: Set repository and image name to lowercase + run: | + echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV} + echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV} + env: + IMAGE_NAME: '${{ github.repository }}' + - name: Download digests uses: actions/download-artifact@v4 with: @@ -335,6 +369,15 @@ jobs: runs-on: ubuntu-latest needs: [ build-cuda-image ] steps: + # GitHub Packages requires the entire repository name to be in lowercase + # although the repository owner has a lowercase username, this prevents some people from running actions after forking + - name: Set repository and image name to lowercase + run: | + echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV} + echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV} + env: + IMAGE_NAME: '${{ github.repository }}' + - name: Download digests uses: actions/download-artifact@v4 with: @@ -382,6 +425,15 @@ jobs: runs-on: ubuntu-latest needs: [ build-ollama-image ] steps: + # GitHub Packages requires the entire repository name to be in lowercase + # although the repository owner has a lowercase username, this prevents some people from running actions after forking + - name: Set repository and image name to lowercase + run: | + echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV} + echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV} + env: + IMAGE_NAME: '${{ github.repository }}' + - name: Download digests uses: actions/download-artifact@v4 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index bfff72eed..6756d105b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ 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.3.5] - 2024-06-16 + +### Added + +- **📞 Enhanced Voice Call**: Text-to-speech (TTS) callback now operates in real-time for each sentence, reducing latency by not waiting for full completion. +- **👆 Tap to Interrupt**: During a call, you can now stop the assistant from speaking by simply tapping, instead of using voice. This resolves the issue of the speaker's voice being mistakenly registered as input. +- **😊 Emoji Call**: Toggle this feature on from the Settings > Interface, allowing LLMs to express emotions using emojis during voice calls for a more dynamic interaction. +- **🖱️ Quick Archive/Delete**: Use the Shift key + mouseover on the chat list to swiftly archive or delete items. +- **📝 Markdown Support in Model Descriptions**: You can now format model descriptions with markdown, enabling bold text, links, etc. +- **🧠 Editable Memories**: Adds the capability to modify memories. +- **📋 Admin Panel Sorting**: Introduces the ability to sort users/chats within the admin panel. +- **🌑 Dark Mode for Quick Selectors**: Dark mode now available for chat quick selectors (prompts, models, documents). +- **🔧 Advanced Parameters**: Adds 'num_keep' and 'num_batch' to advanced parameters for customization. +- **📅 Dynamic System Prompts**: New variables '{{CURRENT_DATETIME}}', '{{CURRENT_TIME}}', '{{USER_LOCATION}}' added for system prompts. Ensure '{{USER_LOCATION}}' is toggled on from Settings > Interface. +- **🌐 Tavily Web Search**: Includes Tavily as a web search provider option. +- **🖊️ Federated Auth Usernames**: Ability to set user names for federated authentication. +- **🔗 Auto Clean URLs**: When adding connection URLs, trailing slashes are now automatically removed. +- **🌐 Enhanced Translations**: Improved Chinese and Swedish translations. + +### Fixed + +- **⏳ AIOHTTP_CLIENT_TIMEOUT**: Introduced a new environment variable 'AIOHTTP_CLIENT_TIMEOUT' for requests to Ollama lasting longer than 5 minutes. Default is 300 seconds; set to blank ('') for no timeout. +- **❌ Message Delete Freeze**: Resolved an issue where message deletion would sometimes cause the web UI to freeze. + ## [0.3.4] - 2024-06-12 ### Fixed diff --git a/README.md b/README.md index 5f6e4550b..f3cfe0d27 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ Check our Migration Guide available in our [Open WebUI Documentation](https://do If you want to try out the latest bleeding-edge features and are okay with occasional instability, you can use the `:dev` tag like this: ```bash -docker run -d -p 3000:8080 -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:dev +docker run -d -p 3000:8080 -v open-webui:/app/backend/data --name open-webui --add-host=host.docker.internal:host-gateway --restart always ghcr.io/open-webui/open-webui:dev ``` ## What's Next? 🌟 diff --git a/backend/apps/images/main.py b/backend/apps/images/main.py index 4419ccf19..af7e5592d 100644 --- a/backend/apps/images/main.py +++ b/backend/apps/images/main.py @@ -37,6 +37,10 @@ from config import ( ENABLE_IMAGE_GENERATION, AUTOMATIC1111_BASE_URL, COMFYUI_BASE_URL, + COMFYUI_CFG_SCALE, + COMFYUI_SAMPLER, + COMFYUI_SCHEDULER, + COMFYUI_SD3, IMAGES_OPENAI_API_BASE_URL, IMAGES_OPENAI_API_KEY, IMAGE_GENERATION_MODEL, @@ -78,6 +82,10 @@ app.state.config.COMFYUI_BASE_URL = COMFYUI_BASE_URL app.state.config.IMAGE_SIZE = IMAGE_SIZE app.state.config.IMAGE_STEPS = IMAGE_STEPS +app.state.config.COMFYUI_CFG_SCALE = COMFYUI_CFG_SCALE +app.state.config.COMFYUI_SAMPLER = COMFYUI_SAMPLER +app.state.config.COMFYUI_SCHEDULER = COMFYUI_SCHEDULER +app.state.config.COMFYUI_SD3 = COMFYUI_SD3 @app.get("/config") @@ -457,6 +465,18 @@ def generate_image( if form_data.negative_prompt is not None: data["negative_prompt"] = form_data.negative_prompt + if app.state.config.COMFYUI_CFG_SCALE: + data["cfg_scale"] = app.state.config.COMFYUI_CFG_SCALE + + if app.state.config.COMFYUI_SAMPLER is not None: + data["sampler"] = app.state.config.COMFYUI_SAMPLER + + if app.state.config.COMFYUI_SCHEDULER is not None: + data["scheduler"] = app.state.config.COMFYUI_SCHEDULER + + if app.state.config.COMFYUI_SD3 is not None: + data["sd3"] = app.state.config.COMFYUI_SD3 + data = ImageGenerationPayload(**data) res = comfyui_generate_image( diff --git a/backend/apps/images/utils/comfyui.py b/backend/apps/images/utils/comfyui.py index 05df1c166..599b1f337 100644 --- a/backend/apps/images/utils/comfyui.py +++ b/backend/apps/images/utils/comfyui.py @@ -190,6 +190,10 @@ class ImageGenerationPayload(BaseModel): width: int height: int n: int = 1 + cfg_scale: Optional[float] = None + sampler: Optional[str] = None + scheduler: Optional[str] = None + sd3: Optional[bool] = None def comfyui_generate_image( @@ -199,6 +203,18 @@ def comfyui_generate_image( comfyui_prompt = json.loads(COMFYUI_DEFAULT_PROMPT) + if payload.cfg_scale: + comfyui_prompt["3"]["inputs"]["cfg"] = payload.cfg_scale + + if payload.sampler: + comfyui_prompt["3"]["inputs"]["sampler"] = payload.sampler + + if payload.scheduler: + comfyui_prompt["3"]["inputs"]["scheduler"] = payload.scheduler + + if payload.sd3: + comfyui_prompt["5"]["class_type"] = "EmptySD3LatentImage" + comfyui_prompt["4"]["inputs"]["ckpt_name"] = model comfyui_prompt["5"]["inputs"]["batch_size"] = payload.n comfyui_prompt["5"]["inputs"]["width"] = payload.width diff --git a/backend/apps/ollama/main.py b/backend/apps/ollama/main.py index 118c688d3..81a3b2a0e 100644 --- a/backend/apps/ollama/main.py +++ b/backend/apps/ollama/main.py @@ -850,8 +850,7 @@ async def generate_chat_completion( url = app.state.config.OLLAMA_BASE_URLS[url_idx] log.info(f"url: {url}") - - print(payload) + log.debug(payload) return await post_streaming_url(f"{url}/api/chat", json.dumps(payload)) diff --git a/backend/apps/openai/main.py b/backend/apps/openai/main.py index 93f913dea..c09c030d2 100644 --- a/backend/apps/openai/main.py +++ b/backend/apps/openai/main.py @@ -430,13 +430,11 @@ async def generate_chat_completion( # Convert the modified body back to JSON payload = json.dumps(payload) - print(payload) + log.debug(payload) url = app.state.config.OPENAI_API_BASE_URLS[idx] key = app.state.config.OPENAI_API_KEYS[idx] - print(payload) - headers = {} headers["Authorization"] = f"Bearer {key}" headers["Content-Type"] = "application/json" diff --git a/backend/apps/webui/internal/migrations/013_add_user_info.py b/backend/apps/webui/internal/migrations/013_add_user_info.py new file mode 100644 index 000000000..0f68669cc --- /dev/null +++ b/backend/apps/webui/internal/migrations/013_add_user_info.py @@ -0,0 +1,48 @@ +"""Peewee migrations -- 002_add_local_sharing.py. + +Some examples (model - class or model name):: + + > Model = migrator.orm['table_name'] # Return model in current state by name + > Model = migrator.ModelClass # Return model in current state by name + + > migrator.sql(sql) # Run custom SQL + > migrator.run(func, *args, **kwargs) # Run python function with the given args + > migrator.create_model(Model) # Create a model (could be used as decorator) + > migrator.remove_model(model, cascade=True) # Remove a model + > migrator.add_fields(model, **fields) # Add fields to a model + > migrator.change_fields(model, **fields) # Change fields + > migrator.remove_fields(model, *field_names, cascade=True) + > migrator.rename_field(model, old_field_name, new_field_name) + > migrator.rename_table(model, new_table_name) + > migrator.add_index(model, *col_names, unique=False) + > migrator.add_not_null(model, *field_names) + > migrator.add_default(model, field_name, default) + > migrator.add_constraint(model, name, sql) + > migrator.drop_index(model, *col_names) + > migrator.drop_not_null(model, *field_names) + > migrator.drop_constraints(model, *constraints) + +""" + +from contextlib import suppress + +import peewee as pw +from peewee_migrate import Migrator + + +with suppress(ImportError): + import playhouse.postgres_ext as pw_pext + + +def migrate(migrator: Migrator, database: pw.Database, *, fake=False): + """Write your migrations here.""" + + # Adding fields info to the 'user' table + migrator.add_fields("user", info=pw.TextField(null=True)) + + +def rollback(migrator: Migrator, database: pw.Database, *, fake=False): + """Write your rollback migrations here.""" + + # Remove the settings field + migrator.remove_fields("user", "info") diff --git a/backend/apps/webui/main.py b/backend/apps/webui/main.py index 62a0a7a7b..190d2d1c3 100644 --- a/backend/apps/webui/main.py +++ b/backend/apps/webui/main.py @@ -25,6 +25,7 @@ from config import ( USER_PERMISSIONS, WEBHOOK_URL, WEBUI_AUTH_TRUSTED_EMAIL_HEADER, + WEBUI_AUTH_TRUSTED_NAME_HEADER, JWT_EXPIRES_IN, WEBUI_BANNERS, ENABLE_COMMUNITY_SHARING, @@ -40,6 +41,7 @@ app.state.config = AppConfig() app.state.config.ENABLE_SIGNUP = ENABLE_SIGNUP app.state.config.JWT_EXPIRES_IN = JWT_EXPIRES_IN app.state.AUTH_TRUSTED_EMAIL_HEADER = WEBUI_AUTH_TRUSTED_EMAIL_HEADER +app.state.AUTH_TRUSTED_NAME_HEADER = WEBUI_AUTH_TRUSTED_NAME_HEADER app.state.config.SHOW_ADMIN_DETAILS = SHOW_ADMIN_DETAILS diff --git a/backend/apps/webui/models/users.py b/backend/apps/webui/models/users.py index 48811e8af..485a9eea4 100644 --- a/backend/apps/webui/models/users.py +++ b/backend/apps/webui/models/users.py @@ -26,6 +26,7 @@ class User(Model): api_key = CharField(null=True, unique=True) settings = JSONField(null=True) + info = JSONField(null=True) class Meta: database = DB @@ -50,6 +51,7 @@ class UserModel(BaseModel): api_key: Optional[str] = None settings: Optional[UserSettings] = None + info: Optional[dict] = None #################### diff --git a/backend/apps/webui/routers/auths.py b/backend/apps/webui/routers/auths.py index d45879a24..16e395737 100644 --- a/backend/apps/webui/routers/auths.py +++ b/backend/apps/webui/routers/auths.py @@ -33,7 +33,11 @@ from utils.utils import ( from utils.misc import parse_duration, validate_email_format from utils.webhook import post_webhook from constants import ERROR_MESSAGES, WEBHOOK_MESSAGES -from config import WEBUI_AUTH, WEBUI_AUTH_TRUSTED_EMAIL_HEADER +from config import ( + WEBUI_AUTH, + WEBUI_AUTH_TRUSTED_EMAIL_HEADER, + WEBUI_AUTH_TRUSTED_NAME_HEADER, +) router = APIRouter() @@ -110,11 +114,16 @@ async def signin(request: Request, form_data: SigninForm): raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_TRUSTED_HEADER) trusted_email = request.headers[WEBUI_AUTH_TRUSTED_EMAIL_HEADER].lower() + trusted_name = trusted_email + if WEBUI_AUTH_TRUSTED_NAME_HEADER: + trusted_name = request.headers.get( + WEBUI_AUTH_TRUSTED_NAME_HEADER, trusted_email + ) if not Users.get_user_by_email(trusted_email.lower()): await signup( request, SignupForm( - email=trusted_email, password=str(uuid.uuid4()), name=trusted_email + email=trusted_email, password=str(uuid.uuid4()), name=trusted_name ), ) user = Auths.authenticate_user_by_trusted_header(trusted_email) diff --git a/backend/apps/webui/routers/users.py b/backend/apps/webui/routers/users.py index eccafde10..270d72a23 100644 --- a/backend/apps/webui/routers/users.py +++ b/backend/apps/webui/routers/users.py @@ -115,6 +115,52 @@ async def update_user_settings_by_session_user( ) +############################ +# GetUserInfoBySessionUser +############################ + + +@router.get("/user/info", response_model=Optional[dict]) +async def get_user_info_by_session_user(user=Depends(get_verified_user)): + user = Users.get_user_by_id(user.id) + if user: + return user.info + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.USER_NOT_FOUND, + ) + + +############################ +# UpdateUserInfoBySessionUser +############################ + + +@router.post("/user/info/update", response_model=Optional[dict]) +async def update_user_settings_by_session_user( + form_data: dict, user=Depends(get_verified_user) +): + user = Users.get_user_by_id(user.id) + if user: + if user.info is None: + user.info = {} + + user = Users.update_user_by_id(user.id, {"info": {**user.info, **form_data}}) + if user: + return user.info + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.USER_NOT_FOUND, + ) + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.USER_NOT_FOUND, + ) + + ############################ # GetUserById ############################ diff --git a/backend/config.py b/backend/config.py index e0190f645..d19a81fce 100644 --- a/backend/config.py +++ b/backend/config.py @@ -294,6 +294,7 @@ WEBUI_AUTH = os.environ.get("WEBUI_AUTH", "True").lower() == "true" WEBUI_AUTH_TRUSTED_EMAIL_HEADER = os.environ.get( "WEBUI_AUTH_TRUSTED_EMAIL_HEADER", None ) +WEBUI_AUTH_TRUSTED_NAME_HEADER = os.environ.get("WEBUI_AUTH_TRUSTED_NAME_HEADER", None) JWT_EXPIRES_IN = PersistentConfig( "JWT_EXPIRES_IN", "auth.jwt_expiry", os.environ.get("JWT_EXPIRES_IN", "-1") ) @@ -425,7 +426,14 @@ OLLAMA_API_BASE_URL = os.environ.get( ) OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "") -AIOHTTP_CLIENT_TIMEOUT = int(os.environ.get("AIOHTTP_CLIENT_TIMEOUT", "300")) +AIOHTTP_CLIENT_TIMEOUT = os.environ.get("AIOHTTP_CLIENT_TIMEOUT", "300") + +if AIOHTTP_CLIENT_TIMEOUT == "": + AIOHTTP_CLIENT_TIMEOUT = None +else: + AIOHTTP_CLIENT_TIMEOUT = int(AIOHTTP_CLIENT_TIMEOUT) + + K8S_FLAG = os.environ.get("K8S_FLAG", "") USE_OLLAMA_DOCKER = os.environ.get("USE_OLLAMA_DOCKER", "false") @@ -1009,6 +1017,30 @@ COMFYUI_BASE_URL = PersistentConfig( os.getenv("COMFYUI_BASE_URL", ""), ) +COMFYUI_CFG_SCALE = PersistentConfig( + "COMFYUI_CFG_SCALE", + "image_generation.comfyui.cfg_scale", + os.getenv("COMFYUI_CFG_SCALE", ""), +) + +COMFYUI_SAMPLER = PersistentConfig( + "COMFYUI_SAMPLER", + "image_generation.comfyui.sampler", + os.getenv("COMFYUI_SAMPLER", ""), +) + +COMFYUI_SCHEDULER = PersistentConfig( + "COMFYUI_SCHEDULER", + "image_generation.comfyui.scheduler", + os.getenv("COMFYUI_SCHEDULER", ""), +) + +COMFYUI_SD3 = PersistentConfig( + "COMFYUI_SD3", + "image_generation.comfyui.sd3", + os.environ.get("COMFYUI_SD3", "").lower() == "true", +) + IMAGES_OPENAI_API_BASE_URL = PersistentConfig( "IMAGES_OPENAI_API_BASE_URL", "image_generation.openai.api_base_url", diff --git a/backend/main.py b/backend/main.py index e42c4ed9c..04f886162 100644 --- a/backend/main.py +++ b/backend/main.py @@ -764,7 +764,12 @@ async def generate_title(form_data: dict, user=Depends(get_verified_user)): template = app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE content = title_generation_template( - template, form_data["prompt"], user.model_dump() + template, + form_data["prompt"], + { + "name": user.name, + "location": user.info.get("location") if user.info else None, + }, ) payload = { @@ -776,7 +781,7 @@ async def generate_title(form_data: dict, user=Depends(get_verified_user)): "title": True, } - print(payload) + log.debug(payload) try: payload = filter_pipeline(payload, user) @@ -830,7 +835,7 @@ async def generate_search_query(form_data: dict, user=Depends(get_verified_user) template = app.state.config.SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE content = search_query_generation_template( - template, form_data["prompt"], user.model_dump() + template, form_data["prompt"], {"name": user.name} ) payload = { @@ -893,7 +898,12 @@ Message: """{{prompt}}""" ''' content = title_generation_template( - template, form_data["prompt"], user.model_dump() + template, + form_data["prompt"], + { + "name": user.name, + "location": user.info.get("location") if user.info else None, + }, ) payload = { @@ -905,7 +915,7 @@ Message: """{{prompt}}""" "task": True, } - print(payload) + log.debug(payload) try: payload = filter_pipeline(payload, user) diff --git a/backend/utils/task.py b/backend/utils/task.py index 615febcdc..ea277eb0b 100644 --- a/backend/utils/task.py +++ b/backend/utils/task.py @@ -6,24 +6,28 @@ from typing import Optional def prompt_template( - template: str, user_name: str = None, current_location: str = None + template: str, user_name: str = None, user_location: str = None ) -> str: # Get the current date current_date = datetime.now() # Format the date to YYYY-MM-DD formatted_date = current_date.strftime("%Y-%m-%d") + formatted_time = current_date.strftime("%I:%M:%S %p") - # Replace {{CURRENT_DATE}} in the template with the formatted date template = template.replace("{{CURRENT_DATE}}", formatted_date) + template = template.replace("{{CURRENT_TIME}}", formatted_time) + template = template.replace( + "{{CURRENT_DATETIME}}", f"{formatted_date} {formatted_time}" + ) if user_name: # Replace {{USER_NAME}} in the template with the user's name template = template.replace("{{USER_NAME}}", user_name) - if current_location: - # Replace {{CURRENT_LOCATION}} in the template with the current location - template = template.replace("{{CURRENT_LOCATION}}", current_location) + if user_location: + # Replace {{USER_LOCATION}} in the template with the current location + template = template.replace("{{USER_LOCATION}}", user_location) return template @@ -61,7 +65,7 @@ def title_generation_template( template = prompt_template( template, **( - {"user_name": user.get("name"), "current_location": user.get("location")} + {"user_name": user.get("name"), "user_location": user.get("location")} if user else {} ), @@ -104,7 +108,7 @@ def search_query_generation_template( template = prompt_template( template, **( - {"user_name": user.get("name"), "current_location": user.get("location")} + {"user_name": user.get("name"), "user_location": user.get("location")} if user else {} ), diff --git a/package-lock.json b/package-lock.json index f5b9d6a78..513993c74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.3.4", + "version": "0.3.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.3.4", + "version": "0.3.5", "dependencies": { "@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-python": "^6.1.6", diff --git a/package.json b/package.json index bf353ef7f..46aeb14f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.3.4", + "version": "0.3.5", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", diff --git a/src/lib/apis/users/index.ts b/src/lib/apis/users/index.ts index 4c97b0036..0b22b7171 100644 --- a/src/lib/apis/users/index.ts +++ b/src/lib/apis/users/index.ts @@ -1,4 +1,5 @@ import { WEBUI_API_BASE_URL } from '$lib/constants'; +import { getUserPosition } from '$lib/utils'; export const getUserPermissions = async (token: string) => { let error = null; @@ -198,6 +199,75 @@ export const getUserById = async (token: string, userId: string) => { return res; }; +export const getUserInfo = async (token: string) => { + let error = null; + const res = await fetch(`${WEBUI_API_BASE_URL}/users/user/info`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const updateUserInfo = async (token: string, info: object) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/users/user/info/update`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + ...info + }) + }) + .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 getAndUpdateUserLocation = async (token: string) => { + const location = await getUserPosition().catch((err) => { + throw err; + }); + + if (location) { + await updateUserInfo(token, { location: location }); + return location; + } else { + throw new Error('Failed to get user location'); + } +}; + export const deleteUserById = async (token: string, userId: string) => { let error = null; diff --git a/src/lib/components/admin/Settings/Connections.svelte b/src/lib/components/admin/Settings/Connections.svelte index 669fe8aae..909a07581 100644 --- a/src/lib/components/admin/Settings/Connections.svelte +++ b/src/lib/components/admin/Settings/Connections.svelte @@ -44,6 +44,8 @@ let ENABLE_OLLAMA_API = null; const verifyOpenAIHandler = async (idx) => { + OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS.map((url) => url.replace(/\/$/, '')); + OPENAI_API_BASE_URLS = await updateOpenAIUrls(localStorage.token, OPENAI_API_BASE_URLS); OPENAI_API_KEYS = await updateOpenAIKeys(localStorage.token, OPENAI_API_KEYS); @@ -63,6 +65,10 @@ }; const verifyOllamaHandler = async (idx) => { + OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter((url) => url !== '').map((url) => + url.replace(/\/$/, '') + ); + OLLAMA_BASE_URLS = await updateOllamaUrls(localStorage.token, OLLAMA_BASE_URLS); const res = await getOllamaVersion(localStorage.token, idx).catch((error) => { @@ -78,6 +84,8 @@ }; const updateOpenAIHandler = async () => { + OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS.map((url) => url.replace(/\/$/, '')); + // Check if API KEYS length is same than API URLS length if (OPENAI_API_KEYS.length !== OPENAI_API_BASE_URLS.length) { // if there are more keys than urls, remove the extra keys @@ -100,7 +108,10 @@ }; const updateOllamaUrlsHandler = async () => { - OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter((url) => url !== ''); + OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter((url) => url !== '').map((url) => + url.replace(/\/$/, '') + ); + console.log(OLLAMA_BASE_URLS); if (OLLAMA_BASE_URLS.length === 0) { diff --git a/src/lib/components/admin/UserChatsModal.svelte b/src/lib/components/admin/UserChatsModal.svelte index 67fa367cd..535dee074 100644 --- a/src/lib/components/admin/UserChatsModal.svelte +++ b/src/lib/components/admin/UserChatsModal.svelte @@ -31,6 +31,17 @@ } })(); } + + let sortKey = 'updated_at'; // default sort key + let sortOrder = 'desc'; // default sort order + function setSortKey(key) { + if (sortKey === key) { + sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; + } else { + sortKey = key; + sortOrder = 'asc'; + } + } @@ -69,18 +80,56 @@ class="text-xs text-gray-700 uppercase bg-transparent dark:text-gray-200 border-b-2 dark:border-gray-800" > - {$i18n.t('Name')} - {$i18n.t('Created at')} + setSortKey('title')} + > + {$i18n.t('Title')} + {#if sortKey === 'title'} + {sortOrder === 'asc' ? '▲' : '▼'} + {:else} + + {/if} + + setSortKey('created_at')} + > + {$i18n.t('Created at')} + {#if sortKey === 'created_at'} + {sortOrder === 'asc' ? '▲' : '▼'} + {:else} + + {/if} + + setSortKey('updated_at')} + > + {$i18n.t('Updated at')} + {#if sortKey === 'updated_at'} + {sortOrder === 'asc' ? '▲' : '▼'} + {:else} + + {/if} + - {#each chats as chat, idx} + {#each chats.sort((a, b) => { + if (a[sortKey] < b[sortKey]) return sortOrder === 'asc' ? -1 : 1; + if (a[sortKey] > b[sortKey]) return sortOrder === 'asc' ? 1 : -1; + return 0; + }) as chat, idx} - +
{chat.title} @@ -88,11 +137,16 @@ - +
{dayjs(chat.created_at * 1000).format($i18n.t('MMMM DD, YYYY HH:mm'))}
+ +
+ {dayjs(chat.updated_at * 1000).format($i18n.t('MMMM DD, YYYY HH:mm'))} +
+
diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 73b480796..8819a0428 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -31,6 +31,7 @@ convertMessagesToHistory, copyToClipboard, extractSentencesForAudio, + getUserPosition, promptTemplate, splitStream } from '$lib/utils'; @@ -50,7 +51,7 @@ import { runWebSearch } from '$lib/apis/rag'; import { createOpenAITextStream } from '$lib/apis/streaming'; import { queryMemory } from '$lib/apis/memories'; - import { getUserSettings } from '$lib/apis/users'; + import { getAndUpdateUserLocation, getUserSettings } from '$lib/apis/users'; import { chatCompleted, generateTitle, generateSearchQuery } from '$lib/apis'; import Banner from '../common/Banner.svelte'; @@ -533,7 +534,13 @@ $settings.system || (responseMessage?.userContext ?? null) ? { role: 'system', - content: `${promptTemplate($settings?.system ?? '', $user.name)}${ + content: `${promptTemplate( + $settings?.system ?? '', + $user.name, + $settings?.userLocation + ? await getAndUpdateUserLocation(localStorage.token) + : undefined + )}${ responseMessage?.userContext ?? null ? `\n\nUser Context:\n${(responseMessage?.userContext ?? []).join('\n')}` : '' @@ -871,7 +878,13 @@ $settings.system || (responseMessage?.userContext ?? null) ? { role: 'system', - content: `${promptTemplate($settings?.system ?? '', $user.name)}${ + content: `${promptTemplate( + $settings?.system ?? '', + $user.name, + $settings?.userLocation + ? await getAndUpdateUserLocation(localStorage.token) + : undefined + )}${ responseMessage?.userContext ?? null ? `\n\nUser Context:\n${(responseMessage?.userContext ?? []).join('\n')}` : '' diff --git a/src/lib/components/chat/MessageInput/CallOverlay.svelte b/src/lib/components/chat/MessageInput/CallOverlay.svelte index 11adc5298..67f80a34c 100644 --- a/src/lib/components/chat/MessageInput/CallOverlay.svelte +++ b/src/lib/components/chat/MessageInput/CallOverlay.svelte @@ -31,6 +31,7 @@ let loading = false; let confirmed = false; let interrupted = false; + let assistantSpeaking = false; let emoji = null; @@ -268,6 +269,15 @@ return; } + if (assistantSpeaking) { + // Mute the audio if the assistant is speaking + analyser.maxDecibels = 0; + analyser.minDecibels = -1; + } else { + analyser.minDecibels = MIN_DECIBELS; + analyser.maxDecibels = -30; + } + analyser.getByteTimeDomainData(timeDomainData); analyser.getByteFrequencyData(domainData); @@ -379,6 +389,7 @@ }; const stopAllAudio = async () => { + assistantSpeaking = false; interrupted = true; if (chatStreaming) { @@ -485,6 +496,7 @@ } } else if (finishedMessages[id] && messages[id] && messages[id].length === 0) { // If the message is finished and there are no more messages to process, break the loop + assistantSpeaking = false; break; } else { // No messages to process, sleep for a bit @@ -511,6 +523,7 @@ } audioAbortController = new AbortController(); + assistantSpeaking = true; // Start monitoring and playing audio for the message ID monitorAndPlayAudio(id, audioAbortController.signal); } @@ -545,9 +558,9 @@ const chatFinishHandler = async (e) => { const { id, content } = e.detail; // "content" here is the entire message from the assistant + finishedMessages[id] = true; chatStreaming = false; - finishedMessages[id] = true; }; eventTarget.addEventListener('chat:start', chatStartHandler); @@ -577,7 +590,15 @@ >
{#if camera} -
+
+ {/if}
{#if !camera} - {#if emoji} -
- {emoji} -
- {:else if loading} -
+ {emoji} +
+ {:else if loading || assistantSpeaking} + - {:else} -
- {/if} + @keyframes spinner_8HQG { + 0%, + 57.14% { + animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1); + transform: translate(0); + } + 28.57% { + animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33); + transform: translateY(-6px); + } + 100% { + transform: translate(0); + } + } + + {:else} +
+ {/if} + {:else}
- @@ -122,7 +124,7 @@
{ + const deleteMessageHandler = async (messageId) => { const messageToDelete = history.messages[messageId]; - const messageParentId = messageToDelete.parentId; - const messageChildrenIds = messageToDelete.childrenIds ?? []; - const hasSibling = messageChildrenIds.some( + + const parentMessageId = messageToDelete.parentId; + const childMessageIds = messageToDelete.childrenIds ?? []; + + const hasDescendantMessages = childMessageIds.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; + + history.currentId = parentMessageId; + await tick(); + + // Remove the message itself from the parent message's children array + history.messages[parentMessageId].childrenIds = history.messages[ + parentMessageId + ].childrenIds.filter((id) => id !== messageId); + + await tick(); + + childMessageIds.forEach((childId) => { + const childMessage = history.messages[childId]; + + if (childMessage && childMessage.childrenIds) { + if (childMessage.childrenIds.length === 0 && !hasDescendantMessages) { + // If there are no other responses/prompts + history.messages[parentMessageId].childrenIds = []; } else { - child.childrenIds.forEach((grandChildId) => { + childMessage.childrenIds.forEach((grandChildId) => { if (history.messages[grandChildId]) { - history.messages[grandChildId].parentId = messageParentId; - history.messages[messageParentId].childrenIds.push(grandChildId); + history.messages[grandChildId].parentId = parentMessageId; + history.messages[parentMessageId].childrenIds.push(grandChildId); } }); } } - // remove response - history.messages[messageParentId].childrenIds = history.messages[ - messageParentId + + // Remove child message id from the parent message's children array + history.messages[parentMessageId].childrenIds = history.messages[ + parentMessageId ].childrenIds.filter((id) => id !== childId); }); - // remove prompt - history.messages[messageParentId].childrenIds = history.messages[ - messageParentId - ].childrenIds.filter((id) => id !== messageId); + + await tick(); + await updateChatById(localStorage.token, chatId, { messages: messages, history: history @@ -292,7 +305,7 @@ > {#if message.role === 'user'} messageDeleteHandler(message.id)} + on:delete={() => deleteMessageHandler(message.id)} {user} {readOnly} {message} @@ -308,7 +321,7 @@ copyToClipboard={copyToClipboardWithToast} /> {:else if $mobile || (history.messages[message.parentId]?.models?.length ?? 1) === 1} - {#key message.id} + {#key message.id && history.currentId} - {/if} - {#if isLastMessage && !readOnly} - - - + + + + + + - - - + + + + + + {/if} {/if} {/if}
diff --git a/src/lib/components/chat/Settings/Interface.svelte b/src/lib/components/chat/Settings/Interface.svelte index 25a504ef5..b96a16d9d 100644 --- a/src/lib/components/chat/Settings/Interface.svelte +++ b/src/lib/components/chat/Settings/Interface.svelte @@ -5,6 +5,8 @@ import { createEventDispatcher, onMount, getContext } from 'svelte'; import { toast } from 'svelte-sonner'; import Tooltip from '$lib/components/common/Tooltip.svelte'; + import { updateUserInfo } from '$lib/apis/users'; + import { getUserPosition } from '$lib/utils'; const dispatch = createEventDispatcher(); const i18n = getContext('i18n'); @@ -16,6 +18,7 @@ let responseAutoCopy = false; let widescreenMode = false; let splitLargeChunks = false; + let userLocation = false; // Interface let defaultModelId = ''; @@ -51,6 +54,26 @@ saveSettings({ showEmojiInCall: showEmojiInCall }); }; + const toggleUserLocation = async () => { + userLocation = !userLocation; + + if (userLocation) { + const position = await getUserPosition().catch((error) => { + toast.error(error.message); + return null; + }); + + if (position) { + await updateUserInfo(localStorage.token, { location: position }); + toast.success('User location successfully retrieved.'); + } else { + userLocation = false; + } + } + + saveSettings({ userLocation }); + }; + const toggleTitleAutoGenerate = async () => { titleAutoGenerate = !titleAutoGenerate; saveSettings({ @@ -106,6 +129,7 @@ widescreenMode = $settings.widescreenMode ?? false; splitLargeChunks = $settings.splitLargeChunks ?? false; chatDirection = $settings.chatDirection ?? 'LTR'; + userLocation = $settings.userLocation ?? false; defaultModelId = ($settings?.models ?? ['']).at(0); }); @@ -142,6 +166,26 @@
+
+
+
{$i18n.t('Widescreen Mode')}
+ + +
+
+
{$i18n.t('Title Auto-Generation')}
@@ -186,16 +230,16 @@
-
{$i18n.t('Widescreen Mode')}
+
{$i18n.t('Allow User Location')}