diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 438666133..10958583f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -24,6 +24,9 @@ assignees: '' ## Environment +- **Open WebUI Version:** [e.g., 0.1.120] +- **Ollama (if applicable):** [e.g., 0.1.30, 0.1.32-rc1] + - **Operating System:** [e.g., Windows 10, macOS Big Sur, Ubuntu 20.04] - **Browser (if applicable):** [e.g., Chrome 100.0, Firefox 98.0] diff --git a/CHANGELOG.md b/CHANGELOG.md index 478c1668c..dad583399 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ 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.120] - 2024-04-20 + +### Added + +- **๐Ÿ“ฆ Archive Chat Feature**: Easily archive chats with a new sidebar button, and access archived chats via the profile button > archived chats. +- **๐Ÿ”Š Configurable Text-to-Speech Endpoint**: Customize your Text-to-Speech experience with configurable OpenAI endpoints. +- **๐Ÿ› ๏ธ Improved Error Handling**: Enhanced error message handling for connection failures. +- **โŒจ๏ธ Enhanced Shortcut**: When editing messages, use ctrl/cmd+enter to save and submit, and esc to close. +- **๐ŸŒ Language Support**: Added support for Georgian and enhanced translations for Portuguese and Vietnamese. + +### Fixed + +- **๐Ÿ”ง Model Selector**: Resolved issue where default model selection was not saving. +- **๐Ÿ”— Share Link Copy Button**: Fixed bug where the copy button wasn't copying links in Safari. +- **๐ŸŽจ Light Theme Styling**: Addressed styling issue with the light theme. + ## [0.1.119] - 2024-04-16 ### Added diff --git a/README.md b/README.md index a5ccb5412..cd06bc384 100644 --- a/README.md +++ b/README.md @@ -185,4 +185,4 @@ If you have any questions, suggestions, or need assistance, please open an issue --- -Created by [Timothy J. Baek](https://github.com/tjbck) - Let's make Open Web UI even more amazing together! ๐Ÿ’ช +Created by [Timothy J. Baek](https://github.com/tjbck) - Let's make Open WebUI even more amazing together! ๐Ÿ’ช diff --git a/backend/apps/audio/main.py b/backend/apps/audio/main.py index f93b50f6e..addaf4b76 100644 --- a/backend/apps/audio/main.py +++ b/backend/apps/audio/main.py @@ -10,8 +10,19 @@ from fastapi import ( File, Form, ) + +from fastapi.responses import StreamingResponse, JSONResponse, FileResponse + from fastapi.middleware.cors import CORSMiddleware from faster_whisper import WhisperModel +from pydantic import BaseModel + + +import requests +import hashlib +from pathlib import Path +import json + from constants import ERROR_MESSAGES from utils.utils import ( @@ -30,6 +41,8 @@ from config import ( WHISPER_MODEL_DIR, WHISPER_MODEL_AUTO_UPDATE, DEVICE_TYPE, + AUDIO_OPENAI_API_BASE_URL, + AUDIO_OPENAI_API_KEY, ) log = logging.getLogger(__name__) @@ -44,12 +57,104 @@ app.add_middleware( allow_headers=["*"], ) + +app.state.OPENAI_API_BASE_URL = AUDIO_OPENAI_API_BASE_URL +app.state.OPENAI_API_KEY = AUDIO_OPENAI_API_KEY + # setting device type for whisper model whisper_device_type = DEVICE_TYPE if DEVICE_TYPE and DEVICE_TYPE == "cuda" else "cpu" log.info(f"whisper_device_type: {whisper_device_type}") +SPEECH_CACHE_DIR = Path(CACHE_DIR).joinpath("./audio/speech/") +SPEECH_CACHE_DIR.mkdir(parents=True, exist_ok=True) -@app.post("/transcribe") + +class OpenAIConfigUpdateForm(BaseModel): + url: str + key: str + + +@app.get("/config") +async def get_openai_config(user=Depends(get_admin_user)): + return { + "OPENAI_API_BASE_URL": app.state.OPENAI_API_BASE_URL, + "OPENAI_API_KEY": app.state.OPENAI_API_KEY, + } + + +@app.post("/config/update") +async def update_openai_config( + form_data: OpenAIConfigUpdateForm, user=Depends(get_admin_user) +): + if form_data.key == "": + raise HTTPException(status_code=400, detail=ERROR_MESSAGES.API_KEY_NOT_FOUND) + + app.state.OPENAI_API_BASE_URL = form_data.url + app.state.OPENAI_API_KEY = form_data.key + + return { + "status": True, + "OPENAI_API_BASE_URL": app.state.OPENAI_API_BASE_URL, + "OPENAI_API_KEY": app.state.OPENAI_API_KEY, + } + + +@app.post("/speech") +async def speech(request: Request, user=Depends(get_verified_user)): + body = await request.body() + name = hashlib.sha256(body).hexdigest() + + 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_KEY}" + headers["Content-Type"] = "application/json" + + r = None + try: + r = requests.post( + url=f"{app.state.OPENAI_API_BASE_URL}/audio/speech", + data=body, + headers=headers, + stream=True, + ) + + r.raise_for_status() + + # 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) + + # Return the saved file + return FileResponse(file_path) + + except Exception as e: + log.exception(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']['message']}" + except: + error_detail = f"External: {e}" + + raise HTTPException( + status_code=r.status_code if r != None else 500, + detail=error_detail, + ) + + +@app.post("/transcriptions") def transcribe( file: UploadFile = File(...), user=Depends(get_current_user), diff --git a/backend/apps/images/main.py b/backend/apps/images/main.py index f39984de0..a3939d206 100644 --- a/backend/apps/images/main.py +++ b/backend/apps/images/main.py @@ -35,6 +35,8 @@ from config import ( ENABLE_IMAGE_GENERATION, AUTOMATIC1111_BASE_URL, COMFYUI_BASE_URL, + OPENAI_API_BASE_URL, + OPENAI_API_KEY, ) @@ -56,7 +58,9 @@ app.add_middleware( app.state.ENGINE = "" app.state.ENABLED = ENABLE_IMAGE_GENERATION -app.state.OPENAI_API_KEY = "" +app.state.OPENAI_API_BASE_URL = OPENAI_API_BASE_URL +app.state.OPENAI_API_KEY = OPENAI_API_KEY + app.state.MODEL = "" @@ -360,7 +364,7 @@ def generate_image( } r = requests.post( - url=f"https://api.openai.com/v1/images/generations", + url=f"{app.state.OPENAI_API_BASE_URL}/images/generations", json=data, headers=headers, ) diff --git a/backend/apps/openai/main.py b/backend/apps/openai/main.py index 4098d73a5..4647d7489 100644 --- a/backend/apps/openai/main.py +++ b/backend/apps/openai/main.py @@ -341,7 +341,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)): try: res = r.json() if "error" in res: - error_detail = f"External: {res['error']}" + error_detail = f"External: {res['error']['message'] if 'message' in res['error'] else res['error']}" except: error_detail = f"External: {e}" diff --git a/backend/apps/rag/main.py b/backend/apps/rag/main.py index 5e9564f7d..ac8410dbe 100644 --- a/backend/apps/rag/main.py +++ b/backend/apps/rag/main.py @@ -70,6 +70,8 @@ from config import ( RAG_EMBEDDING_ENGINE, RAG_EMBEDDING_MODEL, RAG_EMBEDDING_MODEL_AUTO_UPDATE, + RAG_OPENAI_API_BASE_URL, + RAG_OPENAI_API_KEY, DEVICE_TYPE, CHROMA_CLIENT, CHUNK_SIZE, @@ -94,8 +96,8 @@ app.state.RAG_EMBEDDING_ENGINE = RAG_EMBEDDING_ENGINE app.state.RAG_EMBEDDING_MODEL = RAG_EMBEDDING_MODEL app.state.RAG_TEMPLATE = RAG_TEMPLATE -app.state.RAG_OPENAI_API_BASE_URL = "https://api.openai.com" -app.state.RAG_OPENAI_API_KEY = "" +app.state.OPENAI_API_BASE_URL = RAG_OPENAI_API_BASE_URL +app.state.OPENAI_API_KEY = RAG_OPENAI_API_KEY app.state.PDF_EXTRACT_IMAGES = False @@ -148,8 +150,8 @@ async def get_embedding_config(user=Depends(get_admin_user)): "embedding_engine": app.state.RAG_EMBEDDING_ENGINE, "embedding_model": app.state.RAG_EMBEDDING_MODEL, "openai_config": { - "url": app.state.RAG_OPENAI_API_BASE_URL, - "key": app.state.RAG_OPENAI_API_KEY, + "url": app.state.OPENAI_API_BASE_URL, + "key": app.state.OPENAI_API_KEY, }, } @@ -180,8 +182,8 @@ async def update_embedding_config( app.state.sentence_transformer_ef = None if form_data.openai_config != None: - app.state.RAG_OPENAI_API_BASE_URL = form_data.openai_config.url - app.state.RAG_OPENAI_API_KEY = form_data.openai_config.key + app.state.OPENAI_API_BASE_URL = form_data.openai_config.url + app.state.OPENAI_API_KEY = form_data.openai_config.key else: sentence_transformer_ef = ( embedding_functions.SentenceTransformerEmbeddingFunction( @@ -199,8 +201,8 @@ async def update_embedding_config( "embedding_engine": app.state.RAG_EMBEDDING_ENGINE, "embedding_model": app.state.RAG_EMBEDDING_MODEL, "openai_config": { - "url": app.state.RAG_OPENAI_API_BASE_URL, - "key": app.state.RAG_OPENAI_API_KEY, + "url": app.state.OPENAI_API_BASE_URL, + "key": app.state.OPENAI_API_KEY, }, } @@ -315,8 +317,8 @@ def query_doc_handler( query_embeddings = generate_openai_embeddings( model=app.state.RAG_EMBEDDING_MODEL, text=form_data.query, - key=app.state.RAG_OPENAI_API_KEY, - url=app.state.RAG_OPENAI_API_BASE_URL, + key=app.state.OPENAI_API_KEY, + url=app.state.OPENAI_API_BASE_URL, ) return query_embeddings_doc( @@ -367,8 +369,8 @@ def query_collection_handler( query_embeddings = generate_openai_embeddings( model=app.state.RAG_EMBEDDING_MODEL, text=form_data.query, - key=app.state.RAG_OPENAI_API_KEY, - url=app.state.RAG_OPENAI_API_BASE_URL, + key=app.state.OPENAI_API_KEY, + url=app.state.OPENAI_API_BASE_URL, ) return query_embeddings_collection( @@ -484,8 +486,8 @@ def store_docs_in_vector_db(docs, collection_name, overwrite: bool = False) -> b generate_openai_embeddings( model=app.state.RAG_EMBEDDING_MODEL, text=text, - key=app.state.RAG_OPENAI_API_KEY, - url=app.state.RAG_OPENAI_API_BASE_URL, + key=app.state.OPENAI_API_KEY, + url=app.state.OPENAI_API_BASE_URL, ) for text in texts ] diff --git a/backend/apps/rag/utils.py b/backend/apps/rag/utils.py index daea36863..f4d1246c7 100644 --- a/backend/apps/rag/utils.py +++ b/backend/apps/rag/utils.py @@ -324,11 +324,11 @@ def get_embedding_model_path( def generate_openai_embeddings( - model: str, text: str, key: str, url: str = "https://api.openai.com" + model: str, text: str, key: str, url: str = "https://api.openai.com/v1" ): try: r = requests.post( - f"{url}/v1/embeddings", + f"{url}/embeddings", headers={ "Content-Type": "application/json", "Authorization": f"Bearer {key}", diff --git a/backend/apps/web/internal/migrations/004_add_archived.py b/backend/apps/web/internal/migrations/004_add_archived.py new file mode 100644 index 000000000..d01c06b4e --- /dev/null +++ b/backend/apps/web/internal/migrations/004_add_archived.py @@ -0,0 +1,46 @@ +"""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.""" + + migrator.add_fields("chat", archived=pw.BooleanField(default=False)) + + +def rollback(migrator: Migrator, database: pw.Database, *, fake=False): + """Write your rollback migrations here.""" + + migrator.remove_fields("chat", "archived") diff --git a/backend/apps/web/internal/migrations/005_add_updated_at.py b/backend/apps/web/internal/migrations/005_add_updated_at.py new file mode 100644 index 000000000..63a023cdb --- /dev/null +++ b/backend/apps/web/internal/migrations/005_add_updated_at.py @@ -0,0 +1,77 @@ +"""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 created_at and updated_at to the 'chat' table + migrator.add_fields( + "chat", + created_at=pw.DateTimeField(null=True), # Allow null for transition + updated_at=pw.DateTimeField(null=True), # Allow null for transition + ) + + # Populate the new fields from an existing 'timestamp' field + migrator.sql( + "UPDATE chat SET created_at = timestamp, updated_at = timestamp WHERE timestamp IS NOT NULL" + ) + + # Now that the data has been copied, remove the original 'timestamp' field + migrator.remove_fields("chat", "timestamp") + + # Update the fields to be not null now that they are populated + migrator.change_fields( + "chat", + created_at=pw.DateTimeField(null=False), + updated_at=pw.DateTimeField(null=False), + ) + + +def rollback(migrator: Migrator, database: pw.Database, *, fake=False): + """Write your rollback migrations here.""" + + # Recreate the timestamp field initially allowing null values for safe transition + migrator.add_fields("chat", timestamp=pw.DateTimeField(null=True)) + + # Copy the earliest created_at date back into the new timestamp field + # This assumes created_at was originally a copy of timestamp + migrator.sql("UPDATE chat SET timestamp = created_at") + + # Remove the created_at and updated_at fields + migrator.remove_fields("chat", "created_at", "updated_at") + + # Finally, alter the timestamp field to not allow nulls if that was the original setting + migrator.change_fields("chat", timestamp=pw.DateTimeField(null=False)) diff --git a/backend/apps/web/models/chats.py b/backend/apps/web/models/chats.py index ef16ce731..ea7fb355d 100644 --- a/backend/apps/web/models/chats.py +++ b/backend/apps/web/models/chats.py @@ -19,8 +19,12 @@ class Chat(Model): user_id = CharField() title = CharField() chat = TextField() # Save Chat JSON as Text - timestamp = DateField() + + created_at = DateTimeField() + updated_at = DateTimeField() + share_id = CharField(null=True, unique=True) + archived = BooleanField(default=False) class Meta: database = DB @@ -31,8 +35,12 @@ class ChatModel(BaseModel): user_id: str title: str chat: str - timestamp: int # timestamp in epoch + + created_at: int # timestamp in epoch + updated_at: int # timestamp in epoch + share_id: Optional[str] = None + archived: bool = False #################### @@ -53,13 +61,17 @@ class ChatResponse(BaseModel): user_id: str title: str chat: dict - timestamp: int # timestamp in epoch + updated_at: int # timestamp in epoch + created_at: int # timestamp in epoch share_id: Optional[str] = None # id of the chat to be shared + archived: bool class ChatTitleIdResponse(BaseModel): id: str title: str + updated_at: int + created_at: int class ChatTable: @@ -77,7 +89,8 @@ class ChatTable: form_data.chat["title"] if "title" in form_data.chat else "New Chat" ), "chat": json.dumps(form_data.chat), - "timestamp": int(time.time()), + "created_at": int(time.time()), + "updated_at": int(time.time()), } ) @@ -89,7 +102,7 @@ class ChatTable: query = Chat.update( chat=json.dumps(chat), title=chat["title"] if "title" in chat else "New Chat", - timestamp=int(time.time()), + updated_at=int(time.time()), ).where(Chat.id == id) query.execute() @@ -111,7 +124,8 @@ class ChatTable: "user_id": f"shared-{chat_id}", "title": chat.title, "chat": chat.chat, - "timestamp": int(time.time()), + "created_at": chat.created_at, + "updated_at": int(time.time()), } ) shared_result = Chat.create(**shared_chat.model_dump()) @@ -163,14 +177,42 @@ class ChatTable: except: return None + def toggle_chat_archive_by_id(self, id: str) -> Optional[ChatModel]: + try: + chat = self.get_chat_by_id(id) + query = Chat.update( + archived=(not chat.archived), + ).where(Chat.id == id) + + query.execute() + + chat = Chat.get(Chat.id == id) + return ChatModel(**model_to_dict(chat)) + except: + return None + + def get_archived_chat_lists_by_user_id( + self, user_id: str, skip: int = 0, limit: int = 50 + ) -> List[ChatModel]: + return [ + ChatModel(**model_to_dict(chat)) + for chat in Chat.select() + .where(Chat.archived == True) + .where(Chat.user_id == user_id) + .order_by(Chat.updated_at.desc()) + # .limit(limit) + # .offset(skip) + ] + def get_chat_lists_by_user_id( self, user_id: str, skip: int = 0, limit: int = 50 ) -> List[ChatModel]: return [ ChatModel(**model_to_dict(chat)) for chat in Chat.select() + .where(Chat.archived == False) .where(Chat.user_id == user_id) - .order_by(Chat.timestamp.desc()) + .order_by(Chat.updated_at.desc()) # .limit(limit) # .offset(skip) ] @@ -181,14 +223,15 @@ class ChatTable: return [ ChatModel(**model_to_dict(chat)) for chat in Chat.select() + .where(Chat.archived == False) .where(Chat.id.in_(chat_ids)) - .order_by(Chat.timestamp.desc()) + .order_by(Chat.updated_at.desc()) ] def get_all_chats(self) -> List[ChatModel]: return [ ChatModel(**model_to_dict(chat)) - for chat in Chat.select().order_by(Chat.timestamp.desc()) + for chat in Chat.select().order_by(Chat.updated_at.desc()) ] def get_all_chats_by_user_id(self, user_id: str) -> List[ChatModel]: @@ -196,7 +239,7 @@ class ChatTable: ChatModel(**model_to_dict(chat)) for chat in Chat.select() .where(Chat.user_id == user_id) - .order_by(Chat.timestamp.desc()) + .order_by(Chat.updated_at.desc()) ] def get_chat_by_id(self, id: str) -> Optional[ChatModel]: diff --git a/backend/apps/web/routers/chats.py b/backend/apps/web/routers/chats.py index 2e2bb5b00..678c9aea7 100644 --- a/backend/apps/web/routers/chats.py +++ b/backend/apps/web/routers/chats.py @@ -47,6 +47,18 @@ async def get_user_chats( return Chats.get_chat_lists_by_user_id(user.id, skip, limit) +############################ +# GetArchivedChats +############################ + + +@router.get("/archived", response_model=List[ChatTitleIdResponse]) +async def get_archived_user_chats( + user=Depends(get_current_user), skip: int = 0, limit: int = 50 +): + return Chats.get_archived_chat_lists_by_user_id(user.id, skip, limit) + + ############################ # GetAllChats ############################ @@ -189,6 +201,23 @@ async def delete_chat_by_id(request: Request, id: str, user=Depends(get_current_ return result +############################ +# ArchiveChat +############################ + + +@router.get("/{id}/archive", response_model=Optional[ChatResponse]) +async def archive_chat_by_id(id: str, user=Depends(get_current_user)): + chat = Chats.get_chat_by_id_and_user_id(id, user.id) + if chat: + chat = Chats.toggle_chat_archive_by_id(id) + return ChatResponse(**{**chat.model_dump(), "chat": json.loads(chat.chat)}) + else: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT() + ) + + ############################ # ShareChatById ############################ diff --git a/backend/config.py b/backend/config.py index 938df9961..6ca2c67bf 100644 --- a/backend/config.py +++ b/backend/config.py @@ -321,6 +321,13 @@ OPENAI_API_BASE_URLS = [ for url in OPENAI_API_BASE_URLS.split(";") ] +OPENAI_API_KEY = "" +OPENAI_API_KEY = OPENAI_API_KEYS[ + OPENAI_API_BASE_URLS.index("https://api.openai.com/v1") +] +OPENAI_API_BASE_URL = "https://api.openai.com/v1" + + #################################### # WEBUI #################################### @@ -447,6 +454,9 @@ And answer according to the language of the user's question. Given the context information, answer the query. Query: [query]""" +RAG_OPENAI_API_BASE_URL = os.getenv("RAG_OPENAI_API_BASE_URL", OPENAI_API_BASE_URL) +RAG_OPENAI_API_KEY = os.getenv("RAG_OPENAI_API_KEY", OPENAI_API_KEY) + #################################### # Transcribe #################################### @@ -467,3 +477,11 @@ ENABLE_IMAGE_GENERATION = ( ) AUTOMATIC1111_BASE_URL = os.getenv("AUTOMATIC1111_BASE_URL", "") COMFYUI_BASE_URL = os.getenv("COMFYUI_BASE_URL", "") + + +#################################### +# Audio +#################################### + +AUDIO_OPENAI_API_BASE_URL = os.getenv("AUDIO_OPENAI_API_BASE_URL", OPENAI_API_BASE_URL) +AUDIO_OPENAI_API_KEY = os.getenv("AUDIO_OPENAI_API_KEY", OPENAI_API_KEY) diff --git a/backend/static/favicon.png b/backend/static/favicon.png index 519af1db6..2b2074780 100644 Binary files a/backend/static/favicon.png and b/backend/static/favicon.png differ diff --git a/package-lock.json b/package-lock.json index 83ec91d1c..a310c609d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.1.119", + "version": "0.1.120", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.1.119", + "version": "0.1.120", "dependencies": { "@sveltejs/adapter-node": "^1.3.1", "async": "^3.2.5", diff --git a/package.json b/package.json index e88a0063b..12afea0f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.1.119", + "version": "0.1.120", "private": true, "scripts": { "dev": "vite dev --host", diff --git a/src/lib/apis/audio/index.ts b/src/lib/apis/audio/index.ts index d28483394..6679420d9 100644 --- a/src/lib/apis/audio/index.ts +++ b/src/lib/apis/audio/index.ts @@ -1,11 +1,73 @@ import { AUDIO_API_BASE_URL } from '$lib/constants'; +export const getAudioConfig = async (token: string) => { + let error = null; + + const res = await fetch(`${AUDIO_API_BASE_URL}/config`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +type OpenAIConfigForm = { + url: string; + key: string; +}; + +export const updateAudioConfig = async (token: string, payload: OpenAIConfigForm) => { + let error = null; + + const res = await fetch(`${AUDIO_API_BASE_URL}/config/update`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + ...payload + }) + }) + .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 transcribeAudio = async (token: string, file: File) => { const data = new FormData(); data.append('file', file); let error = null; - const res = await fetch(`${AUDIO_API_BASE_URL}/transcribe`, { + const res = await fetch(`${AUDIO_API_BASE_URL}/transcriptions`, { method: 'POST', headers: { Accept: 'application/json', @@ -29,3 +91,40 @@ export const transcribeAudio = async (token: string, file: File) => { return res; }; + +export const synthesizeOpenAISpeech = async ( + token: string = '', + speaker: string = 'alloy', + text: string = '' +) => { + let error = null; + + const res = await fetch(`${AUDIO_API_BASE_URL}/speech`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + model: 'tts-1', + input: text, + voice: speaker + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res; + }) + .catch((err) => { + error = err.detail; + console.log(err); + + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/apis/chats/index.ts b/src/lib/apis/chats/index.ts index 28b3d4be5..5a9071bbc 100644 --- a/src/lib/apis/chats/index.ts +++ b/src/lib/apis/chats/index.ts @@ -62,6 +62,37 @@ export const getChatList = async (token: string = '') => { return res; }; +export const getArchivedChatList = async (token: string = '') => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/archived`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err; + console.log(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const getAllChats = async (token: string) => { let error = null; @@ -282,6 +313,38 @@ export const shareChatById = async (token: string, id: string) => { return res; }; +export const archiveChatById = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/archive`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err; + + console.log(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const deleteSharedChatById = async (token: string, id: string) => { let error = null; diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index eff65a254..ebf8e9713 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -328,27 +328,28 @@ ]; }; - const inputFiles = e.dataTransfer?.files; + const inputFiles = Array.from(e.dataTransfer?.files); if (inputFiles && inputFiles.length > 0) { - const file = inputFiles[0]; - console.log(file, file.name.split('.').at(-1)); - if (['image/gif', 'image/jpeg', 'image/png'].includes(file['type'])) { - reader.readAsDataURL(file); - } else if ( - SUPPORTED_FILE_TYPE.includes(file['type']) || - SUPPORTED_FILE_EXTENSIONS.includes(file.name.split('.').at(-1)) - ) { - uploadDoc(file); - } else { - toast.error( - $i18n.t( - `Unknown File Type '{{file_type}}', but accepting and treating as plain text`, - { file_type: file['type'] } - ) - ); - uploadDoc(file); - } + inputFiles.forEach((file) => { + console.log(file, file.name.split('.').at(-1)); + if (['image/gif', 'image/jpeg', 'image/png'].includes(file['type'])) { + reader.readAsDataURL(file); + } else if ( + SUPPORTED_FILE_TYPE.includes(file['type']) || + SUPPORTED_FILE_EXTENSIONS.includes(file.name.split('.').at(-1)) + ) { + uploadDoc(file); + } else { + toast.error( + $i18n.t( + `Unknown File Type '{{file_type}}', but accepting and treating as plain text`, + { file_type: file['type'] } + ) + ); + uploadDoc(file); + } + }); } else { toast.error($i18n.t(`File not found.`)); } @@ -467,6 +468,7 @@ bind:files={inputFiles} type="file" hidden + multiple on:change={async () => { let reader = new FileReader(); reader.onload = (event) => { @@ -482,25 +484,27 @@ }; if (inputFiles && inputFiles.length > 0) { - const file = inputFiles[0]; - if (['image/gif', 'image/jpeg', 'image/png'].includes(file['type'])) { - reader.readAsDataURL(file); - } else if ( - SUPPORTED_FILE_TYPE.includes(file['type']) || - SUPPORTED_FILE_EXTENSIONS.includes(file.name.split('.').at(-1)) - ) { - uploadDoc(file); - filesInputElement.value = ''; - } else { - toast.error( - $i18n.t( - `Unknown File Type '{{file_type}}', but accepting and treating as plain text`, - { file_type: file['type'] } - ) - ); - uploadDoc(file); - filesInputElement.value = ''; - } + const _inputFiles = Array.from(inputFiles); + _inputFiles.forEach((file) => { + if (['image/gif', 'image/jpeg', 'image/png'].includes(file['type'])) { + reader.readAsDataURL(file); + } else if ( + SUPPORTED_FILE_TYPE.includes(file['type']) || + SUPPORTED_FILE_EXTENSIONS.includes(file.name.split('.').at(-1)) + ) { + uploadDoc(file); + filesInputElement.value = ''; + } else { + toast.error( + $i18n.t( + `Unknown File Type '{{file_type}}', but accepting and treating as plain text`, + { file_type: file['type'] } + ) + ); + uploadDoc(file); + filesInputElement.value = ''; + } + }); } else { toast.error($i18n.t(`File not found.`)); } diff --git a/src/lib/components/chat/Messages.svelte b/src/lib/components/chat/Messages.svelte index 892777662..2da91c50f 100644 --- a/src/lib/components/chat/Messages.svelte +++ b/src/lib/components/chat/Messages.svelte @@ -12,6 +12,7 @@ import Placeholder from './Messages/Placeholder.svelte'; import Spinner from '../common/Spinner.svelte'; import { imageGenerations } from '$lib/apis/images'; + import { copyToClipboard } from '$lib/utils'; const i18n = getContext('i18n'); @@ -42,40 +43,11 @@ element.scrollTop = element.scrollHeight; }; - const copyToClipboard = (text) => { - if (!navigator.clipboard) { - var textArea = document.createElement('textarea'); - textArea.value = text; - - // Avoid scrolling to bottom - textArea.style.top = '0'; - textArea.style.left = '0'; - textArea.style.position = 'fixed'; - - document.body.appendChild(textArea); - textArea.focus(); - textArea.select(); - - try { - var successful = document.execCommand('copy'); - var msg = successful ? 'successful' : 'unsuccessful'; - console.log('Fallback: Copying text command was ' + msg); - } catch (err) { - console.error('Fallback: Oops, unable to copy', err); - } - - document.body.removeChild(textArea); - return; + const copyToClipboardWithToast = async (text) => { + const res = await copyToClipboard(text); + if (res) { + toast.success($i18n.t('Copying to clipboard was successful!')); } - navigator.clipboard.writeText(text).then( - function () { - console.log('Async: Copying to clipboard was successful!'); - toast.success($i18n.t('Copying to clipboard was successful!')); - }, - function (err) { - console.error('Async: Could not copy text: ', err); - } - ); }; const confirmEditMessage = async (messageId, content) => { @@ -330,7 +302,7 @@ {confirmEditMessage} {showPreviousMessage} {showNextMessage} - {copyToClipboard} + copyToClipboard={copyToClipboardWithToast} /> {:else} { diff --git a/src/lib/components/chat/Messages/RateComment.svelte b/src/lib/components/chat/Messages/RateComment.svelte index 2bdc3d047..05eaae39f 100644 --- a/src/lib/components/chat/Messages/RateComment.svelte +++ b/src/lib/components/chat/Messages/RateComment.svelte @@ -1,31 +1,39 @@ -
+
-
Tell us more:
+
{$i18n.t('Tell us more:')}