Merge branch 'open-webui:main' into fix/oidc-500-error-name-field

This commit is contained in:
Kevin Wang 2025-01-27 13:10:08 +01:00 committed by GitHub
commit 9eaf01c323
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
193 changed files with 7697 additions and 3905 deletions

View File

@ -52,6 +52,8 @@ jobs:
- name: Cypress run - name: Cypress run
uses: cypress-io/github-action@v6 uses: cypress-io/github-action@v6
env:
LIBGL_ALWAYS_SOFTWARE: 1
with: with:
browser: chrome browser: chrome
wait-on: 'http://localhost:3000' wait-on: 'http://localhost:3000'

View File

@ -5,6 +5,57 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.5.7] - 2025-01-23
### Added
- **🌍 Enhanced Internationalization (i18n)**: Refined and expanded translations for greater global accessibility and a smoother experience for international users.
### Fixed
- **🔗 Connection Model ID Resolution**: Resolved an issue preventing model IDs from registering in connections.
- **💡 Prefix ID for Ollama Connections**: Fixed a bug where prefix IDs in Ollama connections were non-functional.
- **🔧 Ollama Model Enable/Disable Functionality**: Addressed the issue of enable/disable toggles not working for Ollama base models.
- **🔒 RBAC Permissions for Tools and Models**: Corrected incorrect Role-Based Access Control (RBAC) permissions for tools and models, ensuring that users now only access features according to their assigned privileges, enhancing security and role clarity.
## [0.5.6] - 2025-01-22
### Added
- **🧠 Effortful Reasoning Control for OpenAI Models**: Introduced the reasoning_effort parameter in chat controls for supported OpenAI models, enabling users to fine-tune how much cognitive effort a model dedicates to its responses, offering greater customization for complex queries and reasoning tasks.
### Fixed
- **🔄 Chat Controls Loading UI Bug**: Resolved an issue where collapsible chat controls appeared as "loading," ensuring a smoother and more intuitive user experience for managing chat settings.
### Changed
- **🔧 Updated Ollama Model Creation**: Revamped the Ollama model creation method to align with their new JSON payload format, ensuring seamless compatibility and more efficient model setup workflows.
## [0.5.5] - 2025-01-22
### Added
- **🤔 Native 'Think' Tag Support**: Introduced the new 'think' tag support that visually displays how long the model is thinking, omitting the reasoning content itself until the next turn. Ideal for creating a more streamlined and focused interaction experience.
- **🖼️ Toggle Image Generation On/Off**: In the chat input menu, you can now easily toggle image generation before initiating chats, providing greater control and flexibility to suit your needs.
- **🔒 Chat Controls Permissions**: Admins can now disable chat controls access for users, offering tighter management and customization over user interactions.
- **🔍 Web Search & Image Generation Permissions**: Easily disable web search and image generation for specific users, improving workflow governance and security for certain environments.
- **🗂️ S3 and GCS Storage Provider Support**: Scaled deployments now benefit from expanded storage options with Amazon S3 and Google Cloud Storage seamlessly integrated as providers.
- **🎨 Enhanced Model Management**: Reintroduced the ability to download and delete models directly in the admin models settings page to minimize user confusion and aid efficient model management.
- **🔗 Improved Connection Handling**: Enhanced backend to smoothly handle multiple identical base URLs, allowing more flexible multi-instance configurations with fewer hiccups.
- **✨ General UI/UX Refinements**: Numerous tweaks across the WebUI make navigation and usability even more user-friendly and intuitive.
- **🌍 Translation Enhancements**: Various translation updates ensure smoother and more polished interactions for international users.
### Fixed
- **⚡ MPS Functionality for Mac Users**: Fixed MPS support, ensuring smooth performance and compatibility for Mac users leveraging MPS.
- **📡 Ollama Connection Management**: Resolved the issue where deleting all Ollama connections prevented adding new ones.
### Changed
- **⚙️ General Stability Refac**: Backend refactoring delivers a more stable, robust platform.
- **🖥️ Desktop App Preparations**: Ongoing work to support the upcoming Open WebUI desktop app. Follow our progress and updates here: https://github.com/open-webui/desktop
## [0.5.4] - 2025-01-05 ## [0.5.4] - 2025-01-05
### Added ### Added

View File

@ -5,12 +5,31 @@ from pathlib import Path
import typer import typer
import uvicorn import uvicorn
from typing import Optional
from typing_extensions import Annotated
app = typer.Typer() app = typer.Typer()
KEY_FILE = Path.cwd() / ".webui_secret_key" KEY_FILE = Path.cwd() / ".webui_secret_key"
def version_callback(value: bool):
if value:
from open_webui.env import VERSION
typer.echo(f"Open WebUI version: {VERSION}")
raise typer.Exit()
@app.command()
def main(
version: Annotated[
Optional[bool], typer.Option("--version", callback=version_callback)
] = None,
):
pass
@app.command() @app.command()
def serve( def serve(
host: str = "0.0.0.0", host: str = "0.0.0.0",

View File

@ -9,22 +9,22 @@ from urllib.parse import urlparse
import chromadb import chromadb
import requests import requests
import yaml from pydantic import BaseModel
from open_webui.internal.db import Base, get_db from sqlalchemy import JSON, Column, DateTime, Integer, func
from open_webui.env import ( from open_webui.env import (
OPEN_WEBUI_DIR,
DATA_DIR, DATA_DIR,
DATABASE_URL,
ENV, ENV,
FRONTEND_BUILD_DIR, FRONTEND_BUILD_DIR,
OFFLINE_MODE,
OPEN_WEBUI_DIR,
WEBUI_AUTH, WEBUI_AUTH,
WEBUI_FAVICON_URL, WEBUI_FAVICON_URL,
WEBUI_NAME, WEBUI_NAME,
log, log,
DATABASE_URL,
OFFLINE_MODE,
) )
from pydantic import BaseModel from open_webui.internal.db import Base, get_db
from sqlalchemy import JSON, Column, DateTime, Integer, func
class EndpointFilter(logging.Filter): class EndpointFilter(logging.Filter):
@ -362,6 +362,30 @@ MICROSOFT_REDIRECT_URI = PersistentConfig(
os.environ.get("MICROSOFT_REDIRECT_URI", ""), os.environ.get("MICROSOFT_REDIRECT_URI", ""),
) )
GITHUB_CLIENT_ID = PersistentConfig(
"GITHUB_CLIENT_ID",
"oauth.github.client_id",
os.environ.get("GITHUB_CLIENT_ID", ""),
)
GITHUB_CLIENT_SECRET = PersistentConfig(
"GITHUB_CLIENT_SECRET",
"oauth.github.client_secret",
os.environ.get("GITHUB_CLIENT_SECRET", ""),
)
GITHUB_CLIENT_SCOPE = PersistentConfig(
"GITHUB_CLIENT_SCOPE",
"oauth.github.scope",
os.environ.get("GITHUB_CLIENT_SCOPE", "user:email"),
)
GITHUB_CLIENT_REDIRECT_URI = PersistentConfig(
"GITHUB_CLIENT_REDIRECT_URI",
"oauth.github.redirect_uri",
os.environ.get("GITHUB_CLIENT_REDIRECT_URI", ""),
)
OAUTH_CLIENT_ID = PersistentConfig( OAUTH_CLIENT_ID = PersistentConfig(
"OAUTH_CLIENT_ID", "OAUTH_CLIENT_ID",
"oauth.oidc.client_id", "oauth.oidc.client_id",
@ -468,12 +492,20 @@ OAUTH_ALLOWED_DOMAINS = PersistentConfig(
def load_oauth_providers(): def load_oauth_providers():
OAUTH_PROVIDERS.clear() OAUTH_PROVIDERS.clear()
if GOOGLE_CLIENT_ID.value and GOOGLE_CLIENT_SECRET.value: if GOOGLE_CLIENT_ID.value and GOOGLE_CLIENT_SECRET.value:
def google_oauth_register(client):
client.register(
name="google",
client_id=GOOGLE_CLIENT_ID.value,
client_secret=GOOGLE_CLIENT_SECRET.value,
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
client_kwargs={"scope": GOOGLE_OAUTH_SCOPE.value},
redirect_uri=GOOGLE_REDIRECT_URI.value,
)
OAUTH_PROVIDERS["google"] = { OAUTH_PROVIDERS["google"] = {
"client_id": GOOGLE_CLIENT_ID.value,
"client_secret": GOOGLE_CLIENT_SECRET.value,
"server_metadata_url": "https://accounts.google.com/.well-known/openid-configuration",
"scope": GOOGLE_OAUTH_SCOPE.value,
"redirect_uri": GOOGLE_REDIRECT_URI.value, "redirect_uri": GOOGLE_REDIRECT_URI.value,
"register": google_oauth_register,
} }
if ( if (
@ -481,12 +513,44 @@ def load_oauth_providers():
and MICROSOFT_CLIENT_SECRET.value and MICROSOFT_CLIENT_SECRET.value
and MICROSOFT_CLIENT_TENANT_ID.value and MICROSOFT_CLIENT_TENANT_ID.value
): ):
def microsoft_oauth_register(client):
client.register(
name="microsoft",
client_id=MICROSOFT_CLIENT_ID.value,
client_secret=MICROSOFT_CLIENT_SECRET.value,
server_metadata_url=f"https://login.microsoftonline.com/{MICROSOFT_CLIENT_TENANT_ID.value}/v2.0/.well-known/openid-configuration",
client_kwargs={
"scope": MICROSOFT_OAUTH_SCOPE.value,
},
redirect_uri=MICROSOFT_REDIRECT_URI.value,
)
OAUTH_PROVIDERS["microsoft"] = { OAUTH_PROVIDERS["microsoft"] = {
"client_id": MICROSOFT_CLIENT_ID.value,
"client_secret": MICROSOFT_CLIENT_SECRET.value,
"server_metadata_url": f"https://login.microsoftonline.com/{MICROSOFT_CLIENT_TENANT_ID.value}/v2.0/.well-known/openid-configuration",
"scope": MICROSOFT_OAUTH_SCOPE.value,
"redirect_uri": MICROSOFT_REDIRECT_URI.value, "redirect_uri": MICROSOFT_REDIRECT_URI.value,
"picture_url": "https://graph.microsoft.com/v1.0/me/photo/$value",
"register": microsoft_oauth_register,
}
if GITHUB_CLIENT_ID.value and GITHUB_CLIENT_SECRET.value:
def github_oauth_register(client):
client.register(
name="github",
client_id=GITHUB_CLIENT_ID.value,
client_secret=GITHUB_CLIENT_SECRET.value,
access_token_url="https://github.com/login/oauth/access_token",
authorize_url="https://github.com/login/oauth/authorize",
api_base_url="https://api.github.com",
userinfo_endpoint="https://api.github.com/user",
client_kwargs={"scope": GITHUB_CLIENT_SCOPE.value},
redirect_uri=GITHUB_CLIENT_REDIRECT_URI.value,
)
OAUTH_PROVIDERS["github"] = {
"redirect_uri": GITHUB_CLIENT_REDIRECT_URI.value,
"register": github_oauth_register,
"sub_claim": "id",
} }
if ( if (
@ -494,13 +558,23 @@ def load_oauth_providers():
and OAUTH_CLIENT_SECRET.value and OAUTH_CLIENT_SECRET.value
and OPENID_PROVIDER_URL.value and OPENID_PROVIDER_URL.value
): ):
def oidc_oauth_register(client):
client.register(
name="oidc",
client_id=OAUTH_CLIENT_ID.value,
client_secret=OAUTH_CLIENT_SECRET.value,
server_metadata_url=OPENID_PROVIDER_URL.value,
client_kwargs={
"scope": OAUTH_SCOPES.value,
},
redirect_uri=OPENID_REDIRECT_URI.value,
)
OAUTH_PROVIDERS["oidc"] = { OAUTH_PROVIDERS["oidc"] = {
"client_id": OAUTH_CLIENT_ID.value,
"client_secret": OAUTH_CLIENT_SECRET.value,
"server_metadata_url": OPENID_PROVIDER_URL.value,
"scope": OAUTH_SCOPES.value,
"name": OAUTH_PROVIDER_NAME.value, "name": OAUTH_PROVIDER_NAME.value,
"redirect_uri": OPENID_REDIRECT_URI.value, "redirect_uri": OPENID_REDIRECT_URI.value,
"register": oidc_oauth_register,
} }
@ -580,7 +654,7 @@ if CUSTOM_NAME:
# STORAGE PROVIDER # STORAGE PROVIDER
#################################### ####################################
STORAGE_PROVIDER = os.environ.get("STORAGE_PROVIDER", "") # defaults to local, s3 STORAGE_PROVIDER = os.environ.get("STORAGE_PROVIDER", "local") # defaults to local, s3
S3_ACCESS_KEY_ID = os.environ.get("S3_ACCESS_KEY_ID", None) S3_ACCESS_KEY_ID = os.environ.get("S3_ACCESS_KEY_ID", None)
S3_SECRET_ACCESS_KEY = os.environ.get("S3_SECRET_ACCESS_KEY", None) S3_SECRET_ACCESS_KEY = os.environ.get("S3_SECRET_ACCESS_KEY", None)
@ -588,6 +662,11 @@ S3_REGION_NAME = os.environ.get("S3_REGION_NAME", None)
S3_BUCKET_NAME = os.environ.get("S3_BUCKET_NAME", None) S3_BUCKET_NAME = os.environ.get("S3_BUCKET_NAME", None)
S3_ENDPOINT_URL = os.environ.get("S3_ENDPOINT_URL", None) S3_ENDPOINT_URL = os.environ.get("S3_ENDPOINT_URL", None)
GCS_BUCKET_NAME = os.environ.get("GCS_BUCKET_NAME", None)
GOOGLE_APPLICATION_CREDENTIALS_JSON = os.environ.get(
"GOOGLE_APPLICATION_CREDENTIALS_JSON", None
)
#################################### ####################################
# File Upload DIR # File Upload DIR
#################################### ####################################
@ -819,6 +898,10 @@ USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS = (
os.environ.get("USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS", "False").lower() == "true" os.environ.get("USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS", "False").lower() == "true"
) )
USER_PERMISSIONS_CHAT_CONTROLS = (
os.environ.get("USER_PERMISSIONS_CHAT_CONTROLS", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_FILE_UPLOAD = ( USER_PERMISSIONS_CHAT_FILE_UPLOAD = (
os.environ.get("USER_PERMISSIONS_CHAT_FILE_UPLOAD", "True").lower() == "true" os.environ.get("USER_PERMISSIONS_CHAT_FILE_UPLOAD", "True").lower() == "true"
) )
@ -835,23 +918,39 @@ USER_PERMISSIONS_CHAT_TEMPORARY = (
os.environ.get("USER_PERMISSIONS_CHAT_TEMPORARY", "True").lower() == "true" os.environ.get("USER_PERMISSIONS_CHAT_TEMPORARY", "True").lower() == "true"
) )
USER_PERMISSIONS_FEATURES_WEB_SEARCH = (
os.environ.get("USER_PERMISSIONS_FEATURES_WEB_SEARCH", "True").lower() == "true"
)
USER_PERMISSIONS_FEATURES_IMAGE_GENERATION = (
os.environ.get("USER_PERMISSIONS_FEATURES_IMAGE_GENERATION", "True").lower()
== "true"
)
DEFAULT_USER_PERMISSIONS = {
"workspace": {
"models": USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS,
"knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ACCESS,
"prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ACCESS,
"tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS,
},
"chat": {
"controls": USER_PERMISSIONS_CHAT_CONTROLS,
"file_upload": USER_PERMISSIONS_CHAT_FILE_UPLOAD,
"delete": USER_PERMISSIONS_CHAT_DELETE,
"edit": USER_PERMISSIONS_CHAT_EDIT,
"temporary": USER_PERMISSIONS_CHAT_TEMPORARY,
},
"features": {
"web_search": USER_PERMISSIONS_FEATURES_WEB_SEARCH,
"image_generation": USER_PERMISSIONS_FEATURES_IMAGE_GENERATION,
},
}
USER_PERMISSIONS = PersistentConfig( USER_PERMISSIONS = PersistentConfig(
"USER_PERMISSIONS", "USER_PERMISSIONS",
"user.permissions", "user.permissions",
{ DEFAULT_USER_PERMISSIONS,
"workspace": {
"models": USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS,
"knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ACCESS,
"prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ACCESS,
"tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS,
},
"chat": {
"file_upload": USER_PERMISSIONS_CHAT_FILE_UPLOAD,
"delete": USER_PERMISSIONS_CHAT_DELETE,
"edit": USER_PERMISSIONS_CHAT_EDIT,
"temporary": USER_PERMISSIONS_CHAT_TEMPORARY,
},
},
) )
ENABLE_CHANNELS = PersistentConfig( ENABLE_CHANNELS = PersistentConfig(
@ -1034,6 +1133,32 @@ JSON format: { "tags": ["tag1", "tag2", "tag3"] }
{{MESSAGES:END:6}} {{MESSAGES:END:6}}
</chat_history>""" </chat_history>"""
IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE = PersistentConfig(
"IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE",
"task.image.prompt_template",
os.environ.get("IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE", ""),
)
DEFAULT_IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE = """### Task:
Generate a detailed prompt for am image generation task based on the given language and context. Describe the image as if you were explaining it to someone who cannot see it. Include relevant details, colors, shapes, and any other important elements.
### Guidelines:
- Be descriptive and detailed, focusing on the most important aspects of the image.
- Avoid making assumptions or adding information not present in the image.
- Use the chat's primary language; default to English if multilingual.
- If the image is too complex, focus on the most prominent elements.
### Output:
Strictly return in JSON format:
{
"prompt": "Your detailed description here."
}
### Chat History:
<chat_history>
{{MESSAGES:END:6}}
</chat_history>"""
ENABLE_TAGS_GENERATION = PersistentConfig( ENABLE_TAGS_GENERATION = PersistentConfig(
"ENABLE_TAGS_GENERATION", "ENABLE_TAGS_GENERATION",
"task.tags.enable", "task.tags.enable",
@ -1193,6 +1318,7 @@ CHROMA_HTTP_SSL = os.environ.get("CHROMA_HTTP_SSL", "false").lower() == "true"
# Milvus # Milvus
MILVUS_URI = os.environ.get("MILVUS_URI", f"{DATA_DIR}/vector_db/milvus.db") MILVUS_URI = os.environ.get("MILVUS_URI", f"{DATA_DIR}/vector_db/milvus.db")
MILVUS_DB = os.environ.get("MILVUS_DB", "default")
# Qdrant # Qdrant
QDRANT_URI = os.environ.get("QDRANT_URI", None) QDRANT_URI = os.environ.get("QDRANT_URI", None)
@ -1602,6 +1728,13 @@ ENABLE_IMAGE_GENERATION = PersistentConfig(
"image_generation.enable", "image_generation.enable",
os.environ.get("ENABLE_IMAGE_GENERATION", "").lower() == "true", os.environ.get("ENABLE_IMAGE_GENERATION", "").lower() == "true",
) )
ENABLE_IMAGE_PROMPT_GENERATION = PersistentConfig(
"ENABLE_IMAGE_PROMPT_GENERATION",
"image_generation.prompt.enable",
os.environ.get("ENABLE_IMAGE_PROMPT_GENERATION", "true").lower() == "true",
)
AUTOMATIC1111_BASE_URL = PersistentConfig( AUTOMATIC1111_BASE_URL = PersistentConfig(
"AUTOMATIC1111_BASE_URL", "AUTOMATIC1111_BASE_URL",
"image_generation.automatic1111.base_url", "image_generation.automatic1111.base_url",
@ -1931,6 +2064,12 @@ LDAP_SERVER_PORT = PersistentConfig(
int(os.environ.get("LDAP_SERVER_PORT", "389")), int(os.environ.get("LDAP_SERVER_PORT", "389")),
) )
LDAP_ATTRIBUTE_FOR_MAIL = PersistentConfig(
"LDAP_ATTRIBUTE_FOR_MAIL",
"ldap.server.attribute_for_mail",
os.environ.get("LDAP_ATTRIBUTE_FOR_MAIL", "mail"),
)
LDAP_ATTRIBUTE_FOR_USERNAME = PersistentConfig( LDAP_ATTRIBUTE_FOR_USERNAME = PersistentConfig(
"LDAP_ATTRIBUTE_FOR_USERNAME", "LDAP_ATTRIBUTE_FOR_USERNAME",
"ldap.server.attribute_for_username", "ldap.server.attribute_for_username",

View File

@ -113,6 +113,7 @@ class TASKS(str, Enum):
TAGS_GENERATION = "tags_generation" TAGS_GENERATION = "tags_generation"
EMOJI_GENERATION = "emoji_generation" EMOJI_GENERATION = "emoji_generation"
QUERY_GENERATION = "query_generation" QUERY_GENERATION = "query_generation"
IMAGE_PROMPT_GENERATION = "image_prompt_generation"
AUTOCOMPLETE_GENERATION = "autocomplete_generation" AUTOCOMPLETE_GENERATION = "autocomplete_generation"
FUNCTION_CALLING = "function_calling" FUNCTION_CALLING = "function_calling"
MOA_RESPONSE_GENERATION = "moa_response_generation" MOA_RESPONSE_GENERATION = "moa_response_generation"

View File

@ -274,6 +274,8 @@ DATABASE_URL = os.environ.get("DATABASE_URL", f"sqlite:///{DATA_DIR}/webui.db")
if "postgres://" in DATABASE_URL: if "postgres://" in DATABASE_URL:
DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://") DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://")
DATABASE_SCHEMA = os.environ.get("DATABASE_SCHEMA", None)
DATABASE_POOL_SIZE = os.environ.get("DATABASE_POOL_SIZE", 0) DATABASE_POOL_SIZE = os.environ.get("DATABASE_POOL_SIZE", 0)
if DATABASE_POOL_SIZE == "": if DATABASE_POOL_SIZE == "":

View File

@ -7,6 +7,7 @@ from open_webui.internal.wrappers import register_connection
from open_webui.env import ( from open_webui.env import (
OPEN_WEBUI_DIR, OPEN_WEBUI_DIR,
DATABASE_URL, DATABASE_URL,
DATABASE_SCHEMA,
SRC_LOG_LEVELS, SRC_LOG_LEVELS,
DATABASE_POOL_MAX_OVERFLOW, DATABASE_POOL_MAX_OVERFLOW,
DATABASE_POOL_RECYCLE, DATABASE_POOL_RECYCLE,
@ -14,7 +15,7 @@ from open_webui.env import (
DATABASE_POOL_TIMEOUT, DATABASE_POOL_TIMEOUT,
) )
from peewee_migrate import Router from peewee_migrate import Router
from sqlalchemy import Dialect, create_engine, types from sqlalchemy import Dialect, create_engine, MetaData, types
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import scoped_session, sessionmaker from sqlalchemy.orm import scoped_session, sessionmaker
from sqlalchemy.pool import QueuePool, NullPool from sqlalchemy.pool import QueuePool, NullPool
@ -99,7 +100,8 @@ else:
SessionLocal = sessionmaker( SessionLocal = sessionmaker(
autocommit=False, autoflush=False, bind=engine, expire_on_commit=False autocommit=False, autoflush=False, bind=engine, expire_on_commit=False
) )
Base = declarative_base() metadata_obj = MetaData(schema=DATABASE_SCHEMA)
Base = declarative_base(metadata=metadata_obj)
Session = scoped_session(SessionLocal) Session = scoped_session(SessionLocal)

View File

@ -108,6 +108,7 @@ from open_webui.config import (
COMFYUI_WORKFLOW, COMFYUI_WORKFLOW,
COMFYUI_WORKFLOW_NODES, COMFYUI_WORKFLOW_NODES,
ENABLE_IMAGE_GENERATION, ENABLE_IMAGE_GENERATION,
ENABLE_IMAGE_PROMPT_GENERATION,
IMAGE_GENERATION_ENGINE, IMAGE_GENERATION_ENGINE,
IMAGE_GENERATION_MODEL, IMAGE_GENERATION_MODEL,
IMAGE_SIZE, IMAGE_SIZE,
@ -225,6 +226,7 @@ from open_webui.config import (
LDAP_SERVER_LABEL, LDAP_SERVER_LABEL,
LDAP_SERVER_HOST, LDAP_SERVER_HOST,
LDAP_SERVER_PORT, LDAP_SERVER_PORT,
LDAP_ATTRIBUTE_FOR_MAIL,
LDAP_ATTRIBUTE_FOR_USERNAME, LDAP_ATTRIBUTE_FOR_USERNAME,
LDAP_SEARCH_FILTERS, LDAP_SEARCH_FILTERS,
LDAP_SEARCH_BASE, LDAP_SEARCH_BASE,
@ -254,6 +256,7 @@ from open_webui.config import (
ENABLE_AUTOCOMPLETE_GENERATION, ENABLE_AUTOCOMPLETE_GENERATION,
TITLE_GENERATION_PROMPT_TEMPLATE, TITLE_GENERATION_PROMPT_TEMPLATE,
TAGS_GENERATION_PROMPT_TEMPLATE, TAGS_GENERATION_PROMPT_TEMPLATE,
IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE,
TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE, TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE,
QUERY_GENERATION_PROMPT_TEMPLATE, QUERY_GENERATION_PROMPT_TEMPLATE,
AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE, AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE,
@ -437,6 +440,7 @@ app.state.config.ENABLE_LDAP = ENABLE_LDAP
app.state.config.LDAP_SERVER_LABEL = LDAP_SERVER_LABEL app.state.config.LDAP_SERVER_LABEL = LDAP_SERVER_LABEL
app.state.config.LDAP_SERVER_HOST = LDAP_SERVER_HOST app.state.config.LDAP_SERVER_HOST = LDAP_SERVER_HOST
app.state.config.LDAP_SERVER_PORT = LDAP_SERVER_PORT app.state.config.LDAP_SERVER_PORT = LDAP_SERVER_PORT
app.state.config.LDAP_ATTRIBUTE_FOR_MAIL = LDAP_ATTRIBUTE_FOR_MAIL
app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME = LDAP_ATTRIBUTE_FOR_USERNAME app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME = LDAP_ATTRIBUTE_FOR_USERNAME
app.state.config.LDAP_APP_DN = LDAP_APP_DN app.state.config.LDAP_APP_DN = LDAP_APP_DN
app.state.config.LDAP_APP_PASSWORD = LDAP_APP_PASSWORD app.state.config.LDAP_APP_PASSWORD = LDAP_APP_PASSWORD
@ -572,6 +576,7 @@ app.state.EMBEDDING_FUNCTION = get_embedding_function(
app.state.config.IMAGE_GENERATION_ENGINE = IMAGE_GENERATION_ENGINE app.state.config.IMAGE_GENERATION_ENGINE = IMAGE_GENERATION_ENGINE
app.state.config.ENABLE_IMAGE_GENERATION = ENABLE_IMAGE_GENERATION app.state.config.ENABLE_IMAGE_GENERATION = ENABLE_IMAGE_GENERATION
app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = ENABLE_IMAGE_PROMPT_GENERATION
app.state.config.IMAGES_OPENAI_API_BASE_URL = IMAGES_OPENAI_API_BASE_URL app.state.config.IMAGES_OPENAI_API_BASE_URL = IMAGES_OPENAI_API_BASE_URL
app.state.config.IMAGES_OPENAI_API_KEY = IMAGES_OPENAI_API_KEY app.state.config.IMAGES_OPENAI_API_KEY = IMAGES_OPENAI_API_KEY
@ -642,6 +647,10 @@ app.state.config.ENABLE_TAGS_GENERATION = ENABLE_TAGS_GENERATION
app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE = TITLE_GENERATION_PROMPT_TEMPLATE app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE = TITLE_GENERATION_PROMPT_TEMPLATE
app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE = TAGS_GENERATION_PROMPT_TEMPLATE app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE = TAGS_GENERATION_PROMPT_TEMPLATE
app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE = (
IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE
)
app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = ( app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = (
TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE
) )

View File

@ -393,7 +393,7 @@ class ChatTable:
limit: int = 50, limit: int = 50,
) -> list[ChatModel]: ) -> list[ChatModel]:
with get_db() as db: with get_db() as db:
query = db.query(Chat).filter_by(user_id=user_id).filter_by(folder_id=None) query = db.query(Chat).filter_by(user_id=user_id)
if not include_archived: if not include_archived:
query = query.filter_by(archived=False) query = query.filter_by(archived=False)

View File

@ -80,12 +80,11 @@ class GroupResponse(BaseModel):
class GroupForm(BaseModel): class GroupForm(BaseModel):
name: str name: str
description: str description: str
permissions: Optional[dict] = None
class GroupUpdateForm(GroupForm): class GroupUpdateForm(GroupForm):
permissions: Optional[dict] = None
user_ids: Optional[list[str]] = None user_ids: Optional[list[str]] = None
admin_ids: Optional[list[str]] = None
class GroupTable: class GroupTable:
@ -95,7 +94,7 @@ class GroupTable:
with get_db() as db: with get_db() as db:
group = GroupModel( group = GroupModel(
**{ **{
**form_data.model_dump(), **form_data.model_dump(exclude_none=True),
"id": str(uuid.uuid4()), "id": str(uuid.uuid4()),
"user_id": user_id, "user_id": user_id,
"created_at": int(time.time()), "created_at": int(time.time()),
@ -189,5 +188,24 @@ class GroupTable:
except Exception: except Exception:
return False return False
def remove_user_from_all_groups(self, user_id: str) -> bool:
with get_db() as db:
try:
groups = self.get_groups_by_member_id(user_id)
for group in groups:
group.user_ids.remove(user_id)
db.query(Group).filter_by(id=group.id).update(
{
"user_ids": group.user_ids,
"updated_at": int(time.time()),
}
)
db.commit()
return True
except Exception:
return False
Groups = GroupTable() Groups = GroupTable()

View File

@ -2,7 +2,12 @@ import time
from typing import Optional from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db from open_webui.internal.db import Base, JSONField, get_db
from open_webui.models.chats import Chats from open_webui.models.chats import Chats
from open_webui.models.groups import Groups
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text from sqlalchemy import BigInteger, Column, String, Text
@ -268,9 +273,11 @@ class UsersTable:
def delete_user_by_id(self, id: str) -> bool: def delete_user_by_id(self, id: str) -> bool:
try: try:
# Remove User from Groups
Groups.remove_user_from_all_groups(id)
# Delete User Chats # Delete User Chats
result = Chats.delete_chats_by_user_id(id) result = Chats.delete_chats_by_user_id(id)
if result: if result:
with get_db() as db: with get_db() as db:
# Delete User # Delete User
@ -300,5 +307,10 @@ class UsersTable:
except Exception: except Exception:
return None return None
def get_valid_user_ids(self, user_ids: list[str]) -> list[str]:
with get_db() as db:
users = db.query(User).filter(User.id.in_(user_ids)).all()
return [user.id for user in users]
Users = UsersTable() Users = UsersTable()

View File

@ -7,13 +7,14 @@ from typing import Optional
from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
from open_webui.config import ( from open_webui.config import (
MILVUS_URI, MILVUS_URI,
MILVUS_DB,
) )
class MilvusClient: class MilvusClient:
def __init__(self): def __init__(self):
self.collection_prefix = "open_webui" self.collection_prefix = "open_webui"
self.client = Client(uri=MILVUS_URI) self.client = Client(uri=MILVUS_URI, database=MILVUS_DB)
def _result_to_get_result(self, result) -> GetResult: def _result_to_get_result(self, result) -> GetResult:
ids = [] ids = []

View File

@ -23,7 +23,7 @@ def search_bing(
filter_list: Optional[list[str]] = None, filter_list: Optional[list[str]] = None,
) -> list[SearchResult]: ) -> list[SearchResult]:
mkt = locale mkt = locale
params = {"q": query, "mkt": mkt, "answerCount": count} params = {"q": query, "mkt": mkt, "count": count}
headers = {"Ocp-Apim-Subscription-Key": subscription_key} headers = {"Ocp-Apim-Subscription-Key": subscription_key}
try: try:

View File

@ -170,6 +170,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
LDAP_SERVER_LABEL = request.app.state.config.LDAP_SERVER_LABEL LDAP_SERVER_LABEL = request.app.state.config.LDAP_SERVER_LABEL
LDAP_SERVER_HOST = request.app.state.config.LDAP_SERVER_HOST LDAP_SERVER_HOST = request.app.state.config.LDAP_SERVER_HOST
LDAP_SERVER_PORT = request.app.state.config.LDAP_SERVER_PORT LDAP_SERVER_PORT = request.app.state.config.LDAP_SERVER_PORT
LDAP_ATTRIBUTE_FOR_MAIL = request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL
LDAP_ATTRIBUTE_FOR_USERNAME = request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME LDAP_ATTRIBUTE_FOR_USERNAME = request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME
LDAP_SEARCH_BASE = request.app.state.config.LDAP_SEARCH_BASE LDAP_SEARCH_BASE = request.app.state.config.LDAP_SEARCH_BASE
LDAP_SEARCH_FILTERS = request.app.state.config.LDAP_SEARCH_FILTERS LDAP_SEARCH_FILTERS = request.app.state.config.LDAP_SEARCH_FILTERS
@ -218,7 +219,11 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
search_success = connection_app.search( search_success = connection_app.search(
search_base=LDAP_SEARCH_BASE, search_base=LDAP_SEARCH_BASE,
search_filter=f"(&({LDAP_ATTRIBUTE_FOR_USERNAME}={escape_filter_chars(form_data.user.lower())}){LDAP_SEARCH_FILTERS})", search_filter=f"(&({LDAP_ATTRIBUTE_FOR_USERNAME}={escape_filter_chars(form_data.user.lower())}){LDAP_SEARCH_FILTERS})",
attributes=[f"{LDAP_ATTRIBUTE_FOR_USERNAME}", "mail", "cn"], attributes=[
f"{LDAP_ATTRIBUTE_FOR_USERNAME}",
f"{LDAP_ATTRIBUTE_FOR_MAIL}",
"cn",
],
) )
if not search_success: if not search_success:
@ -226,7 +231,9 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
entry = connection_app.entries[0] entry = connection_app.entries[0]
username = str(entry[f"{LDAP_ATTRIBUTE_FOR_USERNAME}"]).lower() username = str(entry[f"{LDAP_ATTRIBUTE_FOR_USERNAME}"]).lower()
mail = str(entry["mail"]) mail = str(entry[f"{LDAP_ATTRIBUTE_FOR_MAIL}"])
if not mail or mail == "" or mail == "[]":
raise HTTPException(400, f"User {form_data.user} does not have mail.")
cn = str(entry["cn"]) cn = str(entry["cn"])
user_dn = entry.entry_dn user_dn = entry.entry_dn
@ -691,6 +698,7 @@ class LdapServerConfig(BaseModel):
label: str label: str
host: str host: str
port: Optional[int] = None port: Optional[int] = None
attribute_for_mail: str = "mail"
attribute_for_username: str = "uid" attribute_for_username: str = "uid"
app_dn: str app_dn: str
app_dn_password: str app_dn_password: str
@ -707,6 +715,7 @@ async def get_ldap_server(request: Request, user=Depends(get_admin_user)):
"label": request.app.state.config.LDAP_SERVER_LABEL, "label": request.app.state.config.LDAP_SERVER_LABEL,
"host": request.app.state.config.LDAP_SERVER_HOST, "host": request.app.state.config.LDAP_SERVER_HOST,
"port": request.app.state.config.LDAP_SERVER_PORT, "port": request.app.state.config.LDAP_SERVER_PORT,
"attribute_for_mail": request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL,
"attribute_for_username": request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME, "attribute_for_username": request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME,
"app_dn": request.app.state.config.LDAP_APP_DN, "app_dn": request.app.state.config.LDAP_APP_DN,
"app_dn_password": request.app.state.config.LDAP_APP_PASSWORD, "app_dn_password": request.app.state.config.LDAP_APP_PASSWORD,
@ -725,6 +734,7 @@ async def update_ldap_server(
required_fields = [ required_fields = [
"label", "label",
"host", "host",
"attribute_for_mail",
"attribute_for_username", "attribute_for_username",
"app_dn", "app_dn",
"app_dn_password", "app_dn_password",
@ -743,6 +753,7 @@ async def update_ldap_server(
request.app.state.config.LDAP_SERVER_LABEL = form_data.label request.app.state.config.LDAP_SERVER_LABEL = form_data.label
request.app.state.config.LDAP_SERVER_HOST = form_data.host request.app.state.config.LDAP_SERVER_HOST = form_data.host
request.app.state.config.LDAP_SERVER_PORT = form_data.port request.app.state.config.LDAP_SERVER_PORT = form_data.port
request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL = form_data.attribute_for_mail
request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME = ( request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME = (
form_data.attribute_for_username form_data.attribute_for_username
) )
@ -758,6 +769,7 @@ async def update_ldap_server(
"label": request.app.state.config.LDAP_SERVER_LABEL, "label": request.app.state.config.LDAP_SERVER_LABEL,
"host": request.app.state.config.LDAP_SERVER_HOST, "host": request.app.state.config.LDAP_SERVER_HOST,
"port": request.app.state.config.LDAP_SERVER_PORT, "port": request.app.state.config.LDAP_SERVER_PORT,
"attribute_for_mail": request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL,
"attribute_for_username": request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME, "attribute_for_username": request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME,
"app_dn": request.app.state.config.LDAP_APP_DN, "app_dn": request.app.state.config.LDAP_APP_DN,
"app_dn_password": request.app.state.config.LDAP_APP_PASSWORD, "app_dn_password": request.app.state.config.LDAP_APP_PASSWORD,

View File

@ -345,6 +345,8 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
async def delete_file_by_id(id: str, user=Depends(get_verified_user)): async def delete_file_by_id(id: str, user=Depends(get_verified_user)):
file = Files.get_file_by_id(id) file = Files.get_file_by_id(id)
if file and (file.user_id == user.id or user.role == "admin"): if file and (file.user_id == user.id or user.role == "admin"):
# We should add Chroma cleanup here
result = Files.delete_file_by_id(id) result = Files.delete_file_by_id(id)
if result: if result:
try: try:

View File

@ -2,6 +2,8 @@ import os
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from open_webui.models.users import Users
from open_webui.models.groups import ( from open_webui.models.groups import (
Groups, Groups,
GroupForm, GroupForm,
@ -80,6 +82,9 @@ async def update_group_by_id(
id: str, form_data: GroupUpdateForm, user=Depends(get_admin_user) id: str, form_data: GroupUpdateForm, user=Depends(get_admin_user)
): ):
try: try:
if form_data.user_ids:
form_data.user_ids = Users.get_valid_user_ids(form_data.user_ids)
group = Groups.update_group_by_id(id, form_data) group = Groups.update_group_by_id(id, form_data)
if group: if group:
return group return group

View File

@ -43,6 +43,7 @@ async def get_config(request: Request, user=Depends(get_admin_user)):
return { return {
"enabled": request.app.state.config.ENABLE_IMAGE_GENERATION, "enabled": request.app.state.config.ENABLE_IMAGE_GENERATION,
"engine": request.app.state.config.IMAGE_GENERATION_ENGINE, "engine": request.app.state.config.IMAGE_GENERATION_ENGINE,
"prompt_generation": request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION,
"openai": { "openai": {
"OPENAI_API_BASE_URL": request.app.state.config.IMAGES_OPENAI_API_BASE_URL, "OPENAI_API_BASE_URL": request.app.state.config.IMAGES_OPENAI_API_BASE_URL,
"OPENAI_API_KEY": request.app.state.config.IMAGES_OPENAI_API_KEY, "OPENAI_API_KEY": request.app.state.config.IMAGES_OPENAI_API_KEY,
@ -86,6 +87,7 @@ class ComfyUIConfigForm(BaseModel):
class ConfigForm(BaseModel): class ConfigForm(BaseModel):
enabled: bool enabled: bool
engine: str engine: str
prompt_generation: bool
openai: OpenAIConfigForm openai: OpenAIConfigForm
automatic1111: Automatic1111ConfigForm automatic1111: Automatic1111ConfigForm
comfyui: ComfyUIConfigForm comfyui: ComfyUIConfigForm
@ -98,6 +100,10 @@ async def update_config(
request.app.state.config.IMAGE_GENERATION_ENGINE = form_data.engine request.app.state.config.IMAGE_GENERATION_ENGINE = form_data.engine
request.app.state.config.ENABLE_IMAGE_GENERATION = form_data.enabled request.app.state.config.ENABLE_IMAGE_GENERATION = form_data.enabled
request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = (
form_data.prompt_generation
)
request.app.state.config.IMAGES_OPENAI_API_BASE_URL = ( request.app.state.config.IMAGES_OPENAI_API_BASE_URL = (
form_data.openai.OPENAI_API_BASE_URL form_data.openai.OPENAI_API_BASE_URL
) )
@ -137,6 +143,7 @@ async def update_config(
return { return {
"enabled": request.app.state.config.ENABLE_IMAGE_GENERATION, "enabled": request.app.state.config.ENABLE_IMAGE_GENERATION,
"engine": request.app.state.config.IMAGE_GENERATION_ENGINE, "engine": request.app.state.config.IMAGE_GENERATION_ENGINE,
"prompt_generation": request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION,
"openai": { "openai": {
"OPENAI_API_BASE_URL": request.app.state.config.IMAGES_OPENAI_API_BASE_URL, "OPENAI_API_BASE_URL": request.app.state.config.IMAGES_OPENAI_API_BASE_URL,
"OPENAI_API_KEY": request.app.state.config.IMAGES_OPENAI_API_KEY, "OPENAI_API_KEY": request.app.state.config.IMAGES_OPENAI_API_KEY,
@ -408,10 +415,14 @@ def save_b64_image(b64_str):
return None return None
def save_url_image(url): def save_url_image(url, headers=None):
image_id = str(uuid.uuid4()) image_id = str(uuid.uuid4())
try: try:
r = requests.get(url) if headers:
r = requests.get(url, headers=headers)
else:
r = requests.get(url)
r.raise_for_status() r.raise_for_status()
if r.headers["content-type"].split("/")[0] == "image": if r.headers["content-type"].split("/")[0] == "image":
mime_type = r.headers["content-type"] mime_type = r.headers["content-type"]
@ -535,7 +546,13 @@ async def image_generations(
images = [] images = []
for image in res["data"]: for image in res["data"]:
image_filename = save_url_image(image["url"]) headers = None
if request.app.state.config.COMFYUI_API_KEY:
headers = {
"Authorization": f"Bearer {request.app.state.config.COMFYUI_API_KEY}"
}
image_filename = save_url_image(image["url"], headers)
images.append({"url": f"/cache/image/generations/{image_filename}"}) images.append({"url": f"/cache/image/generations/{image_filename}"})
file_body_path = IMAGE_CACHE_DIR.joinpath(f"{image_filename}.json") file_body_path = IMAGE_CACHE_DIR.joinpath(f"{image_filename}.json")

View File

@ -17,7 +17,7 @@ from open_webui.routers.retrieval import (
process_files_batch, process_files_batch,
BatchProcessFilesForm, BatchProcessFilesForm,
) )
from open_webui.storage.provider import Storage
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.auth import get_verified_user from open_webui.utils.auth import get_verified_user
@ -213,8 +213,12 @@ async def update_knowledge_by_id(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND,
) )
# Is the user the original creator, in a group with write access, or an admin
if knowledge.user_id != user.id and user.role != "admin": if (
knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control)
and user.role != "admin"
):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
@ -420,6 +424,18 @@ def remove_file_from_knowledge_by_id(
collection_name=knowledge.id, filter={"file_id": form_data.file_id} collection_name=knowledge.id, filter={"file_id": form_data.file_id}
) )
# Remove the file's collection from vector database
file_collection = f"file-{form_data.file_id}"
if VECTOR_DB_CLIENT.has_collection(collection_name=file_collection):
VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection)
# Delete physical file
if file.path:
Storage.delete_file(file.path)
# Delete file from database
Files.delete_file_by_id(form_data.file_id)
if knowledge: if knowledge:
data = knowledge.data or {} data = knowledge.data or {}
file_ids = data.get("file_ids", []) file_ids = data.get("file_ids", [])

View File

@ -155,6 +155,16 @@ async def update_model_by_id(
detail=ERROR_MESSAGES.NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND,
) )
if (
model.user_id != user.id
and not has_access(user.id, "write", model.access_control)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
model = Models.update_model_by_id(id, form_data) model = Models.update_model_by_id(id, form_data)
return model return model

View File

@ -152,10 +152,12 @@ async def send_post_request(
) )
def get_api_key(url, configs): def get_api_key(idx, url, configs):
parsed_url = urlparse(url) parsed_url = urlparse(url)
base_url = f"{parsed_url.scheme}://{parsed_url.netloc}" base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
return configs.get(base_url, {}).get("key", None) return configs.get(str(idx), configs.get(base_url, {})).get(
"key", None
) # Legacy support
########################################## ##########################################
@ -238,11 +240,13 @@ async def update_config(
request.app.state.config.OLLAMA_BASE_URLS = form_data.OLLAMA_BASE_URLS request.app.state.config.OLLAMA_BASE_URLS = form_data.OLLAMA_BASE_URLS
request.app.state.config.OLLAMA_API_CONFIGS = form_data.OLLAMA_API_CONFIGS request.app.state.config.OLLAMA_API_CONFIGS = form_data.OLLAMA_API_CONFIGS
# Remove any extra configs # Remove the API configs that are not in the API URLS
config_urls = request.app.state.config.OLLAMA_API_CONFIGS.keys() keys = list(map(str, range(len(request.app.state.config.OLLAMA_BASE_URLS))))
for url in list(request.app.state.config.OLLAMA_BASE_URLS): request.app.state.config.OLLAMA_API_CONFIGS = {
if url not in config_urls: key: value
request.app.state.config.OLLAMA_API_CONFIGS.pop(url, None) for key, value in request.app.state.config.OLLAMA_API_CONFIGS.items()
if key in keys
}
return { return {
"ENABLE_OLLAMA_API": request.app.state.config.ENABLE_OLLAMA_API, "ENABLE_OLLAMA_API": request.app.state.config.ENABLE_OLLAMA_API,
@ -256,12 +260,19 @@ async def get_all_models(request: Request):
log.info("get_all_models()") log.info("get_all_models()")
if request.app.state.config.ENABLE_OLLAMA_API: if request.app.state.config.ENABLE_OLLAMA_API:
request_tasks = [] request_tasks = []
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS): for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS):
if url not in request.app.state.config.OLLAMA_API_CONFIGS: if (str(idx) not in request.app.state.config.OLLAMA_API_CONFIGS) and (
url not in request.app.state.config.OLLAMA_API_CONFIGS # Legacy support
):
request_tasks.append(send_get_request(f"{url}/api/tags")) request_tasks.append(send_get_request(f"{url}/api/tags"))
else: else:
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}) api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(
url, {}
), # Legacy support
)
enable = api_config.get("enable", True) enable = api_config.get("enable", True)
key = api_config.get("key", None) key = api_config.get("key", None)
@ -275,7 +286,12 @@ async def get_all_models(request: Request):
for idx, response in enumerate(responses): for idx, response in enumerate(responses):
if response: if response:
url = request.app.state.config.OLLAMA_BASE_URLS[idx] url = request.app.state.config.OLLAMA_BASE_URLS[idx]
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}) api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(
url, {}
), # Legacy support
)
prefix_id = api_config.get("prefix_id", None) prefix_id = api_config.get("prefix_id", None)
model_ids = api_config.get("model_ids", []) model_ids = api_config.get("model_ids", [])
@ -349,7 +365,7 @@ async def get_ollama_tags(
models = await get_all_models(request) models = await get_all_models(request)
else: else:
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
key = get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS) key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
r = None r = None
try: try:
@ -393,11 +409,14 @@ async def get_ollama_versions(request: Request, url_idx: Optional[int] = None):
request_tasks = [ request_tasks = [
send_get_request( send_get_request(
f"{url}/api/version", f"{url}/api/version",
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}).get( request.app.state.config.OLLAMA_API_CONFIGS.get(
"key", None str(idx),
), request.app.state.config.OLLAMA_API_CONFIGS.get(
url, {}
), # Legacy support
).get("key", None),
) )
for url in request.app.state.config.OLLAMA_BASE_URLS for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS)
] ]
responses = await asyncio.gather(*request_tasks) responses = await asyncio.gather(*request_tasks)
responses = list(filter(lambda x: x is not None, responses)) responses = list(filter(lambda x: x is not None, responses))
@ -454,11 +473,14 @@ async def get_ollama_loaded_models(request: Request, user=Depends(get_verified_u
request_tasks = [ request_tasks = [
send_get_request( send_get_request(
f"{url}/api/ps", f"{url}/api/ps",
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}).get( request.app.state.config.OLLAMA_API_CONFIGS.get(
"key", None str(idx),
), request.app.state.config.OLLAMA_API_CONFIGS.get(
url, {}
), # Legacy support
).get("key", None),
) )
for url in request.app.state.config.OLLAMA_BASE_URLS for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS)
] ]
responses = await asyncio.gather(*request_tasks) responses = await asyncio.gather(*request_tasks)
@ -488,7 +510,7 @@ async def pull_model(
return await send_post_request( return await send_post_request(
url=f"{url}/api/pull", url=f"{url}/api/pull",
payload=json.dumps(payload), payload=json.dumps(payload),
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
) )
@ -524,16 +546,17 @@ async def push_model(
return await send_post_request( return await send_post_request(
url=f"{url}/api/push", url=f"{url}/api/push",
payload=form_data.model_dump_json(exclude_none=True).encode(), payload=form_data.model_dump_json(exclude_none=True).encode(),
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
) )
class CreateModelForm(BaseModel): class CreateModelForm(BaseModel):
name: str model: Optional[str] = None
modelfile: Optional[str] = None
stream: Optional[bool] = None stream: Optional[bool] = None
path: Optional[str] = None path: Optional[str] = None
model_config = ConfigDict(extra="allow")
@router.post("/api/create") @router.post("/api/create")
@router.post("/api/create/{url_idx}") @router.post("/api/create/{url_idx}")
@ -549,7 +572,7 @@ async def create_model(
return await send_post_request( return await send_post_request(
url=f"{url}/api/create", url=f"{url}/api/create",
payload=form_data.model_dump_json(exclude_none=True).encode(), payload=form_data.model_dump_json(exclude_none=True).encode(),
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
) )
@ -579,7 +602,7 @@ async def copy_model(
) )
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
key = get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS) key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
try: try:
r = requests.request( r = requests.request(
@ -634,7 +657,7 @@ async def delete_model(
) )
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
key = get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS) key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
try: try:
r = requests.request( r = requests.request(
@ -684,7 +707,7 @@ async def show_model_info(
url_idx = random.choice(models[form_data.name]["urls"]) url_idx = random.choice(models[form_data.name]["urls"])
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
key = get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS) key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
try: try:
r = requests.request( r = requests.request(
@ -753,7 +776,7 @@ async def embed(
) )
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
key = get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS) key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
try: try:
r = requests.request( r = requests.request(
@ -822,7 +845,7 @@ async def embeddings(
) )
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
key = get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS) key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
try: try:
r = requests.request( r = requests.request(
@ -897,7 +920,10 @@ async def generate_completion(
) )
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}) api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(url_idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support
)
prefix_id = api_config.get("prefix_id", None) prefix_id = api_config.get("prefix_id", None)
if prefix_id: if prefix_id:
@ -906,7 +932,7 @@ async def generate_completion(
return await send_post_request( return await send_post_request(
url=f"{url}/api/generate", url=f"{url}/api/generate",
payload=form_data.model_dump_json(exclude_none=True).encode(), payload=form_data.model_dump_json(exclude_none=True).encode(),
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
) )
@ -936,7 +962,7 @@ async def get_ollama_url(request: Request, model: str, url_idx: Optional[int] =
) )
url_idx = random.choice(models[model].get("urls", [])) url_idx = random.choice(models[model].get("urls", []))
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
return url return url, url_idx
@router.post("/api/chat") @router.post("/api/chat")
@ -1004,8 +1030,11 @@ async def generate_chat_completion(
if ":" not in payload["model"]: if ":" not in payload["model"]:
payload["model"] = f"{payload['model']}:latest" payload["model"] = f"{payload['model']}:latest"
url = await get_ollama_url(request, payload["model"], url_idx) url, url_idx = await get_ollama_url(request, payload["model"], url_idx)
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}) api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(url_idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support
)
prefix_id = api_config.get("prefix_id", None) prefix_id = api_config.get("prefix_id", None)
if prefix_id: if prefix_id:
@ -1015,7 +1044,7 @@ async def generate_chat_completion(
url=f"{url}/api/chat", url=f"{url}/api/chat",
payload=json.dumps(payload), payload=json.dumps(payload),
stream=form_data.stream, stream=form_data.stream,
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
content_type="application/x-ndjson", content_type="application/x-ndjson",
) )
@ -1103,8 +1132,11 @@ async def generate_openai_completion(
if ":" not in payload["model"]: if ":" not in payload["model"]:
payload["model"] = f"{payload['model']}:latest" payload["model"] = f"{payload['model']}:latest"
url = await get_ollama_url(request, payload["model"], url_idx) url, url_idx = await get_ollama_url(request, payload["model"], url_idx)
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}) api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(url_idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support
)
prefix_id = api_config.get("prefix_id", None) prefix_id = api_config.get("prefix_id", None)
@ -1115,7 +1147,7 @@ async def generate_openai_completion(
url=f"{url}/v1/completions", url=f"{url}/v1/completions",
payload=json.dumps(payload), payload=json.dumps(payload),
stream=payload.get("stream", False), stream=payload.get("stream", False),
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
) )
@ -1177,8 +1209,11 @@ async def generate_openai_chat_completion(
if ":" not in payload["model"]: if ":" not in payload["model"]:
payload["model"] = f"{payload['model']}:latest" payload["model"] = f"{payload['model']}:latest"
url = await get_ollama_url(request, payload["model"], url_idx) url, url_idx = await get_ollama_url(request, payload["model"], url_idx)
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}) api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(url_idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support
)
prefix_id = api_config.get("prefix_id", None) prefix_id = api_config.get("prefix_id", None)
if prefix_id: if prefix_id:
@ -1188,7 +1223,7 @@ async def generate_openai_chat_completion(
url=f"{url}/v1/chat/completions", url=f"{url}/v1/chat/completions",
payload=json.dumps(payload), payload=json.dumps(payload),
stream=payload.get("stream", False), stream=payload.get("stream", False),
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
) )

View File

@ -145,11 +145,13 @@ async def update_config(
request.app.state.config.OPENAI_API_CONFIGS = form_data.OPENAI_API_CONFIGS request.app.state.config.OPENAI_API_CONFIGS = form_data.OPENAI_API_CONFIGS
# Remove any extra configs # Remove the API configs that are not in the API URLS
config_urls = request.app.state.config.OPENAI_API_CONFIGS.keys() keys = list(map(str, range(len(request.app.state.config.OPENAI_API_BASE_URLS))))
for idx, url in enumerate(request.app.state.config.OPENAI_API_BASE_URLS): request.app.state.config.OPENAI_API_CONFIGS = {
if url not in config_urls: key: value
request.app.state.config.OPENAI_API_CONFIGS.pop(url, None) for key, value in request.app.state.config.OPENAI_API_CONFIGS.items()
if key in keys
}
return { return {
"ENABLE_OPENAI_API": request.app.state.config.ENABLE_OPENAI_API, "ENABLE_OPENAI_API": request.app.state.config.ENABLE_OPENAI_API,
@ -264,14 +266,21 @@ async def get_all_models_responses(request: Request) -> list:
request_tasks = [] request_tasks = []
for idx, url in enumerate(request.app.state.config.OPENAI_API_BASE_URLS): for idx, url in enumerate(request.app.state.config.OPENAI_API_BASE_URLS):
if url not in request.app.state.config.OPENAI_API_CONFIGS: if (str(idx) not in request.app.state.config.OPENAI_API_CONFIGS) and (
url not in request.app.state.config.OPENAI_API_CONFIGS # Legacy support
):
request_tasks.append( request_tasks.append(
send_get_request( send_get_request(
f"{url}/models", request.app.state.config.OPENAI_API_KEYS[idx] f"{url}/models", request.app.state.config.OPENAI_API_KEYS[idx]
) )
) )
else: else:
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(url, {}) api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
str(idx),
request.app.state.config.OPENAI_API_CONFIGS.get(
url, {}
), # Legacy support
)
enable = api_config.get("enable", True) enable = api_config.get("enable", True)
model_ids = api_config.get("model_ids", []) model_ids = api_config.get("model_ids", [])
@ -310,7 +319,12 @@ async def get_all_models_responses(request: Request) -> list:
for idx, response in enumerate(responses): for idx, response in enumerate(responses):
if response: if response:
url = request.app.state.config.OPENAI_API_BASE_URLS[idx] url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(url, {}) api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
str(idx),
request.app.state.config.OPENAI_API_CONFIGS.get(
url, {}
), # Legacy support
)
prefix_id = api_config.get("prefix_id", None) prefix_id = api_config.get("prefix_id", None)
@ -573,6 +587,7 @@ async def generate_chat_completion(
detail="Model not found", detail="Model not found",
) )
await get_all_models(request)
model = request.app.state.OPENAI_MODELS.get(model_id) model = request.app.state.OPENAI_MODELS.get(model_id)
if model: if model:
idx = model["urlIdx"] idx = model["urlIdx"]
@ -584,7 +599,10 @@ async def generate_chat_completion(
# Get the API config for the model # Get the API config for the model
api_config = request.app.state.config.OPENAI_API_CONFIGS.get( api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
request.app.state.config.OPENAI_API_BASE_URLS[idx], {} str(idx),
request.app.state.config.OPENAI_API_CONFIGS.get(
request.app.state.config.OPENAI_API_BASE_URLS[idx], {}
), # Legacy support
) )
prefix_id = api_config.get("prefix_id", None) prefix_id = api_config.get("prefix_id", None)

View File

@ -112,7 +112,12 @@ async def update_prompt_by_command(
detail=ERROR_MESSAGES.NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND,
) )
if prompt.user_id != user.id and user.role != "admin": # Is the user the original creator, in a group with write access, or an admin
if (
prompt.user_id != user.id
and not has_access(user.id, "write", prompt.access_control)
and user.role != "admin"
):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED,

View File

@ -9,6 +9,7 @@ from open_webui.utils.chat import generate_chat_completion
from open_webui.utils.task import ( from open_webui.utils.task import (
title_generation_template, title_generation_template,
query_generation_template, query_generation_template,
image_prompt_generation_template,
autocomplete_generation_template, autocomplete_generation_template,
tags_generation_template, tags_generation_template,
emoji_generation_template, emoji_generation_template,
@ -23,6 +24,7 @@ from open_webui.utils.task import get_task_model_id
from open_webui.config import ( from open_webui.config import (
DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE, DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE,
DEFAULT_TAGS_GENERATION_PROMPT_TEMPLATE, DEFAULT_TAGS_GENERATION_PROMPT_TEMPLATE,
DEFAULT_IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE,
DEFAULT_QUERY_GENERATION_PROMPT_TEMPLATE, DEFAULT_QUERY_GENERATION_PROMPT_TEMPLATE,
DEFAULT_AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE, DEFAULT_AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE,
DEFAULT_EMOJI_GENERATION_PROMPT_TEMPLATE, DEFAULT_EMOJI_GENERATION_PROMPT_TEMPLATE,
@ -50,6 +52,7 @@ async def get_task_config(request: Request, user=Depends(get_verified_user)):
"TASK_MODEL": request.app.state.config.TASK_MODEL, "TASK_MODEL": request.app.state.config.TASK_MODEL,
"TASK_MODEL_EXTERNAL": request.app.state.config.TASK_MODEL_EXTERNAL, "TASK_MODEL_EXTERNAL": request.app.state.config.TASK_MODEL_EXTERNAL,
"TITLE_GENERATION_PROMPT_TEMPLATE": request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE, "TITLE_GENERATION_PROMPT_TEMPLATE": request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE,
"IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE": request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE,
"ENABLE_AUTOCOMPLETE_GENERATION": request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION, "ENABLE_AUTOCOMPLETE_GENERATION": request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION,
"AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH": request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH, "AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH": request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH,
"TAGS_GENERATION_PROMPT_TEMPLATE": request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE, "TAGS_GENERATION_PROMPT_TEMPLATE": request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE,
@ -65,6 +68,7 @@ class TaskConfigForm(BaseModel):
TASK_MODEL: Optional[str] TASK_MODEL: Optional[str]
TASK_MODEL_EXTERNAL: Optional[str] TASK_MODEL_EXTERNAL: Optional[str]
TITLE_GENERATION_PROMPT_TEMPLATE: str TITLE_GENERATION_PROMPT_TEMPLATE: str
IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE: str
ENABLE_AUTOCOMPLETE_GENERATION: bool ENABLE_AUTOCOMPLETE_GENERATION: bool
AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH: int AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH: int
TAGS_GENERATION_PROMPT_TEMPLATE: str TAGS_GENERATION_PROMPT_TEMPLATE: str
@ -114,6 +118,7 @@ async def update_task_config(
"TASK_MODEL": request.app.state.config.TASK_MODEL, "TASK_MODEL": request.app.state.config.TASK_MODEL,
"TASK_MODEL_EXTERNAL": request.app.state.config.TASK_MODEL_EXTERNAL, "TASK_MODEL_EXTERNAL": request.app.state.config.TASK_MODEL_EXTERNAL,
"TITLE_GENERATION_PROMPT_TEMPLATE": request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE, "TITLE_GENERATION_PROMPT_TEMPLATE": request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE,
"IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE": request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE,
"ENABLE_AUTOCOMPLETE_GENERATION": request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION, "ENABLE_AUTOCOMPLETE_GENERATION": request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION,
"AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH": request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH, "AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH": request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH,
"TAGS_GENERATION_PROMPT_TEMPLATE": request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE, "TAGS_GENERATION_PROMPT_TEMPLATE": request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE,
@ -256,6 +261,66 @@ async def generate_chat_tags(
) )
@router.post("/image_prompt/completions")
async def generate_image_prompt(
request: Request, form_data: dict, user=Depends(get_verified_user)
):
models = request.app.state.MODELS
model_id = form_data["model"]
if model_id not in models:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Model not found",
)
# Check if the user has a custom task model
# If the user has a custom task model, use that model
task_model_id = get_task_model_id(
model_id,
request.app.state.config.TASK_MODEL,
request.app.state.config.TASK_MODEL_EXTERNAL,
models,
)
log.debug(
f"generating image prompt using model {task_model_id} for user {user.email} "
)
if request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE != "":
template = request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE
else:
template = DEFAULT_IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE
content = image_prompt_generation_template(
template,
form_data["messages"],
user={
"name": user.name,
},
)
payload = {
"model": task_model_id,
"messages": [{"role": "user", "content": content}],
"stream": False,
"metadata": {
"task": str(TASKS.IMAGE_PROMPT_GENERATION),
"task_body": form_data,
"chat_id": form_data.get("chat_id", None),
},
}
try:
return await generate_chat_completion(request, form_data=payload, user=user)
except Exception as e:
log.error("Exception occurred", exc_info=True)
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"detail": "An internal error has occurred."},
)
@router.post("/queries/completions") @router.post("/queries/completions")
async def generate_queries( async def generate_queries(
request: Request, form_data: dict, user=Depends(get_verified_user) request: Request, form_data: dict, user=Depends(get_verified_user)

View File

@ -70,7 +70,7 @@ async def create_new_tools(
user=Depends(get_verified_user), user=Depends(get_verified_user),
): ):
if user.role != "admin" and not has_permission( if user.role != "admin" and not has_permission(
user.id, "workspace.knowledge", request.app.state.config.USER_PERMISSIONS user.id, "workspace.tools", request.app.state.config.USER_PERMISSIONS
): ):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@ -165,7 +165,12 @@ async def update_tools_by_id(
detail=ERROR_MESSAGES.NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND,
) )
if tools.user_id != user.id and user.role != "admin": # Is the user the original creator, in a group with write access, or an admin
if (
tools.user_id != user.id
and not has_access(user.id, "write", tools.access_control)
and user.role != "admin"
):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED, detail=ERROR_MESSAGES.UNAUTHORIZED,
@ -304,6 +309,17 @@ async def update_tools_valves_by_id(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND,
) )
if (
tools.user_id != user.id
and not has_access(user.id, "write", tools.access_control)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
if id in request.app.state.TOOLS: if id in request.app.state.TOOLS:
tools_module = request.app.state.TOOLS[id] tools_module = request.app.state.TOOLS[id]
else: else:

View File

@ -62,27 +62,44 @@ async def get_user_permissisions(user=Depends(get_verified_user)):
# User Default Permissions # User Default Permissions
############################ ############################
class WorkspacePermissions(BaseModel): class WorkspacePermissions(BaseModel):
models: bool models: bool = False
knowledge: bool knowledge: bool = False
prompts: bool prompts: bool = False
tools: bool tools: bool = False
class ChatPermissions(BaseModel): class ChatPermissions(BaseModel):
file_upload: bool controls: bool = True
delete: bool file_upload: bool = True
edit: bool delete: bool = True
temporary: bool edit: bool = True
temporary: bool = True
class FeaturesPermissions(BaseModel):
web_search: bool = True
image_generation: bool = True
class UserPermissions(BaseModel): class UserPermissions(BaseModel):
workspace: WorkspacePermissions workspace: WorkspacePermissions
chat: ChatPermissions chat: ChatPermissions
features: FeaturesPermissions
@router.get("/default/permissions") @router.get("/default/permissions", response_model=UserPermissions)
async def get_user_permissions(request: Request, user=Depends(get_admin_user)): async def get_user_permissions(request: Request, user=Depends(get_admin_user)):
return request.app.state.config.USER_PERMISSIONS return {
"workspace": WorkspacePermissions(
**request.app.state.config.USER_PERMISSIONS.get("workspace", {})
),
"chat": ChatPermissions(
**request.app.state.config.USER_PERMISSIONS.get("chat", {})
),
"features": FeaturesPermissions(
**request.app.state.config.USER_PERMISSIONS.get("features", {})
),
}
@router.post("/default/permissions") @router.post("/default/permissions")

View File

@ -26,7 +26,7 @@ class RedisLock:
def release_lock(self): def release_lock(self):
lock_value = self.redis.get(self.lock_name) lock_value = self.redis.get(self.lock_name)
if lock_value and lock_value.decode("utf-8") == self.lock_id: if lock_value and lock_value == self.lock_id:
self.redis.delete(self.lock_name) self.redis.delete(self.lock_name)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@ -1,121 +1,73 @@
import os import os
import shutil
import json
from abc import ABC, abstractmethod
from typing import BinaryIO, Tuple
import boto3 import boto3
from botocore.exceptions import ClientError from botocore.exceptions import ClientError
import shutil
from typing import BinaryIO, Tuple, Optional, Union
from open_webui.constants import ERROR_MESSAGES
from open_webui.config import ( from open_webui.config import (
STORAGE_PROVIDER,
S3_ACCESS_KEY_ID, S3_ACCESS_KEY_ID,
S3_SECRET_ACCESS_KEY,
S3_BUCKET_NAME, S3_BUCKET_NAME,
S3_REGION_NAME,
S3_ENDPOINT_URL, S3_ENDPOINT_URL,
S3_REGION_NAME,
S3_SECRET_ACCESS_KEY,
GCS_BUCKET_NAME,
GOOGLE_APPLICATION_CREDENTIALS_JSON,
STORAGE_PROVIDER,
UPLOAD_DIR, UPLOAD_DIR,
) )
from google.cloud import storage
from google.cloud.exceptions import GoogleCloudError, NotFound
from open_webui.constants import ERROR_MESSAGES
import boto3 class StorageProvider(ABC):
from botocore.exceptions import ClientError @abstractmethod
from typing import BinaryIO, Tuple, Optional def get_file(self, file_path: str) -> str:
pass
@abstractmethod
def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]:
pass
@abstractmethod
def delete_all_files(self) -> None:
pass
@abstractmethod
def delete_file(self, file_path: str) -> None:
pass
class StorageProvider: class LocalStorageProvider(StorageProvider):
def __init__(self, provider: Optional[str] = None): @staticmethod
self.storage_provider: str = provider or STORAGE_PROVIDER def upload_file(file: BinaryIO, filename: str) -> Tuple[bytes, str]:
contents = file.read()
self.s3_client = None if not contents:
self.s3_bucket_name: Optional[str] = None raise ValueError(ERROR_MESSAGES.EMPTY_CONTENT)
if self.storage_provider == "s3":
self._initialize_s3()
def _initialize_s3(self) -> None:
"""Initializes the S3 client and bucket name if using S3 storage."""
self.s3_client = boto3.client(
"s3",
region_name=S3_REGION_NAME,
endpoint_url=S3_ENDPOINT_URL,
aws_access_key_id=S3_ACCESS_KEY_ID,
aws_secret_access_key=S3_SECRET_ACCESS_KEY,
)
self.bucket_name = S3_BUCKET_NAME
def _upload_to_s3(self, file_path: str, filename: str) -> Tuple[bytes, str]:
"""Handles uploading of the file to S3 storage."""
if not self.s3_client:
raise RuntimeError("S3 Client is not initialized.")
try:
self.s3_client.upload_file(file_path, self.bucket_name, filename)
return (
open(file_path, "rb").read(),
"s3://" + self.bucket_name + "/" + filename,
)
except ClientError as e:
raise RuntimeError(f"Error uploading file to S3: {e}")
def _upload_to_local(self, contents: bytes, filename: str) -> Tuple[bytes, str]:
"""Handles uploading of the file to local storage."""
file_path = f"{UPLOAD_DIR}/{filename}" file_path = f"{UPLOAD_DIR}/{filename}"
with open(file_path, "wb") as f: with open(file_path, "wb") as f:
f.write(contents) f.write(contents)
return contents, file_path return contents, file_path
def _get_file_from_s3(self, file_path: str) -> str: @staticmethod
"""Handles downloading of the file from S3 storage.""" def get_file(file_path: str) -> str:
if not self.s3_client:
raise RuntimeError("S3 Client is not initialized.")
try:
bucket_name, key = file_path.split("//")[1].split("/")
local_file_path = f"{UPLOAD_DIR}/{key}"
self.s3_client.download_file(bucket_name, key, local_file_path)
return local_file_path
except ClientError as e:
raise RuntimeError(f"Error downloading file from S3: {e}")
def _get_file_from_local(self, file_path: str) -> str:
"""Handles downloading of the file from local storage.""" """Handles downloading of the file from local storage."""
return file_path return file_path
def _delete_from_s3(self, filename: str) -> None: @staticmethod
"""Handles deletion of the file from S3 storage.""" def delete_file(file_path: str) -> None:
if not self.s3_client:
raise RuntimeError("S3 Client is not initialized.")
try:
self.s3_client.delete_object(Bucket=self.bucket_name, Key=filename)
except ClientError as e:
raise RuntimeError(f"Error deleting file from S3: {e}")
def _delete_from_local(self, filename: str) -> None:
"""Handles deletion of the file from local storage.""" """Handles deletion of the file from local storage."""
filename = file_path.split("/")[-1]
file_path = f"{UPLOAD_DIR}/{filename}" file_path = f"{UPLOAD_DIR}/{filename}"
if os.path.isfile(file_path): if os.path.isfile(file_path):
os.remove(file_path) os.remove(file_path)
else: else:
print(f"File {file_path} not found in local storage.") print(f"File {file_path} not found in local storage.")
def _delete_all_from_s3(self) -> None: @staticmethod
"""Handles deletion of all files from S3 storage.""" def delete_all_files() -> None:
if not self.s3_client:
raise RuntimeError("S3 Client is not initialized.")
try:
response = self.s3_client.list_objects_v2(Bucket=self.bucket_name)
if "Contents" in response:
for content in response["Contents"]:
self.s3_client.delete_object(
Bucket=self.bucket_name, Key=content["Key"]
)
except ClientError as e:
raise RuntimeError(f"Error deleting all files from S3: {e}")
def _delete_all_from_local(self) -> None:
"""Handles deletion of all files from local storage.""" """Handles deletion of all files from local storage."""
if os.path.exists(UPLOAD_DIR): if os.path.exists(UPLOAD_DIR):
for filename in os.listdir(UPLOAD_DIR): for filename in os.listdir(UPLOAD_DIR):
@ -130,40 +82,141 @@ class StorageProvider:
else: else:
print(f"Directory {UPLOAD_DIR} not found in local storage.") print(f"Directory {UPLOAD_DIR} not found in local storage.")
def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]:
"""Uploads a file either to S3 or the local file system."""
contents = file.read()
if not contents:
raise ValueError(ERROR_MESSAGES.EMPTY_CONTENT)
contents, file_path = self._upload_to_local(contents, filename)
if self.storage_provider == "s3": class S3StorageProvider(StorageProvider):
return self._upload_to_s3(file_path, filename) def __init__(self):
return contents, file_path self.s3_client = boto3.client(
"s3",
region_name=S3_REGION_NAME,
endpoint_url=S3_ENDPOINT_URL,
aws_access_key_id=S3_ACCESS_KEY_ID,
aws_secret_access_key=S3_SECRET_ACCESS_KEY,
)
self.bucket_name = S3_BUCKET_NAME
def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]:
"""Handles uploading of the file to S3 storage."""
_, file_path = LocalStorageProvider.upload_file(file, filename)
try:
self.s3_client.upload_file(file_path, self.bucket_name, filename)
return (
open(file_path, "rb").read(),
"s3://" + self.bucket_name + "/" + filename,
)
except ClientError as e:
raise RuntimeError(f"Error uploading file to S3: {e}")
def get_file(self, file_path: str) -> str: def get_file(self, file_path: str) -> str:
"""Downloads a file either from S3 or the local file system and returns the file path.""" """Handles downloading of the file from S3 storage."""
if self.storage_provider == "s3": try:
return self._get_file_from_s3(file_path) bucket_name, key = file_path.split("//")[1].split("/")
return self._get_file_from_local(file_path) local_file_path = f"{UPLOAD_DIR}/{key}"
self.s3_client.download_file(bucket_name, key, local_file_path)
return local_file_path
except ClientError as e:
raise RuntimeError(f"Error downloading file from S3: {e}")
def delete_file(self, file_path: str) -> None: def delete_file(self, file_path: str) -> None:
"""Deletes a file either from S3 or the local file system.""" """Handles deletion of the file from S3 storage."""
filename = file_path.split("/")[-1] filename = file_path.split("/")[-1]
try:
if self.storage_provider == "s3": self.s3_client.delete_object(Bucket=self.bucket_name, Key=filename)
self._delete_from_s3(filename) except ClientError as e:
raise RuntimeError(f"Error deleting file from S3: {e}")
# Always delete from local storage # Always delete from local storage
self._delete_from_local(filename) LocalStorageProvider.delete_file(file_path)
def delete_all_files(self) -> None: def delete_all_files(self) -> None:
"""Deletes all files from the storage.""" """Handles deletion of all files from S3 storage."""
if self.storage_provider == "s3": try:
self._delete_all_from_s3() response = self.s3_client.list_objects_v2(Bucket=self.bucket_name)
if "Contents" in response:
for content in response["Contents"]:
self.s3_client.delete_object(
Bucket=self.bucket_name, Key=content["Key"]
)
except ClientError as e:
raise RuntimeError(f"Error deleting all files from S3: {e}")
# Always delete from local storage # Always delete from local storage
self._delete_all_from_local() LocalStorageProvider.delete_all_files()
Storage = StorageProvider(provider=STORAGE_PROVIDER) class GCSStorageProvider(StorageProvider):
def __init__(self):
self.bucket_name = GCS_BUCKET_NAME
if GOOGLE_APPLICATION_CREDENTIALS_JSON:
self.gcs_client = storage.Client.from_service_account_info(
info=json.loads(GOOGLE_APPLICATION_CREDENTIALS_JSON)
)
else:
# if no credentials json is provided, credentials will be picked up from the environment
# if running on local environment, credentials would be user credentials
# if running on a Compute Engine instance, credentials would be from Google Metadata server
self.gcs_client = storage.Client()
self.bucket = self.gcs_client.bucket(GCS_BUCKET_NAME)
def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]:
"""Handles uploading of the file to GCS storage."""
contents, file_path = LocalStorageProvider.upload_file(file, filename)
try:
blob = self.bucket.blob(filename)
blob.upload_from_filename(file_path)
return contents, "gs://" + self.bucket_name + "/" + filename
except GoogleCloudError as e:
raise RuntimeError(f"Error uploading file to GCS: {e}")
def get_file(self, file_path: str) -> str:
"""Handles downloading of the file from GCS storage."""
try:
filename = file_path.removeprefix("gs://").split("/")[1]
local_file_path = f"{UPLOAD_DIR}/{filename}"
blob = self.bucket.get_blob(filename)
blob.download_to_filename(local_file_path)
return local_file_path
except NotFound as e:
raise RuntimeError(f"Error downloading file from GCS: {e}")
def delete_file(self, file_path: str) -> None:
"""Handles deletion of the file from GCS storage."""
try:
filename = file_path.removeprefix("gs://").split("/")[1]
blob = self.bucket.get_blob(filename)
blob.delete()
except NotFound as e:
raise RuntimeError(f"Error deleting file from GCS: {e}")
# Always delete from local storage
LocalStorageProvider.delete_file(file_path)
def delete_all_files(self) -> None:
"""Handles deletion of all files from GCS storage."""
try:
blobs = self.bucket.list_blobs()
for blob in blobs:
blob.delete()
except NotFound as e:
raise RuntimeError(f"Error deleting all files from GCS: {e}")
# Always delete from local storage
LocalStorageProvider.delete_all_files()
def get_storage_provider(storage_provider: str):
if storage_provider == "local":
Storage = LocalStorageProvider()
elif storage_provider == "s3":
Storage = S3StorageProvider()
elif storage_provider == "gcs":
Storage = GCSStorageProvider()
else:
raise RuntimeError(f"Unsupported storage provider: {storage_provider}")
return Storage
Storage = get_storage_provider(STORAGE_PROVIDER)

View File

@ -0,0 +1,274 @@
import io
import os
import boto3
import pytest
from botocore.exceptions import ClientError
from moto import mock_aws
from open_webui.storage import provider
from gcp_storage_emulator.server import create_server
from google.cloud import storage
def mock_upload_dir(monkeypatch, tmp_path):
"""Fixture to monkey-patch the UPLOAD_DIR and create a temporary directory."""
directory = tmp_path / "uploads"
directory.mkdir()
monkeypatch.setattr(provider, "UPLOAD_DIR", str(directory))
return directory
def test_imports():
provider.StorageProvider
provider.LocalStorageProvider
provider.S3StorageProvider
provider.GCSStorageProvider
provider.Storage
def test_get_storage_provider():
Storage = provider.get_storage_provider("local")
assert isinstance(Storage, provider.LocalStorageProvider)
Storage = provider.get_storage_provider("s3")
assert isinstance(Storage, provider.S3StorageProvider)
Storage = provider.get_storage_provider("gcs")
assert isinstance(Storage, provider.GCSStorageProvider)
with pytest.raises(RuntimeError):
provider.get_storage_provider("invalid")
def test_class_instantiation():
with pytest.raises(TypeError):
provider.StorageProvider()
with pytest.raises(TypeError):
class Test(provider.StorageProvider):
pass
Test()
provider.LocalStorageProvider()
provider.S3StorageProvider()
provider.GCSStorageProvider()
class TestLocalStorageProvider:
Storage = provider.LocalStorageProvider()
file_content = b"test content"
file_bytesio = io.BytesIO(file_content)
filename = "test.txt"
filename_extra = "test_exyta.txt"
file_bytesio_empty = io.BytesIO()
def test_upload_file(self, monkeypatch, tmp_path):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
contents, file_path = self.Storage.upload_file(self.file_bytesio, self.filename)
assert (upload_dir / self.filename).exists()
assert (upload_dir / self.filename).read_bytes() == self.file_content
assert contents == self.file_content
assert file_path == str(upload_dir / self.filename)
with pytest.raises(ValueError):
self.Storage.upload_file(self.file_bytesio_empty, self.filename)
def test_get_file(self, monkeypatch, tmp_path):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
file_path = str(upload_dir / self.filename)
file_path_return = self.Storage.get_file(file_path)
assert file_path == file_path_return
def test_delete_file(self, monkeypatch, tmp_path):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
(upload_dir / self.filename).write_bytes(self.file_content)
assert (upload_dir / self.filename).exists()
file_path = str(upload_dir / self.filename)
self.Storage.delete_file(file_path)
assert not (upload_dir / self.filename).exists()
def test_delete_all_files(self, monkeypatch, tmp_path):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
(upload_dir / self.filename).write_bytes(self.file_content)
(upload_dir / self.filename_extra).write_bytes(self.file_content)
self.Storage.delete_all_files()
assert not (upload_dir / self.filename).exists()
assert not (upload_dir / self.filename_extra).exists()
@mock_aws
class TestS3StorageProvider:
def __init__(self):
self.Storage = provider.S3StorageProvider()
self.Storage.bucket_name = "my-bucket"
self.s3_client = boto3.resource("s3", region_name="us-east-1")
self.file_content = b"test content"
self.filename = "test.txt"
self.filename_extra = "test_exyta.txt"
self.file_bytesio_empty = io.BytesIO()
super().__init__()
def test_upload_file(self, monkeypatch, tmp_path):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
# S3 checks
with pytest.raises(Exception):
self.Storage.upload_file(io.BytesIO(self.file_content), self.filename)
self.s3_client.create_bucket(Bucket=self.Storage.bucket_name)
contents, s3_file_path = self.Storage.upload_file(
io.BytesIO(self.file_content), self.filename
)
object = self.s3_client.Object(self.Storage.bucket_name, self.filename)
assert self.file_content == object.get()["Body"].read()
# local checks
assert (upload_dir / self.filename).exists()
assert (upload_dir / self.filename).read_bytes() == self.file_content
assert contents == self.file_content
assert s3_file_path == "s3://" + self.Storage.bucket_name + "/" + self.filename
with pytest.raises(ValueError):
self.Storage.upload_file(self.file_bytesio_empty, self.filename)
def test_get_file(self, monkeypatch, tmp_path):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
self.s3_client.create_bucket(Bucket=self.Storage.bucket_name)
contents, s3_file_path = self.Storage.upload_file(
io.BytesIO(self.file_content), self.filename
)
file_path = self.Storage.get_file(s3_file_path)
assert file_path == str(upload_dir / self.filename)
assert (upload_dir / self.filename).exists()
def test_delete_file(self, monkeypatch, tmp_path):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
self.s3_client.create_bucket(Bucket=self.Storage.bucket_name)
contents, s3_file_path = self.Storage.upload_file(
io.BytesIO(self.file_content), self.filename
)
assert (upload_dir / self.filename).exists()
self.Storage.delete_file(s3_file_path)
assert not (upload_dir / self.filename).exists()
with pytest.raises(ClientError) as exc:
self.s3_client.Object(self.Storage.bucket_name, self.filename).load()
error = exc.value.response["Error"]
assert error["Code"] == "404"
assert error["Message"] == "Not Found"
def test_delete_all_files(self, monkeypatch, tmp_path):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
# create 2 files
self.s3_client.create_bucket(Bucket=self.Storage.bucket_name)
self.Storage.upload_file(io.BytesIO(self.file_content), self.filename)
object = self.s3_client.Object(self.Storage.bucket_name, self.filename)
assert self.file_content == object.get()["Body"].read()
assert (upload_dir / self.filename).exists()
assert (upload_dir / self.filename).read_bytes() == self.file_content
self.Storage.upload_file(io.BytesIO(self.file_content), self.filename_extra)
object = self.s3_client.Object(self.Storage.bucket_name, self.filename_extra)
assert self.file_content == object.get()["Body"].read()
assert (upload_dir / self.filename).exists()
assert (upload_dir / self.filename).read_bytes() == self.file_content
self.Storage.delete_all_files()
assert not (upload_dir / self.filename).exists()
with pytest.raises(ClientError) as exc:
self.s3_client.Object(self.Storage.bucket_name, self.filename).load()
error = exc.value.response["Error"]
assert error["Code"] == "404"
assert error["Message"] == "Not Found"
assert not (upload_dir / self.filename_extra).exists()
with pytest.raises(ClientError) as exc:
self.s3_client.Object(self.Storage.bucket_name, self.filename_extra).load()
error = exc.value.response["Error"]
assert error["Code"] == "404"
assert error["Message"] == "Not Found"
self.Storage.delete_all_files()
assert not (upload_dir / self.filename).exists()
assert not (upload_dir / self.filename_extra).exists()
class TestGCSStorageProvider:
Storage = provider.GCSStorageProvider()
Storage.bucket_name = "my-bucket"
file_content = b"test content"
filename = "test.txt"
filename_extra = "test_exyta.txt"
file_bytesio_empty = io.BytesIO()
@pytest.fixture(scope="class")
def setup(self):
host, port = "localhost", 9023
server = create_server(host, port, in_memory=True)
server.start()
os.environ["STORAGE_EMULATOR_HOST"] = f"http://{host}:{port}"
gcs_client = storage.Client()
bucket = gcs_client.bucket(self.Storage.bucket_name)
bucket.create()
self.Storage.gcs_client, self.Storage.bucket = gcs_client, bucket
yield
bucket.delete(force=True)
server.stop()
def test_upload_file(self, monkeypatch, tmp_path, setup):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
# catch error if bucket does not exist
with pytest.raises(Exception):
self.Storage.bucket = monkeypatch(self.Storage, "bucket", None)
self.Storage.upload_file(io.BytesIO(self.file_content), self.filename)
contents, gcs_file_path = self.Storage.upload_file(
io.BytesIO(self.file_content), self.filename
)
object = self.Storage.bucket.get_blob(self.filename)
assert self.file_content == object.download_as_bytes()
# local checks
assert (upload_dir / self.filename).exists()
assert (upload_dir / self.filename).read_bytes() == self.file_content
assert contents == self.file_content
assert gcs_file_path == "gs://" + self.Storage.bucket_name + "/" + self.filename
# test error if file is empty
with pytest.raises(ValueError):
self.Storage.upload_file(self.file_bytesio_empty, self.filename)
def test_get_file(self, monkeypatch, tmp_path, setup):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
contents, gcs_file_path = self.Storage.upload_file(
io.BytesIO(self.file_content), self.filename
)
file_path = self.Storage.get_file(gcs_file_path)
assert file_path == str(upload_dir / self.filename)
assert (upload_dir / self.filename).exists()
def test_delete_file(self, monkeypatch, tmp_path, setup):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
contents, gcs_file_path = self.Storage.upload_file(
io.BytesIO(self.file_content), self.filename
)
# ensure that local directory has the uploaded file as well
assert (upload_dir / self.filename).exists()
assert self.Storage.bucket.get_blob(self.filename).name == self.filename
self.Storage.delete_file(gcs_file_path)
# check that deleting file from gcs will delete the local file as well
assert not (upload_dir / self.filename).exists()
assert self.Storage.bucket.get_blob(self.filename) == None
def test_delete_all_files(self, monkeypatch, tmp_path, setup):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
# create 2 files
self.Storage.upload_file(io.BytesIO(self.file_content), self.filename)
object = self.Storage.bucket.get_blob(self.filename)
assert (upload_dir / self.filename).exists()
assert (upload_dir / self.filename).read_bytes() == self.file_content
assert self.Storage.bucket.get_blob(self.filename).name == self.filename
assert self.file_content == object.download_as_bytes()
self.Storage.upload_file(io.BytesIO(self.file_content), self.filename_extra)
object = self.Storage.bucket.get_blob(self.filename_extra)
assert (upload_dir / self.filename_extra).exists()
assert (upload_dir / self.filename_extra).read_bytes() == self.file_content
assert (
self.Storage.bucket.get_blob(self.filename_extra).name
== self.filename_extra
)
assert self.file_content == object.download_as_bytes()
self.Storage.delete_all_files()
assert not (upload_dir / self.filename).exists()
assert not (upload_dir / self.filename_extra).exists()
assert self.Storage.bucket.get_blob(self.filename) == None
assert self.Storage.bucket.get_blob(self.filename_extra) == None

View File

@ -1,9 +1,30 @@
from typing import Optional, Union, List, Dict, Any from typing import Optional, Union, List, Dict, Any
from open_webui.models.users import Users, UserModel from open_webui.models.users import Users, UserModel
from open_webui.models.groups import Groups from open_webui.models.groups import Groups
from open_webui.config import DEFAULT_USER_PERMISSIONS
import json import json
def fill_missing_permissions(
permissions: Dict[str, Any], default_permissions: Dict[str, Any]
) -> Dict[str, Any]:
"""
Recursively fills in missing properties in the permissions dictionary
using the default permissions as a template.
"""
for key, value in default_permissions.items():
if key not in permissions:
permissions[key] = value
elif isinstance(value, dict) and isinstance(
permissions[key], dict
): # Both are nested dictionaries
permissions[key] = fill_missing_permissions(permissions[key], value)
return permissions
def get_permissions( def get_permissions(
user_id: str, user_id: str,
default_permissions: Dict[str, Any], default_permissions: Dict[str, Any],
@ -27,39 +48,45 @@ def get_permissions(
if key not in permissions: if key not in permissions:
permissions[key] = value permissions[key] = value
else: else:
permissions[key] = permissions[key] or value permissions[key] = (
permissions[key] or value
) # Use the most permissive value (True > False)
return permissions return permissions
user_groups = Groups.get_groups_by_member_id(user_id) user_groups = Groups.get_groups_by_member_id(user_id)
# deep copy default permissions to avoid modifying the original dict # Deep copy default permissions to avoid modifying the original dict
permissions = json.loads(json.dumps(default_permissions)) permissions = json.loads(json.dumps(default_permissions))
# Combine permissions from all user groups
for group in user_groups: for group in user_groups:
group_permissions = group.permissions group_permissions = group.permissions
permissions = combine_permissions(permissions, group_permissions) permissions = combine_permissions(permissions, group_permissions)
# Ensure all fields from default_permissions are present and filled in
permissions = fill_missing_permissions(permissions, default_permissions)
return permissions return permissions
def has_permission( def has_permission(
user_id: str, user_id: str,
permission_key: str, permission_key: str,
default_permissions: Dict[str, bool] = {}, default_permissions: Dict[str, Any] = {},
) -> bool: ) -> bool:
""" """
Check if a user has a specific permission by checking the group permissions Check if a user has a specific permission by checking the group permissions
and falls back to default permissions if not found in any group. and fall back to default permissions if not found in any group.
Permission keys can be hierarchical and separated by dots ('.'). Permission keys can be hierarchical and separated by dots ('.').
""" """
def get_permission(permissions: Dict[str, bool], keys: List[str]) -> bool: def get_permission(permissions: Dict[str, Any], keys: List[str]) -> bool:
"""Traverse permissions dict using a list of keys (from dot-split permission_key).""" """Traverse permissions dict using a list of keys (from dot-split permission_key)."""
for key in keys: for key in keys:
if key not in permissions: if key not in permissions:
return False # If any part of the hierarchy is missing, deny access return False # If any part of the hierarchy is missing, deny access
permissions = permissions[key] # Go one level deeper permissions = permissions[key] # Traverse one level deeper
return bool(permissions) # Return the boolean at the final level return bool(permissions) # Return the boolean at the final level
@ -73,7 +100,10 @@ def has_permission(
if get_permission(group_permissions, permission_hierarchy): if get_permission(group_permissions, permission_hierarchy):
return True return True
# Check default permissions afterwards if the group permissions don't allow it # Check default permissions afterward if the group permissions don't allow it
default_permissions = fill_missing_permissions(
default_permissions, DEFAULT_USER_PERMISSIONS
)
return get_permission(default_permissions, permission_hierarchy) return get_permission(default_permissions, permission_hierarchy)

View File

@ -28,9 +28,13 @@ from open_webui.socket.main import (
from open_webui.routers.tasks import ( from open_webui.routers.tasks import (
generate_queries, generate_queries,
generate_title, generate_title,
generate_image_prompt,
generate_chat_tags, generate_chat_tags,
) )
from open_webui.routers.retrieval import process_web_search, SearchForm from open_webui.routers.retrieval import process_web_search, SearchForm
from open_webui.routers.images import image_generations, GenerateImageForm
from open_webui.utils.webhook import post_webhook from open_webui.utils.webhook import post_webhook
@ -486,6 +490,100 @@ async def chat_web_search_handler(
return form_data return form_data
async def chat_image_generation_handler(
request: Request, form_data: dict, extra_params: dict, user
):
__event_emitter__ = extra_params["__event_emitter__"]
await __event_emitter__(
{
"type": "status",
"data": {"description": "Generating an image", "done": False},
}
)
messages = form_data["messages"]
user_message = get_last_user_message(messages)
prompt = user_message
negative_prompt = ""
if request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION:
try:
res = await generate_image_prompt(
request,
{
"model": form_data["model"],
"messages": messages,
},
user,
)
response = res["choices"][0]["message"]["content"]
try:
bracket_start = response.find("{")
bracket_end = response.rfind("}") + 1
if bracket_start == -1 or bracket_end == -1:
raise Exception("No JSON object found in the response")
response = response[bracket_start:bracket_end]
response = json.loads(response)
prompt = response.get("prompt", [])
except Exception as e:
prompt = user_message
except Exception as e:
log.exception(e)
prompt = user_message
system_message_content = ""
try:
images = await image_generations(
request=request,
form_data=GenerateImageForm(**{"prompt": prompt}),
user=user,
)
await __event_emitter__(
{
"type": "status",
"data": {"description": "Generated an image", "done": True},
}
)
for image in images:
await __event_emitter__(
{
"type": "message",
"data": {"content": f"![Generated Image]({image['url']})\n"},
}
)
system_message_content = "<context>User is shown the generated image, tell the user that the image has been generated</context>"
except Exception as e:
log.exception(e)
await __event_emitter__(
{
"type": "status",
"data": {
"description": f"An error occured while generating an image",
"done": True,
},
}
)
system_message_content = "<context>Unable to generate an image, tell the user that an error occured</context>"
if system_message_content:
form_data["messages"] = add_or_update_system_message(
system_message_content, form_data["messages"]
)
return form_data
async def chat_completion_files_handler( async def chat_completion_files_handler(
request: Request, body: dict, user: UserModel request: Request, body: dict, user: UserModel
) -> tuple[dict, dict[str, list]]: ) -> tuple[dict, dict[str, list]]:
@ -523,17 +621,28 @@ async def chat_completion_files_handler(
if len(queries) == 0: if len(queries) == 0:
queries = [get_last_user_message(body["messages"])] queries = [get_last_user_message(body["messages"])]
sources = get_sources_from_files( try:
files=files, # Offload get_sources_from_files to a separate thread
queries=queries, loop = asyncio.get_running_loop()
embedding_function=request.app.state.EMBEDDING_FUNCTION, with ThreadPoolExecutor() as executor:
k=request.app.state.config.TOP_K, sources = await loop.run_in_executor(
reranking_function=request.app.state.rf, executor,
r=request.app.state.config.RELEVANCE_THRESHOLD, lambda: get_sources_from_files(
hybrid_search=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH, files=files,
) queries=queries,
embedding_function=request.app.state.EMBEDDING_FUNCTION,
k=request.app.state.config.TOP_K,
reranking_function=request.app.state.rf,
r=request.app.state.config.RELEVANCE_THRESHOLD,
hybrid_search=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH,
),
)
except Exception as e:
log.exception(e)
log.debug(f"rag_contexts:sources: {sources}") log.debug(f"rag_contexts:sources: {sources}")
return body, {"sources": sources} return body, {"sources": sources}
@ -562,6 +671,10 @@ def apply_params_to_form_data(form_data, model):
if "frequency_penalty" in params: if "frequency_penalty" in params:
form_data["frequency_penalty"] = params["frequency_penalty"] form_data["frequency_penalty"] = params["frequency_penalty"]
if "reasoning_effort" in params:
form_data["reasoning_effort"] = params["reasoning_effort"]
return form_data return form_data
@ -640,6 +753,11 @@ async def process_chat_payload(request, form_data, metadata, user, model):
request, form_data, extra_params, user request, form_data, extra_params, user
) )
if "image_generation" in features and features["image_generation"]:
form_data = await chat_image_generation_handler(
request, form_data, extra_params, user
)
try: try:
form_data, flags = await chat_completion_filter_functions_handler( form_data, flags = await chat_completion_filter_functions_handler(
request, form_data, model, extra_params request, form_data, model, extra_params
@ -958,6 +1076,16 @@ async def process_chat_response(
}, },
) )
# We might want to disable this by default
detect_reasoning = True
reasoning_tags = ["think", "reason", "reasoning", "thought", "Thought"]
current_tag = None
reasoning_start_time = None
reasoning_content = ""
ongoing_content = ""
async for line in response.body_iterator: async for line in response.body_iterator:
line = line.decode("utf-8") if isinstance(line, bytes) else line line = line.decode("utf-8") if isinstance(line, bytes) else line
data = line data = line
@ -966,12 +1094,12 @@ async def process_chat_response(
if not data.strip(): if not data.strip():
continue continue
# "data: " is the prefix for each event # "data:" is the prefix for each event
if not data.startswith("data: "): if not data.startswith("data:"):
continue continue
# Remove the prefix # Remove the prefix
data = data[len("data: ") :] data = data[len("data:") :].strip()
try: try:
data = json.loads(data) data = json.loads(data)
@ -984,7 +1112,6 @@ async def process_chat_response(
"selectedModelId": data["selected_model_id"], "selectedModelId": data["selected_model_id"],
}, },
) )
else: else:
value = ( value = (
data.get("choices", [])[0] data.get("choices", [])[0]
@ -995,6 +1122,73 @@ async def process_chat_response(
if value: if value:
content = f"{content}{value}" content = f"{content}{value}"
if detect_reasoning:
for tag in reasoning_tags:
start_tag = f"<{tag}>\n"
end_tag = f"</{tag}>\n"
if start_tag in content:
# Remove the start tag
content = content.replace(start_tag, "")
ongoing_content = content
reasoning_start_time = time.time()
reasoning_content = ""
current_tag = tag
break
if reasoning_start_time is not None:
# Remove the last value from the content
content = content[: -len(value)]
reasoning_content += value
end_tag = f"</{current_tag}>\n"
if end_tag in reasoning_content:
reasoning_end_time = time.time()
reasoning_duration = int(
reasoning_end_time
- reasoning_start_time
)
reasoning_content = (
reasoning_content.strip(
f"<{current_tag}>\n"
)
.strip(end_tag)
.strip()
)
if reasoning_content:
reasoning_display_content = "\n".join(
(
f"> {line}"
if not line.startswith(">")
else line
)
for line in reasoning_content.splitlines()
)
# Format reasoning with <details> tag
content = f'{ongoing_content}<details type="reasoning" done="true" duration="{reasoning_duration}">\n<summary>Thought for {reasoning_duration} seconds</summary>\n{reasoning_display_content}\n</details>\n'
else:
content = ""
reasoning_start_time = None
else:
reasoning_display_content = "\n".join(
(
f"> {line}"
if not line.startswith(">")
else line
)
for line in reasoning_content.splitlines()
)
# Show ongoing thought process
content = f'{ongoing_content}<details type="reasoning" done="false">\n<summary>Thinking…</summary>\n{reasoning_display_content}\n</details>\n'
if ENABLE_REALTIME_CHAT_SAVE: if ENABLE_REALTIME_CHAT_SAVE:
# Save message in the database # Save message in the database
Chats.upsert_message_to_chat_by_id_and_message_id( Chats.upsert_message_to_chat_by_id_and_message_id(
@ -1015,10 +1209,8 @@ async def process_chat_response(
"data": data, "data": data,
} }
) )
except Exception as e: except Exception as e:
done = "data: [DONE]" in line done = "data: [DONE]" in line
if done: if done:
pass pass
else: else:

View File

@ -63,17 +63,8 @@ auth_manager_config.JWT_EXPIRES_IN = JWT_EXPIRES_IN
class OAuthManager: class OAuthManager:
def __init__(self): def __init__(self):
self.oauth = OAuth() self.oauth = OAuth()
for provider_name, provider_config in OAUTH_PROVIDERS.items(): for _, provider_config in OAUTH_PROVIDERS.items():
self.oauth.register( provider_config["register"](self.oauth)
name=provider_name,
client_id=provider_config["client_id"],
client_secret=provider_config["client_secret"],
server_metadata_url=provider_config["server_metadata_url"],
client_kwargs={
"scope": provider_config["scope"],
},
redirect_uri=provider_config["redirect_uri"],
)
def get_client(self, provider_name): def get_client(self, provider_name):
return self.oauth.create_client(provider_name) return self.oauth.create_client(provider_name)
@ -200,14 +191,14 @@ class OAuthManager:
except Exception as e: except Exception as e:
log.warning(f"OAuth callback error: {e}") log.warning(f"OAuth callback error: {e}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
user_data: UserInfo = token["userinfo"] user_data: UserInfo = token.get("userinfo")
if not user_data: if not user_data:
user_data: UserInfo = await client.userinfo(token=token) user_data: UserInfo = await client.userinfo(token=token)
if not user_data: if not user_data:
log.warning(f"OAuth callback failed, user data is missing: {token}") log.warning(f"OAuth callback failed, user data is missing: {token}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
sub = user_data.get("sub") sub = user_data.get(OAUTH_PROVIDERS[provider].get("sub_claim", "sub"))
if not sub: if not sub:
log.warning(f"OAuth callback failed, sub is missing: {user_data}") log.warning(f"OAuth callback failed, sub is missing: {user_data}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
@ -255,12 +246,20 @@ class OAuthManager:
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN) raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
picture_url = user_data.get(picture_claim, "") picture_url = user_data.get(
picture_claim, OAUTH_PROVIDERS[provider].get("picture_url", "")
)
if picture_url: if picture_url:
# Download the profile image into a base64 string # Download the profile image into a base64 string
try: try:
access_token = token.get("access_token")
get_kwargs = {}
if access_token:
get_kwargs["headers"] = {
"Authorization": f"Bearer {access_token}",
}
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get(picture_url) as resp: async with session.get(picture_url, **get_kwargs) as resp:
picture = await resp.read() picture = await resp.read()
base64_encoded_picture = base64.b64encode( base64_encoded_picture = base64.b64encode(
picture picture

View File

@ -47,6 +47,7 @@ def apply_model_params_to_body_openai(params: dict, form_data: dict) -> dict:
"top_p": float, "top_p": float,
"max_tokens": int, "max_tokens": int,
"frequency_penalty": float, "frequency_penalty": float,
"reasoning_effort": str,
"seed": lambda x: x, "seed": lambda x: x,
"stop": lambda x: [bytes(s, "utf-8").decode("unicode_escape") for s in x], "stop": lambda x: [bytes(s, "utf-8").decode("unicode_escape") for s in x],
} }

View File

@ -217,6 +217,24 @@ def tags_generation_template(
return template return template
def image_prompt_generation_template(
template: str, messages: list[dict], user: Optional[dict] = None
) -> str:
prompt = get_last_user_message(messages)
template = replace_prompt_variable(template, prompt)
template = replace_messages_variable(template, messages)
template = prompt_template(
template,
**(
{"user_name": user.get("name"), "user_location": user.get("location")}
if user
else {}
),
)
return template
def emoji_generation_template( def emoji_generation_template(
template: str, prompt: str, user: Optional[dict] = None template: str, prompt: str, user: Optional[dict] = None
) -> str: ) -> str:

View File

@ -47,6 +47,8 @@ pymilvus==2.5.0
qdrant-client~=1.12.0 qdrant-client~=1.12.0
opensearch-py==2.7.1 opensearch-py==2.7.1
transformers
sentence-transformers==3.3.1 sentence-transformers==3.3.1
colbert-ai==0.2.21 colbert-ai==0.2.21
einops==0.8.0 einops==0.8.0
@ -87,7 +89,7 @@ pytube==15.0.0
extract_msg extract_msg
pydub pydub
duckduckgo-search~=6.3.5 duckduckgo-search~=7.2.1
## Google Drive ## Google Drive
google-api-python-client google-api-python-client
@ -100,6 +102,7 @@ pytest~=8.3.2
pytest-docker~=3.1.1 pytest-docker~=3.1.1
googleapis-common-protos==1.63.2 googleapis-common-protos==1.63.2
google-cloud-storage==2.19.0
## LDAP ## LDAP
ldap3==2.9.1 ldap3==2.9.1

22
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "open-webui", "name": "open-webui",
"version": "0.5.4", "version": "0.5.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "open-webui", "name": "open-webui",
"version": "0.5.4", "version": "0.5.7",
"dependencies": { "dependencies": {
"@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-javascript": "^6.2.2",
"@codemirror/lang-python": "^6.1.6", "@codemirror/lang-python": "^6.1.6",
@ -41,7 +41,7 @@
"i18next-resources-to-backend": "^1.2.0", "i18next-resources-to-backend": "^1.2.0",
"idb": "^7.1.1", "idb": "^7.1.1",
"js-sha256": "^0.10.1", "js-sha256": "^0.10.1",
"katex": "^0.16.9", "katex": "^0.16.21",
"marked": "^9.1.0", "marked": "^9.1.0",
"mermaid": "^10.9.3", "mermaid": "^10.9.3",
"paneforge": "^0.0.6", "paneforge": "^0.0.6",
@ -89,7 +89,7 @@
"tailwindcss": "^3.3.3", "tailwindcss": "^3.3.3",
"tslib": "^2.4.1", "tslib": "^2.4.1",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"vite": "^5.3.5", "vite": "^5.4.14",
"vitest": "^1.6.0" "vitest": "^1.6.0"
}, },
"engines": { "engines": {
@ -7110,13 +7110,14 @@
} }
}, },
"node_modules/katex": { "node_modules/katex": {
"version": "0.16.10", "version": "0.16.21",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.10.tgz", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.21.tgz",
"integrity": "sha512-ZiqaC04tp2O5utMsl2TEZTXxa6WSC4yo0fv5ML++D3QZv/vx2Mct0mTlRx3O+uUkjfuAgOkzsCmq5MiUEsDDdA==", "integrity": "sha512-XvqR7FgOHtWupfMiigNzmh+MgUVmDGU2kXZm899ZkPfcuoPuFxyHmXsgATDpFZDAXCI8tvinaVcDo8PIIJSo4A==",
"funding": [ "funding": [
"https://opencollective.com/katex", "https://opencollective.com/katex",
"https://github.com/sponsors/katex" "https://github.com/sponsors/katex"
], ],
"license": "MIT",
"dependencies": { "dependencies": {
"commander": "^8.3.0" "commander": "^8.3.0"
}, },
@ -11677,9 +11678,10 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.6", "version": "5.4.14",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.6.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.14.tgz",
"integrity": "sha512-IeL5f8OO5nylsgzd9tq4qD2QqI0k2CQLGrWD0rCN0EQJZpBK5vJAx0I+GDkMOXxQX/OfFHMuLIx6ddAxGX/k+Q==", "integrity": "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==",
"license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",

View File

@ -1,9 +1,10 @@
{ {
"name": "open-webui", "name": "open-webui",
"version": "0.5.4", "version": "0.5.7",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "npm run pyodide:fetch && vite dev --host", "dev": "npm run pyodide:fetch && vite dev --host",
"dev:5050": "npm run pyodide:fetch && vite dev --port 5050",
"build": "npm run pyodide:fetch && vite build", "build": "npm run pyodide:fetch && vite build",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
@ -44,7 +45,7 @@
"tailwindcss": "^3.3.3", "tailwindcss": "^3.3.3",
"tslib": "^2.4.1", "tslib": "^2.4.1",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"vite": "^5.3.5", "vite": "^5.4.14",
"vitest": "^1.6.0" "vitest": "^1.6.0"
}, },
"type": "module", "type": "module",
@ -82,7 +83,7 @@
"i18next-resources-to-backend": "^1.2.0", "i18next-resources-to-backend": "^1.2.0",
"idb": "^7.1.1", "idb": "^7.1.1",
"js-sha256": "^0.10.1", "js-sha256": "^0.10.1",
"katex": "^0.16.9", "katex": "^0.16.21",
"marked": "^9.1.0", "marked": "^9.1.0",
"mermaid": "^10.9.3", "mermaid": "^10.9.3",
"paneforge": "^0.0.6", "paneforge": "^0.0.6",

View File

@ -54,6 +54,7 @@ dependencies = [
"qdrant-client~=1.12.0", "qdrant-client~=1.12.0",
"opensearch-py==2.7.1", "opensearch-py==2.7.1",
"transformers",
"sentence-transformers==3.3.1", "sentence-transformers==3.3.1",
"colbert-ai==0.2.21", "colbert-ai==0.2.21",
"einops==0.8.0", "einops==0.8.0",
@ -93,15 +94,22 @@ dependencies = [
"extract_msg", "extract_msg",
"pydub", "pydub",
"duckduckgo-search~=6.3.5", "duckduckgo-search~=7.2.1",
"google-api-python-client",
"google-auth-httplib2",
"google-auth-oauthlib",
"docker~=7.1.0", "docker~=7.1.0",
"pytest~=8.3.2", "pytest~=8.3.2",
"pytest-docker~=3.1.1", "pytest-docker~=3.1.1",
"moto[s3]>=5.0.26",
"googleapis-common-protos==1.63.2", "googleapis-common-protos==1.63.2",
"google-cloud-storage==2.19.0",
"ldap3==2.9.1" "ldap3==2.9.1",
"gcp-storage-emulator>=2024.8.3",
] ]
readme = "README.md" readme = "README.md"
requires-python = ">= 3.11, < 3.13.0a1" requires-python = ">= 3.11, < 3.13.0a1"

View File

@ -53,11 +53,11 @@ math {
} }
.markdown-prose { .markdown-prose {
@apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line; @apply prose dark:prose-invert prose-blockquote:border-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-l-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
} }
.markdown-prose-xs { .markdown-prose-xs {
@apply text-xs prose dark:prose-invert prose-headings:font-semibold prose-hr:my-0 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line; @apply text-xs prose dark:prose-invert prose-blockquote:border-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-l-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-0 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
} }
.markdown a { .markdown a {
@ -68,6 +68,19 @@ math {
font-family: 'Archivo', sans-serif; font-family: 'Archivo', sans-serif;
} }
.drag-region {
-webkit-app-region: drag;
}
.drag-region a,
.drag-region button {
-webkit-app-region: no-drag;
}
.no-drag-region {
-webkit-app-region: no-drag;
}
iframe { iframe {
@apply rounded-lg; @apply rounded-lg;
} }
@ -102,18 +115,62 @@ li p {
select { select {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236B7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236B7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");
background-position: right 0.5rem center; background-position: right 0rem center;
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: 1.5em 1.5em; background-size: 1.5em 1.5em;
padding-right: 2.5rem;
-webkit-print-color-adjust: exact; -webkit-print-color-adjust: exact;
print-color-adjust: exact; print-color-adjust: exact;
/* padding-right: 2.5rem; */
/* for Firefox */ /* for Firefox */
-moz-appearance: none; -moz-appearance: none;
/* for Chrome */ /* for Chrome */
-webkit-appearance: none; -webkit-appearance: none;
} }
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.shimmer {
background: linear-gradient(90deg, #9a9b9e 25%, #2a2929 50%, #9a9b9e 75%);
background-size: 200% 100%;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: shimmer 4s linear infinite;
color: #818286; /* Fallback color */
}
:global(.dark) .shimmer {
background: linear-gradient(90deg, #818286 25%, #eae5e5 50%, #818286 75%);
background-size: 200% 100%;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: shimmer 4s linear infinite;
color: #a1a3a7; /* Darker fallback color for dark mode */
}
@keyframes smoothFadeIn {
0% {
opacity: 0;
transform: translateY(-10px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.status-description {
animation: smoothFadeIn 0.2s forwards;
}
.katex-mathml { .katex-mathml {
display: none; display: none;
} }

View File

@ -360,12 +360,7 @@ export const generateChatCompletion = async (token: string = '', body: object) =
return [res, controller]; return [res, controller];
}; };
export const createModel = async ( export const createModel = async (token: string, payload: object, urlIdx: string | null = null) => {
token: string,
tagName: string,
content: string,
urlIdx: string | null = null
) => {
let error = null; let error = null;
const res = await fetch( const res = await fetch(
@ -377,10 +372,7 @@ export const createModel = async (
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
}, },
body: JSON.stringify({ body: JSON.stringify(payload)
name: tagName,
modelfile: content
})
} }
).catch((err) => { ).catch((err) => {
error = err; error = err;

View File

@ -61,7 +61,7 @@
const shareHandler = async (func) => { const shareHandler = async (func) => {
const item = await getFunctionById(localStorage.token, func.id).catch((error) => { const item = await getFunctionById(localStorage.token, func.id).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });
@ -88,7 +88,7 @@
const cloneHandler = async (func) => { const cloneHandler = async (func) => {
const _function = await getFunctionById(localStorage.token, func.id).catch((error) => { const _function = await getFunctionById(localStorage.token, func.id).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });
@ -104,7 +104,7 @@
const exportHandler = async (func) => { const exportHandler = async (func) => {
const _function = await getFunctionById(localStorage.token, func.id).catch((error) => { const _function = await getFunctionById(localStorage.token, func.id).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });
@ -118,7 +118,7 @@
const deleteHandler = async (func) => { const deleteHandler = async (func) => {
const res = await deleteFunctionById(localStorage.token, func.id).catch((error) => { const res = await deleteFunctionById(localStorage.token, func.id).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });
@ -132,7 +132,7 @@
const toggleGlobalHandler = async (func) => { const toggleGlobalHandler = async (func) => {
const res = await toggleGlobalById(localStorage.token, func.id).catch((error) => { const res = await toggleGlobalById(localStorage.token, func.id).catch((error) => {
toast.error(error); toast.error(`${error}`);
}); });
if (res) { if (res) {
@ -418,7 +418,7 @@
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition" class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
on:click={async () => { on:click={async () => {
const _functions = await exportFunctions(localStorage.token).catch((error) => { const _functions = await exportFunctions(localStorage.token).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });
@ -510,7 +510,7 @@
for (const func of _functions) { for (const func of _functions) {
const res = await createNewFunction(localStorage.token, func).catch((error) => { const res = await createNewFunction(localStorage.token, func).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });
} }

View File

@ -43,9 +43,8 @@
const updateOpenAIHandler = async () => { const updateOpenAIHandler = async () => {
if (ENABLE_OPENAI_API !== null) { if (ENABLE_OPENAI_API !== null) {
OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS.filter( // Remove trailing slashes
(url, urlIdx) => OPENAI_API_BASE_URLS.indexOf(url) === urlIdx && url !== '' OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS.map((url) => url.replace(/\/$/, ''));
).map((url) => url.replace(/\/$/, ''));
// Check if API KEYS length is same than API URLS length // Check if API KEYS length is same than API URLS length
if (OPENAI_API_KEYS.length !== OPENAI_API_BASE_URLS.length) { if (OPENAI_API_KEYS.length !== OPENAI_API_BASE_URLS.length) {
@ -69,7 +68,7 @@
OPENAI_API_KEYS: OPENAI_API_KEYS, OPENAI_API_KEYS: OPENAI_API_KEYS,
OPENAI_API_CONFIGS: OPENAI_API_CONFIGS OPENAI_API_CONFIGS: OPENAI_API_CONFIGS
}).catch((error) => { }).catch((error) => {
toast.error(error); toast.error(`${error}`);
}); });
if (res) { if (res) {
@ -81,24 +80,15 @@
const updateOllamaHandler = async () => { const updateOllamaHandler = async () => {
if (ENABLE_OLLAMA_API !== null) { if (ENABLE_OLLAMA_API !== null) {
// Remove duplicate URLs // Remove trailing slashes
OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter( OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.map((url) => url.replace(/\/$/, ''));
(url, urlIdx) => OLLAMA_BASE_URLS.indexOf(url) === urlIdx && url !== ''
).map((url) => url.replace(/\/$/, ''));
console.log(OLLAMA_BASE_URLS);
if (OLLAMA_BASE_URLS.length === 0) {
ENABLE_OLLAMA_API = false;
toast.info($i18n.t('Ollama API disabled'));
}
const res = await updateOllamaConfig(localStorage.token, { const res = await updateOllamaConfig(localStorage.token, {
ENABLE_OLLAMA_API: ENABLE_OLLAMA_API, ENABLE_OLLAMA_API: ENABLE_OLLAMA_API,
OLLAMA_BASE_URLS: OLLAMA_BASE_URLS, OLLAMA_BASE_URLS: OLLAMA_BASE_URLS,
OLLAMA_API_CONFIGS: OLLAMA_API_CONFIGS OLLAMA_API_CONFIGS: OLLAMA_API_CONFIGS
}).catch((error) => { }).catch((error) => {
toast.error(error); toast.error(`${error}`);
}); });
if (res) { if (res) {
@ -111,14 +101,14 @@
const addOpenAIConnectionHandler = async (connection) => { const addOpenAIConnectionHandler = async (connection) => {
OPENAI_API_BASE_URLS = [...OPENAI_API_BASE_URLS, connection.url]; OPENAI_API_BASE_URLS = [...OPENAI_API_BASE_URLS, connection.url];
OPENAI_API_KEYS = [...OPENAI_API_KEYS, connection.key]; OPENAI_API_KEYS = [...OPENAI_API_KEYS, connection.key];
OPENAI_API_CONFIGS[connection.url] = connection.config; OPENAI_API_CONFIGS[OPENAI_API_BASE_URLS.length] = connection.config;
await updateOpenAIHandler(); await updateOpenAIHandler();
}; };
const addOllamaConnectionHandler = async (connection) => { const addOllamaConnectionHandler = async (connection) => {
OLLAMA_BASE_URLS = [...OLLAMA_BASE_URLS, connection.url]; OLLAMA_BASE_URLS = [...OLLAMA_BASE_URLS, connection.url];
OLLAMA_API_CONFIGS[connection.url] = connection.config; OLLAMA_API_CONFIGS[OLLAMA_BASE_URLS.length] = connection.config;
await updateOllamaHandler(); await updateOllamaHandler();
}; };
@ -148,15 +138,17 @@
OLLAMA_API_CONFIGS = ollamaConfig.OLLAMA_API_CONFIGS; OLLAMA_API_CONFIGS = ollamaConfig.OLLAMA_API_CONFIGS;
if (ENABLE_OPENAI_API) { if (ENABLE_OPENAI_API) {
for (const url of OPENAI_API_BASE_URLS) { // get url and idx
if (!OPENAI_API_CONFIGS[url]) { for (const [idx, url] of OPENAI_API_BASE_URLS.entries()) {
OPENAI_API_CONFIGS[url] = {}; if (!OPENAI_API_CONFIGS[idx]) {
// Legacy support, url as key
OPENAI_API_CONFIGS[idx] = OPENAI_API_CONFIGS[url] || {};
} }
} }
OPENAI_API_BASE_URLS.forEach(async (url, idx) => { OPENAI_API_BASE_URLS.forEach(async (url, idx) => {
OPENAI_API_CONFIGS[url] = OPENAI_API_CONFIGS[url] || {}; OPENAI_API_CONFIGS[idx] = OPENAI_API_CONFIGS[idx] || {};
if (!(OPENAI_API_CONFIGS[url]?.enable ?? true)) { if (!(OPENAI_API_CONFIGS[idx]?.enable ?? true)) {
return; return;
} }
const res = await getOpenAIModels(localStorage.token, idx); const res = await getOpenAIModels(localStorage.token, idx);
@ -167,9 +159,9 @@
} }
if (ENABLE_OLLAMA_API) { if (ENABLE_OLLAMA_API) {
for (const url of OLLAMA_BASE_URLS) { for (const [idx, url] of OLLAMA_BASE_URLS.entries()) {
if (!OLLAMA_API_CONFIGS[url]) { if (!OLLAMA_API_CONFIGS[idx]) {
OLLAMA_API_CONFIGS[url] = {}; OLLAMA_API_CONFIGS[idx] = OLLAMA_API_CONFIGS[url] || {};
} }
} }
} }
@ -242,7 +234,7 @@
pipeline={pipelineUrls[url] ? true : false} pipeline={pipelineUrls[url] ? true : false}
bind:url bind:url
bind:key={OPENAI_API_KEYS[idx]} bind:key={OPENAI_API_KEYS[idx]}
bind:config={OPENAI_API_CONFIGS[url]} bind:config={OPENAI_API_CONFIGS[idx]}
onSubmit={() => { onSubmit={() => {
updateOpenAIHandler(); updateOpenAIHandler();
}} }}
@ -251,6 +243,8 @@
(url, urlIdx) => idx !== urlIdx (url, urlIdx) => idx !== urlIdx
); );
OPENAI_API_KEYS = OPENAI_API_KEYS.filter((key, keyIdx) => idx !== keyIdx); OPENAI_API_KEYS = OPENAI_API_KEYS.filter((key, keyIdx) => idx !== keyIdx);
delete OPENAI_API_CONFIGS[idx];
}} }}
/> />
{/each} {/each}
@ -301,13 +295,14 @@
{#each OLLAMA_BASE_URLS as url, idx} {#each OLLAMA_BASE_URLS as url, idx}
<OllamaConnection <OllamaConnection
bind:url bind:url
bind:config={OLLAMA_API_CONFIGS[url]} bind:config={OLLAMA_API_CONFIGS[idx]}
{idx} {idx}
onSubmit={() => { onSubmit={() => {
updateOllamaHandler(); updateOllamaHandler();
}} }}
onDelete={() => { onDelete={() => {
OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter((url, urlIdx) => idx !== urlIdx); OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter((url, urlIdx) => idx !== urlIdx);
delete OLLAMA_API_CONFIGS[idx];
}} }}
/> />
{/each} {/each}

View File

@ -37,7 +37,7 @@
const verifyOllamaHandler = async () => { const verifyOllamaHandler = async () => {
const res = await verifyOllamaConnection(localStorage.token, url, key).catch((error) => { const res = await verifyOllamaConnection(localStorage.token, url, key).catch((error) => {
toast.error(error); toast.error(`${error}`);
}); });
if (res) { if (res) {
@ -47,7 +47,7 @@
const verifyOpenAIHandler = async () => { const verifyOpenAIHandler = async () => {
const res = await verifyOpenAIConnection(localStorage.token, url, key).catch((error) => { const res = await verifyOpenAIConnection(localStorage.token, url, key).catch((error) => {
toast.error(error); toast.error(`${error}`);
}); });
if (res) { if (res) {

View File

@ -9,6 +9,7 @@
import Cog6 from '$lib/components/icons/Cog6.svelte'; import Cog6 from '$lib/components/icons/Cog6.svelte';
import Wrench from '$lib/components/icons/Wrench.svelte'; import Wrench from '$lib/components/icons/Wrench.svelte';
import ManageOllamaModal from './ManageOllamaModal.svelte'; import ManageOllamaModal from './ManageOllamaModal.svelte';
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
export let onDelete = () => {}; export let onDelete = () => {};
export let onSubmit = () => {}; export let onSubmit = () => {};
@ -70,7 +71,7 @@
}} }}
type="button" type="button"
> >
<Wrench /> <ArrowDownTray />
</button> </button>
</Tooltip> </Tooltip>

View File

@ -47,7 +47,7 @@
reader.onload = async (e) => { reader.onload = async (e) => {
const res = await importConfig(localStorage.token, JSON.parse(e.target.result)).catch( const res = await importConfig(localStorage.token, JSON.parse(e.target.result)).catch(
(error) => { (error) => {
toast.error(error); toast.error(`${error}`);
} }
); );
@ -132,7 +132,7 @@
// exportAllUserChats(); // exportAllUserChats();
downloadDatabase(localStorage.token).catch((error) => { downloadDatabase(localStorage.token).catch((error) => {
toast.error(error); toast.error(`${error}`);
}); });
}} }}
> >

View File

@ -119,7 +119,7 @@
url: OpenAIUrl url: OpenAIUrl
} }
}).catch(async (error) => { }).catch(async (error) => {
toast.error(error); toast.error(`${error}`);
await setEmbeddingConfig(); await setEmbeddingConfig();
return null; return null;
}); });
@ -142,7 +142,7 @@
const res = await updateRerankingConfig(localStorage.token, { const res = await updateRerankingConfig(localStorage.token, {
reranking_model: rerankingModel reranking_model: rerankingModel
}).catch(async (error) => { }).catch(async (error) => {
toast.error(error); toast.error(`${error}`);
await setRerankingConfig(); await setRerankingConfig();
return null; return null;
}); });
@ -258,7 +258,7 @@
bind:show={showResetUploadDirConfirm} bind:show={showResetUploadDirConfirm}
on:confirm={async () => { on:confirm={async () => {
const res = await deleteAllFiles(localStorage.token).catch((error) => { const res = await deleteAllFiles(localStorage.token).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });
@ -272,7 +272,7 @@
bind:show={showResetConfirm} bind:show={showResetConfirm}
on:confirm={() => { on:confirm={() => {
const res = resetVectorDB(localStorage.token).catch((error) => { const res = resetVectorDB(localStorage.token).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });

View File

@ -28,6 +28,7 @@
label: '', label: '',
host: '', host: '',
port: '', port: '',
attribute_for_mail: 'mail',
attribute_for_username: 'uid', attribute_for_username: 'uid',
app_dn: '', app_dn: '',
app_dn_password: '', app_dn_password: '',
@ -41,7 +42,7 @@
const updateLdapServerHandler = async () => { const updateLdapServerHandler = async () => {
if (!ENABLE_LDAP) return; if (!ENABLE_LDAP) return;
const res = await updateLdapServer(localStorage.token, LDAP_SERVER).catch((error) => { const res = await updateLdapServer(localStorage.token, LDAP_SERVER).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });
if (res) { if (res) {
@ -342,6 +343,26 @@
/> />
</div> </div>
</div> </div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Attribute for Mail')}
</div>
<Tooltip
content={$i18n.t(
'The LDAP attribute that maps to the mail that users use to sign in.'
)}
placement="top-start"
>
<input
class="w-full bg-transparent outline-none py-0.5"
required
placeholder={$i18n.t('Example: mail')}
bind:value={LDAP_SERVER.attribute_for_mail}
/>
</Tooltip>
</div>
</div>
<div class="flex w-full gap-2"> <div class="flex w-full gap-2">
<div class="w-full"> <div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1"> <div class=" self-center text-xs font-medium min-w-fit mb-1">

View File

@ -99,7 +99,7 @@
const getModels = async () => { const getModels = async () => {
models = await getImageGenerationModels(localStorage.token).catch((error) => { models = await getImageGenerationModels(localStorage.token).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });
}; };
@ -107,11 +107,11 @@
const updateConfigHandler = async () => { const updateConfigHandler = async () => {
const res = await updateConfig(localStorage.token, config) const res = await updateConfig(localStorage.token, config)
.catch((error) => { .catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}) })
.catch((error) => { .catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });
@ -159,13 +159,13 @@
} }
await updateConfig(localStorage.token, config).catch((error) => { await updateConfig(localStorage.token, config).catch((error) => {
toast.error(error); toast.error(`${error}`);
loading = false; loading = false;
return null; return null;
}); });
await updateImageGenerationConfig(localStorage.token, imageGenerationConfig).catch((error) => { await updateImageGenerationConfig(localStorage.token, imageGenerationConfig).catch((error) => {
toast.error(error); toast.error(`${error}`);
loading = false; loading = false;
return null; return null;
}); });
@ -178,7 +178,7 @@
onMount(async () => { onMount(async () => {
if ($user.role === 'admin') { if ($user.role === 'admin') {
const res = await getConfig(localStorage.token).catch((error) => { const res = await getConfig(localStorage.token).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });
@ -211,7 +211,7 @@
}); });
const imageConfigRes = await getImageGenerationConfig(localStorage.token).catch((error) => { const imageConfigRes = await getImageGenerationConfig(localStorage.token).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });
@ -234,7 +234,7 @@
<div class=" mb-1 text-sm font-medium">{$i18n.t('Image Settings')}</div> <div class=" mb-1 text-sm font-medium">{$i18n.t('Image Settings')}</div>
<div> <div>
<div class=" py-0.5 flex w-full justify-between"> <div class=" py-1 flex w-full justify-between">
<div class=" self-center text-xs font-medium"> <div class=" self-center text-xs font-medium">
{$i18n.t('Image Generation (Experimental)')} {$i18n.t('Image Generation (Experimental)')}
</div> </div>
@ -271,7 +271,16 @@
</div> </div>
</div> </div>
<div class=" py-0.5 flex w-full justify-between"> {#if config.enabled}
<div class=" py-1 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Image Prompt Generation')}</div>
<div class="px-1">
<Switch bind:state={config.prompt_generation} />
</div>
</div>
{/if}
<div class=" py-1 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Image Generation Engine')}</div> <div class=" self-center text-xs font-medium">{$i18n.t('Image Generation Engine')}</div>
<div class="flex items-center relative"> <div class="flex items-center relative">
<select <select
@ -309,7 +318,7 @@
on:click={async () => { on:click={async () => {
await updateConfigHandler(); await updateConfigHandler();
const res = await verifyConfigUrl(localStorage.token).catch((error) => { const res = await verifyConfigUrl(localStorage.token).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });
@ -445,7 +454,7 @@
on:click={async () => { on:click={async () => {
await updateConfigHandler(); await updateConfigHandler();
const res = await verifyConfigUrl(localStorage.token).catch((error) => { const res = await verifyConfigUrl(localStorage.token).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });

View File

@ -24,6 +24,7 @@
TASK_MODEL: '', TASK_MODEL: '',
TASK_MODEL_EXTERNAL: '', TASK_MODEL_EXTERNAL: '',
TITLE_GENERATION_PROMPT_TEMPLATE: '', TITLE_GENERATION_PROMPT_TEMPLATE: '',
IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE: '',
ENABLE_AUTOCOMPLETE_GENERATION: true, ENABLE_AUTOCOMPLETE_GENERATION: true,
AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH: -1, AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH: -1,
TAGS_GENERATION_PROMPT_TEMPLATE: '', TAGS_GENERATION_PROMPT_TEMPLATE: '',
@ -140,6 +141,22 @@
</Tooltip> </Tooltip>
</div> </div>
<div class="mt-3">
<div class=" mb-2.5 text-xs font-medium">{$i18n.t('Image Prompt Generation Prompt')}</div>
<Tooltip
content={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
placement="top-start"
>
<Textarea
bind:value={taskConfig.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE}
placeholder={$i18n.t(
'Leave empty to use the default prompt, or enter a custom prompt'
)}
/>
</Tooltip>
</div>
<hr class=" border-gray-50 dark:border-gray-850 my-3" /> <hr class=" border-gray-50 dark:border-gray-850 my-3" />
<div class="my-3 flex w-full items-center justify-between"> <div class="my-3 flex w-full items-center justify-between">

View File

@ -26,6 +26,9 @@
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Cog6 from '$lib/components/icons/Cog6.svelte'; import Cog6 from '$lib/components/icons/Cog6.svelte';
import ConfigureModelsModal from './Models/ConfigureModelsModal.svelte'; import ConfigureModelsModal from './Models/ConfigureModelsModal.svelte';
import Wrench from '$lib/components/icons/Wrench.svelte';
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
import ManageModelsModal from './Models/ManageModelsModal.svelte';
let importFiles; let importFiles;
let modelsImportInputElement: HTMLInputElement; let modelsImportInputElement: HTMLInputElement;
@ -39,6 +42,7 @@
let selectedModelId = null; let selectedModelId = null;
let showConfigModal = false; let showConfigModal = false;
let showManageModal = false;
$: if (models) { $: if (models) {
filteredModels = models filteredModels = models
@ -138,6 +142,7 @@
</script> </script>
<ConfigureModelsModal bind:show={showConfigModal} initHandler={init} /> <ConfigureModelsModal bind:show={showConfigModal} initHandler={init} />
<ManageModelsModal bind:show={showManageModal} />
{#if models !== null} {#if models !== null}
{#if selectedModelId === null} {#if selectedModelId === null}
@ -151,10 +156,22 @@
> >
</div> </div>
<div> <div class="flex items-center gap-1.5">
<Tooltip content={$i18n.t('Configure')}> <Tooltip content={$i18n.t('Manage Models')}>
<button <button
class=" px-2.5 py-1 rounded-full flex gap-1 items-center" class=" p-1 rounded-full flex gap-1 items-center"
type="button"
on:click={() => {
showManageModal = true;
}}
>
<ArrowDownTray />
</button>
</Tooltip>
<Tooltip content={$i18n.t('Settings')}>
<button
class=" p-1 rounded-full flex gap-1 items-center"
type="button" type="button"
on:click={() => { on:click={() => {
showConfigModal = true; showConfigModal = true;

View File

@ -33,6 +33,24 @@
init(); init();
} }
$: if (selectedModelId) {
onModelSelect();
}
const onModelSelect = () => {
if (selectedModelId === '') {
return;
}
if (defaultModelIds.includes(selectedModelId)) {
selectedModelId = '';
return;
}
defaultModelIds = [...defaultModelIds, selectedModelId];
selectedModelId = '';
};
const init = async () => { const init = async () => {
config = await getModelsConfig(localStorage.token); config = await getModelsConfig(localStorage.token);
@ -95,7 +113,7 @@
<div> <div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-2"> <div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
<div class=" text-lg font-medium self-center font-primary"> <div class=" text-lg font-medium self-center font-primary">
{$i18n.t('Configure Models')} {$i18n.t('Settings')}
</div> </div>
<button <button
class="self-center" class="self-center"
@ -143,6 +161,24 @@
<div class="text-xs text-gray-500">{$i18n.t('Default Models')}</div> <div class="text-xs text-gray-500">{$i18n.t('Default Models')}</div>
</div> </div>
<div class="flex items-center -mr-1">
<select
class="w-full py-1 text-sm rounded-lg bg-transparent {selectedModelId
? ''
: 'text-gray-500'} placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
bind:value={selectedModelId}
>
<option value="">{$i18n.t('Select a model')}</option>
{#each $models as model}
<option value={model.id} class="bg-gray-50 dark:bg-gray-700"
>{model.name}</option
>
{/each}
</select>
</div>
<!-- <hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" /> -->
{#if defaultModelIds.length > 0} {#if defaultModelIds.length > 0}
<div class="flex flex-col"> <div class="flex flex-col">
{#each defaultModelIds as modelId, modelIdx} {#each defaultModelIds as modelId, modelIdx}
@ -170,44 +206,6 @@
{$i18n.t('No models selected')} {$i18n.t('No models selected')}
</div> </div>
{/if} {/if}
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="flex items-center">
<select
class="w-full py-1 text-sm rounded-lg bg-transparent {selectedModelId
? ''
: 'text-gray-500'} placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
bind:value={selectedModelId}
>
<option value="">{$i18n.t('Select a model')}</option>
{#each $models as model}
<option value={model.id} class="bg-gray-50 dark:bg-gray-700"
>{model.name}</option
>
{/each}
</select>
<div>
<button
type="button"
on:click={() => {
if (selectedModelId === '') {
return;
}
if (defaultModelIds.includes(selectedModelId)) {
return;
}
defaultModelIds = [...defaultModelIds, selectedModelId];
selectedModelId = '';
}}
>
<Plus className="size-3.5" strokeWidth="2" />
</button>
</div>
</div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,26 @@
<script>
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import ManageOllama from './ManageOllama.svelte';
export let ollamaConfig = null;
let selectedUrlIdx = 0;
</script>
{#if ollamaConfig}
<div class="flex-1 mb-2.5 pr-1.5 rounded-lg bg-gray-50 dark:text-gray-300 dark:bg-gray-850">
<select
class="w-full py-2 px-4 text-sm outline-none bg-transparent"
bind:value={selectedUrlIdx}
placeholder={$i18n.t('Select an Ollama instance')}
>
{#each ollamaConfig.OLLAMA_BASE_URLS as url, idx}
<option value={idx}>{url}</option>
{/each}
</select>
</div>
<ManageOllama urlIdx={selectedUrlIdx} />
{/if}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,110 @@
<script>
import { toast } from 'svelte-sonner';
import { createEventDispatcher, getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
import { user } from '$lib/stores';
import Modal from '$lib/components/common/Modal.svelte';
import ManageOllama from './Manage/ManageOllama.svelte';
import { getOllamaConfig } from '$lib/apis/ollama';
import Spinner from '$lib/components/common/Spinner.svelte';
import ManageMultipleOllama from './Manage/ManageMultipleOllama.svelte';
export let show = false;
let selected = null;
let ollamaConfig = null;
onMount(async () => {
if ($user.role === 'admin') {
await Promise.all([
(async () => {
ollamaConfig = await getOllamaConfig(localStorage.token);
})()
]);
if (ollamaConfig) {
selected = 'ollama';
return;
}
selected = '';
}
});
</script>
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4">
<div class=" text-lg font-medium self-center font-primary">
{$i18n.t('Manage Models')}
</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-3 pb-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
{#if selected === ''}
<div class=" py-5 text-gray-400 text-xs">
<div>
{$i18n.t('No inference engine with management support found')}
</div>
</div>
{:else if selected !== null}
<div class=" flex w-full flex-col">
<div
class="flex gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent dark:text-gray-200"
>
<button
class="min-w-fit rounded-full p-1.5 {selected === 'ollama'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
on:click={() => {
selected = 'ollama';
}}>{$i18n.t('Ollama')}</button
>
<!-- <button
class="min-w-fit rounded-full p-1.5 {selected === 'llamacpp'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
on:click={() => {
selected = 'llamacpp';
}}>{$i18n.t('Llama.cpp')}</button
> -->
</div>
<div class=" px-1.5 py-1">
{#if selected === 'ollama'}
<ManageMultipleOllama {ollamaConfig} />
{/if}
</div>
</div>
{:else}
<div class=" py-5">
<Spinner />
</div>
{/if}
</div>
</div>
</div>
</Modal>

View File

@ -57,7 +57,7 @@
valves, valves,
selectedPipelinesUrlIdx selectedPipelinesUrlIdx
).catch((error) => { ).catch((error) => {
toast.error(error); toast.error(`${error}`);
}); });
if (res) { if (res) {
@ -118,7 +118,7 @@
pipelineDownloadUrl, pipelineDownloadUrl,
selectedPipelinesUrlIdx selectedPipelinesUrlIdx
).catch((error) => { ).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });
@ -172,7 +172,7 @@
pipelines[selectedPipelineIdx].id, pipelines[selectedPipelineIdx].id,
selectedPipelinesUrlIdx selectedPipelinesUrlIdx
).catch((error) => { ).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });

View File

@ -53,10 +53,15 @@
tools: false tools: false
}, },
chat: { chat: {
controls: true,
file_upload: true, file_upload: true,
delete: true, delete: true,
edit: true, edit: true,
temporary: true temporary: true
},
features: {
web_search: true,
image_generation: true
} }
}; };
@ -69,7 +74,7 @@
const addGroupHandler = async (group) => { const addGroupHandler = async (group) => {
const res = await createNewGroup(localStorage.token, group).catch((error) => { const res = await createNewGroup(localStorage.token, group).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });
@ -84,7 +89,7 @@
const res = await updateUserDefaultPermissions(localStorage.token, group.permissions).catch( const res = await updateUserDefaultPermissions(localStorage.token, group.permissions).catch(
(error) => { (error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
} }
); );

View File

@ -37,10 +37,15 @@
tools: false tools: false
}, },
chat: { chat: {
controls: true,
file_upload: true, file_upload: true,
delete: true, delete: true,
edit: true, edit: true,
temporary: true temporary: true
},
features: {
web_search: true,
image_generation: true
} }
}; };
export let userIds = []; export let userIds = [];
@ -65,20 +70,8 @@
if (group) { if (group) {
name = group.name; name = group.name;
description = group.description; description = group.description;
permissions = group?.permissions ?? { permissions = group?.permissions ?? {};
workspace: {
models: false,
knowledge: false,
prompts: false,
tools: false
},
chat: {
file_upload: true,
delete: true,
edit: true,
temporary: true
}
};
userIds = group?.user_ids ?? []; userIds = group?.user_ids ?? [];
} }
}; };

View File

@ -23,7 +23,7 @@
const updateHandler = async (_group) => { const updateHandler = async (_group) => {
const res = await updateGroupById(localStorage.token, group.id, _group).catch((error) => { const res = await updateGroupById(localStorage.token, group.id, _group).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });
@ -35,7 +35,7 @@
const deleteHandler = async () => { const deleteHandler = async () => {
const res = await deleteGroupById(localStorage.token, group.id).catch((error) => { const res = await deleteGroupById(localStorage.token, group.id).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });

View File

@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte'; import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
import Switch from '$lib/components/common/Switch.svelte'; import Switch from '$lib/components/common/Switch.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
export let permissions = { // Default values for permissions
const defaultPermissions = {
workspace: { workspace: {
models: false, models: false,
knowledge: false, knowledge: false,
@ -13,12 +14,38 @@
tools: false tools: false
}, },
chat: { chat: {
controls: true,
delete: true, delete: true,
edit: true, edit: true,
temporary: true, temporary: true,
file_upload: true file_upload: true
},
features: {
web_search: true,
image_generation: true
} }
}; };
export let permissions = {};
// Reactive statement to ensure all fields are present in `permissions`
$: {
permissions = fillMissingProperties(permissions, defaultPermissions);
}
function fillMissingProperties(obj: any, defaults: any) {
return {
...defaults,
...obj,
workspace: { ...defaults.workspace, ...obj.workspace },
chat: { ...defaults.chat, ...obj.chat },
features: { ...defaults.features, ...obj.features }
};
}
onMount(() => {
permissions = fillMissingProperties(permissions, defaultPermissions);
});
</script> </script>
<div> <div>
@ -169,6 +196,14 @@
<div> <div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Chat Permissions')}</div> <div class=" mb-2 text-sm font-medium">{$i18n.t('Chat Permissions')}</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Controls')}
</div>
<Switch bind:state={permissions.chat.controls} />
</div>
<div class=" flex w-full justify-between my-2 pr-2"> <div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium"> <div class=" self-center text-xs font-medium">
{$i18n.t('Allow File Upload')} {$i18n.t('Allow File Upload')}
@ -201,4 +236,26 @@
<Switch bind:state={permissions.chat.temporary} /> <Switch bind:state={permissions.chat.temporary} />
</div> </div>
</div> </div>
<hr class=" border-gray-50 dark:border-gray-850 my-2" />
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Features Permissions')}</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Web Search')}
</div>
<Switch bind:state={permissions.features.web_search} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Image Generation')}
</div>
<Switch bind:state={permissions.features.image_generation} />
</div>
</div>
</div> </div>

View File

@ -44,7 +44,7 @@
const updateRoleHandler = async (id, role) => { const updateRoleHandler = async (id, role) => {
const res = await updateUserRole(localStorage.token, id, role).catch((error) => { const res = await updateUserRole(localStorage.token, id, role).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });
@ -55,7 +55,7 @@
const deleteUserHandler = async (id) => { const deleteUserHandler = async (id) => {
const res = await deleteUserById(localStorage.token, id).catch((error) => { const res = await deleteUserById(localStorage.token, id).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });
if (res) { if (res) {

View File

@ -49,7 +49,7 @@
_user.password, _user.password,
_user.role _user.role
).catch((error) => { ).catch((error) => {
toast.error(error); toast.error(`${error}`);
}); });
if (res) { if (res) {
@ -150,9 +150,13 @@
submitHandler(); submitHandler();
}} }}
> >
<div class="flex text-center text-sm font-medium rounded-full bg-transparent/10 p-1 mb-2"> <div
class="flex -mt-2 mb-1.5 gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent dark:text-gray-200"
>
<button <button
class="w-full rounded-full p-1.5 {tab === '' ? 'bg-gray-50 dark:bg-gray-850' : ''}" class="min-w-fit rounded-full p-1.5 {tab === ''
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
type="button" type="button"
on:click={() => { on:click={() => {
tab = ''; tab = '';
@ -160,15 +164,16 @@
> >
<button <button
class="w-full rounded-full p-1 {tab === 'import' class="min-w-fit rounded-full p-1.5 {tab === 'import'
? 'bg-gray-50 dark:bg-gray-850' ? ''
: ''}" : 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
type="button" type="button"
on:click={() => { on:click={() => {
tab = 'import'; tab = 'import';
}}>{$i18n.t('CSV Import')}</button }}>{$i18n.t('CSV Import')}</button
> >
</div> </div>
<div class="px-1"> <div class="px-1">
{#if tab === ''} {#if tab === ''}
<div class="flex flex-col w-full mb-3"> <div class="flex flex-col w-full mb-3">
@ -176,7 +181,7 @@
<div class="flex-1"> <div class="flex-1">
<select <select
class="w-full capitalize rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 disabled:text-gray-500 dark:disabled:text-gray-500 outline-none" class="w-full capitalize rounded-lg text-sm bg-transparent dark:disabled:text-gray-500 outline-none"
bind:value={_user.role} bind:value={_user.role}
placeholder={$i18n.t('Enter Your Role')} placeholder={$i18n.t('Enter Your Role')}
required required

View File

@ -24,7 +24,7 @@
const submitHandler = async () => { const submitHandler = async () => {
const res = await updateUserById(localStorage.token, selectedUser.id, _user).catch((error) => { const res = await updateUserById(localStorage.token, selectedUser.id, _user).catch((error) => {
toast.error(error); toast.error(`${error}`);
}); });
if (res) { if (res) {

View File

@ -20,7 +20,7 @@
const deleteChatHandler = async (chatId) => { const deleteChatHandler = async (chatId) => {
const res = await deleteChatById(localStorage.token, chatId).catch((error) => { const res = await deleteChatById(localStorage.token, chatId).catch((error) => {
toast.error(error); toast.error(`${error}`);
}); });
chats = await getChatListByUserId(localStorage.token, user.id); chats = await getChatListByUserId(localStorage.token, user.id);

View File

@ -0,0 +1,65 @@
<script lang="ts">
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
let selected = '';
</script>
<div class="min-w-[4.5rem] bg-gray-50 dark:bg-gray-950 flex gap-2.5 flex-col pt-8">
<div class="flex justify-center relative">
{#if selected === 'home'}
<div class="absolute top-0 left-0 flex h-full">
<div class="my-auto rounded-r-lg w-1 h-8 bg-black dark:bg-white"></div>
</div>
{/if}
<Tooltip content="Home" placement="right">
<button
class=" cursor-pointer {selected === 'home' ? 'rounded-2xl' : 'rounded-full'}"
on:click={() => {
selected = 'home';
if (window.electronAPI) {
window.electronAPI.load('home');
}
}}
>
<img
src="/static/splash.png"
class="size-11 dark:invert p-0.5"
alt="logo"
draggable="false"
/>
</button>
</Tooltip>
</div>
<div class=" -mt-1 border-[1.5px] border-gray-100 dark:border-gray-900 mx-4"></div>
<div class="flex justify-center relative group">
{#if selected === ''}
<div class="absolute top-0 left-0 flex h-full">
<div class="my-auto rounded-r-lg w-1 h-8 bg-black dark:bg-white"></div>
</div>
{/if}
<button
class=" cursor-pointer bg-transparent"
on:click={() => {
selected = '';
}}
>
<img
src="/static/favicon.png"
class="size-10 {selected === '' ? 'rounded-2xl' : 'rounded-full'}"
alt="logo"
draggable="false"
/>
</button>
</div>
<!-- <div class="flex justify-center relative group text-gray-400">
<button class=" cursor-pointer p-2" on:click={() => {}}>
<Plus className="size-4" strokeWidth="2" />
</button>
</div> -->
</div>

View File

@ -142,7 +142,7 @@
const res = await sendMessage(localStorage.token, id, { content: content, data: data }).catch( const res = await sendMessage(localStorage.token, id, { content: content, data: data }).catch(
(error) => { (error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
} }
); );
@ -199,7 +199,7 @@
</svelte:head> </svelte:head>
<div <div
class="h-screen max-h-[100dvh] {$showSidebar class="h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
? 'md:max-w-[calc(100%-260px)]' ? 'md:max-w-[calc(100%-260px)]'
: ''} w-full max-w-full flex flex-col" : ''} w-full max-w-full flex flex-col"
id="channel-container" id="channel-container"
@ -266,7 +266,7 @@
threadId = null; threadId = null;
}} }}
> >
<div class=" {threadId !== null ? ' h-screen w-screen' : 'px-6 py-4'} h-full"> <div class=" {threadId !== null ? ' h-screen w-full' : 'px-6 py-4'} h-full">
<Thread <Thread
{threadId} {threadId}
{channel} {channel}

View File

@ -158,7 +158,7 @@
// Check if the file is an audio file and transcribe/convert it to text file // Check if the file is an audio file and transcribe/convert it to text file
if (['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/x-m4a'].includes(file['type'])) { if (['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/x-m4a'].includes(file['type'])) {
const res = await transcribeAudio(localStorage.token, file).catch((error) => { const res = await transcribeAudio(localStorage.token, file).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });

View File

@ -102,7 +102,7 @@
const res = deleteMessage(localStorage.token, message.channel_id, message.id).catch( const res = deleteMessage(localStorage.token, message.channel_id, message.id).catch(
(error) => { (error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
} }
); );
@ -118,7 +118,7 @@
const res = updateMessage(localStorage.token, message.channel_id, message.id, { const res = updateMessage(localStorage.token, message.channel_id, message.id, {
content: content content: content
}).catch((error) => { }).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });
}} }}
@ -154,7 +154,7 @@
message.id, message.id,
name name
).catch((error) => { ).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });
} else { } else {
@ -180,7 +180,7 @@
const res = addReaction(localStorage.token, message.channel_id, message.id, name).catch( const res = addReaction(localStorage.token, message.channel_id, message.id, name).catch(
(error) => { (error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
} }
); );

View File

@ -30,6 +30,7 @@
import FaceSmile from '$lib/components/icons/FaceSmile.svelte'; import FaceSmile from '$lib/components/icons/FaceSmile.svelte';
import ReactionPicker from './Message/ReactionPicker.svelte'; import ReactionPicker from './Message/ReactionPicker.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte'; import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import { formatDate } from '$lib/utils';
export let message; export let message;
export let showUserProfile = true; export let showUserProfile = true;
@ -45,19 +46,6 @@
let edit = false; let edit = false;
let editedContent = null; let editedContent = null;
let showDeleteConfirmDialog = false; let showDeleteConfirmDialog = false;
const formatDate = (inputDate) => {
const date = dayjs(inputDate);
const now = dayjs();
if (date.isToday()) {
return `Today at ${date.format('HH:mm')}`;
} else if (date.isYesterday()) {
return `Yesterday at ${date.format('HH:mm')}`;
} else {
return `${date.format('DD/MM/YYYY')} at ${date.format('HH:mm')}`;
}
};
</script> </script>
<ConfirmDialog <ConfirmDialog

View File

@ -16,7 +16,7 @@
export let channel; export let channel;
</script> </script>
<div class="sticky top-0 z-30 w-full px-1.5 py-1.5 -mb-8 flex items-center"> <nav class="sticky top-0 z-30 w-full px-1.5 py-1.5 -mb-8 flex items-center drag-region">
<div <div
class=" bg-gradient-to-b via-50% from-white via-white to-transparent dark:from-gray-900 dark:via-gray-900 dark:to-transparent pointer-events-none absolute inset-0 -bottom-7 z-[-1] blur" class=" bg-gradient-to-b via-50% from-white via-white to-transparent dark:from-gray-900 dark:via-gray-900 dark:to-transparent pointer-events-none absolute inset-0 -bottom-7 z-[-1] blur"
></div> ></div>
@ -83,4 +83,4 @@
</div> </div>
</div> </div>
</div> </div>
</div> </nav>

View File

@ -128,7 +128,7 @@
content: content, content: content,
data: data data: data
}).catch((error) => { }).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });
}; };

View File

@ -44,7 +44,8 @@
extractSentencesForAudio, extractSentencesForAudio,
promptTemplate, promptTemplate,
splitStream, splitStream,
sleep sleep,
removeDetailsWithReasoning
} from '$lib/utils'; } from '$lib/utils';
import { generateChatCompletion } from '$lib/apis/ollama'; import { generateChatCompletion } from '$lib/apis/ollama';
@ -111,6 +112,7 @@
$: selectedModelIds = atSelectedModel !== undefined ? [atSelectedModel.id] : selectedModels; $: selectedModelIds = atSelectedModel !== undefined ? [atSelectedModel.id] : selectedModels;
let selectedToolIds = []; let selectedToolIds = [];
let imageGenerationEnabled = false;
let webSearchEnabled = false; let webSearchEnabled = false;
let chat = null; let chat = null;
@ -137,6 +139,7 @@
files = []; files = [];
selectedToolIds = []; selectedToolIds = [];
webSearchEnabled = false; webSearchEnabled = false;
imageGenerationEnabled = false;
loaded = false; loaded = false;
@ -152,6 +155,7 @@
files = input.files; files = input.files;
selectedToolIds = input.selectedToolIds; selectedToolIds = input.selectedToolIds;
webSearchEnabled = input.webSearchEnabled; webSearchEnabled = input.webSearchEnabled;
imageGenerationEnabled = input.imageGenerationEnabled;
} catch (e) {} } catch (e) {}
} }
@ -318,6 +322,19 @@
eventConfirmationMessage = data.message; eventConfirmationMessage = data.message;
eventConfirmationInputPlaceholder = data.placeholder; eventConfirmationInputPlaceholder = data.placeholder;
eventConfirmationInputValue = data?.value ?? ''; eventConfirmationInputValue = data?.value ?? '';
} else if (type === 'notification') {
const toastType = data?.type ?? 'info';
const toastContent = data?.content ?? '';
if (toastType === 'success') {
toast.success(toastContent);
} else if (toastType === 'error') {
toast.error(toastContent);
} else if (toastType === 'warning') {
toast.warning(toastContent);
} else {
toast.info(toastContent);
}
} else { } else {
console.log('Unknown message type', data); console.log('Unknown message type', data);
} }
@ -390,11 +407,13 @@
files = input.files; files = input.files;
selectedToolIds = input.selectedToolIds; selectedToolIds = input.selectedToolIds;
webSearchEnabled = input.webSearchEnabled; webSearchEnabled = input.webSearchEnabled;
imageGenerationEnabled = input.imageGenerationEnabled;
} catch (e) { } catch (e) {
prompt = ''; prompt = '';
files = []; files = [];
selectedToolIds = []; selectedToolIds = [];
webSearchEnabled = false; webSearchEnabled = false;
imageGenerationEnabled = false;
} }
} }
@ -696,6 +715,9 @@
if ($page.url.searchParams.get('web-search') === 'true') { if ($page.url.searchParams.get('web-search') === 'true') {
webSearchEnabled = true; webSearchEnabled = true;
} }
if ($page.url.searchParams.get('image-generation') === 'true') {
imageGenerationEnabled = true;
}
if ($page.url.searchParams.get('tools')) { if ($page.url.searchParams.get('tools')) {
selectedToolIds = $page.url.searchParams selectedToolIds = $page.url.searchParams
@ -830,7 +852,7 @@
session_id: $socket?.id, session_id: $socket?.id,
id: responseMessageId id: responseMessageId
}).catch((error) => { }).catch((error) => {
toast.error(error); toast.error(`${error}`);
messages.at(-1).error = { content: error }; messages.at(-1).error = { content: error };
return null; return null;
@ -888,7 +910,7 @@
session_id: $socket?.id, session_id: $socket?.id,
id: responseMessageId id: responseMessageId
}).catch((error) => { }).catch((error) => {
toast.error(error); toast.error(`${error}`);
messages.at(-1).error = { content: error }; messages.at(-1).error = { content: error };
return null; return null;
}); });
@ -1396,7 +1418,7 @@
if ($settings?.memory ?? false) { if ($settings?.memory ?? false) {
if (userContext === null) { if (userContext === null) {
const res = await queryMemory(localStorage.token, prompt).catch((error) => { const res = await queryMemory(localStorage.token, prompt).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });
if (res) { if (res) {
@ -1482,7 +1504,10 @@
}` }`
} }
: undefined, : undefined,
...createMessagesList(responseMessageId) ...createMessagesList(responseMessageId).map((message) => ({
...message,
content: removeDetailsWithReasoning(message.content)
}))
] ]
.filter((message) => message?.content?.trim()) .filter((message) => message?.content?.trim())
.map((message, idx, arr) => ({ .map((message, idx, arr) => ({
@ -1533,6 +1558,7 @@
files: (files?.length ?? 0) > 0 ? files : undefined, files: (files?.length ?? 0) > 0 ? files : undefined,
tool_ids: selectedToolIds.length > 0 ? selectedToolIds : undefined, tool_ids: selectedToolIds.length > 0 ? selectedToolIds : undefined,
features: { features: {
image_generation: imageGenerationEnabled,
web_search: webSearchEnabled web_search: webSearchEnabled
}, },
@ -1829,13 +1855,13 @@
}} }}
/> />
{#if !chatIdProp || (loaded && chatIdProp)} <div
<div class="h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
class="h-screen max-h-[100dvh] {$showSidebar ? ' md:max-w-[calc(100%-260px)]'
? 'md:max-w-[calc(100%-260px)]' : ' '} w-full max-w-full flex flex-col"
: ''} w-full max-w-full flex flex-col" id="chat-container"
id="chat-container" >
> {#if !chatIdProp || (loaded && chatIdProp)}
{#if $settings?.backgroundImageUrl ?? null} {#if $settings?.backgroundImageUrl ?? null}
<div <div
class="absolute {$showSidebar class="absolute {$showSidebar
@ -1935,6 +1961,7 @@
bind:prompt bind:prompt
bind:autoScroll bind:autoScroll
bind:selectedToolIds bind:selectedToolIds
bind:imageGenerationEnabled
bind:webSearchEnabled bind:webSearchEnabled
bind:atSelectedModel bind:atSelectedModel
transparentBackground={$settings?.backgroundImageUrl ?? false} transparentBackground={$settings?.backgroundImageUrl ?? false}
@ -1985,6 +2012,7 @@
bind:prompt bind:prompt
bind:autoScroll bind:autoScroll
bind:selectedToolIds bind:selectedToolIds
bind:imageGenerationEnabled
bind:webSearchEnabled bind:webSearchEnabled
bind:atSelectedModel bind:atSelectedModel
transparentBackground={$settings?.backgroundImageUrl ?? false} transparentBackground={$settings?.backgroundImageUrl ?? false}
@ -2037,5 +2065,5 @@
{eventTarget} {eventTarget}
/> />
</PaneGroup> </PaneGroup>
</div> {/if}
{/if} </div>

View File

@ -146,7 +146,7 @@
> >
<div <div
class=" {$showCallOverlay || $showOverview || $showArtifacts class=" {$showCallOverlay || $showOverview || $showArtifacts
? ' h-screen w-screen' ? ' h-screen w-full'
: 'px-6 py-4'} h-full" : 'px-6 py-4'} h-full"
> >
{#if $showCallOverlay} {#if $showCallOverlay}

View File

@ -30,64 +30,70 @@
</button> </button>
</div> </div>
<div class=" dark:text-gray-200 text-sm font-primary py-0.5 px-0.5"> {#if $user.role === 'admin' || $user?.permissions.chat?.controls}
{#if chatFiles.length > 0} <div class=" dark:text-gray-200 text-sm font-primary py-0.5 px-0.5">
<Collapsible title={$i18n.t('Files')} open={true} buttonClassName="w-full"> {#if chatFiles.length > 0}
<div class="flex flex-col gap-1 mt-1.5" slot="content"> <Collapsible title={$i18n.t('Files')} open={true} buttonClassName="w-full">
{#each chatFiles as file, fileIdx} <div class="flex flex-col gap-1 mt-1.5" slot="content">
<FileItem {#each chatFiles as file, fileIdx}
className="w-full" <FileItem
item={file} className="w-full"
edit={true} item={file}
url={file?.url ? file.url : null} edit={true}
name={file.name} url={file?.url ? file.url : null}
type={file.type} name={file.name}
size={file?.size} type={file.type}
dismissible={true} size={file?.size}
on:dismiss={() => { dismissible={true}
// Remove the file from the chatFiles array on:dismiss={() => {
// Remove the file from the chatFiles array
chatFiles.splice(fileIdx, 1); chatFiles.splice(fileIdx, 1);
chatFiles = chatFiles; chatFiles = chatFiles;
}} }}
on:click={() => { on:click={() => {
console.log(file); console.log(file);
}} }}
/> />
{/each} {/each}
</div>
</Collapsible>
<hr class="my-2 border-gray-50 dark:border-gray-700/10" />
{/if}
<Collapsible bind:open={showValves} title={$i18n.t('Valves')} buttonClassName="w-full">
<div class="text-sm" slot="content">
<Valves show={showValves} />
</div> </div>
</Collapsible> </Collapsible>
<hr class="my-2 border-gray-50 dark:border-gray-700/10" /> <hr class="my-2 border-gray-50 dark:border-gray-700/10" />
{/if}
<Collapsible bind:open={showValves} title={$i18n.t('Valves')} buttonClassName="w-full"> <Collapsible title={$i18n.t('System Prompt')} open={true} buttonClassName="w-full">
<div class="text-sm" slot="content"> <div class="" slot="content">
<Valves show={showValves} /> <textarea
</div> bind:value={params.system}
</Collapsible> class="w-full text-xs py-1.5 bg-transparent outline-none resize-none"
rows="4"
<hr class="my-2 border-gray-50 dark:border-gray-700/10" /> placeholder={$i18n.t('Enter system prompt')}
/>
<Collapsible title={$i18n.t('System Prompt')} open={true} buttonClassName="w-full">
<div class="" slot="content">
<textarea
bind:value={params.system}
class="w-full text-xs py-1.5 bg-transparent outline-none resize-none"
rows="4"
placeholder={$i18n.t('Enter system prompt')}
/>
</div>
</Collapsible>
<hr class="my-2 border-gray-50 dark:border-gray-700/10" />
<Collapsible title={$i18n.t('Advanced Params')} open={true} buttonClassName="w-full">
<div class="text-sm mt-1.5" slot="content">
<div>
<AdvancedParams admin={$user?.role === 'admin'} bind:params />
</div> </div>
</div> </Collapsible>
</Collapsible>
</div> <hr class="my-2 border-gray-50 dark:border-gray-700/10" />
<Collapsible title={$i18n.t('Advanced Params')} open={true} buttonClassName="w-full">
<div class="text-sm mt-1.5" slot="content">
<div>
<AdvancedParams admin={$user?.role === 'admin'} bind:params />
</div>
</div>
</Collapsible>
</div>
{:else}
<div class="text-sm dark:text-gray-300 text-center py-2 px-10">
{$i18n.t('You do not have permission to access this feature.')}
</div>
{/if}
</div> </div>

View File

@ -82,7 +82,7 @@
if (tab === 'tools') { if (tab === 'tools') {
const res = await updateToolUserValvesById(localStorage.token, selectedId, valves).catch( const res = await updateToolUserValvesById(localStorage.token, selectedId, valves).catch(
(error) => { (error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
} }
); );
@ -97,7 +97,7 @@
selectedId, selectedId,
valves valves
).catch((error) => { ).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });

View File

@ -38,6 +38,7 @@
import { generateAutoCompletion } from '$lib/apis'; import { generateAutoCompletion } from '$lib/apis';
import { error, text } from '@sveltejs/kit'; import { error, text } from '@sveltejs/kit';
import Image from '../common/Image.svelte'; import Image from '../common/Image.svelte';
import { deleteFileById } from '$lib/apis/files';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -61,12 +62,15 @@
export let files = []; export let files = [];
export let selectedToolIds = []; export let selectedToolIds = [];
export let imageGenerationEnabled = false;
export let webSearchEnabled = false; export let webSearchEnabled = false;
$: onChange({ $: onChange({
prompt, prompt,
files, files,
selectedToolIds, selectedToolIds,
imageGenerationEnabled,
webSearchEnabled webSearchEnabled
}); });
@ -165,7 +169,7 @@
// Check if the file is an audio file and transcribe/convert it to text file // Check if the file is an audio file and transcribe/convert it to text file
if (['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/x-m4a'].includes(file['type'])) { if (['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/x-m4a'].includes(file['type'])) {
const res = await transcribeAudio(localStorage.token, file).catch((error) => { const res = await transcribeAudio(localStorage.token, file).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });
@ -381,7 +385,7 @@
</div> </div>
<div class="w-full relative"> <div class="w-full relative">
{#if atSelectedModel !== undefined || selectedToolIds.length > 0 || webSearchEnabled} {#if atSelectedModel !== undefined || selectedToolIds.length > 0 || webSearchEnabled || imageGenerationEnabled}
<div <div
class="px-3 pb-0.5 pt-1.5 text-left w-full flex flex-col absolute bottom-0 left-0 right-0 bg-gradient-to-t from-white dark:from-gray-900 z-10" class="px-3 pb-0.5 pt-1.5 text-left w-full flex flex-col absolute bottom-0 left-0 right-0 bg-gradient-to-t from-white dark:from-gray-900 z-10"
> >
@ -396,7 +400,7 @@
<span class="relative inline-flex rounded-full size-2 bg-yellow-500" /> <span class="relative inline-flex rounded-full size-2 bg-yellow-500" />
</span> </span>
</div> </div>
<div class=" translate-y-[0.5px] text-ellipsis line-clamp-1 flex"> <div class=" text-ellipsis line-clamp-1 flex">
{#each selectedToolIds.map((id) => { {#each selectedToolIds.map((id) => {
return $tools ? $tools.find((t) => t.id === id) : { id: id, name: id }; return $tools ? $tools.find((t) => t.id === id) : { id: id, name: id };
}) as tool, toolIdx (toolIdx)} }) as tool, toolIdx (toolIdx)}
@ -417,6 +421,22 @@
</div> </div>
{/if} {/if}
{#if imageGenerationEnabled}
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2.5 text-sm dark:text-gray-500">
<div class="pl-1">
<span class="relative flex size-2">
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
/>
<span class="relative inline-flex rounded-full size-2 bg-green-500" />
</span>
</div>
<div class=" ">{$i18n.t('Image generation')}</div>
</div>
</div>
{/if}
{#if webSearchEnabled} {#if webSearchEnabled}
<div class="flex items-center justify-between w-full"> <div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2.5 text-sm dark:text-gray-500"> <div class="flex items-center gap-2.5 text-sm dark:text-gray-500">
@ -428,7 +448,7 @@
<span class="relative inline-flex rounded-full size-2 bg-green-500" /> <span class="relative inline-flex rounded-full size-2 bg-green-500" />
</span> </span>
</div> </div>
<div class=" translate-y-[0.5px]">{$i18n.t('Search the web')}</div> <div class=" ">{$i18n.t('Search the web')}</div>
</div> </div>
</div> </div>
{/if} {/if}
@ -548,15 +568,15 @@
dir={$settings?.chatDirection ?? 'LTR'} dir={$settings?.chatDirection ?? 'LTR'}
> >
{#if files.length > 0} {#if files.length > 0}
<div class="mx-1 mt-2.5 mb-1 flex flex-wrap gap-2"> <div class="mx-1 mt-2.5 mb-1 flex items-center flex-wrap gap-2">
{#each files as file, fileIdx} {#each files as file, fileIdx}
{#if file.type === 'image'} {#if file.type === 'image'}
<div class=" relative group"> <div class=" relative group">
<div class="relative"> <div class="relative flex items-center">
<Image <Image
src={file.url} src={file.url}
alt="input" alt="input"
imageClassName=" h-16 w-16 rounded-xl object-cover" imageClassName=" size-14 rounded-xl object-cover"
/> />
{#if atSelectedModel ? visionCapableModels.length === 0 : selectedModels.length !== visionCapableModels.length} {#if atSelectedModel ? visionCapableModels.length === 0 : selectedModels.length !== visionCapableModels.length}
<Tooltip <Tooltip
@ -615,7 +635,15 @@
loading={file.status === 'uploading'} loading={file.status === 'uploading'}
dismissible={true} dismissible={true}
edit={true} edit={true}
on:dismiss={() => { on:dismiss={async () => {
if (file.type !== 'collection' && !file?.collection) {
if (file.id) {
// This will handle both file deletion and Chroma cleanup
await deleteFileById(localStorage.token, file.id);
}
}
// Remove from UI state
files.splice(fileIdx, 1); files.splice(fileIdx, 1);
files = files; files = files;
}} }}
@ -631,6 +659,7 @@
<div class=" flex"> <div class=" flex">
<div class="ml-1 self-end mb-1.5 flex space-x-1"> <div class="ml-1 self-end mb-1.5 flex space-x-1">
<InputMenu <InputMenu
bind:imageGenerationEnabled
bind:webSearchEnabled bind:webSearchEnabled
bind:selectedToolIds bind:selectedToolIds
{screenCaptureHandler} {screenCaptureHandler}
@ -839,6 +868,7 @@
atSelectedModel = undefined; atSelectedModel = undefined;
selectedToolIds = []; selectedToolIds = [];
webSearchEnabled = false; webSearchEnabled = false;
imageGenerationEnabled = false;
} }
}} }}
on:paste={async (e) => { on:paste={async (e) => {
@ -1025,6 +1055,7 @@
atSelectedModel = undefined; atSelectedModel = undefined;
selectedToolIds = []; selectedToolIds = [];
webSearchEnabled = false; webSearchEnabled = false;
imageGenerationEnabled = false;
} }
}} }}
rows="1" rows="1"

View File

@ -153,7 +153,7 @@
const file = blobToFile(audioBlob, 'recording.wav'); const file = blobToFile(audioBlob, 'recording.wav');
const res = await transcribeAudio(localStorage.token, file).catch((error) => { const res = await transcribeAudio(localStorage.token, file).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });

View File

@ -78,6 +78,10 @@
}} }}
on:select={(e) => { on:select={(e) => {
console.log(e); console.log(e);
if (files.find((f) => f.id === e.detail.id)) {
return;
}
files = [ files = [
...files, ...files,
{ {

View File

@ -127,7 +127,7 @@
...a, ...a,
...(item?.files ?? []).map((file) => ({ ...(item?.files ?? []).map((file) => ({
...file, ...file,
collection: { name: item.name, description: item.description } collection: { name: item.name, description: item.description } // DO NOT REMOVE, USED IN FILE DESCRIPTION/ATTACHMENT
})) }))
]) ])
]; ];

View File

@ -14,6 +14,7 @@
import GlobeAltSolid from '$lib/components/icons/GlobeAltSolid.svelte'; import GlobeAltSolid from '$lib/components/icons/GlobeAltSolid.svelte';
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte'; import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
import CameraSolid from '$lib/components/icons/CameraSolid.svelte'; import CameraSolid from '$lib/components/icons/CameraSolid.svelte';
import PhotoSolid from '$lib/components/icons/PhotoSolid.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -24,11 +25,25 @@
export let selectedToolIds: string[] = []; export let selectedToolIds: string[] = [];
export let webSearchEnabled: boolean; export let webSearchEnabled: boolean;
export let imageGenerationEnabled: boolean;
export let onClose: Function; export let onClose: Function;
let tools = {}; let tools = {};
let show = false; let show = false;
let showImageGeneration = false;
$: showImageGeneration =
$config?.features?.enable_image_generation &&
($user.role === 'admin' || $user?.permissions?.features?.image_generation);
let showWebSearch = false;
$: showWebSearch =
$config?.features?.enable_web_search &&
($user.role === 'admin' || $user?.permissions?.features?.web_search);
$: if (show) { $: if (show) {
init(); init();
} }
@ -63,7 +78,7 @@
<div slot="content"> <div slot="content">
<DropdownMenu.Content <DropdownMenu.Content
class="w-full max-w-[200px] rounded-xl px-1 py-1 border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow" class="w-full max-w-[220px] rounded-xl px-1 py-1 border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow"
sideOffset={15} sideOffset={15}
alignOffset={-8} alignOffset={-8}
side="top" side="top"
@ -114,7 +129,23 @@
<hr class="border-black/5 dark:border-white/5 my-1" /> <hr class="border-black/5 dark:border-white/5 my-1" />
{/if} {/if}
{#if $config?.features?.enable_web_search} {#if showImageGeneration}
<button
class="flex w-full justify-between gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer rounded-xl"
on:click={() => {
imageGenerationEnabled = !imageGenerationEnabled;
}}
>
<div class="flex-1 flex items-center gap-2">
<PhotoSolid />
<div class=" line-clamp-1">{$i18n.t('Image')}</div>
</div>
<Switch state={imageGenerationEnabled} />
</button>
{/if}
{#if showWebSearch}
<button <button
class="flex w-full justify-between gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer rounded-xl" class="flex w-full justify-between gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer rounded-xl"
on:click={() => { on:click={() => {
@ -128,7 +159,9 @@
<Switch state={webSearchEnabled} /> <Switch state={webSearchEnabled} />
</button> </button>
{/if}
{#if showImageGeneration || showWebSearch}
<hr class="border-black/5 dark:border-white/5 my-1" /> <hr class="border-black/5 dark:border-white/5 my-1" />
{/if} {/if}

View File

@ -137,7 +137,7 @@
const file = blobToFile(audioBlob, 'recording.wav'); const file = blobToFile(audioBlob, 'recording.wav');
const res = await transcribeAudio(localStorage.token, file).catch((error) => { const res = await transcribeAudio(localStorage.token, file).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });

View File

@ -195,7 +195,7 @@
</ul> </ul>
{/if} {/if}
{:else if token.type === 'details'} {:else if token.type === 'details'}
<Collapsible title={token.summary} className="w-fit space-y-1"> <Collapsible title={token.summary} attributes={token?.attributes} className="w-fit space-y-1">
<div class=" mb-1.5" slot="content"> <div class=" mb-1.5" slot="content">
<svelte:self id={`${id}-${tokenIdx}-d`} tokens={marked.lexer(token.text)} /> <svelte:self id={`${id}-${tokenIdx}-d`} tokens={marked.lexer(token.text)} />
</div> </div>

View File

@ -1,3 +1,3 @@
<div class=" self-center font-semibold mb-0.5 line-clamp-1 flex gap-1 items-center"> <div class=" self-center font-semibold line-clamp-1 flex gap-1 items-center">
<slot /> <slot />
</div> </div>

View File

@ -17,7 +17,8 @@
approximateToHumanReadable, approximateToHumanReadable,
getMessageContentParts, getMessageContentParts,
sanitizeResponseContent, sanitizeResponseContent,
createMessagesList createMessagesList,
formatDate
} from '$lib/utils'; } from '$lib/utils';
import { WEBUI_BASE_URL } from '$lib/constants'; import { WEBUI_BASE_URL } from '$lib/constants';
@ -229,7 +230,7 @@
sentence sentence
).catch((error) => { ).catch((error) => {
console.error(error); console.error(error);
toast.error(error); toast.error(`${error}`);
speaking = false; speaking = false;
loadingSpeech = false; loadingSpeech = false;
@ -321,7 +322,7 @@
const generateImage = async (message: MessageType) => { const generateImage = async (message: MessageType) => {
generatingImage = true; generatingImage = true;
const res = await imageGenerations(localStorage.token, message.content).catch((error) => { const res = await imageGenerations(localStorage.token, message.content).catch((error) => {
toast.error(error); toast.error(`${error}`);
}); });
console.log(res); console.log(res);
@ -356,7 +357,7 @@
}; };
const chat = await getChatById(localStorage.token, chatId).catch((error) => { const chat = await getChatById(localStorage.token, chatId).catch((error) => {
toast.error(error); toast.error(`${error}`);
}); });
if (!chat) { if (!chat) {
return; return;
@ -411,11 +412,11 @@
message.feedbackId, message.feedbackId,
feedbackItem feedbackItem
).catch((error) => { ).catch((error) => {
toast.error(error); toast.error(`${error}`);
}); });
} else { } else {
feedback = await createNewFeedback(localStorage.token, feedbackItem).catch((error) => { feedback = await createNewFeedback(localStorage.token, feedbackItem).catch((error) => {
toast.error(error); toast.error(`${error}`);
}); });
if (feedback) { if (feedback) {
@ -451,7 +452,7 @@
updatedMessage.feedbackId, updatedMessage.feedbackId,
feedbackItem feedbackItem
).catch((error) => { ).catch((error) => {
toast.error(error); toast.error(`${error}`);
}); });
} }
} }
@ -496,11 +497,13 @@
</Tooltip> </Tooltip>
{#if message.timestamp} {#if message.timestamp}
<span <div
class=" self-center shrink-0 translate-y-0.5 invisible group-hover:visible text-gray-400 text-xs font-medium uppercase ml-0.5 -mt-0.5" class=" self-center text-xs invisible group-hover:visible text-gray-400 font-medium first-letter:capitalize ml-0.5 translate-y-[1px]"
> >
{dayjs(message.timestamp * 1000).format($i18n.t('h:mm a'))} <Tooltip content={dayjs(message.timestamp * 1000).format('dddd, DD MMMM YYYY HH:mm')}>
</span> <span class="line-clamp-1">{formatDate(message.timestamp * 1000)}</span>
</Tooltip>
</div>
{/if} {/if}
</Name> </Name>
@ -917,7 +920,7 @@
</button> </button>
</Tooltip> </Tooltip>
{#if $config?.features.enable_image_generation && !readOnly} {#if $config?.features.enable_image_generation && ($user.role === 'admin' || $user?.permissions?.features?.image_generation) && !readOnly}
<Tooltip content={$i18n.t('Generate Image')} placement="bottom"> <Tooltip content={$i18n.t('Generate Image')} placement="bottom">
<button <button
class="{isLastMessage class="{isLastMessage
@ -1180,20 +1183,22 @@
type="button" type="button"
class="{isLastMessage class="{isLastMessage
? 'visible' ? 'visible'
: 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition regenerate-response-button" : 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition"
on:click={() => { on:click={() => {
actionMessage(action.id, message); actionMessage(action.id, message);
}} }}
> >
{#if action.icon_url} {#if action.icon_url}
<img <div class="size-4">
src={action.icon_url} <img
class="w-4 h-4 {action.icon_url.includes('svg') src={action.icon_url}
? 'dark:invert-[80%]' class="w-4 h-4 {action.icon_url.includes('svg')
: ''}" ? 'dark:invert-[80%]'
style="fill: currentColor;" : ''}"
alt={action.name} style="fill: currentColor;"
/> alt={action.name}
/>
</div>
{:else} {:else}
<Sparkles strokeWidth="2.1" className="size-4" /> <Sparkles strokeWidth="2.1" className="size-4" />
{/if} {/if}
@ -1232,48 +1237,4 @@
-ms-overflow-style: none; /* IE and Edge */ -ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */ scrollbar-width: none; /* Firefox */
} }
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.shimmer {
background: linear-gradient(90deg, #9a9b9e 25%, #2a2929 50%, #9a9b9e 75%);
background-size: 200% 100%;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: shimmer 4s linear infinite;
color: #818286; /* Fallback color */
}
:global(.dark) .shimmer {
background: linear-gradient(90deg, #818286 25%, #eae5e5 50%, #818286 75%);
background-size: 200% 100%;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: shimmer 4s linear infinite;
color: #a1a3a7; /* Darker fallback color for dark mode */
}
@keyframes smoothFadeIn {
0% {
opacity: 0;
transform: translateY(-10px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.status-description {
animation: smoothFadeIn 0.2s forwards;
}
</style> </style>

View File

@ -5,7 +5,7 @@
import { models, settings } from '$lib/stores'; import { models, settings } from '$lib/stores';
import { user as _user } from '$lib/stores'; import { user as _user } from '$lib/stores';
import { copyToClipboard as _copyToClipboard } from '$lib/utils'; import { copyToClipboard as _copyToClipboard, formatDate } from '$lib/utils';
import Name from './Name.svelte'; import Name from './Name.svelte';
import ProfileImage from './ProfileImage.svelte'; import ProfileImage from './ProfileImage.svelte';
@ -109,11 +109,13 @@
{/if} {/if}
{#if message.timestamp} {#if message.timestamp}
<span <div
class=" invisible group-hover:visible text-gray-400 text-xs font-medium uppercase ml-0.5 -mt-0.5" class=" self-center text-xs invisible group-hover:visible text-gray-400 font-medium first-letter:capitalize ml-0.5 translate-y-[1px]"
> >
{dayjs(message.timestamp * 1000).format($i18n.t('h:mm a'))} <Tooltip content={dayjs(message.timestamp * 1000).format('dddd, DD MMMM YYYY HH:mm')}>
</span> <span class="line-clamp-1">{formatDate(message.timestamp * 1000)}</span>
</Tooltip>
</div>
{/if} {/if}
</Name> </Name>
</div> </div>

View File

@ -97,7 +97,7 @@
const [res, controller] = await pullModel(localStorage.token, sanitizedModelTag, '0').catch( const [res, controller] = await pullModel(localStorage.token, sanitizedModelTag, '0').catch(
(error) => { (error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
} }
); );
@ -173,7 +173,7 @@
error = error.message; error = error.message;
} }
toast.error(error); toast.error(`${error}`);
// opts.callback({ success: false, error, modelName: opts.modelName }); // opts.callback({ success: false, error, modelName: opts.modelName });
break; break;
} }

View File

@ -43,7 +43,7 @@
<ShareChatModal bind:show={showShareChatModal} chatId={$chatId} /> <ShareChatModal bind:show={showShareChatModal} chatId={$chatId} />
<div class="sticky top-0 z-30 w-full px-1.5 py-1.5 -mb-8 flex items-center"> <nav class="sticky top-0 z-30 w-full px-1.5 py-1.5 -mb-8 flex items-center drag-region">
<div <div
class=" bg-gradient-to-b via-50% from-white via-white to-transparent dark:from-gray-900 dark:via-gray-900 dark:to-transparent pointer-events-none absolute inset-0 -bottom-7 z-[-1] blur" class=" bg-gradient-to-b via-50% from-white via-white to-transparent dark:from-gray-900 dark:via-gray-900 dark:to-transparent pointer-events-none absolute inset-0 -bottom-7 z-[-1] blur"
></div> ></div>
@ -114,7 +114,7 @@
</div> </div>
</button> </button>
</Menu> </Menu>
{:else if $mobile} {:else if $mobile && ($user.role === 'admin' || $user?.permissions.chat?.controls)}
<Tooltip content={$i18n.t('Controls')}> <Tooltip content={$i18n.t('Controls')}>
<button <button
class=" flex cursor-pointer px-2 py-2 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-850 transition" class=" flex cursor-pointer px-2 py-2 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-850 transition"
@ -130,7 +130,7 @@
</Tooltip> </Tooltip>
{/if} {/if}
{#if !$mobile} {#if !$mobile && ($user.role === 'admin' || $user?.permissions.chat?.controls)}
<Tooltip content={$i18n.t('Controls')}> <Tooltip content={$i18n.t('Controls')}>
<button <button
class=" flex cursor-pointer px-2 py-2 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-850 transition" class=" flex cursor-pointer px-2 py-2 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-850 transition"
@ -191,4 +191,4 @@
</div> </div>
</div> </div>
</div> </div>
</div> </nav>

View File

@ -34,6 +34,7 @@
export let files = []; export let files = [];
export let selectedToolIds = []; export let selectedToolIds = [];
export let imageGenerationEnabled = false;
export let webSearchEnabled = false; export let webSearchEnabled = false;
let models = []; let models = [];
@ -194,6 +195,7 @@
bind:prompt bind:prompt
bind:autoScroll bind:autoScroll
bind:selectedToolIds bind:selectedToolIds
bind:imageGenerationEnabled
bind:webSearchEnabled bind:webSearchEnabled
bind:atSelectedModel bind:atSelectedModel
{transparentBackground} {transparentBackground}

View File

@ -48,7 +48,7 @@
const updatedUser = await updateUserProfile(localStorage.token, name, profileImageUrl).catch( const updatedUser = await updateUserProfile(localStorage.token, name, profileImageUrl).catch(
(error) => { (error) => {
toast.error(error); toast.error(`${error}`);
} }
); );

View File

@ -14,7 +14,7 @@
if (newPassword === newPasswordConfirm) { if (newPassword === newPasswordConfirm) {
const res = await updateUserPassword(localStorage.token, currentPassword, newPassword).catch( const res = await updateUserPassword(localStorage.token, currentPassword, newPassword).catch(
(error) => { (error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
} }
); );

View File

@ -15,6 +15,7 @@
seed: null, seed: null,
stop: null, stop: null,
temperature: null, temperature: null,
reasoning_effort: null,
frequency_penalty: null, frequency_penalty: null,
repeat_last_n: null, repeat_last_n: null,
mirostat: null, mirostat: null,
@ -158,7 +159,7 @@
<div class="flex mt-0.5 space-x-2"> <div class="flex mt-0.5 space-x-2">
<div class=" flex-1"> <div class=" flex-1">
<input <input
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none" class="w-full rounded-lg py-2 px-1 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
type="text" type="text"
placeholder={$i18n.t('Enter stop sequence')} placeholder={$i18n.t('Enter stop sequence')}
bind:value={params.stop} bind:value={params.stop}
@ -224,6 +225,49 @@
{/if} {/if}
</div> </div>
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(
'Constrains effort on reasoning for reasoning models. Only applicable to reasoning models from specific providers that support reasoning effort. (Default: medium)'
)}
placement="top-start"
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Reasoning Effort')}
</div>
<button
class="p-1 px-3 text-xs flex rounded transition flex-shrink-0 outline-none"
type="button"
on:click={() => {
params.reasoning_effort = (params?.reasoning_effort ?? null) === null ? 'medium' : null;
}}
>
{#if (params?.reasoning_effort ?? null) === null}
<span class="ml-2 self-center"> {$i18n.t('Default')} </span>
{:else}
<span class="ml-2 self-center"> {$i18n.t('Custom')} </span>
{/if}
</button>
</div>
</Tooltip>
{#if (params?.reasoning_effort ?? null) !== null}
<div class="flex mt-0.5 space-x-2">
<div class=" flex-1">
<input
class="w-full rounded-lg py-2 px-1 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
type="text"
placeholder={$i18n.t('Enter reasoning effort')}
bind:value={params.reasoning_effort}
autocomplete="off"
/>
</div>
</div>
{/if}
</div>
<div class=" py-0.5 w-full justify-between"> <div class=" py-0.5 w-full justify-between">
<Tooltip <Tooltip
content={$i18n.t( content={$i18n.t(
@ -1086,7 +1130,7 @@
<div class=" py-0.5 w-full justify-between"> <div class=" py-0.5 w-full justify-between">
<Tooltip <Tooltip
content={$i18n.t( content={$i18n.t(
'Set the number of GPU devices used for computation. This option controls how many GPU devices (if available) are used to process incoming requests. Increasing this value can significantly improve performance for models that are optimized for GPU acceleration but may also consume more power and GPU resources.' 'Set the number of layers, which will be off-loaded to GPU. Increasing this value can significantly improve performance for models that are optimized for GPU acceleration but may also consume more power and GPU resources.'
)} )}
placement="top-start" placement="top-start"
className="inline-tooltip" className="inline-tooltip"

View File

@ -77,7 +77,7 @@
const archiveAllChatsHandler = async () => { const archiveAllChatsHandler = async () => {
await goto('/'); await goto('/');
await archiveAllChats(localStorage.token).catch((error) => { await archiveAllChats(localStorage.token).catch((error) => {
toast.error(error); toast.error(`${error}`);
}); });
currentChatPage.set(1); currentChatPage.set(1);
@ -88,7 +88,7 @@
const deleteAllChatsHandler = async () => { const deleteAllChatsHandler = async () => {
await goto('/'); await goto('/');
await deleteAllChats(localStorage.token).catch((error) => { await deleteAllChats(localStorage.token).catch((error) => {
toast.error(error); toast.error(`${error}`);
}); });
currentChatPage.set(1); currentChatPage.set(1);

View File

@ -232,78 +232,79 @@
</div> </div>
</div> </div>
<hr class=" dark:border-gray-850 my-3" /> {#if $user.role === 'admin' || $user?.permissions.chat?.controls}
<hr class=" dark:border-gray-850 my-3" />
<div> <div>
<div class=" my-2.5 text-sm font-medium">{$i18n.t('System Prompt')}</div> <div class=" my-2.5 text-sm font-medium">{$i18n.t('System Prompt')}</div>
<textarea <textarea
bind:value={system} bind:value={system}
class="w-full rounded-lg p-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none resize-none" class="w-full rounded-lg p-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none resize-none"
rows="4" rows="4"
/> />
</div>
<div class="mt-2 space-y-3 pr-1.5">
<div class="flex justify-between items-center text-sm">
<div class=" font-medium">{$i18n.t('Advanced Parameters')}</div>
<button
class=" text-xs font-medium text-gray-500"
type="button"
on:click={() => {
showAdvanced = !showAdvanced;
}}>{showAdvanced ? $i18n.t('Hide') : $i18n.t('Show')}</button
>
</div> </div>
{#if showAdvanced} <div class="mt-2 space-y-3 pr-1.5">
<AdvancedParams admin={$user?.role === 'admin'} bind:params /> <div class="flex justify-between items-center text-sm">
<hr class=" dark:border-gray-850" /> <div class=" font-medium">{$i18n.t('Advanced Parameters')}</div>
<button
<div class=" py-1 w-full justify-between"> class=" text-xs font-medium text-gray-500"
<div class="flex w-full justify-between"> type="button"
<div class=" self-center text-xs font-medium">{$i18n.t('Keep Alive')}</div> on:click={() => {
showAdvanced = !showAdvanced;
<button }}>{showAdvanced ? $i18n.t('Hide') : $i18n.t('Show')}</button
class="p-1 px-3 text-xs flex rounded transition" >
type="button"
on:click={() => {
keepAlive = keepAlive === null ? '5m' : null;
}}
>
{#if keepAlive === null}
<span class="ml-2 self-center"> {$i18n.t('Default')} </span>
{:else}
<span class="ml-2 self-center"> {$i18n.t('Custom')} </span>
{/if}
</button>
</div>
{#if keepAlive !== null}
<div class="flex mt-1 space-x-2">
<input
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
type="text"
placeholder={$i18n.t("e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.")}
bind:value={keepAlive}
/>
</div>
{/if}
</div> </div>
<div> {#if showAdvanced}
<div class=" py-1 flex w-full justify-between"> <AdvancedParams admin={$user?.role === 'admin'} bind:params />
<div class=" self-center text-sm font-medium">{$i18n.t('Request Mode')}</div> <hr class=" dark:border-gray-850" />
<button <div class=" py-1 w-full justify-between">
class="p-1 px-3 text-xs flex rounded transition" <div class="flex w-full justify-between">
on:click={() => { <div class=" self-center text-xs font-medium">{$i18n.t('Keep Alive')}</div>
toggleRequestFormat();
}} <button
> class="p-1 px-3 text-xs flex rounded transition"
{#if requestFormat === ''} type="button"
<span class="ml-2 self-center"> {$i18n.t('Default')} </span> on:click={() => {
{:else if requestFormat === 'json'} keepAlive = keepAlive === null ? '5m' : null;
<!-- <svg }}
>
{#if keepAlive === null}
<span class="ml-2 self-center"> {$i18n.t('Default')} </span>
{:else}
<span class="ml-2 self-center"> {$i18n.t('Custom')} </span>
{/if}
</button>
</div>
{#if keepAlive !== null}
<div class="flex mt-1 space-x-2">
<input
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
type="text"
placeholder={$i18n.t("e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.")}
bind:value={keepAlive}
/>
</div>
{/if}
</div>
<div>
<div class=" py-1 flex w-full justify-between">
<div class=" self-center text-sm font-medium">{$i18n.t('Request Mode')}</div>
<button
class="p-1 px-3 text-xs flex rounded transition"
on:click={() => {
toggleRequestFormat();
}}
>
{#if requestFormat === ''}
<span class="ml-2 self-center"> {$i18n.t('Default')} </span>
{:else if requestFormat === 'json'}
<!-- <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" viewBox="0 0 20 20"
fill="currentColor" fill="currentColor"
@ -313,13 +314,14 @@
d="M10 2a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0v-1.5A.75.75 0 0110 2zM10 15a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0v-1.5A.75.75 0 0110 15zM10 7a3 3 0 100 6 3 3 0 000-6zM15.657 5.404a.75.75 0 10-1.06-1.06l-1.061 1.06a.75.75 0 001.06 1.06l1.06-1.06zM6.464 14.596a.75.75 0 10-1.06-1.06l-1.06 1.06a.75.75 0 001.06 1.06l1.06-1.06zM18 10a.75.75 0 01-.75.75h-1.5a.75.75 0 010-1.5h1.5A.75.75 0 0118 10zM5 10a.75.75 0 01-.75.75h-1.5a.75.75 0 010-1.5h1.5A.75.75 0 015 10zM14.596 15.657a.75.75 0 001.06-1.06l-1.06-1.061a.75.75 0 10-1.06 1.06l1.06 1.06zM5.404 6.464a.75.75 0 001.06-1.06l-1.06-1.06a.75.75 0 10-1.061 1.06l1.06 1.06z" d="M10 2a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0v-1.5A.75.75 0 0110 2zM10 15a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0v-1.5A.75.75 0 0110 15zM10 7a3 3 0 100 6 3 3 0 000-6zM15.657 5.404a.75.75 0 10-1.06-1.06l-1.061 1.06a.75.75 0 001.06 1.06l1.06-1.06zM6.464 14.596a.75.75 0 10-1.06-1.06l-1.06 1.06a.75.75 0 001.06 1.06l1.06-1.06zM18 10a.75.75 0 01-.75.75h-1.5a.75.75 0 010-1.5h1.5A.75.75 0 0118 10zM5 10a.75.75 0 01-.75.75h-1.5a.75.75 0 010-1.5h1.5A.75.75 0 015 10zM14.596 15.657a.75.75 0 001.06-1.06l-1.06-1.061a.75.75 0 10-1.06 1.06l1.06 1.06zM5.404 6.464a.75.75 0 001.06-1.06l-1.06-1.06a.75.75 0 10-1.061 1.06l1.06 1.06z"
/> />
</svg> --> </svg> -->
<span class="ml-2 self-center"> {$i18n.t('JSON')} </span> <span class="ml-2 self-center"> {$i18n.t('JSON')} </span>
{/if} {/if}
</button> </button>
</div>
</div> </div>
</div> {/if}
{/if} </div>
</div> {/if}
</div> </div>
<div class="flex justify-end pt-3 text-sm font-medium"> <div class="flex justify-end pt-3 text-sm font-medium">

View File

@ -17,7 +17,7 @@
loading = true; loading = true;
const res = await addNewMemory(localStorage.token, content).catch((error) => { const res = await addNewMemory(localStorage.token, content).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });

View File

@ -28,7 +28,7 @@
loading = true; loading = true;
const res = await updateMemoryById(localStorage.token, memory.id, content).catch((error) => { const res = await updateMemoryById(localStorage.token, memory.id, content).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });

View File

@ -124,7 +124,7 @@
localStorage.token, localStorage.token,
memory.id memory.id
).catch((error) => { ).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });
@ -177,7 +177,7 @@
class=" px-3.5 py-1.5 font-medium text-red-500 hover:bg-black/5 dark:hover:bg-white/5 outline outline-1 outline-red-300 dark:outline-red-800 rounded-3xl" class=" px-3.5 py-1.5 font-medium text-red-500 hover:bg-black/5 dark:hover:bg-white/5 outline outline-1 outline-red-300 dark:outline-red-800 rounded-3xl"
on:click={async () => { on:click={async () => {
const res = await deleteMemoriesByUserId(localStorage.token).catch((error) => { const res = await deleteMemoriesByUserId(localStorage.token).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });

View File

@ -33,7 +33,7 @@
const addTag = async (tagName) => { const addTag = async (tagName) => {
const res = await addTagById(localStorage.token, chatId, tagName).catch(async (error) => { const res = await addTagById(localStorage.token, chatId, tagName).catch(async (error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });
if (!res) { if (!res) {

View File

@ -64,7 +64,7 @@
export const formatPythonCodeHandler = async () => { export const formatPythonCodeHandler = async () => {
if (codeEditor) { if (codeEditor) {
const res = await formatPythonCode(_value).catch((error) => { const res = await formatPythonCode(_value).catch((error) => {
toast.error(error); toast.error(`${error}`);
return null; return null;
}); });

View File

@ -1,5 +1,27 @@
<script lang="ts"> <script lang="ts">
import { getContext, createEventDispatcher } from 'svelte'; import { getContext, createEventDispatcher } from 'svelte';
const i18n = getContext('i18n');
import dayjs from '$lib/dayjs';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(duration);
dayjs.extend(relativeTime);
async function loadLocale(locales) {
for (const locale of locales) {
try {
dayjs.locale(locale);
break; // Stop after successfully loading the first available locale
} catch (error) {
console.error(`Could not load locale '${locale}':`, error);
}
}
}
// Assuming $i18n.languages is an array of language codes
$: loadLocale($i18n.languages);
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
$: dispatch('change', open); $: dispatch('change', open);
@ -9,12 +31,14 @@
import ChevronUp from '../icons/ChevronUp.svelte'; import ChevronUp from '../icons/ChevronUp.svelte';
import ChevronDown from '../icons/ChevronDown.svelte'; import ChevronDown from '../icons/ChevronDown.svelte';
import Spinner from './Spinner.svelte';
export let open = false; export let open = false;
export let className = ''; export let className = '';
export let buttonClassName = export let buttonClassName =
'w-fit text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition'; 'w-fit text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition';
export let title = null; export let title = null;
export let attributes = null;
export let grow = false; export let grow = false;
@ -34,12 +58,34 @@
} }
}} }}
> >
<div class=" w-full font-medium flex items-center justify-between gap-2"> <div
class=" w-full font-medium flex items-center justify-between gap-2 {attributes?.done &&
attributes?.done !== 'true'
? 'shimmer'
: ''}
"
>
{#if attributes?.done && attributes?.done !== 'true'}
<div>
<Spinner className="size-4" />
</div>
{/if}
<div class=""> <div class="">
{title} {#if attributes?.type === 'reasoning'}
{#if attributes?.done === 'true' && attributes?.duration}
{$i18n.t('Thought for {{DURATION}}', {
DURATION: dayjs.duration(attributes.duration, 'seconds').humanize()
})}
{:else}
{$i18n.t('Thinking...')}
{/if}
{:else}
{title}
{/if}
</div> </div>
<div> <div class="flex self-center translate-y-[1px]">
{#if open} {#if open}
<ChevronUp strokeWidth="3.5" className="size-3.5" /> <ChevronUp strokeWidth="3.5" className="size-3.5" />
{:else} {:else}

Some files were not shown because too many files have changed in this diff Show More