Merge branch 'open-webui:dev' into dev

This commit is contained in:
Kevin Rohn 2026-02-01 12:25:15 +01:00 • committed by GitHub
commit 99c0ce08f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
120 changed files with 6782 additions and 1608 deletions

View file

@ -1,3 +1,9 @@
<!--
⚠️ CRITICAL CHECKS FOR CONTRIBUTORS (READ, DON'T DELETE) ⚠️
1. Target the `dev` branch. PRs targeting `main` will be automatically closed.
2. Do NOT delete the CLA section at the bottom. It is required for the bot to accept your PR.
-->
# Pull Request Checklist
### Note to first-time contributors: Please open a discussion post in [Discussions](https://github.com/open-webui/open-webui/discussions) to discuss your idea/fix with the community before creating a pull request, and describe your changes before submitting a pull request.
@ -6,14 +12,16 @@ This is to ensure large feature PRs are discussed with the community first, befo
**Before submitting, make sure you've checked the following:**
- [ ] **Target branch:** Verify that the pull request targets the `dev` branch. **Not targeting the `dev` branch will lead to immediate closure of the PR.**
- [ ] **Target branch:** Verify that the pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.**
- [ ] **Description:** Provide a concise description of the changes made in this pull request down below.
- [ ] **Changelog:** Ensure a changelog entry following the format of [Keep a Changelog](https://keepachangelog.com/) is added at the bottom of the PR description.
- [ ] **Documentation:** If necessary, update relevant documentation [Open WebUI Docs](https://github.com/open-webui/docs) like environment variables, the tutorials, or other documentation sources.
- [ ] **Dependencies:** Are there any new dependencies? Have you updated the dependency versions in the documentation?
- [ ] **Testing:** Perform manual tests to **verify the implemented fix/feature works as intended AND does not break any other functionality**. Take this as an opportunity to **make screenshots of the feature/fix and include it in the PR description**.
- [ ] **Documentation:** Add docs in [Open WebUI Docs Repository](https://github.com/open-webui/docs). Document user-facing behavior, environment variables, public APIs/interfaces, or deployment steps.
- [ ] **Dependencies:** Are there any new or upgraded dependencies? If so, explain why, update the changelog/docs, and include any compatibility notes. Actually run the code/function that uses updated library to ensure it doesn't crash.
- [ ] **Testing:** Perform manual tests to **verify the implemented fix/feature works as intended AND does not break any other functionality**. Include reproducible steps to demonstrate the issue before the fix. Test edge cases (URL encoding, HTML entities, types). Take this as an opportunity to **make screenshots of the feature/fix and include them in the PR description**.
- [ ] **Agentic AI Code:** Confirm this Pull Request is **not written by any AI Agent** or has at least **gone through additional human review AND manual testing**. If any AI Agent is the co-author of this PR, it may lead to immediate closure of the PR.
- [ ] **Code review:** Have you performed a self-review of your code, addressing any coding standard issues and ensuring adherence to the project's coding standards?
- [ ] **Design & Architecture:** Prefer smart defaults over adding new settings; use local state for ephemeral UI logic. Open a Discussion for major architectural or UX changes.
- [ ] **Git Hygiene:** Keep PRs atomic (one logical change). Clean up commits and rebase on `dev` to ensure no unrelated commits (e.g. from `main`) are included. Push updates to the existing PR branch instead of closing and reopening.
- [ ] **Title Prefix:** To clearly categorize this pull request, prefix the pull request title using one of the following:
- **BREAKING CHANGE**: Significant changes that may affect compatibility
- **build**: Changes that affect the build system or external dependencies
@ -76,7 +84,13 @@ This is to ensure large feature PRs are discussed with the community first, befo
### Contributor License Agreement
<!--
🚨 DO NOT DELETE THE TEXT BELOW 🚨
Keep the "Contributor License Agreement" confirmation text intact.
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
-->
By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
> [!NOTE]
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.

View file

@ -128,7 +128,7 @@ RUN apt-get update && \
apt-get install -y --no-install-recommends \
git build-essential pandoc gcc netcat-openbsd curl jq \
python3-dev \
ffmpeg libsm6 libxext6 \
ffmpeg libsm6 libxext6 zstd \
&& rm -rf /var/lib/apt/lists/*
# install python dependencies

View file

@ -257,7 +257,7 @@ class AppConfig:
self._state[key].value = value
self._state[key].save()
if self._redis:
if self._redis and ENABLE_PERSISTENT_CONFIG:
redis_key = f"{self._redis_key_prefix}:config:{key}"
self._redis.set(redis_key, json.dumps(self._state[key].value))
@ -265,8 +265,8 @@ class AppConfig:
if key not in self._state:
raise AttributeError(f"Config key '{key}' not found")
# If Redis is available, check for an updated value
if self._redis:
# If Redis is available and persistent config is enabled, check for an updated value
if self._redis and ENABLE_PERSISTENT_CONFIG:
redis_key = f"{self._redis_key_prefix}:config:{key}"
redis_value = self._redis.get(redis_key)
@ -2246,9 +2246,13 @@ ENABLE_QDRANT_MULTITENANCY_MODE = (
QDRANT_COLLECTION_PREFIX = os.environ.get("QDRANT_COLLECTION_PREFIX", "open-webui")
WEAVIATE_HTTP_HOST = os.environ.get("WEAVIATE_HTTP_HOST", "")
WEAVIATE_GRPC_HOST = os.environ.get("WEAVIATE_GRPC_HOST", "")
WEAVIATE_HTTP_PORT = int(os.environ.get("WEAVIATE_HTTP_PORT", "8080"))
WEAVIATE_GRPC_PORT = int(os.environ.get("WEAVIATE_GRPC_PORT", "50051"))
WEAVIATE_API_KEY = os.environ.get("WEAVIATE_API_KEY")
WEAVIATE_HTTP_SECURE = os.environ.get("WEAVIATE_HTTP_SECURE", "false").lower() == "true"
WEAVIATE_GRPC_SECURE = os.environ.get("WEAVIATE_GRPC_SECURE", "false").lower() == "true"
WEAVIATE_SKIP_INIT_CHECKS = os.environ.get("WEAVIATE_SKIP_INIT_CHECKS", "false").lower() == "true"
# OpenSearch
OPENSEARCH_URI = os.environ.get("OPENSEARCH_URI", "https://localhost:9200")
@ -2817,6 +2821,12 @@ PDF_EXTRACT_IMAGES = PersistentConfig(
os.environ.get("PDF_EXTRACT_IMAGES", "False").lower() == "true",
)
PDF_LOADER_MODE = PersistentConfig(
"PDF_LOADER_MODE",
"rag.pdf_loader_mode",
os.environ.get("PDF_LOADER_MODE", "page"),
)
RAG_EMBEDDING_MODEL = PersistentConfig(
"RAG_EMBEDDING_MODEL",
"rag.embedding_model",
@ -3412,6 +3422,24 @@ EXTERNAL_WEB_LOADER_API_KEY = PersistentConfig(
os.environ.get("EXTERNAL_WEB_LOADER_API_KEY", ""),
)
YANDEX_WEB_SEARCH_URL = PersistentConfig(
"YANDEX_WEB_SEARCH_URL",
"rag.web.search.yandex_web_search_url",
os.environ.get("YANDEX_WEB_SEARCH_URL", ""),
)
YANDEX_WEB_SEARCH_API_KEY = PersistentConfig(
"YANDEX_WEB_SEARCH_API_KEY",
"rag.web.search.yandex_web_search_api_key",
os.environ.get("YANDEX_WEB_SEARCH_API_KEY", ""),
)
YANDEX_WEB_SEARCH_CONFIG = PersistentConfig(
"YANDEX_WEB_SEARCH_CONFIG",
"rag.web.search.yandex_web_search_config",
os.environ.get("YANDEX_WEB_SEARCH_CONFIG", ""),
)
####################################
# Images
####################################

View file

@ -199,6 +199,8 @@ ENABLE_STAR_SESSIONS_MIDDLEWARE = (
os.environ.get("ENABLE_STAR_SESSIONS_MIDDLEWARE", "False").lower() == "true"
)
ENABLE_EASTER_EGGS = os.environ.get("ENABLE_EASTER_EGGS", "True").lower() == "true"
####################################
# WEBUI_BUILD_HASH
####################################
@ -390,6 +392,22 @@ try:
REDIS_SOCKET_CONNECT_TIMEOUT = float(REDIS_SOCKET_CONNECT_TIMEOUT)
except ValueError:
REDIS_SOCKET_CONNECT_TIMEOUT = None
REDIS_RECONNECT_DELAY = os.environ.get(
"REDIS_RECONNECT_DELAY", ""
)
if REDIS_RECONNECT_DELAY == "":
REDIS_RECONNECT_DELAY = None
else:
try:
REDIS_RECONNECT_DELAY = float(
REDIS_RECONNECT_DELAY
)
if REDIS_RECONNECT_DELAY < 0:
REDIS_RECONNECT_DELAY = None
except Exception:
REDIS_RECONNECT_DELAY = None
####################################
# UVICORN WORKERS
@ -455,6 +473,8 @@ except Exception as e:
r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$"
)
PASSWORD_VALIDATION_HINT = os.environ.get("PASSWORD_VALIDATION_HINT", "")
BYPASS_MODEL_ACCESS_CONTROL = (
os.environ.get("BYPASS_MODEL_ACCESS_CONTROL", "False").lower() == "true"
@ -519,6 +539,12 @@ OAUTH_SESSION_TOKEN_ENCRYPTION_KEY = os.environ.get(
"OAUTH_SESSION_TOKEN_ENCRYPTION_KEY", WEBUI_SECRET_KEY
)
# Token Exchange Configuration
# Allows external apps to exchange OAuth tokens for OpenWebUI tokens
ENABLE_OAUTH_TOKEN_EXCHANGE = (
os.environ.get("ENABLE_OAUTH_TOKEN_EXCHANGE", "False").lower() == "true"
)
####################################
# SCIM Configuration
####################################
@ -673,7 +699,11 @@ WEBSOCKET_SERVER_LOGGING = (
os.environ.get("WEBSOCKET_SERVER_LOGGING", "False").lower() == "true"
)
WEBSOCKET_SERVER_ENGINEIO_LOGGING = (
os.environ.get("WEBSOCKET_SERVER_LOGGING", "False").lower() == "true"
os.environ.get(
"WEBSOCKET_SERVER_ENGINEIO_LOGGING",
os.environ.get("WEBSOCKET_SERVER_LOGGING", "False"),
).lower()
== "true"
)
WEBSOCKET_SERVER_PING_TIMEOUT = os.environ.get("WEBSOCKET_SERVER_PING_TIMEOUT", "20")
try:

View file

@ -290,6 +290,7 @@ from open_webui.config import (
ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER,
TIKTOKEN_ENCODING_NAME,
PDF_EXTRACT_IMAGES,
PDF_LOADER_MODE,
YOUTUBE_LOADER_LANGUAGE,
YOUTUBE_LOADER_PROXY_URL,
# Retrieval (Web Search)
@ -354,6 +355,9 @@ from open_webui.config import (
EXTERNAL_WEB_SEARCH_API_KEY,
EXTERNAL_WEB_LOADER_URL,
EXTERNAL_WEB_LOADER_API_KEY,
YANDEX_WEB_SEARCH_URL,
YANDEX_WEB_SEARCH_API_KEY,
YANDEX_WEB_SEARCH_CONFIG,
# WebUI
WEBUI_AUTH,
WEBUI_NAME,
@ -492,6 +496,7 @@ from open_webui.env import (
WEBUI_ADMIN_EMAIL,
WEBUI_ADMIN_PASSWORD,
WEBUI_ADMIN_NAME,
ENABLE_EASTER_EGGS,
)
@ -948,6 +953,7 @@ app.state.config.RAG_OLLAMA_BASE_URL = RAG_OLLAMA_BASE_URL
app.state.config.RAG_OLLAMA_API_KEY = RAG_OLLAMA_API_KEY
app.state.config.PDF_EXTRACT_IMAGES = PDF_EXTRACT_IMAGES
app.state.config.PDF_LOADER_MODE = PDF_LOADER_MODE
app.state.config.YOUTUBE_LOADER_LANGUAGE = YOUTUBE_LOADER_LANGUAGE
app.state.config.YOUTUBE_LOADER_PROXY_URL = YOUTUBE_LOADER_PROXY_URL
@ -1009,6 +1015,9 @@ app.state.config.EXTERNAL_WEB_SEARCH_URL = EXTERNAL_WEB_SEARCH_URL
app.state.config.EXTERNAL_WEB_SEARCH_API_KEY = EXTERNAL_WEB_SEARCH_API_KEY
app.state.config.EXTERNAL_WEB_LOADER_URL = EXTERNAL_WEB_LOADER_URL
app.state.config.EXTERNAL_WEB_LOADER_API_KEY = EXTERNAL_WEB_LOADER_API_KEY
app.state.config.YANDEX_WEB_SEARCH_URL = YANDEX_WEB_SEARCH_URL
app.state.config.YANDEX_WEB_SEARCH_API_KEY = YANDEX_WEB_SEARCH_API_KEY
app.state.config.YANDEX_WEB_SEARCH_CONFIG = YANDEX_WEB_SEARCH_CONFIG
app.state.config.PLAYWRIGHT_WS_URL = PLAYWRIGHT_WS_URL
@ -1370,6 +1379,13 @@ async def check_url(request: Request, call_next):
request.state.token = get_http_authorization_cred(
request.headers.get("Authorization")
)
# Fallback to cookie token for browser sessions
if request.state.token is None and request.cookies.get("token"):
from fastapi.security import HTTPAuthorizationCredentials
request.state.token = HTTPAuthorizationCredentials(
scheme="Bearer",
credentials=request.cookies.get("token")
)
request.state.enable_api_keys = app.state.config.ENABLE_API_KEYS
response = await call_next(request)
@ -1934,6 +1950,7 @@ async def get_app_config(request: Request):
"enable_websocket": ENABLE_WEBSOCKET_SUPPORT,
"enable_version_update_check": ENABLE_VERSION_UPDATE_CHECK,
"enable_public_active_users_count": ENABLE_PUBLIC_ACTIVE_USERS_COUNT,
"enable_easter_eggs": ENABLE_EASTER_EGGS,
**(
{
"enable_direct_connections": app.state.config.ENABLE_DIRECT_CONNECTIONS,

View file

@ -0,0 +1,248 @@
"""Add prompt history table
Revision ID: 374d2f66af06
Revises: c440947495f3
Create Date: 2026-01-23 17:15:00.000000
"""
from typing import Sequence, Union
import uuid
from alembic import op
import sqlalchemy as sa
revision: str = "374d2f66af06"
down_revision: Union[str, None] = "c440947495f3"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
# Step 1: Read existing data from OLD table (schema likely command as PK)
# We use batch_alter previously, but we want to move to new table.
# We need to assume the OLD structure.
old_prompt_table = sa.table(
"prompt",
sa.column("command", sa.Text()),
sa.column("user_id", sa.Text()),
sa.column("title", sa.Text()),
sa.column("content", sa.Text()),
sa.column("timestamp", sa.BigInteger()),
sa.column("access_control", sa.JSON()),
)
# Check if table exists/read data
try:
existing_prompts = conn.execute(
sa.select(
old_prompt_table.c.command,
old_prompt_table.c.user_id,
old_prompt_table.c.title,
old_prompt_table.c.content,
old_prompt_table.c.timestamp,
old_prompt_table.c.access_control,
)
).fetchall()
except Exception:
# Fallback if table doesn't exist (new install)
existing_prompts = []
# Step 2: Create new prompt table with 'id' as PRIMARY KEY
op.create_table(
"prompt_new",
sa.Column("id", sa.Text(), primary_key=True),
sa.Column("command", sa.String(), unique=True, index=True),
sa.Column("user_id", sa.String(), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("data", sa.JSON(), nullable=True),
sa.Column("meta", sa.JSON(), nullable=True),
sa.Column("access_control", sa.JSON(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("version_id", sa.Text(), nullable=True),
sa.Column("tags", sa.JSON(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.Column("updated_at", sa.BigInteger(), nullable=False),
)
# Step 3: Create prompt_history table
op.create_table(
"prompt_history",
sa.Column("id", sa.Text(), primary_key=True),
sa.Column("prompt_id", sa.Text(), nullable=False, index=True),
sa.Column("parent_id", sa.Text(), nullable=True),
sa.Column("snapshot", sa.JSON(), nullable=False),
sa.Column("user_id", sa.Text(), nullable=False),
sa.Column("commit_message", sa.Text(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=False),
)
# Step 4: Migrate data
prompt_new_table = sa.table(
"prompt_new",
sa.column("id", sa.Text()),
sa.column("command", sa.String()),
sa.column("user_id", sa.String()),
sa.column("name", sa.Text()),
sa.column("content", sa.Text()),
sa.column("data", sa.JSON()),
sa.column("meta", sa.JSON()),
sa.column("access_control", sa.JSON()),
sa.column("is_active", sa.Boolean()),
sa.column("version_id", sa.Text()),
sa.column("tags", sa.JSON()),
sa.column("created_at", sa.BigInteger()),
sa.column("updated_at", sa.BigInteger()),
)
prompt_history_table = sa.table(
"prompt_history",
sa.column("id", sa.Text()),
sa.column("prompt_id", sa.Text()),
sa.column("parent_id", sa.Text()),
sa.column("snapshot", sa.JSON()),
sa.column("user_id", sa.Text()),
sa.column("commit_message", sa.Text()),
sa.column("created_at", sa.BigInteger()),
)
for row in existing_prompts:
command = row[0]
user_id = row[1]
title = row[2]
content = row[3]
timestamp = row[4]
access_control = row[5]
new_uuid = str(uuid.uuid4())
history_uuid = str(uuid.uuid4())
clean_command = command[1:] if command and command.startswith("/") else command
# Insert into prompt_new
conn.execute(
sa.insert(prompt_new_table).values(
id=new_uuid,
command=clean_command,
user_id=user_id,
name=title,
content=content,
data={},
meta={},
access_control=access_control,
is_active=True,
version_id=history_uuid,
tags=[],
created_at=timestamp,
updated_at=timestamp,
)
)
# Create initial history entry
conn.execute(
sa.insert(prompt_history_table).values(
id=history_uuid,
prompt_id=new_uuid,
parent_id=None,
snapshot={
"name": title,
"content": content,
"command": clean_command,
"data": {},
"meta": {},
"access_control": access_control,
},
user_id=user_id,
commit_message=None,
created_at=timestamp,
)
)
# Step 5: Replace old table with new one
op.drop_table("prompt")
op.rename_table("prompt_new", "prompt")
def downgrade() -> None:
conn = op.get_bind()
# Step 1: Read new data
prompt_table = sa.table(
"prompt",
sa.column("command", sa.String()),
sa.column("name", sa.Text()),
sa.column("created_at", sa.BigInteger()),
sa.column("user_id", sa.Text()),
sa.column("content", sa.Text()),
sa.column("access_control", sa.JSON()),
)
try:
current_data = conn.execute(
sa.select(
prompt_table.c.command,
prompt_table.c.name,
prompt_table.c.created_at,
prompt_table.c.user_id,
prompt_table.c.content,
prompt_table.c.access_control,
)
).fetchall()
except Exception:
current_data = []
# Step 2: Drop history and table
op.drop_table("prompt_history")
op.drop_table("prompt")
# Step 3: Recreate old table (command as PK?)
# Assuming old schema:
op.create_table(
"prompt",
sa.Column("command", sa.String(), primary_key=True),
sa.Column("user_id", sa.String()),
sa.Column("title", sa.Text()),
sa.Column("content", sa.Text()),
sa.Column("timestamp", sa.BigInteger()),
sa.Column("access_control", sa.JSON()),
sa.Column("id", sa.Integer(), nullable=True),
)
# Step 4: Restore data
old_prompt_table = sa.table(
"prompt",
sa.column("command", sa.String()),
sa.column("user_id", sa.String()),
sa.column("title", sa.Text()),
sa.column("content", sa.Text()),
sa.column("timestamp", sa.BigInteger()),
sa.column("access_control", sa.JSON()),
)
for row in current_data:
command = row[0]
name = row[1]
created_at = row[2]
user_id = row[3]
content = row[4]
access_control = row[5]
# Restore leading /
old_command = (
"/" + command if command and not command.startswith("/") else command
)
conn.execute(
sa.insert(old_prompt_table).values(
command=old_command,
user_id=user_id,
title=name,
content=content,
timestamp=created_at,
access_control=access_control,
)
)

View file

@ -4,7 +4,7 @@ from typing import Optional
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.users import UserModel, UserProfileImageResponse, Users
from open_webui.models.users import User, UserModel, UserProfileImageResponse, Users
from pydantic import BaseModel
from sqlalchemy import Boolean, Column, String, Text
@ -155,10 +155,17 @@ class AuthsTable:
log.info(f"authenticate_user_by_email: {email}")
try:
with get_db_context(db) as db:
auth = db.query(Auth).filter_by(email=email, active=True).first()
if auth:
user = Users.get_user_by_id(auth.id, db=db)
return user
# Single JOIN query instead of two separate queries
result = (
db.query(Auth, User)
.join(User, Auth.id == User.id)
.filter(Auth.email == email, Auth.active == True)
.first()
)
if result:
_, user = result
return UserModel.model_validate(user)
return None
except Exception:
return None

View file

@ -168,6 +168,14 @@ class ChatTitleIdResponse(BaseModel):
created_at: int
class SharedChatResponse(BaseModel):
id: str
title: str
share_id: Optional[str] = None
updated_at: int
created_at: int
class ChatListResponse(BaseModel):
items: list[ChatModel]
total: int
@ -675,6 +683,49 @@ class ChatTable:
all_chats = query.all()
return [ChatModel.model_validate(chat) for chat in all_chats]
def get_shared_chat_list_by_user_id(
self,
user_id: str,
filter: Optional[dict] = None,
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[ChatModel]:
with get_db_context(db) as db:
query = db.query(Chat).filter_by(user_id=user_id).filter(
Chat.share_id.isnot(None)
)
if filter:
query_key = filter.get("query")
if query_key:
query = query.filter(Chat.title.ilike(f"%{query_key}%"))
order_by = filter.get("order_by")
direction = filter.get("direction")
if order_by and direction:
if not getattr(Chat, order_by, None):
raise ValueError("Invalid order_by field")
if direction.lower() == "asc":
query = query.order_by(getattr(Chat, order_by).asc())
elif direction.lower() == "desc":
query = query.order_by(getattr(Chat, order_by).desc())
else:
raise ValueError("Invalid direction for ordering")
else:
query = query.order_by(Chat.updated_at.desc())
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
all_chats = query.all()
return [ChatModel.model_validate(chat) for chat in all_chats]
def get_chat_list_by_user_id(
self,
user_id: str,

View file

@ -460,23 +460,15 @@ class FeedbackTable:
self, user_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
feedbacks = db.query(Feedback).filter_by(user_id=user_id).all()
if not feedbacks:
return False
for feedback in feedbacks:
db.delete(feedback)
result = db.query(Feedback).filter_by(user_id=user_id).delete()
db.commit()
return True
return result > 0
def delete_all_feedbacks(self, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
feedbacks = db.query(Feedback).all()
if not feedbacks:
return False
for feedback in feedbacks:
db.delete(feedback)
result = db.query(Feedback).delete()
db.commit()
return True
return result > 0
Feedbacks = FeedbackTable()

View file

@ -4,7 +4,7 @@ from typing import Optional
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, model_validator
from sqlalchemy import BigInteger, Column, String, Text, JSON
log = logging.getLogger(__name__)
@ -63,6 +63,25 @@ class FileMeta(BaseModel):
model_config = ConfigDict(extra="allow")
@model_validator(mode="before")
@classmethod
def sanitize_meta(cls, data):
"""Sanitize metadata fields to handle malformed legacy data."""
if not isinstance(data, dict):
return data
# Handle content_type that may be a list like ['application/pdf', None]
content_type = data.get("content_type")
if isinstance(content_type, list):
# Extract first non-None string value
data["content_type"] = next(
(item for item in content_type if isinstance(item, str)), None
)
elif content_type is not None and not isinstance(content_type, str):
data["content_type"] = None
return data
class FileModelResponse(BaseModel):
id: str
@ -74,7 +93,7 @@ class FileModelResponse(BaseModel):
meta: FileMeta
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
updated_at: Optional[int] = None # timestamp in epoch, optional for legacy files
model_config = ConfigDict(extra="allow")

View file

@ -195,6 +195,26 @@ class FunctionsTable:
except Exception:
return None
def get_functions_by_ids(
self, ids: list[str], db: Optional[Session] = None
) -> list[FunctionModel]:
"""
Batch fetch multiple functions by their IDs in a single query.
Returns functions in the same order as the input IDs (None entries filtered out).
"""
if not ids:
return []
try:
with get_db_context(db) as db:
functions = db.query(Function).filter(Function.id.in_(ids)).all()
# Create a dict for O(1) lookup
func_dict = {f.id: FunctionModel.model_validate(f) for f in functions}
# Return in original order, filtering out any not found
return [func_dict[id] for id in ids if id in func_dict]
except Exception:
return []
def get_functions(
self, active_only=False, include_valves=False, db: Optional[Session] = None
) -> list[FunctionModel | FunctionWithValvesModel]:
@ -299,7 +319,7 @@ class FunctionsTable:
function.updated_at = int(time.time())
db.commit()
db.refresh(function)
return self.get_function_by_id(id, db=db)
return FunctionModel.model_validate(function)
except Exception:
return None
@ -319,7 +339,7 @@ class FunctionsTable:
function.updated_at = int(time.time())
db.commit()
db.refresh(function)
return self.get_function_by_id(id, db=db)
return FunctionModel.model_validate(function)
else:
return None
except Exception as e:
@ -381,7 +401,8 @@ class FunctionsTable:
}
)
db.commit()
return self.get_function_by_id(id, db=db)
function = db.get(Function, id)
return FunctionModel.model_validate(function) if function else None
except Exception:
return None

View file

@ -589,11 +589,10 @@ class GroupTable:
if not user_ids:
return GroupModel.model_validate(group)
# Remove each user from group_member
for user_id in user_ids:
db.query(GroupMember).filter(
GroupMember.group_id == id, GroupMember.user_id == user_id
).delete()
# Remove users from group_member in batch
db.query(GroupMember).filter(
GroupMember.group_id == id, GroupMember.user_id.in_(user_ids)
).delete(synchronize_session=False)
# Update group timestamp
group.updated_at = int(time.time())

View file

@ -229,6 +229,9 @@ class KnowledgeTable:
or_(
Knowledge.name.ilike(f"%{query_key}%"),
Knowledge.description.ilike(f"%{query_key}%"),
User.name.ilike(f"%{query_key}%"),
User.email.ilike(f"%{query_key}%"),
User.username.ilike(f"%{query_key}%"),
)
)
@ -240,7 +243,7 @@ class KnowledgeTable:
query = has_permission(db, Knowledge, query, filter)
query = query.order_by(Knowledge.updated_at.desc())
query = query.order_by(Knowledge.updated_at.desc(), Knowledge.id.asc())
total = query.count()
if skip:
@ -300,7 +303,7 @@ class KnowledgeTable:
query = query.filter(File.filename.ilike(f"%{q}%"))
# Order by file changes
query = query.order_by(File.updated_at.desc())
query = query.order_by(File.updated_at.desc(), File.id.asc())
# Count before pagination
total = query.count()
@ -427,6 +430,9 @@ class KnowledgeTable:
.filter(KnowledgeFile.knowledge_id == knowledge_id)
)
# Default sort: updated_at descending
primary_sort = File.updated_at.desc()
if filter:
query_key = filter.get("query")
if query_key:
@ -440,27 +446,17 @@ class KnowledgeTable:
order_by = filter.get("order_by")
direction = filter.get("direction")
is_asc = direction == "asc"
if order_by == "name":
if direction == "asc":
query = query.order_by(File.filename.asc())
else:
query = query.order_by(File.filename.desc())
primary_sort = File.filename.asc() if is_asc else File.filename.desc()
elif order_by == "created_at":
if direction == "asc":
query = query.order_by(File.created_at.asc())
else:
query = query.order_by(File.created_at.desc())
primary_sort = File.created_at.asc() if is_asc else File.created_at.desc()
elif order_by == "updated_at":
if direction == "asc":
query = query.order_by(File.updated_at.asc())
else:
query = query.order_by(File.updated_at.desc())
else:
query = query.order_by(File.updated_at.desc())
primary_sort = File.updated_at.asc() if is_asc else File.updated_at.desc()
else:
query = query.order_by(File.updated_at.desc())
# Apply sort with secondary key for deterministic pagination
query = query.order_by(primary_sort, File.id.asc())
# Count BEFORE pagination
total = query.count()

View file

@ -82,7 +82,8 @@ class MemoriesTable:
memory.updated_at = int(time.time())
db.commit()
return self.get_memory_by_id(id)
db.refresh(memory)
return MemoryModel.model_validate(memory)
except Exception:
return None

View file

@ -294,6 +294,9 @@ class ModelsTable:
or_(
Model.name.ilike(f"%{query_key}%"),
Model.base_model_id.ilike(f"%{query_key}%"),
User.name.ilike(f"%{query_key}%"),
User.email.ilike(f"%{query_key}%"),
User.username.ilike(f"%{query_key}%"),
)
)
@ -391,17 +394,16 @@ class ModelsTable:
) -> Optional[ModelModel]:
with get_db_context(db) as db:
try:
is_active = db.query(Model).filter_by(id=id).first().is_active
model = db.query(Model).filter_by(id=id).first()
if not model:
return None
db.query(Model).filter_by(id=id).update(
{
"is_active": not is_active,
"updated_at": int(time.time()),
}
)
model.is_active = not model.is_active
model.updated_at = int(time.time())
db.commit()
db.refresh(model)
return self.get_model_by_id(id, db=db)
return ModelModel.model_validate(model)
except Exception:
return None

View file

@ -0,0 +1,223 @@
"""Prompt history model for version tracking."""
import time
import uuid
from typing import Optional
import json
import difflib
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db_context
from open_webui.models.users import Users, UserResponse
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, Text, JSON, Index
####################
# PromptHistory DB Schema
####################
class PromptHistory(Base):
__tablename__ = "prompt_history"
id = Column(Text, primary_key=True)
prompt_id = Column(Text, nullable=False, index=True)
parent_id = Column(Text, nullable=True) # Reference to parent commit
snapshot = Column(JSON, nullable=False)
user_id = Column(Text, nullable=False)
commit_message = Column(Text, nullable=True)
created_at = Column(BigInteger, nullable=False)
class PromptHistoryModel(BaseModel):
id: str
prompt_id: str
parent_id: Optional[str] = None
snapshot: dict
user_id: str
commit_message: Optional[str] = None
created_at: int
model_config = ConfigDict(from_attributes=True)
class PromptHistoryResponse(PromptHistoryModel):
"""Response model with user info."""
user: Optional[UserResponse] = None
class PromptHistoryTable:
def create_history_entry(
self,
prompt_id: str,
snapshot: dict,
user_id: str,
parent_id: Optional[str] = None,
commit_message: Optional[str] = None,
db: Optional[Session] = None,
) -> Optional[PromptHistoryModel]:
"""Create a new history entry (commit) for a prompt."""
with get_db_context(db) as db:
history = PromptHistory(
id=str(uuid.uuid4()),
prompt_id=prompt_id,
parent_id=parent_id,
snapshot=snapshot,
user_id=user_id,
commit_message=commit_message,
created_at=int(time.time()),
)
db.add(history)
db.commit()
db.refresh(history)
return PromptHistoryModel.model_validate(history)
def get_history_by_prompt_id(
self,
prompt_id: str,
limit: int = 50,
offset: int = 0,
db: Optional[Session] = None,
) -> list[PromptHistoryResponse]:
"""Get all history entries for a prompt, ordered by created_at desc."""
with get_db_context(db) as db:
entries = (
db.query(PromptHistory)
.filter(PromptHistory.prompt_id == prompt_id)
.order_by(PromptHistory.created_at.desc())
.offset(offset)
.limit(limit)
.all()
)
# Get user info for each entry
user_ids = list(set(e.user_id for e in entries))
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
return [
PromptHistoryResponse(
**PromptHistoryModel.model_validate(entry).model_dump(),
user=users_dict.get(entry.user_id).model_dump() if users_dict.get(entry.user_id) else None,
)
for entry in entries
]
def get_history_entry_by_id(
self,
history_id: str,
db: Optional[Session] = None,
) -> Optional[PromptHistoryModel]:
"""Get a specific history entry by ID."""
with get_db_context(db) as db:
entry = db.query(PromptHistory).filter(PromptHistory.id == history_id).first()
if entry:
return PromptHistoryModel.model_validate(entry)
return None
def get_latest_history_entry(
self,
prompt_id: str,
db: Optional[Session] = None,
) -> Optional[PromptHistoryModel]:
"""Get the most recent history entry for a prompt."""
with get_db_context(db) as db:
entry = (
db.query(PromptHistory)
.filter(PromptHistory.prompt_id == prompt_id)
.order_by(PromptHistory.created_at.desc())
.first()
)
if entry:
return PromptHistoryModel.model_validate(entry)
return None
def get_history_count(
self,
prompt_id: str,
db: Optional[Session] = None,
) -> int:
"""Get the number of history entries for a prompt."""
with get_db_context(db) as db:
return (
db.query(PromptHistory)
.filter(PromptHistory.prompt_id == prompt_id)
.count()
)
def compute_diff(
self,
from_id: str,
to_id: str,
db: Optional[Session] = None,
) -> Optional[dict]:
"""Compute diff between two history entries."""
with get_db_context(db) as db:
from_entry = db.query(PromptHistory).filter(PromptHistory.id == from_id).first()
to_entry = db.query(PromptHistory).filter(PromptHistory.id == to_id).first()
if not from_entry or not to_entry:
return None
from_snapshot = from_entry.snapshot
to_snapshot = to_entry.snapshot
# Compute diff for content field
from_content = from_snapshot.get("content", "")
to_content = to_snapshot.get("content", "")
diff_lines = list(difflib.unified_diff(
from_content.splitlines(keepends=True),
to_content.splitlines(keepends=True),
fromfile=f"v{from_id[:8]}",
tofile=f"v{to_id[:8]}",
lineterm="",
))
return {
"from_id": from_id,
"to_id": to_id,
"from_snapshot": from_snapshot,
"to_snapshot": to_snapshot,
"content_diff": diff_lines,
"name_changed": from_snapshot.get("name") != to_snapshot.get("name"),
"access_control_changed": from_snapshot.get("access_control") != to_snapshot.get("access_control"),
}
def delete_history_by_prompt_id(
self,
prompt_id: str,
db: Optional[Session] = None,
) -> bool:
"""Delete all history entries for a prompt."""
with get_db_context(db) as db:
db.query(PromptHistory).filter(PromptHistory.prompt_id == prompt_id).delete()
db.commit()
return True
def delete_history_entry(
self,
history_id: str,
db: Optional[Session] = None,
) -> bool:
"""Delete a history entry and reparent its children to grandparent."""
with get_db_context(db) as db:
entry = db.query(PromptHistory).filter_by(id=history_id).first()
if not entry:
return False
# Find children that reference this entry as parent
children = db.query(PromptHistory).filter_by(parent_id=history_id).all()
# Reparent children to grandparent
for child in children:
child.parent_id = entry.parent_id
db.delete(entry)
db.commit()
return True
PromptHistories = PromptHistoryTable()

View file

@ -1,16 +1,21 @@
import time
import uuid
from typing import Optional
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.groups import Groups
from open_webui.models.users import Users, UserResponse
from open_webui.models.prompt_history import PromptHistories
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text, JSON
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, or_, func, cast
from open_webui.utils.access_control import has_access
####################
# Prompts DB Schema
####################
@ -19,11 +24,18 @@ from open_webui.utils.access_control import has_access
class Prompt(Base):
__tablename__ = "prompt"
command = Column(String, primary_key=True)
id = Column(Text, primary_key=True)
command = Column(String, unique=True, index=True)
user_id = Column(String)
title = Column(Text)
name = Column(Text)
content = Column(Text)
timestamp = Column(BigInteger)
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
tags = Column(JSON, nullable=True)
is_active = Column(Boolean, default=True)
version_id = Column(Text, nullable=True) # Points to active history entry
created_at = Column(BigInteger, nullable=True)
updated_at = Column(BigInteger, nullable=True)
access_control = Column(JSON, nullable=True) # Controls data access levels.
# Defines access control rules for this entry.
@ -44,13 +56,20 @@ class Prompt(Base):
class PromptModel(BaseModel):
id: Optional[str] = None
command: str
user_id: str
title: str
name: str
content: str
timestamp: int # timestamp in epoch
data: Optional[dict] = None
meta: Optional[dict] = None
tags: Optional[list[str]] = None
is_active: Optional[bool] = True
version_id: Optional[str] = None
created_at: Optional[int] = None
updated_at: Optional[int] = None
access_control: Optional[dict] = None
model_config = ConfigDict(from_attributes=True)
@ -67,23 +86,50 @@ class PromptAccessResponse(PromptUserResponse):
write_access: Optional[bool] = False
class PromptListResponse(BaseModel):
items: list[PromptUserResponse]
total: int
class PromptAccessListResponse(BaseModel):
items: list[PromptAccessResponse]
total: int
class PromptForm(BaseModel):
command: str
title: str
name: str # Changed from title
content: str
data: Optional[dict] = None
meta: Optional[dict] = None
tags: Optional[list[str]] = None
access_control: Optional[dict] = None
version_id: Optional[str] = None # Active version
commit_message: Optional[str] = None # For history tracking
is_production: Optional[bool] = True # Whether to set new version as production
class PromptsTable:
def insert_new_prompt(
self, user_id: str, form_data: PromptForm, db: Optional[Session] = None
) -> Optional[PromptModel]:
now = int(time.time())
prompt_id = str(uuid.uuid4())
prompt = PromptModel(
**{
"user_id": user_id,
**form_data.model_dump(),
"timestamp": int(time.time()),
}
id=prompt_id,
user_id=user_id,
command=form_data.command,
name=form_data.name,
content=form_data.content,
data=form_data.data or {},
meta=form_data.meta or {},
tags=form_data.tags or [],
access_control=form_data.access_control,
is_active=True,
created_at=now,
updated_at=now,
)
try:
@ -92,26 +138,72 @@ class PromptsTable:
db.add(result)
db.commit()
db.refresh(result)
if result:
snapshot = {
"name": form_data.name,
"content": form_data.content,
"command": form_data.command,
"data": form_data.data or {},
"meta": form_data.meta or {},
"tags": form_data.tags or [],
"access_control": form_data.access_control,
}
history_entry = PromptHistories.create_history_entry(
prompt_id=prompt_id,
snapshot=snapshot,
user_id=user_id,
parent_id=None, # Initial commit has no parent
commit_message=form_data.commit_message or "Initial version",
db=db,
)
# Set the initial version as the production version
if history_entry:
result.version_id = history_entry.id
db.commit()
db.refresh(result)
return PromptModel.model_validate(result)
else:
return None
except Exception:
return None
def get_prompt_by_id(
self, prompt_id: str, db: Optional[Session] = None
) -> Optional[PromptModel]:
"""Get prompt by UUID."""
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
if prompt:
return PromptModel.model_validate(prompt)
return None
except Exception:
return None
def get_prompt_by_command(
self, command: str, db: Optional[Session] = None
) -> Optional[PromptModel]:
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(command=command).first()
return PromptModel.model_validate(prompt)
if prompt:
return PromptModel.model_validate(prompt)
return None
except Exception:
return None
def get_prompts(self, db: Optional[Session] = None) -> list[PromptUserResponse]:
with get_db_context(db) as db:
all_prompts = db.query(Prompt).order_by(Prompt.timestamp.desc()).all()
all_prompts = (
db.query(Prompt)
.filter(Prompt.is_active == True)
.order_by(Prompt.updated_at.desc())
.all()
)
user_ids = list(set(prompt.user_id for prompt in all_prompts))
@ -147,17 +239,306 @@ class PromptsTable:
or has_access(user_id, permission, prompt.access_control, user_group_ids)
]
def search_prompts(
self,
user_id: str,
filter: dict = {},
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
) -> PromptListResponse:
with get_db_context(db) as db:
from open_webui.models.users import User, UserModel
# Join with User table for user filtering and sorting
query = db.query(Prompt, User).outerjoin(User, User.id == Prompt.user_id)
query = query.filter(Prompt.is_active == True)
if filter:
query_key = filter.get("query")
if query_key:
query = query.filter(
or_(
Prompt.name.ilike(f"%{query_key}%"),
Prompt.command.ilike(f"%{query_key}%"),
Prompt.content.ilike(f"%{query_key}%"),
User.name.ilike(f"%{query_key}%"),
User.email.ilike(f"%{query_key}%"),
)
)
view_option = filter.get("view_option")
if view_option == "created":
query = query.filter(Prompt.user_id == user_id)
elif view_option == "shared":
query = query.filter(Prompt.user_id != user_id)
# Apply access control filtering
group_ids = filter.get("group_ids", [])
filter_user_id = filter.get("user_id")
if filter_user_id:
# User must have access: owner OR public OR explicit access
access_conditions = [
Prompt.user_id == filter_user_id, # Owner
Prompt.access_control == None, # Public
]
query = query.filter(or_(*access_conditions))
tag = filter.get("tag")
if tag:
# Search for tag in JSON array field
like_pattern = f'%"{tag.lower()}"%'
tags_text = func.lower(cast(Prompt.tags, String))
query = query.filter(tags_text.like(like_pattern))
order_by = filter.get("order_by")
direction = filter.get("direction")
if order_by == "name":
if direction == "asc":
query = query.order_by(Prompt.name.asc())
else:
query = query.order_by(Prompt.name.desc())
elif order_by == "created_at":
if direction == "asc":
query = query.order_by(Prompt.created_at.asc())
else:
query = query.order_by(Prompt.created_at.desc())
elif order_by == "updated_at":
if direction == "asc":
query = query.order_by(Prompt.updated_at.asc())
else:
query = query.order_by(Prompt.updated_at.desc())
else:
query = query.order_by(Prompt.updated_at.desc())
else:
query = query.order_by(Prompt.updated_at.desc())
# Count BEFORE pagination
total = query.count()
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
items = query.all()
prompts = []
for prompt, user in items:
prompts.append(
PromptUserResponse(
**PromptModel.model_validate(prompt).model_dump(),
user=(
UserResponse(**UserModel.model_validate(user).model_dump())
if user
else None
),
)
)
return PromptListResponse(items=prompts, total=total)
def update_prompt_by_command(
self, command: str, form_data: PromptForm, db: Optional[Session] = None
self,
command: str,
form_data: PromptForm,
user_id: str,
db: Optional[Session] = None,
) -> Optional[PromptModel]:
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(command=command).first()
prompt.title = form_data.title
if not prompt:
return None
latest_history = PromptHistories.get_latest_history_entry(
prompt.id, db=db
)
parent_id = latest_history.id if latest_history else None
# Check if content changed to decide on history creation
content_changed = (
prompt.name != form_data.name
or prompt.content != form_data.content
or prompt.access_control != form_data.access_control
)
# Update prompt fields
prompt.name = form_data.name
prompt.content = form_data.content
prompt.data = form_data.data or prompt.data
prompt.meta = form_data.meta or prompt.meta
prompt.access_control = form_data.access_control
prompt.timestamp = int(time.time())
prompt.updated_at = int(time.time())
db.commit()
# Create history entry only if content changed
if content_changed:
snapshot = {
"name": form_data.name,
"content": form_data.content,
"command": command,
"data": form_data.data or {},
"meta": form_data.meta or {},
"access_control": form_data.access_control,
}
history_entry = PromptHistories.create_history_entry(
prompt_id=prompt.id,
snapshot=snapshot,
user_id=user_id,
parent_id=parent_id,
commit_message=form_data.commit_message,
db=db,
)
# Set as production if flag is True (default)
if form_data.is_production and history_entry:
prompt.version_id = history_entry.id
db.commit()
return PromptModel.model_validate(prompt)
except Exception:
return None
def update_prompt_by_id(
self,
prompt_id: str,
form_data: PromptForm,
user_id: str,
db: Optional[Session] = None,
) -> Optional[PromptModel]:
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
if not prompt:
return None
latest_history = PromptHistories.get_latest_history_entry(
prompt.id, db=db
)
parent_id = latest_history.id if latest_history else None
# Check if content changed to decide on history creation
content_changed = (
prompt.name != form_data.name
or prompt.command != form_data.command
or prompt.content != form_data.content
or prompt.access_control != form_data.access_control
or (form_data.tags is not None and prompt.tags != form_data.tags)
)
# Update prompt fields
prompt.name = form_data.name
prompt.command = form_data.command
prompt.content = form_data.content
prompt.data = form_data.data or prompt.data
prompt.meta = form_data.meta or prompt.meta
prompt.access_control = form_data.access_control
if form_data.tags is not None:
prompt.tags = form_data.tags
prompt.updated_at = int(time.time())
db.commit()
# Create history entry only if content changed
if content_changed:
snapshot = {
"name": form_data.name,
"content": form_data.content,
"command": prompt.command,
"data": form_data.data or {},
"meta": form_data.meta or {},
"tags": prompt.tags or [],
"access_control": form_data.access_control,
}
history_entry = PromptHistories.create_history_entry(
prompt_id=prompt.id,
snapshot=snapshot,
user_id=user_id,
parent_id=parent_id,
commit_message=form_data.commit_message,
db=db,
)
# Set as production if flag is True (default)
if form_data.is_production and history_entry:
prompt.version_id = history_entry.id
db.commit()
return PromptModel.model_validate(prompt)
except Exception:
return None
def update_prompt_metadata(
self,
prompt_id: str,
name: str,
command: str,
tags: Optional[list[str]] = None,
db: Optional[Session] = None,
) -> Optional[PromptModel]:
"""Update only name and command (no history created)."""
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
if not prompt:
return None
prompt.name = name
prompt.command = command
if tags is not None:
prompt.tags = tags
prompt.updated_at = int(time.time())
db.commit()
return PromptModel.model_validate(prompt)
except Exception:
return None
def update_prompt_version(
self,
prompt_id: str,
version_id: str,
db: Optional[Session] = None,
) -> Optional[PromptModel]:
"""Set the active version of a prompt and restore content from that version's snapshot."""
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
if not prompt:
return None
history_entry = PromptHistories.get_history_entry_by_id(
version_id, db=db
)
if not history_entry:
return None
# Restore prompt content from the snapshot
snapshot = history_entry.snapshot
if snapshot:
prompt.name = snapshot.get("name", prompt.name)
prompt.content = snapshot.get("content", prompt.content)
prompt.data = snapshot.get("data", prompt.data)
prompt.meta = snapshot.get("meta", prompt.meta)
prompt.tags = snapshot.get("tags", prompt.tags)
# Note: command and access_control are not restored from snapshot
prompt.version_id = version_id
prompt.updated_at = int(time.time())
db.commit()
return PromptModel.model_validate(prompt)
except Exception:
return None
@ -165,14 +546,68 @@ class PromptsTable:
def delete_prompt_by_command(
self, command: str, db: Optional[Session] = None
) -> bool:
"""Soft delete a prompt by setting is_active to False."""
try:
with get_db_context(db) as db:
db.query(Prompt).filter_by(command=command).delete()
db.commit()
prompt = db.query(Prompt).filter_by(command=command).first()
if prompt:
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
return True
prompt.is_active = False
prompt.updated_at = int(time.time())
db.commit()
return True
return False
except Exception:
return False
def delete_prompt_by_id(self, prompt_id: str, db: Optional[Session] = None) -> bool:
"""Soft delete a prompt by setting is_active to False."""
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
if prompt:
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
prompt.is_active = False
prompt.updated_at = int(time.time())
db.commit()
return True
return False
except Exception:
return False
def hard_delete_prompt_by_command(
self, command: str, db: Optional[Session] = None
) -> bool:
"""Permanently delete a prompt and its history."""
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(command=command).first()
if prompt:
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
# Delete prompt
db.query(Prompt).filter_by(command=command).delete()
db.commit()
return True
return False
except Exception:
return False
def get_tags(self, db: Optional[Session] = None) -> list[str]:
try:
with get_db_context(db) as db:
prompts = db.query(Prompt).filter_by(is_active=True).all()
tags = set()
for prompt in prompts:
if prompt.tags:
for tag in prompt.tags:
if tag:
tags.add(tag)
return sorted(list(tags))
except Exception:
return []
Prompts = PromptsTable()

View file

@ -530,9 +530,12 @@ class UsersTable:
) -> Optional[UserModel]:
try:
with get_db_context(db) as db:
db.query(User).filter_by(id=id).update({"role": role})
db.commit()
user = db.query(User).filter_by(id=id).first()
if not user:
return None
user.role = role
db.commit()
db.refresh(user)
return UserModel.model_validate(user)
except Exception:
return None
@ -542,12 +545,13 @@ class UsersTable:
) -> Optional[UserModel]:
try:
with get_db_context(db) as db:
db.query(User).filter_by(id=id).update(
{**form_data.model_dump(exclude_none=True)}
)
db.commit()
user = db.query(User).filter_by(id=id).first()
if not user:
return None
for key, value in form_data.model_dump(exclude_none=True).items():
setattr(user, key, value)
db.commit()
db.refresh(user)
return UserModel.model_validate(user)
except Exception:
return None
@ -557,12 +561,12 @@ class UsersTable:
) -> Optional[UserModel]:
try:
with get_db_context(db) as db:
db.query(User).filter_by(id=id).update(
{"profile_image_url": profile_image_url}
)
db.commit()
user = db.query(User).filter_by(id=id).first()
if not user:
return None
user.profile_image_url = profile_image_url
db.commit()
db.refresh(user)
return UserModel.model_validate(user)
except Exception:
return None
@ -573,12 +577,12 @@ class UsersTable:
) -> Optional[UserModel]:
try:
with get_db_context(db) as db:
db.query(User).filter_by(id=id).update(
{"last_active_at": int(time.time())}
)
db.commit()
user = db.query(User).filter_by(id=id).first()
if not user:
return None
user.last_active_at = int(time.time())
db.commit()
db.refresh(user)
return UserModel.model_validate(user)
except Exception:
return None
@ -620,12 +624,14 @@ class UsersTable:
) -> Optional[UserModel]:
try:
with get_db_context(db) as db:
db.query(User).filter_by(id=id).update(updated)
db.commit()
user = db.query(User).filter_by(id=id).first()
if not user:
return None
for key, value in updated.items():
setattr(user, key, value)
db.commit()
db.refresh(user)
return UserModel.model_validate(user)
# return UserModel(**user.dict())
except Exception as e:
print(e)
return None

View file

@ -143,7 +143,7 @@ class DoclingLoader:
with open(self.file_path, "rb") as f:
headers = {}
if self.api_key:
headers["X-Api-Key"] = f"Bearer {self.api_key}"
headers["X-Api-Key"] = f"{self.api_key}"
r = requests.post(
f"{self.url}/v1/convert/file",
@ -363,7 +363,9 @@ class Loader:
else:
if file_ext == "pdf":
loader = PyPDFLoader(
file_path, extract_images=self.kwargs.get("PDF_EXTRACT_IMAGES")
file_path,
extract_images=self.kwargs.get("PDF_EXTRACT_IMAGES"),
mode=self.kwargs.get("PDF_LOADER_MODE", "page"),
)
elif file_ext == "csv":
loader = CSVLoader(file_path, autodetect_encoding=True)

View file

@ -12,9 +12,13 @@ from open_webui.retrieval.vector.main import (
from open_webui.retrieval.vector.utils import process_metadata
from open_webui.config import (
WEAVIATE_HTTP_HOST,
WEAVIATE_GRPC_HOST,
WEAVIATE_HTTP_PORT,
WEAVIATE_GRPC_PORT,
WEAVIATE_API_KEY,
WEAVIATE_HTTP_SECURE,
WEAVIATE_GRPC_SECURE,
WEAVIATE_SKIP_INIT_CHECKS,
)
@ -52,9 +56,13 @@ class WeaviateClient(VectorDBBase):
try:
# Build connection parameters
connection_params = {
"host": WEAVIATE_HTTP_HOST,
"port": WEAVIATE_HTTP_PORT,
"http_host": WEAVIATE_HTTP_HOST,
"http_port": WEAVIATE_HTTP_PORT,
"http_secure": WEAVIATE_HTTP_SECURE,
"grpc_host": WEAVIATE_GRPC_HOST,
"grpc_port": WEAVIATE_GRPC_PORT,
"grpc_secure": WEAVIATE_GRPC_SECURE,
"skip_init_checks": WEAVIATE_SKIP_INIT_CHECKS,
}
# Only add auth_credentials if WEAVIATE_API_KEY exists and is not empty
@ -63,7 +71,7 @@ class WeaviateClient(VectorDBBase):
weaviate.classes.init.Auth.api_key(WEAVIATE_API_KEY)
)
self.client = weaviate.connect_to_local(**connection_params)
self.client = weaviate.connect_to_custom(**connection_params)
self.client.connect()
except Exception as e:
raise ConnectionError(f"Failed to connect to Weaviate: {e}") from e

View file

@ -0,0 +1,147 @@
import base64
import io
import json
import logging
import os
from typing import Optional, List
import requests
from fastapi import Request
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.utils.headers import include_user_info_headers
from xml.etree import ElementTree as ET
from xml.etree.ElementTree import Element
log = logging.getLogger(__name__)
def xml_element_contents_to_string(element: Element) -> str:
buffer = [element.text if element.text else ""]
for child in element:
buffer.append(xml_element_contents_to_string(child))
buffer.append(element.tail if element.tail else "")
return "".join(buffer)
def search_yandex(
request: Request,
yandex_search_url: str,
yandex_search_api_key: str,
yandex_search_config: str,
query: str,
count: int,
filter_list: Optional[List[str]] = None,
user=None,
) -> List[SearchResult]:
try:
headers = {
"User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot",
"Authorization": f"Api-Key {yandex_search_api_key}",
}
if user is not None:
headers = include_user_info_headers(headers, user)
chat_id = getattr(request.state, "chat_id", None)
if chat_id:
headers["X-OpenWebUI-Chat-Id"] = str(chat_id)
payload = {} if yandex_search_config == "" else json.loads(yandex_search_config)
if type(payload.get("query", None)) != dict:
payload["query"] = {}
if "searchType" not in payload["query"]:
payload["query"]["searchType"] = "SEARCH_TYPE_RU"
payload["query"]["queryText"] = query
if type(payload.get("groupSpec", None)) != dict:
payload["groupSpec"] = {}
if "groupMode" not in payload["groupSpec"]:
payload["groupSpec"]["groupMode"] = "GROUP_MODE_DEEP"
payload["groupSpec"]["groupsOnPage"] = count
payload["groupSpec"]["docsInGroup"] = 1
response = requests.post(
"https://searchapi.api.cloud.yandex.net/v2/web/search" if yandex_search_url == "" else yandex_search_url,
headers=headers,
json=payload,
)
response.raise_for_status()
response_body = response.json()
if "rawData" not in response_body:
raise Exception(f"No `rawData` in response body: {response_body}")
search_result_body_bytes = base64.decodebytes(bytes(response_body["rawData"], "utf-8"))
doc_root = ET.parse(io.BytesIO(search_result_body_bytes))
results = []
for group in doc_root.findall("response/results/grouping/group"):
results.append({
"url": xml_element_contents_to_string(group.find("doc/url")).strip("\n"),
"title": xml_element_contents_to_string(group.find("doc/title")).strip("\n"),
"snippet": xml_element_contents_to_string(group.find("doc/passages/passage")),
})
results = get_filtered_results(results, filter_list)
results = [
SearchResult(
link=result.get("url"),
title=result.get("title"),
snippet=result.get("snippet"),
)
for result in results[:count]
]
log.info(f"Yandex search results: {results}")
return results
except Exception as e:
log.error(f"Error in search: {e}")
return []
if __name__ == "__main__":
from starlette.datastructures import Headers
from fastapi import FastAPI
result = search_yandex(
Request(
{
"type": "http",
"asgi.version": "3.0",
"asgi.spec_version": "2.0",
"method": "GET",
"path": "/internal",
"query_string": b"",
"headers": Headers({}).raw,
"client": ("127.0.0.1", 12345),
"server": ("127.0.0.1", 80),
"scheme": "http",
"app": FastAPI(),
},
None,
),
os.environ.get("YANDEX_WEB_SEARCH_URL", ""),
os.environ.get("YANDEX_WEB_SEARCH_API_KEY", ""),
os.environ.get("YANDEX_WEB_SEARCH_CONFIG", "{\"query\": {\"searchType\": \"SEARCH_TYPE_COM\"}}"),
"TOP movies of the past year",
3,
)
print(result)

View file

@ -37,6 +37,8 @@ from open_webui.env import (
WEBUI_AUTH_COOKIE_SECURE,
WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
ENABLE_INITIAL_ADMIN_SIGNUP,
ENABLE_OAUTH_TOKEN_EXCHANGE,
AIOHTTP_CLIENT_SESSION_SSL,
)
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse, Response, JSONResponse
@ -45,6 +47,8 @@ from open_webui.config import (
ENABLE_OAUTH_SIGNUP,
ENABLE_LDAP,
ENABLE_PASSWORD_AUTH,
OAUTH_PROVIDERS,
OAUTH_MERGE_ACCOUNTS_BY_EMAIL,
)
from pydantic import BaseModel
@ -87,6 +91,63 @@ signin_rate_limiter = RateLimiter(
redis_client=get_redis_client(), limit=5 * 3, window=60 * 3
)
def create_session_response(
request: Request, user, db, response: Response = None, set_cookie: bool = False
) -> dict:
"""
Create JWT token and build session response for a user.
Shared helper for signin, signup, ldap_auth, add_user, and token_exchange endpoints.
Args:
request: FastAPI request object
user: User object
db: Database session
response: FastAPI response object (required if set_cookie is True)
set_cookie: Whether to set the auth cookie on the response
"""
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
expires_at = None
if expires_delta:
expires_at = int(time.time()) + int(expires_delta.total_seconds())
token = create_token(
data={"id": user.id},
expires_delta=expires_delta,
)
if set_cookie and response:
datetime_expires_at = (
datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc)
if expires_at
else None
)
response.set_cookie(
key="token",
value=token,
expires=datetime_expires_at,
httponly=True,
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
user_permissions = get_permissions(
user.id, request.app.state.config.USER_PERMISSIONS, db=db
)
return {
"token": token,
"token_type": "Bearer",
"expires_at": expires_at,
"id": user.id,
"email": user.email,
"name": user.name,
"role": user.role,
"profile_image_url": user.profile_image_url,
"permissions": user_permissions,
}
############################
# GetSessionUser
############################
@ -482,36 +543,6 @@ async def ldap_auth(
user = Auths.authenticate_user_by_email(email, db=db)
if user:
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
expires_at = None
if expires_delta:
expires_at = int(time.time()) + int(expires_delta.total_seconds())
token = create_token(
data={"id": user.id},
expires_delta=expires_delta,
)
# Set the cookie token
response.set_cookie(
key="token",
value=token,
expires=(
datetime.datetime.fromtimestamp(
expires_at, datetime.timezone.utc
)
if expires_at
else None
),
httponly=True, # Ensures the cookie is not accessible via JavaScript
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
user_permissions = get_permissions(
user.id, request.app.state.config.USER_PERMISSIONS, db=db
)
if (
user.role != "admin"
and ENABLE_LDAP_GROUP_MANAGEMENT
@ -527,17 +558,7 @@ async def ldap_auth(
except Exception as e:
log.error(f"Failed to sync groups for user {user.id}: {e}")
return {
"token": token,
"token_type": "Bearer",
"expires_at": expires_at,
"id": user.id,
"email": user.email,
"name": user.name,
"role": user.role,
"profile_image_url": user.profile_image_url,
"permissions": user_permissions,
}
return create_session_response(request, user, db, response, set_cookie=True)
else:
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
else:
@ -646,48 +667,7 @@ async def signin(
)
if user:
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
expires_at = None
if expires_delta:
expires_at = int(time.time()) + int(expires_delta.total_seconds())
token = create_token(
data={"id": user.id},
expires_delta=expires_delta,
)
datetime_expires_at = (
datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc)
if expires_at
else None
)
# Set the cookie token
response.set_cookie(
key="token",
value=token,
expires=datetime_expires_at,
httponly=True, # Ensures the cookie is not accessible via JavaScript
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
user_permissions = get_permissions(
user.id, request.app.state.config.USER_PERMISSIONS, db=db
)
return {
"token": token,
"token_type": "Bearer",
"expires_at": expires_at,
"id": user.id,
"email": user.email,
"name": user.name,
"role": user.role,
"profile_image_url": user.profile_image_url,
"permissions": user_permissions,
}
return create_session_response(request, user, db, response, set_cookie=True)
else:
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
@ -748,32 +728,6 @@ async def signup(
)
if user:
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
expires_at = None
if expires_delta:
expires_at = int(time.time()) + int(expires_delta.total_seconds())
token = create_token(
data={"id": user.id},
expires_delta=expires_delta,
)
datetime_expires_at = (
datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc)
if expires_at
else None
)
# Set the cookie token
response.set_cookie(
key="token",
value=token,
expires=datetime_expires_at,
httponly=True, # Ensures the cookie is not accessible via JavaScript
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
if request.app.state.config.WEBHOOK_URL:
await post_webhook(
request.app.state.WEBUI_NAME,
@ -786,10 +740,6 @@ async def signup(
},
)
user_permissions = get_permissions(
user.id, request.app.state.config.USER_PERMISSIONS, db=db
)
if not has_users:
# Disable signup after the first user is created
request.app.state.config.ENABLE_SIGNUP = False
@ -800,19 +750,11 @@ async def signup(
db=db,
)
return {
"token": token,
"token_type": "Bearer",
"expires_at": expires_at,
"id": user.id,
"email": user.email,
"name": user.name,
"role": user.role,
"profile_image_url": user.profile_image_url,
"permissions": user_permissions,
}
return create_session_response(request, user, db, response, set_cookie=True)
else:
raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
except HTTPException:
raise
except Exception as err:
log.error(f"Signup error: {str(err)}")
raise HTTPException(500, detail="An internal error occurred during signup.")
@ -954,6 +896,8 @@ async def add_user(
}
else:
raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
except HTTPException:
raise
except Exception as err:
log.error(f"Add user error: {str(err)}")
raise HTTPException(
@ -1283,3 +1227,108 @@ async def get_api_key(
}
else:
raise HTTPException(404, detail=ERROR_MESSAGES.API_KEY_NOT_FOUND)
############################
# Token Exchange
############################
class TokenExchangeForm(BaseModel):
token: str # OAuth access token from external provider
@router.post("/oauth/{provider}/token/exchange", response_model=SessionUserResponse)
async def token_exchange(
request: Request,
response: Response,
provider: str,
form_data: TokenExchangeForm,
db: Session = Depends(get_session),
):
"""
Exchange an external OAuth provider token for an OpenWebUI JWT.
This endpoint is disabled by default. Set ENABLE_OAUTH_TOKEN_EXCHANGE=True to enable.
"""
if not ENABLE_OAUTH_TOKEN_EXCHANGE:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Token exchange is disabled",
)
provider = provider.lower()
# Check if provider is configured
if provider not in OAUTH_PROVIDERS:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Provider '{provider}' is not configured",
)
# Get the OAuth client for this provider
oauth_manager = request.app.state.oauth_manager
client = oauth_manager.get_client(provider)
if not client:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"OAuth client for '{provider}' not found",
)
# Validate the token by calling the userinfo endpoint
try:
token_data = {"access_token": form_data.token, "token_type": "Bearer"}
user_data = await client.userinfo(token=token_data)
if not user_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid token or unable to fetch user info",
)
except Exception as e:
log.warning(f"Token exchange failed for provider {provider}: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid token or unable to validate with provider",
)
# Extract user information from the token claims
email_claim = request.app.state.config.OAUTH_EMAIL_CLAIM
username_claim = request.app.state.config.OAUTH_USERNAME_CLAIM
# Get sub claim
sub = user_data.get(
request.app.state.config.OAUTH_SUB_CLAIM
or OAUTH_PROVIDERS[provider].get("sub_claim", "sub")
)
if not sub:
log.warning(f"Token exchange failed: sub claim missing from user data")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Token missing required 'sub' claim",
)
email = user_data.get(email_claim, "")
if not email:
log.warning(f"Token exchange failed: email claim missing from user data")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Token missing required email claim",
)
email = email.lower()
# Try to find the user by OAuth sub
user = Users.get_user_by_oauth_sub(provider, sub, db=db)
if not user and OAUTH_MERGE_ACCOUNTS_BY_EMAIL.value:
# Try to find by email if merge is enabled
user = Users.get_user_by_email(email, db=db)
if user:
# Link the OAuth sub to this user
Users.update_user_oauth_by_id(user.id, provider, sub, db=db)
if not user:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User not found. Please sign in via the web interface first.",
)
return create_session_response(request, user, db)

View file

@ -22,6 +22,7 @@ from open_webui.models.users import (
UserListResponse,
UserModelResponse,
Users,
UserModel,
UserNameResponse,
)
@ -80,7 +81,7 @@ router = APIRouter()
############################
def check_channels_access(request: Request):
def check_channels_access(request: Request, user: Optional[UserModel] = None):
"""Dependency to ensure channels are globally enabled."""
if not request.app.state.config.ENABLE_CHANNELS:
raise HTTPException(
@ -88,6 +89,15 @@ def check_channels_access(request: Request):
detail="Channels are not enabled",
)
if user:
if user.role != "admin" and not has_permission(
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
############################
# GetChatList
@ -355,7 +365,7 @@ async def get_channel_by_id(
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
check_channels_access(request, user)
channel = Channels.get_channel_by_id(id, db=db)
if not channel:
raise HTTPException(
@ -467,7 +477,7 @@ async def get_channel_members_by_id(
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
check_channels_access(request, user)
channel = Channels.get_channel_by_id(id, db=db)
if not channel:
@ -788,7 +798,7 @@ async def get_channel_messages(
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
check_channels_access(request, user)
channel = Channels.get_channel_by_id(id, db=db)
if not channel:
raise HTTPException(

View file

@ -16,6 +16,7 @@ from open_webui.models.chats import (
ChatResponse,
Chats,
ChatTitleIdResponse,
SharedChatResponse,
ChatStatsExport,
AggregateChatStats,
ChatBody,
@ -357,9 +358,7 @@ def _process_chat_for_export(chat) -> Optional[ChatStatsExport]:
return None
def calculate_chat_stats(
user_id, skip=0, limit=10, filter=None, db: Optional[Session] = None
):
def calculate_chat_stats(user_id, skip=0, limit=10, filter=None):
if filter is None:
filter = {}
@ -368,7 +367,6 @@ def calculate_chat_stats(
skip=skip,
limit=limit,
filter=filter,
db=db,
)
chat_stats_export_list = []
@ -424,7 +422,6 @@ async def export_chat_stats(
page: Optional[int] = 1,
stream: bool = False,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
# Check if the user has permission to share/export chats
if (user.role != "admin") and (
@ -455,7 +452,7 @@ async def export_chat_stats(
skip = (page - 1) * limit
chat_stats_export_list, total = await asyncio.to_thread(
calculate_chat_stats, user.id, skip, limit, filter, db=db
calculate_chat_stats, user.id, skip, limit, filter
)
return ChatStatsExportList(
@ -862,6 +859,48 @@ async def unarchive_all_chats(
return Chats.unarchive_all_chats_by_user_id(user.id, db=db)
############################
# GetSharedChats
############################
@router.get("/shared", response_model=list[SharedChatResponse])
async def get_shared_session_user_chat_list(
page: Optional[int] = None,
query: Optional[str] = None,
order_by: Optional[str] = None,
direction: Optional[str] = None,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if page is None:
page = 1
limit = 60
skip = (page - 1) * limit
filter = {}
if query:
filter["query"] = query
if order_by:
filter["order_by"] = order_by
if direction:
filter["direction"] = direction
chat_list = [
SharedChatResponse(**chat.model_dump())
for chat in Chats.get_shared_chat_list_by_user_id(
user.id,
filter=filter,
skip=skip,
limit=limit,
db=db,
)
]
return chat_list
############################
# GetSharedChatById
############################

View file

@ -224,7 +224,7 @@ async def verify_tool_servers_config(
try:
if form_data.type == "mcp":
if form_data.auth_type == "oauth_2.1":
discovery_urls = get_discovery_urls(form_data.url)
discovery_urls = await get_discovery_urls(form_data.url)
for discovery_url in discovery_urls:
log.debug(
f"Trying to fetch OAuth 2.1 discovery document from {discovery_url}"

View file

@ -282,7 +282,11 @@ def upload_file_handler(
},
"meta": {
"name": name,
"content_type": file.content_type,
"content_type": (
file.content_type
if isinstance(file.content_type, str)
else None
),
"size": len(contents),
"data": file_metadata,
},
@ -332,6 +336,8 @@ def upload_file_handler(
detail=ERROR_MESSAGES.DEFAULT("Error uploading file"),
)
except HTTPException as e:
raise e
except Exception as e:
log.exception(e)
raise HTTPException(
@ -575,7 +581,7 @@ class ContentForm(BaseModel):
@router.post("/{id}/data/content/update")
async def update_file_data_content_by_id(
def update_file_data_content_by_id(
request: Request,
id: str,
form_data: ContentForm,
@ -825,6 +831,23 @@ async def delete_file_by_id(
or has_access_to_file(id, "write", user, db=db)
):
# Clean up KB associations and embeddings before deleting
knowledges = Knowledges.get_knowledges_by_file_id(id, db=db)
for knowledge in knowledges:
# Remove KB-file relationship
Knowledges.remove_file_from_knowledge_by_id(knowledge.id, id, db=db)
# Clean KB embeddings (same logic as /knowledge/{id}/file/remove)
try:
VECTOR_DB_CLIENT.delete(
collection_name=knowledge.id, filter={"file_id": id}
)
if file.hash:
VECTOR_DB_CLIENT.delete(
collection_name=knowledge.id, filter={"hash": file.hash}
)
except Exception as e:
log.debug(f"KB embedding cleanup for {knowledge.id}: {e}")
result = Files.delete_file_by_id(id, db=db)
if result:
try:

View file

@ -19,6 +19,7 @@ from open_webui.utils.plugin import (
load_function_module_by_id,
replace_imports,
get_function_module_from_cache,
resolve_valves_schema_options,
)
from open_webui.config import CACHE_DIR
from open_webui.constants import ERROR_MESSAGES
@ -446,7 +447,10 @@ async def get_function_valves_spec_by_id(
if hasattr(function_module, "Valves"):
Valves = function_module.Valves
return Valves.schema()
schema = Valves.schema()
# Resolve dynamic options for select dropdowns
schema = resolve_valves_schema_options(Valves, schema, user)
return schema
return None
else:
raise HTTPException(
@ -546,7 +550,10 @@ async def get_function_user_valves_spec_by_id(
if hasattr(function_module, "UserValves"):
UserValves = function_module.UserValves
return UserValves.schema()
schema = UserValves.schema()
# Resolve dynamic options for select dropdowns
schema = resolve_valves_schema_options(UserValves, schema, user)
return schema
return None
else:
raise HTTPException(

View file

@ -16,6 +16,7 @@ from fastapi.responses import FileResponse
from open_webui.config import CACHE_DIR
from open_webui.constants import ERROR_MESSAGES
from open_webui.retrieval.web.utils import validate_url
from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS
from open_webui.models.chats import Chats
@ -881,6 +882,8 @@ async def image_edits(
return data
if data.startswith("http://") or data.startswith("https://"):
# Validate URL to prevent SSRF attacks against local/private networks
validate_url(data)
r = await asyncio.to_thread(requests.get, data)
r.raise_for_status()
@ -910,7 +913,8 @@ async def image_edits(
if isinstance(form_data.image, str):
form_data.image = await load_url_image(form_data.image)
elif isinstance(form_data.image, list):
form_data.image = [await load_url_image(img) for img in form_data.image]
# Load all images in parallel for better performance
form_data.image = list(await asyncio.gather(*[load_url_image(img) for img in form_data.image]))
except Exception as e:
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e))

View file

@ -5,18 +5,40 @@ from open_webui.models.prompts import (
PromptForm,
PromptUserResponse,
PromptAccessResponse,
PromptAccessListResponse,
PromptModel,
Prompts,
)
from open_webui.models.groups import Groups
from open_webui.models.prompt_history import (
PromptHistories,
PromptHistoryModel,
PromptHistoryResponse,
)
from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
from pydantic import BaseModel
class PromptVersionUpdateForm(BaseModel):
version_id: str
class PromptMetadataForm(BaseModel):
name: str
command: str
tags: Optional[list[str]] = None
router = APIRouter()
PAGE_ITEM_COUNT = 30
############################
# GetPrompts
############################
@ -34,26 +56,72 @@ async def get_prompts(
return prompts
@router.get("/list", response_model=list[PromptAccessResponse])
async def get_prompt_list(
@router.get("/tags", response_model=list[str])
async def get_prompt_tags(
user=Depends(get_verified_user), db: Session = Depends(get_session)
):
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
prompts = Prompts.get_prompts(db=db)
return Prompts.get_tags(db=db)
else:
prompts = Prompts.get_prompts_by_user_id(user.id, "read", db=db)
tags = set()
for prompt in prompts:
if prompt.tags:
tags.update(prompt.tags)
return sorted(list(tags))
return [
PromptAccessResponse(
**prompt.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == prompt.user_id
or has_access(user.id, "write", prompt.access_control, db=db)
),
)
for prompt in prompts
]
@router.get("/list", response_model=PromptAccessListResponse)
async def get_prompt_list(
query: Optional[str] = None,
view_option: Optional[str] = None,
tag: Optional[str] = None,
order_by: Optional[str] = None,
direction: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
limit = PAGE_ITEM_COUNT
page = max(1, page)
skip = (page - 1) * limit
filter = {}
if query:
filter["query"] = query
if view_option:
filter["view_option"] = view_option
if tag:
filter["tag"] = tag
if order_by:
filter["order_by"] = order_by
if direction:
filter["direction"] = direction
if not (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL):
groups = Groups.get_groups_by_member_id(user.id, db=db)
if groups:
filter["group_ids"] = [group.id for group in groups]
filter["user_id"] = user.id
result = Prompts.search_prompts(user.id, filter=filter, skip=skip, limit=limit, db=db)
return PromptAccessListResponse(
items=[
PromptAccessResponse(
**prompt.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == prompt.user_id
or has_access(user.id, "write", prompt.access_control, db=db)
),
)
for prompt in result.items
],
total=result.total,
)
############################
@ -112,7 +180,7 @@ async def create_new_prompt(
async def get_prompt_by_command(
command: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
prompt = Prompts.get_prompt_by_command(f"/{command}", db=db)
prompt = Prompts.get_prompt_by_command(command, db=db)
if prompt:
if (
@ -128,29 +196,62 @@ async def get_prompt_by_command(
or has_access(user.id, "write", prompt.access_control, db=db)
),
)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.NOT_FOUND,
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
############################
# UpdatePromptByCommand
# GetPromptById
############################
@router.post("/command/{command}/update", response_model=Optional[PromptModel])
async def update_prompt_by_command(
command: str,
@router.get("/id/{prompt_id}", response_model=Optional[PromptAccessResponse])
async def get_prompt_by_id(
prompt_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
if prompt:
if (
user.role == "admin"
or prompt.user_id == user.id
or has_access(user.id, "read", prompt.access_control, db=db)
):
return PromptAccessResponse(
**prompt.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == prompt.user_id
or has_access(user.id, "write", prompt.access_control, db=db)
),
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
############################
# UpdatePromptById
############################
@router.post("/id/{prompt_id}/update", response_model=Optional[PromptModel])
async def update_prompt_by_id(
prompt_id: str,
form_data: PromptForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
prompt = Prompts.get_prompt_by_command(f"/{command}", db=db)
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
if not prompt:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
@ -165,29 +266,46 @@ async def update_prompt_by_command(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
prompt = Prompts.update_prompt_by_command(f"/{command}", form_data, db=db)
if prompt:
return prompt
# Check for command collision if command is being changed
if form_data.command != prompt.command:
existing_prompt = Prompts.get_prompt_by_command(form_data.command, db=db)
if existing_prompt and existing_prompt.id != prompt.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Command '/{form_data.command}' is already in use by another prompt",
)
# Use the ID from the found prompt
updated_prompt = Prompts.update_prompt_by_id(
prompt.id, form_data, user.id, db=db
)
if updated_prompt:
return updated_prompt
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(),
)
############################
# DeletePromptByCommand
# UpdatePromptMetadata
############################
@router.delete("/command/{command}/delete", response_model=bool)
async def delete_prompt_by_command(
command: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
@router.post("/id/{prompt_id}/update/meta", response_model=Optional[PromptModel])
async def update_prompt_metadata(
prompt_id: str,
form_data: PromptMetadataForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
prompt = Prompts.get_prompt_by_command(f"/{command}", db=db)
"""Update prompt name and command only (no history created)."""
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
if not prompt:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
@ -201,5 +319,252 @@ async def delete_prompt_by_command(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
result = Prompts.delete_prompt_by_command(f"/{command}", db=db)
# Check for command collision if command is being changed
if form_data.command != prompt.command:
existing_prompt = Prompts.get_prompt_by_command(form_data.command, db=db)
if existing_prompt and existing_prompt.id != prompt.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Command '/{form_data.command}' is already in use",
)
updated_prompt = Prompts.update_prompt_metadata(
prompt.id, form_data.name, form_data.command, form_data.tags, db=db
)
if updated_prompt:
return updated_prompt
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(),
)
@router.post("/id/{prompt_id}/update/version", response_model=Optional[PromptModel])
async def set_prompt_version(
prompt_id: str,
form_data: PromptVersionUpdateForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
if not prompt:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
prompt.user_id != user.id
and not has_access(user.id, "write", prompt.access_control, db=db)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
updated_prompt = Prompts.update_prompt_version(
prompt.id, form_data.version_id, db=db
)
if updated_prompt:
return updated_prompt
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(),
)
############################
# DeletePromptById
############################
@router.delete("/id/{prompt_id}/delete", response_model=bool)
async def delete_prompt_by_id(
prompt_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
if not prompt:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
prompt.user_id != user.id
and not has_access(user.id, "write", prompt.access_control, db=db)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
result = Prompts.delete_prompt_by_id(prompt.id, db=db)
return result
############################
# Prompt History Endpoints
############################
@router.get("/id/{prompt_id}/history", response_model=list[PromptHistoryResponse])
async def get_prompt_history(
prompt_id: str,
page: int = 0,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
"""Get version history for a prompt."""
PAGE_SIZE = 20
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
if not prompt:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
# Check read access
if not (
user.role == "admin"
or prompt.user_id == user.id
or has_access(user.id, "read", prompt.access_control, db=db)
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
history = PromptHistories.get_history_by_prompt_id(
prompt.id, limit=PAGE_SIZE, offset=page * PAGE_SIZE, db=db
)
return history
@router.get(
"/id/{prompt_id}/history/{history_id}", response_model=PromptHistoryModel
)
async def get_prompt_history_entry(
prompt_id: str,
history_id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
"""Get a specific version from history."""
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
if not prompt:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
# Check read access
if not (
user.role == "admin"
or prompt.user_id == user.id
or has_access(user.id, "read", prompt.access_control, db=db)
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
history_entry = PromptHistories.get_history_entry_by_id(history_id, db=db)
if not history_entry or history_entry.prompt_id != prompt.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
return history_entry
@router.delete(
"/id/{prompt_id}/history/{history_id}", response_model=bool
)
async def delete_prompt_history_entry(
prompt_id: str,
history_id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
"""Delete a history entry. Cannot delete the active production version."""
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
if not prompt:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
# Check write access
if not (
user.role == "admin"
or prompt.user_id == user.id
or has_access(user.id, "write", prompt.access_control, db=db)
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
# Cannot delete active production version
if prompt.version_id == history_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete the active production version",
)
success = PromptHistories.delete_history_entry(history_id, db=db)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
return success
@router.get("/id/{prompt_id}/history/diff")
async def get_prompt_diff(
prompt_id: str,
from_id: str,
to_id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
"""Get diff between two versions."""
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
if not prompt:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
# Check read access
if not (
user.role == "admin"
or prompt.user_id == user.id
or has_access(user.id, "read", prompt.access_control, db=db)
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
diff = PromptHistories.compute_diff(from_id, to_id, db=db)
if not diff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="One or both history entries not found",
)
return diff

View file

@ -39,7 +39,7 @@ from langchain_core.documents import Document
from open_webui.models.files import FileModel, FileUpdateForm, Files
from open_webui.models.knowledge import Knowledges
from open_webui.storage.provider import Storage
from open_webui.internal.db import get_session
from open_webui.internal.db import get_session, get_db
from sqlalchemy.orm import Session
@ -76,6 +76,7 @@ from open_webui.retrieval.web.perplexity import search_perplexity
from open_webui.retrieval.web.sougou import search_sougou
from open_webui.retrieval.web.firecrawl import search_firecrawl
from open_webui.retrieval.web.external import search_external
from open_webui.retrieval.web.yandex import search_yandex
from open_webui.retrieval.utils import (
get_content_from_url,
@ -468,6 +469,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
# Content extraction settings
"CONTENT_EXTRACTION_ENGINE": request.app.state.config.CONTENT_EXTRACTION_ENGINE,
"PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES,
"PDF_LOADER_MODE": request.app.state.config.PDF_LOADER_MODE,
"DATALAB_MARKER_API_KEY": request.app.state.config.DATALAB_MARKER_API_KEY,
"DATALAB_MARKER_API_BASE_URL": request.app.state.config.DATALAB_MARKER_API_BASE_URL,
"DATALAB_MARKER_ADDITIONAL_CONFIG": request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG,
@ -579,6 +581,9 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"YOUTUBE_LOADER_LANGUAGE": request.app.state.config.YOUTUBE_LOADER_LANGUAGE,
"YOUTUBE_LOADER_PROXY_URL": request.app.state.config.YOUTUBE_LOADER_PROXY_URL,
"YOUTUBE_LOADER_TRANSLATION": request.app.state.YOUTUBE_LOADER_TRANSLATION,
"YANDEX_WEB_SEARCH_URL": request.app.state.config.YANDEX_WEB_SEARCH_URL,
"YANDEX_WEB_SEARCH_API_KEY": request.app.state.config.YANDEX_WEB_SEARCH_API_KEY,
"YANDEX_WEB_SEARCH_CONFIG": request.app.state.config.YANDEX_WEB_SEARCH_CONFIG,
},
}
@ -642,6 +647,9 @@ class WebConfig(BaseModel):
YOUTUBE_LOADER_LANGUAGE: Optional[List[str]] = None
YOUTUBE_LOADER_PROXY_URL: Optional[str] = None
YOUTUBE_LOADER_TRANSLATION: Optional[str] = None
YANDEX_WEB_SEARCH_URL: Optional[str] = None
YANDEX_WEB_SEARCH_API_KEY: Optional[str] = None
YANDEX_WEB_SEARCH_CONFIG: Optional[str] = None
class ConfigForm(BaseModel):
@ -661,6 +669,7 @@ class ConfigForm(BaseModel):
# Content extraction settings
CONTENT_EXTRACTION_ENGINE: Optional[str] = None
PDF_EXTRACT_IMAGES: Optional[bool] = None
PDF_LOADER_MODE: Optional[str] = None
DATALAB_MARKER_API_KEY: Optional[str] = None
DATALAB_MARKER_API_BASE_URL: Optional[str] = None
@ -790,6 +799,11 @@ async def update_rag_config(
if form_data.PDF_EXTRACT_IMAGES is not None
else request.app.state.config.PDF_EXTRACT_IMAGES
)
request.app.state.config.PDF_LOADER_MODE = (
form_data.PDF_LOADER_MODE
if form_data.PDF_LOADER_MODE is not None
else request.app.state.config.PDF_LOADER_MODE
)
request.app.state.config.DATALAB_MARKER_API_KEY = (
form_data.DATALAB_MARKER_API_KEY
if form_data.DATALAB_MARKER_API_KEY is not None
@ -1020,6 +1034,11 @@ async def update_rag_config(
if form_data.TEXT_SPLITTER is not None
else request.app.state.config.TEXT_SPLITTER
)
request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER = (
form_data.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER
if form_data.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER is not None
else request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER
)
request.app.state.config.CHUNK_SIZE = (
form_data.CHUNK_SIZE
if form_data.CHUNK_SIZE is not None
@ -1178,6 +1197,15 @@ async def update_rag_config(
request.app.state.YOUTUBE_LOADER_TRANSLATION = (
form_data.web.YOUTUBE_LOADER_TRANSLATION
)
request.app.state.config.YANDEX_WEB_SEARCH_URL = (
form_data.web.YANDEX_WEB_SEARCH_URL
)
request.app.state.config.YANDEX_WEB_SEARCH_API_KEY = (
form_data.web.YANDEX_WEB_SEARCH_API_KEY
)
request.app.state.config.YANDEX_WEB_SEARCH_CONFIG = (
form_data.web.YANDEX_WEB_SEARCH_CONFIG
)
return {
"status": True,
@ -1194,6 +1222,7 @@ async def update_rag_config(
# Content extraction settings
"CONTENT_EXTRACTION_ENGINE": request.app.state.config.CONTENT_EXTRACTION_ENGINE,
"PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES,
"PDF_LOADER_MODE": request.app.state.config.PDF_LOADER_MODE,
"DATALAB_MARKER_API_KEY": request.app.state.config.DATALAB_MARKER_API_KEY,
"DATALAB_MARKER_API_BASE_URL": request.app.state.config.DATALAB_MARKER_API_BASE_URL,
"DATALAB_MARKER_ADDITIONAL_CONFIG": request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG,
@ -1303,6 +1332,9 @@ async def update_rag_config(
"YOUTUBE_LOADER_LANGUAGE": request.app.state.config.YOUTUBE_LOADER_LANGUAGE,
"YOUTUBE_LOADER_PROXY_URL": request.app.state.config.YOUTUBE_LOADER_PROXY_URL,
"YOUTUBE_LOADER_TRANSLATION": request.app.state.YOUTUBE_LOADER_TRANSLATION,
"YANDEX_WEB_SEARCH_URL": request.app.state.config.YANDEX_WEB_SEARCH_URL,
"YANDEX_WEB_SEARCH_API_KEY": request.app.state.config.YANDEX_WEB_SEARCH_API_KEY,
"YANDEX_WEB_SEARCH_CONFIG": request.app.state.config.YANDEX_WEB_SEARCH_CONFIG,
},
}
@ -1433,8 +1465,16 @@ def save_docs_to_vector_db(
if result is not None and result.ids and len(result.ids) > 0:
existing_doc_ids = result.ids[0]
if existing_doc_ids:
log.info(f"Document with hash {metadata['hash']} already exists")
raise ValueError(ERROR_MESSAGES.DUPLICATE_CONTENT)
# Check if the existing document belongs to the same file
# If same file_id, this is a re-add/reindex - allow it
# If different file_id, this is a duplicate - block it
existing_file_id = None
if result.metadatas and result.metadatas[0]:
existing_file_id = result.metadatas[0][0].get("file_id")
if existing_file_id != metadata.get("file_id"):
log.info(f"Document with hash {metadata['hash']} already exists")
raise ValueError(ERROR_MESSAGES.DUPLICATE_CONTENT)
if split:
if request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER:
@ -1602,6 +1642,9 @@ def process_file(
):
"""
Process a file and save its content to the vector database.
Process a file and save its content to the vector database.
Note: granular session management is used to prevent connection pool exhaustion.
The session is committed before external API calls, and updates use a fresh session.
"""
if user.role == "admin":
file = Files.get_file_by_id(form_data.file_id, db=db)
@ -1765,6 +1808,12 @@ def process_file(
}
else:
try:
# Commit any pending changes before the slow embedding step.
# Note: file is already a Pydantic model (not ORM), so no expunge needed.
db.commit()
# External embedding API takes time (5-60s+).
# Subsequent updates use fresh sessions via get_db().
result = save_docs_to_vector_db(
request,
docs=docs,
@ -1780,27 +1829,29 @@ def process_file(
log.info(f"added {len(docs)} items to collection {collection_name}")
if result:
Files.update_file_metadata_by_id(
file.id,
{
# Fresh session for the final update.
with get_db() as session:
Files.update_file_metadata_by_id(
file.id,
{
"collection_name": collection_name,
},
db=session,
)
Files.update_file_data_by_id(
file.id,
{"status": "completed"},
db=session,
)
Files.update_file_hash_by_id(file.id, hash, db=session)
return {
"status": True,
"collection_name": collection_name,
},
db=db,
)
Files.update_file_data_by_id(
file.id,
{"status": "completed"},
db=db,
)
Files.update_file_hash_by_id(file.id, hash, db=db)
return {
"status": True,
"collection_name": collection_name,
"filename": file.filename,
"content": text_content,
}
"filename": file.filename,
"content": text_content,
}
else:
raise Exception("Error saving document to vector database")
except Exception as e:
@ -1808,13 +1859,15 @@ def process_file(
except Exception as e:
log.exception(e)
Files.update_file_data_by_id(
file.id,
{"status": "failed"},
db=db,
)
# Clear the hash so the file can be re-uploaded after fixing the issue
Files.update_file_hash_by_id(file.id, None, db=db)
# Fresh session for error status update.
with get_db() as session:
Files.update_file_data_by_id(
file.id,
{"status": "failed"},
db=session,
)
# Clear the hash so the file can be re-uploaded after fixing the issue
Files.update_file_hash_by_id(file.id, None, db=session)
if "No pandoc was found" in str(e):
raise HTTPException(
@ -2224,6 +2277,17 @@ def search_web(
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
user=user,
)
elif engine == "yandex":
return search_yandex(
request,
request.app.state.config.YANDEX_WEB_SEARCH_URL,
request.app.state.config.YANDEX_WEB_SEARCH_API_KEY,
request.app.state.config.YANDEX_WEB_SEARCH_CONFIG,
query,
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
user=user,
)
else:
raise Exception("No search engine API key found in environment variables")
@ -2711,9 +2775,7 @@ async def process_files_batch(
# Update all files with collection name
for file_update, file_result in zip(file_updates, file_results):
Files.update_file_by_id(
id=file_result.file_id, form_data=file_update
)
Files.update_file_by_id(id=file_result.file_id, form_data=file_update)
file_result.status = "completed"
except Exception as e:
@ -2723,7 +2785,9 @@ async def process_files_batch(
for file_result in file_results:
file_result.status = "failed"
file_errors.append(
BatchProcessFilesResult(file_id=file_result.file_id, error=str(e))
BatchProcessFilesResult(
file_id=file_result.file_id, status="failed", error=str(e)
)
)
return BatchProcessFilesResponse(results=file_results, errors=file_errors)

View file

@ -352,18 +352,17 @@ def user_to_scim(user: UserModel, request: Request, db=None) -> SCIMUser:
def group_to_scim(group: GroupModel, request: Request, db=None) -> SCIMGroup:
"""Convert internal Group model to SCIM Group"""
member_ids = Groups.get_group_user_ids_by_id(group.id, db) or []
members = []
for user_id in member_ids:
user = Users.get_user_by_id(user_id, db=db)
if user:
members.append(
SCIMGroupMember(
value=user.id,
ref=f"{request.base_url}api/v1/scim/v2/Users/{user.id}",
display=user.name,
)
)
# Batch-fetch all users to avoid N+1 queries
users = Users.get_users_by_user_ids(member_ids, db=db) if member_ids else []
members = [
SCIMGroupMember(
value=user.id,
ref=f"{request.base_url}api/v1/scim/v2/Users/{user.id}",
display=user.name,
)
for user in users
]
return SCIMGroup(
id=group.id,

View file

@ -25,6 +25,7 @@ from open_webui.utils.plugin import (
load_tool_module_by_id,
replace_imports,
get_tool_module_from_cache,
resolve_valves_schema_options,
)
from open_webui.utils.tools import get_tool_specs
from open_webui.utils.auth import get_admin_user, get_verified_user
@ -553,7 +554,10 @@ async def get_tools_valves_spec_by_id(
if hasattr(tools_module, "Valves"):
Valves = tools_module.Valves
return Valves.schema()
schema = Valves.schema()
# Resolve dynamic options for select dropdowns
schema = resolve_valves_schema_options(Valves, schema, user)
return schema
return None
else:
raise HTTPException(
@ -662,7 +666,10 @@ async def get_tools_user_valves_spec_by_id(
if hasattr(tools_module, "UserValves"):
UserValves = tools_module.UserValves
return UserValves.schema()
schema = UserValves.schema()
# Resolve dynamic options for select dropdowns
schema = resolve_valves_schema_options(UserValves, schema, user)
return schema
return None
else:
raise HTTPException(

View file

@ -36,6 +36,7 @@ from open_webui.models.chats import Chats
from open_webui.models.channels import Channels, ChannelMember, Channel
from open_webui.models.messages import Messages, Message
from open_webui.models.groups import Groups
from open_webui.utils.sanitize import strip_markdown_code_fences
log = logging.getLogger(__name__)
@ -166,7 +167,7 @@ async def search_web(
engine = __request__.app.state.config.WEB_SEARCH_ENGINE
user = UserModel(**__user__) if __user__ else None
results = _search_web(__request__, engine, query, user)
results = await asyncio.to_thread(_search_web, __request__, engine, query, user)
# Limit results
results = results[:count] if results else []
@ -370,6 +371,9 @@ async def execute_code(
return json.dumps({"error": "Request context not available"})
try:
# Strip markdown fences if model included them
code = strip_markdown_code_fences(code)
# Import blocked modules from config (same as middleware)
from open_webui.config import CODE_INTERPRETER_BLOCKED_MODULES
@ -892,6 +896,7 @@ async def search_chats(
end_timestamp: Optional[int] = None,
__request__: Request = None,
__user__: dict = None,
__chat_id__: str = None,
) -> str:
"""
Search the user's previous chat conversations by title and message content.
@ -921,6 +926,10 @@ async def search_chats(
results = []
for chat in chats:
# Skip the current chat to avoid showing it in search results
if __chat_id__ and chat.id == __chat_id__:
continue
# Apply date filters (updated_at is in seconds)
if start_timestamp and chat.updated_at < start_timestamp:
continue
@ -1599,6 +1608,25 @@ async def query_knowledge_files(
if not __user__:
return json.dumps({"error": "User context not available"})
# Coerce parameters from LLM tool calls (may come as strings)
if isinstance(count, str):
try:
count = int(count)
except ValueError:
count = 5 # Default fallback
# Handle knowledge_ids being string "None", "null", or empty
if isinstance(knowledge_ids, str):
if knowledge_ids.lower() in ("none", "null", ""):
knowledge_ids = None
else:
# Try to parse as JSON array if it looks like one
try:
knowledge_ids = json.loads(knowledge_ids)
except json.JSONDecodeError:
# Treat as single ID
knowledge_ids = [knowledge_ids]
try:
from open_webui.models.knowledge import Knowledges
from open_webui.models.files import Files

View file

@ -221,7 +221,8 @@ class AuditLoggingMiddleware:
return False # Do NOT skip logging for auth endpoints
# Skip logging if the request is not authenticated
if not request.headers.get("authorization"):
# Check both Authorization header (API keys) and token cookie (browser sessions)
if not request.headers.get("authorization") and not request.cookies.get("token"):
return True
# match either /api/<resource>/...(for the endpoint /api/chat case) or /api/v1/<resource>/...

View file

@ -33,6 +33,7 @@ from open_webui.env import (
ENABLE_PASSWORD_VALIDATION,
OFFLINE_MODE,
LICENSE_BLOB,
PASSWORD_VALIDATION_HINT,
PASSWORD_VALIDATION_REGEX_PATTERN,
REDIS_KEY_PREFIX,
pk,
@ -173,7 +174,7 @@ def validate_password(password: str) -> bool:
if ENABLE_PASSWORD_VALIDATION:
if not PASSWORD_VALIDATION_REGEX_PATTERN.match(password):
raise Exception(ERROR_MESSAGES.INVALID_PASSWORD())
raise Exception(ERROR_MESSAGES.INVALID_PASSWORD(PASSWORD_VALIDATION_HINT))
return True

View file

@ -341,12 +341,8 @@ async def chat_completed(request: Request, form_data: dict, user: Any):
}
try:
filter_functions = [
Functions.get_function_by_id(filter_id)
for filter_id in get_sorted_filter_ids(
request, model, metadata.get("filter_ids", [])
)
]
filter_ids = get_sorted_filter_ids(request, model, metadata.get("filter_ids", []))
filter_functions = Functions.get_functions_by_ids(filter_ids)
result, _ = await process_filter_functions(
request=request,

View file

@ -18,6 +18,7 @@ from open_webui.storage.provider import Storage
from open_webui.models.chats import Chats
from open_webui.models.files import Files
from open_webui.routers.files import upload_file_handler
from open_webui.retrieval.web.utils import validate_url
import mimetypes
import base64
@ -33,6 +34,8 @@ MARKDOWN_IMAGE_URL_PATTERN = re.compile(r"!\[(.*?)\]\((.+?)\)", re.IGNORECASE)
def get_image_base64_from_url(url: str) -> Optional[str]:
try:
if url.startswith("http"):
# Validate URL to prevent SSRF attacks against local/private networks
validate_url(url)
# Download the image from the URL
response = requests.get(url)
response.raise_for_status()

View file

@ -8,6 +8,15 @@ from mcp import ClientSession
from mcp.client.auth import OAuthClientProvider, TokenStorage
from mcp.client.streamable_http import streamablehttp_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
import httpx
from mcp.shared._httpx_utils import create_mcp_http_client
from open_webui.env import AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL
def create_insecure_httpx_client(headers=None, timeout=None, auth=None):
client = create_mcp_http_client(headers=headers, timeout=timeout, auth=auth)
client.verify = False
return client
class MCPClient:
@ -18,7 +27,14 @@ class MCPClient:
async def connect(self, url: str, headers: Optional[dict] = None):
async with AsyncExitStack() as exit_stack:
try:
self._streams_context = streamablehttp_client(url, headers=headers)
if AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL:
self._streams_context = streamablehttp_client(url, headers=headers)
else:
self._streams_context = streamablehttp_client(
url,
headers=headers,
httpx_client_factory=create_insecure_httpx_client,
)
transport = await exit_stack.enter_async_context(self._streams_context)
read_stream, write_stream, _ = transport

View file

@ -73,6 +73,7 @@ from open_webui.models.models import Models
from open_webui.retrieval.utils import get_sources_from_items
from open_webui.utils.sanitize import strip_markdown_code_fences
from open_webui.utils.chat import generate_chat_completion
from open_webui.utils.task import (
get_task_model_id,
@ -92,6 +93,7 @@ from open_webui.utils.misc import (
prepend_to_first_user_message_content,
convert_logit_bias_input_to_json,
get_content_from_message,
convert_output_to_messages,
)
from open_webui.utils.tools import (
get_tools,
@ -146,6 +148,11 @@ DEFAULT_SOLUTION_TAGS = [("<|begin_of_solution|>", "<|end_of_solution|>")]
DEFAULT_CODE_INTERPRETER_TAGS = [("<code_interpreter>", "</code_interpreter>")]
def output_id(prefix: str) -> str:
"""Generate OR-style ID: prefix + 24-char hex UUID."""
return f"{prefix}_{uuid4().hex[:24]}"
def get_citation_source_from_tool_result(
tool_name: str, tool_params: dict, tool_result: str, tool_id: str = ""
) -> list[dict]:
@ -1399,6 +1406,30 @@ async def convert_url_images_to_base64(form_data):
return form_data
def process_messages_with_output(messages: list[dict]) -> list[dict]:
"""
Process messages with OR-aligned output items for LLM consumption.
For assistant messages with 'output' field, produces properly formatted
OpenAI-style messages (tool_calls + tool results). Strips 'output' before LLM.
"""
processed = []
for message in messages:
if message.get("role") == "assistant" and message.get("output"):
# Use output items for clean OpenAI-format messages
output_messages = convert_output_to_messages(message["output"])
if output_messages:
processed.extend(output_messages)
continue
# Strip 'output' field before adding (LLM shouldn't see it)
clean_message = {k: v for k, v in message.items() if k != "output"}
processed.append(clean_message)
return processed
async def process_chat_payload(request, form_data, user, metadata, model):
# Pipeline Inlet -> Filter Inlet -> Chat Memory -> Chat Web Search -> Chat Image Generation
# -> Chat Code Interpreter (Form Data Update) -> (Default) Chat Tools Function Calling
@ -1407,6 +1438,9 @@ async def process_chat_payload(request, form_data, user, metadata, model):
form_data = apply_params_to_form_data(form_data, model)
log.debug(f"form_data: {form_data}")
# Process messages with OR-aligned output items for clean LLM messages
form_data["messages"] = process_messages_with_output(form_data.get("messages", []))
system_message = get_system_message(form_data.get("messages", []))
if system_message: # Chat Controls/User Settings
try:
@ -1536,12 +1570,8 @@ async def process_chat_payload(request, form_data, user, metadata, model):
raise e
try:
filter_functions = [
Functions.get_function_by_id(filter_id)
for filter_id in get_sorted_filter_ids(
request, model, metadata.get("filter_ids", [])
)
]
filter_ids = get_sorted_filter_ids(request, model, metadata.get("filter_ids", []))
filter_functions = Functions.get_functions_by_ids(filter_ids)
form_data, flags = await process_filter_functions(
request=request,
@ -1808,11 +1838,8 @@ async def process_chat_payload(request, form_data, user, metadata, model):
# Inject builtin tools for native function calling based on enabled features and model capability
# Check if builtin_tools capability is enabled for this model (defaults to True if not specified)
builtin_tools_enabled = (
model.get("info", {})
.get("meta", {})
.get("capabilities", {})
.get("builtin_tools", True)
)
model.get("info", {}).get("meta", {}).get("capabilities") or {}
).get("builtin_tools", True)
if (
metadata.get("params", {}).get("function_calling") == "native"
and builtin_tools_enabled
@ -1856,11 +1883,8 @@ async def process_chat_payload(request, form_data, user, metadata, model):
# Check if file context extraction is enabled for this model (default True)
file_context_enabled = (
model.get("info", {})
.get("meta", {})
.get("capabilities", {})
.get("file_context", True)
)
model.get("info", {}).get("meta", {}).get("capabilities") or {}
).get("file_context", True)
if file_context_enabled:
try:
@ -2210,12 +2234,20 @@ async def process_chat_response(
title = Chats.get_chat_title_by_id(metadata["chat_id"])
# Use output from backend if provided (OR-compliant backends)
response_output = response_data.get("output")
await event_emitter(
{
"type": "chat:completion",
"data": {
"done": True,
"content": content,
**(
{"output": response_output}
if response_output
else {}
),
"title": title,
},
}
@ -2228,6 +2260,11 @@ async def process_chat_response(
{
"role": "assistant",
"content": content,
**(
{"output": response_output}
if response_output
else {}
),
},
)
@ -2481,6 +2518,107 @@ async def process_chat_response(
return content.strip()
def serialize_output(output: list) -> str:
"""
Convert OR-aligned output items to HTML for display.
For LLM consumption, use convert_output_to_messages() instead.
"""
content = ""
# First pass: collect function_call_output items by call_id for lookup
tool_outputs = {}
for item in output:
if item.get("type") == "function_call_output":
tool_outputs[item.get("call_id")] = item
# Second pass: render items in order
for item in output:
item_type = item.get("type", "")
if item_type == "message":
for content_part in item.get("content", []):
if content_part.get("type") == "output_text":
text = content_part.get("text", "").strip()
if text:
content = f"{content}{text}\n"
elif item_type == "function_call":
# Render tool call inline with its result (if available)
if content and not content.endswith("\n"):
content += "\n"
call_id = item.get("call_id", "")
name = item.get("name", "")
arguments = item.get("arguments", "")
result_item = tool_outputs.get(call_id)
if result_item:
result_text = ""
for out in result_item.get("output", []):
if out.get("type") == "input_text":
result_text += out.get("text", "")
files = result_item.get("files")
embeds = result_item.get("embeds", "")
content += f'<details type="tool_calls" done="true" id="{call_id}" name="{name}" arguments="{html.escape(json.dumps(arguments))}" result="{html.escape(json.dumps(result_text, ensure_ascii=False))}" files="{html.escape(json.dumps(files)) if files else ""}" embeds="{html.escape(json.dumps(embeds))}">\n<summary>Tool Executed</summary>\n</details>\n'
else:
content += f'<details type="tool_calls" done="false" id="{call_id}" name="{name}" arguments="{html.escape(json.dumps(arguments))}">\n<summary>Executing...</summary>\n</details>\n'
elif item_type == "function_call_output":
# Already handled inline with function_call above
pass
elif item_type == "reasoning":
reasoning_content = ""
for content_part in item.get("content", []):
if content_part.get("type") == "output_text":
reasoning_content = content_part.get("text", "").strip()
duration = item.get("duration")
status = item.get("status", "in_progress")
if content and not content.endswith("\n"):
content += "\n"
display = html.escape(
"\n".join(
(f"> {line}" if not line.startswith(">") else line)
for line in reasoning_content.splitlines()
)
)
if status == "completed" or duration is not None:
content = f'{content}<details type="reasoning" done="true" duration="{duration or 0}">\n<summary>Thought for {duration or 0} seconds</summary>\n{display}\n</details>\n'
else:
content = f'{content}<details type="reasoning" done="false">\n<summary>Thinking…</summary>\n{display}\n</details>\n'
elif item_type == "open_webui:code_interpreter":
code = item.get("code", "")
output_val = item.get("output")
lang = item.get("lang", "")
content_stripped, original_whitespace = (
split_content_and_whitespace(content)
)
if is_opening_code_block(content_stripped):
content = (
content_stripped.rstrip("`").rstrip()
+ original_whitespace
)
else:
content = content_stripped + original_whitespace
if content and not content.endswith("\n"):
content += "\n"
if output_val:
output_escaped = html.escape(json.dumps(output_val))
content = f'{content}<details type="code_interpreter" done="true" output="{output_escaped}">\n<summary>Analyzed</summary>\n```{lang}\n{code}\n```\n</details>\n'
else:
content = f'{content}<details type="code_interpreter" done="false">\n<summary>Analyzing...</summary>\n```{lang}\n{code}\n```\n</details>\n'
return content.strip()
def convert_content_blocks_to_messages(content_blocks, raw=False):
messages = []
@ -2521,6 +2659,127 @@ async def process_chat_response(
return messages
def convert_content_blocks_to_output(content_blocks):
"""
Convert content_blocks to Open Responses-aligned output items.
See: https://openresponses.org/specification
"""
output_items = []
def next_id(prefix):
return f"{prefix}_{uuid4().hex[:24]}"
for block in content_blocks:
block_type = block.get("type", "")
# Use backend-provided ID if available, fallback to generated
block_id = block.get("id")
if block_type == "text":
text_content = block.get("content", "").strip()
if text_content:
output_items.append(
{
"type": "message",
"id": block_id or next_id("msg"),
"status": "completed",
"role": "assistant",
"content": [
{"type": "output_text", "text": text_content}
],
}
)
elif block_type == "tool_calls":
tool_calls = block.get("content", [])
results = block.get("results", [])
# Emit function_call items
for tool_call in tool_calls:
call_id = tool_call.get("id", "")
func = tool_call.get("function", {})
output_items.append(
{
"type": "function_call",
"id": call_id
or next_id(
"fc"
), # Use call_id as item id if available
"call_id": call_id,
"name": func.get("name", ""),
"arguments": func.get("arguments", "{}"),
"status": "completed" if results else "in_progress",
}
)
# Emit function_call_output items
for result in results:
output_items.append(
{
"type": "function_call_output",
"id": result.get("id") or next_id("fco"),
"call_id": result.get("tool_call_id", ""),
"output": [
{
"type": "input_text",
"text": result.get("content", ""),
}
],
"status": "completed",
**(
{"files": result.get("files")}
if result.get("files")
else {}
),
**(
{"embeds": result.get("embeds")}
if result.get("embeds")
else {}
),
}
)
elif block_type == "reasoning":
reasoning_content = block.get("content", "").strip()
duration = block.get("duration")
output_items.append(
{
"type": "reasoning",
"id": block_id or next_id("r"),
"status": (
"completed"
if duration is not None
else "in_progress"
),
"content": (
[{"type": "output_text", "text": reasoning_content}]
if reasoning_content
else None
),
"summary": None,
}
)
elif block_type == "code_interpreter":
code = block.get("content", "")
output_val = block.get("output")
attrs = block.get("attributes", {})
output_items.append(
{
"type": "open_webui:code_interpreter",
"id": block_id or next_id("ci"),
"status": (
"completed"
if output_val is not None
else "in_progress"
),
"lang": attrs.get("lang", ""),
"code": code,
"output": output_val,
}
)
return output_items
def tag_content_handler(content_type, tags, content, content_blocks):
end_flag = False
@ -2718,6 +2977,23 @@ async def process_chat_response(
else last_assistant_message if last_assistant_message else ""
)
# Initialize output: use existing from message if continuing, else create new
existing_output = message.get("output") if message else None
if existing_output:
output = existing_output
else:
# Always create an initial message item (even if content is empty)
output = [
{
"type": "message",
"id": output_id("msg"),
"status": "in_progress",
"role": "assistant",
"content": [{"type": "output_text", "text": content}],
}
]
# Keep content_blocks for backward compatibility during transition
content_blocks = [
{
"type": "text",
@ -3129,13 +3405,15 @@ async def process_chat_response(
if ENABLE_REALTIME_CHAT_SAVE:
# Save message in the database
output = convert_content_blocks_to_output(
content_blocks
)
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"content": serialize_content_blocks(
content_blocks
),
"content": serialize_output(output),
"output": output,
},
)
else:
@ -3220,11 +3498,13 @@ async def process_chat_response(
}
)
output = convert_content_blocks_to_output(content_blocks)
await event_emitter(
{
"type": "chat:completion",
"data": {
"content": serialize_content_blocks(content_blocks),
"content": serialize_output(output),
"output": output,
},
}
)
@ -3399,11 +3679,13 @@ async def process_chat_response(
)
tool_call_sources.clear()
output = convert_content_blocks_to_output(content_blocks)
await event_emitter(
{
"type": "chat:completion",
"data": {
"content": serialize_content_blocks(content_blocks),
"content": serialize_output(output),
"output": output,
},
}
)
@ -3445,11 +3727,13 @@ async def process_chat_response(
and retries < MAX_RETRIES
):
output = convert_content_blocks_to_output(content_blocks)
await event_emitter(
{
"type": "chat:completion",
"data": {
"content": serialize_content_blocks(content_blocks),
"content": serialize_output(output),
"output": output,
},
}
)
@ -3461,6 +3745,9 @@ async def process_chat_response(
try:
if content_blocks[-1]["attributes"].get("type") == "code":
code = content_blocks[-1]["content"]
# Strip markdown fences if model included them
code = strip_markdown_code_fences(code)
if CODE_INTERPRETER_BLOCKED_MODULES:
blocking_code = textwrap.dedent(
f"""
@ -3576,11 +3863,13 @@ async def process_chat_response(
}
)
output = convert_content_blocks_to_output(content_blocks)
await event_emitter(
{
"type": "chat:completion",
"data": {
"content": serialize_content_blocks(content_blocks),
"content": serialize_output(output),
"output": output,
},
}
)
@ -3617,9 +3906,11 @@ async def process_chat_response(
break
title = Chats.get_chat_title_by_id(metadata["chat_id"])
output = convert_content_blocks_to_output(content_blocks)
data = {
"done": True,
"content": serialize_content_blocks(content_blocks),
"content": serialize_output(output),
"output": output,
"title": title,
}
@ -3629,7 +3920,8 @@ async def process_chat_response(
metadata["chat_id"],
metadata["message_id"],
{
"content": serialize_content_blocks(content_blocks),
"content": serialize_output(output),
"output": output,
},
)
@ -3663,11 +3955,13 @@ async def process_chat_response(
if not ENABLE_REALTIME_CHAT_SAVE:
# Save message in the database
output = convert_content_blocks_to_output(content_blocks)
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"content": serialize_content_blocks(content_blocks),
"content": serialize_output(output),
"output": output,
},
)

View file

@ -128,6 +128,86 @@ def get_content_from_message(message: dict) -> Optional[str]:
return None
def convert_output_to_messages(output: list) -> list[dict]:
"""
Convert OR-aligned output items to OpenAI-format messages for LLM consumption.
This is the inverse of convert_content_blocks_to_output() in middleware.py.
"""
if not output or not isinstance(output, list):
return []
messages = []
pending_tool_calls = []
pending_content = []
for item in output:
item_type = item.get("type", "")
if item_type == "message":
# Extract text from output_text content parts
content_parts = item.get("content", [])
text = ""
for part in content_parts:
if part.get("type") == "output_text":
text += part.get("text", "")
if text:
pending_content.append(text)
elif item_type == "function_call":
# Collect tool calls to batch into assistant message
pending_tool_calls.append({
"id": item.get("call_id", ""),
"type": "function",
"function": {
"name": item.get("name", ""),
"arguments": item.get("arguments", "{}"),
}
})
elif item_type == "function_call_output":
# Flush any pending content/tool_calls before adding tool result
if pending_content or pending_tool_calls:
messages.append({
"role": "assistant",
"content": "\n".join(pending_content) if pending_content else "",
**({"tool_calls": pending_tool_calls} if pending_tool_calls else {}),
})
pending_content = []
pending_tool_calls = []
# Extract text from output content parts
output_parts = item.get("output", [])
content = ""
for part in output_parts:
if part.get("type") == "input_text":
content += part.get("text", "")
messages.append({
"role": "tool",
"tool_call_id": item.get("call_id", ""),
"content": content,
})
elif item_type == "reasoning":
# Skip reasoning blocks for LLM messages
pass
elif item_type.startswith("open_webui:"):
# Skip extension types
pass
# Flush remaining content/tool_calls
if pending_content or pending_tool_calls:
messages.append({
"role": "assistant",
"content": "\n".join(pending_content) if pending_content else "",
**({"tool_calls": pending_tool_calls} if pending_tool_calls else {}),
})
return messages
def get_last_user_message(messages: list[dict]) -> Optional[str]:
message = get_last_user_message_item(messages)
if message is None:

View file

@ -287,7 +287,11 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict:
Returns:
dict: A modified payload compatible with the Ollama API.
"""
openai_payload = copy.deepcopy(openai_payload)
# Shallow copy metadata separately (may contain non-picklable objects)
metadata = openai_payload.get("metadata")
openai_payload = copy.deepcopy({k: v for k, v in openai_payload.items() if k != "metadata"})
if metadata is not None:
openai_payload["metadata"] = dict(metadata)
ollama_payload = {}
# Mapping basic model and message details

View file

@ -6,6 +6,7 @@ from importlib import util
import types
import tempfile
import logging
from typing import Any
from open_webui.env import PIP_OPTIONS, PIP_PACKAGE_INDEX_OPTIONS, OFFLINE_MODE
from open_webui.models.functions import Functions
@ -14,6 +15,143 @@ from open_webui.models.tools import Tools
log = logging.getLogger(__name__)
def resolve_valves_schema_options(
valves_class: type, schema: dict, user: Any = None
) -> dict:
"""
Resolve dynamic options in a Valves schema.
For properties with `input.options`, this function handles two cases:
- List: Used directly as dropdown options
- String: Treated as method name, called to get options dynamically
Usage in Valves:
class UserValves(BaseModel):
# Static options
priority: str = Field(
default="medium",
json_schema_extra={
"input": {
"type": "select",
"options": ["low", "medium", "high"]
}
}
)
# Dynamic options (method name)
model: str = Field(
default="",
json_schema_extra={
"input": {
"type": "select",
"options": "get_model_options"
}
}
)
@classmethod
def get_model_options(cls, __user__=None) -> list[dict]:
return [{"value": "gpt-4", "label": "GPT-4"}]
Args:
valves_class: The Valves or UserValves Pydantic model class
schema: The JSON schema dict from valves_class.schema()
user: Optional user object passed to methods that accept __user__
Returns:
Modified schema dict with resolved options
"""
if not schema or "properties" not in schema:
return schema
# Make a copy to avoid mutating the original
schema = dict(schema)
schema["properties"] = dict(schema.get("properties", {}))
for prop_name, prop_schema in list(schema["properties"].items()):
# Get the original field info from the Pydantic model
if not hasattr(valves_class, "model_fields"):
continue
field_info = valves_class.model_fields.get(prop_name)
if not field_info:
continue
# Check json_schema_extra for options
json_schema_extra = field_info.json_schema_extra
if not json_schema_extra or not isinstance(json_schema_extra, dict):
continue
input_config = json_schema_extra.get("input")
if not input_config or not isinstance(input_config, dict):
continue
options = input_config.get("options")
if options is None:
continue
resolved_options = None
# Case 1: options is already a list - use directly
if isinstance(options, list):
resolved_options = options
# Case 2: options is a string - treat as method name
elif isinstance(options, str) and options:
method = getattr(valves_class, options, None)
if method is None or not callable(method):
log.warning(
f"options '{options}' not found or not callable on {valves_class.__name__}"
)
continue
try:
import inspect
sig = inspect.signature(method)
params = sig.parameters
# Prepare kwargs based on what the method accepts
kwargs = {}
if "__user__" in params and user is not None:
kwargs["__user__"] = (
user.model_dump() if hasattr(user, "model_dump") else user
)
if "user" in params and user is not None:
kwargs["user"] = (
user.model_dump() if hasattr(user, "model_dump") else user
)
resolved_options = method(**kwargs) if kwargs else method()
# Validate return type
if not isinstance(resolved_options, list):
log.warning(
f"Method '{options}' did not return a list for {prop_name}"
)
continue
except Exception as e:
log.warning(f"Failed to resolve options for {prop_name}: {e}")
continue
else:
# Invalid options type - skip
continue
# Update the schema with resolved options
schema["properties"][prop_name] = dict(prop_schema)
if "input" not in schema["properties"][prop_name]:
schema["properties"][prop_name]["input"] = {"type": "select"}
else:
schema["properties"][prop_name]["input"] = dict(
schema["properties"][prop_name].get("input", {})
)
schema["properties"][prop_name]["input"]["options"] = resolved_options
return schema
def extract_frontmatter(content):
"""
Extract frontmatter as a dictionary from the provided content string.

View file

@ -1,5 +1,7 @@
import inspect
from urllib.parse import urlparse
import asyncio
import time
import logging
@ -12,6 +14,7 @@ from open_webui.env import (
REDIS_SENTINEL_MAX_RETRY_COUNT,
REDIS_SENTINEL_PORT,
REDIS_URL,
REDIS_RECONNECT_DELAY,
)
log = logging.getLogger(__name__)
@ -63,6 +66,8 @@ class SentinelRedisProxy:
i + 1,
REDIS_SENTINEL_MAX_RETRY_COUNT,
)
if REDIS_RECONNECT_DELAY:
time.sleep(REDIS_RECONNECT_DELAY / 1000)
continue
log.error(
"Redis operation failed after %s retries: %s",
@ -94,6 +99,8 @@ class SentinelRedisProxy:
i + 1,
REDIS_SENTINEL_MAX_RETRY_COUNT,
)
if REDIS_RECONNECT_DELAY:
await asyncio.sleep(REDIS_RECONNECT_DELAY / 1000)
continue
log.error(
"Redis operation failed after %s retries: %s",
@ -122,6 +129,8 @@ class SentinelRedisProxy:
i + 1,
REDIS_SENTINEL_MAX_RETRY_COUNT,
)
if REDIS_RECONNECT_DELAY:
time.sleep(REDIS_RECONNECT_DELAY / 1000)
continue
log.error(
"Redis operation failed after %s retries: %s",

View file

@ -0,0 +1,21 @@
import re
def strip_markdown_code_fences(code: str) -> str:
"""
Strip markdown code fences if present.
This is a defensive, non-breaking change — if the code doesn't
contain fences, it passes through unchanged.
Handles patterns like:
- ```python
- ```py
- ```
"""
code = code.strip()
# Remove opening fence (```python, ```py, ``` etc.)
code = re.sub(r"^```\w*\n?", "", code)
# Remove closing fence
code = re.sub(r"\n?```\s*$", "", code)
return code.strip()

View file

@ -69,6 +69,7 @@ def prompt_template(template: str, user: Optional[Any] = None) -> str:
USER_VARIABLES = {
"name": str(user.get("name")),
"email": str(user.get("email")),
"location": str(user_info.get("location")),
"bio": str(user.get("bio")),
"gender": str(user.get("gender")),
@ -92,6 +93,9 @@ def prompt_template(template: str, user: Optional[Any] = None) -> str:
template = template.replace("{{CURRENT_WEEKDAY}}", formatted_weekday)
template = template.replace("{{USER_NAME}}", USER_VARIABLES.get("name", "Unknown"))
template = template.replace(
"{{USER_EMAIL}}", USER_VARIABLES.get("email", "Unknown")
)
template = template.replace("{{USER_BIO}}", USER_VARIABLES.get("bio", "Unknown"))
template = template.replace(
"{{USER_GENDER}}", USER_VARIABLES.get("gender", "Unknown")

View file

@ -398,74 +398,87 @@ def get_builtin_tools(
# Helper to get model capabilities (defaults to True if not specified)
def get_model_capability(name: str, default: bool = True) -> bool:
return (
model.get("info", {})
.get("meta", {})
.get("capabilities", {})
(model.get("info", {}).get("meta", {}).get("capabilities") or {})
.get(name, default)
)
# Time utilities - always available for date calculations
builtin_functions.extend([get_current_timestamp, calculate_timestamp])
# Helper to check if a builtin tool category is enabled via meta.builtinTools
# Defaults to True if not specified (backward compatible)
def is_builtin_tool_enabled(category: str) -> bool:
builtin_tools = model.get("info", {}).get("meta", {}).get("builtinTools", {})
return builtin_tools.get(category, True)
# Time utilities - available for date calculations
if is_builtin_tool_enabled("time"):
builtin_functions.extend([get_current_timestamp, calculate_timestamp])
# Knowledge base tools - conditional injection based on model knowledge
# If model has attached knowledge (any type), only provide query_knowledge_files
# Otherwise, provide all KB browsing tools
model_knowledge = model.get("info", {}).get("meta", {}).get("knowledge", [])
if model_knowledge:
# Model has attached knowledge - only allow semantic search within it
builtin_functions.append(query_knowledge_files)
else:
# No model knowledge - allow full KB browsing
builtin_functions.extend(
[
list_knowledge_bases,
search_knowledge_bases,
query_knowledge_bases,
search_knowledge_files,
query_knowledge_files,
view_knowledge_file,
]
)
if is_builtin_tool_enabled("knowledge"):
if model_knowledge:
# Model has attached knowledge - only allow semantic search within it
builtin_functions.append(query_knowledge_files)
else:
# No model knowledge - allow full KB browsing
builtin_functions.extend(
[
list_knowledge_bases,
search_knowledge_bases,
query_knowledge_bases,
search_knowledge_files,
query_knowledge_files,
view_knowledge_file,
]
)
# Chats tools - search and fetch user's chat history
builtin_functions.extend([search_chats, view_chat])
if is_builtin_tool_enabled("chats"):
builtin_functions.extend([search_chats, view_chat])
# Add memory tools if enabled for this chat
if features.get("memory"):
# Add memory tools if builtin category enabled AND enabled for this chat
if is_builtin_tool_enabled("memory") and features.get("memory"):
builtin_functions.extend([search_memories, add_memory, replace_memory_content])
# Add web search tools if enabled globally AND model has web_search capability
if getattr(
request.app.state.config, "ENABLE_WEB_SEARCH", False
) and get_model_capability("web_search"):
# Add web search tools if builtin category enabled AND enabled globally AND model has web_search capability
if (
is_builtin_tool_enabled("web_search")
and getattr(request.app.state.config, "ENABLE_WEB_SEARCH", False)
and get_model_capability("web_search")
):
builtin_functions.extend([search_web, fetch_url])
# Add image generation/edit tools if enabled globally AND model has image_generation capability
if getattr(
request.app.state.config, "ENABLE_IMAGE_GENERATION", False
) and get_model_capability("image_generation"):
# Add image generation/edit tools if builtin category enabled AND enabled globally AND model has image_generation capability
if (
is_builtin_tool_enabled("image_generation")
and getattr(request.app.state.config, "ENABLE_IMAGE_GENERATION", False)
and get_model_capability("image_generation")
):
builtin_functions.append(generate_image)
if getattr(
request.app.state.config, "ENABLE_IMAGE_EDIT", False
) and get_model_capability("image_generation"):
if (
is_builtin_tool_enabled("image_generation")
and getattr(request.app.state.config, "ENABLE_IMAGE_EDIT", False)
and get_model_capability("image_generation")
):
builtin_functions.append(edit_image)
# Add code interpreter tool if enabled globally AND model has code_interpreter capability
# Supports both pyodide (via frontend event call) and jupyter engines
# Add code interpreter tool if builtin category enabled AND enabled globally AND model has code_interpreter capability
if (
getattr(request.app.state.config, "ENABLE_CODE_INTERPRETER", True)
is_builtin_tool_enabled("code_interpreter")
and getattr(request.app.state.config, "ENABLE_CODE_INTERPRETER", True)
and get_model_capability("code_interpreter")
):
builtin_functions.append(execute_code)
# Notes tools - search, view, create, and update user's notes (if notes enabled globally)
if getattr(request.app.state.config, "ENABLE_NOTES", False):
# Notes tools - search, view, create, and update user's notes (if builtin category enabled AND notes enabled globally)
if is_builtin_tool_enabled("notes") and getattr(request.app.state.config, "ENABLE_NOTES", False):
builtin_functions.extend(
[search_notes, view_note, write_note, replace_note_content]
)
# Channels tools - search channels and messages (if channels enabled globally)
if getattr(request.app.state.config, "ENABLE_CHANNELS", False):
# Channels tools - search channels and messages (if builtin category enabled AND channels enabled globally)
if is_builtin_tool_enabled("channels") and getattr(request.app.state.config, "ENABLE_CHANNELS", False):
builtin_functions.extend(
[
search_channels,

View file

@ -4,7 +4,7 @@
fastapi==0.128.0
uvicorn[standard]==0.40.0
pydantic==2.12.5
python-multipart==0.0.21
python-multipart==0.0.22
itsdangerous==2.2.0
python-socketio==5.16.0
@ -16,20 +16,20 @@ PyJWT[crypto]==2.10.1
authlib==1.6.6
requests==2.32.5
aiohttp==3.13.2
aiohttp==3.13.2 # do not update to 3.13.3 - broken
async-timeout
aiocache
aiofiles
starlette-compress==1.6.1
starlette-compress==1.7.0
httpx[socks,http2,zstd,cli,brotli]==0.28.1
starsessions[redis]==2.2.1
sqlalchemy==2.0.45
alembic==1.17.2
peewee==3.18.3
sqlalchemy==2.0.46
alembic==1.18.3
peewee==3.19.0
peewee-migrate==1.14.3
pycrdt==0.12.44
pycrdt==0.12.45
redis
APScheduler==3.11.2
@ -38,17 +38,17 @@ RestrictedPython==8.1
loguru==0.7.3
asgiref==3.11.0
mcp==1.25.0
mcp==1.26.0
openai
langchain==1.2.0
langchain==1.2.7
langchain-community==0.4.1
langchain-classic==1.0.1
langchain-text-splitters==1.1.0
fake-useragent==2.2.0
chromadb==1.4.0
black==25.12.0
chromadb==1.4.1
black==26.1.0
pydub
chardet==5.2.0

View file

@ -1,7 +1,7 @@
fastapi==0.128.0
uvicorn[standard]==0.40.0
pydantic==2.12.5
python-multipart==0.0.21
python-multipart==0.0.22
itsdangerous==2.2.0
python-socketio==5.16.0
@ -13,21 +13,21 @@ PyJWT[crypto]==2.10.1
authlib==1.6.6
requests==2.32.5
aiohttp==3.13.2
aiohttp==3.13.2 # do not update to 3.13.3 - broken
async-timeout
aiocache
aiofiles
starlette-compress==1.6.1
starlette-compress==1.7.0
httpx[socks,http2,zstd,cli,brotli]==0.28.1
starsessions[redis]==2.2.1
python-mimeparse==2.0.0
sqlalchemy==2.0.45
alembic==1.17.2
peewee==3.18.3
sqlalchemy==2.0.46
alembic==1.18.3
peewee==3.19.0
peewee-migrate==1.14.3
pycrdt==0.12.44
pycrdt==0.12.45
redis
APScheduler==3.11.2
@ -38,39 +38,39 @@ asgiref==3.11.0
# AI libraries
tiktoken
mcp==1.25.0
mcp==1.26.0
openai
anthropic
google-genai==1.56.0
google-genai==1.60.0
langchain==1.2.0
langchain==1.2.7
langchain-community==0.4.1
langchain-classic==1.0.1
langchain-text-splitters==1.1.0
fake-useragent==2.2.0
chromadb==1.4.0
chromadb==1.4.1
weaviate-client==4.19.2
opensearch-py==3.1.0
transformers==4.57.3
sentence-transformers==5.2.0
transformers==4.57.6
sentence-transformers==5.2.2
accelerate
pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897
einops==0.8.1
einops==0.8.2
ftfy==6.3.1
chardet==5.2.0
pypdf==6.5.0
pypdf==6.6.2
fpdf2==2.8.5
pymdown-extensions==10.20
pymdown-extensions==10.20.1
docx2txt==0.9
python-pptx==1.0.2
unstructured==0.18.24
unstructured==0.18.31
msoffcrypto-tool==5.4.2
nltk==3.9.2
Markdown==3.10
Markdown==3.10.1
pypandoc==1.16.2
pandas==2.3.3
openpyxl==3.1.5
@ -82,15 +82,15 @@ sentencepiece
soundfile==0.13.1
pillow==12.1.0
opencv-python-headless==4.12.0.88
opencv-python-headless==4.13.0.90
rapidocr-onnxruntime==1.4.4
rank-bm25==0.2.2
onnxruntime==1.23.2
faster-whisper==1.2.1
black==25.12.0
youtube-transcript-api==1.2.3
black==26.1.0
youtube-transcript-api==1.2.4
pytube==15.0.0
pydub
@ -98,7 +98,7 @@ ddgs==9.10.0
azure-ai-documentintelligence==1.0.2
azure-identity==1.25.1
azure-storage-blob==12.27.1
azure-storage-blob==12.28.0
azure-search-documents==11.6.0
## Google Drive
@ -107,7 +107,7 @@ google-auth-httplib2
google-auth-oauthlib
googleapis-common-protos==1.72.0
google-cloud-storage==3.7.0
google-cloud-storage==3.8.0
## Databases
pymongo
@ -115,14 +115,14 @@ psycopg2-binary==2.9.11
pgvector==0.4.2
PyMySQL==1.1.2
boto3==1.42.21
boto3==1.42.38
pymilvus==2.6.6
pymilvus==2.6.8
qdrant-client==1.16.2
playwright==1.57.0 # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary
playwright==1.58.0 # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary
elasticsearch==9.2.1
pinecone==6.0.2
oracledb==3.4.1
oracledb==3.4.2
av==14.0.1 # Caution: Set due to FATAL FIPS SELFTEST FAILURE, see discussion https://github.com/open-webui/open-webui/discussions/15720
@ -138,7 +138,7 @@ pytest-docker~=3.2.5
ldap3==2.9.1
## Firecrawl
firecrawl-py==4.12.0
firecrawl-py==4.14.0
## Trace
opentelemetry-api==1.39.1

View file

@ -1,8 +1,8 @@
services:
playwright:
image: mcr.microsoft.com/playwright:v1.57.0-noble # Version must match requirements.txt
image: mcr.microsoft.com/playwright:v1.58.0-noble # Version must match requirements.txt
container_name: playwright
command: npx -y playwright@1.57.0 run-server --port 3000 --host 0.0.0.0
command: npx -y playwright@1.58.0 run-server --port 3000 --host 0.0.0.0
open-webui:
environment:

View file

@ -9,7 +9,7 @@ dependencies = [
"fastapi==0.128.0",
"uvicorn[standard]==0.40.0",
"pydantic==2.12.5",
"python-multipart==0.0.21",
"python-multipart==0.0.22",
"itsdangerous==2.2.0",
"python-socketio==5.16.0",
@ -21,21 +21,21 @@ dependencies = [
"authlib==1.6.6",
"requests==2.32.5",
"aiohttp==3.13.2",
"aiohttp==3.13.2" # do not update to 3.13.3 - broken,
"async-timeout",
"aiocache",
"aiofiles",
"starlette-compress==1.6.1",
"starlette-compress==1.7.0",
"httpx[socks,http2,zstd,cli,brotli]==0.28.1",
"starsessions[redis]==2.2.1",
"python-mimeparse==2.0.0",
"sqlalchemy==2.0.45",
"alembic==1.17.2",
"peewee==3.18.3",
"sqlalchemy==2.0.46",
"alembic==1.18.3",
"peewee==3.19.0",
"peewee-migrate==1.14.3",
"pycrdt==0.12.44",
"pycrdt==0.12.45",
"redis",
"APScheduler==3.11.2",
@ -45,40 +45,40 @@ dependencies = [
"asgiref==3.11.0",
"tiktoken",
"mcp==1.25.0",
"mcp==1.26.0",
"openai",
"anthropic",
"google-genai==1.56.0",
"google-genai==1.60.0",
"langchain==1.2.0",
"langchain==1.2.7",
"langchain-community==0.4.1",
"langchain-classic==1.0.1",
"langchain-text-splitters==1.1.0",
"fake-useragent==2.2.0",
"chromadb==1.4.0",
"chromadb==1.4.1",
"opensearch-py==3.1.0",
"PyMySQL==1.1.2",
"boto3==1.42.21",
"boto3==1.42.38",
"transformers==4.57.3",
"sentence-transformers==5.2.0",
"transformers==4.57.6",
"sentence-transformers==5.2.2",
"accelerate",
"pyarrow==20.0.0", # fix: pin pyarrow version to 20 for rpi compatibility #15897
"einops==0.8.1",
"einops==0.8.2",
"ftfy==6.3.1",
"chardet==5.2.0",
"pypdf==6.5.0",
"pypdf==6.6.2",
"fpdf2==2.8.5",
"pymdown-extensions==10.20",
"pymdown-extensions==10.20.1",
"docx2txt==0.9",
"python-pptx==1.0.2",
"unstructured==0.18.24",
"unstructured==0.18.31",
"msoffcrypto-tool==5.4.2",
"nltk==3.9.2",
"Markdown==3.10",
"Markdown==3.10.1",
"pypandoc==1.16.2",
"pandas==2.3.3",
"openpyxl==3.1.5",
@ -91,15 +91,15 @@ dependencies = [
"azure-ai-documentintelligence==1.0.2",
"pillow==12.1.0",
"opencv-python-headless==4.12.0.88",
"opencv-python-headless==4.13.0.90",
"rapidocr-onnxruntime==1.4.4",
"rank-bm25==0.2.2",
"onnxruntime==1.23.2",
"faster-whisper==1.2.1",
"black==25.12.0",
"youtube-transcript-api==1.2.3",
"black==26.1.0",
"youtube-transcript-api==1.2.4",
"pytube==15.0.0",
"pydub",
@ -110,10 +110,10 @@ dependencies = [
"google-auth-oauthlib",
"googleapis-common-protos==1.72.0",
"google-cloud-storage==3.7.0",
"google-cloud-storage==3.8.0",
"azure-identity==1.25.1",
"azure-storage-blob==12.27.1",
"azure-storage-blob==12.28.0",
"ldap3==2.9.1",
]
@ -145,18 +145,18 @@ all = [
"docker~=7.1.0",
"pytest~=8.3.2",
"pytest-docker~=3.2.5",
"playwright==1.57.0", # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary
"playwright==1.58.0", # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary
"elasticsearch==9.2.1",
"qdrant-client==1.16.2",
"weaviate-client==4.19.2",
"pymilvus==2.6.6",
"pymilvus==2.6.8",
"pinecone==6.0.2",
"oracledb==3.4.1",
"oracledb==3.4.2",
"colbert-ai==0.2.22",
"firecrawl-py==4.12.0",
"firecrawl-py==4.14.0",
"azure-search-documents==11.6.0",
]

View file

@ -255,6 +255,55 @@ export const getArchivedChatList = async (
}));
};
export const getSharedChatList = async (
token: string = '',
page: number = 1,
filter?: object
) => {
let error = null;
const searchParams = new URLSearchParams();
searchParams.append('page', `${page}`);
if (filter) {
Object.entries(filter).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, value.toString());
}
});
}
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/shared?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res.map((chat) => ({
...chat,
time_range: getTimeRange(chat.updated_at)
}));
};
export const getAllChats = async (token: string) => {
let error = null;

View file

@ -175,6 +175,44 @@ export const getFiles = async (token: string = '') => {
return res;
};
export const searchFiles = async (
token: string,
filename: string = '*',
skip: number = 0,
limit: number = 50
) => {
let error = null;
const searchParams = new URLSearchParams();
searchParams.append('filename', filename);
searchParams.append('skip', String(skip));
searchParams.append('limit', String(limit));
const res = await fetch(`${WEBUI_API_BASE_URL}/files/search?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return [];
});
if (error) {
throw error;
}
return res;
};
export const getFileById = async (token: string, id: string) => {
let error = null;

View file

@ -217,7 +217,63 @@ export const imageGenerations = async (token: string = '', prompt: string) => {
.catch((err) => {
console.error(err);
if ('detail' in err) {
error = err.detail;
if (Array.isArray(err.detail)) {
error = err.detail.map((e: { msg?: string }) => e.msg || JSON.stringify(e)).join(', ');
} else {
error = err.detail;
}
} else {
error = 'Server connection failed';
}
return null;
});
if (error) {
throw error;
}
return res;
};
export const imageEdits = async (
token: string = '',
images: string | string[],
prompt: string,
model?: string,
size?: string,
n?: number
) => {
let error = null;
const res = await fetch(`${IMAGES_API_BASE_URL}/edit`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({
form_data: {
image: images,
prompt,
...(model && { model }),
...(size && { size }),
...(n && { n })
}
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.error(err);
if ('detail' in err) {
if (Array.isArray(err.detail)) {
error = err.detail.map((e: { msg?: string }) => e.msg || JSON.stringify(e)).join(', ');
} else {
error = err.detail;
}
} else {
error = 'Server connection failed';
}

View file

@ -1,10 +1,48 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
type PromptItem = {
id?: string; // Prompt ID
command: string;
title: string;
name: string; // Changed from title
content: string;
data?: object | null;
meta?: object | null;
access_control?: null | object;
version_id?: string | null; // Active version
commit_message?: string | null; // For history tracking
is_production?: boolean; // Whether to set new version as production
};
type PromptHistoryItem = {
id: string;
prompt_id: string;
parent_id: string | null;
snapshot: {
name: string;
content: string;
command: string;
data: object;
meta: object;
access_control: object | null;
};
user_id: string;
commit_message: string | null;
created_at: number;
user?: {
id: string;
name: string;
email: string;
};
};
type PromptDiff = {
from_id: string;
to_id: string;
from_snapshot: object;
to_snapshot: object;
content_diff: string[];
name_changed: boolean;
access_control_changed: boolean;
};
export const createNewPrompt = async (token: string, prompt: PromptItem) => {
@ -19,7 +57,7 @@ export const createNewPrompt = async (token: string, prompt: PromptItem) => {
},
body: JSON.stringify({
...prompt,
command: `/${prompt.command}`
command: prompt.command.startsWith('/') ? prompt.command.slice(1) : prompt.command
})
})
.then(async (res) => {
@ -70,7 +108,95 @@ export const getPrompts = async (token: string = '') => {
return res;
};
export const getPromptTags = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/tags`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getPromptItems = async (
token: string = '',
query: string | null,
viewOption: string | null,
selectedTag: string | null,
orderBy: string | null,
direction: string | null,
page: number
) => {
let error = null;
const searchParams = new URLSearchParams();
if (query) {
searchParams.append('query', query);
}
if (viewOption) {
searchParams.append('view_option', viewOption);
}
if (selectedTag) {
searchParams.append('tag', selectedTag);
}
if (orderBy) {
searchParams.append('order_by', orderBy);
}
if (direction) {
searchParams.append('direction', direction);
}
if (page) {
searchParams.append('page', page.toString());
}
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/list?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getPromptList = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/list`, {
@ -104,6 +230,8 @@ export const getPromptList = async (token: string = '') => {
export const getPromptByCommand = async (token: string, command: string) => {
let error = null;
command = command.charAt(0) === '/' ? command.slice(1) : command;
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/command/${command}`, {
method: 'GET',
headers: {
@ -133,20 +261,16 @@ export const getPromptByCommand = async (token: string, command: string) => {
return res;
};
export const updatePromptByCommand = async (token: string, prompt: PromptItem) => {
export const getPromptById = async (token: string, promptId: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/command/${prompt.command}/update`, {
method: 'POST',
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/id/${promptId}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
...prompt,
command: `/${prompt.command}`
})
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
@ -169,12 +293,113 @@ export const updatePromptByCommand = async (token: string, prompt: PromptItem) =
return res;
};
export const deletePromptByCommand = async (token: string, command: string) => {
export const updatePromptById = async (token: string, prompt: PromptItem) => {
let error = null;
command = command.charAt(0) === '/' ? command.slice(1) : command;
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/id/${prompt.id}/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(prompt)
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/command/${command}/delete`, {
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updatePromptMetadata = async (
token: string,
promptId: string,
name: string,
command: string,
tags: string[] = []
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/id/${promptId}/update/meta`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ name, command, tags })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const setProductionPromptVersion = async (
token: string,
promptId: string,
version_id: string
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/id/${promptId}/update/version`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
version_id: version_id
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const deletePromptById = async (token: string, promptId: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/id/${promptId}/delete`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
@ -202,3 +427,188 @@ export const deletePromptByCommand = async (token: string, command: string) => {
return res;
};
////////////////////////////
// Prompt History APIs
////////////////////////////
export const getPromptHistory = async (
token: string,
promptId: string,
page: number = 0
): Promise<PromptHistoryItem[]> => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/prompts/id/${promptId}/history?page=${page}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const deletePromptHistoryVersion = async (
token: string,
promptId: string,
historyId: string
): Promise<boolean> => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/prompts/id/${promptId}/history/${historyId}`,
{
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return false;
});
if (error) {
throw error;
}
return res;
};
export const getPromptHistoryEntry = async (
token: string,
promptId: string,
historyId: string
): Promise<PromptHistoryItem> => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/prompts/id/${promptId}/history/${historyId}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const restorePromptFromHistory = async (
token: string,
promptId: string,
historyId: string,
commitMessage?: string
) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/prompts/id/${promptId}/history/${historyId}/restore`,
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
commit_message: commitMessage
})
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getPromptDiff = async (
token: string,
promptId: string,
fromId: string,
toId: string
): Promise<PromptDiff> => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/prompts/id/${promptId}/history/diff?from_id=${fromId}&to_id=${toId}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};

View file

@ -302,9 +302,11 @@
<div class="flex flex-col items-start gap-0.5 h-full">
<div class="flex flex-col h-full">
{#if feedback.data?.sibling_model_ids}
<div class="font-medium text-gray-600 dark:text-gray-400 flex-1">
<Tooltip content={feedback.data?.model_id} placement="top-start">
<div class="font-medium text-gray-600 dark:text-gray-400 flex-1 line-clamp-1">
{feedback.data?.model_id}
</div>
</Tooltip>
<Tooltip content={feedback.data.sibling_model_ids.join(', ')}>
<div class=" text-[0.65rem] text-gray-600 dark:text-gray-400 line-clamp-1">
@ -320,11 +322,11 @@
</div>
</Tooltip>
{:else}
<div
class=" text-sm font-medium text-gray-600 dark:text-gray-400 flex-1 py-1.5"
>
<Tooltip content={feedback.data?.model_id} placement="top-start">
<div class="text-sm font-medium text-gray-600 dark:text-gray-400 flex-1 py-1.5 line-clamp-1">
{feedback.data?.model_id}
</div>
</Tooltip>
{/if}
</div>
</div>

View file

@ -182,7 +182,9 @@
alt={model.name}
class="size-5 rounded-full object-cover"
/>
<span class="font-medium text-gray-800 dark:text-gray-200">{model.name}</span>
<Tooltip content={`${model.name} (${model.id})`} placement="top-start">
<span class="font-medium text-gray-800 dark:text-gray-200 line-clamp-1">{model.name}</span>
</Tooltip>
</div>
</td>
<td class="px-3 py-1.5 text-right font-medium text-gray-900 dark:text-white">

View file

@ -4,6 +4,7 @@
import { getModelHistory } from '$lib/apis/evaluations';
import ModelActivityChart from './ModelActivityChart.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
export let show = false;
export let model = null;
@ -60,9 +61,11 @@
<Modal size="md" bind:show>
{#if model}
<div class="flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
<div class="text-lg font-medium self-center">
{model.name}
</div>
<Tooltip content={`${model.name} (${model.id})`} placement="top-start">
<div class="text-lg font-medium self-center line-clamp-1">
{model.name}
</div>
</Tooltip>
<button class="self-center" on:click={close} aria-label="Close">
<XMark className={'size-5'} />
</button>

View file

@ -4,7 +4,7 @@
const { saveAs } = fileSaver;
import { WEBUI_NAME, config, functions as _functions, models, settings, user } from '$lib/stores';
import { onMount, getContext, tick } from 'svelte';
import { onMount, getContext, tick, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import {
@ -53,6 +53,7 @@
let viewOption = '';
let query = '';
let searchDebounceTimer: ReturnType<typeof setTimeout>;
let selectedTag = '';
let selectedType = '';
@ -70,12 +71,14 @@
let functions = null;
let filteredItems = [];
$: if (
functions &&
query !== undefined &&
selectedType !== undefined &&
viewOption !== undefined
) {
$: if (query !== undefined) {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
setFilteredItems();
}, 300);
}
$: if (functions && selectedType !== undefined && viewOption !== undefined) {
setFilteredItems();
}
@ -86,7 +89,10 @@
(selectedType !== '' ? f.type === selectedType : true) &&
(query === '' ||
f.name.toLowerCase().includes(query.toLowerCase()) ||
f.id.toLowerCase().includes(query.toLowerCase())) &&
f.id.toLowerCase().includes(query.toLowerCase()) ||
(f.user?.name || '').toLowerCase().includes(query.toLowerCase()) ||
(f.user?.email || '').toLowerCase().includes(query.toLowerCase()) ||
(f.user?.username || '').toLowerCase().includes(query.toLowerCase())) &&
(viewOption === '' ||
(viewOption === 'created' && f.user_id === $user?.id) ||
(viewOption === 'shared' && f.user_id !== $user?.id))
@ -236,6 +242,10 @@
window.removeEventListener('blur-sm', onBlur);
};
});
onDestroy(() => {
clearTimeout(searchDebounceTimer);
});
</script>
<svelte:head>

View file

@ -362,6 +362,30 @@
</div>
</div>
</div>
<div class="flex w-full mt-2">
<div class="flex-1 flex justify-between">
<div class=" self-center text-xs font-medium">
<Tooltip
content={$i18n.t(
'Page mode creates one document per page. Single mode combines all pages into one document for better chunking across page boundaries.'
)}
placement="top-start"
>
{$i18n.t('PDF Loader Mode')}
</Tooltip>
</div>
<div class="">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={RAGConfig.PDF_LOADER_MODE}
>
<option value="page">{$i18n.t('Page')}</option>
<option value="single">{$i18n.t('Single')}</option>
</select>
</div>
</div>
</div>
{:else if RAGConfig.CONTENT_EXTRACTION_ENGINE === 'datalab_marker'}
<div class="my-0.5 flex gap-2 pr-2">
<Tooltip

View file

@ -40,6 +40,10 @@
import Eye from '$lib/components/icons/Eye.svelte';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { goto } from '$app/navigation';
import { DropdownMenu } from 'bits-ui';
import { flyAndScale } from '$lib/utils/transitions';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import AdminViewSelector from './Models/AdminViewSelector.svelte';
let shiftKey = false;
@ -58,21 +62,43 @@
let showConfigModal = false;
let showManageModal = false;
let viewOption = ''; // '' = All, 'enabled', 'disabled', 'visible', 'hidden'
$: if (models) {
filteredModels = models
.filter((m) => searchValue === '' || m.name.toLowerCase().includes(searchValue.toLowerCase()))
.filter((m) => {
if (viewOption === 'enabled') return m?.is_active ?? true;
if (viewOption === 'disabled') return !(m?.is_active ?? true);
if (viewOption === 'visible') return !(m?.meta?.hidden ?? false);
if (viewOption === 'hidden') return m?.meta?.hidden === true;
return true; // All
})
.sort((a, b) => {
// // Check if either model is inactive and push them to the bottom
// if ((a.is_active ?? true) !== (b.is_active ?? true)) {
// return (b.is_active ?? true) - (a.is_active ?? true);
// }
// If both models' active states are the same, sort alphabetically
return (a?.name ?? a?.id ?? '').localeCompare(b?.name ?? b?.id ?? '');
});
}
let searchValue = '';
const enableAllHandler = async () => {
const modelsToEnable = filteredModels.filter((m) => !(m.is_active ?? true));
// Optimistic UI update
modelsToEnable.forEach((m) => (m.is_active = true));
models = models;
// Sync with server
await Promise.all(modelsToEnable.map((model) => toggleModelById(localStorage.token, model.id)));
};
const disableAllHandler = async () => {
const modelsToDisable = filteredModels.filter((m) => m.is_active ?? true);
// Optimistic UI update
modelsToDisable.forEach((m) => (m.is_active = false));
models = models;
// Sync with server
await Promise.all(modelsToDisable.map((model) => toggleModelById(localStorage.token, model.id)));
};
const downloadModels = async (models) => {
let blob = new Blob([JSON.stringify(models)], {
type: 'application/json'
@ -275,41 +301,48 @@
{#if selectedModelId === null}
<div class="flex flex-col gap-1 mt-1.5 mb-2">
<div class="flex justify-between items-center">
<div class="flex items-center md:self-center text-xl font-medium px-0.5 gap-2">
{$i18n.t('Models')}
<span class="text-lg font-medium text-gray-500 dark:text-gray-300"
>{filteredModels.length}</span
>
<div class="flex items-center md:self-center text-xl font-medium px-0.5 gap-2 shrink-0">
<div>
{$i18n.t('Models')}
</div>
<div class="text-lg font-medium text-gray-500 dark:text-gray-500">
{filteredModels.length}
</div>
</div>
<div class="flex items-center gap-1.5">
<Tooltip content={$i18n.t('Manage Models')}>
<button
class=" p-1 rounded-full flex gap-1 items-center"
type="button"
on:click={() => {
showManageModal = true;
}}
>
<Download />
</button>
</Tooltip>
<div class="flex w-full justify-end gap-1.5">
<button
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-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
type="button"
on:click={() => {
showManageModal = true;
}}
>
<div class=" self-center font-medium line-clamp-1">
{$i18n.t('Manage')}
</div>
</button>
<Tooltip content={$i18n.t('Settings')}>
<button
class=" p-1 rounded-full flex gap-1 items-center"
type="button"
on:click={() => {
showConfigModal = true;
}}
>
<Cog6 />
</button>
</Tooltip>
<button
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-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
type="button"
on:click={() => {
showConfigModal = true;
}}
>
<div class=" self-center font-medium line-clamp-1">
{$i18n.t('Settings')}
</div>
</button>
</div>
</div>
</div>
<div class=" flex flex-1 items-center w-full space-x-2">
<div
class="py-2 bg-white dark:bg-gray-900 rounded-3xl border border-gray-100/30 dark:border-gray-850/30"
>
<div class="px-3.5 flex flex-1 items-center w-full space-x-2 py-0.5 pb-2">
<div class="flex flex-1 items-center">
<div class=" self-center ml-1 mr-3">
<Search className="size-3.5" />
@ -333,156 +366,215 @@
{/if}
</div>
</div>
</div>
<div class=" my-2 mb-5" id="model-list">
{#if models.length > 0}
{#each filteredModels as model, modelIdx (`${model.id}-${modelIdx}`)}
<div
class=" flex space-x-4 cursor-pointer w-full px-3 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-lg transition {model
?.meta?.hidden
? 'opacity-50 dark:opacity-50'
: ''}"
id="model-item-{model.id}"
>
<div class="px-3 flex w-full items-center bg-transparent overflow-x-auto scrollbar-none">
<div
class="flex gap-0.5 w-fit text-center text-sm rounded-full bg-transparent whitespace-nowrap"
>
<AdminViewSelector bind:value={viewOption} />
</div>
<div class="flex-1"></div>
<Dropdown>
<Tooltip content={$i18n.t('Actions')}>
<button
class=" flex flex-1 text-left space-x-3.5 cursor-pointer w-full"
class="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition"
type="button"
on:click={() => {
selectedModelId = model.id;
}}
>
<div class=" self-center w-8">
<div
class=" rounded-full object-cover {(model?.is_active ?? true)
? ''
: 'opacity-50 dark:opacity-50'} "
>
<img
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${model.id}`}
alt="modelfile profile"
class=" rounded-full w-full h-auto object-cover"
/>
</div>
</div>
<div class=" flex-1 self-center {(model?.is_active ?? true) ? '' : 'text-gray-500'}">
<Tooltip
content={marked.parse(
!!model?.meta?.description
? model?.meta?.description
: model?.ollama?.digest
? `${model?.ollama?.digest} **(${model?.ollama?.modified_at})**`
: model.id
)}
className=" w-fit"
placement="top-start"
>
<div class=" font-semibold line-clamp-1">{model.name}</div>
</Tooltip>
<div class=" text-xs overflow-hidden text-ellipsis line-clamp-1 text-gray-500">
<span class=" line-clamp-1">
{!!model?.meta?.description
? model?.meta?.description
: model?.ollama?.digest
? `${model.id} (${model?.ollama?.digest})`
: model.id}
</span>
</div>
</div>
<EllipsisHorizontal className="size-4" />
</button>
<div class="flex flex-row gap-0.5 items-center self-center">
{#if shiftKey}
<Tooltip content={model?.meta?.hidden ? $i18n.t('Show') : $i18n.t('Hide')}>
</Tooltip>
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[170px] rounded-xl p-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
sideOffset={-2}
side="bottom"
align="end"
transition={flyAndScale}
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
enableAllHandler();
}}
>
<Eye className="size-4" />
<div class="flex items-center">{$i18n.t('Enable All')}</div>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
disableAllHandler();
}}
>
<EyeSlash className="size-4" />
<div class="flex items-center">{$i18n.t('Disable All')}</div>
</DropdownMenu.Item>
</DropdownMenu.Content>
</div>
</Dropdown>
</div>
<div class="px-3 my-2" id="model-list">
{#if filteredModels.length > 0}
{#each filteredModels as model, modelIdx (`${model.id}-${modelIdx}`)}
<div
class=" flex space-x-4 cursor-pointer w-full px-3 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl transition {model
?.meta?.hidden
? 'opacity-50 dark:opacity-50'
: ''}"
id="model-item-{model.id}"
>
<button
class=" flex flex-1 text-left space-x-3.5 cursor-pointer w-full"
type="button"
on:click={() => {
selectedModelId = model.id;
}}
>
<div class=" self-center w-8">
<div
class=" rounded-full object-cover {(model?.is_active ?? true)
? ''
: 'opacity-50 dark:opacity-50'} "
>
<img
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${model.id}`}
alt="modelfile profile"
class=" rounded-full w-full h-auto object-cover"
/>
</div>
</div>
<div
class=" flex-1 self-center {(model?.is_active ?? true) ? '' : 'text-gray-500'}"
>
<Tooltip
content={marked.parse(
!!model?.meta?.description
? model?.meta?.description
: model?.ollama?.digest
? `${model?.ollama?.digest} **(${model?.ollama?.modified_at})**`
: model.id
)}
className=" w-fit"
placement="top-start"
>
<div class=" font-semibold line-clamp-1">{model.name}</div>
</Tooltip>
<div class=" text-xs overflow-hidden text-ellipsis line-clamp-1 text-gray-500">
<span class=" line-clamp-1">
{!!model?.meta?.description
? model?.meta?.description
: model?.ollama?.digest
? `${model.id} (${model?.ollama?.digest})`
: model.id}
</span>
</div>
</div>
</button>
<div class="flex flex-row gap-0.5 items-center self-center">
{#if shiftKey}
<Tooltip content={model?.meta?.hidden ? $i18n.t('Show') : $i18n.t('Hide')}>
<button
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={() => {
hideModelHandler(model);
}}
>
{#if model?.meta?.hidden}
<EyeSlash />
{:else}
<Eye />
{/if}
</button>
</Tooltip>
{:else}
<button
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={() => {
hideModelHandler(model);
selectedModelId = model.id;
}}
>
{#if model?.meta?.hidden}
<EyeSlash />
{:else}
<Eye />
{/if}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125"
/>
</svg>
</button>
</Tooltip>
{:else}
<button
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={() => {
selectedModelId = model.id;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125"
/>
</svg>
</button>
<ModelMenu
user={$user}
{model}
exportHandler={() => {
exportModelHandler(model);
}}
hideHandler={() => {
hideModelHandler(model);
}}
pinModelHandler={() => {
pinModelHandler(model.id);
}}
copyLinkHandler={() => {
copyLinkHandler(model);
}}
cloneHandler={() => {
cloneHandler(model);
}}
onClose={() => {}}
>
<button
class="self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
<ModelMenu
user={$user}
{model}
exportHandler={() => {
exportModelHandler(model);
}}
hideHandler={() => {
hideModelHandler(model);
}}
pinModelHandler={() => {
pinModelHandler(model.id);
}}
copyLinkHandler={() => {
copyLinkHandler(model);
}}
cloneHandler={() => {
cloneHandler(model);
}}
onClose={() => {}}
>
<EllipsisHorizontal className="size-5" />
</button>
</ModelMenu>
<button
class="self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
>
<EllipsisHorizontal className="size-5" />
</button>
</ModelMenu>
<div class="ml-1">
<Tooltip
content={(model?.is_active ?? true) ? $i18n.t('Enabled') : $i18n.t('Disabled')}
>
<Switch
bind:state={model.is_active}
on:change={async () => {
toggleModelHandler(model);
}}
/>
</Tooltip>
</div>
{/if}
<div class="ml-1">
<Tooltip
content={(model?.is_active ?? true)
? $i18n.t('Enabled')
: $i18n.t('Disabled')}
>
<Switch
bind:state={model.is_active}
on:change={async () => {
toggleModelHandler(model);
}}
/>
</Tooltip>
</div>
{/if}
</div>
</div>
{/each}
{:else}
<div class=" w-full h-full flex flex-col justify-center items-center my-16 mb-24">
<div class="max-w-md text-center">
<div class=" text-3xl mb-3">😕</div>
<div class=" text-lg font-medium mb-1">{$i18n.t('No models found')}</div>
<div class=" text-gray-500 text-center text-xs">
{$i18n.t('Try adjusting your search or filter to find what you are looking for.')}
</div>
</div>
</div>
{/each}
{:else}
<div class="flex flex-col items-center justify-center w-full h-20">
<div class="text-gray-500 dark:text-gray-400 text-xs">
{$i18n.t('No models found')}
</div>
</div>
{/if}
{/if}
</div>
</div>
{#if $user?.role === 'admin'}

View file

@ -0,0 +1,63 @@
<script lang="ts">
import { Select } from 'bits-ui';
import { getContext } from 'svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import Check from '$lib/components/icons/Check.svelte';
const i18n = getContext('i18n');
export let value = '';
export let placeholder = $i18n.t('Select view');
export let onChange: (value: string) => void = () => {};
const items = [
{ value: '', label: $i18n.t('All') },
{ value: 'enabled', label: $i18n.t('Enabled') },
{ value: 'disabled', label: $i18n.t('Disabled') },
{ value: 'visible', label: $i18n.t('Visible') },
{ value: 'hidden', label: $i18n.t('Hidden') }
];
</script>
<Select.Root
selected={items.find((item) => item.value === value)}
{items}
onSelectedChange={(selectedItem) => {
value = selectedItem.value;
onChange(value);
}}
>
<Select.Trigger
class="relative w-full flex items-center gap-0.5 px-2.5 py-1.5 bg-gray-50 dark:bg-gray-850 rounded-xl"
aria-label={placeholder}
>
<Select.Value
class="inline-flex h-input px-0.5 w-full outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden"
{placeholder}
/>
<ChevronDown className="size-3.5" strokeWidth="2.5" />
</Select.Trigger>
<Select.Content
class="rounded-2xl min-w-[170px] p-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
sameWidth={false}
align="start"
>
{#each items as item}
<Select.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
value={item.value}
label={item.label}
>
{item.label}
{#if value === item.value}
<div class="ml-auto">
<Check />
</div>
{/if}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>

View file

@ -50,7 +50,7 @@
<div class="flex items-center gap-1">
<EllipsisVertical className="size-4 cursor-move model-item-handle" />
<div class=" text-sm flex-1 py-1 rounded-lg">
<div class=" text-sm flex-1 py-1 rounded-lg line-clamp-1">
{#if $models.find((model) => model.id === modelId)}
{$models.find((model) => model.id === modelId).name}
{:else}

View file

@ -7,6 +7,7 @@
import { toast } from 'svelte-sonner';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
const i18n = getContext('i18n');
@ -35,7 +36,8 @@
'perplexity',
'sougou',
'firecrawl',
'external'
'external',
'yandex'
];
let webLoaderEngines = ['playwright', 'firecrawl', 'tavily', 'external'];
@ -735,6 +737,53 @@
/>
</div>
</div>
{:else if webConfig.WEB_SEARCH_ENGINE === 'yandex'}
<div class="mb-2.5 flex w-full flex-col">
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Yandex Web Search URL')}
</div>
<div class="flex w-full">
<div class="flex-1">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="text"
placeholder={$i18n.t('Enter Yandex Web Search URL')}
bind:value={webConfig.YANDEX_WEB_SEARCH_URL}
autocomplete="off"
/>
</div>
</div>
</div>
<div class="mt-2">
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Yandex Web Search API Key')}
</div>
<SensitiveInput
placeholder={$i18n.t('Enter Yandex Web Search API Key')}
bind:value={webConfig.YANDEX_WEB_SEARCH_API_KEY}
/>
</div>
<div class="mb-2.5">
<div class=" mb-1 text-xs font-medium">{$i18n.t('Yandex Web Search config')}</div>
<Tooltip
content={$i18n.t('Leave empty to use the default config, or enter a valid json (see https://yandex.cloud/en/docs/search-api/api-ref/WebSearch/search#yandex.cloud.searchapi.v2.WebSearchRequest)')}
placement="top-start"
>
<Textarea
bind:value={webConfig.YANDEX_WEB_SEARCH_CONFIG}
placeholder={$i18n.t(
'Leave empty to use the default config, or enter a valid json (see https://yandex.cloud/en/docs/search-api/api-ref/WebSearch/search#yandex.cloud.searchapi.v2.WebSearchRequest)'
)}
/>
</Tooltip>
</div>
</div>
{/if}
{#if webConfig.WEB_SEARCH_ENGINE === 'duckduckgo'}

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { getContext } from 'svelte';
import { getContext, onDestroy } from 'svelte';
const i18n = getContext('i18n');
import dayjs from 'dayjs';
@ -30,6 +30,7 @@
let total = null;
let query = '';
let searchDebounceTimer: ReturnType<typeof setTimeout>;
let orderBy = 'created_at'; // default sort key
let direction = 'desc'; // default sort order
@ -79,13 +80,21 @@
getUserList();
};
$: if (page !== null && query !== null && orderBy !== null && direction !== null) {
$: if (page !== null && orderBy !== null && direction !== null) {
getUserList();
}
$: if (query) {
page = 1;
$: if (query !== undefined) {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
page = 1;
getUserList();
}, 300);
}
onDestroy(() => {
clearTimeout(searchDebounceTimer);
});
</script>
<div class=" max-h-full h-full w-full flex flex-col overflow-y-hidden">

View file

@ -1,8 +1,8 @@
<script>
<script lang="ts">
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { WEBUI_NAME, config, user, showSidebar } from '$lib/stores';
import { goto } from '$app/navigation';
import { onMount, getContext } from 'svelte';
import { onMount, getContext, onDestroy } from 'svelte';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
@ -43,6 +43,7 @@
let total = null;
let query = '';
let searchDebounceTimer: ReturnType<typeof setTimeout>;
let orderBy = 'created_at'; // default sort key
let direction = 'asc'; // default sort order
@ -97,9 +98,21 @@
}
};
$: if (query !== null && page !== null && orderBy !== null && direction !== null) {
$: if (query !== undefined) {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
page = 1;
getUserList();
}, 300);
}
$: if (page !== null && orderBy !== null && direction !== null) {
getUserList();
}
onDestroy(() => {
clearTimeout(searchDebounceTimer);
});
</script>
<ConfirmDialog

View file

@ -1,4 +1,4 @@
<script>
<script lang="ts">
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { WEBUI_NAME, config, user as _user, showSidebar } from '$lib/stores';
import { goto } from '$app/navigation';
@ -38,6 +38,8 @@
let total = null;
let query = '';
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let orderBy = 'name'; // default sort key
let direction = 'asc'; // default sort order
@ -77,13 +79,16 @@
}
};
$: if (
channel !== null &&
page !== null &&
query !== null &&
orderBy !== null &&
direction !== null
) {
// Debounce only query changes
$: if (query !== undefined && channel !== null) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
getUserList();
}, 300);
}
// Immediate response to page/sort changes
$: if (channel !== null && page && orderBy && direction) {
getUserList();
}
</script>

View file

@ -147,6 +147,14 @@
text = text.replaceAll('{{USER_NAME}}', name);
}
if (text.includes('{{USER_EMAIL}}')) {
const email = sessionUser?.email || '';
if (email) {
text = text.replaceAll('{{USER_EMAIL}}', email);
}
}
if (text.includes('{{USER_BIO}}')) {
const bio = sessionUser?.bio || '';

View file

@ -1282,6 +1282,11 @@
const modelId = selectedModels[0];
const model = $models.filter((m) => m.id === modelId).at(0);
if (!model) {
toast.error($i18n.t('Model not found'));
return;
}
const messages = createMessagesList(history, history.currentId);
const parentMessage = messages.length !== 0 ? messages.at(-1) : null;
@ -1398,7 +1403,12 @@
};
const chatCompletionEventHandler = async (data, message, chatId) => {
const { id, done, choices, content, sources, selected_model_id, error, usage } = data;
const { id, done, choices, content, output, sources, selected_model_id, error, usage } = data;
// Store raw OR-aligned output items from backend
if (output) {
message.output = output;
}
if (error) {
await handleOpenAIError(error, message);
@ -1894,7 +1904,9 @@
: undefined,
..._messages.map((message) => ({
...message,
content: processDetails(message.content)
content: processDetails(message.content),
// Include output for temp chats (backend will use it and strip before LLM)
...(message.output ? { output: message.output } : {})
}))
].filter((message) => message);

View file

@ -172,7 +172,7 @@
>{$i18n.t('Select a tool')}</option
>
{#each $tools.filter((tool) => !tool?.id?.startsWith('server:')) as tool, toolIdx}
{#each $tools.filter((tool) => !tool?.id?.startsWith('server:')).sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')) as tool, toolIdx}
<option value={tool.id} class="bg-gray-100 dark:bg-gray-800">{tool.name}</option>
{/each}
{:else if tab === 'functions'}
@ -180,7 +180,7 @@
>{$i18n.t('Select a function')}</option
>
{#each $functions as func, funcIdx}
{#each $functions.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')) as func, funcIdx}
<option value={func.id} class="bg-gray-100 dark:bg-gray-800">{func.name}</option>
{/each}
{/if}

View file

@ -221,6 +221,14 @@
text = text.replaceAll('{{USER_NAME}}', name);
}
if (text.includes('{{USER_EMAIL}}')) {
const email = sessionUser?.email || '';
if (email) {
text = text.replaceAll('{{USER_EMAIL}}', email);
}
}
if (text.includes('{{USER_BIO}}')) {
const bio = sessionUser?.bio || '';

View file

@ -25,6 +25,7 @@
let selectedIdx = 0;
let items = [];
let searchDebounceTimer: ReturnType<typeof setTimeout>;
export let filteredItems = [];
$: filteredItems = [
@ -69,10 +70,17 @@
$: items = [...folderItems, ...knowledgeItems, ...fileItems];
$: if (query !== null) {
getItems();
$: if (query !== undefined) {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
getItems();
}, 200);
}
onDestroy(() => {
clearTimeout(searchDebounceTimer);
});
const getItems = () => {
getFolderItems();
getKnowledgeItems();

View file

@ -11,10 +11,23 @@
let selectedPromptIdx = 0;
export let filteredItems = [];
let searchDebounceTimer: ReturnType<typeof setTimeout>;
let debouncedQuery = '';
$: if (query !== undefined) {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
debouncedQuery = query;
}, 200);
}
onDestroy(() => {
clearTimeout(searchDebounceTimer);
});
$: filteredItems = prompts
.filter((p) => p.command.toLowerCase().includes(query.toLowerCase()))
.sort((a, b) => a.title.localeCompare(b.title));
.filter((p) => p.command.toLowerCase().includes(debouncedQuery.toLowerCase()))
.sort((a, b) => a.name.localeCompare(b.name));
$: if (query) {
selectedPromptIdx = 0;
@ -42,7 +55,7 @@
{#if filteredItems.length > 0}
<div class=" space-y-0.5 scrollbar-hidden">
{#each filteredItems as promptItem, promptIdx}
<Tooltip content={promptItem.title} placement="top-start">
<Tooltip content={promptItem.name} placement="top-start">
<button
class=" px-3 py-1 rounded-xl w-full text-left {promptIdx === selectedPromptIdx
? ' bg-gray-50 dark:bg-gray-800 selected-command-option-button'
@ -62,7 +75,7 @@
</span>
<span class=" text-xs text-gray-600 dark:text-gray-100">
{promptItem.title}
{promptItem.name}
</span>
</button>
</Tooltip>

View file

@ -4,7 +4,7 @@
import { fly } from 'svelte/transition';
import { flyAndScale } from '$lib/utils/transitions';
import { config, user, tools as _tools, mobile, knowledge, chats } from '$lib/stores';
import { config, user, tools as _tools, mobile, knowledge } from '$lib/stores';
import { getKnowledgeBases } from '$lib/apis/knowledge';
import { createPicker } from '$lib/utils/google-drive-picker';
@ -269,37 +269,35 @@
</button>
</Tooltip>
{#if ($chats ?? []).length > 0}
<Tooltip
content={fileUploadCapableModels.length !== selectedModels.length
? $i18n.t('Model(s) do not support file upload')
: !fileUploadEnabled
? $i18n.t('You do not have permission to upload files.')
: ''}
className="w-full"
<Tooltip
content={fileUploadCapableModels.length !== selectedModels.length
? $i18n.t('Model(s) do not support file upload')
: !fileUploadEnabled
? $i18n.t('You do not have permission to upload files.')
: ''}
className="w-full"
>
<button
class="flex gap-2 w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
tab = 'chats';
}}
>
<button
class="flex gap-2 w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
tab = 'chats';
}}
>
<ClockRotateRight />
<ClockRotateRight />
<div class="flex items-center w-full justify-between">
<div class=" line-clamp-1">
{$i18n.t('Reference Chats')}
</div>
<div class="text-gray-500">
<ChevronRight />
</div>
<div class="flex items-center w-full justify-between">
<div class=" line-clamp-1">
{$i18n.t('Reference Chats')}
</div>
</button>
</Tooltip>
{/if}
<div class="text-gray-500">
<ChevronRight />
</div>
</div>
</button>
</Tooltip>
{#if fileUploadEnabled}
{#if $config?.features?.enable_google_drive_integration}

View file

@ -178,7 +178,7 @@
</div>
</div>
{#if filter?.has_user_valves}
{#if filter?.has_user_valves && ($user?.role === 'admin' || ($user?.permissions?.chat?.valves ?? true))}
<div class=" shrink-0">
<Tooltip content={$i18n.t('Valves')}>
<button
@ -371,7 +371,7 @@
</div>
</div>
{#if tools[toolId]?.has_user_valves}
{#if tools[toolId]?.has_user_valves && ($user?.role === 'admin' || ($user?.permissions?.chat?.valves ?? true))}
<div class=" shrink-0">
<Tooltip content={$i18n.t('Valves')}>
<button

View file

@ -53,16 +53,16 @@
const exportTableToCSVHandler = (token, tokenIdx = 0) => {
console.log('Exporting table to CSV');
// Extract header row text and escape for CSV.
const header = token.header.map((headerCell) => `"${headerCell.text.replace(/"/g, '""')}"`);
// Extract header row text, decode HTML entities, and escape for CSV.
const header = token.header.map((headerCell) => `"${decode(headerCell.text).replace(/"/g, '""')}"`);
// Create an array for rows that will hold the mapped cell text.
const rows = token.rows.map((row) =>
row.map((cell) => {
// Map tokens into a single text
const cellContent = cell.tokens.map((token) => token.text).join('');
// Escape double quotes and wrap the content in double quotes
return `"${cellContent.replace(/"/g, '""')}"`;
// Decode HTML entities and escape double quotes, wrap in double quotes
return `"${decode(cellContent).replace(/"/g, '""')}"`;
})
);

View file

@ -5,6 +5,7 @@
import { config, models, tags as _tags } from '$lib/stores';
import Tags from '$lib/components/common/Tags.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
const i18n = getContext('i18n');
@ -248,4 +249,20 @@
{$i18n.t('Save')}
</button>
</div>
{#if $config?.features.enable_community_sharing && message?.model}
<div class="mt-3 pt-3 border-t border-gray-100/30 dark:border-gray-850/30">
<a
href={`https://openwebui.com/models?q=${encodeURIComponent(message.model)}`}
target="_blank"
class="flex cursor-pointer items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-850 w-full px-3 py-2 rounded-xl transition"
>
<div class="self-center">
<div class="text-sm font-medium">{$i18n.t('Leave a review for {{modelName}}', { modelName: message.model })}</div>
<div class="text-xs text-gray-500">{$i18n.t('Help the community discover great models')}</div>
</div>
<ChevronRight className="size-4" />
</a>
</div>
{/if}
</div>

View file

@ -9,7 +9,8 @@
import PinSlash from '$lib/components/icons/PinSlash.svelte';
import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
import Link from '$lib/components/icons/Link.svelte';
import { settings } from '$lib/stores';
import { config, settings } from '$lib/stores';
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
const i18n = getContext('i18n');
@ -45,7 +46,7 @@
<DropdownMenu.Content
strategy="fixed"
class="w-full max-w-[180px] text-sm rounded-2xl p-1 z-[9999999] bg-white dark:bg-gray-850 dark:text-white shadow-lg border border-gray-100 dark:border-gray-800"
class="w-full max-w-[210px] text-sm rounded-2xl p-1 z-[9999999] bg-white dark:bg-gray-850 dark:text-white shadow-lg border border-gray-100 dark:border-gray-800"
sideOffset={-2}
side="bottom"
align="end"
@ -93,5 +94,23 @@
<div class="flex items-center">{$i18n.t('Copy Link')}</div>
</DropdownMenu.Item>
{#if $config?.features.enable_community_sharing}
<DropdownMenu.Item
type="button"
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
window.open(`https://openwebui.com/models?q=${encodeURIComponent(model?.id ?? '')}`, '_blank');
show = false;
}}
>
<GlobeAlt className="size-4" />
<div class="flex items-center">{$i18n.t('Community Reviews')}</div>
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>

View file

@ -24,6 +24,9 @@
import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner';
import ArchivedChatsModal from '$lib/components/layout/ArchivedChatsModal.svelte';
import SharedChatsModal from '$lib/components/layout/SharedChatsModal.svelte';
import FilesModal from '$lib/components/layout/FilesModal.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
const i18n = getContext('i18n');
@ -32,9 +35,11 @@
// Chats
let importFiles;
let showArchiveConfirm = false;
let showDeleteConfirm = false;
let showArchiveConfirmDialog = false;
let showDeleteConfirmDialog = false;
let showArchivedChatsModal = false;
let showSharedChatsModal = false;
let showFilesModal = false;
let chatImportInputElement: HTMLInputElement;
@ -135,266 +140,153 @@
</script>
<ArchivedChatsModal bind:show={showArchivedChatsModal} onUpdate={handleArchivedChatsChange} />
<SharedChatsModal bind:show={showSharedChatsModal} />
<FilesModal bind:show={showFilesModal} />
<div id="tab-chats" class="flex flex-col h-full justify-between space-y-3 text-sm">
<div class=" space-y-2 overflow-y-scroll max-h-[28rem] md:max-h-full">
<div class="flex flex-col">
<input
id="chat-import-input"
bind:this={chatImportInputElement}
bind:files={importFiles}
type="file"
accept=".json"
hidden
/>
<button
class=" flex rounded-md py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
on:click={() => {
chatImportInputElement.click();
}}
>
<div class=" self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
<ConfirmDialog
title={$i18n.t('Archive All Chats')}
message={$i18n.t('Are you sure you want to archive all chats? This action cannot be undone.')}
bind:show={showArchiveConfirmDialog}
on:confirm={archiveAllChatsHandler}
on:cancel={() => {
showArchiveConfirmDialog = false;
}}
/>
<ConfirmDialog
title={$i18n.t('Delete All Chats')}
message={$i18n.t('Are you sure you want to delete all chats? This action cannot be undone.')}
bind:show={showDeleteConfirmDialog}
on:confirm={deleteAllChatsHandler}
on:cancel={() => {
showDeleteConfirmDialog = false;
}}
/>
<div id="tab-chats" class="flex flex-col h-full justify-between text-sm">
<div class="space-y-3 overflow-y-scroll max-h-[28rem] md:max-h-full">
<input
id="chat-import-input"
bind:this={chatImportInputElement}
bind:files={importFiles}
type="file"
accept=".json"
hidden
/>
<div>
<div class="mb-1 text-sm font-medium">{$i18n.t('Chats')}</div>
<div>
<div class="py-0.5 flex w-full justify-between">
<div class="self-center text-xs">{$i18n.t('Import Chats')}</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition"
on:click={() => {
chatImportInputElement.click();
}}
type="button"
>
<path
fill-rule="evenodd"
d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 9.5a.75.75 0 0 1-.75-.75V8.06l-.72.72a.75.75 0 0 1-1.06-1.06l2-2a.75.75 0 0 1 1.06 0l2 2a.75.75 0 1 1-1.06 1.06l-.72-.72v2.69a.75.75 0 0 1-.75.75Z"
clip-rule="evenodd"
/>
</svg>
<span class="self-center">{$i18n.t('Import')}</span>
</button>
</div>
<div class=" self-center text-sm font-medium">{$i18n.t('Import Chats')}</div>
</button>
</div>
{#if $user?.role === 'admin' || ($user.permissions?.chat?.export ?? true)}
<button
class=" flex rounded-md py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
on:click={() => {
exportChats();
}}
>
<div class=" self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
<div>
<div class="py-0.5 flex w-full justify-between">
<div class="self-center text-xs">{$i18n.t('Export Chats')}</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition"
on:click={() => {
exportChats();
}}
type="button"
>
<path
fill-rule="evenodd"
d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 3.5a.75.75 0 0 1 .75.75v2.69l.72-.72a.75.75 0 1 1 1.06 1.06l-2 2a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 0 1 1.06-1.06l.72.72V6.25A.75.75 0 0 1 8 5.5Z"
clip-rule="evenodd"
/>
</svg>
<span class="self-center">{$i18n.t('Export')}</span>
</button>
</div>
<div class=" self-center text-sm font-medium">{$i18n.t('Export Chats')}</div>
</button>
</div>
{/if}
<div>
<div class="py-0.5 flex w-full justify-between">
<div class="self-center text-xs">{$i18n.t('Archived Chats')}</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition"
on:click={() => {
showArchivedChatsModal = true;
}}
type="button"
>
<span class="self-center">{$i18n.t('Manage')}</span>
</button>
</div>
</div>
<div>
<div class="py-0.5 flex w-full justify-between">
<div class="self-center text-xs">{$i18n.t('Shared Chats')}</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition"
on:click={() => {
showSharedChatsModal = true;
}}
type="button"
>
<span class="self-center">{$i18n.t('Manage')}</span>
</button>
</div>
</div>
<div>
<div class="py-0.5 flex w-full justify-between">
<div class="self-center text-xs">{$i18n.t('Archive All Chats')}</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition"
on:click={() => {
showArchiveConfirmDialog = true;
}}
type="button"
>
<span class="self-center">{$i18n.t('Archive All')}</span>
</button>
</div>
</div>
<div>
<div class="py-0.5 flex w-full justify-between">
<div class="self-center text-xs">{$i18n.t('Delete All Chats')}</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition"
on:click={() => {
showDeleteConfirmDialog = true;
}}
type="button"
>
<span class="self-center">{$i18n.t('Delete All')}</span>
</button>
</div>
</div>
</div>
<hr class=" border-gray-100/30 dark:border-gray-850/30" />
<div>
<div class="mb-1 text-sm font-medium">{$i18n.t('Files')}</div>
<div class="flex flex-col">
<button
class=" flex rounded-md py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
on:click={() => {
showArchivedChatsModal = true;
}}
>
<div class=" self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-4"
<div>
<div class="py-0.5 flex w-full justify-between">
<div class="self-center text-xs">{$i18n.t('Manage Files')}</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition"
on:click={() => {
showFilesModal = true;
}}
type="button"
>
<path
d="M3.375 3C2.339 3 1.5 3.84 1.5 4.875v.75c0 1.036.84 1.875 1.875 1.875h17.25c1.035 0 1.875-.84 1.875-1.875v-.75C22.5 3.839 21.66 3 20.625 3H3.375Z"
/>
<path
fill-rule="evenodd"
d="m3.087 9 .54 9.176A3 3 0 0 0 6.62 21h10.757a3 3 0 0 0 2.995-2.824L20.913 9H3.087ZM12 10.5a.75.75 0 0 1 .75.75v4.94l1.72-1.72a.75.75 0 1 1 1.06 1.06l-3 3a.75.75 0 0 1-1.06 0l-3-3a.75.75 0 1 1 1.06-1.06l1.72 1.72v-4.94a.75.75 0 0 1 .75-.75Z"
clip-rule="evenodd"
/>
</svg>
<span class="self-center">{$i18n.t('Manage')}</span>
</button>
</div>
<div class=" self-center text-sm font-medium">{$i18n.t('Archived Chats')}</div>
</button>
{#if showArchiveConfirm}
<div class="flex justify-between rounded-md items-center py-2 px-3.5 w-full transition">
<div class="flex items-center space-x-3">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path d="M2 3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3Z" />
<path
fill-rule="evenodd"
d="M13 6H3v6a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V6ZM5.72 7.47a.75.75 0 0 1 1.06 0L8 8.69l1.22-1.22a.75.75 0 1 1 1.06 1.06L9.06 9.75l1.22 1.22a.75.75 0 1 1-1.06 1.06L8 10.81l-1.22 1.22a.75.75 0 0 1-1.06-1.06l1.22-1.22-1.22-1.22a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
<span>{$i18n.t('Are you sure?')}</span>
</div>
<div class="flex space-x-1.5 items-center">
<button
class="hover:text-white transition"
on:click={() => {
archiveAllChatsHandler();
showArchiveConfirm = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
clip-rule="evenodd"
/>
</svg>
</button>
<button
class="hover:text-white transition"
on:click={() => {
showArchiveConfirm = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<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>
{:else}
<button
class=" flex rounded-md py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
on:click={() => {
showArchiveConfirm = true;
}}
>
<div class=" self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-4"
>
<path
d="M3.375 3C2.339 3 1.5 3.84 1.5 4.875v.75c0 1.036.84 1.875 1.875 1.875h17.25c1.035 0 1.875-.84 1.875-1.875v-.75C22.5 3.839 21.66 3 20.625 3H3.375Z"
/>
<path
fill-rule="evenodd"
d="m3.087 9 .54 9.176A3 3 0 0 0 6.62 21h10.757a3 3 0 0 0 2.995-2.824L20.913 9H3.087Zm6.163 3.75A.75.75 0 0 1 10 12h4a.75.75 0 0 1 0 1.5h-4a.75.75 0 0 1-.75-.75Z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center text-sm font-medium">{$i18n.t('Archive All Chats')}</div>
</button>
{/if}
{#if showDeleteConfirm}
<div class="flex justify-between rounded-md items-center py-2 px-3.5 w-full transition">
<div class="flex items-center space-x-3">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path d="M2 3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3Z" />
<path
fill-rule="evenodd"
d="M13 6H3v6a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V6ZM5.72 7.47a.75.75 0 0 1 1.06 0L8 8.69l1.22-1.22a.75.75 0 1 1 1.06 1.06L9.06 9.75l1.22 1.22a.75.75 0 1 1-1.06 1.06L8 10.81l-1.22 1.22a.75.75 0 0 1-1.06-1.06l1.22-1.22-1.22-1.22a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
<span>{$i18n.t('Are you sure?')}</span>
</div>
<div class="flex space-x-1.5 items-center">
<button
class="hover:text-white transition"
on:click={() => {
deleteAllChatsHandler();
showDeleteConfirm = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
clip-rule="evenodd"
/>
</svg>
</button>
<button
class="hover:text-white transition"
on:click={() => {
showDeleteConfirm = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<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>
{:else}
<button
class=" flex rounded-md py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
on:click={() => {
showDeleteConfirm = true;
}}
>
<div class=" self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm7 7a.75.75 0 0 1-.75.75h-4.5a.75.75 0 0 1 0-1.5h4.5A.75.75 0 0 1 11 9Z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class=" self-center text-sm font-medium">{$i18n.t('Delete All Chats')}</div>
</button>
{/if}
</div>
</div>
</div>
</div>

View file

@ -112,6 +112,10 @@
languages = await getLanguages();
if (!$config?.features?.enable_easter_eggs) {
languages = languages.filter((l) => l.code !== 'dg-DG');
}
notificationEnabled = $settings.notificationEnabled ?? false;
system = $settings.system ?? '';
@ -210,9 +214,9 @@
<option value="dark">🌑 {$i18n.t('Dark')}</option>
<option value="oled-dark">🌃 {$i18n.t('OLED Dark')}</option>
<option value="light">☀️ {$i18n.t('Light')}</option>
<option value="her">🌷 Her</option>
<!-- <option value="rose-pine dark">🪻 {$i18n.t('Rosé Pine')}</option>
<option value="rose-pine-dawn light">🌷 {$i18n.t('Rosé Pine Dawn')}</option> -->
{#if $config?.features?.enable_easter_eggs}
<option value="her">🌷 Her</option>
{/if}
</select>
</div>
</div>

View file

@ -12,8 +12,8 @@
</script>
<div
class=" text-xs font-semibold {classNames[type] ??
classNames['info']} w-fit px-2 rounded-sm uppercase line-clamp-1 mr-0.5"
class=" text-xs font-medium {classNames[type] ??
classNames['info']} w-fit px-1.5 py-[1px] rounded-lg uppercase line-clamp-1 mr-0.5"
>
{content}
</div>

View file

@ -100,7 +100,7 @@
{/if}
</div>
<div class="flex-1 text-xs text-gray-700 dark:text-white max-h-60 overflow-y-auto">
{@html marked.parse(DOMPurify.sanitize((banner?.content ?? '').replace(/\n/g, '<br>')))}
{@html DOMPurify.sanitize(marked.parse((banner?.content ?? '').replace(/\n/g, '<br>')))}
</div>
</div>

View file

@ -104,6 +104,12 @@
})
);
// Add 'matlab' alias to Octave language (MATLAB-compatible syntax)
const octaveLang = languages.find((l) => l.name === 'Octave');
if (octaveLang && !octaveLang.alias.includes('matlab')) {
octaveLang.alias.push('matlab');
}
const getLang = async () => {
const language = languages.find((l) => l.alias.includes(lang));
return await language?.load();

View file

@ -46,6 +46,8 @@
if (event.key === 'Enter') {
console.log('Enter');
event.preventDefault();
event.stopPropagation();
confirmHandler();
}
};
@ -81,6 +83,7 @@
onDestroy(() => {
show = false;
window.removeEventListener('keydown', handleKeyDown);
if (focusTrap) {
focusTrap.deactivate();
}

View file

@ -1,5 +1,6 @@
<script lang="ts">
import type { WorkBook } from 'xlsx';
import DOMPurify from 'dompurify';
import { getContext, onMount, tick } from 'svelte';
@ -118,11 +119,11 @@
const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1:A1');
rowCount = range.e.r - range.s.r + 1;
excelHtml = XLSX.utils.sheet_to_html(worksheet, {
excelHtml = DOMPurify.sanitize(XLSX.utils.sheet_to_html(worksheet, {
id: 'excel-table',
editable: false,
header: ''
});
}));
};
$: if (selectedSheet && excelWorkbook) {

View file

@ -11,12 +11,15 @@
export let outerClassName = 'flex flex-1 bg-transparent';
export let inputClassName = 'w-full text-sm py-0.5 bg-transparent';
export let showButtonClassName = 'pl-1.5 transition bg-transparent';
export let screenReader = true;
let show = false;
</script>
<div class={outerClassName}>
<label class="sr-only" for={id}>{placeholder || $i18n.t('Password')}</label>
{#if screenReader}
<label class="sr-only" for={id}>{placeholder || $i18n.t('Password')}</label>
{/if}
<input
{id}
class={`${inputClassName} ${show ? '' : 'password'} ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : ' outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-600'}`}

View file

@ -4,6 +4,7 @@
const i18n = getContext('i18n');
import Switch from './Switch.svelte';
import SensitiveInput from './SensitiveInput.svelte';
import MapSelector from './Valves/MapSelector.svelte';
export let valvesSpec = null;
@ -103,7 +104,40 @@
}}
/>
{:else if valvesSpec.properties[property]?.input ?? null}
{#if valvesSpec.properties[property]?.input?.type === 'color'}
{#if valvesSpec.properties[property]?.input?.type === 'password'}
<div class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 border border-gray-100/30 dark:border-gray-850/30">
<SensitiveInput
id="valve-{property}"
placeholder={valvesSpec.properties[property]?.description ?? ''}
bind:value={valves[property]}
required={(valvesSpec?.required ?? []).includes(property)}
on:change={() => {
dispatch('change');
}}
/>
</div>
{:else if valvesSpec.properties[property]?.input?.type === 'select' && valvesSpec.properties[property]?.input?.options}
<select
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30"
bind:value={valves[property]}
on:change={() => {
dispatch('change');
}}
>
<option value="" disabled>{valvesSpec.properties[property]?.description ?? $i18n.t('Select an option')}</option>
{#each valvesSpec.properties[property].input.options as option}
{#if typeof option === 'object' && option !== null}
<option value={option.value} selected={option.value === valves[property]}>
{option.label ?? option.value}
</option>
{:else}
<option value={option} selected={option === valves[property]}>
{option}
</option>
{/if}
{/each}
</select>
{:else if valvesSpec.properties[property]?.input?.type === 'color'}
<div class="flex items-center space-x-2">
<div class="relative size-6">
<input

View file

@ -0,0 +1,26 @@
<script lang="ts">
export let className = 'size-4';
export let strokeWidth = '1.5';
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke="currentColor"
class={className}
><path
d="M7.14286 16.9953C6.75006 16.9953 6.36756 16.9525 6 16.8715C3.70973 16.3665 2 14.3761 2 11.9977C2 9.284 4.22573 7.07548 7 7.00195"
stroke-linecap="round"
stroke-linejoin="round"
></path><path
d="M13.3184 9.63429C12.7858 8.73635 11.9737 7.96977 11 7.4989"
stroke-linecap="round"
stroke-linejoin="round"
></path><path
d="M16.8571 6.99999C17.2499 6.99999 17.6324 7.04278 18 7.12383C20.2903 7.62884 22 9.6192 22 11.9976C22 14.7577 19.6975 16.9952 16.8571 16.9952C16.581 16.9952 15.4776 16.9952 15.1429 16.9952C12.317 16.9952 10 14.4893 10 11.9976C10 11.9976 10 11 10.5 10.5"
stroke-linecap="round"
stroke-linejoin="round"
></path><path d="M3 3L21 21" stroke-linecap="round" stroke-linejoin="round"></path></svg
>

View file

@ -20,6 +20,9 @@
import XMark from '../icons/XMark.svelte';
import ChevronUp from '../icons/ChevronUp.svelte';
import ChevronDown from '../icons/ChevronDown.svelte';
import Link from '../icons/Link.svelte';
import LinkSlash from '../icons/LinkSlash.svelte';
import Clipboard from '../icons/Clipboard.svelte';
const i18n = getContext('i18n');
@ -46,6 +49,7 @@
export let loadHandler: null | Function = null;
export let unarchiveHandler: null | Function = null;
export let unshareHandler: null | Function = null;
const setSortKey = (key) => {
if (orderBy === key) {
@ -286,30 +290,57 @@
</Tooltip>
{/if}
<Tooltip content={$i18n.t('Delete Chat')}>
{#if unshareHandler && chat.share_id}
<Tooltip content={$i18n.t('Copy Share Link')}>
<button
class="self-center w-fit px-1 text-sm rounded-xl"
on:click={async (e) => {
e.stopImmediatePropagation();
e.stopPropagation();
const shareUrl = `${window.location.origin}/s/${chat.share_id}`;
await navigator.clipboard.writeText(shareUrl);
toast.success($i18n.t('Share link copied to clipboard.'));
}}
>
<Clipboard class="size-4" strokeWidth="1.5" />
</button>
</Tooltip>
{/if}
<Tooltip
content={unshareHandler ? $i18n.t('Unshare Chat') : $i18n.t('Delete Chat')}
>
<button
class="self-center w-fit px-1 text-sm rounded-xl"
on:click={async (e) => {
e.stopImmediatePropagation();
e.stopPropagation();
selectedChatId = chat.id;
showDeleteConfirmDialog = true;
if (unshareHandler) {
unshareHandler(chat.id);
} else {
selectedChatId = chat.id;
showDeleteConfirmDialog = true;
}
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
{#if unshareHandler}
<LinkSlash />
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
{/if}
</button>
</Tooltip>
</div>

View file

@ -0,0 +1,384 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, onMount, onDestroy } from 'svelte';
import type { Writable } from 'svelte/store';
import dayjs from 'dayjs';
import { searchFiles, deleteFileById } from '$lib/apis/files';
import Modal from '$lib/components/common/Modal.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Loader from '$lib/components/common/Loader.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import FileItemModal from '$lib/components/common/FileItemModal.svelte';
const i18n: Writable<any> = getContext('i18n');
export let show = false;
let files: any[] | null = null;
let query = '';
let orderBy = 'created_at';
let direction = 'desc';
let page = 0;
let allFilesLoaded = false;
let filesLoading = false;
let searchDebounceTimer: ReturnType<typeof setTimeout>;
let selectedFileId: string | null = null;
let showDeleteConfirmDialog = false;
let selectedFile: any = null;
let showFileItemModal = false;
let shiftKey = false;
const PAGE_SIZE = 50;
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
const setSortKey = (key: string) => {
if (orderBy === key) {
direction = direction === 'asc' ? 'desc' : 'asc';
} else {
orderBy = key;
direction = 'asc';
}
searchHandler();
};
const searchHandler = async () => {
if (!show) return;
page = 0;
files = null;
allFilesLoaded = false;
try {
const pattern = query ? `*${query}*` : '*';
const newFiles = await searchFiles(localStorage.token, pattern, 0, PAGE_SIZE);
files = sortFiles(newFiles);
allFilesLoaded = newFiles.length < PAGE_SIZE;
} catch (error) {
// Handle 404 or other errors - show empty state instead of spinner
files = [];
allFilesLoaded = true;
}
};
const loadMoreFiles = async () => {
if (filesLoading || allFilesLoaded) return;
filesLoading = true;
page += 1;
try {
const pattern = query ? `*${query}*` : '*';
const newFiles = await searchFiles(localStorage.token, pattern, page * PAGE_SIZE, PAGE_SIZE);
allFilesLoaded = newFiles.length < PAGE_SIZE;
if (newFiles.length > 0) {
files = sortFiles([...(files || []), ...newFiles]);
}
} catch (error) {
// Handle errors silently for load more
allFilesLoaded = true;
}
filesLoading = false;
};
const sortFiles = (fileList: any[]): any[] => {
return fileList.sort((a, b) => {
let aVal = a[orderBy] ?? 0;
let bVal = b[orderBy] ?? 0;
if (orderBy === 'filename') {
aVal = a.filename?.toLowerCase() ?? '';
bVal = b.filename?.toLowerCase() ?? '';
}
if (direction === 'asc') {
return aVal > bVal ? 1 : -1;
} else {
return aVal < bVal ? 1 : -1;
}
});
};
const deleteHandler = async (fileId: string) => {
try {
await deleteFileById(localStorage.token, fileId);
toast.success($i18n.t('File deleted successfully.'));
// Remove from local array instead of re-fetching to allow rapid deletion
files = files?.filter((f) => f.id !== fileId) ?? null;
} catch (error) {
toast.error(`${error}`);
}
};
const openFileViewer = (file: any) => {
selectedFile = {
id: file.id,
name: file.filename,
type: 'file',
size: file.meta?.size,
meta: file.meta
};
showFileItemModal = true;
};
// Debounce query changes
$: if (show && query !== undefined) {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
searchHandler();
}, 300);
}
onMount(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftKey = true;
}
};
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftKey = false;
}
};
const onBlur = () => {
shiftKey = false;
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', onBlur);
return () => {
clearTimeout(searchDebounceTimer);
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', onBlur);
};
});
onDestroy(() => {
clearTimeout(searchDebounceTimer);
});
</script>
<ConfirmDialog
bind:show={showDeleteConfirmDialog}
on:confirm={() => {
if (selectedFileId) {
deleteHandler(selectedFileId);
selectedFileId = null;
}
}}
/>
<FileItemModal bind:show={showFileItemModal} item={selectedFile} edit={false} />
<Modal size="xl" bind:show>
<div>
<div class="flex justify-between dark:text-gray-300 px-5 pt-4 pb-1">
<div class="text-lg font-medium self-center">{$i18n.t('Files')}</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
fill-rule="evenodd"
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"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
<div class="flex flex-col w-full px-5 pb-4 dark:text-gray-200">
<!-- Search -->
<div class="flex w-full space-x-2 mb-0.5">
<div class="flex flex-1">
<div class="self-center ml-1 mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
clip-rule="evenodd"
/>
</svg>
</div>
<input
class="w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
placeholder={$i18n.t('Search Files')}
maxlength="500"
/>
{#if query}
<div class="self-center pl-1.5 pr-1 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
on:click={() => {
query = '';
}}
>
<XMark className="size-3" strokeWidth="2" />
</button>
</div>
{/if}
</div>
</div>
<!-- Files List -->
<div class="flex flex-col w-full">
{#if files !== null}
<div class="w-full">
{#if files.length > 0}
<div class="flex text-xs font-medium mb-1.5">
<button
class="px-1.5 py-1 cursor-pointer select-none basis-3/5"
on:click={() => setSortKey('filename')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Filename')}
{#if orderBy === 'filename'}
<span class="font-normal">
{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</button>
<button
class="px-1.5 py-1 cursor-pointer select-none hidden sm:flex sm:basis-2/5 justify-end"
on:click={() => setSortKey('created_at')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Created at')}
{#if orderBy === 'created_at'}
<span class="font-normal">
{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</button>
</div>
{/if}
<div class="text-left text-sm w-full mb-3 max-h-[32rem] overflow-y-scroll">
{#if files.length === 0}
<div
class="text-xs text-gray-500 dark:text-gray-400 text-center px-5 min-h-20 w-full h-full flex justify-center items-center"
>
{$i18n.t('No files found')}
</div>
{/if}
{#each files as file (file.id)}
<div
class="w-full flex justify-between items-center rounded-lg text-sm py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-850 cursor-pointer"
on:click={() => openFileViewer(file)}
>
<div class="basis-3/5 min-w-0">
<div class="text-ellipsis line-clamp-1">{file.filename}</div>
<div class="text-xs text-gray-500">
{formatFileSize(file.meta?.size ?? 0)}
</div>
</div>
<div class="basis-2/5 flex items-center justify-end">
<div class="hidden sm:flex text-gray-500 dark:text-gray-400 text-xs">
{dayjs(file.created_at * 1000).format('MMM D, YYYY')}
</div>
<div class="flex justify-end pl-2.5 text-gray-600 dark:text-gray-300">
<Tooltip content={shiftKey ? $i18n.t('Delete File') : $i18n.t('Delete File')}>
<button
class="self-center w-fit px-1 text-sm rounded-xl {shiftKey ? 'text-red-500' : ''}"
on:click|stopPropagation={() => {
if (shiftKey) {
deleteHandler(file.id);
} else {
selectedFileId = file.id;
showDeleteConfirmDialog = true;
}
}}
>
<GarbageBin class="size-4" strokeWidth="1.5" />
</button>
</Tooltip>
</div>
</div>
</div>
{/each}
{#if !allFilesLoaded}
<Loader
on:visible={() => {
if (!filesLoading) {
loadMoreFiles();
}
}}
>
<div class="w-full flex justify-center py-1 text-xs animate-pulse items-center gap-2">
<Spinner className="size-4" />
<div>{$i18n.t('Loading...')}</div>
</div>
</Loader>
{/if}
</div>
</div>
{:else}
<div class="w-full h-full flex justify-center items-center min-h-20">
<Spinner className="size-5" />
</div>
{/if}
</div>
</div>
</div>
</Modal>

View file

@ -0,0 +1,126 @@
<script lang="ts">
import type { Writable } from 'svelte/store';
import { toast } from 'svelte-sonner';
import { getContext } from 'svelte';
import { deleteSharedChatById, getSharedChatList } from '$lib/apis/chats';
import ChatsModal from './ChatsModal.svelte';
const i18n: Writable<any> = getContext('i18n');
export let show = false;
export let onUpdate = () => {};
let chatList: any[] | null = null;
let page = 1;
let query = '';
let orderBy = 'updated_at';
let direction = 'desc';
let allChatsLoaded = false;
let chatListLoading = false;
let searchDebounceTimeout: any;
let filter: any = {};
$: filter = {
...(query ? { query } : {}),
...(orderBy ? { order_by: orderBy } : {}),
...(direction ? { direction } : {})
};
$: if (filter !== null) {
searchHandler();
}
const searchHandler = async () => {
if (!show) {
return;
}
if (searchDebounceTimeout) {
clearTimeout(searchDebounceTimeout);
}
page = 1;
chatList = null;
if (query === '') {
chatList = await getSharedChatList(localStorage.token, page, filter);
} else {
searchDebounceTimeout = setTimeout(async () => {
chatList = await getSharedChatList(localStorage.token, page, filter);
}, 500);
}
if ((chatList ?? []).length === 0) {
allChatsLoaded = true;
} else {
allChatsLoaded = false;
}
};
const loadMoreChats = async () => {
chatListLoading = true;
page += 1;
let newChatList = [];
if (query) {
newChatList = await getSharedChatList(localStorage.token, page, filter);
} else {
newChatList = await getSharedChatList(localStorage.token, page, filter);
}
// once the bottom of the list has been reached (no results) there is no need to continue querying
allChatsLoaded = newChatList.length === 0;
if (newChatList.length > 0) {
chatList = [...(chatList || []), ...newChatList];
}
chatListLoading = false;
};
const unshareHandler = async (chatId: string) => {
const res = await deleteSharedChatById(localStorage.token, chatId).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res === true) {
toast.success($i18n.t('Chat unshared successfully.'));
onUpdate();
init();
} else if (res === false) {
toast.error($i18n.t('Failed to unshare chat.'));
}
};
const init = async () => {
chatList = await getSharedChatList(localStorage.token);
};
$: if (show) {
init();
}
</script>
<ChatsModal
bind:show
bind:query
bind:orderBy
bind:direction
title={$i18n.t('Shared Chats')}
emptyPlaceholder={$i18n.t('You have no shared conversations.')}
shareUrl={false}
{chatList}
{allChatsLoaded}
{chatListLoading}
onUpdate={() => {
onUpdate();
init();
}}
loadHandler={loadMoreChats}
{unshareHandler}
/>

View file

@ -60,6 +60,7 @@
let total = null;
let query = '';
let searchDebounceTimer: ReturnType<typeof setTimeout>;
let sortKey = null;
let displayOption = null;
@ -163,9 +164,17 @@
await getItemsPage();
};
$: if (query !== undefined) {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
if (loaded) {
init();
}
}, 300);
}
$: if (
loaded &&
query !== undefined &&
sortKey !== undefined &&
permission !== undefined &&
viewOption !== undefined
@ -283,6 +292,7 @@
});
onDestroy(() => {
clearTimeout(searchDebounceTimer);
console.log('destroy');
const dropzoneElement = document.getElementById('notes-container');

View file

@ -1,5 +1,9 @@
<script lang="ts">
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import { toast } from 'svelte-sonner';
import { DropdownMenu } from 'bits-ui';
import { goto } from '$app/navigation';
import { onMount, tick, getContext } from 'svelte';
@ -11,11 +15,13 @@
WEBUI_BASE_URL
} from '$lib/constants';
import { WEBUI_NAME, config, user, models, settings } from '$lib/stores';
import { flyAndScale } from '$lib/utils/transitions';
import { chatCompletion } from '$lib/apis/openai';
import { splitStream } from '$lib/utils';
import Collapsible from '../common/Collapsible.svelte';
import Dropdown from '../common/Dropdown.svelte';
import Messages from '$lib/components/playground/Chat/Messages.svelte';
import ChevronUp from '../icons/ChevronUp.svelte';
@ -24,6 +30,8 @@
import Cog6 from '../icons/Cog6.svelte';
import Sidebar from '../common/Sidebar.svelte';
import ArrowRight from '../icons/ArrowRight.svelte';
import Download from '../icons/Download.svelte';
import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
const i18n = getContext('i18n');
@ -193,6 +201,94 @@
}
};
const exportToJson = () => {
const now = Math.floor(Date.now() / 1000);
// Convert flat messages array to history map format
const messagesMap: Record<string, any> = {};
let currentId: string | null = null;
let parentId: string | null = null;
// Add system message if present
if (system) {
const systemId = crypto.randomUUID();
messagesMap[systemId] = {
id: systemId,
parentId: null,
childrenIds: [],
role: 'system',
content: system,
timestamp: now
};
parentId = systemId;
}
// Add conversation messages
for (const msg of messages) {
const msgId = crypto.randomUUID();
// Link parent to child
if (parentId && messagesMap[parentId]) {
messagesMap[parentId].childrenIds.push(msgId);
}
messagesMap[msgId] = {
id: msgId,
parentId: parentId,
childrenIds: [],
role: msg.role,
content: msg.content,
timestamp: now,
...(msg.role === 'assistant' && selectedModelId ? { model: selectedModelId } : {})
};
currentId = msgId;
parentId = msgId;
}
const exportData = {
chat: {
title: 'Playground Chat',
models: [selectedModelId],
params: system ? { system } : {},
history: {
messages: messagesMap,
currentId
}
},
meta: {},
pinned: false,
created_at: now,
updated_at: now
};
const blob = new Blob([JSON.stringify([exportData], null, 2)], {
type: 'application/json'
});
saveAs(blob, `playground-chat-${Date.now()}.json`);
toast.success($i18n.t('Chat exported successfully'));
};
const downloadTxt = () => {
let chatText = '';
// Add system message if present
if (system) {
chatText += `### SYSTEM\n${system}\n\n`;
}
// Add conversation messages
for (const msg of messages) {
chatText += `### ${msg.role.toUpperCase()}\n${msg.content}\n\n`;
}
const blob = new Blob([chatText.trim()], {
type: 'text/plain'
});
saveAs(blob, `playground-chat-${Date.now()}.txt`);
toast.success($i18n.t('Chat exported successfully'));
};
onMount(async () => {
if ($user?.role !== 'admin') {
await goto('/');
@ -212,7 +308,7 @@
<div class=" flex flex-col justify-between w-full overflow-y-auto h-full">
<div class="mx-auto w-full md:px-0 h-full relative">
<div class=" flex flex-col h-full px-3.5">
<div class="flex w-full items-start gap-1.5">
<div class="flex w-full items-center gap-1.5">
<Collapsible
className="w-full flex-1"
bind:open={showSystem}
@ -256,6 +352,58 @@
</div>
</div>
</Collapsible>
<Dropdown>
<button
class="p-1.5 text-sm font-medium bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400 transition rounded-lg"
aria-label={$i18n.t('More options')}
>
<EllipsisHorizontal className="size-4" />
</button>
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[200px] rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
sideOffset={8}
side="bottom"
align="end"
transition={flyAndScale}
>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl select-none w-full"
>
<Download strokeWidth="1.5" />
<div class="flex items-center">{$i18n.t('Download')}</div>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
class="w-full rounded-2xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white border border-gray-100 dark:border-gray-800 shadow-lg max-h-52 overflow-y-auto scrollbar-hidden"
transition={flyAndScale}
sideOffset={8}
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl select-none w-full"
disabled={messages.length === 0}
on:click={() => {
exportToJson();
}}
>
<div class="flex items-center line-clamp-1">{$i18n.t('Export chat (.json)')}</div>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl select-none w-full"
disabled={messages.length === 0}
on:click={() => {
downloadTxt();
}}
>
<div class="flex items-center line-clamp-1">{$i18n.t('Plain text (.txt)')}</div>
</DropdownMenu.Item>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>
</div>
</Dropdown>
</div>
<div

View file

@ -0,0 +1,276 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { onMount, getContext } from 'svelte';
import { goto } from '$app/navigation';
import { user } from '$lib/stores';
import { imageGenerations, imageEdits } from '$lib/apis/images';
import Spinner from '$lib/components/common/Spinner.svelte';
const i18n = getContext('i18n');
let loaded = false;
let loading = false;
let prompt = '';
let sourceImages: string[] = [];
let generatedImages: { url: string }[] = [];
let promptTextareaElement: HTMLTextAreaElement;
let fileInputElement: HTMLInputElement;
const resizePromptTextarea = () => {
if (promptTextareaElement) {
promptTextareaElement.style.height = '';
promptTextareaElement.style.height = Math.min(promptTextareaElement.scrollHeight, 150) + 'px';
}
};
const handleFileUpload = (event: Event) => {
const input = event.target as HTMLInputElement;
if (input.files) {
Array.from(input.files).forEach((file) => {
const reader = new FileReader();
reader.onload = (e) => {
if (e.target?.result) {
sourceImages = [...sourceImages, e.target.result as string];
}
};
reader.readAsDataURL(file);
});
}
};
const handleDrop = (event: DragEvent) => {
event.preventDefault();
const files = event.dataTransfer?.files;
if (files) {
Array.from(files).forEach((file) => {
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
if (e.target?.result) {
sourceImages = [...sourceImages, e.target.result as string];
}
};
reader.readAsDataURL(file);
}
});
}
};
const removeImage = (index: number) => {
sourceImages = sourceImages.filter((_, i) => i !== index);
};
const submitHandler = async () => {
if (!prompt.trim()) {
toast.error($i18n.t('Please enter a prompt'));
return;
}
loading = true;
try {
let result;
if (sourceImages.length > 0) {
console.log('Calling imageEdits with', sourceImages.length, 'images');
result = await imageEdits(
localStorage.token,
sourceImages.length === 1 ? sourceImages[0] : sourceImages,
prompt
);
} else {
console.log('Calling imageGenerations');
result = await imageGenerations(localStorage.token, prompt);
}
console.log('Result:', result);
if (result) {
generatedImages = [...result, ...generatedImages];
}
} catch (error) {
console.error('Image generation/edit error:', error);
toast.error(`${error}`);
} finally {
loading = false;
}
};
const downloadImage = async (url: string, index: number) => {
try {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = `image-${Date.now()}-${index}.png`;
a.click();
URL.revokeObjectURL(blobUrl);
} catch (error) {
toast.error($i18n.t('Failed to download image'));
}
};
onMount(async () => {
if ($user?.role !== 'admin') {
await goto('/');
return;
}
loaded = true;
});
</script>
<div class=" flex flex-col justify-between w-full overflow-y-auto h-full">
<div class="mx-auto w-full md:px-0 h-full">
<div class=" flex flex-col h-full px-4">
<!-- Results Area -->
<div
class=" pt-0.5 pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0"
id="images-container"
>
<div class=" h-full w-full flex flex-col">
<div class="flex-1 p-1">
{#if generatedImages.length > 0}
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{#each generatedImages as image, index}
<button
class="relative group cursor-pointer"
on:click={() => downloadImage(image.url, index)}
>
<img
src={image.url}
alt=""
class="w-full aspect-square object-cover rounded-lg border border-gray-100/30 dark:border-gray-850/30"
/>
<div
class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition rounded-lg flex items-center justify-center"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6 text-white"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7,10 12,15 17,10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</div>
</button>
{/each}
</div>
{:else}
<div class="h-full flex items-center justify-center text-gray-400 dark:text-gray-600 text-sm">
{$i18n.t('Generated images will appear here')}
</div>
{/if}
</div>
</div>
</div>
<!-- Input Area -->
<div class="pb-3">
<div class="border border-gray-100/30 dark:border-gray-850/30 w-full px-3 py-2.5 rounded-xl">
<!-- Source Images -->
{#if sourceImages.length > 0}
<div class="flex flex-wrap gap-2 mb-2">
{#each sourceImages as image, index}
<div class=" relative group">
<div class="relative flex items-center">
<img src={image} alt="" class="size-10 rounded-xl object-cover" />
</div>
<div class=" absolute -top-1 -right-1">
<button
class=" bg-white text-black border border-white rounded-full group-hover:visible invisible transition"
type="button"
on:click={() => removeImage(index)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
class="size-4"
>
<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>
{/each}
</div>
{/if}
<!-- Prompt Textarea -->
<div class="py-0.5">
<textarea
bind:this={promptTextareaElement}
bind:value={prompt}
class=" w-full h-full bg-transparent resize-none outline-hidden text-sm"
placeholder={sourceImages.length > 0
? $i18n.t('Describe the edit...')
: $i18n.t('Describe the image...')}
on:input={resizePromptTextarea}
on:focus={resizePromptTextarea}
on:keydown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !loading) {
e.preventDefault();
submitHandler();
}
}}
rows="2"
/>
</div>
<!-- Actions -->
<div class="flex justify-between items-center gap-2 mt-2">
<div class="shrink-0">
<input
type="file"
accept="image/*"
multiple
class="hidden"
bind:this={fileInputElement}
on:change={handleFileUpload}
/>
<button
type="button"
class="px-3.5 py-1.5 text-sm font-medium bg-gray-50 hover:bg-gray-100 text-gray-900 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition rounded-lg"
on:click={() => fileInputElement?.click()}
on:dragover|preventDefault
on:drop={handleDrop}
>
{$i18n.t('Add Image')}
</button>
</div>
<div class="flex gap-2 shrink-0">
{#if !loading}
<button
disabled={prompt.trim() === ''}
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
on:click={submitHandler}
>
{$i18n.t('Run')}
</button>
{:else}
<button
class="px-3.5 py-1.5 text-sm font-medium bg-gray-300 text-black transition rounded-lg flex items-center gap-2"
disabled
>
<Spinner className="size-4" />
{$i18n.t('Running...')}
</button>
{/if}
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View file

@ -4,7 +4,7 @@
dayjs.extend(relativeTime);
import { toast } from 'svelte-sonner';
import { onMount, getContext, tick } from 'svelte';
import { onMount, getContext, tick, onDestroy } from 'svelte';
const i18n = getContext('i18n');
import { WEBUI_NAME, knowledge, user } from '$lib/stores';
@ -36,6 +36,7 @@
let page = 1;
let query = '';
let searchDebounceTimer: ReturnType<typeof setTimeout>;
let viewOption = '';
let items = null;
@ -44,7 +45,18 @@
let allItemsLoaded = false;
let itemsLoading = false;
$: if (loaded && query !== undefined && viewOption !== undefined) {
$: if (query !== undefined) {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
init();
}, 300);
}
onDestroy(() => {
clearTimeout(searchDebounceTimer);
});
$: if (viewOption !== undefined) {
init();
}
@ -63,6 +75,8 @@
};
const init = async () => {
if (!loaded) return;
reset();
await getItemsPage();
};

View file

@ -88,6 +88,8 @@
let inputFiles = null;
let query = '';
let searchDebounceTimer: ReturnType<typeof setTimeout>;
let viewOption = null;
let sortKey = null;
let direction = null;
@ -105,9 +107,18 @@
await getItemsPage();
};
// Debounce only query changes
$: if (query !== undefined) {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
getItemsPage();
}, 300);
}
// Immediate response to filter/pagination changes
$: if (
knowledgeId !== null &&
query !== undefined &&
viewOption !== undefined &&
sortKey !== undefined &&
direction !== undefined &&
@ -746,6 +757,7 @@
});
onDestroy(() => {
clearTimeout(searchDebounceTimer);
mediaQuery?.removeEventListener('change', handleMediaQuery);
const dropZone = document.querySelector('body');
dropZone?.removeEventListener('dragover', onDragOver);

View file

@ -85,6 +85,8 @@
}
const getModelList = async () => {
if (!loaded) return;
try {
const res = await getWorkspaceModels(
localStorage.token,

View file

@ -0,0 +1,87 @@
<script lang="ts">
import { getContext } from 'svelte';
import Checkbox from '$lib/components/common/Checkbox.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import { marked } from 'marked';
const i18n = getContext('i18n');
const toolLabels = {
time: {
label: $i18n.t('Time & Calculation'),
description: $i18n.t('Get current time and perform date/time calculations')
},
memory: {
label: $i18n.t('Memory'),
description: $i18n.t('Search and manage user memories')
},
chats: {
label: $i18n.t('Chat History'),
description: $i18n.t('Search and view user chat history')
},
notes: {
label: $i18n.t('Notes'),
description: $i18n.t('Search, view, and manage user notes')
},
knowledge: {
label: $i18n.t('Knowledge Base'),
description: $i18n.t('Browse and query knowledge bases')
},
channels: {
label: $i18n.t('Channels'),
description: $i18n.t('Search channels and channel messages')
},
web_search: {
label: $i18n.t('Web Search'),
description: $i18n.t('Search the web and fetch URLs')
},
image_generation: {
label: $i18n.t('Image Generation'),
description: $i18n.t('Generate and edit images')
},
code_interpreter: {
label: $i18n.t('Code Interpreter'),
description: $i18n.t('Execute code')
}
};
const allTools = Object.keys(toolLabels);
export let builtinTools: Record<string, boolean> = {};
// Initialize missing keys to true (default enabled)
$: {
for (const tool of allTools) {
if (!(tool in builtinTools)) {
builtinTools[tool] = true;
}
}
}
</script>
<div>
<div class="flex w-full justify-between mb-1">
<div class="self-center text-xs font-medium text-gray-500">{$i18n.t('Builtin Tools')}</div>
</div>
<div class="flex items-center mt-2 flex-wrap">
{#each allTools as tool}
<div class="flex items-center gap-2 mr-3">
<Checkbox
state={builtinTools[tool] !== false ? 'checked' : 'unchecked'}
on:change={(e) => {
builtinTools = {
...builtinTools,
[tool]: e.detail === 'checked'
};
}}
/>
<div class="py-0.5 text-sm">
<Tooltip content={marked.parse(toolLabels[tool].description)}>
{$i18n.t(toolLabels[tool].label)}
</Tooltip>
</div>
</div>
{/each}
</div>
</div>

View file

@ -1,8 +1,7 @@
<script lang="ts">
import dayjs from 'dayjs';
import { DropdownMenu } from 'bits-ui';
import { onMount, getContext, createEventDispatcher } from 'svelte';
import { onMount, onDestroy, getContext, createEventDispatcher } from 'svelte';
import { searchNotes } from '$lib/apis/notes';
import { searchKnowledgeBases, searchKnowledgeFiles } from '$lib/apis/knowledge';
@ -26,6 +25,7 @@
let show = false;
let query = '';
let searchDebounceTimer: ReturnType<typeof setTimeout>;
let noteItems = [];
let knowledgeItems = [];
@ -35,10 +35,17 @@
$: items = [...noteItems, ...knowledgeItems, ...fileItems];
$: if (query !== null) {
getItems();
$: if (query !== undefined) {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
getItems();
}, 300);
}
onDestroy(() => {
clearTimeout(searchDebounceTimer);
});
const getItems = () => {
getNoteItems();
getKnowledgeItems();

View file

@ -21,6 +21,7 @@
import XMark from '$lib/components/icons/XMark.svelte';
import DefaultFiltersSelector from './DefaultFiltersSelector.svelte';
import DefaultFeatures from './DefaultFeatures.svelte';
import BuiltinTools from './BuiltinTools.svelte';
import PromptSuggestions from './PromptSuggestions.svelte';
import AccessControlModal from '../common/AccessControlModal.svelte';
import LockClosed from '$lib/components/icons/LockClosed.svelte';
@ -104,6 +105,7 @@
builtin_tools: true
};
let defaultFeatureIds = [];
let builtinTools = {};
let actionIds = [];
let accessControl = {};
@ -195,6 +197,14 @@
}
}
if (Object.keys(builtinTools).length > 0) {
info.meta.builtinTools = builtinTools;
} else {
if (info.meta.builtinTools) {
delete info.meta.builtinTools;
}
}
if (tts.voice !== '') {
if (!info.meta.tts) info.meta.tts = {};
info.meta.tts.voice = tts.voice;
@ -288,6 +298,7 @@
capabilities = { ...capabilities, ...(model?.meta?.capabilities ?? {}) };
defaultFeatureIds = model?.meta?.defaultFeatureIds ?? [];
builtinTools = model?.meta?.builtinTools ?? {};
tts = { voice: model?.meta?.tts?.voice ?? '' };
if ('access_control' in model) {
@ -780,6 +791,12 @@
{/if}
{/if}
{#if capabilities.builtin_tools}
<div class="my-2">
<BuiltinTools bind:builtinTools />
</div>
{/if}
<div class="my-2">
<div class="flex w-full justify-between mb-1">
<div class="self-center text-xs font-medium text-gray-500">

View file

@ -12,7 +12,13 @@
const i18n = getContext('i18n');
onMount(() => {
_tools = tools.reduce((acc, tool) => {
// Filter out OAuth2.1 MCP tools - these cannot be set as model defaults
// because OAuth authentication is per-user and would fail for users
// who haven't completed the OAuth flow.
// The `authenticated` field only exists on OAuth2.1 MCP tools.
const availableTools = tools.filter((tool) => tool.authenticated === undefined);
_tools = availableTools.reduce((acc, tool) => {
acc[tool.id] = {
...tool,
selected: selectedToolIds.includes(tool.id)

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