mirror of
https://github.com/open-webui/open-webui.git
synced 2026-09-15 23:32:40 +00:00
Merge branch 'dev' into feat/rds-iam-support
This commit is contained in:
commit
acad321c83
165 changed files with 5578 additions and 2206 deletions
6
.github/pull_request_template.md
vendored
6
.github/pull_request_template.md
vendored
|
|
@ -10,6 +10,12 @@
|
|||
|
||||
This is to ensure large feature PRs are discussed with the community first, before starting work on it. If the community does not want this feature or it is not relevant for Open WebUI as a project, it can be identified in the discussion before working on the feature and submitting the PR.
|
||||
|
||||
<!--
|
||||
### ⚠️ Important: Your PR is a contribution, not a guarantee of merge.
|
||||
|
||||
The most impactful way to contribute to Open WebUI is through well-written bug reports, detailed feature discussions, and thoughtful ideas. These directly shape the project. If you do open a pull request, please know that Open WebUI is held to the highest standard of code quality, consistency, and architectural coherence, and every line merged becomes something the core team must own, maintain, and support indefinitely. Submitted code may be refactored, rewritten, or used as inspiration for a different implementation. This is not a reflection of your work's quality. It is how we ensure that a small team can deeply understand and evolve every part of the codebase.
|
||||
-->
|
||||
|
||||
**Before submitting, make sure you've checked the following:**
|
||||
|
||||
- [ ] **Target branch:** Verify that the pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.**
|
||||
|
|
|
|||
24
LICENSE
24
LICENSE
|
|
@ -1,3 +1,5 @@
|
|||
Open WebUI License
|
||||
|
||||
Copyright (c) 2023- Open WebUI Inc. [Created by Timothy Jaeryang Baek]
|
||||
All rights reserved.
|
||||
|
||||
|
|
@ -15,11 +17,27 @@ modification, are permitted provided that the following conditions are met:
|
|||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
4. Notwithstanding any other provision of this License, and as a material condition of the rights granted herein, licensees are strictly prohibited from altering, removing, obscuring, or replacing any "Open WebUI" branding, including but not limited to the name, logo, or any visual, textual, or symbolic identifiers that distinguish the software and its interfaces, in any deployment or distribution, regardless of the number of users, except as explicitly set forth in Clauses 5 and 6 below.
|
||||
4. Notwithstanding any other provision of this License, and as a material
|
||||
condition of the rights granted herein, licensees are strictly prohibited
|
||||
from altering, removing, obscuring, or replacing any "Open WebUI"
|
||||
branding, including but not limited to the name, logo, or any visual,
|
||||
textual, or symbolic identifiers that distinguish the software and its
|
||||
interfaces, in any deployment or distribution, except in the following
|
||||
circumstances: (i) deployments or distributions where the total number
|
||||
of end users (defined as individual natural persons with direct access
|
||||
to the application) does not exceed fifty (50) within any rolling
|
||||
thirty (30) day period; (ii) the licensee has obtained specific prior
|
||||
written permission from the copyright holder; or (iii) where the
|
||||
licensee has obtained a duly executed enterprise license expressly
|
||||
permitting such modification. For all other cases, any removal or
|
||||
alteration of the "Open WebUI" branding shall constitute a material
|
||||
breach of license.
|
||||
|
||||
5. The branding restriction enumerated in Clause 4 shall not apply in the following limited circumstances: (i) deployments or distributions where the total number of end users (defined as individual natural persons with direct access to the application) does not exceed fifty (50) within any rolling thirty (30) day period; (ii) cases in which the licensee is an official contributor to the codebase—with a substantive code change successfully merged into the main branch of the official codebase maintained by the copyright holder—who has obtained specific prior written permission for branding adjustment from the copyright holder; or (iii) where the licensee has obtained a duly executed enterprise license expressly permitting such modification. For all other cases, any removal or alteration of the "Open WebUI" branding shall constitute a material breach of license.
|
||||
Materials governed by prior licenses retain those original license
|
||||
terms, as specified in LICENSE_HISTORY.
|
||||
|
||||
6. All code, modifications, or derivative works incorporated into this project prior to the incorporation of this branding clause remain licensed under the BSD 3-Clause License, and prior contributors retain all BSD-3 rights therein; if any such contributor requests the removal of their BSD-3-licensed code, the copyright holder will do so, and any replacement code will be licensed under the project's primary license then in effect. By contributing after this clause's adoption, you agree to the project's Contributor License Agreement (CLA) and to these updated terms for all new contributions.
|
||||
By contributing to this project, you agree to the project's Contributor
|
||||
License Agreement (CONTRIBUTOR_LICENSE_AGREEMENT).
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
|
@ -35,7 +36,7 @@ from open_webui.env import (
|
|||
WEBUI_NAME,
|
||||
log,
|
||||
)
|
||||
from open_webui.internal.db import Base, get_db
|
||||
from open_webui.internal.db import Base, get_db, get_async_db
|
||||
from open_webui.utils.redis import get_redis_connection
|
||||
|
||||
|
||||
|
|
@ -90,6 +91,7 @@ def load_json_config():
|
|||
|
||||
|
||||
def save_to_db(data):
|
||||
"""Sync save — used ONLY at startup/import time."""
|
||||
with get_db() as db:
|
||||
existing_config = db.query(Config).first()
|
||||
if not existing_config:
|
||||
|
|
@ -102,12 +104,39 @@ def save_to_db(data):
|
|||
db.commit()
|
||||
|
||||
|
||||
async def async_save_to_db(data):
|
||||
"""Async save — used for ALL runtime config persistence."""
|
||||
from sqlalchemy import select
|
||||
|
||||
async with get_async_db() as db:
|
||||
result = await db.execute(select(Config).limit(1))
|
||||
existing_config = result.scalars().first()
|
||||
if not existing_config:
|
||||
new_config = Config(data=data, version=0)
|
||||
db.add(new_config)
|
||||
else:
|
||||
existing_config.data = data
|
||||
existing_config.updated_at = datetime.now()
|
||||
db.add(existing_config)
|
||||
await db.commit()
|
||||
|
||||
|
||||
def reset_config():
|
||||
"""Sync reset — used ONLY at startup."""
|
||||
with get_db() as db:
|
||||
db.query(Config).delete()
|
||||
db.commit()
|
||||
|
||||
|
||||
async def async_reset_config():
|
||||
"""Async reset — used at runtime."""
|
||||
from sqlalchemy import delete as sa_delete
|
||||
|
||||
async with get_async_db() as db:
|
||||
await db.execute(sa_delete(Config))
|
||||
await db.commit()
|
||||
|
||||
|
||||
# When initializing, check if config.json exists and migrate it to the database
|
||||
if os.path.exists(f'{DATA_DIR}/config.json'):
|
||||
data = load_json_config()
|
||||
|
|
@ -144,6 +173,7 @@ PERSISTENT_CONFIG_REGISTRY = []
|
|||
|
||||
|
||||
def save_config(config):
|
||||
"""Sync save — used ONLY at startup/import time."""
|
||||
global CONFIG_DATA
|
||||
global PERSISTENT_CONFIG_REGISTRY
|
||||
try:
|
||||
|
|
@ -159,6 +189,23 @@ def save_config(config):
|
|||
return True
|
||||
|
||||
|
||||
async def async_save_config(config):
|
||||
"""Async save — used for ALL runtime config persistence."""
|
||||
global CONFIG_DATA
|
||||
global PERSISTENT_CONFIG_REGISTRY
|
||||
try:
|
||||
await async_save_to_db(config)
|
||||
CONFIG_DATA = config
|
||||
|
||||
# Trigger updates on all registered PersistentConfig entries
|
||||
for config_item in PERSISTENT_CONFIG_REGISTRY:
|
||||
config_item.update()
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
ENABLE_PERSISTENT_CONFIG = os.environ.get('ENABLE_PERSISTENT_CONFIG', 'True').lower() == 'true'
|
||||
|
|
@ -202,6 +249,7 @@ class PersistentConfig(Generic[T]):
|
|||
log.info(f'Updated {self.env_name} to new value {self.value}')
|
||||
|
||||
def save(self):
|
||||
"""Sync save — used ONLY at startup/import time."""
|
||||
log.info(f"Saving '{self.env_name}' to the database")
|
||||
path_parts = self.config_path.split('.')
|
||||
sub_config = CONFIG_DATA
|
||||
|
|
@ -213,6 +261,19 @@ class PersistentConfig(Generic[T]):
|
|||
save_to_db(CONFIG_DATA)
|
||||
self.config_value = self.value
|
||||
|
||||
async def async_save(self):
|
||||
"""Async save — used for ALL runtime config persistence."""
|
||||
log.info(f"Saving '{self.env_name}' to the database")
|
||||
path_parts = self.config_path.split('.')
|
||||
sub_config = CONFIG_DATA
|
||||
for key in path_parts[:-1]:
|
||||
if key not in sub_config:
|
||||
sub_config[key] = {}
|
||||
sub_config = sub_config[key]
|
||||
sub_config[path_parts[-1]] = self.value
|
||||
await async_save_to_db(CONFIG_DATA)
|
||||
self.config_value = self.value
|
||||
|
||||
|
||||
class AppConfig:
|
||||
_redis: Union[redis.Redis, redis.cluster.RedisCluster] = None
|
||||
|
|
@ -246,12 +307,27 @@ class AppConfig:
|
|||
self._state[key] = value
|
||||
else:
|
||||
self._state[key].value = value
|
||||
self._state[key].save()
|
||||
|
||||
# At runtime (inside the event loop) persist via the async engine
|
||||
# to avoid blocking the loop and contending with the async DB pool.
|
||||
# At startup/import time, fall back to sync.
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.create_task(self._async_persist(key))
|
||||
except RuntimeError:
|
||||
self._state[key].save()
|
||||
|
||||
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))
|
||||
|
||||
async def _async_persist(self, key):
|
||||
"""Persist a single config key via the async engine."""
|
||||
try:
|
||||
await self._state[key].async_save()
|
||||
except Exception as e:
|
||||
log.error(f'Failed to async-persist config key {key}: {e}')
|
||||
|
||||
def __getattr__(self, key):
|
||||
if key not in self._state:
|
||||
raise AttributeError(f"Config key '{key}' not found")
|
||||
|
|
@ -1149,10 +1225,16 @@ ENABLE_SIGNUP = PersistentConfig(
|
|||
|
||||
ENABLE_LOGIN_FORM = PersistentConfig(
|
||||
'ENABLE_LOGIN_FORM',
|
||||
'ui.ENABLE_LOGIN_FORM',
|
||||
'ui.enable_login_form',
|
||||
os.environ.get('ENABLE_LOGIN_FORM', 'True').lower() == 'true',
|
||||
)
|
||||
|
||||
ENABLE_PASSWORD_CHANGE_FORM = PersistentConfig(
|
||||
'ENABLE_PASSWORD_CHANGE_FORM',
|
||||
'ui.enable_password_change_form',
|
||||
os.environ.get('ENABLE_PASSWORD_CHANGE_FORM', 'True').lower() == 'true',
|
||||
)
|
||||
|
||||
ENABLE_PASSWORD_AUTH = os.environ.get('ENABLE_PASSWORD_AUTH', 'True').lower() == 'true'
|
||||
|
||||
DEFAULT_LOCALE = PersistentConfig(
|
||||
|
|
@ -2883,6 +2965,12 @@ RAG_RERANKING_MODEL_TRUST_REMOTE_CODE = (
|
|||
os.environ.get('RAG_RERANKING_MODEL_TRUST_REMOTE_CODE', 'True').lower() == 'true'
|
||||
)
|
||||
|
||||
RAG_RERANKING_BATCH_SIZE = PersistentConfig(
|
||||
'RAG_RERANKING_BATCH_SIZE',
|
||||
'rag.reranking_batch_size',
|
||||
int(os.environ.get('RAG_RERANKING_BATCH_SIZE', '32')),
|
||||
)
|
||||
|
||||
RAG_EXTERNAL_RERANKER_URL = PersistentConfig(
|
||||
'RAG_EXTERNAL_RERANKER_URL',
|
||||
'rag.external_reranker_url',
|
||||
|
|
|
|||
|
|
@ -91,6 +91,17 @@ class ERROR_MESSAGES(str, Enum):
|
|||
|
||||
INVALID_PASSWORD = lambda err='': err if err else 'The password does not meet the required validation criteria.'
|
||||
|
||||
AUTOMATION_LIMIT_EXCEEDED = lambda size='': f'Automation limit reached ({size})'
|
||||
AUTOMATION_TOO_FREQUENT = lambda interval='': f'Schedule too frequent. Minimum interval is {interval} seconds.'
|
||||
AUTOMATION_INVALID_RRULE = lambda err='': f'Invalid RRULE: {err}'
|
||||
AUTOMATION_NO_FUTURE_RUNS = 'RRULE has no future occurrences'
|
||||
|
||||
FEATURE_DISABLED = lambda name='': f'{name} is disabled'
|
||||
INPUT_TOO_LONG = lambda size='': f'Input prompt exceeds maximum length of {size}'
|
||||
SERVER_CONNECTION_ERROR = 'Open WebUI: Server Connection Error'
|
||||
REQUIRED_FIELD_EMPTY = lambda name='': f'Required field {name} is empty'
|
||||
OAUTH_NOT_CONFIGURED = lambda name='': f"Provider '{name}' is not configured"
|
||||
|
||||
|
||||
class TASKS(str, Enum):
|
||||
def __str__(self) -> str:
|
||||
|
|
|
|||
|
|
@ -397,7 +397,35 @@ else:
|
|||
except Exception:
|
||||
DATABASE_POOL_RECYCLE = 3600
|
||||
|
||||
DATABASE_ENABLE_SQLITE_WAL = os.environ.get('DATABASE_ENABLE_SQLITE_WAL', 'False').lower() == 'true'
|
||||
DATABASE_ENABLE_SQLITE_WAL = os.environ.get('DATABASE_ENABLE_SQLITE_WAL', 'True').lower() == 'true'
|
||||
|
||||
# SQLite PRAGMA tuning — these defaults are optimised for WAL-mode web-server
|
||||
# workloads. Each can be overridden via its environment variable.
|
||||
# Set any value to an empty string to skip that PRAGMA entirely.
|
||||
|
||||
# PRAGMA synchronous: NORMAL (1) is safe with WAL and avoids an fsync per
|
||||
# transaction. Valid values: OFF (0), NORMAL (1), FULL (2), EXTRA (3).
|
||||
DATABASE_SQLITE_PRAGMA_SYNCHRONOUS = os.environ.get('DATABASE_SQLITE_PRAGMA_SYNCHRONOUS', 'NORMAL')
|
||||
|
||||
# PRAGMA busy_timeout (ms): how long a connection waits for a write lock
|
||||
# before raising SQLITE_BUSY.
|
||||
DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT = os.environ.get('DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT', '5000')
|
||||
|
||||
# PRAGMA cache_size: negative value = KiB. -65536 ≈ 64 MB page cache.
|
||||
DATABASE_SQLITE_PRAGMA_CACHE_SIZE = os.environ.get('DATABASE_SQLITE_PRAGMA_CACHE_SIZE', '-65536')
|
||||
|
||||
# PRAGMA temp_store: MEMORY (2) keeps temp tables and indices in RAM.
|
||||
# Valid values: DEFAULT (0), FILE (1), MEMORY (2).
|
||||
DATABASE_SQLITE_PRAGMA_TEMP_STORE = os.environ.get('DATABASE_SQLITE_PRAGMA_TEMP_STORE', 'MEMORY')
|
||||
|
||||
# PRAGMA mmap_size (bytes): memory-mapped I/O size. 268435456 ≈ 256 MB.
|
||||
# Set to 0 to disable mmap.
|
||||
DATABASE_SQLITE_PRAGMA_MMAP_SIZE = os.environ.get('DATABASE_SQLITE_PRAGMA_MMAP_SIZE', '268435456')
|
||||
|
||||
# PRAGMA journal_size_limit (bytes): caps the WAL file size after checkpoint.
|
||||
# Without this the WAL grows unbounded during write bursts and is never
|
||||
# truncated. 67108864 ≈ 64 MB. Set to -1 for no limit (SQLite default).
|
||||
DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT = os.environ.get('DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT', '67108864')
|
||||
|
||||
DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = os.environ.get('DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL', None)
|
||||
if DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL is not None:
|
||||
|
|
@ -539,6 +567,11 @@ PASSWORD_VALIDATION_HINT = os.environ.get('PASSWORD_VALIDATION_HINT', '')
|
|||
|
||||
BYPASS_MODEL_ACCESS_CONTROL = os.environ.get('BYPASS_MODEL_ACCESS_CONTROL', 'False').lower() == 'true'
|
||||
|
||||
# When enabled, skips pydub-based preprocessing (format conversion, compression,
|
||||
# and chunked splitting) before sending files to processing engines. Useful when
|
||||
# the upstream provider handles these steps or when ffmpeg is unavailable.
|
||||
BYPASS_PYDUB_PREPROCESSING = os.environ.get('BYPASS_PYDUB_PREPROCESSING', 'False').lower() == 'true'
|
||||
|
||||
# When disabled (default), the OpenAI catch-all proxy endpoint (/{path:path})
|
||||
# is blocked. Enable only if you need direct passthrough to upstream OpenAI-
|
||||
# compatible APIs for endpoints not natively handled by Open WebUI.
|
||||
|
|
|
|||
|
|
@ -34,8 +34,10 @@ from open_webui.utils.plugin import (
|
|||
load_function_module_by_id,
|
||||
get_function_module_from_cache,
|
||||
)
|
||||
from open_webui.utils.access_control import check_model_access
|
||||
|
||||
from open_webui.env import GLOBAL_LOG_LEVEL
|
||||
from open_webui.env import GLOBAL_LOG_LEVEL, BYPASS_MODEL_ACCESS_CONTROL
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
|
||||
from open_webui.utils.misc import (
|
||||
add_or_update_system_message,
|
||||
|
|
@ -260,6 +262,10 @@ async def generate_function_chat_completion(request, form_data, user, models: di
|
|||
if model_info.base_model_id:
|
||||
form_data['model'] = model_info.base_model_id
|
||||
|
||||
if not BYPASS_MODEL_ACCESS_CONTROL:
|
||||
bypass = isinstance(user, UserModel) and user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL
|
||||
await check_model_access(user if isinstance(user, UserModel) else UserModel(**user), model_info, bypass)
|
||||
|
||||
params = model_info.params.model_dump()
|
||||
|
||||
if params:
|
||||
|
|
|
|||
|
|
@ -24,6 +24,12 @@ from open_webui.env import (
|
|||
DATABASE_POOL_TIMEOUT,
|
||||
DATABASE_ENABLE_SQLITE_WAL,
|
||||
DATABASE_ENABLE_SESSION_SHARING,
|
||||
DATABASE_SQLITE_PRAGMA_SYNCHRONOUS,
|
||||
DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT,
|
||||
DATABASE_SQLITE_PRAGMA_CACHE_SIZE,
|
||||
DATABASE_SQLITE_PRAGMA_TEMP_STORE,
|
||||
DATABASE_SQLITE_PRAGMA_MMAP_SIZE,
|
||||
DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT,
|
||||
ENABLE_DB_MIGRATIONS,
|
||||
)
|
||||
from peewee_migrate import Router
|
||||
|
|
@ -370,14 +376,32 @@ if SQLALCHEMY_DATABASE_URL.startswith('sqlite+sqlcipher://'):
|
|||
elif 'sqlite' in SQLALCHEMY_DATABASE_URL:
|
||||
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={'check_same_thread': False})
|
||||
|
||||
def on_connect(dbapi_connection, connection_record):
|
||||
def _apply_sqlite_pragmas(dbapi_connection):
|
||||
"""Apply all configured SQLite PRAGMAs to a raw DBAPI connection."""
|
||||
cursor = dbapi_connection.cursor()
|
||||
if DATABASE_ENABLE_SQLITE_WAL:
|
||||
cursor.execute('PRAGMA journal_mode=WAL')
|
||||
else:
|
||||
cursor.execute('PRAGMA journal_mode=DELETE')
|
||||
|
||||
# Each PRAGMA is skipped when its env var is empty, allowing opt-out.
|
||||
if DATABASE_SQLITE_PRAGMA_SYNCHRONOUS:
|
||||
cursor.execute(f'PRAGMA synchronous={DATABASE_SQLITE_PRAGMA_SYNCHRONOUS}')
|
||||
if DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT:
|
||||
cursor.execute(f'PRAGMA busy_timeout={DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT}')
|
||||
if DATABASE_SQLITE_PRAGMA_CACHE_SIZE:
|
||||
cursor.execute(f'PRAGMA cache_size={DATABASE_SQLITE_PRAGMA_CACHE_SIZE}')
|
||||
if DATABASE_SQLITE_PRAGMA_TEMP_STORE:
|
||||
cursor.execute(f'PRAGMA temp_store={DATABASE_SQLITE_PRAGMA_TEMP_STORE}')
|
||||
if DATABASE_SQLITE_PRAGMA_MMAP_SIZE:
|
||||
cursor.execute(f'PRAGMA mmap_size={DATABASE_SQLITE_PRAGMA_MMAP_SIZE}')
|
||||
if DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT:
|
||||
cursor.execute(f'PRAGMA journal_size_limit={DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT}')
|
||||
cursor.close()
|
||||
|
||||
def on_connect(dbapi_connection, connection_record):
|
||||
_apply_sqlite_pragmas(dbapi_connection)
|
||||
|
||||
event.listen(engine, 'connect', on_connect)
|
||||
elif DATABASE_ENABLE_IAM_TOKEN_AUTH:
|
||||
engine = rds_iam_config.create_engine()
|
||||
|
|
@ -416,15 +440,10 @@ if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
|
|||
ASYNC_SQLALCHEMY_DATABASE_URL,
|
||||
connect_args={'check_same_thread': False},
|
||||
)
|
||||
|
||||
if DATABASE_ENABLE_SQLITE_WAL:
|
||||
|
||||
@event.listens_for(async_engine.sync_engine, 'connect')
|
||||
def _set_sqlite_wal(dbapi_connection, connection_record):
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute('PRAGMA journal_mode=WAL')
|
||||
cursor.close()
|
||||
|
||||
|
||||
@event.listens_for(async_engine.sync_engine, 'connect')
|
||||
def _set_sqlite_pragmas(dbapi_connection, connection_record):
|
||||
_apply_sqlite_pragmas(dbapi_connection)
|
||||
elif DATABASE_ENABLE_IAM_TOKEN_AUTH:
|
||||
async_engine = rds_iam_config.create_engine(is_async=True)
|
||||
event.listen(async_engine.sync_engine, 'do_connect', rds_iam_config.check_token)
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ from typing import Optional
|
|||
from aiocache import cached
|
||||
import aiohttp
|
||||
import anyio.to_thread
|
||||
import requests
|
||||
|
||||
from redis import Redis
|
||||
|
||||
|
||||
|
|
@ -46,7 +46,6 @@ from fastapi.staticfiles import StaticFiles
|
|||
from starlette_compress import CompressMiddleware
|
||||
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from starlette.responses import Response, StreamingResponse
|
||||
from starlette.datastructures import Headers
|
||||
|
|
@ -58,8 +57,15 @@ from starsessions import (
|
|||
from starsessions.stores.redis import RedisStore
|
||||
|
||||
from open_webui.utils import logger
|
||||
from open_webui.utils.asgi_middleware import (
|
||||
AuthTokenMiddleware,
|
||||
CommitSessionMiddleware,
|
||||
RedirectMiddleware,
|
||||
WebsocketUpgradeGuardMiddleware,
|
||||
)
|
||||
from open_webui.utils.audit import AuditLevel, AuditLoggingMiddleware
|
||||
from open_webui.utils.logger import start_logger
|
||||
from open_webui.utils.session_pool import get_session
|
||||
from open_webui.socket.main import (
|
||||
MODELS,
|
||||
app as socket_app,
|
||||
|
|
@ -115,7 +121,7 @@ from open_webui.internal.db import ScopedSession, engine, get_async_session
|
|||
from open_webui.models.functions import Functions
|
||||
from open_webui.models.models import Models
|
||||
from open_webui.models.users import UserModel, Users
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.chats import Chats, ChatForm
|
||||
|
||||
from open_webui.config import (
|
||||
# Ollama
|
||||
|
|
@ -242,6 +248,7 @@ from open_webui.config import (
|
|||
RAG_EXTERNAL_RERANKER_URL,
|
||||
RAG_EXTERNAL_RERANKER_API_KEY,
|
||||
RAG_EXTERNAL_RERANKER_TIMEOUT,
|
||||
RAG_RERANKING_BATCH_SIZE,
|
||||
RAG_RERANKING_MODEL_AUTO_UPDATE,
|
||||
RAG_RERANKING_MODEL_TRUST_REMOTE_CODE,
|
||||
RAG_EMBEDDING_ENGINE,
|
||||
|
|
@ -379,6 +386,7 @@ from open_webui.config import (
|
|||
JWT_EXPIRES_IN,
|
||||
ENABLE_SIGNUP,
|
||||
ENABLE_LOGIN_FORM,
|
||||
ENABLE_PASSWORD_CHANGE_FORM,
|
||||
ENABLE_API_KEYS,
|
||||
ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS,
|
||||
API_KEYS_ALLOWED_ENDPOINTS,
|
||||
|
|
@ -470,6 +478,7 @@ from open_webui.config import (
|
|||
AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH,
|
||||
AppConfig,
|
||||
reset_config,
|
||||
async_reset_config,
|
||||
)
|
||||
from open_webui.env import (
|
||||
ENABLE_CUSTOM_MODEL_FALLBACK,
|
||||
|
|
@ -555,6 +564,7 @@ from open_webui.utils.oauth import (
|
|||
get_oauth_client_info_with_static_credentials,
|
||||
encrypt_data,
|
||||
decrypt_data,
|
||||
resolve_oauth_client_info,
|
||||
OAuthManager,
|
||||
OAuthClientManager,
|
||||
OAuthClientInformationFull,
|
||||
|
|
@ -574,7 +584,7 @@ from open_webui.tasks import (
|
|||
from open_webui.utils.redis import get_sentinels_from_env
|
||||
|
||||
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.constants import ERROR_MESSAGES, TASKS
|
||||
|
||||
if SAFE_MODE:
|
||||
print('SAFE MODE ENABLED')
|
||||
|
|
@ -625,7 +635,7 @@ async def lifespan(app: FastAPI):
|
|||
start_logger()
|
||||
|
||||
if RESET_CONFIG_ON_START:
|
||||
reset_config()
|
||||
await async_reset_config()
|
||||
|
||||
if LICENSE_KEY:
|
||||
get_license_data(app, LICENSE_KEY)
|
||||
|
|
@ -853,6 +863,7 @@ app.state.BASE_MODELS = []
|
|||
app.state.config.WEBUI_URL = WEBUI_URL
|
||||
app.state.config.ENABLE_SIGNUP = ENABLE_SIGNUP
|
||||
app.state.config.ENABLE_LOGIN_FORM = ENABLE_LOGIN_FORM
|
||||
app.state.config.ENABLE_PASSWORD_CHANGE_FORM = ENABLE_PASSWORD_CHANGE_FORM
|
||||
|
||||
app.state.config.ENABLE_API_KEYS = ENABLE_API_KEYS
|
||||
app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS = ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS
|
||||
|
|
@ -1034,6 +1045,7 @@ app.state.config.RAG_RERANKING_MODEL = RAG_RERANKING_MODEL
|
|||
app.state.config.RAG_EXTERNAL_RERANKER_URL = RAG_EXTERNAL_RERANKER_URL
|
||||
app.state.config.RAG_EXTERNAL_RERANKER_API_KEY = RAG_EXTERNAL_RERANKER_API_KEY
|
||||
app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT = RAG_EXTERNAL_RERANKER_TIMEOUT
|
||||
app.state.config.RAG_RERANKING_BATCH_SIZE = RAG_RERANKING_BATCH_SIZE
|
||||
|
||||
app.state.config.RAG_TEMPLATE = RAG_TEMPLATE
|
||||
|
||||
|
|
@ -1183,6 +1195,7 @@ app.state.RERANKING_FUNCTION = get_reranking_function(
|
|||
app.state.config.RAG_RERANKING_ENGINE,
|
||||
app.state.config.RAG_RERANKING_MODEL,
|
||||
reranking_function=app.state.rf,
|
||||
reranking_batch_size=app.state.config.RAG_RERANKING_BATCH_SIZE,
|
||||
)
|
||||
|
||||
########################################
|
||||
|
|
@ -1354,104 +1367,19 @@ if ENABLE_COMPRESSION_MIDDLEWARE:
|
|||
app.add_middleware(CompressMiddleware)
|
||||
|
||||
|
||||
class RedirectMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Check if the request is a GET request
|
||||
if request.method == 'GET':
|
||||
path = request.url.path
|
||||
query_params = dict(parse_qs(urlparse(str(request.url)).query))
|
||||
|
||||
redirect_params = {}
|
||||
|
||||
# Check for the specific watch path and the presence of 'v' parameter
|
||||
if path.endswith('/watch') and 'v' in query_params:
|
||||
# Extract the first 'v' parameter
|
||||
youtube_video_id = query_params['v'][0]
|
||||
redirect_params['youtube'] = youtube_video_id
|
||||
|
||||
if 'shared' in query_params and len(query_params['shared']) > 0:
|
||||
# PWA share_target support
|
||||
|
||||
text = query_params['shared'][0]
|
||||
if text:
|
||||
urls = re.match(r'https://\S+', text)
|
||||
if urls:
|
||||
from open_webui.retrieval.loaders.youtube import _parse_video_id
|
||||
|
||||
if youtube_video_id := _parse_video_id(urls[0]):
|
||||
redirect_params['youtube'] = youtube_video_id
|
||||
else:
|
||||
redirect_params['load-url'] = urls[0]
|
||||
else:
|
||||
redirect_params['q'] = text
|
||||
|
||||
if redirect_params:
|
||||
redirect_url = f'/?{urlencode(redirect_params)}'
|
||||
return RedirectResponse(url=redirect_url)
|
||||
|
||||
# Proceed with the normal flow of other requests
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
|
||||
# All HTTP middlewares below are pure-ASGI implementations. The previous
|
||||
# `BaseHTTPMiddleware` / `@app.middleware('http')` versions wrapped the
|
||||
# downstream app in an anyio task group whose cancel scope cancelled
|
||||
# in-flight DB calls (and any other awaits) on client disconnect /
|
||||
# response completion — which surfaced as noisy SQLAlchemy
|
||||
# `terminate_force_close` tracebacks under aiosqlite and as random
|
||||
# CancelledError storms across the request path. See
|
||||
# `open_webui.utils.asgi_middleware` for the rationale.
|
||||
app.add_middleware(RedirectMiddleware)
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
|
||||
|
||||
@app.middleware('http')
|
||||
async def commit_session_after_request(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
# log.debug("Commit session after request")
|
||||
try:
|
||||
ScopedSession.commit()
|
||||
finally:
|
||||
# CRITICAL: remove() returns the connection to the pool.
|
||||
# Without this, connections remain "checked out" and accumulate
|
||||
# as "idle in transaction" in PostgreSQL.
|
||||
ScopedSession.remove()
|
||||
return response
|
||||
|
||||
|
||||
@app.middleware('http')
|
||||
async def check_url(request: Request, call_next):
|
||||
start_time = int(time.time())
|
||||
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'))
|
||||
|
||||
# Fallback to x-api-key header for Anthropic Messages API routes
|
||||
if request.state.token is None and request.headers.get('x-api-key'):
|
||||
request_path = request.url.path
|
||||
if request_path in ('/api/message', '/api/v1/messages') or request_path.startswith('/ollama/v1/messages'):
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
|
||||
request.state.token = HTTPAuthorizationCredentials(
|
||||
scheme='Bearer', credentials=request.headers.get('x-api-key')
|
||||
)
|
||||
|
||||
request.state.enable_api_keys = app.state.config.ENABLE_API_KEYS
|
||||
response = await call_next(request)
|
||||
process_time = int(time.time()) - start_time
|
||||
response.headers['X-Process-Time'] = str(process_time)
|
||||
return response
|
||||
|
||||
|
||||
@app.middleware('http')
|
||||
async def inspect_websocket(request: Request, call_next):
|
||||
if '/ws/socket.io' in request.url.path and request.query_params.get('transport') == 'websocket':
|
||||
upgrade = (request.headers.get('Upgrade') or '').lower()
|
||||
connection = (request.headers.get('Connection') or '').lower().split(',')
|
||||
# Check that there's the correct headers for an upgrade, else reject the connection
|
||||
# This is to work around this upstream issue: https://github.com/miguelgrinberg/python-engineio/issues/367
|
||||
if upgrade != 'websocket' or 'upgrade' not in connection:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content={'detail': 'Invalid WebSocket upgrade request'},
|
||||
)
|
||||
return await call_next(request)
|
||||
app.add_middleware(CommitSessionMiddleware)
|
||||
app.add_middleware(AuthTokenMiddleware, fastapi_app=app)
|
||||
app.add_middleware(WebsocketUpgradeGuardMiddleware)
|
||||
|
||||
|
||||
app.add_middleware(
|
||||
|
|
@ -1691,13 +1619,30 @@ async def chat_completion(
|
|||
if model_info_params.get('reasoning_tags') is not None:
|
||||
reasoning_tags = model_info_params.get('reasoning_tags')
|
||||
|
||||
# parent_id signals intent:
|
||||
# null → new chat (root message, no parent)
|
||||
# value → follow-up (user message's parentId = prev assistant)
|
||||
# absent → legacy caller, no chat management
|
||||
is_new_chat = 'parent_id' in form_data and form_data['parent_id'] is None and not form_data.get('chat_id')
|
||||
parent_id = form_data.pop('parent_id', None)
|
||||
form_data.pop('new_chat', None) # Legacy field
|
||||
|
||||
# Multi-model: {model_id: assistant_message_id}
|
||||
# Single-model fallback: built from 'model' + 'id'
|
||||
message_ids = form_data.pop('message_ids', None)
|
||||
if not message_ids:
|
||||
message_ids = {model_id: form_data.pop('id', None)}
|
||||
else:
|
||||
form_data.pop('id', None)
|
||||
|
||||
user_message = form_data.pop('user_message', None) or form_data.pop('parent_message', None)
|
||||
metadata = {
|
||||
'user_id': user.id,
|
||||
'chat_id': form_data.pop('chat_id', None),
|
||||
'message_id': form_data.pop('id', None),
|
||||
'parent_message': form_data.pop('parent_message', None),
|
||||
'parent_message_id': form_data.pop('parent_id', None),
|
||||
'user_message': user_message,
|
||||
'user_message_id': user_message.get('id') if user_message else None,
|
||||
'session_id': form_data.pop('session_id', None),
|
||||
'folder_id': form_data.pop('folder_id', None),
|
||||
'filter_ids': form_data.pop('filter_ids', []),
|
||||
'tool_ids': form_data.get('tool_ids', None),
|
||||
'tool_servers': form_data.pop('tool_servers', None),
|
||||
|
|
@ -1720,40 +1665,166 @@ async def chat_completion(
|
|||
},
|
||||
}
|
||||
|
||||
if is_new_chat:
|
||||
metadata['chat_id'] = str(uuid4())
|
||||
|
||||
if metadata.get('chat_id') and user:
|
||||
if not metadata['chat_id'].startswith('local:'): # temporary chats are not stored
|
||||
# Verify chat ownership — lightweight EXISTS check avoids
|
||||
# deserializing the full chat JSON blob just to confirm the row exists
|
||||
if (
|
||||
not await Chats.is_chat_owner(metadata['chat_id'], user.id) and user.role != 'admin'
|
||||
): # admins can access any chat
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.DEFAULT(),
|
||||
chat_id = metadata['chat_id']
|
||||
if not chat_id.startswith('local:'): # temporary chats are not stored
|
||||
if is_new_chat:
|
||||
# Build the full history upfront with ALL assistant placeholders
|
||||
user_message = metadata.get('user_message') or {}
|
||||
user_message_id = user_message.get('id') if user_message else None
|
||||
|
||||
history_messages = {}
|
||||
all_assistant_ids = [assistant_id for assistant_id in message_ids.values() if assistant_id]
|
||||
|
||||
if user_message_id and user_message:
|
||||
user_message['childrenIds'] = all_assistant_ids
|
||||
history_messages[user_message_id] = user_message
|
||||
|
||||
for target_model_id, assistant_message_id in message_ids.items():
|
||||
if assistant_message_id:
|
||||
history_messages[assistant_message_id] = {
|
||||
'id': assistant_message_id,
|
||||
'parentId': user_message_id,
|
||||
'childrenIds': [],
|
||||
'role': 'assistant',
|
||||
'content': '',
|
||||
'done': False,
|
||||
'model': target_model_id,
|
||||
'timestamp': int(time.time()),
|
||||
}
|
||||
|
||||
await Chats.insert_new_chat(
|
||||
chat_id,
|
||||
user.id,
|
||||
ChatForm(
|
||||
chat={
|
||||
'id': chat_id,
|
||||
'title': 'New Chat',
|
||||
'models': list(message_ids.keys()),
|
||||
'history': {
|
||||
'currentId': all_assistant_ids[0] if all_assistant_ids else user_message_id,
|
||||
'messages': history_messages,
|
||||
},
|
||||
'messages': [
|
||||
{'role': 'user', 'content': user_message.get('content', '')},
|
||||
]
|
||||
if user_message_id
|
||||
else [],
|
||||
'tags': [],
|
||||
'timestamp': int(time.time() * 1000),
|
||||
},
|
||||
folder_id=metadata.get('folder_id'),
|
||||
),
|
||||
)
|
||||
|
||||
# Insert chat files from parent message if any
|
||||
parent_message = metadata.get('parent_message') or {}
|
||||
parent_message_files = parent_message.get('files', [])
|
||||
if parent_message_files:
|
||||
try:
|
||||
await Chats.insert_chat_files(
|
||||
metadata['chat_id'],
|
||||
parent_message.get('id'),
|
||||
[
|
||||
file_item.get('id')
|
||||
for file_item in parent_message_files
|
||||
if file_item.get('type') == 'file'
|
||||
],
|
||||
user.id,
|
||||
# Insert chat files from user message if any
|
||||
user_message_files = user_message.get('files', [])
|
||||
if user_message_files:
|
||||
try:
|
||||
await Chats.insert_chat_files(
|
||||
chat_id,
|
||||
user_message_id,
|
||||
[
|
||||
file_item.get('id')
|
||||
for file_item in user_message_files
|
||||
if file_item.get('type') == 'file'
|
||||
],
|
||||
user.id,
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f'Error inserting chat files: {e}')
|
||||
pass
|
||||
else:
|
||||
# Existing chat — verify ownership
|
||||
if not await Chats.is_chat_owner(chat_id, user.id) and user.role != 'admin':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.DEFAULT(),
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f'Error inserting chat files: {e}')
|
||||
pass
|
||||
|
||||
# Save user message to DB
|
||||
user_message = metadata.get('user_message') or {}
|
||||
if user_message and user_message.get('id'):
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
chat_id,
|
||||
user_message['id'],
|
||||
user_message,
|
||||
)
|
||||
|
||||
# Link grandparent → user message (childrenIds)
|
||||
grandparent_id = user_message.get('parentId')
|
||||
if grandparent_id:
|
||||
grandparent = await Chats.get_message_by_id_and_message_id(chat_id, grandparent_id)
|
||||
if grandparent:
|
||||
child_ids = grandparent.get('childrenIds', [])
|
||||
if user_message['id'] not in child_ids:
|
||||
child_ids.append(user_message['id'])
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
chat_id, grandparent_id, {'childrenIds': child_ids}
|
||||
)
|
||||
|
||||
# Insert chat files from user message if any
|
||||
user_message_files = user_message.get('files', [])
|
||||
if user_message_files:
|
||||
try:
|
||||
await Chats.insert_chat_files(
|
||||
chat_id,
|
||||
user_message.get('id'),
|
||||
[
|
||||
file_item.get('id')
|
||||
for file_item in user_message_files
|
||||
if file_item.get('type') == 'file'
|
||||
],
|
||||
user.id,
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f'Error inserting chat files: {e}')
|
||||
pass
|
||||
|
||||
# Save ALL assistant placeholders
|
||||
user_message_id = metadata.get('user_message_id')
|
||||
all_assistant_ids = [assistant_id for assistant_id in message_ids.values() if assistant_id]
|
||||
|
||||
# Link user message → all assistant messages (childrenIds)
|
||||
if user_message_id and all_assistant_ids:
|
||||
existing_user_message = await Chats.get_message_by_id_and_message_id(chat_id, user_message_id)
|
||||
if existing_user_message:
|
||||
child_ids = existing_user_message.get('childrenIds', [])
|
||||
for assistant_id in all_assistant_ids:
|
||||
if assistant_id not in child_ids:
|
||||
child_ids.append(assistant_id)
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
chat_id,
|
||||
user_message_id,
|
||||
{'childrenIds': child_ids},
|
||||
)
|
||||
|
||||
# Save each assistant placeholder
|
||||
for target_model_id, assistant_message_id in message_ids.items():
|
||||
if assistant_message_id:
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
chat_id,
|
||||
assistant_message_id,
|
||||
{
|
||||
'id': assistant_message_id,
|
||||
'parentId': user_message_id,
|
||||
'childrenIds': [],
|
||||
'role': 'assistant',
|
||||
'content': '',
|
||||
'done': False,
|
||||
'model': target_model_id,
|
||||
'timestamp': int(time.time()),
|
||||
},
|
||||
)
|
||||
|
||||
request.state.metadata = metadata
|
||||
form_data['metadata'] = metadata
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
log.debug(f'Error processing chat metadata: {e}')
|
||||
raise HTTPException(
|
||||
|
|
@ -1761,24 +1832,26 @@ async def chat_completion(
|
|||
detail=str(e),
|
||||
)
|
||||
|
||||
async def process_chat(request, form_data, user, metadata, model):
|
||||
async def process_chat(request, form_data, user, metadata, model, tasks=None):
|
||||
try:
|
||||
form_data, metadata, events = await process_chat_payload(request, form_data, user, metadata, model)
|
||||
|
||||
response = await chat_completion_handler(request, form_data, user)
|
||||
if metadata.get('chat_id') and metadata.get('message_id'):
|
||||
|
||||
# When the upstream provider returns an error (e.g. HTTP 400
|
||||
# content-filter, quota exceeded), generate_chat_completion
|
||||
# returns a JSONResponse instead of raising. Detect this and
|
||||
# raise so the except-block below emits chat:message:error +
|
||||
# chat:tasks:cancel, unblocking the frontend.
|
||||
if isinstance(response, JSONResponse) and response.status_code >= 400:
|
||||
try:
|
||||
if not metadata['chat_id'].startswith('local:'):
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata['chat_id'],
|
||||
metadata['message_id'],
|
||||
{
|
||||
'parentId': metadata.get('parent_message_id', None),
|
||||
'model': model_id,
|
||||
},
|
||||
)
|
||||
error_body = json.loads(response.body.decode('utf-8', 'replace'))
|
||||
detail = error_body.get('error', error_body) if isinstance(error_body, dict) else error_body
|
||||
if isinstance(detail, dict):
|
||||
detail = detail.get('message', detail.get('detail', str(detail)))
|
||||
except Exception:
|
||||
pass
|
||||
detail = f'Provider returned HTTP {response.status_code}'
|
||||
raise Exception(detail)
|
||||
|
||||
ctx = await build_chat_response_context(request, form_data, user, model, metadata, tasks, events)
|
||||
|
||||
|
|
@ -1787,11 +1860,12 @@ async def chat_completion(
|
|||
log.info('Chat processing was cancelled')
|
||||
try:
|
||||
event_emitter = await get_event_emitter(metadata)
|
||||
await asyncio.shield(
|
||||
event_emitter(
|
||||
{'type': 'chat:tasks:cancel'},
|
||||
if event_emitter:
|
||||
await asyncio.shield(
|
||||
event_emitter(
|
||||
{'type': 'chat:tasks:cancel'},
|
||||
)
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
pass
|
||||
finally:
|
||||
|
|
@ -1806,21 +1880,22 @@ async def chat_completion(
|
|||
metadata['chat_id'],
|
||||
metadata['message_id'],
|
||||
{
|
||||
'parentId': metadata.get('parent_message_id', None),
|
||||
'parentId': metadata.get('user_message_id', None),
|
||||
'error': {'content': str(e)},
|
||||
},
|
||||
)
|
||||
|
||||
event_emitter = await get_event_emitter(metadata)
|
||||
await event_emitter(
|
||||
{
|
||||
'type': 'chat:message:error',
|
||||
'data': {'error': {'content': str(e)}},
|
||||
}
|
||||
)
|
||||
await event_emitter(
|
||||
{'type': 'chat:tasks:cancel'},
|
||||
)
|
||||
if event_emitter:
|
||||
await event_emitter(
|
||||
{
|
||||
'type': 'chat:message:error',
|
||||
'data': {'error': {'content': str(e)}},
|
||||
}
|
||||
)
|
||||
await event_emitter(
|
||||
{'type': 'chat:tasks:cancel'},
|
||||
)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -1856,20 +1931,72 @@ async def chat_completion(
|
|||
except Exception as e:
|
||||
log.debug(f'Error emitting chat:active: {e}')
|
||||
|
||||
if metadata.get('session_id') and metadata.get('chat_id') and metadata.get('message_id'):
|
||||
# Asynchronous Chat Processing
|
||||
task_id, _ = await create_task(
|
||||
request.app.state.redis,
|
||||
process_chat(request, form_data, user, metadata, model),
|
||||
id=metadata['chat_id'],
|
||||
)
|
||||
# Emit chat:active=true when task starts
|
||||
event_emitter = await get_event_emitter(metadata, update_db=False)
|
||||
if event_emitter:
|
||||
await event_emitter({'type': 'chat:active', 'data': {'active': True}})
|
||||
return {'status': True, 'task_id': task_id}
|
||||
# Fan out: one task per model
|
||||
if metadata.get('session_id') and metadata.get('chat_id'):
|
||||
task_ids = []
|
||||
chat_id = metadata['chat_id']
|
||||
|
||||
for idx, (target_model_id, assistant_message_id) in enumerate(message_ids.items()):
|
||||
if not assistant_message_id:
|
||||
continue
|
||||
|
||||
# Per-model metadata: own message_id + model
|
||||
per_model_metadata = {
|
||||
**metadata,
|
||||
'message_id': assistant_message_id,
|
||||
}
|
||||
|
||||
# Per-model form_data: own model
|
||||
model_form_data = {
|
||||
**form_data,
|
||||
'model': target_model_id,
|
||||
'metadata': per_model_metadata,
|
||||
}
|
||||
|
||||
# Resolve the model object for this specific model
|
||||
resolved_model = request.app.state.MODELS.get(target_model_id, model)
|
||||
|
||||
# Only the first model runs title/tags generation;
|
||||
# subsequent models only run follow-ups.
|
||||
task_id, _ = await create_task(
|
||||
request.app.state.redis,
|
||||
process_chat(
|
||||
request,
|
||||
model_form_data,
|
||||
user,
|
||||
per_model_metadata,
|
||||
resolved_model,
|
||||
tasks
|
||||
if idx == 0
|
||||
else {
|
||||
k: v
|
||||
for k, v in (tasks or {}).items()
|
||||
if k not in (TASKS.TITLE_GENERATION, TASKS.TAGS_GENERATION)
|
||||
}
|
||||
or None,
|
||||
),
|
||||
id=chat_id,
|
||||
)
|
||||
task_ids.append(task_id)
|
||||
|
||||
# Emit chat:active=true
|
||||
if task_ids:
|
||||
event_emitter = await get_event_emitter(
|
||||
{**metadata, 'message_id': list(message_ids.values())[0]},
|
||||
update_db=False,
|
||||
)
|
||||
if event_emitter:
|
||||
await event_emitter({'type': 'chat:active', 'data': {'active': True}})
|
||||
|
||||
return {
|
||||
'status': True,
|
||||
'task_ids': task_ids,
|
||||
'chat_id': chat_id,
|
||||
}
|
||||
else:
|
||||
return await process_chat(request, form_data, user, metadata, model)
|
||||
# Legacy/direct: single model, synchronous
|
||||
metadata['message_id'] = list(message_ids.values())[0]
|
||||
return await process_chat(request, form_data, user, metadata, model, tasks)
|
||||
|
||||
|
||||
# Alias for chat_completion (Legacy)
|
||||
|
|
@ -1943,6 +2070,8 @@ async def generate_messages(
|
|||
|
||||
@app.post('/api/chat/completed')
|
||||
async def chat_completed(request: Request, form_data: dict, user=Depends(get_verified_user)):
|
||||
"""Deprecated: outlet filters now run inline during chat completion.
|
||||
Kept for backward compatibility with external integrations."""
|
||||
try:
|
||||
model_item = form_data.pop('model_item', {})
|
||||
|
||||
|
|
@ -2076,6 +2205,7 @@ async def get_app_config(request: Request):
|
|||
'enable_api_keys': app.state.config.ENABLE_API_KEYS,
|
||||
'enable_signup': app.state.config.ENABLE_SIGNUP,
|
||||
'enable_login_form': app.state.config.ENABLE_LOGIN_FORM,
|
||||
'enable_password_change_form': app.state.config.ENABLE_PASSWORD_CHANGE_FORM,
|
||||
'enable_websocket': ENABLE_WEBSOCKET_SUPPORT,
|
||||
'enable_version_update_check': ENABLE_VERSION_UPDATE_CHECK,
|
||||
'enable_public_active_users_count': ENABLE_PUBLIC_ACTIVE_USERS_COUNT,
|
||||
|
|
@ -2286,10 +2416,8 @@ if len(app.state.config.TOOL_SERVER_CONNECTIONS) > 0:
|
|||
auth_type = tool_server_connection.get('auth_type', 'none')
|
||||
|
||||
if server_id and auth_type in ('oauth_2.1', 'oauth_2.1_static'):
|
||||
oauth_client_info = tool_server_connection.get('info', {}).get('oauth_client_info', '')
|
||||
|
||||
try:
|
||||
oauth_client_info = decrypt_data(oauth_client_info)
|
||||
oauth_client_info = resolve_oauth_client_info(tool_server_connection)
|
||||
app.state.oauth_client_manager.add_client(
|
||||
f'mcp:{server_id}',
|
||||
OAuthClientInformationFull(**oauth_client_info),
|
||||
|
|
@ -2350,18 +2478,25 @@ async def register_client(request, client_id: str) -> bool:
|
|||
|
||||
try:
|
||||
if auth_type == 'oauth_2.1_static':
|
||||
# Static credentials: rebuild from stored credentials + fresh metadata
|
||||
existing_client_info = connection.get('info', {}).get('oauth_client_info', '')
|
||||
if not existing_client_info:
|
||||
log.error(f'No stored OAuth client info for static client {client_id}')
|
||||
return False
|
||||
existing_data = decrypt_data(existing_client_info)
|
||||
# Static credentials: rebuild from admin-provided credentials + fresh metadata
|
||||
info = connection.get('info', {})
|
||||
oauth_client_id = info.get('oauth_client_id') or ''
|
||||
oauth_client_secret = info.get('oauth_client_secret') or ''
|
||||
if not oauth_client_id or not oauth_client_secret:
|
||||
# Fall back to blob for backward compatibility
|
||||
existing_client_info = info.get('oauth_client_info', '')
|
||||
if not existing_client_info:
|
||||
log.error(f'No stored OAuth client info for static client {client_id}')
|
||||
return False
|
||||
existing_data = decrypt_data(existing_client_info)
|
||||
oauth_client_id = oauth_client_id or existing_data.get('client_id', '')
|
||||
oauth_client_secret = oauth_client_secret or existing_data.get('client_secret', '')
|
||||
oauth_client_info = await get_oauth_client_info_with_static_credentials(
|
||||
request,
|
||||
client_id,
|
||||
server_url,
|
||||
oauth_client_id=existing_data.get('client_id', ''),
|
||||
oauth_client_secret=existing_data.get('client_secret', ''),
|
||||
oauth_client_id=oauth_client_id,
|
||||
oauth_client_secret=oauth_client_secret,
|
||||
)
|
||||
else:
|
||||
oauth_client_info = await get_oauth_client_info_with_dynamic_client_registration(
|
||||
|
|
@ -2494,7 +2629,13 @@ async def oauth_backchannel_logout(
|
|||
@app.get('/manifest.json')
|
||||
async def get_manifest_json():
|
||||
if app.state.EXTERNAL_PWA_MANIFEST_URL:
|
||||
return requests.get(app.state.EXTERNAL_PWA_MANIFEST_URL).json()
|
||||
session = await get_session()
|
||||
async with session.get(
|
||||
app.state.EXTERNAL_PWA_MANIFEST_URL,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
return await r.json()
|
||||
else:
|
||||
return {
|
||||
'name': app.state.WEBUI_NAME,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,164 @@
|
|||
"""Add shared_chat table and migrate existing shares
|
||||
|
||||
Revision ID: c1d2e3f4a5b6
|
||||
Revises: e1f2a3b4c5d6
|
||||
Create Date: 2026-04-16 23:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'c1d2e3f4a5b6'
|
||||
down_revision = 'e1f2a3b4c5d6'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# Lightweight table references for data migration (no ORM models needed)
|
||||
chat_t = sa.table(
|
||||
'chat',
|
||||
sa.column('id', sa.Text),
|
||||
sa.column('user_id', sa.Text),
|
||||
sa.column('title', sa.Text),
|
||||
sa.column('chat', sa.JSON),
|
||||
sa.column('share_id', sa.Text),
|
||||
sa.column('created_at', sa.BigInteger),
|
||||
sa.column('updated_at', sa.BigInteger),
|
||||
sa.column('archived', sa.Boolean),
|
||||
sa.column('meta', sa.JSON),
|
||||
)
|
||||
|
||||
shared_chat_t = sa.table(
|
||||
'shared_chat',
|
||||
sa.column('id', sa.Text),
|
||||
sa.column('chat_id', sa.Text),
|
||||
sa.column('user_id', sa.Text),
|
||||
sa.column('title', sa.Text),
|
||||
sa.column('chat', sa.JSON),
|
||||
sa.column('created_at', sa.BigInteger),
|
||||
sa.column('updated_at', sa.BigInteger),
|
||||
)
|
||||
|
||||
chat_message_t = sa.table(
|
||||
'chat_message',
|
||||
sa.column('chat_id', sa.Text),
|
||||
)
|
||||
|
||||
access_grant_t = sa.table(
|
||||
'access_grant',
|
||||
sa.column('id', sa.Text),
|
||||
sa.column('resource_type', sa.Text),
|
||||
sa.column('resource_id', sa.Text),
|
||||
sa.column('principal_type', sa.Text),
|
||||
sa.column('principal_id', sa.Text),
|
||||
sa.column('permission', sa.Text),
|
||||
sa.column('created_at', sa.BigInteger),
|
||||
)
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
|
||||
# 1. Create shared_chat table
|
||||
op.create_table(
|
||||
'shared_chat',
|
||||
sa.Column('id', sa.Text(), primary_key=True),
|
||||
sa.Column('chat_id', sa.Text(), sa.ForeignKey('chat.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('title', sa.Text(), nullable=True),
|
||||
sa.Column('chat', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('updated_at', sa.BigInteger(), nullable=True),
|
||||
)
|
||||
|
||||
# 2. Migrate existing shared-* rows
|
||||
shared_rows = conn.execute(
|
||||
sa.select(
|
||||
chat_t.c.id,
|
||||
chat_t.c.user_id,
|
||||
chat_t.c.title,
|
||||
chat_t.c.chat,
|
||||
chat_t.c.created_at,
|
||||
chat_t.c.updated_at,
|
||||
).where(chat_t.c.user_id.like('shared-%'))
|
||||
).fetchall()
|
||||
|
||||
for row in shared_rows:
|
||||
share_token = row.id
|
||||
original_chat_id = row.user_id.replace('shared-', '', 1)
|
||||
|
||||
# Verify original chat still exists
|
||||
original = conn.execute(sa.select(chat_t.c.user_id).where(chat_t.c.id == original_chat_id)).fetchone()
|
||||
|
||||
if not original:
|
||||
continue
|
||||
|
||||
# Insert snapshot into shared_chat
|
||||
conn.execute(
|
||||
shared_chat_t.insert().values(
|
||||
id=share_token,
|
||||
chat_id=original_chat_id,
|
||||
user_id=original.user_id,
|
||||
title=row.title,
|
||||
chat=row.chat,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
)
|
||||
|
||||
# Create user:*:read grant for backward compat
|
||||
conn.execute(
|
||||
access_grant_t.insert().values(
|
||||
id=str(uuid.uuid4()),
|
||||
resource_type='shared_chat',
|
||||
resource_id=original_chat_id,
|
||||
principal_type='user',
|
||||
principal_id='*',
|
||||
permission='read',
|
||||
created_at=row.created_at or int(time.time()),
|
||||
)
|
||||
)
|
||||
|
||||
# 3. Clean up old phantom rows
|
||||
conn.execute(
|
||||
chat_message_t.delete().where(
|
||||
chat_message_t.c.chat_id.in_(sa.select(chat_t.c.id).where(chat_t.c.user_id.like('shared-%')))
|
||||
)
|
||||
)
|
||||
conn.execute(chat_t.delete().where(chat_t.c.user_id.like('shared-%')))
|
||||
|
||||
|
||||
def downgrade():
|
||||
conn = op.get_bind()
|
||||
|
||||
shared_rows = conn.execute(
|
||||
sa.select(
|
||||
shared_chat_t.c.id,
|
||||
shared_chat_t.c.chat_id,
|
||||
shared_chat_t.c.user_id,
|
||||
shared_chat_t.c.title,
|
||||
shared_chat_t.c.chat,
|
||||
shared_chat_t.c.created_at,
|
||||
shared_chat_t.c.updated_at,
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
for row in shared_rows:
|
||||
conn.execute(
|
||||
chat_t.insert().values(
|
||||
id=row.id,
|
||||
user_id=f'shared-{row.chat_id}',
|
||||
title=row.title,
|
||||
chat=row.chat,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
archived=False,
|
||||
meta={},
|
||||
)
|
||||
)
|
||||
|
||||
conn.execute(access_grant_t.delete().where(access_grant_t.c.resource_type == 'shared_chat'))
|
||||
op.drop_table('shared_chat')
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
"""Add is_pinned to note table
|
||||
|
||||
Revision ID: e1f2a3b4c5d6
|
||||
Revises: b7c8d9e0f1a2
|
||||
Create Date: 2026-04-14 22:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'e1f2a3b4c5d6'
|
||||
down_revision = 'b7c8d9e0f1a2'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('note', sa.Column('is_pinned', sa.Boolean(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('note', 'is_pinned')
|
||||
|
|
@ -293,10 +293,9 @@ class ChatTable:
|
|||
return changed
|
||||
|
||||
async def insert_new_chat(
|
||||
self, user_id: str, form_data: ChatForm, db: Optional[AsyncSession] = None
|
||||
self, id: str, user_id: str, form_data: ChatForm, db: Optional[AsyncSession] = None
|
||||
) -> Optional[ChatModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
id = str(uuid.uuid4())
|
||||
chat = ChatModel(
|
||||
**{
|
||||
'id': id,
|
||||
|
|
@ -556,77 +555,51 @@ class ChatTable:
|
|||
async def insert_shared_chat_by_chat_id(
|
||||
self, chat_id: str, db: Optional[AsyncSession] = None
|
||||
) -> Optional[ChatModel]:
|
||||
"""Create a shared snapshot for a chat. Returns the original chat with share_id set."""
|
||||
from open_webui.models.shared_chats import SharedChats
|
||||
|
||||
async with get_async_db_context(db) as db:
|
||||
# Get the existing chat to share
|
||||
chat = await db.get(Chat, chat_id)
|
||||
# Check if chat exists
|
||||
if not chat:
|
||||
return None
|
||||
# Check if the chat is already shared
|
||||
if chat.share_id:
|
||||
return await self.get_chat_by_id_and_user_id(chat.share_id, 'shared', db=db)
|
||||
# Create a new chat with the same data, but with a new ID
|
||||
shared_chat = ChatModel(
|
||||
**{
|
||||
'id': str(uuid.uuid4()),
|
||||
'user_id': f'shared-{chat_id}',
|
||||
'title': chat.title,
|
||||
'chat': chat.chat,
|
||||
'meta': chat.meta,
|
||||
'pinned': chat.pinned,
|
||||
'folder_id': chat.folder_id,
|
||||
'created_at': chat.created_at,
|
||||
'updated_at': int(time.time()),
|
||||
}
|
||||
)
|
||||
shared_result = Chat(**shared_chat.model_dump())
|
||||
db.add(shared_result)
|
||||
await db.commit()
|
||||
await db.refresh(shared_result)
|
||||
|
||||
# Update the original chat with the share_id
|
||||
await db.execute(update(Chat).filter_by(id=chat_id).values(share_id=shared_chat.id))
|
||||
# If already shared, just update the existing snapshot
|
||||
if chat.share_id:
|
||||
return await self.update_shared_chat_by_chat_id(chat_id, db=db)
|
||||
|
||||
shared = await SharedChats.create(chat_id, chat.user_id, db=db)
|
||||
if not shared:
|
||||
return None
|
||||
|
||||
# Set share_id on the original chat
|
||||
chat.share_id = shared.id
|
||||
await db.commit()
|
||||
return shared_chat if shared_result else None
|
||||
await db.refresh(chat)
|
||||
return ChatModel.model_validate(chat)
|
||||
|
||||
async def update_shared_chat_by_chat_id(
|
||||
self, chat_id: str, db: Optional[AsyncSession] = None
|
||||
) -> Optional[ChatModel]:
|
||||
"""Re-snapshot the shared chat with current chat data."""
|
||||
from open_webui.models.shared_chats import SharedChats
|
||||
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
chat = await db.get(Chat, chat_id)
|
||||
result = await db.execute(select(Chat).filter_by(user_id=f'shared-{chat_id}'))
|
||||
shared_chat = result.scalars().first()
|
||||
|
||||
if shared_chat is None:
|
||||
if not chat or not chat.share_id:
|
||||
return await self.insert_shared_chat_by_chat_id(chat_id, db=db)
|
||||
|
||||
shared_chat.title = chat.title
|
||||
shared_chat.chat = chat.chat
|
||||
shared_chat.meta = chat.meta
|
||||
shared_chat.pinned = chat.pinned
|
||||
shared_chat.folder_id = chat.folder_id
|
||||
shared_chat.updated_at = int(time.time())
|
||||
await db.commit()
|
||||
await db.refresh(shared_chat)
|
||||
|
||||
return ChatModel.model_validate(shared_chat)
|
||||
await SharedChats.update(chat.share_id, db=db)
|
||||
return ChatModel.model_validate(chat)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def delete_shared_chat_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> bool:
|
||||
"""Delete shared snapshot for a chat."""
|
||||
from open_webui.models.shared_chats import SharedChats
|
||||
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
# Get shared chat IDs
|
||||
result = await db.execute(select(Chat.id).filter_by(user_id=f'shared-{chat_id}'))
|
||||
shared_ids = [row[0] for row in result.all()]
|
||||
|
||||
if shared_ids:
|
||||
await db.execute(delete(ChatMessage).filter(ChatMessage.chat_id.in_(shared_ids)))
|
||||
await db.execute(delete(Chat).filter_by(user_id=f'shared-{chat_id}'))
|
||||
await db.commit()
|
||||
|
||||
return True
|
||||
return await SharedChats.delete_by_chat_id(chat_id, db=db)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
|
@ -747,53 +720,10 @@ class ChatTable:
|
|||
limit: int = 50,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> list[SharedChatResponse]:
|
||||
async with get_async_db_context(db) as db:
|
||||
stmt = (
|
||||
select(Chat.id, Chat.title, Chat.share_id, Chat.updated_at, Chat.created_at)
|
||||
.filter_by(user_id=user_id)
|
||||
.filter(Chat.share_id.isnot(None))
|
||||
)
|
||||
"""Delegate to SharedChats for listing shared chats by user."""
|
||||
from open_webui.models.shared_chats import SharedChats
|
||||
|
||||
if filter:
|
||||
query_key = filter.get('query')
|
||||
if query_key:
|
||||
stmt = stmt.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':
|
||||
stmt = stmt.order_by(getattr(Chat, order_by).asc(), Chat.id)
|
||||
elif direction.lower() == 'desc':
|
||||
stmt = stmt.order_by(getattr(Chat, order_by).desc(), Chat.id)
|
||||
else:
|
||||
raise ValueError('Invalid direction for ordering')
|
||||
else:
|
||||
stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id)
|
||||
|
||||
if skip:
|
||||
stmt = stmt.offset(skip)
|
||||
if limit:
|
||||
stmt = stmt.limit(limit)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
all_chats = result.all()
|
||||
return [
|
||||
SharedChatResponse.model_validate(
|
||||
{
|
||||
'id': chat[0],
|
||||
'title': chat[1],
|
||||
'share_id': chat[2],
|
||||
'updated_at': chat[3],
|
||||
'created_at': chat[4],
|
||||
}
|
||||
)
|
||||
for chat in all_chats
|
||||
]
|
||||
return await SharedChats.get_by_user_id(user_id, filter=filter, skip=skip, limit=limit, db=db)
|
||||
|
||||
async def get_chat_list_by_user_id(
|
||||
self,
|
||||
|
|
@ -926,15 +856,23 @@ class ChatTable:
|
|||
return None
|
||||
|
||||
async def get_chat_by_share_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ChatModel]:
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(Chat).filter_by(share_id=id))
|
||||
chat = result.scalars().first()
|
||||
"""Look up a shared chat snapshot by its share token."""
|
||||
from open_webui.models.shared_chats import SharedChats
|
||||
|
||||
if chat:
|
||||
return await self.get_chat_by_id(id, db=db)
|
||||
else:
|
||||
return None
|
||||
try:
|
||||
shared = await SharedChats.get_by_id(id, db=db)
|
||||
if shared:
|
||||
# Return a ChatModel-compatible view of the snapshot
|
||||
return ChatModel(
|
||||
id=shared.id,
|
||||
user_id=shared.user_id,
|
||||
title=shared.title,
|
||||
chat=shared.chat,
|
||||
created_at=shared.created_at,
|
||||
updated_at=shared.updated_at,
|
||||
share_id=shared.id,
|
||||
)
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
|
@ -1324,8 +1262,10 @@ class ChatTable:
|
|||
self, id: str, user_id: str, db: Optional[AsyncSession] = None
|
||||
) -> list[TagModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
chat = await db.get(Chat, id)
|
||||
tag_ids = chat.meta.get('tags', [])
|
||||
stmt = select(Chat.meta).where(Chat.id == id)
|
||||
result = await db.execute(stmt)
|
||||
meta = result.scalar_one_or_none()
|
||||
tag_ids = (meta or {}).get('tags', [])
|
||||
return await Tags.get_tags_by_ids_and_user_id(tag_ids, user_id, db=db)
|
||||
|
||||
async def get_chat_list_by_user_id_and_tag_name(
|
||||
|
|
@ -1569,20 +1509,17 @@ class ChatTable:
|
|||
return False
|
||||
|
||||
async def delete_shared_chats_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> bool:
|
||||
"""Delete all shared chat snapshots created by a user."""
|
||||
from open_webui.models.shared_chats import SharedChats, SharedChat as SharedChatTable
|
||||
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(Chat.id).filter_by(user_id=user_id))
|
||||
id_rows = result.all()
|
||||
shared_chat_ids = [f'shared-{row[0]}' for row in id_rows]
|
||||
# Delete shared_chat rows for this user's chats
|
||||
await db.execute(delete(SharedChatTable).filter_by(user_id=user_id))
|
||||
|
||||
if shared_chat_ids:
|
||||
# Get shared chat IDs to delete associated messages
|
||||
shared_result = await db.execute(select(Chat.id).filter(Chat.user_id.in_(shared_chat_ids)))
|
||||
shared_ids = [row[0] for row in shared_result.all()]
|
||||
if shared_ids:
|
||||
await db.execute(delete(ChatMessage).filter(ChatMessage.chat_id.in_(shared_ids)))
|
||||
await db.execute(delete(Chat).filter(Chat.user_id.in_(shared_chat_ids)))
|
||||
await db.commit()
|
||||
# Clear share_id on all of this user's chats
|
||||
await db.execute(update(Chat).filter_by(user_id=user_id).values(share_id=None))
|
||||
await db.commit()
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
|
|
@ -1599,11 +1536,11 @@ class ChatTable:
|
|||
if not file_ids:
|
||||
return None
|
||||
|
||||
chat_message_file_ids = [
|
||||
chat_message_file_ids = {
|
||||
item.id for item in await self.get_chat_files_by_chat_id_and_message_id(chat_id, message_id, db=db)
|
||||
]
|
||||
}
|
||||
# Remove duplicates and existing file_ids
|
||||
file_ids = list(set([file_id for file_id in file_ids if file_id and file_id not in chat_message_file_ids]))
|
||||
file_ids = list({file_id for file_id in file_ids if file_id and file_id not in chat_message_file_ids})
|
||||
if not file_ids:
|
||||
return None
|
||||
|
||||
|
|
@ -1652,16 +1589,15 @@ class ChatTable:
|
|||
except Exception:
|
||||
return False
|
||||
|
||||
async def get_shared_chats_by_file_id(self, file_id: str, db: Optional[AsyncSession] = None) -> list[ChatModel]:
|
||||
async def get_shared_chat_ids_by_file_id(self, file_id: str, db: Optional[AsyncSession] = None) -> list[str]:
|
||||
"""Return IDs of chats that contain this file and have an active share link."""
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
select(Chat)
|
||||
select(Chat.id)
|
||||
.join(ChatFile, Chat.id == ChatFile.chat_id)
|
||||
.filter(ChatFile.file_id == file_id, Chat.share_id.isnot(None))
|
||||
)
|
||||
all_chats = result.scalars().all()
|
||||
|
||||
return [ChatModel.model_validate(chat) for chat in all_chats]
|
||||
return [row[0] for row in result.all()]
|
||||
|
||||
async def update_chat_tasks_by_id(self, id: str, tasks: list[dict]) -> Optional[ChatModel]:
|
||||
"""Update the tasks list on a chat."""
|
||||
|
|
|
|||
|
|
@ -74,14 +74,14 @@ class FolderForm(BaseModel):
|
|||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
parent_id: Optional[str] = None
|
||||
model_config = ConfigDict(extra='allow')
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
|
||||
|
||||
class FolderUpdateForm(BaseModel):
|
||||
name: Optional[str] = None
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
model_config = ConfigDict(extra='allow')
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
|
||||
|
||||
class FolderTable:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import uuid
|
|||
from typing import Optional
|
||||
from functools import lru_cache
|
||||
|
||||
from sqlalchemy import select, delete, update, or_, func, cast
|
||||
from sqlalchemy import Boolean, select, delete, update, or_, func, cast
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from open_webui.internal.db import Base, get_async_db_context
|
||||
from open_webui.models.groups import Groups
|
||||
|
|
@ -29,6 +29,7 @@ class Note(Base):
|
|||
title = Column(Text)
|
||||
data = Column(JSON, nullable=True)
|
||||
meta = Column(JSON, nullable=True)
|
||||
is_pinned = Column(Boolean, default=False, nullable=True)
|
||||
|
||||
created_at = Column(BigInteger)
|
||||
updated_at = Column(BigInteger)
|
||||
|
|
@ -43,6 +44,7 @@ class NoteModel(BaseModel):
|
|||
title: str
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
is_pinned: Optional[bool] = False
|
||||
|
||||
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||
|
||||
|
|
@ -77,6 +79,7 @@ class NoteItemResponse(BaseModel):
|
|||
id: str
|
||||
title: str
|
||||
data: Optional[dict]
|
||||
is_pinned: Optional[bool] = False
|
||||
updated_at: int
|
||||
created_at: int
|
||||
user: Optional[UserResponse] = None
|
||||
|
|
@ -311,6 +314,39 @@ class NoteTable:
|
|||
await db.commit()
|
||||
return await self._to_note_model(note, db=db) if note else None
|
||||
|
||||
async def toggle_note_pinned_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[NoteModel]:
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(Note).filter(Note.id == id))
|
||||
note = result.scalars().first()
|
||||
if not note:
|
||||
return None
|
||||
note.is_pinned = not note.is_pinned
|
||||
note.updated_at = int(time.time_ns())
|
||||
await db.commit()
|
||||
return await self._to_note_model(note, db=db)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def get_pinned_notes_by_user_id(
|
||||
self,
|
||||
user_id: str,
|
||||
permission: str = 'read',
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> list[NoteModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_group_ids = [group.id for group in user_groups]
|
||||
|
||||
stmt = select(Note).filter(Note.is_pinned == True).order_by(Note.updated_at.desc())
|
||||
stmt = self._has_permission(db, stmt, {'user_id': user_id, 'group_ids': user_group_ids}, permission)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
notes = result.scalars().all()
|
||||
note_ids = [note.id for note in notes]
|
||||
grants_map = await AccessGrants.get_grants_by_resources('note', note_ids, db=db)
|
||||
return [await self._to_note_model(note, access_grants=grants_map.get(note.id, []), db=db) for note in notes]
|
||||
|
||||
async def delete_note_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ class OAuthSessionTable:
|
|||
'user_id': user_id,
|
||||
'provider': provider,
|
||||
'token': self._encrypt_token(token),
|
||||
'expires_at': token.get('expires_at'),
|
||||
'expires_at': token.get('expires_at') or int(time.time() + 3600),
|
||||
'created_at': current_time,
|
||||
'updated_at': current_time,
|
||||
}
|
||||
|
|
@ -274,7 +274,7 @@ class OAuthSessionTable:
|
|||
.filter_by(id=session_id)
|
||||
.values(
|
||||
token=self._encrypt_token(token),
|
||||
expires_at=token.get('expires_at'),
|
||||
expires_at=token.get('expires_at') or int(time.time() + 3600),
|
||||
updated_at=current_time,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import time
|
|||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select, delete, update, or_, func, cast, String
|
||||
from sqlalchemy import select, delete, update, or_, func, text, cast, String
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from open_webui.internal.db import Base, JSONField, get_async_db_context
|
||||
from open_webui.models.groups import Groups
|
||||
|
|
@ -260,12 +260,12 @@ class PromptsTable:
|
|||
) -> PromptListResponse:
|
||||
async with get_async_db_context(db) as db:
|
||||
# Join with User table for user filtering and sorting
|
||||
stmt = select(Prompt, User).outerjoin(User, User.id == Prompt.user_id)
|
||||
query = select(Prompt, User).outerjoin(User, User.id == Prompt.user_id)
|
||||
|
||||
if filter:
|
||||
query_key = filter.get('query')
|
||||
if query_key:
|
||||
stmt = stmt.filter(
|
||||
query = query.filter(
|
||||
or_(
|
||||
Prompt.name.ilike(f'%{query_key}%'),
|
||||
Prompt.command.ilike(f'%{query_key}%'),
|
||||
|
|
@ -277,14 +277,14 @@ class PromptsTable:
|
|||
|
||||
view_option = filter.get('view_option')
|
||||
if view_option == 'created':
|
||||
stmt = stmt.filter(Prompt.user_id == user_id)
|
||||
query = query.filter(Prompt.user_id == user_id)
|
||||
elif view_option == 'shared':
|
||||
stmt = stmt.filter(Prompt.user_id != user_id)
|
||||
query = query.filter(Prompt.user_id != user_id)
|
||||
|
||||
# Apply access grant filtering
|
||||
stmt = AccessGrants.has_permission_filter(
|
||||
query = AccessGrants.has_permission_filter(
|
||||
db=db,
|
||||
query=stmt,
|
||||
query=query,
|
||||
DocumentModel=Prompt,
|
||||
filter=filter,
|
||||
resource_type='prompt',
|
||||
|
|
@ -293,56 +293,63 @@ class PromptsTable:
|
|||
|
||||
tag = filter.get('tag')
|
||||
if tag:
|
||||
# SQLite stores JSON text via json.dumps(ensure_ascii=True),
|
||||
# so non-ASCII chars are \uXXXX-escaped. PostgreSQL native JSONB
|
||||
# stores literal Unicode. Use the right pattern for each.
|
||||
if db.bind.dialect.name == 'sqlite':
|
||||
if tag.isascii():
|
||||
tags_text = func.lower(cast(Prompt.tags, String))
|
||||
pattern = f'%{json.dumps(tag.lower())}%'
|
||||
else:
|
||||
# LOWER() is ASCII-only; non-ASCII codepoints would
|
||||
# produce different \uXXXX escapes when lowered.
|
||||
tags_text = cast(Prompt.tags, String)
|
||||
pattern = f'%{json.dumps(tag)}%'
|
||||
bind = await db.connection()
|
||||
dialect_name = bind.dialect.name
|
||||
tag_lower = tag.lower()
|
||||
|
||||
if dialect_name == 'sqlite':
|
||||
tag_clause = text(
|
||||
'EXISTS (SELECT 1 FROM json_each(prompt.tags) t WHERE LOWER(t.value) = :tag_val)'
|
||||
)
|
||||
elif dialect_name == 'postgresql':
|
||||
tag_clause = text(
|
||||
'EXISTS (SELECT 1 FROM json_array_elements_text(prompt.tags) t WHERE LOWER(t) = :tag_val)'
|
||||
)
|
||||
else:
|
||||
tags_text = func.lower(cast(Prompt.tags, String))
|
||||
pattern = f'%{json.dumps(tag.lower(), ensure_ascii=False)}%'
|
||||
stmt = stmt.filter(tags_text.like(pattern))
|
||||
# Fallback: LIKE on serialised JSON text (ASCII-safe only)
|
||||
tag_clause = func.lower(cast(Prompt.tags, String)).like(
|
||||
f'%{json.dumps(tag_lower, ensure_ascii=False)}%'
|
||||
)
|
||||
tag_lower = None
|
||||
|
||||
if tag_lower is not None:
|
||||
query = query.filter(tag_clause.params(tag_val=tag_lower))
|
||||
else:
|
||||
query = query.filter(tag_clause)
|
||||
|
||||
order_by = filter.get('order_by')
|
||||
direction = filter.get('direction')
|
||||
|
||||
if order_by == 'name':
|
||||
if direction == 'asc':
|
||||
stmt = stmt.order_by(Prompt.name.asc())
|
||||
query = query.order_by(Prompt.name.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(Prompt.name.desc())
|
||||
query = query.order_by(Prompt.name.desc())
|
||||
elif order_by == 'created_at':
|
||||
if direction == 'asc':
|
||||
stmt = stmt.order_by(Prompt.created_at.asc())
|
||||
query = query.order_by(Prompt.created_at.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(Prompt.created_at.desc())
|
||||
query = query.order_by(Prompt.created_at.desc())
|
||||
elif order_by == 'updated_at':
|
||||
if direction == 'asc':
|
||||
stmt = stmt.order_by(Prompt.updated_at.asc())
|
||||
query = query.order_by(Prompt.updated_at.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(Prompt.updated_at.desc())
|
||||
query = query.order_by(Prompt.updated_at.desc())
|
||||
else:
|
||||
stmt = stmt.order_by(Prompt.updated_at.desc())
|
||||
query = query.order_by(Prompt.updated_at.desc())
|
||||
else:
|
||||
stmt = stmt.order_by(Prompt.updated_at.desc())
|
||||
query = query.order_by(Prompt.updated_at.desc())
|
||||
|
||||
# Count BEFORE pagination
|
||||
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
|
||||
count_result = await db.execute(select(func.count()).select_from(query.subquery()))
|
||||
total = count_result.scalar()
|
||||
|
||||
if skip:
|
||||
stmt = stmt.offset(skip)
|
||||
query = query.offset(skip)
|
||||
if limit:
|
||||
stmt = stmt.limit(limit)
|
||||
query = query.limit(limit)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
result = await db.execute(query)
|
||||
items = result.all()
|
||||
|
||||
prompt_ids = [prompt.id for prompt, _ in items]
|
||||
|
|
|
|||
207
backend/open_webui/models/shared_chats.py
Normal file
207
backend/open_webui/models/shared_chats.py
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from open_webui.internal.db import Base, JSONField, get_async_db_context
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Column, ForeignKey, Text, JSON
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
####################
|
||||
# SharedChat DB Schema
|
||||
####################
|
||||
|
||||
|
||||
class SharedChat(Base):
|
||||
__tablename__ = 'shared_chat'
|
||||
|
||||
id = Column(Text, primary_key=True) # The share token (UUID) — used in /s/{id} URL
|
||||
chat_id = Column(Text, ForeignKey('chat.id', ondelete='CASCADE'), nullable=False)
|
||||
user_id = Column(Text, nullable=False) # Who created this share
|
||||
|
||||
title = Column(Text)
|
||||
chat = Column(JSON) # Snapshot of chat JSON at share time
|
||||
|
||||
created_at = Column(BigInteger)
|
||||
updated_at = Column(BigInteger)
|
||||
|
||||
|
||||
class SharedChatModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
chat_id: str
|
||||
user_id: str
|
||||
|
||||
title: str
|
||||
chat: dict
|
||||
|
||||
created_at: int
|
||||
updated_at: int
|
||||
|
||||
|
||||
class SharedChatResponse(BaseModel):
|
||||
id: str
|
||||
chat_id: str
|
||||
title: str
|
||||
share_id: Optional[str] = None # Alias for id, for backward compat
|
||||
updated_at: int
|
||||
created_at: int
|
||||
|
||||
|
||||
####################
|
||||
# Table Operations
|
||||
####################
|
||||
|
||||
|
||||
class SharedChatsTable:
|
||||
async def create(self, chat_id: str, user_id: str, db: Optional[AsyncSession] = None) -> Optional[SharedChatModel]:
|
||||
"""
|
||||
Create a snapshot of the chat for link sharing.
|
||||
Returns the SharedChatModel with the share token as its id.
|
||||
"""
|
||||
async with get_async_db_context(db) as db:
|
||||
from open_webui.models.chats import Chat
|
||||
|
||||
chat = await db.get(Chat, chat_id)
|
||||
if not chat:
|
||||
return None
|
||||
|
||||
share_id = str(uuid.uuid4())
|
||||
now = int(time.time())
|
||||
|
||||
shared_chat = SharedChat(
|
||||
id=share_id,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
title=chat.title,
|
||||
chat=chat.chat,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(shared_chat)
|
||||
await db.commit()
|
||||
await db.refresh(shared_chat)
|
||||
|
||||
return SharedChatModel.model_validate(shared_chat)
|
||||
|
||||
async def update(self, share_id: str, db: Optional[AsyncSession] = None) -> Optional[SharedChatModel]:
|
||||
"""
|
||||
Re-snapshot: update the shared chat with the current state of the original chat.
|
||||
"""
|
||||
async with get_async_db_context(db) as db:
|
||||
from open_webui.models.chats import Chat
|
||||
|
||||
shared_chat = await db.get(SharedChat, share_id)
|
||||
if not shared_chat:
|
||||
return None
|
||||
|
||||
chat = await db.get(Chat, shared_chat.chat_id)
|
||||
if not chat:
|
||||
return None
|
||||
|
||||
shared_chat.title = chat.title
|
||||
shared_chat.chat = chat.chat
|
||||
shared_chat.updated_at = int(time.time())
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(shared_chat)
|
||||
return SharedChatModel.model_validate(shared_chat)
|
||||
|
||||
async def get_by_id(self, share_id: str, db: Optional[AsyncSession] = None) -> Optional[SharedChatModel]:
|
||||
"""Get a shared chat by its share token."""
|
||||
async with get_async_db_context(db) as db:
|
||||
shared_chat = await db.get(SharedChat, share_id)
|
||||
if shared_chat:
|
||||
return SharedChatModel.model_validate(shared_chat)
|
||||
return None
|
||||
|
||||
async def get_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> Optional[SharedChatModel]:
|
||||
"""Get the shared chat for a given original chat. Returns the most recent one."""
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
select(SharedChat).filter_by(chat_id=chat_id).order_by(SharedChat.updated_at.desc()).limit(1)
|
||||
)
|
||||
shared_chat = result.scalars().first()
|
||||
if shared_chat:
|
||||
return SharedChatModel.model_validate(shared_chat)
|
||||
return None
|
||||
|
||||
async def get_by_user_id(
|
||||
self,
|
||||
user_id: str,
|
||||
filter: Optional[dict] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> list[SharedChatResponse]:
|
||||
"""List all shared chats created by a user."""
|
||||
async with get_async_db_context(db) as db:
|
||||
stmt = select(SharedChat).filter_by(user_id=user_id)
|
||||
|
||||
if filter:
|
||||
query_key = filter.get('query')
|
||||
if query_key:
|
||||
stmt = stmt.filter(SharedChat.title.ilike(f'%{query_key}%'))
|
||||
|
||||
order_by = filter.get('order_by')
|
||||
direction = filter.get('direction')
|
||||
|
||||
if order_by and direction:
|
||||
col = getattr(SharedChat, order_by, None)
|
||||
if not col:
|
||||
raise ValueError('Invalid order_by field')
|
||||
if direction.lower() == 'asc':
|
||||
stmt = stmt.order_by(col.asc())
|
||||
elif direction.lower() == 'desc':
|
||||
stmt = stmt.order_by(col.desc())
|
||||
else:
|
||||
raise ValueError('Invalid direction for ordering')
|
||||
else:
|
||||
stmt = stmt.order_by(SharedChat.updated_at.desc())
|
||||
|
||||
if skip:
|
||||
stmt = stmt.offset(skip)
|
||||
if limit:
|
||||
stmt = stmt.limit(limit)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return [
|
||||
SharedChatResponse(
|
||||
id=sc.chat_id,
|
||||
chat_id=sc.chat_id,
|
||||
title=sc.title,
|
||||
share_id=sc.id,
|
||||
updated_at=sc.updated_at,
|
||||
created_at=sc.created_at,
|
||||
)
|
||||
for sc in result.scalars().all()
|
||||
]
|
||||
|
||||
async def delete_by_id(self, share_id: str, db: Optional[AsyncSession] = None) -> bool:
|
||||
"""Delete a shared chat by its share token."""
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
await db.execute(delete(SharedChat).filter_by(id=share_id))
|
||||
await db.commit()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def delete_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> bool:
|
||||
"""Delete all shared chats for a given original chat."""
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
await db.execute(delete(SharedChat).filter_by(chat_id=chat_id))
|
||||
await db.commit()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
SharedChats = SharedChatsTable()
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import asyncio
|
||||
import requests
|
||||
import logging
|
||||
import ftfy
|
||||
|
|
@ -238,6 +239,18 @@ class Loader:
|
|||
|
||||
return [Document(page_content=ftfy.fix_text(doc.page_content), metadata=doc.metadata) for doc in docs]
|
||||
|
||||
async def aload(self, filename: str, file_content_type: str, file_path: str) -> list[Document]:
|
||||
"""
|
||||
Async wrapper around `load`.
|
||||
|
||||
Document loaders dispatched by `_get_loader` (PyMuPDF, Unstructured,
|
||||
python-docx, Tika, etc.) are uniformly synchronous and CPU/IO-bound.
|
||||
Calling `load` directly from an async handler would block the event
|
||||
loop for the entire parse — minutes for large PDFs. This offloads
|
||||
the work to a worker thread so the loop stays responsive.
|
||||
"""
|
||||
return await asyncio.to_thread(self.load, filename, file_content_type, file_path)
|
||||
|
||||
def _is_text_file(self, file_ext: str, file_content_type: str) -> bool:
|
||||
return file_ext in known_source_ext or (
|
||||
file_content_type
|
||||
|
|
|
|||
|
|
@ -59,14 +59,14 @@ class ColBERT(BaseReranker):
|
|||
|
||||
return normalized_scores.detach().cpu().numpy().astype(np.float32)
|
||||
|
||||
def predict(self, sentences):
|
||||
def predict(self, sentences, batch_size=32):
|
||||
query = sentences[0][0]
|
||||
docs = [i[1] for i in sentences]
|
||||
|
||||
# Embedding the documents
|
||||
embedded_docs = self.ckpt.docFromText(docs, bsize=32)[0]
|
||||
embedded_docs = self.ckpt.docFromText(docs, bsize=batch_size)[0]
|
||||
# Embedding the queries
|
||||
embedded_queries = self.ckpt.queryFromText([query], bsize=32)
|
||||
embedded_queries = self.ckpt.queryFromText([query], bsize=batch_size)
|
||||
embedded_query = embedded_queries[0]
|
||||
|
||||
# Calculate retrieval scores for the query against all documents
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from langchain_community.retrievers import BM25Retriever
|
|||
from langchain_core.documents import Document
|
||||
|
||||
from open_webui.config import VECTOR_DB
|
||||
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
|
||||
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
|
||||
|
||||
|
||||
|
|
@ -121,7 +122,7 @@ class VectorSearchRetriever(BaseRetriever):
|
|||
run_manager: CallbackManagerForRetrieverRun,
|
||||
) -> list[Document]:
|
||||
embedding = await self.embedding_function(query, RAG_EMBEDDING_QUERY_PREFIX)
|
||||
result = VECTOR_DB_CLIENT.search(
|
||||
result = await ASYNC_VECTOR_DB_CLIENT.search(
|
||||
collection_name=self.collection_name,
|
||||
vectors=[embedding],
|
||||
limit=self.top_k,
|
||||
|
|
@ -488,16 +489,24 @@ async def query_collection_with_hybrid_search(
|
|||
) -> dict:
|
||||
results = []
|
||||
error = False
|
||||
# Fetch collection data once per collection sequentially
|
||||
# Avoid fetching the same data multiple times later
|
||||
collection_results = {}
|
||||
for collection_name in collection_names:
|
||||
# Fetch every collection's contents once up front so the
|
||||
# per-query/per-document loop below can reuse them. Each fetch
|
||||
# offloads to a worker thread, so run them concurrently with
|
||||
# `asyncio.gather` instead of awaiting them serially — otherwise
|
||||
# latency scales linearly with `len(collection_names)`.
|
||||
log.debug(
|
||||
'query_collection_with_hybrid_search: prefetching %d collections',
|
||||
len(collection_names),
|
||||
)
|
||||
|
||||
async def _fetch_collection(name: str):
|
||||
try:
|
||||
log.debug(f'query_collection_with_hybrid_search:VECTOR_DB_CLIENT.get:collection {collection_name}')
|
||||
collection_results[collection_name] = VECTOR_DB_CLIENT.get(collection_name=collection_name)
|
||||
return name, await ASYNC_VECTOR_DB_CLIENT.get(collection_name=name)
|
||||
except Exception as e:
|
||||
log.exception(f'Failed to fetch collection {collection_name}: {e}')
|
||||
collection_results[collection_name] = None
|
||||
log.exception(f'Failed to fetch collection {name}: {e}')
|
||||
return name, None
|
||||
|
||||
collection_results = dict(await asyncio.gather(*(_fetch_collection(name) for name in collection_names)))
|
||||
|
||||
log.info(f'Starting hybrid search for {len(queries)} queries in {len(collection_names)} collections...')
|
||||
|
||||
|
|
@ -910,7 +919,7 @@ async def generate_embeddings(
|
|||
return embeddings[0] if isinstance(text, str) else embeddings
|
||||
|
||||
|
||||
def get_reranking_function(reranking_engine, reranking_model, reranking_function):
|
||||
def get_reranking_function(reranking_engine, reranking_model, reranking_function, reranking_batch_size=32):
|
||||
if reranking_function is None:
|
||||
return None
|
||||
if reranking_engine == 'external':
|
||||
|
|
@ -919,10 +928,61 @@ def get_reranking_function(reranking_engine, reranking_model, reranking_function
|
|||
)
|
||||
else:
|
||||
return lambda query, documents, user=None: reranking_function.predict(
|
||||
[(query, doc.page_content) for doc in documents]
|
||||
[(query, doc.page_content) for doc in documents], batch_size=int(reranking_batch_size)
|
||||
)
|
||||
|
||||
|
||||
async def filter_accessible_collections(
|
||||
collection_names: set[str],
|
||||
user: UserModel,
|
||||
access_type: str = 'read',
|
||||
) -> set[str]:
|
||||
"""
|
||||
Return only the collection names the user is allowed to access.
|
||||
Admins bypass all checks. For non-admins the policy is:
|
||||
|
||||
- file-* → validated via has_access_to_file
|
||||
- user-memory-* → must match user's own memory collection
|
||||
- web-search-* → ephemeral per-query collections, always allowed
|
||||
- knowledge-bases → always denied (system meta-collection)
|
||||
- everything else → if the name matches a knowledge base, validated
|
||||
via Knowledges.check_access_by_user_id; if no
|
||||
such KB exists, the name is treated as an
|
||||
ephemeral/legacy collection and allowed
|
||||
"""
|
||||
if user.role == 'admin':
|
||||
return collection_names
|
||||
|
||||
validated = set()
|
||||
for name in collection_names:
|
||||
if name == 'knowledge-bases':
|
||||
# System meta-collection — never exposed to non-admins.
|
||||
continue
|
||||
elif name.startswith('file-'):
|
||||
file_id = name[len('file-') :]
|
||||
if await has_access_to_file(file_id=file_id, access_type=access_type, user=user):
|
||||
validated.add(name)
|
||||
elif name.startswith('user-memory-'):
|
||||
if name == f'user-memory-{user.id}':
|
||||
validated.add(name)
|
||||
elif name.startswith('web-search-'):
|
||||
# Ephemeral collections created by process_web_search — safe
|
||||
# to allow because they contain only transient web-search
|
||||
# results scoped to the requesting user's session.
|
||||
validated.add(name)
|
||||
else:
|
||||
# May be a knowledge-base ID or a legacy/ephemeral collection.
|
||||
# If it IS a KB, enforce access control. If no such KB
|
||||
# exists, treat it as a non-sensitive collection (e.g. legacy
|
||||
# model knowledge, process_text SHA256 collections) and allow.
|
||||
if await Knowledges.check_access_by_user_id(name, user.id, permission=access_type):
|
||||
validated.add(name)
|
||||
elif not await Knowledges.get_knowledge_by_id(name):
|
||||
# Not a KB at all — legacy/ephemeral collection, allow
|
||||
validated.add(name)
|
||||
return validated
|
||||
|
||||
|
||||
async def get_sources_from_items(
|
||||
request,
|
||||
items,
|
||||
|
|
@ -1138,9 +1198,18 @@ async def get_sources_from_items(
|
|||
log.debug(f'skipping {item} as it has already been extracted')
|
||||
continue
|
||||
|
||||
# Filter out collections the user cannot read
|
||||
if user:
|
||||
collection_names = await filter_accessible_collections(collection_names, user)
|
||||
if not collection_names:
|
||||
log.debug(f'access denied for all collections in item {item}')
|
||||
continue
|
||||
|
||||
try:
|
||||
if full_context:
|
||||
query_result = get_all_items_from_collections(collection_names)
|
||||
# Sync helper makes blocking VECTOR_DB_CLIENT calls;
|
||||
# offload so the async caller's event loop stays free.
|
||||
query_result = await asyncio.to_thread(get_all_items_from_collections, collection_names)
|
||||
else:
|
||||
query_result = await query_collection(
|
||||
request,
|
||||
|
|
|
|||
129
backend/open_webui/retrieval/vector/async_client.py
Normal file
129
backend/open_webui/retrieval/vector/async_client.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"""
|
||||
Async facade over the synchronous VECTOR_DB_CLIENT.
|
||||
|
||||
The vector DB backends bundled with Open WebUI (Chroma, pgvector, Qdrant,
|
||||
Milvus, OpenSearch, Pinecone, Weaviate, …) all expose a uniformly
|
||||
synchronous API. Each method performs blocking network or disk I/O — and
|
||||
some, like `insert`/`upsert`, can run for several seconds.
|
||||
|
||||
When such a sync method is awaited from an async route handler, it blocks
|
||||
the event loop for its entire duration, freezing every other in-flight
|
||||
HTTP request, websocket message and background task.
|
||||
|
||||
This module wraps the sync client in an `AsyncVectorDBClient` that
|
||||
transparently dispatches each call to a worker thread via
|
||||
`asyncio.to_thread`. Async callers can `await ASYNC_VECTOR_DB_CLIENT.x(...)`
|
||||
in place of `VECTOR_DB_CLIENT.x(...)` and the loop stays responsive.
|
||||
|
||||
The original `VECTOR_DB_CLIENT` is unchanged, so callers already running
|
||||
inside `run_in_threadpool` (e.g. `save_docs_to_vector_db`) are not
|
||||
affected.
|
||||
|
||||
Thread-safety expectations
|
||||
--------------------------
|
||||
Every async caller now invokes `VECTOR_DB_CLIENT` from a worker thread
|
||||
rather than the event-loop thread, and many can run concurrently. The
|
||||
sync client (and its underlying backend driver) is therefore expected
|
||||
to be safe for concurrent use across threads, which is the standard
|
||||
contract for the bundled drivers (chroma, pgvector via SQLAlchemy
|
||||
pool, qdrant-client, opensearch-py, …). This is *not* a new exposure
|
||||
introduced by this facade — `save_docs_to_vector_db` already called
|
||||
the sync client from `run_in_threadpool`, so concurrent threaded
|
||||
access has always been a requirement of the codebase. Adding a global
|
||||
serialization lock here would defeat the responsiveness this facade
|
||||
exists to provide; any backend that genuinely cannot tolerate
|
||||
concurrent access should grow its own internal serialization.
|
||||
|
||||
API surface
|
||||
-----------
|
||||
Method signatures mirror `VectorDBBase` exactly. This is deliberate:
|
||||
permissive `*args/**kwargs` forwarding hides typos at the call site
|
||||
(an earlier revision of this file shipped that, and a `metadata=`
|
||||
typo silently broke an entire endpoint until explicit signatures
|
||||
surfaced it). Callers that need a backend-specific parameter not on
|
||||
`VectorDBBase` should reach for the `.sync` escape hatch and wrap
|
||||
their own `asyncio.to_thread`, e.g. ::
|
||||
|
||||
await asyncio.to_thread(
|
||||
ASYNC_VECTOR_DB_CLIENT.sync.some_backend_specific_op,
|
||||
collection_name, special_kwarg=value,
|
||||
)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
|
||||
from open_webui.retrieval.vector.main import (
|
||||
GetResult,
|
||||
SearchResult,
|
||||
VectorDBBase,
|
||||
VectorItem,
|
||||
)
|
||||
|
||||
|
||||
class AsyncVectorDBClient:
|
||||
"""Awaitable mirror of `VectorDBBase` that off-loads each call to a thread.
|
||||
|
||||
Method signatures mirror `VectorDBBase` exactly so static analysis
|
||||
catches bad kwargs at the call site instead of letting them surface
|
||||
deep inside the worker thread (where the resulting ``TypeError`` is
|
||||
typically swallowed by surrounding ``try/except``).
|
||||
"""
|
||||
|
||||
def __init__(self, sync_client: VectorDBBase) -> None:
|
||||
self._sync = sync_client
|
||||
|
||||
@property
|
||||
def sync(self) -> VectorDBBase:
|
||||
"""Escape hatch for code that must call the sync client directly
|
||||
(e.g. already inside a worker thread)."""
|
||||
return self._sync
|
||||
|
||||
async def has_collection(self, collection_name: str) -> bool:
|
||||
return await asyncio.to_thread(self._sync.has_collection, collection_name)
|
||||
|
||||
async def delete_collection(self, collection_name: str) -> None:
|
||||
return await asyncio.to_thread(self._sync.delete_collection, collection_name)
|
||||
|
||||
async def insert(self, collection_name: str, items: List[VectorItem]) -> None:
|
||||
return await asyncio.to_thread(self._sync.insert, collection_name, items)
|
||||
|
||||
async def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
|
||||
return await asyncio.to_thread(self._sync.upsert, collection_name, items)
|
||||
|
||||
async def search(
|
||||
self,
|
||||
collection_name: str,
|
||||
vectors: List[List[Union[float, int]]],
|
||||
filter: Optional[Dict] = None,
|
||||
limit: int = 10,
|
||||
) -> Optional[SearchResult]:
|
||||
return await asyncio.to_thread(self._sync.search, collection_name, vectors, filter, limit)
|
||||
|
||||
async def query(
|
||||
self,
|
||||
collection_name: str,
|
||||
filter: Dict,
|
||||
limit: Optional[int] = None,
|
||||
) -> Optional[GetResult]:
|
||||
return await asyncio.to_thread(self._sync.query, collection_name, filter, limit)
|
||||
|
||||
async def get(self, collection_name: str) -> Optional[GetResult]:
|
||||
return await asyncio.to_thread(self._sync.get, collection_name)
|
||||
|
||||
async def delete(
|
||||
self,
|
||||
collection_name: str,
|
||||
ids: Optional[List[str]] = None,
|
||||
filter: Optional[Dict] = None,
|
||||
) -> None:
|
||||
return await asyncio.to_thread(self._sync.delete, collection_name, ids, filter)
|
||||
|
||||
async def reset(self) -> None:
|
||||
return await asyncio.to_thread(self._sync.reset)
|
||||
|
||||
|
||||
ASYNC_VECTOR_DB_CLIENT = AsyncVectorDBClient(VECTOR_DB_CLIENT)
|
||||
|
|
@ -34,6 +34,7 @@ from open_webui.retrieval.vector.main import (
|
|||
SearchResult,
|
||||
GetResult,
|
||||
)
|
||||
from open_webui.utils.misc import sanitize_text_for_db
|
||||
from open_webui.config import (
|
||||
PGVECTOR_DB_URL,
|
||||
PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH,
|
||||
|
|
@ -289,7 +290,9 @@ class PgvectorClient(VectorDBBase):
|
|||
vector = self.adjust_vector_length(item['vector'])
|
||||
# Use raw SQL for BYTEA/pgcrypto
|
||||
# Ensure metadata is converted to its JSON text representation
|
||||
json_metadata = json.dumps(item['metadata'])
|
||||
# Sanitize to strip null bytes / surrogates that PostgreSQL cannot store
|
||||
json_metadata = sanitize_text_for_db(json.dumps(item['metadata']))
|
||||
item_text = sanitize_text_for_db(item['text'])
|
||||
self.session.execute(
|
||||
text("""
|
||||
INSERT INTO document_chunk
|
||||
|
|
@ -305,7 +308,7 @@ class PgvectorClient(VectorDBBase):
|
|||
'id': item['id'],
|
||||
'vector': vector,
|
||||
'collection_name': collection_name,
|
||||
'text': item['text'],
|
||||
'text': item_text,
|
||||
'metadata_text': json_metadata,
|
||||
'key': PGVECTOR_PGCRYPTO_KEY,
|
||||
},
|
||||
|
|
@ -338,7 +341,9 @@ class PgvectorClient(VectorDBBase):
|
|||
if PGVECTOR_PGCRYPTO:
|
||||
for item in items:
|
||||
vector = self.adjust_vector_length(item['vector'])
|
||||
json_metadata = json.dumps(item['metadata'])
|
||||
# Sanitize to strip null bytes / surrogates that PostgreSQL cannot store
|
||||
json_metadata = sanitize_text_for_db(json.dumps(item['metadata']))
|
||||
item_text = sanitize_text_for_db(item['text'])
|
||||
self.session.execute(
|
||||
text("""
|
||||
INSERT INTO document_chunk
|
||||
|
|
@ -358,7 +363,7 @@ class PgvectorClient(VectorDBBase):
|
|||
'id': item['id'],
|
||||
'vector': vector,
|
||||
'collection_name': collection_name,
|
||||
'text': item['text'],
|
||||
'text': item_text,
|
||||
'metadata_text': json_metadata,
|
||||
'key': PGVECTOR_PGCRYPTO_KEY,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
from datetime import datetime
|
||||
|
||||
from open_webui.utils.misc import sanitize_text_for_db
|
||||
|
||||
KEYS_TO_EXCLUDE = ['content', 'pages', 'tables', 'paragraphs', 'sections', 'figures']
|
||||
|
||||
|
||||
|
|
@ -12,7 +14,8 @@ def filter_metadata(metadata: dict[str, any]) -> dict[str, any]:
|
|||
def process_metadata(
|
||||
metadata: dict[str, any],
|
||||
) -> dict[str, any]:
|
||||
# Removes large fields and converts non-serializable types (datetime, list, dict) to strings.
|
||||
# Removes large fields, converts non-serializable types (datetime, list, dict) to strings,
|
||||
# and sanitizes strings for database storage (strips null bytes and invalid surrogates).
|
||||
result = {}
|
||||
for key, value in metadata.items():
|
||||
# Skip large fields
|
||||
|
|
@ -20,7 +23,7 @@ def process_metadata(
|
|||
continue
|
||||
# Convert non-serializable fields to strings
|
||||
if isinstance(value, (datetime, list, dict)):
|
||||
result[key] = str(value)
|
||||
result[key] = sanitize_text_for_db(str(value))
|
||||
else:
|
||||
result[key] = value
|
||||
result[key] = sanitize_text_for_db(value)
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import logging
|
||||
from typing import Optional, List
|
||||
|
||||
import requests
|
||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -14,23 +15,38 @@ def search_firecrawl(
|
|||
filter_list: Optional[List[str]] = None,
|
||||
) -> List[SearchResult]:
|
||||
try:
|
||||
from firecrawl import FirecrawlApp
|
||||
url = firecrawl_url.rstrip('/')
|
||||
response = requests.post(
|
||||
f'{url}/v1/search',
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {firecrawl_api_key}',
|
||||
},
|
||||
json={
|
||||
'query': query,
|
||||
'limit': count,
|
||||
'timeout': count * 3000,
|
||||
},
|
||||
timeout=count * 3 + 10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json().get('data', {})
|
||||
|
||||
firecrawl = FirecrawlApp(api_key=firecrawl_api_key, api_url=firecrawl_url)
|
||||
response = firecrawl.search(query=query, limit=count, ignore_invalid_urls=True, timeout=count * 3)
|
||||
results = response.web
|
||||
if filter_list:
|
||||
results = get_filtered_results(results, filter_list)
|
||||
results = [
|
||||
SearchResult(
|
||||
link=result.url,
|
||||
title=result.title,
|
||||
snippet=result.description,
|
||||
link=r.get('url', ''),
|
||||
title=r.get('title', ''),
|
||||
snippet=r.get('description', ''),
|
||||
)
|
||||
for result in results[:count]
|
||||
for r in data.get('web', [])
|
||||
]
|
||||
log.info(f'External search results: {results}')
|
||||
|
||||
if filter_list:
|
||||
results = get_filtered_results(results, filter_list)
|
||||
|
||||
results = results[:count]
|
||||
log.info(f'FireCrawl search results: {results}')
|
||||
return results
|
||||
except Exception as e:
|
||||
log.error(f'Error in External search: {e}')
|
||||
log.error(f'Error in FireCrawl search: {e}')
|
||||
return []
|
||||
|
|
|
|||
|
|
@ -192,27 +192,6 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
|
|||
proxy: Optional[Dict[str, str]] = None,
|
||||
params: Optional[Dict] = None,
|
||||
):
|
||||
"""Concurrent document loader for FireCrawl operations.
|
||||
|
||||
Executes multiple FireCrawlLoader instances concurrently using thread pooling
|
||||
to improve bulk processing efficiency.
|
||||
Args:
|
||||
web_paths: List of URLs/paths to process.
|
||||
verify_ssl: If True, verify SSL certificates.
|
||||
trust_env: If True, use proxy settings from environment variables.
|
||||
requests_per_second: Number of requests per second to limit to.
|
||||
continue_on_failure (bool): If True, continue loading other URLs on failure.
|
||||
api_key: API key for FireCrawl service. Defaults to None
|
||||
(uses FIRE_CRAWL_API_KEY environment variable if not provided).
|
||||
api_url: Base URL for FireCrawl API. Defaults to official API endpoint.
|
||||
mode: Operation mode selection:
|
||||
- 'crawl': Website crawling mode
|
||||
- 'scrape': Direct page scraping (default)
|
||||
- 'map': Site map generation
|
||||
proxy: Proxy override settings for the FireCrawl API.
|
||||
params: The parameters to pass to the Firecrawl API.
|
||||
For more details, visit: https://docs.firecrawl.dev/sdks/python#batch-scrape
|
||||
"""
|
||||
proxy_server = proxy.get('server') if proxy else None
|
||||
if trust_env and not proxy_server:
|
||||
env_proxies = urllib.request.getproxies()
|
||||
|
|
@ -229,44 +208,43 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
|
|||
self.trust_env = trust_env
|
||||
self.continue_on_failure = continue_on_failure
|
||||
self.api_key = api_key
|
||||
self.api_url = api_url
|
||||
self.api_url = (api_url or 'https://api.firecrawl.dev').rstrip('/')
|
||||
self.timeout = timeout
|
||||
self.mode = mode
|
||||
self.params = params or {}
|
||||
|
||||
def lazy_load(self) -> Iterator[Document]:
|
||||
"""Load documents using FireCrawl batch_scrape."""
|
||||
log.debug(
|
||||
'Starting FireCrawl batch scrape for %d URLs, mode: %s, params: %s',
|
||||
len(self.web_paths),
|
||||
self.mode,
|
||||
self.params,
|
||||
)
|
||||
try:
|
||||
from firecrawl import FirecrawlApp
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
}
|
||||
|
||||
firecrawl = FirecrawlApp(api_key=self.api_key, api_url=self.api_url)
|
||||
result = firecrawl.batch_scrape(
|
||||
self.web_paths,
|
||||
formats=['markdown'],
|
||||
skip_tls_verification=not self.verify_ssl,
|
||||
ignore_invalid_urls=True,
|
||||
remove_base64_images=True,
|
||||
max_age=300000, # 5 minutes https://docs.firecrawl.dev/features/fast-scraping#common-maxage-values
|
||||
wait_timeout=self.timeout if self.timeout else len(self.web_paths) * 3,
|
||||
**self.params,
|
||||
)
|
||||
for url in self.web_paths:
|
||||
payload = {
|
||||
'url': url,
|
||||
'formats': ['markdown'],
|
||||
**self.params,
|
||||
}
|
||||
if self.timeout:
|
||||
payload['timeout'] = self.timeout * 1000
|
||||
|
||||
if result.status != 'completed':
|
||||
raise RuntimeError(f'FireCrawl batch scrape did not complete successfully. result: {result}')
|
||||
|
||||
for data in result.data:
|
||||
metadata = data.metadata or {}
|
||||
yield Document(
|
||||
page_content=data.markdown or '',
|
||||
metadata={'source': metadata.url or metadata.source_url or ''},
|
||||
response = requests.post(
|
||||
f'{self.api_url}/v1/scrape',
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=self.timeout or 60,
|
||||
verify=self.verify_ssl,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json().get('data', {})
|
||||
metadata = data.get('metadata', {})
|
||||
source = metadata.get('url') or metadata.get('sourceURL') or url
|
||||
|
||||
yield Document(
|
||||
page_content=data.get('markdown', ''),
|
||||
metadata={'source': source},
|
||||
)
|
||||
except Exception as e:
|
||||
if self.continue_on_failure:
|
||||
log.exception(f'Error extracting content from URLs: {e}')
|
||||
|
|
@ -274,38 +252,10 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
|
|||
raise e
|
||||
|
||||
async def alazy_load(self):
|
||||
"""Async version of lazy_load."""
|
||||
log.debug(
|
||||
'Starting FireCrawl batch scrape for %d URLs, mode: %s, params: %s',
|
||||
len(self.web_paths),
|
||||
self.mode,
|
||||
self.params,
|
||||
)
|
||||
try:
|
||||
from firecrawl import FirecrawlApp
|
||||
|
||||
firecrawl = FirecrawlApp(api_key=self.api_key, api_url=self.api_url)
|
||||
result = firecrawl.batch_scrape(
|
||||
self.web_paths,
|
||||
formats=['markdown'],
|
||||
skip_tls_verification=not self.verify_ssl,
|
||||
ignore_invalid_urls=True,
|
||||
remove_base64_images=True,
|
||||
max_age=300000, # 5 minutes https://docs.firecrawl.dev/features/fast-scraping#common-maxage-values
|
||||
wait_timeout=self.timeout if self.timeout else len(self.web_paths) * 3,
|
||||
**self.params,
|
||||
)
|
||||
|
||||
if result.status != 'completed':
|
||||
raise RuntimeError(f'FireCrawl batch scrape did not complete successfully. result: {result}')
|
||||
|
||||
for data in result.data:
|
||||
metadata = data.metadata or {}
|
||||
yield Document(
|
||||
page_content=data.markdown or '',
|
||||
metadata={'source': metadata.url or metadata.source_url or ''},
|
||||
)
|
||||
|
||||
docs = await run_in_threadpool(lambda: list(self.lazy_load()))
|
||||
for doc in docs:
|
||||
yield doc
|
||||
except Exception as e:
|
||||
if self.continue_on_failure:
|
||||
log.exception(f'Error extracting content from URLs: {e}')
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import os
|
|||
import uuid
|
||||
import html
|
||||
import base64
|
||||
from functools import lru_cache
|
||||
from pydub import AudioSegment
|
||||
from pydub.silence import split_on_silence
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
|
@ -54,6 +53,7 @@ from open_webui.env import (
|
|||
AIOHTTP_CLIENT_SESSION_SSL,
|
||||
AIOHTTP_CLIENT_TIMEOUT,
|
||||
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
||||
BYPASS_PYDUB_PREPROCESSING,
|
||||
DEVICE_TYPE,
|
||||
ENABLE_FORWARD_USER_INFO_HEADERS,
|
||||
)
|
||||
|
|
@ -420,7 +420,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
|||
elif request.app.state.config.TTS_ENGINE == 'elevenlabs':
|
||||
voice_id = payload.get('voice', '')
|
||||
|
||||
if voice_id not in get_available_voices(request):
|
||||
if voice_id not in await get_available_voices(request):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail='Invalid voice id',
|
||||
|
|
@ -974,7 +974,10 @@ def transcription_handler(request, file_path, metadata, user=None):
|
|||
|
||||
# Read and encode audio file as base64
|
||||
with open(audio_file_to_use, 'rb') as audio_file:
|
||||
audio_base64 = base64.b64encode(audio_file.read()).decode('utf-8')
|
||||
audio_base64 = {
|
||||
'data': base64.b64encode(audio_file.read()).decode('utf-8'),
|
||||
'format': mimetypes.guess_extension(mimetypes.guess_type(audio_file_to_use)[0]).lstrip('.'),
|
||||
}
|
||||
|
||||
# Prepare chat completions request
|
||||
url = f'{api_base_url}/chat/completions'
|
||||
|
|
@ -1098,24 +1101,28 @@ def transcription_handler(request, file_path, metadata, user=None):
|
|||
def transcribe(request: Request, file_path: str, metadata: Optional[dict] = None, user=None):
|
||||
log.info(f'transcribe: {file_path} {metadata}')
|
||||
|
||||
if is_audio_conversion_required(file_path):
|
||||
file_path = convert_audio_to_mp3(file_path)
|
||||
if BYPASS_PYDUB_PREPROCESSING:
|
||||
log.info('Bypassing pydub preprocessing (BYPASS_PYDUB_PREPROCESSING=true)')
|
||||
chunk_paths = [file_path]
|
||||
else:
|
||||
if is_audio_conversion_required(file_path):
|
||||
file_path = convert_audio_to_mp3(file_path)
|
||||
|
||||
try:
|
||||
file_path = compress_audio(file_path)
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
try:
|
||||
file_path = compress_audio(file_path)
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
|
||||
# Always produce a list of chunk paths (could be one entry if small)
|
||||
try:
|
||||
chunk_paths = split_audio(file_path, MAX_FILE_SIZE)
|
||||
print(f'Chunk paths: {chunk_paths}')
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT(e),
|
||||
)
|
||||
# Always produce a list of chunk paths (could be one entry if small)
|
||||
try:
|
||||
chunk_paths = split_audio(file_path, MAX_FILE_SIZE)
|
||||
print(f'Chunk paths: {chunk_paths}')
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT(e),
|
||||
)
|
||||
|
||||
results = []
|
||||
try:
|
||||
|
|
@ -1287,40 +1294,53 @@ async def transcription(
|
|||
)
|
||||
|
||||
|
||||
def get_available_models(request: Request) -> list[dict]:
|
||||
async def get_available_models(request: Request) -> list[dict]:
|
||||
available_models = []
|
||||
if request.app.state.config.TTS_ENGINE == 'openai':
|
||||
# Use custom endpoint if not using the official OpenAI API URL
|
||||
if not request.app.state.config.TTS_OPENAI_API_BASE_URL.startswith('https://api.openai.com'):
|
||||
try:
|
||||
response = requests.get(
|
||||
f'{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/models',
|
||||
timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
available_models = data.get('models', [])
|
||||
except Exception as e:
|
||||
log.error(f'Error fetching models from custom endpoint: {str(e)}')
|
||||
available_models = [{'id': 'tts-1'}, {'id': 'tts-1-hd'}]
|
||||
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
|
||||
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
|
||||
try:
|
||||
async with session.get(
|
||||
f'{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/models',
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
available_models = data.get('models', [])
|
||||
except Exception as e:
|
||||
log.debug(f'/audio/models not available, trying /models fallback: {str(e)}')
|
||||
# Fallback to standard OpenAI-compatible /models endpoint
|
||||
# (used by KokoroTTS and similar custom TTS servers)
|
||||
try:
|
||||
async with session.get(
|
||||
f'{request.app.state.config.TTS_OPENAI_API_BASE_URL}/models',
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
# OpenAI /models returns {"data": [...]}, /audio/models returns {"models": [...]}
|
||||
available_models = data.get('data', data.get('models', []))
|
||||
except Exception as e2:
|
||||
log.error(f'Error fetching models from custom endpoint: {str(e2)}')
|
||||
available_models = [{'id': 'tts-1'}, {'id': 'tts-1-hd'}]
|
||||
else:
|
||||
available_models = [{'id': 'tts-1'}, {'id': 'tts-1-hd'}]
|
||||
elif request.app.state.config.TTS_ENGINE == 'elevenlabs':
|
||||
try:
|
||||
response = requests.get(
|
||||
f'{ELEVENLABS_API_BASE_URL}/v1/models',
|
||||
headers={
|
||||
'xi-api-key': request.app.state.config.TTS_API_KEY,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
response.raise_for_status()
|
||||
models = response.json()
|
||||
|
||||
available_models = [{'name': model['name'], 'id': model['model_id']} for model in models]
|
||||
except requests.RequestException as e:
|
||||
log.error(f'Error fetching voices: {str(e)}')
|
||||
timeout = aiohttp.ClientTimeout(total=5)
|
||||
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
|
||||
async with session.get(
|
||||
f'{ELEVENLABS_API_BASE_URL}/v1/models',
|
||||
headers={
|
||||
'xi-api-key': request.app.state.config.TTS_API_KEY,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
models = await response.json()
|
||||
available_models = [{'name': model['name'], 'id': model['model_id']} for model in models]
|
||||
except Exception as e:
|
||||
log.error(f'Error fetching models: {str(e)}')
|
||||
elif request.app.state.config.TTS_ENGINE == 'mistral':
|
||||
available_models = [{'id': 'mistral-tts-latest'}]
|
||||
return available_models
|
||||
|
|
@ -1328,24 +1348,25 @@ def get_available_models(request: Request) -> list[dict]:
|
|||
|
||||
@router.get('/models')
|
||||
async def get_models(request: Request, user=Depends(get_verified_user)):
|
||||
return {'models': get_available_models(request)}
|
||||
return {'models': await get_available_models(request)}
|
||||
|
||||
|
||||
def get_available_voices(request) -> dict:
|
||||
async def get_available_voices(request) -> dict:
|
||||
"""Returns {voice_id: voice_name} dict"""
|
||||
available_voices = {}
|
||||
if request.app.state.config.TTS_ENGINE == 'openai':
|
||||
# Use custom endpoint if not using the official OpenAI API URL
|
||||
if not request.app.state.config.TTS_OPENAI_API_BASE_URL.startswith('https://api.openai.com'):
|
||||
try:
|
||||
response = requests.get(
|
||||
f'{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/voices',
|
||||
timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
voices_list = data.get('voices', [])
|
||||
available_voices = {voice['id']: voice['name'] for voice in voices_list}
|
||||
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
|
||||
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
|
||||
async with session.get(
|
||||
f'{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/voices',
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
voices_list = data.get('voices', [])
|
||||
available_voices = {voice['id']: voice['name'] for voice in voices_list}
|
||||
except Exception as e:
|
||||
log.error(f'Error fetching voices from custom endpoint: {str(e)}')
|
||||
available_voices = {
|
||||
|
|
@ -1367,7 +1388,7 @@ def get_available_voices(request) -> dict:
|
|||
}
|
||||
elif request.app.state.config.TTS_ENGINE == 'elevenlabs':
|
||||
try:
|
||||
available_voices = get_elevenlabs_voices(api_key=request.app.state.config.TTS_API_KEY)
|
||||
available_voices = await get_elevenlabs_voices(api_key=request.app.state.config.TTS_API_KEY)
|
||||
except Exception:
|
||||
# Avoided @lru_cache with exception
|
||||
pass
|
||||
|
|
@ -1378,13 +1399,15 @@ def get_available_voices(request) -> dict:
|
|||
url = (base_url or f'https://{region}.tts.speech.microsoft.com') + '/cognitiveservices/voices/list'
|
||||
headers = {'Ocp-Apim-Subscription-Key': request.app.state.config.TTS_API_KEY}
|
||||
|
||||
response = requests.get(url, headers=headers, timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
|
||||
response.raise_for_status()
|
||||
voices = response.json()
|
||||
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
|
||||
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
|
||||
async with session.get(url, headers=headers) as response:
|
||||
response.raise_for_status()
|
||||
voices = await response.json()
|
||||
|
||||
for voice in voices:
|
||||
available_voices[voice['ShortName']] = f'{voice["DisplayName"]} ({voice["ShortName"]})'
|
||||
except requests.RequestException as e:
|
||||
for voice in voices:
|
||||
available_voices[voice['ShortName']] = f'{voice["DisplayName"]} ({voice["ShortName"]})'
|
||||
except Exception as e:
|
||||
log.error(f'Error fetching voices: {str(e)}')
|
||||
elif request.app.state.config.TTS_ENGINE == 'mistral':
|
||||
api_key = request.app.state.config.TTS_MISTRAL_API_KEY
|
||||
|
|
@ -1392,29 +1415,29 @@ def get_available_voices(request) -> dict:
|
|||
|
||||
if api_key:
|
||||
try:
|
||||
response = requests.get(
|
||||
f'{api_base_url}/audio/voices',
|
||||
headers={
|
||||
'Authorization': f'Bearer {api_key}',
|
||||
},
|
||||
timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
||||
)
|
||||
response.raise_for_status()
|
||||
voices_data = response.json()
|
||||
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
|
||||
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
|
||||
async with session.get(
|
||||
f'{api_base_url}/audio/voices',
|
||||
headers={
|
||||
'Authorization': f'Bearer {api_key}',
|
||||
},
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
voices_data = await response.json()
|
||||
|
||||
for voice in voices_data:
|
||||
voice_id = voice.get('voice_id', voice.get('id', ''))
|
||||
voice_name = voice.get('name', voice_id)
|
||||
if voice_id:
|
||||
available_voices[voice_id] = voice_name
|
||||
except requests.RequestException as e:
|
||||
for voice in voices_data:
|
||||
voice_id = voice.get('voice_id', voice.get('id', ''))
|
||||
voice_name = voice.get('name', voice_id)
|
||||
if voice_id:
|
||||
available_voices[voice_id] = voice_name
|
||||
except Exception as e:
|
||||
log.error(f'Error fetching Mistral voices: {str(e)}')
|
||||
|
||||
return available_voices
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_elevenlabs_voices(api_key: str) -> dict:
|
||||
async def get_elevenlabs_voices(api_key: str) -> dict:
|
||||
"""
|
||||
Note, set the following in your .env file to use Elevenlabs:
|
||||
AUDIO_TTS_ENGINE=elevenlabs
|
||||
|
|
@ -1425,22 +1448,22 @@ def get_elevenlabs_voices(api_key: str) -> dict:
|
|||
|
||||
try:
|
||||
# TODO: Add retries
|
||||
response = requests.get(
|
||||
f'{ELEVENLABS_API_BASE_URL}/v1/voices',
|
||||
headers={
|
||||
'xi-api-key': api_key,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
||||
)
|
||||
response.raise_for_status()
|
||||
voices_data = response.json()
|
||||
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
|
||||
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
|
||||
async with session.get(
|
||||
f'{ELEVENLABS_API_BASE_URL}/v1/voices',
|
||||
headers={
|
||||
'xi-api-key': api_key,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
voices_data = await response.json()
|
||||
|
||||
voices = {}
|
||||
for voice in voices_data.get('voices', []):
|
||||
voices[voice['voice_id']] = voice['name']
|
||||
except requests.RequestException as e:
|
||||
# Avoid @lru_cache with exception
|
||||
voices = {}
|
||||
for voice in voices_data.get('voices', []):
|
||||
voices[voice['voice_id']] = voice['name']
|
||||
except Exception as e:
|
||||
log.error(f'Error fetching voices: {str(e)}')
|
||||
raise RuntimeError(f'Error fetching voices: {str(e)}')
|
||||
|
||||
|
|
@ -1449,4 +1472,4 @@ def get_elevenlabs_voices(api_key: str) -> dict:
|
|||
|
||||
@router.get('/voices')
|
||||
async def get_voices(request: Request, user=Depends(get_verified_user)):
|
||||
return {'voices': [{'id': k, 'name': v} for k, v in get_available_voices(request).items()]}
|
||||
return {'voices': [{'id': k, 'name': v} for k, v in (await get_available_voices(request)).items()]}
|
||||
|
|
|
|||
|
|
@ -1131,7 +1131,7 @@ async def update_ldap_server(request: Request, form_data: LdapServerConfig, user
|
|||
for key in required_fields:
|
||||
value = getattr(form_data, key)
|
||||
if not value:
|
||||
raise HTTPException(400, detail=f'Required field {key} is empty')
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.REQUIRED_FIELD_EMPTY(key))
|
||||
|
||||
request.app.state.config.LDAP_SERVER_LABEL = form_data.label
|
||||
request.app.state.config.LDAP_SERVER_HOST = form_data.host
|
||||
|
|
@ -1260,7 +1260,7 @@ async def token_exchange(
|
|||
if provider not in OAUTH_PROVIDERS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Provider '{provider}' is not configured",
|
||||
detail=ERROR_MESSAGES.OAUTH_NOT_CONFIGURED(provider),
|
||||
)
|
||||
# Get the OAuth client for this provider
|
||||
oauth_manager = request.app.state.oauth_manager
|
||||
|
|
@ -1268,7 +1268,7 @@ async def token_exchange(
|
|||
if not client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"OAuth client for '{provider}' not found",
|
||||
detail=ERROR_MESSAGES.OAUTH_NOT_CONFIGURED(provider),
|
||||
)
|
||||
|
||||
# Validate the token by calling the userinfo endpoint
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ async def check_automation_limits(request, user, rrule_str: str, db, is_create:
|
|||
if max_count > 0 and await Automations.count_by_user(user.id, db=db) >= max_count:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f'Automation limit reached ({max_count})',
|
||||
detail=ERROR_MESSAGES.AUTOMATION_LIMIT_EXCEEDED(max_count),
|
||||
)
|
||||
|
||||
# Min interval (create + update)
|
||||
|
|
@ -86,7 +86,7 @@ async def check_automation_limits(request, user, rrule_str: str, db, is_create:
|
|||
if interval is not None and interval < min_interval:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Schedule too frequent. Minimum interval is {min_interval} seconds.',
|
||||
detail=ERROR_MESSAGES.AUTOMATION_TOO_FREQUENT(min_interval),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ async def check_channels_access(request: Request, user: Optional[UserModel] = No
|
|||
if not request.app.state.config.ENABLE_CHANNELS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Channels are not enabled',
|
||||
detail=ERROR_MESSAGES.FEATURE_DISABLED('Channels'),
|
||||
)
|
||||
|
||||
if user:
|
||||
|
|
@ -1791,7 +1791,7 @@ async def post_webhook_message(
|
|||
if not webhook:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Invalid webhook URL',
|
||||
detail=ERROR_MESSAGES.INVALID_URL,
|
||||
)
|
||||
|
||||
channel = await Channels.get_channel_by_id(webhook.channel_id, db=db)
|
||||
|
|
@ -1809,7 +1809,7 @@ async def post_webhook_message(
|
|||
if not message:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to create message',
|
||||
detail=ERROR_MESSAGES.DEFAULT('Failed to create message'),
|
||||
)
|
||||
|
||||
# Update last_used_at
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import asyncio
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
|
@ -16,13 +17,14 @@ from open_webui.models.chats import (
|
|||
ChatResponse,
|
||||
Chats,
|
||||
ChatTitleIdResponse,
|
||||
SharedChatResponse,
|
||||
ChatStatsExport,
|
||||
AggregateChatStats,
|
||||
ChatBody,
|
||||
ChatHistoryStats,
|
||||
MessageStats,
|
||||
)
|
||||
from open_webui.models.shared_chats import SharedChats, SharedChatResponse
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.tags import TagModel, Tags
|
||||
from open_webui.models.folders import Folders
|
||||
from open_webui.internal.db import get_async_session
|
||||
|
|
@ -34,7 +36,7 @@ from pydantic import BaseModel
|
|||
|
||||
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -557,7 +559,7 @@ async def create_new_chat(
|
|||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
chat = await Chats.insert_new_chat(user.id, form_data, db=db)
|
||||
chat = await Chats.insert_new_chat(str(uuid4()), user.id, form_data, db=db)
|
||||
return ChatResponse(**chat.model_dump())
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
|
|
@ -806,7 +808,7 @@ async def get_shared_session_user_chat_list(
|
|||
if direction:
|
||||
filter['direction'] = direction
|
||||
|
||||
return await Chats.get_shared_chat_list_by_user_id(
|
||||
return await SharedChats.get_by_user_id(
|
||||
user.id,
|
||||
filter=filter,
|
||||
skip=skip,
|
||||
|
|
@ -827,17 +829,32 @@ async def get_shared_chat_by_id(
|
|||
if user.role == 'pending':
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
if user.role == 'user' or (user.role == 'admin' and not ENABLE_ADMIN_CHAT_ACCESS):
|
||||
chat = await Chats.get_chat_by_share_id(share_id, db=db)
|
||||
elif user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
|
||||
if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
|
||||
chat = await Chats.get_chat_by_id(share_id, db=db)
|
||||
|
||||
if chat:
|
||||
return ChatResponse(**chat.model_dump())
|
||||
|
||||
else:
|
||||
chat = await Chats.get_chat_by_share_id(share_id, db=db)
|
||||
|
||||
if not chat:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
# Look up the original chat_id to check access grants
|
||||
shared = await SharedChats.get_by_id(share_id, db=db)
|
||||
if shared:
|
||||
has_grant = await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='shared_chat',
|
||||
resource_id=shared.chat_id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
if not has_grant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
return ChatResponse(**chat.model_dump())
|
||||
|
||||
|
||||
############################
|
||||
# GetChatsByTags
|
||||
|
|
@ -877,11 +894,25 @@ async def get_user_chat_list_by_tag_name(
|
|||
async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
||||
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
|
||||
|
||||
if not chat:
|
||||
# Check if user has access via access grants (shared_chat grants)
|
||||
if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
|
||||
chat = await Chats.get_chat_by_id(id, db=db)
|
||||
else:
|
||||
has_grant = await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='shared_chat',
|
||||
resource_id=id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
if has_grant:
|
||||
chat = await Chats.get_chat_by_id(id, db=db)
|
||||
|
||||
if chat:
|
||||
return ChatResponse(**chat.model_dump())
|
||||
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
|
||||
############################
|
||||
|
|
@ -1157,39 +1188,58 @@ async def clone_shared_chat_by_id(
|
|||
else:
|
||||
chat = await Chats.get_chat_by_share_id(id, db=db)
|
||||
|
||||
if chat:
|
||||
updated_chat = {
|
||||
**chat.chat,
|
||||
'originalChatId': chat.id,
|
||||
'branchPointMessageId': chat.chat['history']['currentId'],
|
||||
'title': f'Clone of {chat.title}',
|
||||
}
|
||||
|
||||
chats = await Chats.import_chats(
|
||||
user.id,
|
||||
[
|
||||
ChatImportForm(
|
||||
**{
|
||||
'chat': updated_chat,
|
||||
'meta': chat.meta,
|
||||
'pinned': chat.pinned,
|
||||
'folder_id': chat.folder_id,
|
||||
}
|
||||
)
|
||||
],
|
||||
db=db,
|
||||
if not chat:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if chats:
|
||||
chat = chats[0]
|
||||
return ChatResponse(**chat.model_dump())
|
||||
else:
|
||||
# Enforce access grants
|
||||
shared = await SharedChats.get_by_id(id, db=db)
|
||||
if shared and user.role != 'admin':
|
||||
has_grant = await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='shared_chat',
|
||||
resource_id=shared.chat_id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
if not has_grant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=ERROR_MESSAGES.DEFAULT(),
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
updated_chat = {
|
||||
**chat.chat,
|
||||
'originalChatId': chat.id,
|
||||
'branchPointMessageId': chat.chat['history']['currentId'],
|
||||
'title': f'Clone of {chat.title}',
|
||||
}
|
||||
|
||||
chats = await Chats.import_chats(
|
||||
user.id,
|
||||
[
|
||||
ChatImportForm(
|
||||
**{
|
||||
'chat': updated_chat,
|
||||
'meta': chat.meta,
|
||||
'pinned': chat.pinned,
|
||||
'folder_id': chat.folder_id,
|
||||
}
|
||||
)
|
||||
],
|
||||
db=db,
|
||||
)
|
||||
|
||||
if chats:
|
||||
chat = chats[0]
|
||||
return ChatResponse(**chat.model_dump())
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT())
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=ERROR_MESSAGES.DEFAULT(),
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
|
|
@ -1240,16 +1290,28 @@ async def share_chat_by_id(
|
|||
|
||||
if chat:
|
||||
if chat.share_id:
|
||||
shared_chat = await Chats.update_shared_chat_by_chat_id(chat.id, db=db)
|
||||
return ChatResponse(**shared_chat.model_dump())
|
||||
# Re-snapshot existing share
|
||||
shared = await SharedChats.update(chat.share_id, db=db)
|
||||
if shared:
|
||||
# Re-fetch the original chat to return
|
||||
chat = await Chats.get_chat_by_id(id, db=db)
|
||||
return ChatResponse(**chat.model_dump())
|
||||
|
||||
shared_chat = await Chats.insert_shared_chat_by_chat_id(chat.id, db=db)
|
||||
if not shared_chat:
|
||||
# Create new share
|
||||
shared = await SharedChats.create(id, user.id, db=db)
|
||||
if not shared:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=ERROR_MESSAGES.DEFAULT(),
|
||||
)
|
||||
return ChatResponse(**shared_chat.model_dump())
|
||||
# Set share_id on the original chat
|
||||
chat = await Chats.update_chat_share_id_by_id(id, shared.id, db=db)
|
||||
if not chat:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=ERROR_MESSAGES.DEFAULT(),
|
||||
)
|
||||
return ChatResponse(**chat.model_dump())
|
||||
|
||||
else:
|
||||
raise HTTPException(
|
||||
|
|
@ -1259,7 +1321,7 @@ async def share_chat_by_id(
|
|||
|
||||
|
||||
############################
|
||||
# DeletedSharedChatById
|
||||
# DeleteSharedChatById
|
||||
############################
|
||||
|
||||
|
||||
|
|
@ -1272,10 +1334,13 @@ async def delete_shared_chat_by_id(
|
|||
if not chat.share_id:
|
||||
return False
|
||||
|
||||
result = await Chats.delete_shared_chat_by_chat_id(id, db=db)
|
||||
update_result = await Chats.update_chat_share_id_by_id(id, None, db=db)
|
||||
await SharedChats.delete_by_chat_id(id, db=db)
|
||||
await Chats.update_chat_share_id_by_id(id, None, db=db)
|
||||
|
||||
return result and update_result != None
|
||||
# Revoke all access grants for this shared chat
|
||||
await AccessGrants.set_access_grants('shared_chat', id, [], db=db)
|
||||
|
||||
return True
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
|
@ -1283,6 +1348,85 @@ async def delete_shared_chat_by_id(
|
|||
)
|
||||
|
||||
|
||||
############################
|
||||
# UpdateSharedChatAccessById
|
||||
############################
|
||||
|
||||
|
||||
class ChatAccessGrantsForm(BaseModel):
|
||||
access_grants: list[dict]
|
||||
|
||||
|
||||
@router.post('/shared/{id}/access/update', response_model=Optional[ChatResponse])
|
||||
async def update_shared_chat_access_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
form_data: ChatAccessGrantsForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
|
||||
if not chat:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if chat.user_id != user.id and user.role != 'admin':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
'sharing.public_chats',
|
||||
)
|
||||
|
||||
await AccessGrants.set_access_grants('shared_chat', id, form_data.access_grants, db=db)
|
||||
|
||||
return ChatResponse(**chat.model_dump())
|
||||
|
||||
|
||||
############################
|
||||
# GetSharedChatAccessById
|
||||
############################
|
||||
|
||||
|
||||
@router.get('/shared/{id}/access', response_model=list)
|
||||
async def get_shared_chat_access_by_id(
|
||||
id: str,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
|
||||
if not chat:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if chat.user_id != user.id and user.role != 'admin':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
grants = await AccessGrants.get_grants_by_resource('shared_chat', id, db=db)
|
||||
return [
|
||||
{
|
||||
'id': g.id,
|
||||
'principal_type': g.principal_type,
|
||||
'principal_id': g.principal_id,
|
||||
'permission': g.permission,
|
||||
}
|
||||
for g in grants
|
||||
]
|
||||
|
||||
|
||||
############################
|
||||
# UpdateChatFolderIdById
|
||||
############################
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from typing import Optional
|
|||
|
||||
from open_webui.env import AIOHTTP_CLIENT_TIMEOUT
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.config import get_config, save_config
|
||||
from open_webui.config import get_config, save_config, async_save_config
|
||||
from open_webui.config import BannerModel
|
||||
|
||||
from open_webui.utils.tools import (
|
||||
|
|
@ -27,6 +27,7 @@ from open_webui.utils.oauth import (
|
|||
get_oauth_client_info_with_static_credentials,
|
||||
encrypt_data,
|
||||
decrypt_data,
|
||||
resolve_oauth_client_info,
|
||||
OAuthClientInformationFull,
|
||||
)
|
||||
from mcp.shared.auth import OAuthMetadata
|
||||
|
|
@ -49,7 +50,7 @@ class ImportConfigForm(BaseModel):
|
|||
|
||||
@router.post('/import', response_model=dict)
|
||||
async def import_config(form_data: ImportConfigForm, user=Depends(get_admin_user)):
|
||||
save_config(form_data.config)
|
||||
await async_save_config(form_data.config)
|
||||
return get_config()
|
||||
|
||||
|
||||
|
|
@ -203,9 +204,7 @@ async def set_tool_servers_config(
|
|||
|
||||
if auth_type in ('oauth_2.1', 'oauth_2.1_static') and server_id:
|
||||
try:
|
||||
oauth_client_info = connection.get('info', {}).get('oauth_client_info', '')
|
||||
oauth_client_info = decrypt_data(oauth_client_info)
|
||||
|
||||
oauth_client_info = resolve_oauth_client_info(connection)
|
||||
request.app.state.oauth_client_manager.add_client(
|
||||
f'{server_type}:{server_id}',
|
||||
OAuthClientInformationFull(**oauth_client_info),
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from open_webui.internal.db import get_async_session, get_async_db_context
|
||||
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
|
||||
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
|
||||
|
||||
from open_webui.models.channels import Channels
|
||||
from open_webui.models.users import Users
|
||||
|
|
@ -124,7 +124,7 @@ async def process_uploaded_file(
|
|||
stt_supported_content_types = getattr(request.app.state.config, 'STT_SUPPORTED_CONTENT_TYPES', [])
|
||||
|
||||
if strict_match_mime_type(stt_supported_content_types, content_type):
|
||||
file_path_processed = Storage.get_file(file_path)
|
||||
file_path_processed = await asyncio.to_thread(Storage.get_file, file_path)
|
||||
result = transcribe(request, file_path_processed, file_metadata, user)
|
||||
|
||||
await process_file(
|
||||
|
|
@ -242,7 +242,8 @@ async def upload_file_handler(
|
|||
id = str(uuid.uuid4())
|
||||
name = filename
|
||||
filename = f'{id}_{filename}'
|
||||
contents, file_path = Storage.upload_file(
|
||||
contents, file_path = await asyncio.to_thread(
|
||||
Storage.upload_file,
|
||||
file.file,
|
||||
filename,
|
||||
{
|
||||
|
|
@ -406,8 +407,8 @@ async def delete_all_files(user=Depends(get_admin_user), db: AsyncSession = Depe
|
|||
result = await Files.delete_all_files(db=db)
|
||||
if result:
|
||||
try:
|
||||
Storage.delete_all_files()
|
||||
VECTOR_DB_CLIENT.reset()
|
||||
await asyncio.to_thread(Storage.delete_all_files)
|
||||
await ASYNC_VECTOR_DB_CLIENT.reset()
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
log.error('Error deleting files')
|
||||
|
|
@ -577,7 +578,7 @@ async def update_file_data_content_by_id(
|
|||
for knowledge in knowledges:
|
||||
try:
|
||||
# Remove old embeddings for this file from the KB collection
|
||||
VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'file_id': id})
|
||||
await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'file_id': id})
|
||||
# Re-add from the now-updated file-{file_id} collection
|
||||
await process_file(
|
||||
request,
|
||||
|
|
@ -618,7 +619,7 @@ async def get_file_content_by_id(
|
|||
|
||||
if file.user_id == user.id or user.role == 'admin' or await has_access_to_file(id, 'read', user, db=db):
|
||||
try:
|
||||
file_path = Storage.get_file(file.path)
|
||||
file_path = await asyncio.to_thread(Storage.get_file, file.path)
|
||||
file_path = Path(file_path)
|
||||
|
||||
# Check if the file already exists in the cache
|
||||
|
|
@ -685,7 +686,7 @@ async def get_html_file_content_by_id(
|
|||
|
||||
if file.user_id == user.id or user.role == 'admin' or await has_access_to_file(id, 'read', user, db=db):
|
||||
try:
|
||||
file_path = Storage.get_file(file.path)
|
||||
file_path = await asyncio.to_thread(Storage.get_file, file.path)
|
||||
file_path = Path(file_path)
|
||||
|
||||
# Check if the file already exists in the cache
|
||||
|
|
@ -734,7 +735,7 @@ async def get_file_content_by_id(
|
|||
headers = {'Content-Disposition': f"attachment; filename*=UTF-8''{encoded_filename}"}
|
||||
|
||||
if file_path:
|
||||
file_path = Storage.get_file(file_path)
|
||||
file_path = await asyncio.to_thread(Storage.get_file, file_path)
|
||||
file_path = Path(file_path)
|
||||
|
||||
# Check if the file already exists in the cache
|
||||
|
|
@ -789,17 +790,17 @@ async def delete_file_by_id(id: str, user=Depends(get_verified_user), db: AsyncS
|
|||
await 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})
|
||||
await ASYNC_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})
|
||||
await ASYNC_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 = await Files.delete_file_by_id(id, db=db)
|
||||
if result:
|
||||
try:
|
||||
Storage.delete_file(file.path)
|
||||
VECTOR_DB_CLIENT.delete(collection_name=f'file-{id}')
|
||||
await asyncio.to_thread(Storage.delete_file, file.path)
|
||||
await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=f'file-{id}')
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
log.error('Error deleting files')
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ async def load_function_from_url(request: Request, form_data: LoadUrlForm, user=
|
|||
'content': data,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f'Error importing function: {e}')
|
||||
raise HTTPException(status_code=500, detail=ERROR_MESSAGES.DEFAULT(e))
|
||||
|
||||
|
||||
############################
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ from pathlib import Path
|
|||
from typing import Optional
|
||||
|
||||
from urllib.parse import quote
|
||||
import requests
|
||||
import aiohttp
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
|
|
@ -21,7 +22,8 @@ from open_webui.config import (
|
|||
)
|
||||
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.env import AIOHTTP_CLIENT_SESSION_SSL, ENABLE_FORWARD_USER_INFO_HEADERS
|
||||
from open_webui.utils.session_pool import get_session
|
||||
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.routers.files import upload_file_handler, get_file_content_by_id
|
||||
|
|
@ -50,32 +52,36 @@ IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|||
router = APIRouter()
|
||||
|
||||
|
||||
def set_image_model(request: Request, model: str):
|
||||
async def set_image_model(request: Request, model: str):
|
||||
log.info(f'Setting image model to {model}')
|
||||
request.app.state.config.IMAGE_GENERATION_MODEL = model
|
||||
if request.app.state.config.IMAGE_GENERATION_ENGINE in ['', 'automatic1111']:
|
||||
api_auth = get_automatic1111_api_auth(request)
|
||||
|
||||
try:
|
||||
r = requests.get(
|
||||
session = await get_session()
|
||||
async with session.get(
|
||||
url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options',
|
||||
headers={'authorization': api_auth},
|
||||
)
|
||||
options = r.json()
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
options = await r.json()
|
||||
if model != options['sd_model_checkpoint']:
|
||||
options['sd_model_checkpoint'] = model
|
||||
r = requests.post(
|
||||
async with session.post(
|
||||
url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options',
|
||||
json=options,
|
||||
headers={'authorization': api_auth},
|
||||
)
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
except Exception as e:
|
||||
log.debug(f'{e}')
|
||||
|
||||
return request.app.state.config.IMAGE_GENERATION_MODEL
|
||||
|
||||
|
||||
def get_image_model(request):
|
||||
async def get_image_model(request):
|
||||
if request.app.state.config.IMAGE_GENERATION_ENGINE == 'openai':
|
||||
return (
|
||||
request.app.state.config.IMAGE_GENERATION_MODEL
|
||||
|
|
@ -97,14 +103,15 @@ def get_image_model(request):
|
|||
or request.app.state.config.IMAGE_GENERATION_ENGINE == ''
|
||||
):
|
||||
try:
|
||||
r = requests.get(
|
||||
session = await get_session()
|
||||
async with session.get(
|
||||
url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options',
|
||||
headers={'authorization': get_automatic1111_api_auth(request)},
|
||||
)
|
||||
options = r.json()
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
options = await r.json()
|
||||
return options['sd_model_checkpoint']
|
||||
except Exception as e:
|
||||
request.app.state.config.ENABLE_IMAGE_GENERATION = False
|
||||
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e))
|
||||
|
||||
|
||||
|
|
@ -198,7 +205,7 @@ async def update_config(request: Request, form_data: ImagesConfig, user=Depends(
|
|||
request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = form_data.ENABLE_IMAGE_PROMPT_GENERATION
|
||||
|
||||
request.app.state.config.IMAGE_GENERATION_ENGINE = form_data.IMAGE_GENERATION_ENGINE
|
||||
set_image_model(request, form_data.IMAGE_GENERATION_MODEL)
|
||||
await set_image_model(request, form_data.IMAGE_GENERATION_MODEL)
|
||||
if form_data.IMAGE_SIZE == 'auto' and not re.match(
|
||||
IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN, form_data.IMAGE_GENERATION_MODEL
|
||||
):
|
||||
|
|
@ -313,28 +320,30 @@ def get_automatic1111_api_auth(request: Request):
|
|||
async def verify_url(request: Request, user=Depends(get_admin_user)):
|
||||
if request.app.state.config.IMAGE_GENERATION_ENGINE == 'automatic1111':
|
||||
try:
|
||||
r = requests.get(
|
||||
session = await get_session()
|
||||
async with session.get(
|
||||
url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options',
|
||||
headers={'authorization': get_automatic1111_api_auth(request)},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return True
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except Exception:
|
||||
request.app.state.config.ENABLE_IMAGE_GENERATION = False
|
||||
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.INVALID_URL)
|
||||
elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'comfyui':
|
||||
headers = None
|
||||
if request.app.state.config.COMFYUI_API_KEY:
|
||||
headers = {'Authorization': f'Bearer {request.app.state.config.COMFYUI_API_KEY}'}
|
||||
try:
|
||||
r = requests.get(
|
||||
session = await get_session()
|
||||
async with session.get(
|
||||
url=f'{request.app.state.config.COMFYUI_BASE_URL}/object_info',
|
||||
headers=headers,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return True
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except Exception:
|
||||
request.app.state.config.ENABLE_IMAGE_GENERATION = False
|
||||
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.INVALID_URL)
|
||||
else:
|
||||
return True
|
||||
|
|
@ -357,11 +366,13 @@ async def get_models(request: Request, user=Depends(get_verified_user)):
|
|||
elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'comfyui':
|
||||
# TODO - get models from comfyui
|
||||
headers = {'Authorization': f'Bearer {request.app.state.config.COMFYUI_API_KEY}'}
|
||||
r = requests.get(
|
||||
session = await get_session()
|
||||
async with session.get(
|
||||
url=f'{request.app.state.config.COMFYUI_BASE_URL}/object_info',
|
||||
headers=headers,
|
||||
)
|
||||
info = r.json()
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
info = await r.json()
|
||||
|
||||
workflow = json.loads(request.app.state.config.COMFYUI_WORKFLOW)
|
||||
model_node_id = None
|
||||
|
|
@ -399,11 +410,13 @@ async def get_models(request: Request, user=Depends(get_verified_user)):
|
|||
request.app.state.config.IMAGE_GENERATION_ENGINE == 'automatic1111'
|
||||
or request.app.state.config.IMAGE_GENERATION_ENGINE == ''
|
||||
):
|
||||
r = requests.get(
|
||||
session = await get_session()
|
||||
async with session.get(
|
||||
url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/sd-models',
|
||||
headers={'authorization': get_automatic1111_api_auth(request)},
|
||||
)
|
||||
models = r.json()
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
models = await r.json()
|
||||
return list(
|
||||
map(
|
||||
lambda model: {'id': model['title'], 'name': model['model_name']},
|
||||
|
|
@ -411,7 +424,6 @@ async def get_models(request: Request, user=Depends(get_verified_user)):
|
|||
)
|
||||
)
|
||||
except Exception as e:
|
||||
request.app.state.config.ENABLE_IMAGE_GENERATION = False
|
||||
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e))
|
||||
|
||||
|
||||
|
|
@ -427,21 +439,22 @@ class CreateImageForm(BaseModel):
|
|||
GenerateImageForm = CreateImageForm # Alias for backward compatibility
|
||||
|
||||
|
||||
def get_image_data(data: str, headers=None):
|
||||
async def get_image_data(data: str, headers=None):
|
||||
try:
|
||||
if data.startswith('http://') or data.startswith('https://'):
|
||||
if headers:
|
||||
r = requests.get(data, headers=headers)
|
||||
else:
|
||||
r = requests.get(data)
|
||||
|
||||
r.raise_for_status()
|
||||
if r.headers['content-type'].split('/')[0] == 'image':
|
||||
mime_type = r.headers['content-type']
|
||||
return r.content, mime_type
|
||||
else:
|
||||
log.error('Url does not point to an image.')
|
||||
return None
|
||||
session = await get_session()
|
||||
async with session.get(
|
||||
data,
|
||||
headers=headers,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
content_type = r.headers.get('content-type', '')
|
||||
if content_type.split('/')[0] == 'image':
|
||||
return await r.read(), content_type
|
||||
else:
|
||||
log.error('Url does not point to an image.')
|
||||
return None, None
|
||||
else:
|
||||
if ',' in data:
|
||||
header, encoded = data.split(',', 1)
|
||||
|
|
@ -531,9 +544,8 @@ async def image_generations(
|
|||
|
||||
metadata = metadata or {}
|
||||
|
||||
model = get_image_model(request)
|
||||
model = await get_image_model(request)
|
||||
|
||||
r = None
|
||||
try:
|
||||
if request.app.state.config.IMAGE_GENERATION_ENGINE == 'openai':
|
||||
headers = {
|
||||
|
|
@ -552,7 +564,11 @@ async def image_generations(
|
|||
'model': model,
|
||||
'prompt': form_data.prompt,
|
||||
'n': form_data.n,
|
||||
'size': (form_data.size if form_data.size else request.app.state.config.IMAGE_SIZE),
|
||||
**(
|
||||
{'size': form_data.size or request.app.state.config.IMAGE_SIZE}
|
||||
if (form_data.size or request.app.state.config.IMAGE_SIZE)
|
||||
else {}
|
||||
),
|
||||
**(
|
||||
{}
|
||||
if re.match(
|
||||
|
|
@ -568,27 +584,26 @@ async def image_generations(
|
|||
),
|
||||
}
|
||||
|
||||
# Use asyncio.to_thread for the requests.post call
|
||||
r = await asyncio.to_thread(
|
||||
requests.post,
|
||||
session = await get_session()
|
||||
async with session.post(
|
||||
url=url,
|
||||
json=data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
res = r.json()
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
res = await r.json()
|
||||
|
||||
images = []
|
||||
|
||||
for image in res['data']:
|
||||
if image_url := image.get('url', None):
|
||||
image_data, content_type = get_image_data(
|
||||
image_data, content_type = await get_image_data(
|
||||
image_url,
|
||||
{k: v for k, v in headers.items() if k != 'Content-Type'},
|
||||
)
|
||||
else:
|
||||
image_data, content_type = get_image_data(image['b64_json'])
|
||||
image_data, content_type = await get_image_data(image['b64_json'])
|
||||
|
||||
_, url = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
|
||||
images.append({'url': url})
|
||||
|
|
@ -619,29 +634,28 @@ async def image_generations(
|
|||
model = f'{model}:generateContent'
|
||||
data = {'contents': [{'parts': [{'text': form_data.prompt}]}]}
|
||||
|
||||
# Use asyncio.to_thread for the requests.post call
|
||||
r = await asyncio.to_thread(
|
||||
requests.post,
|
||||
session = await get_session()
|
||||
async with session.post(
|
||||
url=f'{request.app.state.config.IMAGES_GEMINI_API_BASE_URL}/models/{model}',
|
||||
json=data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
res = r.json()
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
res = await r.json()
|
||||
|
||||
images = []
|
||||
|
||||
if model.endswith(':predict'):
|
||||
for image in res['predictions']:
|
||||
image_data, content_type = get_image_data(image['bytesBase64Encoded'])
|
||||
image_data, content_type = await get_image_data(image['bytesBase64Encoded'])
|
||||
_, url = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
|
||||
images.append({'url': url})
|
||||
elif model.endswith(':generateContent'):
|
||||
for image in res['candidates']:
|
||||
for part in image['content']['parts']:
|
||||
if part.get('inlineData', {}).get('data'):
|
||||
image_data, content_type = get_image_data(part['inlineData']['data'])
|
||||
image_data, content_type = await get_image_data(part['inlineData']['data'])
|
||||
_, url = await upload_image(
|
||||
request,
|
||||
image_data,
|
||||
|
|
@ -694,7 +708,7 @@ async def image_generations(
|
|||
if request.app.state.config.COMFYUI_API_KEY:
|
||||
headers = {'Authorization': f'Bearer {request.app.state.config.COMFYUI_API_KEY}'}
|
||||
|
||||
image_data, content_type = get_image_data(image['url'], headers)
|
||||
image_data, content_type = await get_image_data(image['url'], headers)
|
||||
_, url = await upload_image(
|
||||
request,
|
||||
image_data,
|
||||
|
|
@ -709,7 +723,7 @@ async def image_generations(
|
|||
or request.app.state.config.IMAGE_GENERATION_ENGINE == ''
|
||||
):
|
||||
if form_data.model:
|
||||
set_image_model(request, form_data.model)
|
||||
await set_image_model(request, form_data.model)
|
||||
|
||||
data = {
|
||||
'prompt': form_data.prompt,
|
||||
|
|
@ -727,21 +741,20 @@ async def image_generations(
|
|||
if request.app.state.config.AUTOMATIC1111_PARAMS:
|
||||
data = {**data, **request.app.state.config.AUTOMATIC1111_PARAMS}
|
||||
|
||||
# Use asyncio.to_thread for the requests.post call
|
||||
r = await asyncio.to_thread(
|
||||
requests.post,
|
||||
session = await get_session()
|
||||
async with session.post(
|
||||
url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/txt2img',
|
||||
json=data,
|
||||
headers={'authorization': get_automatic1111_api_auth(request)},
|
||||
)
|
||||
|
||||
res = r.json()
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
res = await r.json()
|
||||
log.debug(f'res: {res}')
|
||||
|
||||
images = []
|
||||
|
||||
for image in res['images']:
|
||||
image_data, content_type = get_image_data(image)
|
||||
image_data, content_type = await get_image_data(image)
|
||||
_, url = await upload_image(
|
||||
request,
|
||||
image_data,
|
||||
|
|
@ -753,10 +766,8 @@ async def image_generations(
|
|||
return images
|
||||
except Exception as e:
|
||||
error = e
|
||||
if r != None:
|
||||
data = r.json()
|
||||
if 'error' in data:
|
||||
error = data['error']['message']
|
||||
if isinstance(e, aiohttp.ClientResponseError):
|
||||
error = e.message
|
||||
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(error))
|
||||
|
||||
|
||||
|
|
@ -798,11 +809,12 @@ async def image_edits(
|
|||
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()
|
||||
session = await get_session()
|
||||
async with session.get(data, ssl=AIOHTTP_CLIENT_SESSION_SSL) as r:
|
||||
r.raise_for_status()
|
||||
|
||||
image_data = base64.b64encode(r.content).decode('utf-8')
|
||||
return f'data:{r.headers["content-type"]};base64,{image_data}'
|
||||
image_data = base64.b64encode(await r.read()).decode('utf-8')
|
||||
return f'data:{r.headers["content-type"]};base64,{image_data}'
|
||||
|
||||
else:
|
||||
file_id = None
|
||||
|
|
@ -846,7 +858,6 @@ async def image_edits(
|
|||
),
|
||||
)
|
||||
|
||||
r = None
|
||||
try:
|
||||
if request.app.state.config.IMAGE_EDIT_ENGINE == 'openai':
|
||||
headers = {
|
||||
|
|
@ -883,27 +894,40 @@ async def image_edits(
|
|||
if request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION:
|
||||
url_search_params += f'?api-version={request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION}'
|
||||
|
||||
# Use asyncio.to_thread for the requests.post call
|
||||
r = await asyncio.to_thread(
|
||||
requests.post,
|
||||
# Build multipart form data for aiohttp
|
||||
form = aiohttp.FormData()
|
||||
for key, value in data.items():
|
||||
if isinstance(value, dict):
|
||||
form.add_field(key, json.dumps(value))
|
||||
else:
|
||||
form.add_field(key, str(value))
|
||||
for param_name, (filename, file_obj, content_type_val) in files:
|
||||
form.add_field(
|
||||
param_name,
|
||||
file_obj,
|
||||
filename=filename,
|
||||
content_type=content_type_val,
|
||||
)
|
||||
|
||||
session = await get_session()
|
||||
async with session.post(
|
||||
url=f'{request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL}/images/edits{url_search_params}',
|
||||
headers=headers,
|
||||
files=files,
|
||||
data=data,
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
res = r.json()
|
||||
data=form,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
res = await r.json()
|
||||
|
||||
images = []
|
||||
for image in res['data']:
|
||||
if image_url := image.get('url', None):
|
||||
image_data, content_type = get_image_data(
|
||||
image_data, content_type = await get_image_data(
|
||||
image_url,
|
||||
{k: v for k, v in headers.items() if k != 'Content-Type'},
|
||||
)
|
||||
else:
|
||||
image_data, content_type = get_image_data(image['b64_json'])
|
||||
image_data, content_type = await get_image_data(image['b64_json'])
|
||||
|
||||
_, url = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
|
||||
images.append({'url': url})
|
||||
|
|
@ -940,22 +964,21 @@ async def image_edits(
|
|||
]
|
||||
)
|
||||
|
||||
# Use asyncio.to_thread for the requests.post call
|
||||
r = await asyncio.to_thread(
|
||||
requests.post,
|
||||
session = await get_session()
|
||||
async with session.post(
|
||||
url=f'{request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL}/models/{model}',
|
||||
json=data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
res = r.json()
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
res = await r.json()
|
||||
|
||||
images = []
|
||||
for image in res['candidates']:
|
||||
for part in image['content']['parts']:
|
||||
if part.get('inlineData', {}).get('data'):
|
||||
image_data, content_type = get_image_data(part['inlineData']['data'])
|
||||
image_data, content_type = await get_image_data(part['inlineData']['data'])
|
||||
_, url = await upload_image(
|
||||
request,
|
||||
image_data,
|
||||
|
|
@ -1035,7 +1058,7 @@ async def image_edits(
|
|||
if request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY:
|
||||
headers = {'Authorization': f'Bearer {request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY}'}
|
||||
|
||||
image_data, content_type = get_image_data(image_url, headers)
|
||||
image_data, content_type = await get_image_data(image_url, headers)
|
||||
_, url = await upload_image(
|
||||
request,
|
||||
image_data,
|
||||
|
|
@ -1048,13 +1071,7 @@ async def image_edits(
|
|||
return images
|
||||
except Exception as e:
|
||||
error = e
|
||||
if r != None:
|
||||
data = r.text
|
||||
try:
|
||||
data = json.loads(data)
|
||||
if 'error' in data:
|
||||
error = data['error']['message']
|
||||
except Exception:
|
||||
error = data
|
||||
if isinstance(e, aiohttp.ClientResponseError):
|
||||
error = e.message
|
||||
|
||||
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(error))
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from open_webui.models.knowledge import (
|
|||
KnowledgeUserResponse,
|
||||
)
|
||||
from open_webui.models.files import Files, FileModel, FileMetadataResponse
|
||||
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
|
||||
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
|
||||
from open_webui.routers.retrieval import (
|
||||
process_file,
|
||||
ProcessFileForm,
|
||||
|
|
@ -66,7 +66,7 @@ async def embed_knowledge_base_metadata(
|
|||
try:
|
||||
content = f'{name}\n\n{description}' if description else name
|
||||
embedding = await request.app.state.EMBEDDING_FUNCTION(content)
|
||||
VECTOR_DB_CLIENT.upsert(
|
||||
await ASYNC_VECTOR_DB_CLIENT.upsert(
|
||||
collection_name=KNOWLEDGE_BASES_COLLECTION,
|
||||
items=[
|
||||
{
|
||||
|
|
@ -85,10 +85,10 @@ async def embed_knowledge_base_metadata(
|
|||
return False
|
||||
|
||||
|
||||
def remove_knowledge_base_metadata_embedding(knowledge_base_id: str) -> bool:
|
||||
async def remove_knowledge_base_metadata_embedding(knowledge_base_id: str) -> bool:
|
||||
"""Remove knowledge base embedding."""
|
||||
try:
|
||||
VECTOR_DB_CLIENT.delete(
|
||||
await ASYNC_VECTOR_DB_CLIENT.delete(
|
||||
collection_name=KNOWLEDGE_BASES_COLLECTION,
|
||||
ids=[knowledge_base_id],
|
||||
)
|
||||
|
|
@ -310,8 +310,8 @@ async def reindex_knowledge_files(
|
|||
try:
|
||||
files = await Knowledges.get_files_by_id(knowledge_base.id, db=db)
|
||||
try:
|
||||
if VECTOR_DB_CLIENT.has_collection(collection_name=knowledge_base.id):
|
||||
VECTOR_DB_CLIENT.delete_collection(collection_name=knowledge_base.id)
|
||||
if await ASYNC_VECTOR_DB_CLIENT.has_collection(collection_name=knowledge_base.id):
|
||||
await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=knowledge_base.id)
|
||||
except Exception as e:
|
||||
log.error(f'Error deleting collection {knowledge_base.id}: {str(e)}')
|
||||
continue # Skip, don't raise
|
||||
|
|
@ -539,10 +539,12 @@ async def update_knowledge_access_by_id(
|
|||
'sharing.public_knowledge',
|
||||
)
|
||||
|
||||
await AccessGrants.set_access_grants('knowledge', id, form_data.access_grants, db=db)
|
||||
knowledge.access_grants = await AccessGrants.set_access_grants(
|
||||
'knowledge', id, form_data.access_grants, db=db
|
||||
)
|
||||
|
||||
return KnowledgeFilesResponse(
|
||||
**(await Knowledges.get_knowledge_by_id(id=id, db=db)).model_dump(),
|
||||
**knowledge.model_dump(),
|
||||
files=await Knowledges.get_file_metadatas_by_id(id, db=db),
|
||||
)
|
||||
|
||||
|
|
@ -732,7 +734,7 @@ async def update_file_from_knowledge_by_id(
|
|||
)
|
||||
|
||||
# Remove content from the vector database
|
||||
VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'file_id': form_data.file_id})
|
||||
await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'file_id': form_data.file_id})
|
||||
|
||||
# Add content to the vector database
|
||||
try:
|
||||
|
|
@ -814,11 +816,11 @@ async def remove_file_from_knowledge_by_id(
|
|||
|
||||
# Remove content from the vector database
|
||||
try:
|
||||
VECTOR_DB_CLIENT.delete(
|
||||
await ASYNC_VECTOR_DB_CLIENT.delete(
|
||||
collection_name=knowledge.id, filter={'file_id': form_data.file_id}
|
||||
) # Remove by file_id first
|
||||
|
||||
VECTOR_DB_CLIENT.delete(
|
||||
await ASYNC_VECTOR_DB_CLIENT.delete(
|
||||
collection_name=knowledge.id, filter={'hash': file.hash}
|
||||
) # Remove by hash as well in case of duplicates
|
||||
except Exception as e:
|
||||
|
|
@ -826,12 +828,16 @@ async def remove_file_from_knowledge_by_id(
|
|||
log.debug(e)
|
||||
pass
|
||||
|
||||
if delete_file:
|
||||
# Only the file owner or an admin may permanently delete the underlying
|
||||
# file. Collaborators with KB write access can unlink a file from the
|
||||
# knowledge base but must not be able to destroy files they do not own,
|
||||
# as the same file may be referenced by other KBs and chats.
|
||||
if delete_file and (file.user_id == user.id or user.role == 'admin'):
|
||||
try:
|
||||
# Remove the file's collection from vector database
|
||||
file_collection = f'file-{form_data.file_id}'
|
||||
if VECTOR_DB_CLIENT.has_collection(collection_name=file_collection):
|
||||
VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection)
|
||||
if await ASYNC_VECTOR_DB_CLIENT.has_collection(collection_name=file_collection):
|
||||
await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection)
|
||||
except Exception as e:
|
||||
log.debug('This was most likely caused by bypassing embedding processing')
|
||||
log.debug(e)
|
||||
|
|
@ -901,27 +907,18 @@ async def delete_knowledge_by_id(
|
|||
if len(updated_knowledge) != len(knowledge_list):
|
||||
log.info(f'Updating model {model.id} to remove knowledge base {id}')
|
||||
model.meta.knowledge = updated_knowledge
|
||||
# Create a ModelForm for the update
|
||||
model_form = ModelForm(
|
||||
id=model.id,
|
||||
name=model.name,
|
||||
base_model_id=model.base_model_id,
|
||||
meta=model.meta,
|
||||
params=model.params,
|
||||
access_grants=model.access_grants,
|
||||
is_active=model.is_active,
|
||||
)
|
||||
model_form = ModelForm(**model.model_dump())
|
||||
await Models.update_model_by_id(model.id, model_form, db=db)
|
||||
|
||||
# Clean up vector DB
|
||||
try:
|
||||
VECTOR_DB_CLIENT.delete_collection(collection_name=id)
|
||||
await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=id)
|
||||
except Exception as e:
|
||||
log.debug(e)
|
||||
pass
|
||||
|
||||
# Remove knowledge base embedding
|
||||
remove_knowledge_base_metadata_embedding(id)
|
||||
await remove_knowledge_base_metadata_embedding(id)
|
||||
|
||||
result = await Knowledges.delete_knowledge_by_id(id=id, db=db)
|
||||
return result
|
||||
|
|
@ -960,7 +957,7 @@ async def reset_knowledge_by_id(
|
|||
)
|
||||
|
||||
try:
|
||||
VECTOR_DB_CLIENT.delete_collection(collection_name=id)
|
||||
await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=id)
|
||||
except Exception as e:
|
||||
log.debug(e)
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import asyncio
|
|||
from typing import Optional
|
||||
|
||||
from open_webui.models.memories import Memories, MemoryModel
|
||||
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
|
||||
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
|
||||
from open_webui.utils.auth import get_verified_user
|
||||
from open_webui.internal.db import get_async_session
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -85,7 +85,7 @@ async def add_memory(
|
|||
|
||||
vector = await request.app.state.EMBEDDING_FUNCTION(memory.content, user=user)
|
||||
|
||||
VECTOR_DB_CLIENT.upsert(
|
||||
await ASYNC_VECTOR_DB_CLIENT.upsert(
|
||||
collection_name=f'user-memory-{user.id}',
|
||||
items=[
|
||||
{
|
||||
|
|
@ -138,12 +138,44 @@ async def query_memory(
|
|||
|
||||
vector = await request.app.state.EMBEDDING_FUNCTION(form_data.content, user=user)
|
||||
|
||||
results = VECTOR_DB_CLIENT.search(
|
||||
results = await ASYNC_VECTOR_DB_CLIENT.search(
|
||||
collection_name=f'user-memory-{user.id}',
|
||||
vectors=[vector],
|
||||
limit=form_data.k,
|
||||
)
|
||||
|
||||
# Filter results by relevance threshold to avoid returning unrelated
|
||||
# memories. Vector similarity search always returns the top-K nearest
|
||||
# neighbours even when they are completely irrelevant; applying the
|
||||
# same RELEVANCE_THRESHOLD used by RAG ensures only genuinely matching
|
||||
# memories are surfaced (distances are normalised to 0→1, higher is
|
||||
# better).
|
||||
relevance_threshold = getattr(request.app.state.config, 'RELEVANCE_THRESHOLD', 0.0)
|
||||
if results and relevance_threshold > 0.0 and results.distances and results.distances[0]:
|
||||
from open_webui.retrieval.vector.main import SearchResult
|
||||
|
||||
filtered_ids = []
|
||||
filtered_docs = []
|
||||
filtered_metas = []
|
||||
filtered_dists = []
|
||||
|
||||
for idx, score in enumerate(results.distances[0]):
|
||||
if score >= relevance_threshold:
|
||||
if results.ids and results.ids[0]:
|
||||
filtered_ids.append(results.ids[0][idx])
|
||||
if results.documents and results.documents[0]:
|
||||
filtered_docs.append(results.documents[0][idx])
|
||||
if results.metadatas and results.metadatas[0]:
|
||||
filtered_metas.append(results.metadatas[0][idx])
|
||||
filtered_dists.append(score)
|
||||
|
||||
results = SearchResult(
|
||||
ids=[filtered_ids] if filtered_ids else [[]],
|
||||
documents=[filtered_docs] if filtered_docs else [[]],
|
||||
metadatas=[filtered_metas] if filtered_metas else [[]],
|
||||
distances=[filtered_dists] if filtered_dists else [[]],
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
|
|
@ -175,7 +207,7 @@ async def reset_memory_from_vector_db(
|
|||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
VECTOR_DB_CLIENT.delete_collection(f'user-memory-{user.id}')
|
||||
await ASYNC_VECTOR_DB_CLIENT.delete_collection(f'user-memory-{user.id}')
|
||||
|
||||
memories = await Memories.get_memories_by_user_id(user.id)
|
||||
|
||||
|
|
@ -184,7 +216,7 @@ async def reset_memory_from_vector_db(
|
|||
*[request.app.state.EMBEDDING_FUNCTION(memory.content, user=user) for memory in memories]
|
||||
)
|
||||
|
||||
VECTOR_DB_CLIENT.upsert(
|
||||
await ASYNC_VECTOR_DB_CLIENT.upsert(
|
||||
collection_name=f'user-memory-{user.id}',
|
||||
items=[
|
||||
{
|
||||
|
|
@ -230,7 +262,7 @@ async def delete_memory_by_user_id(
|
|||
|
||||
if result:
|
||||
try:
|
||||
VECTOR_DB_CLIENT.delete_collection(f'user-memory-{user.id}')
|
||||
await ASYNC_VECTOR_DB_CLIENT.delete_collection(f'user-memory-{user.id}')
|
||||
except Exception as e:
|
||||
log.error(e)
|
||||
return True
|
||||
|
|
@ -268,12 +300,12 @@ async def update_memory_by_id(
|
|||
|
||||
memory = await Memories.update_memory_by_id_and_user_id(memory_id, user.id, form_data.content)
|
||||
if memory is None:
|
||||
raise HTTPException(status_code=404, detail='Memory not found')
|
||||
raise HTTPException(status_code=404, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
if form_data.content is not None:
|
||||
vector = await request.app.state.EMBEDDING_FUNCTION(memory.content, user=user)
|
||||
|
||||
VECTOR_DB_CLIENT.upsert(
|
||||
await ASYNC_VECTOR_DB_CLIENT.upsert(
|
||||
collection_name=f'user-memory-{user.id}',
|
||||
items=[
|
||||
{
|
||||
|
|
@ -318,7 +350,7 @@ async def delete_memory_by_id(
|
|||
result = await Memories.delete_memory_by_id_and_user_id(memory_id, user.id, db=db)
|
||||
|
||||
if result:
|
||||
VECTOR_DB_CLIENT.delete(collection_name=f'user-memory-{user.id}', ids=[memory_id])
|
||||
await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=f'user-memory-{user.id}', ids=[memory_id])
|
||||
return True
|
||||
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -281,24 +281,71 @@ async def import_models(
|
|||
model.id: model for model in (await Models.get_models_by_ids(model_ids, db=db) if model_ids else [])
|
||||
}
|
||||
|
||||
# Batch-resolve write permissions in one query instead of
|
||||
# per-model has_access calls (N+1 avoidance).
|
||||
existing_model_ids = list(existing_models.keys())
|
||||
if user.role != 'admin' and existing_model_ids:
|
||||
groups = await Groups.get_groups_by_member_id(user.id, db=db)
|
||||
user_group_ids = {group.id for group in groups}
|
||||
writable_model_ids = await AccessGrants.get_accessible_resource_ids(
|
||||
user_id=user.id,
|
||||
resource_type='model',
|
||||
resource_ids=existing_model_ids,
|
||||
permission='write',
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
else:
|
||||
writable_model_ids = set(existing_model_ids)
|
||||
|
||||
for model_data in data:
|
||||
# Here, you can add logic to validate model_data if needed
|
||||
model_id = model_data.get('id')
|
||||
|
||||
if model_id and is_valid_model_id(model_id):
|
||||
existing_model = existing_models.get(model_id)
|
||||
if existing_model:
|
||||
# Enforce ownership/write-access before allowing overwrite
|
||||
if (
|
||||
user.role != 'admin'
|
||||
and existing_model.user_id != user.id
|
||||
and model_id not in writable_model_ids
|
||||
):
|
||||
log.warning(
|
||||
'import_models: user %s skipped model %s (no write access)',
|
||||
user.id,
|
||||
model_id,
|
||||
)
|
||||
continue
|
||||
|
||||
# Update existing model
|
||||
model_data['meta'] = model_data.get('meta', {})
|
||||
model_data['params'] = model_data.get('params', {})
|
||||
|
||||
updated_model = ModelForm(**{**existing_model.model_dump(), **model_data})
|
||||
# Only filter access_grants when explicitly provided
|
||||
# in the payload to avoid altering existing ACLs on
|
||||
# metadata-only imports.
|
||||
if 'access_grants' in model_data:
|
||||
updated_model.access_grants = await filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
user.id,
|
||||
user.role,
|
||||
updated_model.access_grants,
|
||||
'sharing.public_models',
|
||||
)
|
||||
await Models.update_model_by_id(model_id, updated_model, db=db)
|
||||
else:
|
||||
# Insert new model
|
||||
model_data['meta'] = model_data.get('meta', {})
|
||||
model_data['params'] = model_data.get('params', {})
|
||||
new_model = ModelForm(**model_data)
|
||||
new_model.access_grants = await filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
user.id,
|
||||
user.role,
|
||||
new_model.access_grants,
|
||||
'sharing.public_models',
|
||||
)
|
||||
await Models.insert_new_model(user_id=user.id, form_data=new_model, db=db)
|
||||
return True
|
||||
else:
|
||||
|
|
@ -384,8 +431,12 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user), db: AsyncSes
|
|||
|
||||
|
||||
@router.get('/model/profile/image')
|
||||
async def get_model_profile_image(id: str, user=Depends(get_verified_user)):
|
||||
model = await Models.get_model_by_id(id)
|
||||
async def get_model_profile_image(
|
||||
id: str,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
model = await Models.get_model_by_id(id, db=db)
|
||||
|
||||
if model:
|
||||
etag = f'"{model.updated_at}"' if model.updated_at else None
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ class NoteItemResponse(BaseModel):
|
|||
id: str
|
||||
title: str
|
||||
data: Optional[dict]
|
||||
is_pinned: Optional[bool] = False
|
||||
updated_at: int
|
||||
created_at: int
|
||||
user: Optional[UserResponse] = None
|
||||
|
|
@ -104,6 +105,45 @@ async def get_notes(
|
|||
]
|
||||
|
||||
|
||||
############################
|
||||
# GetPinnedNotes
|
||||
############################
|
||||
|
||||
|
||||
@router.get('/pinned', response_model=list[NoteItemResponse])
|
||||
async def get_pinned_notes(
|
||||
request: Request,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
notes = await Notes.get_pinned_notes_by_user_id(user.id, 'read', db=db)
|
||||
if not notes:
|
||||
return []
|
||||
|
||||
user_ids = list(set(note.user_id for note in notes))
|
||||
users = {user.id: user for user in await Users.get_users_by_user_ids(user_ids, db=db)}
|
||||
|
||||
return [
|
||||
NoteUserResponse(
|
||||
**{
|
||||
**note.model_dump(),
|
||||
'data': _truncate_note_data(note.data),
|
||||
'user': UserResponse(**users[note.user_id].model_dump()),
|
||||
}
|
||||
)
|
||||
for note in notes
|
||||
if note.user_id in users
|
||||
]
|
||||
|
||||
|
||||
@router.get('/search', response_model=NoteListResponse)
|
||||
async def search_notes(
|
||||
request: Request,
|
||||
|
|
@ -364,6 +404,46 @@ async def update_note_access_by_id(
|
|||
return await Notes.get_note_by_id(id, db=db)
|
||||
|
||||
|
||||
############################
|
||||
# PinNoteById
|
||||
############################
|
||||
|
||||
|
||||
@router.post('/{id}/pin', response_model=Optional[NoteModel])
|
||||
async def pin_note_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
note = await Notes.get_note_by_id(id, db=db)
|
||||
if not note:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
if user.role != 'admin' and (
|
||||
user.id != note.user_id
|
||||
and not await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='note',
|
||||
resource_id=note.id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
note = await Notes.toggle_note_pinned_by_id(id, db=db)
|
||||
return note
|
||||
|
||||
|
||||
############################
|
||||
# DeleteNoteById
|
||||
############################
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ async def send_request(
|
|||
log.error(f'Failed to parse error response: {e}')
|
||||
raise HTTPException(
|
||||
status_code=r.status,
|
||||
detail='Open WebUI: Server Connection Error',
|
||||
detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR,
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
|
|
@ -184,7 +184,7 @@ async def send_request(
|
|||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=r.status if r else 500,
|
||||
detail=f'Ollama: {e}' if str(e) else 'Open WebUI: Server Connection Error',
|
||||
detail=f'Ollama: {e}' if str(e) else ERROR_MESSAGES.SERVER_CONNECTION_ERROR,
|
||||
)
|
||||
finally:
|
||||
if not streaming:
|
||||
|
|
@ -251,7 +251,7 @@ async def verify_connection(form_data: ConnectionVerificationForm, user=Depends(
|
|||
return data
|
||||
except aiohttp.ClientError as e:
|
||||
log.exception(f'Client error: {str(e)}')
|
||||
raise HTTPException(status_code=500, detail='Open WebUI: Server Connection Error')
|
||||
raise HTTPException(status_code=500, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR)
|
||||
except Exception as e:
|
||||
log.exception(f'Unexpected error: {e}')
|
||||
error_detail = f'Unexpected error: {str(e)}'
|
||||
|
|
@ -428,7 +428,7 @@ async def get_filtered_models(models, user, db=None):
|
|||
@router.get('/api/tags/{url_idx}')
|
||||
async def get_ollama_tags(request: Request, url_idx: Optional[int] = None, user=Depends(get_verified_user)):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail='Ollama API is disabled')
|
||||
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
|
||||
|
||||
models = []
|
||||
|
||||
|
|
@ -621,7 +621,7 @@ async def pull_model(
|
|||
user=Depends(get_admin_user),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail='Ollama API is disabled')
|
||||
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
|
||||
|
||||
form_data = form_data.model_dump(exclude_none=True)
|
||||
form_data['model'] = form_data.get('model', form_data.get('name'))
|
||||
|
|
@ -656,7 +656,7 @@ async def push_model(
|
|||
user=Depends(get_admin_user),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail='Ollama API is disabled')
|
||||
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
|
||||
|
||||
if url_idx is None:
|
||||
await get_all_models(request, user=user)
|
||||
|
|
@ -699,7 +699,7 @@ async def create_model(
|
|||
user=Depends(get_admin_user),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail='Ollama API is disabled')
|
||||
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
|
||||
|
||||
log.debug(f'form_data: {form_data}')
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||
|
|
@ -727,7 +727,7 @@ async def copy_model(
|
|||
user=Depends(get_admin_user),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail='Ollama API is disabled')
|
||||
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
|
||||
|
||||
if url_idx is None:
|
||||
await get_all_models(request, user=user)
|
||||
|
|
@ -762,7 +762,7 @@ async def delete_model(
|
|||
user=Depends(get_admin_user),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail='Ollama API is disabled')
|
||||
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
|
||||
|
||||
form_data = form_data.model_dump(exclude_none=True)
|
||||
form_data['model'] = form_data.get('model', form_data.get('name'))
|
||||
|
|
@ -797,7 +797,7 @@ async def delete_model(
|
|||
@router.post('/api/show')
|
||||
async def show_model_info(request: Request, form_data: ModelNameForm, user=Depends(get_verified_user)):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail='Ollama API is disabled')
|
||||
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
|
||||
|
||||
form_data = form_data.model_dump(exclude_none=True)
|
||||
form_data['model'] = form_data.get('model', form_data.get('name'))
|
||||
|
|
@ -850,7 +850,7 @@ async def embed(
|
|||
user=Depends(get_verified_user),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail='Ollama API is disabled')
|
||||
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
|
||||
|
||||
log.info(f'generate_ollama_batch_embeddings {form_data}')
|
||||
|
||||
|
|
@ -909,7 +909,7 @@ async def embeddings(
|
|||
user=Depends(get_verified_user),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail='Ollama API is disabled')
|
||||
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
|
||||
|
||||
log.info(f'generate_ollama_embeddings {form_data}')
|
||||
|
||||
|
|
@ -976,7 +976,7 @@ async def generate_completion(
|
|||
user=Depends(get_verified_user),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail='Ollama API is disabled')
|
||||
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
|
||||
|
||||
# Enforce per-model access control
|
||||
await check_model_access(user, await Models.get_model_by_id(form_data.model), BYPASS_MODEL_ACCESS_CONTROL)
|
||||
|
|
@ -1020,6 +1020,8 @@ class ChatMessage(BaseModel):
|
|||
tool_calls: Optional[list[dict]] = None
|
||||
images: Optional[list[str]] = None
|
||||
|
||||
model_config = ConfigDict(extra='allow')
|
||||
|
||||
@validator('content', pre=True)
|
||||
@classmethod
|
||||
def check_at_least_one_field(cls, field_value, values, **kwargs):
|
||||
|
|
@ -1067,7 +1069,7 @@ async def generate_chat_completion(
|
|||
bypass_system_prompt: bool = False,
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail='Ollama API is disabled')
|
||||
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
|
||||
|
||||
# NOTE: We intentionally do NOT use Depends(get_async_session) here.
|
||||
# Database operations (get_model_by_id, AccessGrants.has_access) manage their own short-lived sessions.
|
||||
|
|
@ -1313,7 +1315,7 @@ async def generate_anthropic_messages(
|
|||
See https://docs.ollama.com/api/anthropic-compatibility
|
||||
"""
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail='Ollama API is disabled')
|
||||
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
|
||||
|
||||
payload = {**form_data}
|
||||
model_id = payload.get('model', '')
|
||||
|
|
@ -1371,7 +1373,7 @@ async def generate_responses(
|
|||
See https://ollama.com/blog/responses-api
|
||||
"""
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail='Ollama API is disabled')
|
||||
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
|
||||
|
||||
payload = form_data.model_dump()
|
||||
model_id = form_data.model
|
||||
|
|
@ -1381,29 +1383,9 @@ async def generate_responses(
|
|||
if model_info.base_model_id:
|
||||
payload['model'] = model_info.base_model_id
|
||||
|
||||
# Check if user has access to the model
|
||||
if user.role == 'user':
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
|
||||
if not (
|
||||
user.id == model_info.user_id
|
||||
or await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='model',
|
||||
resource_id=model_info.id,
|
||||
permission='read',
|
||||
user_group_ids=user_group_ids,
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail='Model not found',
|
||||
)
|
||||
await check_model_access(user, model_info)
|
||||
else:
|
||||
if user.role != 'admin':
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail='Model not found',
|
||||
)
|
||||
await check_model_access(user, None)
|
||||
|
||||
url, url_idx = await get_ollama_url(request, payload['model'], url_idx)
|
||||
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from urllib.parse import quote, urlparse
|
|||
|
||||
import aiohttp
|
||||
from aiocache import cached
|
||||
import requests
|
||||
|
||||
|
||||
from azure.identity import DefaultAzureCredential, get_bearer_token_provider
|
||||
|
||||
|
|
@ -312,19 +312,20 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
|||
|
||||
r = None
|
||||
try:
|
||||
r = requests.post(
|
||||
session = await get_session()
|
||||
r = await session.post(
|
||||
url=f'{url}/audio/speech',
|
||||
data=body,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
stream=True,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
|
||||
# Save the streaming content to a file
|
||||
with open(file_path, 'wb') as f:
|
||||
for chunk in r.iter_content(chunk_size=8192):
|
||||
async for chunk in r.content.iter_chunked(8192):
|
||||
f.write(chunk)
|
||||
|
||||
with open(file_body_path, 'w') as f:
|
||||
|
|
@ -339,14 +340,14 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
|||
detail = None
|
||||
if r is not None:
|
||||
try:
|
||||
res = r.json()
|
||||
res = await r.json()
|
||||
if 'error' in res:
|
||||
detail = f'External: {res["error"]}'
|
||||
except Exception:
|
||||
detail = f'External: {e}'
|
||||
|
||||
raise HTTPException(
|
||||
status_code=r.status_code if r else 500,
|
||||
status_code=r.status if r else 500,
|
||||
detail=detail if detail else 'Open WebUI: Server Connection Error',
|
||||
)
|
||||
|
||||
|
|
@ -691,7 +692,7 @@ async def verify_connection(
|
|||
elif is_anthropic_url(url):
|
||||
result = await get_anthropic_models(url, key)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=500, detail='Failed to connect to Anthropic API')
|
||||
raise HTTPException(status_code=500, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR)
|
||||
if 'error' in result:
|
||||
raise HTTPException(status_code=500, detail=result['error'])
|
||||
return result
|
||||
|
|
@ -718,10 +719,10 @@ async def verify_connection(
|
|||
except aiohttp.ClientError as e:
|
||||
# ClientError covers all aiohttp requests issues
|
||||
log.exception(f'Client error: {str(e)}')
|
||||
raise HTTPException(status_code=500, detail='Open WebUI: Server Connection Error')
|
||||
raise HTTPException(status_code=500, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR)
|
||||
except Exception as e:
|
||||
log.exception(f'Unexpected error: {e}')
|
||||
raise HTTPException(status_code=500, detail='Open WebUI: Server Connection Error')
|
||||
raise HTTPException(status_code=500, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR)
|
||||
|
||||
|
||||
def get_azure_allowed_params(api_version: str) -> set[str]:
|
||||
|
|
@ -1083,7 +1084,7 @@ async def generate_chat_completion(
|
|||
else:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail='Model not found',
|
||||
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(),
|
||||
)
|
||||
|
||||
# Get the API config for the model
|
||||
|
|
@ -1243,7 +1244,7 @@ async def generate_chat_completion(
|
|||
|
||||
raise HTTPException(
|
||||
status_code=r.status if r else 500,
|
||||
detail='Open WebUI: Server Connection Error',
|
||||
detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR,
|
||||
)
|
||||
finally:
|
||||
if not streaming:
|
||||
|
|
@ -1321,7 +1322,7 @@ async def embeddings(request: Request, form_data: dict, user):
|
|||
log.exception(e)
|
||||
raise HTTPException(
|
||||
status_code=r.status if r else 500,
|
||||
detail='Open WebUI: Server Connection Error',
|
||||
detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR,
|
||||
)
|
||||
finally:
|
||||
if not streaming:
|
||||
|
|
@ -1445,7 +1446,7 @@ async def responses(
|
|||
log.exception(e)
|
||||
raise HTTPException(
|
||||
status_code=r.status if r else 500,
|
||||
detail='Open WebUI: Server Connection Error',
|
||||
detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR,
|
||||
)
|
||||
finally:
|
||||
if not streaming:
|
||||
|
|
|
|||
|
|
@ -324,7 +324,7 @@ async def update_prompt_by_id(
|
|||
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",
|
||||
detail=ERROR_MESSAGES.COMMAND_TAKEN,
|
||||
)
|
||||
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
|
|
@ -389,7 +389,7 @@ async def update_prompt_metadata(
|
|||
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",
|
||||
detail=ERROR_MESSAGES.COMMAND_TAKEN,
|
||||
)
|
||||
|
||||
updated_prompt = await Prompts.update_prompt_metadata(
|
||||
|
|
@ -751,7 +751,7 @@ async def get_prompt_diff(
|
|||
if not diff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='One or both history entries not found',
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
return diff
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
|
||||
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
|
||||
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
|
||||
|
||||
# Document loaders
|
||||
from open_webui.retrieval.loaders.main import Loader
|
||||
|
|
@ -81,6 +82,7 @@ from open_webui.retrieval.web.yandex import search_yandex
|
|||
from open_webui.retrieval.web.ydc import search_youcom
|
||||
|
||||
from open_webui.retrieval.utils import (
|
||||
filter_accessible_collections,
|
||||
get_content_from_url,
|
||||
get_embedding_function,
|
||||
get_reranking_function,
|
||||
|
|
@ -151,7 +153,7 @@ def get_ef(
|
|||
model_kwargs=SENTENCE_TRANSFORMERS_MODEL_KWARGS,
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f'Error loading SentenceTransformer: {e}')
|
||||
log.error(f'Error loading SentenceTransformer: {e}')
|
||||
|
||||
return ef
|
||||
|
||||
|
|
@ -486,6 +488,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
|
|||
# Reranking settings
|
||||
'RAG_RERANKING_MODEL': request.app.state.config.RAG_RERANKING_MODEL,
|
||||
'RAG_RERANKING_ENGINE': request.app.state.config.RAG_RERANKING_ENGINE,
|
||||
'RAG_RERANKING_BATCH_SIZE': request.app.state.config.RAG_RERANKING_BATCH_SIZE,
|
||||
'RAG_EXTERNAL_RERANKER_URL': request.app.state.config.RAG_EXTERNAL_RERANKER_URL,
|
||||
'RAG_EXTERNAL_RERANKER_API_KEY': request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY,
|
||||
'RAG_EXTERNAL_RERANKER_TIMEOUT': request.app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT,
|
||||
|
|
@ -693,6 +696,7 @@ class ConfigForm(BaseModel):
|
|||
# Reranking settings
|
||||
RAG_RERANKING_MODEL: Optional[str] = None
|
||||
RAG_RERANKING_ENGINE: Optional[str] = None
|
||||
RAG_RERANKING_BATCH_SIZE: Optional[int] = None
|
||||
RAG_EXTERNAL_RERANKER_URL: Optional[str] = None
|
||||
RAG_EXTERNAL_RERANKER_API_KEY: Optional[str] = None
|
||||
RAG_EXTERNAL_RERANKER_TIMEOUT: Optional[str] = None
|
||||
|
|
@ -939,6 +943,12 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
|
|||
else request.app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT
|
||||
)
|
||||
|
||||
request.app.state.config.RAG_RERANKING_BATCH_SIZE = (
|
||||
form_data.RAG_RERANKING_BATCH_SIZE
|
||||
if form_data.RAG_RERANKING_BATCH_SIZE is not None
|
||||
else request.app.state.config.RAG_RERANKING_BATCH_SIZE
|
||||
)
|
||||
|
||||
log.info(
|
||||
f'Updating reranking model: {request.app.state.config.RAG_RERANKING_MODEL} to {form_data.RAG_RERANKING_MODEL}'
|
||||
)
|
||||
|
|
@ -966,6 +976,7 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
|
|||
request.app.state.config.RAG_RERANKING_ENGINE,
|
||||
request.app.state.config.RAG_RERANKING_MODEL,
|
||||
request.app.state.rf,
|
||||
reranking_batch_size=request.app.state.config.RAG_RERANKING_BATCH_SIZE,
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f'Error loading reranking model: {e}')
|
||||
|
|
@ -1556,7 +1567,7 @@ async def process_file(
|
|||
|
||||
try:
|
||||
# /files/{file_id}/data/content/update
|
||||
VECTOR_DB_CLIENT.delete_collection(collection_name=f'file-{file.id}')
|
||||
await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=f'file-{file.id}')
|
||||
except Exception:
|
||||
# Audio file upload pipeline
|
||||
pass
|
||||
|
|
@ -1579,7 +1590,9 @@ async def process_file(
|
|||
# Check if the file has already been processed and save the content
|
||||
# Usage: /knowledge/{id}/file/add, /knowledge/{id}/file/update
|
||||
|
||||
result = VECTOR_DB_CLIENT.query(collection_name=f'file-{file.id}', filter={'file_id': file.id})
|
||||
result = await ASYNC_VECTOR_DB_CLIENT.query(
|
||||
collection_name=f'file-{file.id}', filter={'file_id': file.id}
|
||||
)
|
||||
|
||||
if result is not None and len(result.ids[0]) > 0:
|
||||
docs = [
|
||||
|
|
@ -1609,7 +1622,7 @@ async def process_file(
|
|||
# Usage: /files/
|
||||
file_path = file.path
|
||||
if file_path:
|
||||
file_path = Storage.get_file(file_path)
|
||||
file_path = await asyncio.to_thread(Storage.get_file, file_path)
|
||||
loader = Loader(
|
||||
engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE,
|
||||
user=user,
|
||||
|
|
@ -1643,7 +1656,7 @@ async def process_file(
|
|||
MINERU_API_TIMEOUT=request.app.state.config.MINERU_API_TIMEOUT,
|
||||
MINERU_PARAMS=request.app.state.config.MINERU_PARAMS,
|
||||
)
|
||||
docs = loader.load(file.filename, file.meta.get('content_type'), file_path)
|
||||
docs = await loader.aload(file.filename, file.meta.get('content_type'), file_path)
|
||||
|
||||
docs = [
|
||||
Document(
|
||||
|
|
@ -1698,7 +1711,12 @@ async def process_file(
|
|||
|
||||
# External embedding API takes time (5-60s+).
|
||||
# Subsequent updates use fresh async sessions.
|
||||
result = save_docs_to_vector_db(
|
||||
# NOTE: save_docs_to_vector_db is a sync function that
|
||||
# calls asyncio.run_coroutine_threadsafe(..., main_loop).result()
|
||||
# which blocks the calling thread. We MUST run it in a
|
||||
# worker thread to avoid deadlocking the event loop.
|
||||
result = await run_in_threadpool(
|
||||
save_docs_to_vector_db,
|
||||
request,
|
||||
docs=docs,
|
||||
collection_name=collection_name,
|
||||
|
|
@ -1783,6 +1801,8 @@ async def process_text(
|
|||
collection_name = form_data.collection_name
|
||||
if collection_name is None:
|
||||
collection_name = calculate_sha256_string(form_data.content)
|
||||
else:
|
||||
await _validate_collection_access([collection_name], user, access_type='write')
|
||||
|
||||
docs = [
|
||||
Document(
|
||||
|
|
@ -1824,6 +1844,8 @@ async def process_web(
|
|||
collection_name = form_data.collection_name
|
||||
if not collection_name:
|
||||
collection_name = calculate_sha256_string(form_data.url)[:63]
|
||||
else:
|
||||
await _validate_collection_access([collection_name], user, access_type='write')
|
||||
|
||||
if not request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL:
|
||||
await run_in_threadpool(
|
||||
|
|
@ -2327,32 +2349,20 @@ async def process_web_search(request: Request, form_data: SearchForm, user=Depen
|
|||
)
|
||||
|
||||
|
||||
async def _validate_collection_access(collection_names: list[str], user) -> None:
|
||||
async def _validate_collection_access(collection_names: list[str], user, access_type: str = 'read') -> None:
|
||||
"""
|
||||
Prevent users from querying collections they don't own.
|
||||
Enforces ownership on user-memory-* and file-* collections.
|
||||
Admins bypass this check.
|
||||
Raise 403 if the user lacks access to any of the requested collections.
|
||||
Delegates to the shared filter_accessible_collections utility so the
|
||||
access rules stay in one place.
|
||||
"""
|
||||
if user.role == 'admin':
|
||||
return
|
||||
|
||||
for name in collection_names:
|
||||
if name.startswith('user-memory-') and name != f'user-memory-{user.id}':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
elif name.startswith('file-'):
|
||||
file_id = name[len('file-') :]
|
||||
if not await has_access_to_file(
|
||||
file_id=file_id,
|
||||
access_type='read',
|
||||
user=user,
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
requested = set(collection_names)
|
||||
allowed = await filter_accessible_collections(requested, user, access_type=access_type)
|
||||
denied = requested - allowed
|
||||
if denied:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
|
||||
class QueryDocForm(BaseModel):
|
||||
|
|
@ -2375,7 +2385,7 @@ async def query_doc_handler(
|
|||
try:
|
||||
if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH and (form_data.hybrid is None or form_data.hybrid):
|
||||
collection_results = {}
|
||||
collection_results[form_data.collection_name] = VECTOR_DB_CLIENT.get(
|
||||
collection_results[form_data.collection_name] = await ASYNC_VECTOR_DB_CLIENT.get(
|
||||
collection_name=form_data.collection_name
|
||||
)
|
||||
return await query_doc_with_hybrid_search(
|
||||
|
|
@ -2404,7 +2414,10 @@ async def query_doc_handler(
|
|||
query_embedding = await request.app.state.EMBEDDING_FUNCTION(
|
||||
form_data.query, prefix=RAG_EMBEDDING_QUERY_PREFIX, user=user
|
||||
)
|
||||
return query_doc(
|
||||
# query_doc wraps a blocking VECTOR_DB_CLIENT.search call;
|
||||
# offload so the request's event loop stays responsive.
|
||||
return await asyncio.to_thread(
|
||||
query_doc,
|
||||
collection_name=form_data.collection_name,
|
||||
query_embedding=query_embedding,
|
||||
k=form_data.k if form_data.k else request.app.state.config.TOP_K,
|
||||
|
|
@ -2502,7 +2515,7 @@ async def delete_entries_from_collection(
|
|||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
if VECTOR_DB_CLIENT.has_collection(collection_name=form_data.collection_name):
|
||||
if await ASYNC_VECTOR_DB_CLIENT.has_collection(collection_name=form_data.collection_name):
|
||||
file = await Files.get_file_by_id(form_data.file_id, db=db)
|
||||
if not file:
|
||||
raise HTTPException(
|
||||
|
|
@ -2511,13 +2524,37 @@ async def delete_entries_from_collection(
|
|||
)
|
||||
hash = file.hash
|
||||
|
||||
VECTOR_DB_CLIENT.delete(
|
||||
# Refuse to issue a `filter={'hash': None}` query — the
|
||||
# match semantics of a null filter value are
|
||||
# backend-dependent (some backends ignore the key, some
|
||||
# match every row whose metadata lacks `hash`) and risk
|
||||
# deleting unrelated entries. Files without a hash are
|
||||
# typically unprocessed / failed / legacy records that
|
||||
# can't be targeted by hash anyway.
|
||||
if hash is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT('File has no hash; cannot delete vector entries by hash.'),
|
||||
)
|
||||
|
||||
# Pre-existing bug: this used `metadata=` which is not a
|
||||
# parameter on `VectorDBBase.delete` nor on any backend
|
||||
# implementation, so the call always raised TypeError that
|
||||
# was silently swallowed by the surrounding `except
|
||||
# Exception` and the endpoint reported `{'status': False}`
|
||||
# for every request. Use `filter` to actually do what the
|
||||
# endpoint name promises.
|
||||
await ASYNC_VECTOR_DB_CLIENT.delete(
|
||||
collection_name=form_data.collection_name,
|
||||
metadata={'hash': hash},
|
||||
filter={'hash': hash},
|
||||
)
|
||||
return {'status': True}
|
||||
else:
|
||||
return {'status': False}
|
||||
except HTTPException:
|
||||
# Caller-meaningful errors (404/400 above) must not be
|
||||
# swallowed and re-shaped as `{'status': False}`.
|
||||
raise
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
return {'status': False}
|
||||
|
|
@ -2525,7 +2562,7 @@ async def delete_entries_from_collection(
|
|||
|
||||
@router.post('/reset/db')
|
||||
async def reset_vector_db(user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)):
|
||||
VECTOR_DB_CLIENT.reset()
|
||||
await ASYNC_VECTOR_DB_CLIENT.reset()
|
||||
await Knowledges.delete_all_knowledge(db=db)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ from open_webui.utils.task import (
|
|||
moa_response_generation_template,
|
||||
)
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.constants import TASKS
|
||||
from open_webui.constants import ERROR_MESSAGES, TASKS
|
||||
|
||||
from open_webui.routers.pipelines import process_pipeline_inlet_filter
|
||||
|
||||
|
|
@ -168,7 +168,7 @@ async def generate_title(request: Request, form_data: dict, user=Depends(get_ver
|
|||
if model_id not in models:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Model not found',
|
||||
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(),
|
||||
)
|
||||
|
||||
# Check if the user has a custom task model
|
||||
|
|
@ -245,7 +245,7 @@ async def generate_follow_ups(request: Request, form_data: dict, user=Depends(ge
|
|||
if model_id not in models:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Model not found',
|
||||
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(),
|
||||
)
|
||||
|
||||
# Check if the user has a custom task model
|
||||
|
|
@ -313,7 +313,7 @@ async def generate_chat_tags(request: Request, form_data: dict, user=Depends(get
|
|||
if model_id not in models:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Model not found',
|
||||
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(),
|
||||
)
|
||||
|
||||
# Check if the user has a custom task model
|
||||
|
|
@ -375,7 +375,7 @@ async def generate_image_prompt(request: Request, form_data: dict, user=Depends(
|
|||
if model_id not in models:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Model not found',
|
||||
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(),
|
||||
)
|
||||
|
||||
# Check if the user has a custom task model
|
||||
|
|
@ -431,13 +431,13 @@ async def generate_queries(request: Request, form_data: dict, user=Depends(get_v
|
|||
if not request.app.state.config.ENABLE_SEARCH_QUERY_GENERATION:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Search query generation is disabled',
|
||||
detail=ERROR_MESSAGES.FEATURE_DISABLED('Search query generation'),
|
||||
)
|
||||
elif type == 'retrieval':
|
||||
if not request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Query generation is disabled',
|
||||
detail=ERROR_MESSAGES.FEATURE_DISABLED('Query generation'),
|
||||
)
|
||||
|
||||
if getattr(request.state, 'cached_queries', None):
|
||||
|
|
@ -455,7 +455,7 @@ async def generate_queries(request: Request, form_data: dict, user=Depends(get_v
|
|||
if model_id not in models:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Model not found',
|
||||
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(),
|
||||
)
|
||||
|
||||
# Check if the user has a custom task model
|
||||
|
|
@ -508,7 +508,7 @@ async def generate_autocompletion(request: Request, form_data: dict, user=Depend
|
|||
if not request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Autocompletion generation is disabled',
|
||||
detail=ERROR_MESSAGES.FEATURE_DISABLED('Autocompletion generation'),
|
||||
)
|
||||
|
||||
type = form_data.get('type')
|
||||
|
|
@ -519,7 +519,7 @@ async def generate_autocompletion(request: Request, form_data: dict, user=Depend
|
|||
if len(prompt) > request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Input prompt exceeds maximum length of {request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH}',
|
||||
detail=ERROR_MESSAGES.INPUT_TOO_LONG(request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH),
|
||||
)
|
||||
|
||||
if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'):
|
||||
|
|
@ -533,7 +533,7 @@ async def generate_autocompletion(request: Request, form_data: dict, user=Depend
|
|||
if model_id not in models:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Model not found',
|
||||
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(),
|
||||
)
|
||||
|
||||
# Check if the user has a custom task model
|
||||
|
|
@ -595,7 +595,7 @@ async def generate_emoji(request: Request, form_data: dict, user=Depends(get_ver
|
|||
if model_id not in models:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Model not found',
|
||||
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(),
|
||||
)
|
||||
|
||||
# Check if the user has a custom task model
|
||||
|
|
@ -661,7 +661,7 @@ async def generate_moa_response(request: Request, form_data: dict, user=Depends(
|
|||
if model_id not in models:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Model not found',
|
||||
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(),
|
||||
)
|
||||
|
||||
template = DEFAULT_MOA_GENERATION_PROMPT_TEMPLATE
|
||||
|
|
|
|||
|
|
@ -285,7 +285,7 @@ async def load_tool_from_url(request: Request, form_data: LoadUrlForm, user=Depe
|
|||
'content': data,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f'Error importing tool: {e}')
|
||||
raise HTTPException(status_code=500, detail=ERROR_MESSAGES.DEFAULT(e))
|
||||
|
||||
|
||||
############################
|
||||
|
|
|
|||
|
|
@ -276,14 +276,8 @@ async def update_default_user_permissions(request: Request, form_data: UserPermi
|
|||
async def get_user_settings_by_session_user(
|
||||
user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
user = await Users.get_user_by_id(user.id, db=db)
|
||||
if user:
|
||||
return user.settings
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.USER_NOT_FOUND,
|
||||
)
|
||||
# user already fetched by get_verified_user — no need to refetch
|
||||
return user.settings
|
||||
|
||||
|
||||
############################
|
||||
|
|
@ -339,14 +333,8 @@ async def get_user_status_by_session_user(
|
|||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
|
||||
)
|
||||
user = await Users.get_user_by_id(user.id, db=db)
|
||||
if user:
|
||||
return user
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.USER_NOT_FOUND,
|
||||
)
|
||||
# user already fetched by get_verified_user — no need to refetch
|
||||
return user
|
||||
|
||||
|
||||
############################
|
||||
|
|
@ -366,15 +354,14 @@ async def update_user_status_by_session_user(
|
|||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
|
||||
)
|
||||
user = await Users.get_user_by_id(user.id, db=db)
|
||||
if user:
|
||||
user = await Users.update_user_status_by_id(user.id, form_data, db=db)
|
||||
return user
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.USER_NOT_FOUND,
|
||||
)
|
||||
# user already fetched by get_verified_user — no need to refetch
|
||||
updated = await Users.update_user_status_by_id(user.id, form_data, db=db)
|
||||
if updated:
|
||||
return updated
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.USER_NOT_FOUND,
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
|
|
@ -384,14 +371,8 @@ async def update_user_status_by_session_user(
|
|||
|
||||
@router.get('/user/info', response_model=Optional[dict])
|
||||
async def get_user_info_by_session_user(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
||||
user = await Users.get_user_by_id(user.id, db=db)
|
||||
if user:
|
||||
return user.info
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.USER_NOT_FOUND,
|
||||
)
|
||||
# user already fetched by get_verified_user — no need to refetch
|
||||
return user.info
|
||||
|
||||
|
||||
############################
|
||||
|
|
@ -403,19 +384,13 @@ async def get_user_info_by_session_user(user=Depends(get_verified_user), db: Asy
|
|||
async def update_user_info_by_session_user(
|
||||
form_data: dict, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
user = await Users.get_user_by_id(user.id, db=db)
|
||||
if user:
|
||||
if user.info is None:
|
||||
user.info = {}
|
||||
|
||||
user = await Users.update_user_by_id(user.id, {'info': {**user.info, **form_data}}, db=db)
|
||||
if user:
|
||||
return user.info
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.USER_NOT_FOUND,
|
||||
)
|
||||
# Merges against the auth-time snapshot of user.info. The previous pre-merge
|
||||
# refetch only narrowed (did not eliminate) the lost-update window on concurrent
|
||||
# same-user writes; real safety needs row locking or a version column.
|
||||
existing_info = user.info or {}
|
||||
updated = await Users.update_user_by_id(user.id, {'info': {**existing_info, **form_data}}, db=db)
|
||||
if updated:
|
||||
return updated.info
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ async def execute_code(request: Request, form_data: CodeForm, user=Depends(get_v
|
|||
if not request.app.state.config.ENABLE_CODE_EXECUTION:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail='Code execution is disabled',
|
||||
detail=ERROR_MESSAGES.FEATURE_DISABLED('Code execution'),
|
||||
)
|
||||
|
||||
if request.app.state.config.CODE_EXECUTION_ENGINE == 'jupyter':
|
||||
|
|
@ -69,7 +69,7 @@ async def execute_code(request: Request, form_data: CodeForm, user=Depends(get_v
|
|||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail='Code execution engine not supported',
|
||||
detail=ERROR_MESSAGES.DEFAULT('Code execution engine not supported'),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -697,6 +697,31 @@ async def yjs_document_update(sid, data):
|
|||
log.warning(f'Session {sid} not in room {room}. Rejecting update.')
|
||||
return
|
||||
|
||||
# Verify write permission — room membership only proves read access
|
||||
user = SESSION_POOL.get(sid)
|
||||
if not user:
|
||||
return
|
||||
|
||||
if document_id.startswith('note:'):
|
||||
note_id = document_id.split(':')[1]
|
||||
note = await Notes.get_note_by_id(note_id)
|
||||
if not note:
|
||||
log.error(f'Note {note_id} not found')
|
||||
return
|
||||
|
||||
if (
|
||||
user.get('role') != 'admin'
|
||||
and user.get('id') != note.user_id
|
||||
and not await AccessGrants.has_access(
|
||||
user_id=user.get('id'),
|
||||
resource_type='note',
|
||||
resource_id=note.id,
|
||||
permission='write',
|
||||
)
|
||||
):
|
||||
log.warning(f'User {user.get("id")} does not have write access to note {note_id}. Rejecting update.')
|
||||
return
|
||||
|
||||
try:
|
||||
await stop_item_tasks(REDIS, document_id)
|
||||
except Exception:
|
||||
|
|
@ -724,10 +749,6 @@ async def yjs_document_update(sid, data):
|
|||
skip_sid=sid,
|
||||
)
|
||||
|
||||
user = SESSION_POOL.get(sid)
|
||||
if not user:
|
||||
return
|
||||
|
||||
async def debounced_save():
|
||||
await asyncio.sleep(0.5)
|
||||
await document_save_handler(document_id, data.get('data', {}), user)
|
||||
|
|
@ -743,7 +764,7 @@ async def yjs_document_update(sid, data):
|
|||
async def yjs_document_leave(sid, data):
|
||||
"""Handle user leaving a document"""
|
||||
try:
|
||||
document_id = data['document_id']
|
||||
document_id = normalize_document_id(data['document_id'])
|
||||
user_id = data.get('user_id', sid)
|
||||
|
||||
log.info(f'User {user_id} leaving document {document_id}')
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ 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.models.memories import Memories
|
||||
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
|
||||
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
|
||||
from open_webui.utils.sanitize import sanitize_code
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -653,7 +653,7 @@ async def delete_memory(
|
|||
result = await Memories.delete_memory_by_id_and_user_id(memory_id, user.id)
|
||||
|
||||
if result:
|
||||
VECTOR_DB_CLIENT.delete(collection_name=f'user-memory-{user.id}', ids=[memory_id])
|
||||
await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=f'user-memory-{user.id}', ids=[memory_id])
|
||||
return json.dumps(
|
||||
{'status': 'success', 'message': f'Memory {memory_id} deleted'},
|
||||
ensure_ascii=False,
|
||||
|
|
@ -2202,7 +2202,7 @@ async def query_knowledge_bases(
|
|||
import heapq
|
||||
from open_webui.models.knowledge import Knowledges
|
||||
from open_webui.routers.knowledge import KNOWLEDGE_BASES_COLLECTION
|
||||
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
|
||||
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
|
||||
|
||||
user_id = __user__.get('id')
|
||||
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
|
||||
|
|
@ -2227,7 +2227,7 @@ async def query_knowledge_bases(
|
|||
|
||||
accessible_ids = [kb.id for kb in accessible_knowledge_bases.items]
|
||||
|
||||
search_results = VECTOR_DB_CLIENT.search(
|
||||
search_results = await ASYNC_VECTOR_DB_CLIENT.search(
|
||||
collection_name=KNOWLEDGE_BASES_COLLECTION,
|
||||
vectors=[query_embedding],
|
||||
filter={'knowledge_base_id': {'$in': accessible_ids}},
|
||||
|
|
@ -2494,7 +2494,6 @@ async def create_automation(
|
|||
name: str,
|
||||
prompt: str,
|
||||
rrule: str,
|
||||
model_id: Optional[str] = None,
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
__metadata__: dict = None,
|
||||
|
|
@ -2502,6 +2501,7 @@ async def create_automation(
|
|||
"""
|
||||
Create a scheduled automation that runs a prompt on a recurring or one-time schedule.
|
||||
Use this when the user wants to schedule a task to run automatically.
|
||||
The automation will use the current chat model.
|
||||
|
||||
The rrule parameter must be a valid iCalendar RRULE string. Common examples:
|
||||
- Every day at 9am: "DTSTART:20250101T090000\\nRRULE:FREQ=DAILY"
|
||||
|
|
@ -2516,7 +2516,6 @@ async def create_automation(
|
|||
:param name: A short descriptive name for the automation
|
||||
:param prompt: The prompt/instructions to execute on each run
|
||||
:param rrule: An iCalendar RRULE string defining the schedule
|
||||
:param model_id: Optional model ID to use. Defaults to the current chat model if omitted.
|
||||
:return: JSON with the created automation details including id, next scheduled runs
|
||||
"""
|
||||
if __request__ is None:
|
||||
|
|
@ -2535,11 +2534,10 @@ async def create_automation(
|
|||
if not user:
|
||||
return json.dumps({'error': 'User not found'})
|
||||
|
||||
# Default to current chat's model if not specified
|
||||
# Always use the calling model for the automation
|
||||
model_id = (__metadata__ or {}).get('model_id')
|
||||
if not model_id:
|
||||
model_id = (__metadata__ or {}).get('model_id') or (__metadata__ or {}).get('model')
|
||||
if not model_id:
|
||||
return json.dumps({'error': 'model_id is required (could not detect current model)'})
|
||||
return json.dumps({'error': 'Could not detect current model'})
|
||||
|
||||
# Validate the RRULE
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -257,6 +257,47 @@ async def filter_allowed_access_grants(
|
|||
return access_grants
|
||||
|
||||
|
||||
async def has_base_model_access(
|
||||
user_id: str,
|
||||
model_info,
|
||||
*,
|
||||
user_group_ids: set[str] | None = None,
|
||||
db=None,
|
||||
) -> bool:
|
||||
"""
|
||||
Walk the ``base_model_id`` chain and verify the caller has read access
|
||||
at every hop.
|
||||
|
||||
Returns ``True`` when access is granted (or the chain ends at a raw
|
||||
provider model that has no per-model ACL). Returns ``False`` the
|
||||
moment a registered base model denies access.
|
||||
"""
|
||||
from open_webui.models.models import Models
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
|
||||
base_model_id = getattr(model_info, 'base_model_id', None)
|
||||
seen = {model_info.id}
|
||||
while base_model_id and base_model_id not in seen:
|
||||
seen.add(base_model_id)
|
||||
base_model_info = await Models.get_model_by_id(base_model_id, db=db)
|
||||
if base_model_info is None:
|
||||
break # Raw provider model — no per-model ACL
|
||||
if not (
|
||||
user_id == base_model_info.user_id
|
||||
or await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='model',
|
||||
resource_id=base_model_info.id,
|
||||
permission='read',
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
return False
|
||||
base_model_id = getattr(base_model_info, 'base_model_id', None)
|
||||
return True
|
||||
|
||||
|
||||
async def check_model_access(
|
||||
user: UserModel,
|
||||
model_info,
|
||||
|
|
@ -296,6 +337,10 @@ async def check_model_access(
|
|||
)
|
||||
):
|
||||
raise HTTPException(status_code=403, detail='Model not found')
|
||||
|
||||
# Enforce access on chained base models
|
||||
if not await has_base_model_access(user.id, model_info, user_group_ids=user_group_ids):
|
||||
raise HTTPException(status_code=403, detail='Model not found')
|
||||
else:
|
||||
if user.role != 'admin':
|
||||
raise HTTPException(status_code=403, detail='Model not found')
|
||||
|
|
|
|||
|
|
@ -66,10 +66,18 @@ async def has_access_to_file(
|
|||
return True
|
||||
|
||||
# Check if the file is associated with any chats the user has access to
|
||||
# TODO: Granular access control for chats
|
||||
chats = await Chats.get_shared_chats_by_file_id(file_id, db=db)
|
||||
if chats:
|
||||
return True
|
||||
shared_chat_ids = await Chats.get_shared_chat_ids_by_file_id(file_id, db=db)
|
||||
if shared_chat_ids:
|
||||
accessible_ids = await AccessGrants.get_accessible_resource_ids(
|
||||
user_id=user.id,
|
||||
resource_type='shared_chat',
|
||||
resource_ids=shared_chat_ids,
|
||||
permission='read',
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
if accessible_ids:
|
||||
return True
|
||||
|
||||
# Check if the file is directly attached to a shared workspace model
|
||||
for model in await Models.get_models_by_user_id(user.id, permission=access_type, db=db):
|
||||
|
|
|
|||
|
|
@ -181,17 +181,99 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
|
|||
)
|
||||
elif block_type == 'tool_result':
|
||||
# Tool results become separate tool messages in OpenAI format
|
||||
tool_content = block.get('content', '')
|
||||
if isinstance(tool_content, list):
|
||||
tool_text_parts = []
|
||||
for tc in tool_content:
|
||||
if isinstance(tc, dict) and tc.get('type') == 'text':
|
||||
tool_text_parts.append(tc.get('text', ''))
|
||||
tool_content = '\n'.join(tool_text_parts)
|
||||
tool_result_content = block.get('content', '')
|
||||
tool_content: str | list = ''
|
||||
|
||||
if isinstance(tool_result_content, str):
|
||||
tool_content = tool_result_content
|
||||
elif isinstance(tool_result_content, list):
|
||||
# Build a multimodal content array to preserve
|
||||
# images and other non-text content types.
|
||||
converted_parts = []
|
||||
for content_block in tool_result_content:
|
||||
if not isinstance(content_block, dict):
|
||||
continue
|
||||
content_type = content_block.get('type', 'text')
|
||||
|
||||
if content_type == 'text':
|
||||
converted_parts.append(
|
||||
{
|
||||
'type': 'text',
|
||||
'text': content_block.get('text', ''),
|
||||
}
|
||||
)
|
||||
elif content_type == 'image':
|
||||
source = content_block.get('source', {})
|
||||
if source.get('type') == 'base64':
|
||||
media_type = source.get('media_type', 'image/png')
|
||||
data = source.get('data', '')
|
||||
converted_parts.append(
|
||||
{
|
||||
'type': 'image_url',
|
||||
'image_url': {
|
||||
'url': f'data:{media_type};base64,{data}',
|
||||
},
|
||||
}
|
||||
)
|
||||
elif source.get('type') == 'url':
|
||||
converted_parts.append(
|
||||
{
|
||||
'type': 'image_url',
|
||||
'image_url': {
|
||||
'url': source.get('url', ''),
|
||||
},
|
||||
}
|
||||
)
|
||||
elif content_type == 'document':
|
||||
# Documents have no direct OpenAI equivalent;
|
||||
# convert to a text representation.
|
||||
document_source = content_block.get('source', {})
|
||||
document_title = content_block.get('title', 'Document')
|
||||
document_context = content_block.get('context', '')
|
||||
document_text = f'[Document: {document_title}]'
|
||||
if document_context:
|
||||
document_text += f'\n{document_context}'
|
||||
if document_source.get('type') == 'text' and document_source.get('data'):
|
||||
document_text += f'\n{document_source["data"]}'
|
||||
converted_parts.append({'type': 'text', 'text': document_text})
|
||||
elif content_type == 'search_result':
|
||||
# Convert search results to a text
|
||||
# representation with source attribution.
|
||||
search_title = content_block.get('title', '')
|
||||
search_url = content_block.get('source', '')
|
||||
search_content_blocks = content_block.get('content', [])
|
||||
search_texts = []
|
||||
for search_block in search_content_blocks:
|
||||
if isinstance(search_block, dict) and search_block.get('type') == 'text':
|
||||
search_texts.append(search_block.get('text', ''))
|
||||
search_body = '\n'.join(search_texts)
|
||||
search_text = f'[Search Result: {search_title}]'
|
||||
if search_url:
|
||||
search_text += f'\nSource: {search_url}'
|
||||
if search_body:
|
||||
search_text += f'\n{search_body}'
|
||||
converted_parts.append({'type': 'text', 'text': search_text})
|
||||
|
||||
# Flatten to string when only text parts are present
|
||||
if all(part.get('type') == 'text' for part in converted_parts):
|
||||
tool_content = '\n'.join(part.get('text', '') for part in converted_parts)
|
||||
elif converted_parts:
|
||||
tool_content = converted_parts
|
||||
else:
|
||||
tool_content = ''
|
||||
|
||||
# Propagate error status if present
|
||||
if block.get('is_error'):
|
||||
tool_content = f'Error: {tool_content}'
|
||||
if isinstance(tool_content, str):
|
||||
tool_content = f'Error: {tool_content}'
|
||||
elif isinstance(tool_content, list):
|
||||
tool_content.insert(
|
||||
0,
|
||||
{
|
||||
'type': 'text',
|
||||
'text': 'Error: ',
|
||||
},
|
||||
)
|
||||
|
||||
messages.append(
|
||||
{
|
||||
|
|
|
|||
268
backend/open_webui/utils/asgi_middleware.py
Normal file
268
backend/open_webui/utils/asgi_middleware.py
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
"""
|
||||
Pure-ASGI replacements for the project's previous
|
||||
`@app.middleware('http')` / `BaseHTTPMiddleware` middlewares.
|
||||
|
||||
Why this matters
|
||||
----------------
|
||||
Starlette's `BaseHTTPMiddleware` (which `@app.middleware('http')` is
|
||||
sugar for) runs the downstream app inside an `anyio` task group. When
|
||||
the wrapper exits — for any reason: response complete, client
|
||||
disconnect, an outer middleware bailing out — the task group cancels
|
||||
the inner task. That `CancelledError` then propagates into whatever
|
||||
the inner task was doing, including in-flight DB queries, embedding
|
||||
calls and disk I/O.
|
||||
|
||||
In Open WebUI this surfaces as:
|
||||
|
||||
* SQLAlchemy logging multi-page `NotImplementedError:
|
||||
terminate_force_close()` tracebacks at ERROR every time a request is
|
||||
cancelled mid-DB-call (the aiosqlite connector cleanup path).
|
||||
* Spurious cancellations cascading through the four stacked
|
||||
`@app.middleware('http')` wrappers.
|
||||
|
||||
Pure ASGI middleware does not introduce a cancel scope around the
|
||||
downstream app, so client disconnects propagate the way ASGI was
|
||||
designed to (via `receive()` returning `http.disconnect`) instead of
|
||||
being injected as `CancelledError` into arbitrary `await` points.
|
||||
|
||||
Reference: https://www.starlette.io/middleware/#limitations
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from urllib.parse import parse_qs, urlencode
|
||||
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from starlette.datastructures import MutableHeaders
|
||||
from starlette.requests import Request
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
from open_webui.internal.db import ScopedSession
|
||||
from open_webui.utils.auth import get_http_authorization_cred
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CommitSessionMiddleware:
|
||||
"""Commit and release the thread-local sync `ScopedSession` after each
|
||||
HTTP request.
|
||||
|
||||
Most requests now use the async session; the sync ScopedSession is
|
||||
only touched by startup, healthchecks, and a handful of legacy
|
||||
helpers (notably the pgvector / opengauss vector-DB clients). The
|
||||
middleware exists so that PostgreSQL connections do not accumulate
|
||||
as "idle in transaction" and so that any pending sync work made
|
||||
inside the request is durably persisted.
|
||||
|
||||
Failure semantics
|
||||
-----------------
|
||||
* Downstream raised → roll back any pending sync work, release the
|
||||
connection, and re-raise so the outer exception middleware can
|
||||
turn it into an error response. We never commit work on a
|
||||
request that did not complete successfully.
|
||||
* Downstream returned → commit pending sync work; on commit
|
||||
failure, log loudly, roll back, and re-raise. Note that in pure
|
||||
ASGI the response messages have already been emitted by the
|
||||
time `await self.app(...)` returns, so a commit failure cannot
|
||||
retroactively change what the client sees on the wire — but
|
||||
re-raising still surfaces the error in logs and to ASGI servers
|
||||
that expose it. We deliberately do not buffer the response to
|
||||
gate it on commit success, because that would defeat streaming
|
||||
responses (chat completions, SSE) which are core to the app.
|
||||
|
||||
For request paths where commit-before-send is required, manage the
|
||||
sync session explicitly inside the handler instead of relying on
|
||||
this middleware.
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope['type'] != 'http':
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
try:
|
||||
await self.app(scope, receive, send)
|
||||
except BaseException:
|
||||
# Downstream did not complete successfully. Roll back any
|
||||
# pending sync writes, release the connection, and let the
|
||||
# exception propagate.
|
||||
try:
|
||||
ScopedSession.rollback()
|
||||
except Exception:
|
||||
log.exception('CommitSessionMiddleware: rollback failed after downstream error')
|
||||
finally:
|
||||
ScopedSession.remove()
|
||||
raise
|
||||
|
||||
# Downstream completed. Commit pending sync work.
|
||||
try:
|
||||
ScopedSession.commit()
|
||||
except Exception:
|
||||
log.exception('CommitSessionMiddleware: post-request commit failed; response was already sent to client')
|
||||
try:
|
||||
ScopedSession.rollback()
|
||||
except Exception:
|
||||
log.exception('CommitSessionMiddleware: rollback failed after commit failure')
|
||||
raise
|
||||
finally:
|
||||
# CRITICAL: remove() returns the connection to the pool.
|
||||
# Without this, connections remain "checked out" and
|
||||
# accumulate as "idle in transaction" in PostgreSQL.
|
||||
ScopedSession.remove()
|
||||
|
||||
|
||||
class AuthTokenMiddleware:
|
||||
"""Extract the bearer/cookie/x-api-key credential and stash it on
|
||||
`request.state.token`.
|
||||
|
||||
Routes that depend on `get_verified_user` etc. read this state.
|
||||
Also exposes `request.state.enable_api_keys` (snapshotted at request
|
||||
entry from runtime config) and stamps an `X-Process-Time` response
|
||||
header.
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp, *, fastapi_app) -> None:
|
||||
self.app = app
|
||||
self._fastapi_app = fastapi_app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope['type'] != 'http':
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
start_time = time.monotonic()
|
||||
request = Request(scope)
|
||||
|
||||
token = get_http_authorization_cred(request.headers.get('Authorization'))
|
||||
if token is None:
|
||||
cookie_token = request.cookies.get('token')
|
||||
if cookie_token:
|
||||
token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=cookie_token)
|
||||
if token is None:
|
||||
api_key = request.headers.get('x-api-key')
|
||||
if api_key:
|
||||
token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=api_key)
|
||||
|
||||
request.state.token = token
|
||||
request.state.enable_api_keys = self._fastapi_app.state.config.ENABLE_API_KEYS
|
||||
|
||||
async def send_with_timing(message: Message) -> None:
|
||||
if message['type'] == 'http.response.start':
|
||||
process_time = int(time.monotonic() - start_time)
|
||||
headers = MutableHeaders(scope=message)
|
||||
headers['X-Process-Time'] = str(process_time)
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, receive, send_with_timing)
|
||||
|
||||
|
||||
class WebsocketUpgradeGuardMiddleware:
|
||||
"""Reject HTTP requests to `/ws/socket.io` that claim
|
||||
`transport=websocket` but lack the proper `Upgrade`/`Connection`
|
||||
headers.
|
||||
|
||||
Works around https://github.com/miguelgrinberg/python-engineio/issues/367
|
||||
where engineio mishandles such requests.
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope['type'] != 'http':
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
path = scope.get('path', '')
|
||||
if '/ws/socket.io' in path:
|
||||
query_string = scope.get('query_string', b'').decode('latin-1', errors='replace')
|
||||
query_params = parse_qs(query_string)
|
||||
if query_params.get('transport', [''])[0] == 'websocket':
|
||||
headers = _scope_headers(scope)
|
||||
upgrade = headers.get('upgrade', '').lower()
|
||||
connection_tokens = [token.strip() for token in headers.get('connection', '').lower().split(',')]
|
||||
if upgrade != 'websocket' or 'upgrade' not in connection_tokens:
|
||||
response = JSONResponse(
|
||||
status_code=400,
|
||||
content={'detail': 'Invalid WebSocket upgrade request'},
|
||||
)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
|
||||
class RedirectMiddleware:
|
||||
"""Rewrites a couple of legacy entry-points to the SPA's own routes:
|
||||
|
||||
* ``GET /watch?v=ID`` (YouTube) → ``/?youtube=ID``
|
||||
* ``GET /?shared=…`` (PWA share-target) → ``/?youtube=…`` /
|
||||
``/?load-url=…`` / ``/?q=…``
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope['type'] != 'http' or scope.get('method', '').upper() != 'GET':
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
path = scope.get('path', '')
|
||||
query_string = scope.get('query_string', b'').decode('latin-1', errors='replace')
|
||||
query_params = parse_qs(query_string)
|
||||
|
||||
redirect_params: dict[str, str] = {}
|
||||
if path.endswith('/watch') and 'v' in query_params and query_params['v']:
|
||||
redirect_params['youtube'] = query_params['v'][0]
|
||||
|
||||
if 'shared' in query_params and query_params['shared']:
|
||||
text = query_params['shared'][0]
|
||||
if text:
|
||||
url_match = re.match(r'https://\S+', text)
|
||||
if url_match:
|
||||
# Local import: youtube loader pulls heavy deps and is
|
||||
# only needed when a share-target actually contains a
|
||||
# YouTube URL.
|
||||
from open_webui.retrieval.loaders.youtube import _parse_video_id
|
||||
|
||||
youtube_video_id = _parse_video_id(url_match[0])
|
||||
if youtube_video_id:
|
||||
redirect_params['youtube'] = youtube_video_id
|
||||
else:
|
||||
redirect_params['load-url'] = url_match[0]
|
||||
else:
|
||||
redirect_params['q'] = text
|
||||
|
||||
if redirect_params:
|
||||
redirect_url = f'/?{urlencode(redirect_params)}'
|
||||
response = RedirectResponse(url=redirect_url)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
|
||||
def _scope_headers(scope: Scope) -> dict[str, str]:
|
||||
"""Return ASGI scope headers as a lower-cased str→str dict.
|
||||
|
||||
ASGI delivers headers as a list of (bytes, bytes) pairs. For
|
||||
convenience, fold duplicate keys with comma-joining (matching
|
||||
HTTP/1.1 semantics).
|
||||
"""
|
||||
decoded: dict[str, str] = {}
|
||||
for raw_key, raw_value in scope.get('headers', []):
|
||||
key = raw_key.decode('latin-1').lower()
|
||||
value = raw_value.decode('latin-1')
|
||||
if key in decoded:
|
||||
decoded[key] = f'{decoded[key]}, {value}'
|
||||
else:
|
||||
decoded[key] = value
|
||||
return decoded
|
||||
|
|
@ -22,6 +22,7 @@ from dateutil.rrule import rrulestr
|
|||
from fastapi import Request
|
||||
from starlette.datastructures import Headers
|
||||
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.models.automations import Automations, AutomationRuns, AutomationModel
|
||||
from open_webui.models.chats import ChatForm, Chats
|
||||
from open_webui.models.users import Users
|
||||
|
|
@ -59,9 +60,9 @@ def validate_rrule(s: str) -> None:
|
|||
try:
|
||||
rule = _parse_rule(s)
|
||||
except Exception as e:
|
||||
raise ValueError(f'Invalid RRULE: {e}')
|
||||
raise ValueError(ERROR_MESSAGES.AUTOMATION_INVALID_RRULE(e))
|
||||
if rule.after(datetime.now()) is None:
|
||||
raise ValueError('RRULE has no future occurrences')
|
||||
raise ValueError(ERROR_MESSAGES.AUTOMATION_NO_FUTURE_RUNS)
|
||||
|
||||
|
||||
def next_run_ns(s: str, tz: str = None) -> Optional[int]:
|
||||
|
|
@ -306,8 +307,9 @@ async def execute_automation(app, automation: AutomationModel) -> None:
|
|||
user_msg_id = str(uuid4())
|
||||
assistant_msg_id = str(uuid4())
|
||||
|
||||
# Create the chat with user message (same structure as frontend)
|
||||
chat_id = str(uuid4())
|
||||
chat = await Chats.insert_new_chat(
|
||||
chat_id,
|
||||
automation.user_id,
|
||||
ChatForm(
|
||||
chat={
|
||||
|
|
@ -377,7 +379,13 @@ async def execute_automation(app, automation: AutomationModel) -> None:
|
|||
'stream': True,
|
||||
'chat_id': chat.id,
|
||||
'id': assistant_msg_id,
|
||||
'parent_id': user_msg_id,
|
||||
'parent_id': None, # Root message (chat already created above)
|
||||
'user_message': {
|
||||
'id': user_msg_id,
|
||||
'parentId': None,
|
||||
'role': 'user',
|
||||
'content': prompt,
|
||||
},
|
||||
'session_id': f'automation:{automation.id}',
|
||||
'background_tasks': {},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,12 +20,14 @@ 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 asyncio
|
||||
import mimetypes
|
||||
import base64
|
||||
import io
|
||||
import re
|
||||
|
||||
import requests
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL
|
||||
from open_webui.utils.session_pool import get_session
|
||||
|
||||
BASE64_IMAGE_URL_PREFIX = re.compile(r'data:image/\w+;base64,', re.IGNORECASE)
|
||||
MARKDOWN_IMAGE_URL_PATTERN = re.compile(r'!\[(.*?)\]\((.+?)\)', re.IGNORECASE)
|
||||
|
|
@ -37,19 +39,20 @@ async def get_image_base64_from_url(url: str) -> Optional[str]:
|
|||
# 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()
|
||||
image_data = response.content
|
||||
encoded_string = base64.b64encode(image_data).decode('utf-8')
|
||||
content_type = response.headers.get('Content-Type', 'image/png')
|
||||
return f'data:{content_type};base64,{encoded_string}'
|
||||
session = await get_session()
|
||||
async with session.get(url, ssl=AIOHTTP_CLIENT_SESSION_SSL) as response:
|
||||
response.raise_for_status()
|
||||
image_data = await response.read()
|
||||
encoded_string = base64.b64encode(image_data).decode('utf-8')
|
||||
content_type = response.headers.get('Content-Type', 'image/png')
|
||||
return f'data:{content_type};base64,{encoded_string}'
|
||||
else:
|
||||
file = await Files.get_file_by_id(url)
|
||||
|
||||
if not file:
|
||||
return None
|
||||
|
||||
file_path = Storage.get_file(file.path)
|
||||
file_path = await asyncio.to_thread(Storage.get_file, file.path)
|
||||
file_path = Path(file_path)
|
||||
|
||||
if file_path.is_file():
|
||||
|
|
@ -68,7 +71,7 @@ async def get_image_url_from_base64(request, base64_image_string, metadata, user
|
|||
if BASE64_IMAGE_URL_PREFIX.match(base64_image_string):
|
||||
image_url = ''
|
||||
# Extract base64 image data from the line
|
||||
image_data, content_type = get_image_data(base64_image_string)
|
||||
image_data, content_type = await get_image_data(base64_image_string)
|
||||
if image_data is not None:
|
||||
_, image_url = await upload_image(
|
||||
request,
|
||||
|
|
@ -170,7 +173,7 @@ async def get_image_base64_from_file_id(id: str) -> Optional[str]:
|
|||
return None
|
||||
|
||||
try:
|
||||
file_path = Storage.get_file(file.path)
|
||||
file_path = await asyncio.to_thread(Storage.get_file, file.path)
|
||||
file_path = Path(file_path)
|
||||
|
||||
# Check if the file already exists in the cache
|
||||
|
|
|
|||
|
|
@ -1,49 +1,51 @@
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import requests
|
||||
import aiohttp
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Optional
|
||||
|
||||
import websocket # NOTE: websocket-client (https://github.com/websocket-client/websocket-client)
|
||||
import aiohttp
|
||||
from pydantic import BaseModel
|
||||
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL
|
||||
from open_webui.utils.session_pool import get_session
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
default_headers = {'User-Agent': 'Mozilla/5.0'}
|
||||
|
||||
|
||||
def queue_prompt(prompt, client_id, base_url, api_key):
|
||||
async def queue_prompt(prompt, client_id, base_url, api_key):
|
||||
log.info('queue_prompt')
|
||||
p = {'prompt': prompt, 'client_id': client_id}
|
||||
data = json.dumps(p).encode('utf-8')
|
||||
log.debug(f'queue_prompt data: {data}')
|
||||
log.debug(f'queue_prompt data: {p}')
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
session = await get_session()
|
||||
async with session.post(
|
||||
f'{base_url}/prompt',
|
||||
data=data,
|
||||
json=p,
|
||||
headers={**default_headers, 'Authorization': f'Bearer {api_key}'},
|
||||
)
|
||||
response = urllib.request.urlopen(req).read()
|
||||
return json.loads(response)
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
return await r.json()
|
||||
except Exception as e:
|
||||
log.exception(f'Error while queuing prompt: {e}')
|
||||
raise e
|
||||
raise
|
||||
|
||||
|
||||
def get_image(filename, subfolder, folder_type, base_url, api_key):
|
||||
async def get_image(filename, subfolder, folder_type, base_url, api_key):
|
||||
log.info('get_image')
|
||||
data = {'filename': filename, 'subfolder': subfolder, 'type': folder_type}
|
||||
url_values = urllib.parse.urlencode(data)
|
||||
req = urllib.request.Request(
|
||||
session = await get_session()
|
||||
async with session.get(
|
||||
f'{base_url}/view?{url_values}',
|
||||
headers={**default_headers, 'Authorization': f'Bearer {api_key}'},
|
||||
)
|
||||
with urllib.request.urlopen(req) as response:
|
||||
return response.read()
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
return await r.read()
|
||||
|
||||
|
||||
def get_image_url(filename, subfolder, folder_type, base_url):
|
||||
|
|
@ -53,32 +55,39 @@ def get_image_url(filename, subfolder, folder_type, base_url):
|
|||
return f'{base_url}/view?{url_values}'
|
||||
|
||||
|
||||
def get_history(prompt_id, base_url, api_key):
|
||||
async def get_history(prompt_id, base_url, api_key):
|
||||
log.info('get_history')
|
||||
|
||||
req = urllib.request.Request(
|
||||
session = await get_session()
|
||||
async with session.get(
|
||||
f'{base_url}/history/{prompt_id}',
|
||||
headers={**default_headers, 'Authorization': f'Bearer {api_key}'},
|
||||
)
|
||||
with urllib.request.urlopen(req) as response:
|
||||
return json.loads(response.read())
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
return await r.json()
|
||||
|
||||
|
||||
def get_images(ws, workflow, client_id, base_url, api_key):
|
||||
prompt_id = queue_prompt(workflow, client_id, base_url, api_key)['prompt_id']
|
||||
async def _ws_get_images(ws, workflow, client_id, base_url, api_key):
|
||||
"""Queue a prompt and wait on *ws* for ComfyUI to finish executing it.
|
||||
|
||||
Returns a dict of ``{'data': [{'url': ...}, ...]}``.
|
||||
"""
|
||||
prompt_id = (await queue_prompt(workflow, client_id, base_url, api_key))['prompt_id']
|
||||
output_images = []
|
||||
while True:
|
||||
out = ws.recv()
|
||||
if isinstance(out, str):
|
||||
message = json.loads(out)
|
||||
|
||||
async for msg in ws:
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
message = json.loads(msg.data)
|
||||
if message['type'] == 'executing':
|
||||
data = message['data']
|
||||
if data['node'] is None and data['prompt_id'] == prompt_id:
|
||||
break # Execution is done
|
||||
else:
|
||||
continue # previews are binary data
|
||||
elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
|
||||
log.error(f'WebSocket closed unexpectedly: {msg.type}')
|
||||
break
|
||||
# binary messages (previews) are silently skipped
|
||||
|
||||
history = get_history(prompt_id, base_url, api_key)[prompt_id]
|
||||
history = (await get_history(prompt_id, base_url, api_key))[prompt_id]
|
||||
for node_id in history['outputs']:
|
||||
node_output = history['outputs'][node_id]
|
||||
if node_id in workflow and workflow[node_id].get('class_type') in [
|
||||
|
|
@ -105,10 +114,10 @@ async def comfyui_upload_image(image_file_item, base_url, api_key):
|
|||
form.add_field('image', file_bytes, filename=filename, content_type=mime_type)
|
||||
form.add_field('type', 'input') # required by ComfyUI
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(url, data=form, headers=headers) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.json()
|
||||
session = await get_session()
|
||||
async with session.post(url, data=form, headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.json()
|
||||
|
||||
|
||||
class ComfyUINodeInput(BaseModel):
|
||||
|
|
@ -136,11 +145,9 @@ class ComfyUICreateImageForm(BaseModel):
|
|||
seed: Optional[int] = None
|
||||
|
||||
|
||||
async def comfyui_create_image(model: str, payload: ComfyUICreateImageForm, client_id, base_url, api_key):
|
||||
ws_url = base_url.replace('http://', 'ws://').replace('https://', 'wss://')
|
||||
workflow = json.loads(payload.workflow.workflow)
|
||||
|
||||
for node in payload.workflow.nodes:
|
||||
def _apply_workflow_nodes(workflow, nodes, model, payload):
|
||||
"""Mutate *workflow* dict in-place based on typed node definitions."""
|
||||
for node in nodes:
|
||||
if node.type:
|
||||
if node.type == 'model':
|
||||
for node_id in node.node_ids:
|
||||
|
|
@ -151,6 +158,14 @@ async def comfyui_create_image(model: str, payload: ComfyUICreateImageForm, clie
|
|||
elif node.type == 'negative_prompt':
|
||||
for node_id in node.node_ids:
|
||||
workflow[node_id]['inputs'][node.key if node.key else 'text'] = payload.negative_prompt
|
||||
elif node.type == 'image':
|
||||
if isinstance(payload.image, list):
|
||||
for idx, node_id in enumerate(node.node_ids):
|
||||
if idx < len(payload.image):
|
||||
workflow[node_id]['inputs'][node.key] = payload.image[idx]
|
||||
else:
|
||||
for node_id in node.node_ids:
|
||||
workflow[node_id]['inputs'][node.key] = payload.image
|
||||
elif node.type == 'width':
|
||||
for node_id in node.node_ids:
|
||||
workflow[node_id]['inputs'][node.key if node.key else 'width'] = payload.width
|
||||
|
|
@ -171,24 +186,31 @@ async def comfyui_create_image(model: str, payload: ComfyUICreateImageForm, clie
|
|||
for node_id in node.node_ids:
|
||||
workflow[node_id]['inputs'][node.key] = node.value
|
||||
|
||||
|
||||
async def comfyui_create_image(model: str, payload: ComfyUICreateImageForm, client_id, base_url, api_key):
|
||||
ws_url = base_url.replace('http://', 'ws://').replace('https://', 'wss://')
|
||||
workflow = json.loads(payload.workflow.workflow)
|
||||
_apply_workflow_nodes(workflow, payload.workflow.nodes, model, payload)
|
||||
|
||||
headers = {'Authorization': f'Bearer {api_key}'}
|
||||
session = await get_session()
|
||||
|
||||
try:
|
||||
ws = websocket.WebSocket()
|
||||
headers = {'Authorization': f'Bearer {api_key}'}
|
||||
ws.connect(f'{ws_url}/ws?clientId={client_id}', header=headers)
|
||||
log.info('WebSocket connection established.')
|
||||
except Exception as e:
|
||||
async with session.ws_connect(
|
||||
f'{ws_url}/ws?clientId={client_id}',
|
||||
headers=headers,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as ws:
|
||||
log.info('WebSocket connection established.')
|
||||
log.info('Sending workflow to WebSocket server.')
|
||||
log.info(f'Workflow: {workflow}')
|
||||
images = await _ws_get_images(ws, workflow, client_id, base_url, api_key)
|
||||
except aiohttp.WSServerHandshakeError as e:
|
||||
log.exception(f'Failed to connect to WebSocket server: {e}')
|
||||
return None
|
||||
|
||||
try:
|
||||
log.info('Sending workflow to WebSocket server.')
|
||||
log.info(f'Workflow: {workflow}')
|
||||
images = await asyncio.to_thread(get_images, ws, workflow, client_id, base_url, api_key)
|
||||
except Exception as e:
|
||||
log.exception(f'Error while receiving images: {e}')
|
||||
images = None
|
||||
|
||||
ws.close()
|
||||
log.exception(f'Error during image generation: {e}')
|
||||
return None
|
||||
|
||||
return images
|
||||
|
||||
|
|
@ -209,64 +231,26 @@ class ComfyUIEditImageForm(BaseModel):
|
|||
async def comfyui_edit_image(model: str, payload: ComfyUIEditImageForm, client_id, base_url, api_key):
|
||||
ws_url = base_url.replace('http://', 'ws://').replace('https://', 'wss://')
|
||||
workflow = json.loads(payload.workflow.workflow)
|
||||
_apply_workflow_nodes(workflow, payload.workflow.nodes, model, payload)
|
||||
|
||||
for node in payload.workflow.nodes:
|
||||
if node.type:
|
||||
if node.type == 'model':
|
||||
for node_id in node.node_ids:
|
||||
workflow[node_id]['inputs'][node.key] = model
|
||||
elif node.type == 'image':
|
||||
if isinstance(payload.image, list):
|
||||
# check if multiple images are provided
|
||||
for idx, node_id in enumerate(node.node_ids):
|
||||
if idx < len(payload.image):
|
||||
workflow[node_id]['inputs'][node.key] = payload.image[idx]
|
||||
else:
|
||||
for node_id in node.node_ids:
|
||||
workflow[node_id]['inputs'][node.key] = payload.image
|
||||
elif node.type == 'prompt':
|
||||
for node_id in node.node_ids:
|
||||
workflow[node_id]['inputs'][node.key if node.key else 'text'] = payload.prompt
|
||||
elif node.type == 'negative_prompt':
|
||||
for node_id in node.node_ids:
|
||||
workflow[node_id]['inputs'][node.key if node.key else 'text'] = payload.negative_prompt
|
||||
elif node.type == 'width':
|
||||
for node_id in node.node_ids:
|
||||
workflow[node_id]['inputs'][node.key if node.key else 'width'] = payload.width
|
||||
elif node.type == 'height':
|
||||
for node_id in node.node_ids:
|
||||
workflow[node_id]['inputs'][node.key if node.key else 'height'] = payload.height
|
||||
elif node.type == 'n':
|
||||
for node_id in node.node_ids:
|
||||
workflow[node_id]['inputs'][node.key if node.key else 'batch_size'] = payload.n
|
||||
elif node.type == 'steps':
|
||||
for node_id in node.node_ids:
|
||||
workflow[node_id]['inputs'][node.key if node.key else 'steps'] = payload.steps
|
||||
elif node.type == 'seed':
|
||||
seed = payload.seed if payload.seed else random.randint(0, 1125899906842624)
|
||||
for node_id in node.node_ids:
|
||||
workflow[node_id]['inputs'][node.key] = seed
|
||||
else:
|
||||
for node_id in node.node_ids:
|
||||
workflow[node_id]['inputs'][node.key] = node.value
|
||||
headers = {'Authorization': f'Bearer {api_key}'}
|
||||
session = await get_session()
|
||||
|
||||
try:
|
||||
ws = websocket.WebSocket()
|
||||
headers = {'Authorization': f'Bearer {api_key}'}
|
||||
ws.connect(f'{ws_url}/ws?clientId={client_id}', header=headers)
|
||||
log.info('WebSocket connection established.')
|
||||
except Exception as e:
|
||||
async with session.ws_connect(
|
||||
f'{ws_url}/ws?clientId={client_id}',
|
||||
headers=headers,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as ws:
|
||||
log.info('WebSocket connection established.')
|
||||
log.info('Sending workflow to WebSocket server.')
|
||||
log.info(f'Workflow: {workflow}')
|
||||
images = await _ws_get_images(ws, workflow, client_id, base_url, api_key)
|
||||
except aiohttp.WSServerHandshakeError as e:
|
||||
log.exception(f'Failed to connect to WebSocket server: {e}')
|
||||
return None
|
||||
|
||||
try:
|
||||
log.info('Sending workflow to WebSocket server.')
|
||||
log.info(f'Workflow: {workflow}')
|
||||
images = await asyncio.to_thread(get_images, ws, workflow, client_id, base_url, api_key)
|
||||
except Exception as e:
|
||||
log.exception(f'Error while receiving images: {e}')
|
||||
images = None
|
||||
|
||||
ws.close()
|
||||
log.exception(f'Error during image editing: {e}')
|
||||
return None
|
||||
|
||||
return images
|
||||
|
|
|
|||
|
|
@ -401,12 +401,61 @@ def is_opening_code_block(content):
|
|||
return len(backtick_segments) > 1 and len(backtick_segments) % 2 == 0
|
||||
|
||||
|
||||
_OPENAI_TOOL_DISPLAY_NAMES = {
|
||||
'web_search_call': 'Web Search',
|
||||
'file_search_call': 'File Search',
|
||||
'computer_call': 'Computer Use',
|
||||
}
|
||||
|
||||
|
||||
def _render_openai_tool_call_handler(item: dict, done: bool) -> str:
|
||||
"""Render an OpenAI Responses API server-side tool item as a <details> block.
|
||||
|
||||
Handles web_search_call, file_search_call, and computer_call items whose
|
||||
schemas are defined in the openai-python SDK (generated from OpenAPI spec).
|
||||
"""
|
||||
item_type = item.get('type', '')
|
||||
call_id = item.get('id', '')
|
||||
display_name = _OPENAI_TOOL_DISPLAY_NAMES.get(item_type, item_type)
|
||||
|
||||
# Build a short summary of what the tool did
|
||||
summary = ''
|
||||
if item_type == 'web_search_call':
|
||||
action = item.get('action', {})
|
||||
if isinstance(action, dict):
|
||||
atype = action.get('type', '')
|
||||
if atype == 'search':
|
||||
queries = action.get('queries') or []
|
||||
query = action.get('query', '')
|
||||
summary = f'Search: {", ".join(str(q) for q in queries)}' if queries else (f'Search: {query}' if query else '')
|
||||
elif atype == 'open_page':
|
||||
summary = f'Open page: {action.get("url", "")}' if action.get('url') else ''
|
||||
elif atype == 'find_in_page':
|
||||
summary = f'Find in page: {action.get("pattern", "")}' if action.get('pattern') else ''
|
||||
elif item_type == 'file_search_call':
|
||||
queries = item.get('queries', [])
|
||||
if queries:
|
||||
summary = f'Queries: {", ".join(str(q) for q in queries)}'
|
||||
elif item_type == 'computer_call':
|
||||
action = item.get('action')
|
||||
actions = item.get('actions')
|
||||
if isinstance(action, dict):
|
||||
summary = f'Action: {action.get("type", "unknown")}'
|
||||
elif isinstance(actions, list) and actions:
|
||||
summary = f'Actions: {", ".join(a.get("type", "?") for a in actions if isinstance(a, dict))}'
|
||||
|
||||
escaped_name = html.escape(display_name)
|
||||
if done:
|
||||
return f'<details type="tool_calls" done="true" id="{call_id}" name="{escaped_name}" arguments="">\n<summary>Tool Executed</summary>\n{html.escape(summary)}\n</details>\n'
|
||||
return f'<details type="tool_calls" done="false" id="{call_id}" name="{escaped_name}" arguments="">\n<summary>Executing...</summary>\n</details>\n'
|
||||
|
||||
|
||||
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 = ''
|
||||
parts: list[str] = []
|
||||
|
||||
# First pass: collect function_call_output items by call_id for lookup
|
||||
tool_outputs = {}
|
||||
|
|
@ -423,46 +472,48 @@ def serialize_output(output: list) -> str:
|
|||
if 'text' in content_part:
|
||||
text = content_part.get('text', '').strip()
|
||||
if text:
|
||||
content = f'{content}{text}\n'
|
||||
parts.append(text)
|
||||
|
||||
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 = ''
|
||||
result_parts: list[str] = []
|
||||
for result_output in result_item.get('output', []):
|
||||
if 'text' in result_output:
|
||||
output_text = result_output.get('text', '')
|
||||
result_text += str(output_text) if not isinstance(output_text, str) else output_text
|
||||
result_parts.append(str(output_text) if not isinstance(output_text, str) else output_text)
|
||||
result_text = ''.join(result_parts)
|
||||
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'
|
||||
parts.append(f'<details type="tool_calls" done="true" id="{call_id}" name="{name}" arguments="{html.escape(json.dumps(arguments))}" files="{html.escape(json.dumps(files)) if files else ""}" embeds="{html.escape(json.dumps(embeds))}">\n<summary>Tool Executed</summary>\n{html.escape(json.dumps(result_text, ensure_ascii=False))}\n</details>')
|
||||
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'
|
||||
parts.append(f'<details type="tool_calls" done="false" id="{call_id}" name="{name}" arguments="{html.escape(json.dumps(arguments))}">\n<summary>Executing...</summary>\n</details>')
|
||||
|
||||
elif item_type == 'function_call_output':
|
||||
# Already handled inline with function_call above
|
||||
pass
|
||||
|
||||
elif item_type in _OPENAI_TOOL_DISPLAY_NAMES:
|
||||
status = item.get('status', 'in_progress')
|
||||
done = status in ('completed', 'failed', 'incomplete') or idx != len(output) - 1
|
||||
parts.append(_render_openai_tool_call_handler(item, done).rstrip('\n'))
|
||||
|
||||
elif item_type == 'reasoning':
|
||||
reasoning_content = ''
|
||||
reasoning_parts: list[str] = []
|
||||
# Check for 'summary' (new structure) or 'content' (legacy/fallback)
|
||||
source_list = item.get('summary', []) or item.get('content', [])
|
||||
for content_part in source_list:
|
||||
if 'text' in content_part:
|
||||
reasoning_content += content_part.get('text', '')
|
||||
reasoning_parts.append(content_part.get('text', ''))
|
||||
elif 'summary' in content_part: # Handle potential nested logic if any
|
||||
pass
|
||||
|
||||
reasoning_content = reasoning_content.strip()
|
||||
reasoning_content = ''.join(reasoning_parts).strip()
|
||||
|
||||
duration = item.get('duration')
|
||||
status = item.get('status', 'in_progress')
|
||||
|
|
@ -471,9 +522,6 @@ def serialize_output(output: list) -> str:
|
|||
# render as done (a subsequent item means reasoning is complete)
|
||||
is_last_item = idx == len(output) - 1
|
||||
|
||||
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()
|
||||
|
|
@ -481,19 +529,22 @@ def serialize_output(output: list) -> str:
|
|||
)
|
||||
|
||||
if status == 'completed' or duration is not None or not is_last_item:
|
||||
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'
|
||||
parts.append(f'<details type="reasoning" done="true" duration="{duration or 0}">\n<summary>Thought for {duration or 0} seconds</summary>\n{display}\n</details>')
|
||||
else:
|
||||
content = f'{content}<details type="reasoning" done="false">\n<summary>Thinking…</summary>\n{display}\n</details>\n'
|
||||
parts.append(f'<details type="reasoning" done="false">\n<summary>Thinking…</summary>\n{display}\n</details>')
|
||||
|
||||
elif item_type == 'open_webui:code_interpreter':
|
||||
# Code interpreter needs to inspect/mutate prior accumulated content
|
||||
# to strip trailing unclosed code fences — materialize only here.
|
||||
content = '\n'.join(parts)
|
||||
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'
|
||||
# Re-split back into parts list after mutation
|
||||
parts = [content] if content else []
|
||||
|
||||
# Render the code_interpreter item as a <details> block
|
||||
# so the frontend Collapsible renders "Analyzing..."/"Analyzed".
|
||||
|
|
@ -519,11 +570,11 @@ def serialize_output(output: list) -> str:
|
|||
output_attr = f' output="{html.escape(output_json)}"'
|
||||
|
||||
if status == 'completed' or duration is not None or not is_last_item:
|
||||
content += f'<details type="code_interpreter" done="true" duration="{duration or 0}"{output_attr}>\n<summary>Analyzed</summary>\n{display}\n</details>\n'
|
||||
parts.append(f'<details type="code_interpreter" done="true" duration="{duration or 0}"{output_attr}>\n<summary>Analyzed</summary>\n{display}\n</details>')
|
||||
else:
|
||||
content += f'<details type="code_interpreter" done="false"{output_attr}>\n<summary>Analyzing…</summary>\n{display}\n</details>\n'
|
||||
parts.append(f'<details type="code_interpreter" done="false"{output_attr}>\n<summary>Analyzing…</summary>\n{display}\n</details>')
|
||||
|
||||
return content.strip()
|
||||
return '\n'.join(parts).strip()
|
||||
|
||||
|
||||
def deep_merge(target, source):
|
||||
|
|
@ -2054,13 +2105,16 @@ async def convert_url_images_to_base64(form_data):
|
|||
continue
|
||||
|
||||
try:
|
||||
base64_data = await asyncio.to_thread(get_image_base64_from_url, image_url)
|
||||
new_content.append(
|
||||
{
|
||||
'type': 'image_url',
|
||||
'image_url': {'url': base64_data},
|
||||
}
|
||||
)
|
||||
base64_data = await get_image_base64_from_url(image_url)
|
||||
if base64_data:
|
||||
new_content.append(
|
||||
{
|
||||
'type': 'image_url',
|
||||
'image_url': {'url': base64_data},
|
||||
}
|
||||
)
|
||||
else:
|
||||
new_content.append(item)
|
||||
except Exception as e:
|
||||
log.debug(f'Error converting image URL to base64: {e}')
|
||||
new_content.append(item)
|
||||
|
|
@ -2150,10 +2204,10 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
# Load messages from DB when available — DB preserves structured 'output' items
|
||||
# which the frontend strips, causing tool calls to be merged into content.
|
||||
chat_id = metadata.get('chat_id')
|
||||
parent_message_id = metadata.get('parent_message_id')
|
||||
user_message_id = metadata.get('user_message_id')
|
||||
|
||||
if chat_id and parent_message_id and not chat_id.startswith('local:'):
|
||||
db_messages = await load_messages_from_db(chat_id, parent_message_id)
|
||||
if chat_id and user_message_id and not chat_id.startswith('local:'):
|
||||
db_messages = await load_messages_from_db(chat_id, user_message_id)
|
||||
if db_messages:
|
||||
system_message = get_system_message(form_data.get('messages', []))
|
||||
form_data['messages'] = [system_message, *db_messages] if system_message else db_messages
|
||||
|
|
@ -2457,6 +2511,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
|
||||
metadata = {
|
||||
**metadata,
|
||||
'model_id': form_data.get('model'),
|
||||
'tool_ids': tool_ids,
|
||||
'terminal_id': terminal_id,
|
||||
'files': files,
|
||||
|
|
@ -2615,7 +2670,8 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
|
||||
# Resolve terminal tools if terminal_id is set (outside tool_ids check
|
||||
# so system terminals work even when no other tools are selected)
|
||||
if terminal_id:
|
||||
terminal_capability = (model.get('info', {}).get('meta', {}).get('capabilities') or {}).get('terminal', True)
|
||||
if terminal_id and terminal_capability:
|
||||
try:
|
||||
terminal_result = await get_terminal_tools(
|
||||
request,
|
||||
|
|
@ -3058,6 +3114,147 @@ async def background_tasks_handler(ctx):
|
|||
pass
|
||||
|
||||
|
||||
async def outlet_filter_handler(ctx):
|
||||
"""Run outlet filters inline after chat completion.
|
||||
|
||||
Replaces the separate POST /api/chat/completed round-trip.
|
||||
Persists outlet-modified content to DB and emits a chat:outlet event
|
||||
so the frontend can sync its in-memory state.
|
||||
|
||||
For temp chats (local: prefix), messages are built from form_data
|
||||
plus the assistant response message stored in ctx['assistant_message'],
|
||||
since temp chats have no DB-persisted history.
|
||||
"""
|
||||
request = ctx['request']
|
||||
user = ctx['user']
|
||||
model = ctx['model']
|
||||
metadata = ctx['metadata']
|
||||
event_emitter = ctx.get('event_emitter')
|
||||
event_caller = ctx.get('event_caller')
|
||||
|
||||
chat_id = metadata.get('chat_id', '')
|
||||
message_id = metadata.get('message_id')
|
||||
|
||||
if not chat_id or not message_id:
|
||||
return
|
||||
|
||||
is_temp_chat = chat_id.startswith('local:')
|
||||
|
||||
try:
|
||||
messages_map = None
|
||||
|
||||
if is_temp_chat:
|
||||
# Temp chats have no DB record — build message list from
|
||||
# the in-memory form_data plus the assistant response.
|
||||
form_messages = ctx.get('form_data', {}).get('messages', [])
|
||||
assistant_message = ctx.get('assistant_message', {})
|
||||
|
||||
message_list = [
|
||||
{
|
||||
'role': m.get('role'),
|
||||
'content': m.get('content', ''),
|
||||
}
|
||||
for m in form_messages
|
||||
]
|
||||
|
||||
# Append the full assistant message (content, output, usage, etc.)
|
||||
if assistant_message:
|
||||
message_list.append(
|
||||
{
|
||||
'id': message_id,
|
||||
'role': 'assistant',
|
||||
**assistant_message,
|
||||
}
|
||||
)
|
||||
else:
|
||||
messages_map = await Chats.get_messages_map_by_chat_id(chat_id)
|
||||
if not messages_map:
|
||||
return
|
||||
|
||||
message_list = get_message_list(messages_map, message_id)
|
||||
if not message_list:
|
||||
return
|
||||
|
||||
model_id = model.get('id') if isinstance(model, dict) else model
|
||||
|
||||
outlet_data = {
|
||||
'model': model_id,
|
||||
'messages': [
|
||||
{
|
||||
'id': m.get('id'),
|
||||
'role': m.get('role'),
|
||||
'content': m.get('content', ''),
|
||||
'info': m.get('info'),
|
||||
'timestamp': m.get('timestamp'),
|
||||
**({'output': m['output']} if m.get('output') else {}),
|
||||
**({'usage': m['usage']} if m.get('usage') else {}),
|
||||
**({'sources': m['sources']} if m.get('sources') else {}),
|
||||
}
|
||||
for m in message_list
|
||||
],
|
||||
'filter_ids': metadata.get('filter_ids', []),
|
||||
'chat_id': chat_id,
|
||||
'session_id': metadata.get('session_id'),
|
||||
'id': message_id,
|
||||
}
|
||||
|
||||
# Pipeline outlet filters
|
||||
models = request.app.state.MODELS
|
||||
try:
|
||||
outlet_data = await process_pipeline_outlet_filter(request, outlet_data, user, models)
|
||||
except Exception as e:
|
||||
log.debug(f'Pipeline outlet filter error: {e}')
|
||||
|
||||
# Function outlet filters
|
||||
extra_params = {
|
||||
'__event_emitter__': event_emitter,
|
||||
'__event_call__': event_caller,
|
||||
'__user__': user.model_dump() if isinstance(user, UserModel) else {},
|
||||
'__metadata__': metadata,
|
||||
'__request__': request,
|
||||
'__model__': model,
|
||||
}
|
||||
|
||||
filter_ids = await get_sorted_filter_ids(request, model, metadata.get('filter_ids', []))
|
||||
filter_functions = await Functions.get_functions_by_ids(filter_ids)
|
||||
|
||||
outlet_result, _ = await process_filter_functions(
|
||||
request=request,
|
||||
filter_functions=filter_functions,
|
||||
filter_type='outlet',
|
||||
form_data=outlet_data,
|
||||
extra_params=extra_params,
|
||||
)
|
||||
|
||||
# Persist outlet-modified content and notify frontend
|
||||
# (skip DB persistence for temp chats — they have no DB record)
|
||||
if outlet_result and outlet_result.get('messages'):
|
||||
if not is_temp_chat and messages_map:
|
||||
for message in outlet_result['messages']:
|
||||
outlet_message_id = message.get('id')
|
||||
if outlet_message_id and outlet_message_id in messages_map:
|
||||
original_message = messages_map[outlet_message_id]
|
||||
if original_message.get('content') != message.get('content'):
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
chat_id,
|
||||
outlet_message_id,
|
||||
{
|
||||
'content': message['content'],
|
||||
'originalContent': original_message.get('content'),
|
||||
},
|
||||
)
|
||||
|
||||
if event_emitter:
|
||||
await event_emitter(
|
||||
{
|
||||
'type': 'chat:outlet',
|
||||
'data': {'messages': outlet_result['messages']},
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f'Error running outlet filters: {e}')
|
||||
|
||||
|
||||
async def non_streaming_chat_response_handler(response, ctx):
|
||||
request = ctx['request']
|
||||
|
||||
|
|
@ -3169,7 +3366,7 @@ async def non_streaming_chat_response_handler(response, ctx):
|
|||
await post_webhook(
|
||||
request.app.state.WEBUI_NAME,
|
||||
webhook_url,
|
||||
f'{title} - {request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}\n\n{content}',
|
||||
f'{content}\n\n{title} - {request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}',
|
||||
{
|
||||
'action': 'chat',
|
||||
'message': content,
|
||||
|
|
@ -3179,6 +3376,12 @@ async def non_streaming_chat_response_handler(response, ctx):
|
|||
)
|
||||
|
||||
await background_tasks_handler(ctx)
|
||||
ctx['assistant_message'] = {
|
||||
'content': content,
|
||||
'output': response_output,
|
||||
**({'usage': usage} if usage else {}),
|
||||
}
|
||||
await outlet_filter_handler(ctx)
|
||||
|
||||
response = build_response_object(response, merge_events_into_response(response_data, events))
|
||||
except Exception as e:
|
||||
|
|
@ -3603,6 +3806,40 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
elif data.get('type', '').startswith('response.'):
|
||||
output, response_metadata = handle_responses_streaming_event(data, output)
|
||||
|
||||
# Emit citation sources from finalized output items
|
||||
# (mirrors Chat Completions annotation handling at delta level)
|
||||
if data.get('type') == 'response.output_item.done':
|
||||
item = data.get('item', {})
|
||||
if item.get('type') == 'message':
|
||||
for part in item.get('content', []):
|
||||
for annotation in part.get('annotations', []):
|
||||
if annotation.get('type') == 'url_citation':
|
||||
# Handle both flat (Responses API) and nested (Chat Completions) formats
|
||||
url_citation = annotation.get('url_citation', annotation)
|
||||
|
||||
url = url_citation.get('url', '')
|
||||
title = url_citation.get('title', url)
|
||||
|
||||
if url:
|
||||
await event_emitter(
|
||||
{
|
||||
'type': 'source',
|
||||
'data': {
|
||||
'source': {
|
||||
'name': title,
|
||||
'url': url,
|
||||
},
|
||||
'document': [title],
|
||||
'metadata': [
|
||||
{
|
||||
'source': url,
|
||||
'name': title,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
processed_data = {
|
||||
'output': full_output(),
|
||||
'content': serialize_output(full_output()),
|
||||
|
|
@ -4673,7 +4910,7 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
await post_webhook(
|
||||
request.app.state.WEBUI_NAME,
|
||||
webhook_url,
|
||||
f'{title} - {request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}\n\n{content}',
|
||||
f'{content}\n\n{title} - {request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}',
|
||||
{
|
||||
'action': 'chat',
|
||||
'message': content,
|
||||
|
|
@ -4690,27 +4927,41 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
)
|
||||
|
||||
await background_tasks_handler(ctx)
|
||||
ctx['assistant_message'] = {
|
||||
'content': serialize_output(output),
|
||||
'output': output,
|
||||
**({'usage': usage} if usage else {}),
|
||||
}
|
||||
await outlet_filter_handler(ctx)
|
||||
except asyncio.CancelledError:
|
||||
log.warning('Task was cancelled!')
|
||||
await event_emitter({'type': 'chat:tasks:cancel'})
|
||||
try:
|
||||
await asyncio.shield(event_emitter({'type': 'chat:tasks:cancel'}))
|
||||
|
||||
if not ENABLE_REALTIME_CHAT_SAVE:
|
||||
# Save message in the database
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata['chat_id'],
|
||||
metadata['message_id'],
|
||||
{
|
||||
'done': True,
|
||||
'content': serialize_output(output),
|
||||
'output': output,
|
||||
},
|
||||
)
|
||||
else:
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata['chat_id'],
|
||||
metadata['message_id'],
|
||||
{'done': True},
|
||||
)
|
||||
if not ENABLE_REALTIME_CHAT_SAVE:
|
||||
# Save message in the database
|
||||
await asyncio.shield(
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata['chat_id'],
|
||||
metadata['message_id'],
|
||||
{
|
||||
'done': True,
|
||||
'content': serialize_output(output),
|
||||
'output': output,
|
||||
},
|
||||
)
|
||||
)
|
||||
else:
|
||||
await asyncio.shield(
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata['chat_id'],
|
||||
metadata['message_id'],
|
||||
{'done': True},
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise # re-raise CancelledError for proper propagation
|
||||
|
||||
if response.background is not None:
|
||||
await response.background()
|
||||
|
|
|
|||
|
|
@ -148,19 +148,22 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
|
|||
messages = []
|
||||
pending_tool_calls = []
|
||||
pending_content = []
|
||||
pending_reasoning = ''
|
||||
|
||||
def flush_pending():
|
||||
nonlocal pending_content, pending_tool_calls
|
||||
if pending_content or pending_tool_calls:
|
||||
nonlocal pending_content, pending_tool_calls, pending_reasoning
|
||||
if pending_content or pending_tool_calls or pending_reasoning:
|
||||
messages.append(
|
||||
{
|
||||
'role': 'assistant',
|
||||
'content': '\n'.join(pending_content) if pending_content else '',
|
||||
**({'tool_calls': pending_tool_calls} if pending_tool_calls else {}),
|
||||
**({'reasoning_content': pending_reasoning} if pending_reasoning else {}),
|
||||
}
|
||||
)
|
||||
pending_content = []
|
||||
pending_tool_calls = []
|
||||
pending_reasoning = ''
|
||||
|
||||
for item in output:
|
||||
item_type = item.get('type', '')
|
||||
|
|
@ -245,6 +248,10 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
|
|||
start_tag = item.get('start_tag', '<think>')
|
||||
end_tag = item.get('end_tag', '</think>')
|
||||
pending_content.append(f'{start_tag}{reasoning_text}{end_tag}')
|
||||
# Preserve raw reasoning text as reasoning_content for
|
||||
# providers that require it on assistant tool-call messages
|
||||
# (e.g. Moonshot/Kimi K2.5).
|
||||
pending_reasoning += reasoning_text
|
||||
# else: skip reasoning blocks for normal LLM messages
|
||||
|
||||
elif item_type == 'open_webui:code_interpreter':
|
||||
|
|
@ -906,10 +913,16 @@ async def cleanup_response(
|
|||
):
|
||||
if response:
|
||||
if not response.closed:
|
||||
await response.close()
|
||||
# aiohttp 3.9+ made ClientResponse.close() synchronous (returns None).
|
||||
# Older versions returned a coroutine. Handle both gracefully.
|
||||
result = response.close()
|
||||
if result is not None:
|
||||
await result
|
||||
if session:
|
||||
if not session.closed:
|
||||
await session.close()
|
||||
result = session.close()
|
||||
if result is not None:
|
||||
await result
|
||||
|
||||
|
||||
async def stream_wrapper(response, session, content_handler=None):
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ from open_webui.utils.plugin import (
|
|||
load_function_module_by_id,
|
||||
get_function_module_from_cache,
|
||||
)
|
||||
from open_webui.utils.access_control import has_access
|
||||
from open_webui.utils.access_control import has_access, has_base_model_access
|
||||
|
||||
|
||||
from open_webui.config import (
|
||||
|
|
@ -283,11 +283,15 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
|||
# Pre-warm the function module cache once per unique function ID.
|
||||
# This ensures each function's DB freshness check runs exactly once,
|
||||
# not once per (model × function) pair.
|
||||
for function_id in all_function_ids:
|
||||
# Only attempt to load functions that actually exist in the local DB;
|
||||
# imported/custom model configs may reference tools or filters the user
|
||||
# hasn't installed, and trying to load those would cause persistent
|
||||
# "Failed to load function module" log spam on every model refresh.
|
||||
for function_id in functions_by_id:
|
||||
try:
|
||||
await get_function_module_from_cache(request, function_id)
|
||||
except Exception as e:
|
||||
log.info(f'Failed to load function module for {function_id}: {e}')
|
||||
log.debug(f'Failed to load function module for {function_id}: {e}')
|
||||
|
||||
# Apply global model defaults to all models
|
||||
# Per-model overrides take precedence over global defaults
|
||||
|
|
@ -404,6 +408,10 @@ async def check_model_access(user, model, db=None):
|
|||
):
|
||||
raise Exception('Model not found')
|
||||
|
||||
# Enforce access on chained base models
|
||||
if not await has_base_model_access(user.id, model_info, db=db):
|
||||
raise Exception('Model not found')
|
||||
|
||||
|
||||
async def get_filtered_models(models, user, db=None):
|
||||
# Filter out models that the user does not have access to
|
||||
|
|
|
|||
|
|
@ -140,6 +140,41 @@ auth_manager_config.OAUTH_UPDATE_EMAIL_ON_LOGIN = OAUTH_UPDATE_EMAIL_ON_LOGIN
|
|||
auth_manager_config.OAUTH_AUDIENCE = OAUTH_AUDIENCE
|
||||
|
||||
|
||||
# Conservative default when the provider omits both expires_in and expires_at.
|
||||
# Matches the value recommended by Authlib's compliance_fix documentation.
|
||||
DEFAULT_TOKEN_EXPIRY_SECONDS = 3600
|
||||
|
||||
|
||||
def _normalize_token_expiry(token: dict) -> dict:
|
||||
"""Ensure a token dict always has a numeric ``expires_at``.
|
||||
|
||||
Resolution order:
|
||||
1. If *expires_at* is already present and non-None, trust it.
|
||||
2. Else if *expires_in* is present and non-None, compute *expires_at*.
|
||||
3. Otherwise fall back to ``DEFAULT_TOKEN_EXPIRY_SECONDS`` and log a
|
||||
warning so operators can identify providers that omit expiration.
|
||||
|
||||
Also stamps *issued_at* for auditing.
|
||||
"""
|
||||
token['issued_at'] = datetime.now().timestamp()
|
||||
|
||||
if token.get('expires_at') is not None:
|
||||
token['expires_at'] = int(token['expires_at'])
|
||||
return token
|
||||
|
||||
if token.get('expires_in') is not None:
|
||||
token['expires_at'] = int(datetime.now().timestamp() + token['expires_in'])
|
||||
return token
|
||||
|
||||
# Neither field present — conservative fallback
|
||||
log.warning(
|
||||
"OAuth token response missing both 'expires_in' and 'expires_at'; "
|
||||
f'defaulting to {DEFAULT_TOKEN_EXPIRY_SECONDS}s from now'
|
||||
)
|
||||
token['expires_at'] = int(datetime.now().timestamp() + DEFAULT_TOKEN_EXPIRY_SECONDS)
|
||||
return token
|
||||
|
||||
|
||||
FERNET = None
|
||||
|
||||
if len(OAUTH_CLIENT_INFO_ENCRYPTION_KEY) != 44:
|
||||
|
|
@ -478,10 +513,12 @@ async def get_oauth_client_info_with_static_credentials(
|
|||
log.error(f'Error parsing OAuth metadata from {url}: {e}')
|
||||
continue
|
||||
|
||||
# Determine scope from server metadata if available
|
||||
# Let the OAuth provider apply its default scopes.
|
||||
# We intentionally do NOT join all scopes_supported here — that list
|
||||
# represents every scope the server *can* grant, not what the client
|
||||
# should request. Requesting all of them is almost always wrong and
|
||||
# can break providers like Entra ID that require resource-specific scopes.
|
||||
scope = None
|
||||
if oauth_server_metadata and oauth_server_metadata.scopes_supported:
|
||||
scope = ' '.join(oauth_server_metadata.scopes_supported)
|
||||
|
||||
# Determine token_endpoint_auth_method
|
||||
token_endpoint_auth_method = 'client_secret_post'
|
||||
|
|
@ -513,6 +550,24 @@ async def get_oauth_client_info_with_static_credentials(
|
|||
raise e
|
||||
|
||||
|
||||
def resolve_oauth_client_info(connection: dict) -> dict:
|
||||
"""
|
||||
Decrypt OAuth client info from a tool server connection config.
|
||||
|
||||
For oauth_2.1_static, overlays admin-provided credentials from
|
||||
info.oauth_client_id and info.oauth_client_secret onto the blob.
|
||||
"""
|
||||
info = connection.get('info', {})
|
||||
data = decrypt_data(info.get('oauth_client_info', ''))
|
||||
|
||||
if connection.get('auth_type') == 'oauth_2.1_static':
|
||||
if info.get('oauth_client_id') and info.get('oauth_client_secret'):
|
||||
data['client_id'] = info['oauth_client_id']
|
||||
data['client_secret'] = info['oauth_client_secret']
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class OAuthClientManager:
|
||||
def __init__(self, app):
|
||||
self.oauth = OAuth()
|
||||
|
|
@ -536,15 +591,20 @@ class OAuthClientManager:
|
|||
'server_metadata_url': (oauth_client_info.issuer if oauth_client_info.issuer else None),
|
||||
}
|
||||
|
||||
if oauth_client_info.server_metadata and oauth_client_info.server_metadata.code_challenge_methods_supported:
|
||||
if (
|
||||
isinstance(
|
||||
oauth_client_info.server_metadata.code_challenge_methods_supported,
|
||||
list,
|
||||
)
|
||||
and 'S256' in oauth_client_info.server_metadata.code_challenge_methods_supported
|
||||
):
|
||||
kwargs['code_challenge_method'] = 'S256'
|
||||
# Default to S256 for OAuth 2.1 (PKCE is mandatory per RFC 9700)
|
||||
kwargs['code_challenge_method'] = 'S256'
|
||||
|
||||
# Only remove PKCE if metadata explicitly excludes S256
|
||||
if (
|
||||
oauth_client_info.server_metadata
|
||||
and oauth_client_info.server_metadata.code_challenge_methods_supported
|
||||
and isinstance(
|
||||
oauth_client_info.server_metadata.code_challenge_methods_supported,
|
||||
list,
|
||||
)
|
||||
and 'S256' not in oauth_client_info.server_metadata.code_challenge_methods_supported
|
||||
):
|
||||
del kwargs['code_challenge_method']
|
||||
|
||||
self.clients[client_id] = {
|
||||
'client': self.oauth.register(**kwargs),
|
||||
|
|
@ -584,7 +644,7 @@ class OAuthClientManager:
|
|||
continue
|
||||
|
||||
try:
|
||||
oauth_client_info = decrypt_data(oauth_client_info)
|
||||
oauth_client_info = resolve_oauth_client_info(connection)
|
||||
return self.add_client(expected_client_id, OAuthClientInformationFull(**oauth_client_info))['client']
|
||||
except Exception as e:
|
||||
log.error(f'Failed to lazily add OAuth client {expected_client_id} from config: {e}')
|
||||
|
|
@ -707,7 +767,11 @@ class OAuthClientManager:
|
|||
log.warning(f'No OAuth session found for user {user_id}, client_id {client_id}')
|
||||
return None
|
||||
|
||||
if force_refresh or datetime.now() + timedelta(minutes=5) >= datetime.fromtimestamp(session.expires_at):
|
||||
if (
|
||||
force_refresh
|
||||
or session.expires_at is None
|
||||
or datetime.now() + timedelta(minutes=5) >= datetime.fromtimestamp(session.expires_at)
|
||||
):
|
||||
log.debug(f'Token refresh needed for user {user_id}, client_id {session.provider}')
|
||||
refreshed_token = await self._refresh_token(session)
|
||||
if refreshed_token:
|
||||
|
|
@ -818,14 +882,7 @@ class OAuthClientManager:
|
|||
if 'refresh_token' not in new_token_data:
|
||||
new_token_data['refresh_token'] = token_data['refresh_token']
|
||||
|
||||
# Add timestamp for tracking
|
||||
new_token_data['issued_at'] = datetime.now().timestamp()
|
||||
|
||||
# Calculate expires_at if we have expires_in
|
||||
if 'expires_in' in new_token_data and 'expires_at' not in new_token_data:
|
||||
new_token_data['expires_at'] = int(
|
||||
datetime.now().timestamp() + new_token_data['expires_in']
|
||||
)
|
||||
_normalize_token_expiry(new_token_data)
|
||||
|
||||
log.debug(f'Token refresh successful for client_id {client_id}')
|
||||
return new_token_data
|
||||
|
|
@ -878,12 +935,7 @@ class OAuthClientManager:
|
|||
|
||||
if token:
|
||||
try:
|
||||
# Add timestamp for tracking
|
||||
token['issued_at'] = datetime.now().timestamp()
|
||||
|
||||
# Calculate expires_at if we have expires_in
|
||||
if 'expires_in' in token and 'expires_at' not in token:
|
||||
token['expires_at'] = datetime.now().timestamp() + token['expires_in']
|
||||
_normalize_token_expiry(token)
|
||||
|
||||
# Clean up any existing sessions for this user/client_id first
|
||||
sessions = await OAuthSessions.get_sessions_by_user_id(user_id)
|
||||
|
|
@ -970,7 +1022,11 @@ class OAuthManager:
|
|||
log.warning(f'No OAuth session found for user {user_id}, session {session_id}')
|
||||
return None
|
||||
|
||||
if force_refresh or datetime.now() + timedelta(minutes=5) >= datetime.fromtimestamp(session.expires_at):
|
||||
if (
|
||||
force_refresh
|
||||
or session.expires_at is None
|
||||
or datetime.now() + timedelta(minutes=5) >= datetime.fromtimestamp(session.expires_at)
|
||||
):
|
||||
log.debug(f'Token refresh needed for user {user_id}, provider {session.provider}')
|
||||
refreshed_token = await self._refresh_token(session)
|
||||
if refreshed_token:
|
||||
|
|
@ -1084,14 +1140,7 @@ class OAuthManager:
|
|||
if 'refresh_token' not in new_token_data:
|
||||
new_token_data['refresh_token'] = token_data['refresh_token']
|
||||
|
||||
# Add timestamp for tracking
|
||||
new_token_data['issued_at'] = datetime.now().timestamp()
|
||||
|
||||
# Calculate expires_at if we have expires_in
|
||||
if 'expires_in' in new_token_data and 'expires_at' not in new_token_data:
|
||||
new_token_data['expires_at'] = int(
|
||||
datetime.now().timestamp() + new_token_data['expires_in']
|
||||
)
|
||||
_normalize_token_expiry(new_token_data)
|
||||
|
||||
log.debug(f'Token refresh successful for provider {provider}')
|
||||
return new_token_data
|
||||
|
|
@ -1689,12 +1738,7 @@ class OAuthManager:
|
|||
)
|
||||
|
||||
try:
|
||||
# Add timestamp for tracking
|
||||
token['issued_at'] = datetime.now().timestamp()
|
||||
|
||||
# Calculate expires_at if we have expires_in
|
||||
if 'expires_in' in token and 'expires_at' not in token:
|
||||
token['expires_at'] = datetime.now().timestamp() + token['expires_in']
|
||||
_normalize_token_expiry(token)
|
||||
|
||||
# Enforce max concurrent sessions per user/provider to prevent
|
||||
# unbounded growth while allowing multi-device usage
|
||||
|
|
|
|||
|
|
@ -204,6 +204,11 @@ def convert_messages_openai_to_ollama(messages: list[dict]) -> list[dict]:
|
|||
# Initialize the new message structure with the role
|
||||
new_message = {'role': message['role']}
|
||||
|
||||
# Preserve Ollama-native 'thinking' field (used by reasoning models,
|
||||
# may be injected by filter inlet functions).
|
||||
if 'thinking' in message:
|
||||
new_message['thinking'] = message['thinking']
|
||||
|
||||
content = message.get('content', [])
|
||||
tool_calls = message.get('tool_calls', None)
|
||||
tool_call_id = message.get('tool_call_id', None)
|
||||
|
|
|
|||
|
|
@ -93,10 +93,16 @@ async def cleanup_response(
|
|||
"""
|
||||
if response:
|
||||
if not response.closed:
|
||||
await response.close()
|
||||
# aiohttp 3.9+ made ClientResponse.close() synchronous (returns None).
|
||||
# Older versions returned a coroutine. Handle both gracefully.
|
||||
result = response.close()
|
||||
if result is not None:
|
||||
await result
|
||||
if session:
|
||||
if not session.closed:
|
||||
await session.close()
|
||||
result = session.close()
|
||||
if result is not None:
|
||||
await result
|
||||
|
||||
|
||||
async def stream_wrapper(response, session=None, content_handler=None):
|
||||
|
|
|
|||
|
|
@ -13,15 +13,15 @@ cryptography
|
|||
bcrypt==5.0.0
|
||||
argon2-cffi==25.1.0
|
||||
PyJWT[crypto]==2.11.0
|
||||
authlib==1.6.9
|
||||
authlib==1.6.10
|
||||
|
||||
requests==2.32.5
|
||||
aiohttp==3.13.2 # do not update to 3.13.3 - broken
|
||||
requests==2.33.1
|
||||
aiohttp==3.13.5 # do not update to 3.13.3 - broken
|
||||
async-timeout
|
||||
aiocache
|
||||
aiofiles
|
||||
starlette-compress==1.7.0
|
||||
Brotli==1.1.0
|
||||
Brotli==1.2.0
|
||||
httpx[socks,http2,zstd,cli,brotli]==0.28.1
|
||||
starsessions[redis]==2.2.1
|
||||
|
||||
|
|
@ -52,7 +52,7 @@ langchain-text-splitters==1.1.1
|
|||
fake-useragent==2.2.0
|
||||
|
||||
chromadb==1.5.2
|
||||
black==26.1.0
|
||||
black==26.3.1
|
||||
pydub
|
||||
chardet==5.2.0
|
||||
beautifulsoup4
|
||||
|
|
|
|||
|
|
@ -10,15 +10,15 @@ cryptography==46.0.5
|
|||
bcrypt==5.0.0
|
||||
argon2-cffi==25.1.0
|
||||
PyJWT[crypto]==2.11.0
|
||||
authlib==1.6.9
|
||||
authlib==1.6.10
|
||||
|
||||
requests==2.32.5
|
||||
aiohttp==3.13.2 # do not update to 3.13.3 - broken
|
||||
requests==2.33.1
|
||||
aiohttp==3.13.5 # do not update to 3.13.3 - broken
|
||||
async-timeout==5.0.1
|
||||
aiocache==0.12.3
|
||||
aiofiles==25.1.0
|
||||
starlette-compress==1.7.0
|
||||
Brotli==1.1.0
|
||||
Brotli==1.2.0
|
||||
httpx[socks,http2,zstd,cli,brotli]==0.28.1
|
||||
starsessions[redis]==2.2.1
|
||||
python-mimeparse==2.0.0
|
||||
|
|
@ -58,8 +58,8 @@ chromadb==1.5.2
|
|||
weaviate-client==4.20.3
|
||||
opensearch-py==3.1.0
|
||||
|
||||
transformers==5.3.0
|
||||
sentence-transformers==5.2.3
|
||||
transformers==5.5.4
|
||||
sentence-transformers==5.4.0
|
||||
accelerate==1.13.0
|
||||
pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897
|
||||
einops==0.8.2
|
||||
|
|
@ -95,7 +95,7 @@ rank-bm25==0.2.2
|
|||
onnxruntime==1.24.3
|
||||
faster-whisper==1.2.1
|
||||
|
||||
black==26.1.0
|
||||
black==26.3.1
|
||||
youtube-transcript-api==1.2.4
|
||||
pytube==15.0.0
|
||||
|
||||
|
|
|
|||
|
|
@ -1,88 +0,0 @@
|
|||
# Contributing to Open WebUI
|
||||
|
||||
🚀 **Welcome, Contributors!** 🚀
|
||||
|
||||
Your interest in contributing to Open WebUI is greatly appreciated. This document is here to guide you through the process, ensuring your contributions enhance the project effectively. Let's make Open WebUI even better, together!
|
||||
|
||||
## 📌 Key Points
|
||||
|
||||
### 🦙 Ollama vs. Open WebUI
|
||||
|
||||
It's crucial to distinguish between Ollama and Open WebUI:
|
||||
|
||||
- **Open WebUI** focuses on providing an intuitive and responsive web interface for chat interactions.
|
||||
- **Ollama** is the underlying technology that powers these interactions.
|
||||
|
||||
If your issue or contribution pertains directly to the core Ollama technology, please direct it to the appropriate [Ollama project repository](https://ollama.com/). Open WebUI's repository is dedicated to the web interface aspect only.
|
||||
|
||||
### 🚨 Reporting Issues
|
||||
|
||||
Noticed something off? Have an idea? Check our [Issues tab](https://github.com/open-webui/open-webui/issues) to see if it's already been reported or suggested. If not, feel free to open a new issue. When reporting an issue, please follow our issue templates. These templates are designed to ensure that all necessary details are provided from the start, enabling us to address your concerns more efficiently.
|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> - **Template Compliance:** Please be aware that failure to follow the provided issue template, or not providing the requested information at all, will likely result in your issue being closed without further consideration. This approach is critical for maintaining the manageability and integrity of issue tracking.
|
||||
> - **Detail is Key:** To ensure your issue is understood and can be effectively addressed, it's imperative to include comprehensive details. Descriptions should be clear, including steps to reproduce, expected outcomes, and actual results. Lack of sufficient detail may hinder our ability to resolve your issue.
|
||||
|
||||
> [!WARNING]
|
||||
> Reporting vulnerabilities is not wanted through Issues!
|
||||
> Instead, [use the security reporting functionality](https://github.com/open-webui/open-webui/security) and ensure you comply with the outlined requirements.
|
||||
|
||||
### 🧭 Scope of Support
|
||||
|
||||
We've noticed an uptick in issues not directly related to Open WebUI but rather to the environment it's run in, especially Docker setups. While we strive to support Docker deployment, understanding Docker fundamentals is crucial for a smooth experience.
|
||||
|
||||
- **Docker Deployment Support**: Open WebUI supports Docker deployment. Familiarity with Docker is assumed. For Docker basics, please refer to the [official Docker documentation](https://docs.docker.com/get-started/overview/).
|
||||
|
||||
- **Advanced Configurations**: Setting up reverse proxies for HTTPS and managing Docker deployments requires foundational knowledge. There are numerous online resources available to learn these skills. Ensuring you have this knowledge will greatly enhance your experience with Open WebUI and similar projects.
|
||||
|
||||
- **Check the documentation and help improve it**: [Our documentation](https://docs.openwebui.com) has ever growing troubleshooting guides and detailed installation tutorials. Please verify if it is of help to your issue and help expand it by submitting issues and PRs on our [Docs Repository](https://github.com/open-webui/docs).
|
||||
|
||||
## 💡 Contributing
|
||||
|
||||
Looking to contribute? Great! Here's how you can help:
|
||||
|
||||
### 🛠 Pull Requests
|
||||
|
||||
We welcome pull requests. Before submitting one, please:
|
||||
|
||||
1. Open a discussion regarding your ideas [here](https://github.com/open-webui/open-webui/discussions/new/choose).
|
||||
2. Follow the project's coding standards and include tests for new features.
|
||||
3. Update documentation as necessary.
|
||||
4. Write clear, descriptive commit messages.
|
||||
5. It's essential to complete your pull request in a timely manner. We move fast, and having PRs hang around too long is not feasible. If you can't get it done within a reasonable time frame, we may have to close it to keep the project moving forward.
|
||||
|
||||
> [!NOTE]
|
||||
> The Pull Request Template has various requirements outlined. Go through the PR-checklist one by one and ensure you completed all steps before submitting your PR for review (you can open it as draft otherwise!).
|
||||
|
||||
### 📚 Documentation & Tutorials
|
||||
|
||||
Help us make Open WebUI more accessible by improving the documentation, writing tutorials, or creating guides on setting up and optimizing the Web UI.
|
||||
|
||||
Help expand our documentation by submitting issues and PRs on our [Docs Repository](https://github.com/open-webui/docs).
|
||||
We welcome tutorials, guides and other documentation improvements!
|
||||
|
||||
### 🌐 Translations and Internationalization
|
||||
|
||||
Help us make Open WebUI available to a wider audience. In this section, we'll guide you through the process of adding new translations to the project.
|
||||
|
||||
We use JSON files to store translations. You can find the existing translation files in the `src/lib/i18n/locales` directory. Each directory corresponds to a specific language, for example, `en-US` for English (US), `fr-FR` for French (France) and so on. You can refer to [ISO 639 Language Codes](http://www.lingoes.net/en/translator/langcode.htm) to find the appropriate code for a specific language.
|
||||
|
||||
To add a new language:
|
||||
|
||||
- Create a new directory in the `src/lib/i18n/locales` path with the appropriate language code as its name. For instance, if you're adding translations for Spanish (Spain), create a new directory named `es-ES`.
|
||||
- Copy the American English translation file(s) (from `en-US` directory in `src/lib/i18n/locale`) to this new directory and update the string values in JSON format according to your language. Make sure to preserve the structure of the JSON object.
|
||||
- Add the language code and its respective title to languages file at `src/lib/i18n/locales/languages.json`.
|
||||
|
||||
> [!NOTE]
|
||||
> When adding new translations, do so in a standalone PR! Feature PRs or PRs fixing a bug should not contain translation updates. Always keep the scope of a PR narrow.
|
||||
|
||||
### 🤔 Questions & Feedback
|
||||
|
||||
Got questions or feedback? Join our [Discord community](https://discord.gg/5rJgQTnV4s) or open an issue or discussion. We're here to help!
|
||||
|
||||
## 🙏 Thank You!
|
||||
|
||||
Your contributions, big or small, make a significant impact on Open WebUI. We're excited to see what you bring to the project!
|
||||
|
||||
Together, let's create an even more powerful tool for the community. 🌟
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
# Project workflow
|
||||
|
||||
[](https://mermaid.live/edit#pako:eNq1k01rAjEQhv_KkFNLFe1N9iAUevFSRVl6Cci4Gd1ANtlmsmtF_O_N7iqtHxR76ClhMu87zwyZvcicIpEIpo-KbEavGjceC2lL9EFnukQbIGXygNye5y9TY7DAZTpZLsjXXVYXg3dapRM4hh9mu5A7-3hTfSXtAtJK21Tsj8dPl3USmJZkGVbebWNKD2rNOjAYl6HJHYdkNBwNpb3U9aNZvzFNYE6h8tFiSyZzBUGJG4K1dwVwTSYQrCptlLRvLt5dA5i2la5Ruk51Ux0VKQjuxPVbAwuyiuFlNgHfzJ5DoxtgqQf1813gnZRLZ5lAYcD7WT1lpGtiQKug9C4jZrrp-Fd-1-Y1bdzo4dvnZDLz7lPHyj8sOgfg4x84E7RTuEaZt8yRZqtDfgT_rwG2u3Dv_ERPFOQL1Cqu2F5aAClCTgVJkcSrojVWJkgh7SGmYhXcYmczkQRfUU9UZfQ4baRI1miYDl_QqlPg)
|
||||
205
docs/apache.md
205
docs/apache.md
|
|
@ -1,205 +0,0 @@
|
|||
# Hosting UI and Models separately
|
||||
|
||||
Sometimes, it's beneficial to host Ollama, separate from the UI, but retain the RAG and RBAC support features shared across users:
|
||||
|
||||
# Open WebUI Configuration
|
||||
|
||||
## UI Configuration
|
||||
|
||||
For the UI configuration, you can set up the Apache VirtualHost as follows:
|
||||
|
||||
```
|
||||
# Assuming you have a website hosting this UI at "server.com"
|
||||
<VirtualHost 192.168.1.100:80>
|
||||
ServerName server.com
|
||||
DocumentRoot /home/server/public_html
|
||||
|
||||
ProxyPass / http://server.com:3000/ nocanon
|
||||
ProxyPassReverse / http://server.com:3000/
|
||||
# Needed after 0.5
|
||||
ProxyPass / ws://server.com:3000/ nocanon
|
||||
ProxyPassReverse / ws://server.com:3000/
|
||||
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
Enable the site first before you can request SSL:
|
||||
|
||||
`a2ensite server.com.conf` # this will enable the site. a2ensite is short for "Apache 2 Enable Site"
|
||||
|
||||
```
|
||||
# For SSL
|
||||
<VirtualHost 192.168.1.100:443>
|
||||
ServerName server.com
|
||||
DocumentRoot /home/server/public_html
|
||||
|
||||
ProxyPass / http://server.com:3000/ nocanon
|
||||
ProxyPassReverse / http://server.com:3000/
|
||||
# Needed after 0.5
|
||||
ProxyPass / ws://server.com:3000/ nocanon
|
||||
ProxyPassReverse / ws://server.com:3000/
|
||||
|
||||
SSLEngine on
|
||||
SSLCertificateFile /etc/ssl/virtualmin/170514456861234/ssl.cert
|
||||
SSLCertificateKeyFile /etc/ssl/virtualmin/170514456861234/ssl.key
|
||||
SSLProtocol all -SSLv2 -SSLv3 -TLSv1 -TLSv1.1
|
||||
|
||||
SSLProxyEngine on
|
||||
SSLCACertificateFile /etc/ssl/virtualmin/170514456865864/ssl.ca
|
||||
</VirtualHost>
|
||||
|
||||
```
|
||||
|
||||
I'm using virtualmin here for my SSL clusters, but you can also use certbot directly or your preferred SSL method. To use SSL:
|
||||
|
||||
### Prerequisites.
|
||||
|
||||
Run the following commands:
|
||||
|
||||
`snap install certbot --classic`
|
||||
`snap apt install python3-certbot-apache` (this will install the apache plugin).
|
||||
|
||||
Navigate to the apache sites-available directory:
|
||||
|
||||
`cd /etc/apache2/sites-available/`
|
||||
|
||||
Create server.com.conf if it is not yet already created, containing the above `<virtualhost>` configuration (it should match your case. Modify as necessary). Use the one without the SSL:
|
||||
|
||||
Once it's created, run `certbot --apache -d server.com`, this will request and add/create an SSL keys for you as well as create the server.com.le-ssl.conf
|
||||
|
||||
# Configuring Ollama Server
|
||||
|
||||
On your latest installation of Ollama, make sure that you have setup your api server from the official Ollama reference:
|
||||
|
||||
[Ollama FAQ](https://github.com/jmorganca/ollama/blob/main/docs/faq.md)
|
||||
|
||||
### TL;DR
|
||||
|
||||
The guide doesn't seem to match the current updated service file on linux. So, we will address it here:
|
||||
|
||||
Unless when you're compiling Ollama from source, installing with the standard install `curl https://ollama.com/install.sh | sh` creates a file called `ollama.service` in /etc/systemd/system. You can use nano to edit the file:
|
||||
|
||||
```
|
||||
sudo nano /etc/systemd/system/ollama.service
|
||||
```
|
||||
|
||||
Add the following lines:
|
||||
|
||||
```
|
||||
Environment="OLLAMA_HOST=0.0.0.0:11434" # this line is mandatory. You can also specify
|
||||
```
|
||||
|
||||
For instance:
|
||||
|
||||
```
|
||||
[Unit]
|
||||
Description=Ollama Service
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/ollama serve
|
||||
Environment="OLLAMA_HOST=0.0.0.0:11434" # this line is mandatory. You can also specify 192.168.254.109:DIFFERENT_PORT, format
|
||||
Environment="OLLAMA_ORIGINS=http://192.168.254.106:11434,https://models.server.city" # this line is optional
|
||||
User=ollama
|
||||
Group=ollama
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
Environment="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/s>
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
Save the file by pressing CTRL+S, then press CTRL+X
|
||||
|
||||
When your computer restarts, the Ollama server will now be listening on the IP:PORT you specified, in this case 0.0.0.0:11434, or 192.168.254.106:11434 (whatever your local IP address is). Make sure that your router is correctly configured to serve pages from that local IP by forwarding 11434 to your local IP server.
|
||||
|
||||
# Ollama Model Configuration
|
||||
|
||||
## For the Ollama model configuration, use the following Apache VirtualHost setup:
|
||||
|
||||
Navigate to the apache sites-available directory:
|
||||
|
||||
`cd /etc/apache2/sites-available/`
|
||||
|
||||
`nano models.server.city.conf` # match this with your ollama server domain
|
||||
|
||||
Add the following virtualhost containing this example (modify as needed):
|
||||
|
||||
```
|
||||
|
||||
# Assuming you have a website hosting this UI at "models.server.city"
|
||||
<IfModule mod_ssl.c>
|
||||
<VirtualHost 192.168.254.109:443>
|
||||
DocumentRoot "/var/www/html/"
|
||||
ServerName models.server.city
|
||||
<Directory "/var/www/html/">
|
||||
Options None
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
ProxyRequests Off
|
||||
ProxyPreserveHost On
|
||||
ProxyAddHeaders On
|
||||
SSLProxyEngine on
|
||||
|
||||
ProxyPass / http://server.city:1000/ nocanon # or port 11434
|
||||
ProxyPassReverse / http://server.city:1000/ # or port 11434
|
||||
|
||||
SSLCertificateFile /etc/letsencrypt/live/models.server.city/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/models.server.city/privkey.pem
|
||||
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||
</VirtualHost>
|
||||
</IfModule>
|
||||
```
|
||||
|
||||
You may need to enable the site first (if you haven't done so yet) before you can request SSL:
|
||||
|
||||
`a2ensite models.server.city.conf`
|
||||
|
||||
#### For the SSL part of Ollama server
|
||||
|
||||
Run the following commands:
|
||||
|
||||
Navigate to the apache sites-available directory:
|
||||
|
||||
`cd /etc/apache2/sites-available/`
|
||||
`certbot --apache -d server.com`
|
||||
|
||||
```
|
||||
<VirtualHost 192.168.254.109:80>
|
||||
DocumentRoot "/var/www/html/"
|
||||
ServerName models.server.city
|
||||
<Directory "/var/www/html/">
|
||||
Options None
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
ProxyRequests Off
|
||||
ProxyPreserveHost On
|
||||
ProxyAddHeaders On
|
||||
SSLProxyEngine on
|
||||
|
||||
ProxyPass / http://server.city:1000/ nocanon # or port 11434
|
||||
ProxyPassReverse / http://server.city:1000/ # or port 11434
|
||||
|
||||
RewriteEngine on
|
||||
RewriteCond %{SERVER_NAME} =models.server.city
|
||||
RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
|
||||
</VirtualHost>
|
||||
|
||||
```
|
||||
|
||||
Don't forget to restart/reload Apache with `systemctl reload apache2`
|
||||
|
||||
Open your site at https://server.com!
|
||||
|
||||
**Congratulations**, your _**Open-AI-like Chat-GPT style UI**_ is now serving AI with RAG, RBAC and multimodal features! Download Ollama models if you haven't yet done so!
|
||||
|
||||
If you encounter any misconfiguration or errors, please file an issue or engage with our discussion. There are a lot of friendly developers here to assist you.
|
||||
|
||||
Let's make this UI much more user friendly for everyone!
|
||||
|
||||
Thanks for making open-webui your UI Choice for AI!
|
||||
|
||||
This doc is made by **Bob Reyes**, your **Open-WebUI** fan from the Philippines.
|
||||
211
package-lock.json
generated
211
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "open-webui",
|
||||
"version": "0.8.12",
|
||||
"version": "0.9.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "open-webui",
|
||||
"version": "0.8.12",
|
||||
"version": "0.9.0",
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^4.5.0",
|
||||
"@codemirror/lang-javascript": "^6.2.2",
|
||||
|
|
@ -54,6 +54,7 @@
|
|||
"dayjs": "^1.11.10",
|
||||
"dompurify": "^3.2.6",
|
||||
"eventsource-parser": "^1.1.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"file-saver": "^2.0.5",
|
||||
"focus-trap": "^7.6.4",
|
||||
"fuse.js": "^7.0.0",
|
||||
|
|
@ -214,42 +215,40 @@
|
|||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/@chevrotain/cst-dts-gen": {
|
||||
"version": "11.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz",
|
||||
"integrity": "sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==",
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz",
|
||||
"integrity": "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@chevrotain/gast": "11.1.2",
|
||||
"@chevrotain/types": "11.1.2",
|
||||
"lodash-es": "4.17.23"
|
||||
"@chevrotain/gast": "12.0.0",
|
||||
"@chevrotain/types": "12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@chevrotain/gast": {
|
||||
"version": "11.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.1.2.tgz",
|
||||
"integrity": "sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==",
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-12.0.0.tgz",
|
||||
"integrity": "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@chevrotain/types": "11.1.2",
|
||||
"lodash-es": "4.17.23"
|
||||
"@chevrotain/types": "12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@chevrotain/regexp-to-ast": {
|
||||
"version": "11.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.1.2.tgz",
|
||||
"integrity": "sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==",
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz",
|
||||
"integrity": "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@chevrotain/types": {
|
||||
"version": "11.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz",
|
||||
"integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==",
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-12.0.0.tgz",
|
||||
"integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@chevrotain/utils": {
|
||||
"version": "11.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.1.2.tgz",
|
||||
"integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==",
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-12.0.0.tgz",
|
||||
"integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
|
|
@ -1247,9 +1246,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -1373,9 +1372,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -3583,9 +3582,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@sveltejs/kit": {
|
||||
"version": "2.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz",
|
||||
"integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==",
|
||||
"version": "2.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.57.1.tgz",
|
||||
"integrity": "sha512-VRdSbB96cI1EnRh09CqmnQqP/YJvET5buj8S6k7CxaJqBJD4bw4fRKDjcarAj/eX9k2eHifQfDH8NtOh+ZxxPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
|
|
@ -3611,7 +3610,7 @@
|
|||
"@opentelemetry/api": "^1.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0",
|
||||
"svelte": "^4.0.0 || ^5.0.0-next.0",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript": "^5.3.3 || ^6.0.0",
|
||||
"vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
|
|
@ -5389,9 +5388,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.11",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
|
||||
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
|
||||
"version": "0.8.12",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
|
||||
"integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
|
@ -5619,9 +5618,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/anymatch/node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
|
|
@ -5963,9 +5962,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
|
||||
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -6368,29 +6367,31 @@
|
|||
}
|
||||
},
|
||||
"node_modules/chevrotain": {
|
||||
"version": "11.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.2.tgz",
|
||||
"integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==",
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz",
|
||||
"integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@chevrotain/cst-dts-gen": "11.1.2",
|
||||
"@chevrotain/gast": "11.1.2",
|
||||
"@chevrotain/regexp-to-ast": "11.1.2",
|
||||
"@chevrotain/types": "11.1.2",
|
||||
"@chevrotain/utils": "11.1.2",
|
||||
"lodash-es": "4.17.23"
|
||||
"@chevrotain/cst-dts-gen": "12.0.0",
|
||||
"@chevrotain/gast": "12.0.0",
|
||||
"@chevrotain/regexp-to-ast": "12.0.0",
|
||||
"@chevrotain/types": "12.0.0",
|
||||
"@chevrotain/utils": "12.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chevrotain-allstar": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz",
|
||||
"integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==",
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.4.1.tgz",
|
||||
"integrity": "sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash-es": "^4.17.21"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"chevrotain": "^11.0.0"
|
||||
"chevrotain": "^12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
|
|
@ -7743,9 +7744,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz",
|
||||
"integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
|
|
@ -8238,9 +8239,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -8462,7 +8463,6 @@
|
|||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-fifo": {
|
||||
|
|
@ -9051,9 +9051,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
|
|
@ -10152,13 +10152,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/langium": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/langium/-/langium-4.2.1.tgz",
|
||||
"integrity": "sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==",
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/langium/-/langium-4.2.2.tgz",
|
||||
"integrity": "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chevrotain": "~11.1.1",
|
||||
"chevrotain-allstar": "~0.3.1",
|
||||
"@chevrotain/regexp-to-ast": "~12.0.0",
|
||||
"chevrotain": "~12.0.0",
|
||||
"chevrotain-allstar": "~0.4.1",
|
||||
"vscode-languageserver": "~9.0.1",
|
||||
"vscode-languageserver-textdocument": "~1.0.11",
|
||||
"vscode-uri": "~3.1.0"
|
||||
|
|
@ -10600,16 +10601,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
|
||||
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
|
|
@ -10870,9 +10871,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/matcher-collection/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -11103,9 +11104,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/micromatch/node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
|
|
@ -11765,9 +11766,9 @@
|
|||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
|
|
@ -11914,9 +11915,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/postcss-load-config/node_modules/yaml": {
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
|
||||
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
|
||||
"version": "1.10.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
|
||||
"integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
|
|
@ -12326,9 +12327,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.5.4",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
|
||||
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
|
||||
"version": "7.5.5",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz",
|
||||
"integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
|
|
@ -12460,9 +12461,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quick-temp/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -12742,9 +12743,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rimraf/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -15408,9 +15409,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vite-plugin-static-copy/node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
|
|
@ -16152,9 +16153,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/walk-sync/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -16441,9 +16442,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "open-webui",
|
||||
"version": "0.8.12",
|
||||
"version": "0.9.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "npm run pyodide:fetch && vite dev --host",
|
||||
|
|
@ -98,6 +98,7 @@
|
|||
"dayjs": "^1.11.10",
|
||||
"dompurify": "^3.2.6",
|
||||
"eventsource-parser": "^1.1.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"file-saver": "^2.0.5",
|
||||
"focus-trap": "^7.6.4",
|
||||
"fuse.js": "^7.0.0",
|
||||
|
|
|
|||
|
|
@ -18,10 +18,10 @@ dependencies = [
|
|||
"bcrypt==5.0.0",
|
||||
"argon2-cffi==25.1.0",
|
||||
"PyJWT[crypto]==2.11.0",
|
||||
"authlib==1.6.9",
|
||||
"authlib==1.6.10",
|
||||
|
||||
"requests==2.32.5",
|
||||
"aiohttp==3.13.2", # do not update to 3.13.3 - broken
|
||||
"requests==2.33.1",
|
||||
"aiohttp==3.13.5", # do not update to 3.13.3 - broken
|
||||
"async-timeout==5.0.1",
|
||||
"aiocache==0.12.3",
|
||||
"aiofiles==25.1.0",
|
||||
|
|
@ -64,8 +64,8 @@ dependencies = [
|
|||
"PyMySQL==1.1.2",
|
||||
"boto3==1.42.62",
|
||||
|
||||
"transformers==5.3.0",
|
||||
"sentence-transformers==5.2.3",
|
||||
"transformers==5.5.4",
|
||||
"sentence-transformers==5.4.0",
|
||||
"accelerate==1.13.0",
|
||||
"pyarrow==20.0.0", # fix: pin pyarrow version to 20 for rpi compatibility #15897
|
||||
"einops==0.8.2",
|
||||
|
|
@ -100,7 +100,7 @@ dependencies = [
|
|||
"onnxruntime==1.24.3",
|
||||
"faster-whisper==1.2.1",
|
||||
|
||||
"black==26.1.0",
|
||||
"black==26.3.1",
|
||||
"youtube-transcript-api==1.2.4",
|
||||
"pytube==15.0.0",
|
||||
|
||||
|
|
|
|||
|
|
@ -260,6 +260,12 @@ select {
|
|||
display: none;
|
||||
}
|
||||
|
||||
/* Hide leaked Mermaid temp containers if render cleanup misses */
|
||||
body > div[id^='dmermaid-'],
|
||||
body > iframe[id^='imermaid-'] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.scrollbar-hidden:active::-webkit-scrollbar-thumb,
|
||||
.scrollbar-hidden:focus::-webkit-scrollbar-thumb,
|
||||
.scrollbar-hidden:hover::-webkit-scrollbar-thumb {
|
||||
|
|
|
|||
|
|
@ -953,6 +953,73 @@ export const deleteSharedChatById = async (token: string, id: string) => {
|
|||
return res;
|
||||
};
|
||||
|
||||
export const updateChatAccessGrants = async (token: string, id: string, accessGrants: object[]) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/shared/${id}/access/update`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { authorization: `Bearer ${token}` })
|
||||
},
|
||||
body: JSON.stringify({
|
||||
access_grants: accessGrants
|
||||
})
|
||||
})
|
||||
.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 getChatAccessGrants = async (token: string, id: string) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/shared/${id}/access`, {
|
||||
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;
|
||||
};
|
||||
|
||||
export const updateChatById = async (token: string, id: string, chat: object) => {
|
||||
let error = null;
|
||||
|
||||
|
|
|
|||
|
|
@ -313,3 +313,65 @@ export const deleteNoteById = async (token: string, id: string) => {
|
|||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getPinnedNoteList = async (token: string = '') => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/pinned`, {
|
||||
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.detail;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res ?? [];
|
||||
};
|
||||
|
||||
export const toggleNotePinnedStatusById = async (token: string, id: string) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/${id}/pin`, {
|
||||
method: 'POST',
|
||||
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.detail;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -78,21 +78,20 @@
|
|||
return;
|
||||
}
|
||||
|
||||
if (auth_type === 'oauth_2.1_static' && (!oauthClientId || !oauthClientSecret)) {
|
||||
toast.error($i18n.t('Please enter Client ID and Client Secret'));
|
||||
return;
|
||||
}
|
||||
|
||||
// client_id is the tool server ID (used as the internal lookup key for both flows).
|
||||
// For static, client_secret signals the backend to use the static credential path.
|
||||
// The actual OAuth client_id/secret come from the connection info at save time.
|
||||
const formData: { url: string; client_id: string; client_secret?: string } = {
|
||||
url: url,
|
||||
client_id: id
|
||||
client_id: id,
|
||||
...(auth_type === 'oauth_2.1_static' ? { client_secret: oauthClientSecret } : {})
|
||||
};
|
||||
|
||||
// For static OAuth, include client credentials
|
||||
if (auth_type === 'oauth_2.1_static') {
|
||||
if (!oauthClientId || !oauthClientSecret) {
|
||||
toast.error($i18n.t('Please enter Client ID and Client Secret'));
|
||||
return;
|
||||
}
|
||||
formData.client_id = id;
|
||||
formData.client_secret = oauthClientSecret;
|
||||
}
|
||||
|
||||
const res = await registerOAuthClient(localStorage.token, formData, 'mcp').catch((err) => {
|
||||
toast.error($i18n.t('Registration failed'));
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1185,6 +1185,23 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<div class=" mb-2.5 flex w-full justify-between">
|
||||
<div class=" self-center text-xs font-medium">
|
||||
{$i18n.t('Reranking Batch Size')}
|
||||
</div>
|
||||
|
||||
<div class="">
|
||||
<input
|
||||
bind:value={RAGConfig.RAG_RERANKING_BATCH_SIZE}
|
||||
type="number"
|
||||
class=" bg-transparent text-center w-14 outline-none"
|
||||
min="1"
|
||||
max="16000"
|
||||
step="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=" mb-2.5 flex w-full justify-between">
|
||||
<div class=" self-center text-xs font-medium">{$i18n.t('Top K')}</div>
|
||||
<div class="flex items-center relative">
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
<script lang="ts">
|
||||
import type i18nType from '$lib/i18n';
|
||||
import { getContext } from 'svelte';
|
||||
|
||||
import Dropdown from '$lib/components/common/Dropdown.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
const i18n: typeof i18nType = getContext('i18n');
|
||||
|
||||
export let frequency = 'DAILY';
|
||||
export let interval = 1;
|
||||
|
|
@ -23,23 +24,23 @@
|
|||
|
||||
let showDropdown = false;
|
||||
|
||||
const FREQUENCIES = [
|
||||
{ key: 'ONCE', label: 'Once' },
|
||||
{ key: 'HOURLY', label: 'Hourly' },
|
||||
{ key: 'DAILY', label: 'Daily' },
|
||||
{ key: 'WEEKLY', label: 'Weekly' },
|
||||
{ key: 'MONTHLY', label: 'Monthly' },
|
||||
{ key: 'CUSTOM', label: 'Custom' }
|
||||
$: FREQUENCIES = [
|
||||
{ key: 'ONCE', label: $i18n.t('Once') },
|
||||
{ key: 'HOURLY', label: $i18n.t('Hourly') },
|
||||
{ key: 'DAILY', label: $i18n.t('Daily') },
|
||||
{ key: 'WEEKLY', label: $i18n.t('Weekly') },
|
||||
{ key: 'MONTHLY', label: $i18n.t('Monthly') },
|
||||
{ key: 'CUSTOM', label: $i18n.t('Custom') }
|
||||
];
|
||||
|
||||
const DAYS = [
|
||||
{ key: 'MO', label: 'Mo' },
|
||||
{ key: 'TU', label: 'Tu' },
|
||||
{ key: 'WE', label: 'We' },
|
||||
{ key: 'TH', label: 'Th' },
|
||||
{ key: 'FR', label: 'Fr' },
|
||||
{ key: 'SA', label: 'Sa' },
|
||||
{ key: 'SU', label: 'Su' }
|
||||
$: DAYS = [
|
||||
{ key: 'MO', label: $i18n.t('Mo', { context: 'day_of_week' }) },
|
||||
{ key: 'TU', label: $i18n.t('Tu', { context: 'day_of_week' }) },
|
||||
{ key: 'WE', label: $i18n.t('We', { context: 'day_of_week' }) },
|
||||
{ key: 'TH', label: $i18n.t('Th', { context: 'day_of_week' }) },
|
||||
{ key: 'FR', label: $i18n.t('Fr', { context: 'day_of_week' }) },
|
||||
{ key: 'SA', label: $i18n.t('Sa', { context: 'day_of_week' }) },
|
||||
{ key: 'SU', label: $i18n.t('Su', { context: 'day_of_week' }) }
|
||||
];
|
||||
|
||||
let lastVisualFrequency = 'DAILY';
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@
|
|||
type="button"
|
||||
on:click={() => {
|
||||
uploadFilesHandler();
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
<Clip />
|
||||
|
|
@ -62,6 +63,7 @@
|
|||
type="button"
|
||||
on:click={() => {
|
||||
screenCaptureHandler();
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
<Camera />
|
||||
|
|
|
|||
|
|
@ -81,7 +81,6 @@
|
|||
import { processWeb, processWebSearch, processYoutubeVideo } from '$lib/apis/retrieval';
|
||||
import { getAndUpdateUserLocation, getUserSettings } from '$lib/apis/users';
|
||||
import {
|
||||
chatCompleted,
|
||||
generateQueries,
|
||||
chatAction,
|
||||
generateMoACompletion,
|
||||
|
|
@ -181,6 +180,12 @@
|
|||
}
|
||||
|
||||
const navigateHandler = async () => {
|
||||
// Mark the outgoing chat as read before loading the new one.
|
||||
// $chatId still holds the previous chat here — loadChat() updates it.
|
||||
if ($chatId && $chatId !== chatIdProp && !$temporaryChatEnabled) {
|
||||
updateLastReadAt($chatId);
|
||||
}
|
||||
|
||||
loading = true;
|
||||
|
||||
prompt = '';
|
||||
|
|
@ -491,6 +496,23 @@
|
|||
if (autoScroll) {
|
||||
scrollToBottom('smooth');
|
||||
}
|
||||
} else if (type === 'chat:outlet') {
|
||||
// Outlet filter ran on backend — sync in-memory state
|
||||
const outletMessages = data.messages ?? [];
|
||||
for (const msg of outletMessages) {
|
||||
if (msg?.id && history.messages[msg.id]) {
|
||||
const existing = history.messages[msg.id];
|
||||
if (existing.content !== msg.content) {
|
||||
history.messages[msg.id] = {
|
||||
...existing,
|
||||
originalContent: existing.content,
|
||||
...msg
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
history = history;
|
||||
return; // Patches history.messages directly; skip the trailing write-back.
|
||||
} else if (type === 'chat:message:favorite') {
|
||||
// Update message favorite status
|
||||
message.favorite = data.favorite;
|
||||
|
|
@ -1361,6 +1383,17 @@
|
|||
taskIds = taskRes.task_ids;
|
||||
}
|
||||
|
||||
// If no active tasks and current message is incomplete, generation was interrupted
|
||||
const currentMessage = history.currentId ? history.messages[history.currentId] : null;
|
||||
if (
|
||||
currentMessage &&
|
||||
currentMessage.role === 'assistant' &&
|
||||
!currentMessage.done &&
|
||||
(!taskIds || taskIds.length === 0)
|
||||
) {
|
||||
currentMessage.done = true;
|
||||
}
|
||||
|
||||
await tick();
|
||||
|
||||
return true;
|
||||
|
|
@ -1416,71 +1449,12 @@
|
|||
};
|
||||
|
||||
const chatCompletedHandler = async (_chatId, modelId, responseMessageId, messages) => {
|
||||
if (!responseMessageId) {
|
||||
console.error('chatCompleted: missing message id', {
|
||||
chatId: _chatId,
|
||||
modelId,
|
||||
messageCount: messages?.length ?? 0
|
||||
});
|
||||
return;
|
||||
// Backend handles outlet filters and persistence inline.
|
||||
// Just refresh the sidebar chat list.
|
||||
if ($chatId == _chatId && !$temporaryChatEnabled) {
|
||||
currentChatPage.set(1);
|
||||
await chats.set(await getChatList(localStorage.token, $currentChatPage));
|
||||
}
|
||||
|
||||
const res = await chatCompleted(localStorage.token, {
|
||||
model: modelId,
|
||||
messages: messages.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
info: m.info ? m.info : undefined,
|
||||
timestamp: m.timestamp,
|
||||
...(m.usage ? { usage: m.usage } : {}),
|
||||
...(m.sources ? { sources: m.sources } : {})
|
||||
})),
|
||||
filter_ids: selectedFilterIds.length > 0 ? selectedFilterIds : undefined,
|
||||
model_item: $models.find((m) => m.id === modelId),
|
||||
chat_id: _chatId,
|
||||
session_id: $socket?.id,
|
||||
id: responseMessageId
|
||||
}).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
messages.at(-1).error = { content: error };
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res !== null && res.messages) {
|
||||
// Update chat history with the new messages
|
||||
for (const message of res.messages) {
|
||||
if (message?.id) {
|
||||
// Add null check for message and message.id
|
||||
history.messages[message.id] = {
|
||||
...history.messages[message.id],
|
||||
...(history.messages[message.id].content !== message.content
|
||||
? { originalContent: history.messages[message.id].content }
|
||||
: {}),
|
||||
...message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await tick();
|
||||
|
||||
if ($chatId == _chatId) {
|
||||
if (!$temporaryChatEnabled) {
|
||||
chat = await updateChatById(localStorage.token, _chatId, {
|
||||
models: selectedModels,
|
||||
messages: messages,
|
||||
history: history,
|
||||
params: params,
|
||||
files: chatFiles
|
||||
});
|
||||
|
||||
currentChatPage.set(1);
|
||||
await chats.set(await getChatList(localStorage.token, $currentChatPage));
|
||||
}
|
||||
}
|
||||
|
||||
taskIds = null;
|
||||
};
|
||||
|
||||
|
|
@ -1888,13 +1862,15 @@
|
|||
|
||||
history.currentId = userMessageId;
|
||||
|
||||
// focus on chat input
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
chatInput?.focus();
|
||||
// focus on chat input (skip during voice call to avoid triggering mobile keyboard)
|
||||
if (!$showCallOverlay) {
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
chatInput?.focus();
|
||||
}
|
||||
|
||||
saveSessionSelectedModels();
|
||||
|
||||
await sendMessage(history, userMessageId, { newChat: true });
|
||||
await sendMessage(history, userMessageId);
|
||||
};
|
||||
|
||||
const submitHandler = async (userPrompt, { _raw = false } = {}) => {
|
||||
|
|
@ -1994,13 +1970,11 @@
|
|||
{
|
||||
messages = null,
|
||||
modelId = null,
|
||||
modelIdx = null,
|
||||
newChat = false
|
||||
modelIdx = null
|
||||
}: {
|
||||
messages?: any[] | null;
|
||||
modelId?: string | null;
|
||||
modelIdx?: number | null;
|
||||
newChat?: boolean;
|
||||
} = {}
|
||||
) => {
|
||||
if (autoScroll) {
|
||||
|
|
@ -2019,6 +1993,8 @@
|
|||
: selectedModels;
|
||||
|
||||
// Create response messages for each selected model
|
||||
// Build message_ids map: {model_id: assistant_message_id}
|
||||
const messageIdsMap: Record<string, string> = {};
|
||||
for (const [_modelIdx, modelId] of selectedModelIds.entries()) {
|
||||
const model = $models.filter((m) => m.id === modelId).at(0);
|
||||
|
||||
|
|
@ -2030,6 +2006,7 @@
|
|||
childrenIds: [],
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
done: false,
|
||||
model: model.id,
|
||||
modelName: model.name ?? model.id,
|
||||
modelIdx: modelIdx ? modelIdx : _modelIdx,
|
||||
|
|
@ -2042,7 +2019,6 @@
|
|||
|
||||
// Append messageId to childrenIds of parent message
|
||||
if (parentId !== null && history.messages[parentId]) {
|
||||
// Add null check before accessing childrenIds
|
||||
history.messages[parentId].childrenIds = [
|
||||
...history.messages[parentId].childrenIds,
|
||||
responseMessageId
|
||||
|
|
@ -2050,68 +2026,71 @@
|
|||
}
|
||||
|
||||
responseMessageIds[`${modelId}-${modelIdx ? modelIdx : _modelIdx}`] = responseMessageId;
|
||||
messageIdsMap[modelId] = responseMessageId;
|
||||
}
|
||||
}
|
||||
history = history;
|
||||
|
||||
// Create new chat if newChat is true and first user message
|
||||
if (newChat && _history.messages[_history.currentId].parentId === null) {
|
||||
_chatId = await initChatHandler(_history);
|
||||
// New chat — backend generates the chat_id on first request
|
||||
if (!_chatId) {
|
||||
if ($temporaryChatEnabled) {
|
||||
_chatId = `local:${$socket?.id}`;
|
||||
await chatId.set(_chatId);
|
||||
}
|
||||
await tick();
|
||||
}
|
||||
|
||||
await tick();
|
||||
|
||||
// Re-clone history so sendMessageSocket gets the response messages we just added
|
||||
_history = structuredClone(history);
|
||||
// Save chat after all messages have been created
|
||||
await saveChatHandler(_chatId, _history);
|
||||
|
||||
await Promise.all(
|
||||
selectedModelIds.map(async (modelId, _modelIdx) => {
|
||||
console.log('modelId', modelId);
|
||||
const model = $models.filter((m) => m.id === modelId).at(0);
|
||||
// Vision capability check
|
||||
for (const mid of selectedModelIds) {
|
||||
const model = $models.filter((m) => m.id === mid).at(0);
|
||||
if (model) {
|
||||
const hasImages = createMessagesList(_history, parentId).some((message) =>
|
||||
message.files?.some(
|
||||
(file) => file.type === 'image' || (file?.content_type ?? '').startsWith('image/')
|
||||
)
|
||||
);
|
||||
|
||||
if (model) {
|
||||
// If there are image files, check if model is vision capable
|
||||
// Skip this check if image generation is enabled, as images may be for editing or are generated outputs in the history
|
||||
const hasImages = createMessagesList(_history, parentId).some((message) =>
|
||||
message.files?.some(
|
||||
(file) => file.type === 'image' || (file?.content_type ?? '').startsWith('image/')
|
||||
)
|
||||
if (
|
||||
hasImages &&
|
||||
!(model.info?.meta?.capabilities?.vision ?? true) &&
|
||||
!imageGenerationEnabled
|
||||
) {
|
||||
toast.error(
|
||||
$i18n.t('Model {{modelName}} is not vision capable', {
|
||||
modelName: model.name ?? model.id
|
||||
})
|
||||
);
|
||||
|
||||
if (
|
||||
hasImages &&
|
||||
!(model.info?.meta?.capabilities?.vision ?? true) &&
|
||||
!imageGenerationEnabled
|
||||
) {
|
||||
toast.error(
|
||||
$i18n.t('Model {{modelName}} is not vision capable', {
|
||||
modelName: model.name ?? model.id
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
let responseMessageId =
|
||||
responseMessageIds[`${modelId}-${modelIdx ? modelIdx : _modelIdx}`];
|
||||
const chatEventEmitter = await getChatEventEmitter(model.id, _chatId);
|
||||
|
||||
scrollToBottom();
|
||||
await sendMessageSocket(
|
||||
model,
|
||||
messages && messages.length > 0
|
||||
? messages
|
||||
: createMessagesList(_history, responseMessageId),
|
||||
_history,
|
||||
responseMessageId,
|
||||
_chatId
|
||||
);
|
||||
|
||||
if (chatEventEmitter) clearInterval(chatEventEmitter);
|
||||
} else {
|
||||
toast.error($i18n.t(`Model {{modelId}} not found`, { modelId }));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Single request — backend fans out to all models
|
||||
const primaryModelId = selectedModelIds[0];
|
||||
const primaryModel = $models.filter((m) => m.id === primaryModelId).at(0);
|
||||
const primaryResponseMessageId = messageIdsMap[primaryModelId];
|
||||
|
||||
if (primaryModel && primaryResponseMessageId) {
|
||||
const chatEventEmitter = await getChatEventEmitter(primaryModel.id, _chatId);
|
||||
|
||||
scrollToBottom();
|
||||
await sendMessageSocket(
|
||||
primaryModel,
|
||||
messages && messages.length > 0
|
||||
? messages
|
||||
: createMessagesList(_history, primaryResponseMessageId),
|
||||
_history,
|
||||
primaryResponseMessageId,
|
||||
_chatId,
|
||||
selectedModelIds.length > 1 ? messageIdsMap : undefined
|
||||
);
|
||||
|
||||
if (chatEventEmitter) clearInterval(chatEventEmitter);
|
||||
}
|
||||
};
|
||||
|
||||
const getFeatures = () => {
|
||||
|
|
@ -2166,7 +2145,14 @@
|
|||
.map((token) => decodeURIComponent(JSON.parse(`"${token.replace(/"/g, '\\"')}"`)));
|
||||
};
|
||||
|
||||
const sendMessageSocket = async (model, _messages, _history, responseMessageId, _chatId) => {
|
||||
const sendMessageSocket = async (
|
||||
model,
|
||||
_messages,
|
||||
_history,
|
||||
responseMessageId,
|
||||
_chatId,
|
||||
messageIdsMap?: Record<string, string>
|
||||
) => {
|
||||
const responseMessage = _history.messages[responseMessageId];
|
||||
const userMessage = _history.messages[responseMessage.parentId];
|
||||
|
||||
|
|
@ -2217,53 +2203,54 @@
|
|||
$settings?.params?.stream_response ??
|
||||
params?.stream_response ??
|
||||
true;
|
||||
|
||||
// Always include system prompt — backend extracts it and prepends to DB messages.
|
||||
// Only temp chats need conversation messages (persisted chats load from DB).
|
||||
let messages = [
|
||||
params?.system || $settings.system
|
||||
? {
|
||||
role: 'system',
|
||||
content: `${params?.system ?? $settings?.system ?? ''}`
|
||||
}
|
||||
: undefined,
|
||||
..._messages.map((message) => ({
|
||||
...message,
|
||||
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);
|
||||
? { role: 'system', content: `${params?.system ?? $settings?.system ?? ''}` }
|
||||
: undefined
|
||||
].filter(Boolean);
|
||||
if ($temporaryChatEnabled) {
|
||||
messages = [
|
||||
...messages,
|
||||
..._messages.map((message) => ({
|
||||
...message,
|
||||
content: processDetails(message.content),
|
||||
...(message.output ? { output: message.output } : {})
|
||||
}))
|
||||
].filter((message) => message);
|
||||
|
||||
messages = messages
|
||||
.map((message, idx, arr) => {
|
||||
const imageFiles = (message?.files ?? []).filter(
|
||||
(file) => file.type === 'image' || (file?.content_type ?? '').startsWith('image/')
|
||||
);
|
||||
messages = messages
|
||||
.map((message, idx, arr) => {
|
||||
const imageFiles = (message?.files ?? []).filter(
|
||||
(file) => file.type === 'image' || (file?.content_type ?? '').startsWith('image/')
|
||||
);
|
||||
|
||||
return {
|
||||
role: message.role,
|
||||
// Preserve output items so backend can reconstruct tool_calls/tool-role messages (temp chats)
|
||||
...(message.output ? { output: message.output } : {}),
|
||||
...(message.role === 'user' && imageFiles.length > 0
|
||||
? {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: message?.merged?.content ?? message.content
|
||||
},
|
||||
...imageFiles.map((file) => ({
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: file.url
|
||||
}
|
||||
}))
|
||||
]
|
||||
}
|
||||
: {
|
||||
content: message?.merged?.content ?? message.content
|
||||
})
|
||||
};
|
||||
})
|
||||
.filter((message) => message?.role === 'user' || message?.content?.trim());
|
||||
return {
|
||||
role: message.role,
|
||||
...(message.output ? { output: message.output } : {}),
|
||||
...(message.role === 'user' && imageFiles.length > 0
|
||||
? {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: message?.merged?.content ?? message.content
|
||||
},
|
||||
...imageFiles.map((file) => ({
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: file.url
|
||||
}
|
||||
}))
|
||||
]
|
||||
}
|
||||
: {
|
||||
content: message?.merged?.content ?? message.content
|
||||
})
|
||||
};
|
||||
})
|
||||
.filter((message) => message?.role === 'user' || message?.content?.trim());
|
||||
}
|
||||
|
||||
const toolIds = [];
|
||||
const toolServerIds = [];
|
||||
|
|
@ -2320,12 +2307,15 @@
|
|||
// Use the user-selected terminal from the dropdown
|
||||
const activeTerminalId = $selectedTerminalId ?? null;
|
||||
|
||||
// Only send terminal_id if the model has terminal capability enabled
|
||||
const terminalEnabled = model.info?.meta?.capabilities?.terminal ?? true;
|
||||
|
||||
const res = await generateOpenAIChatCompletion(
|
||||
localStorage.token,
|
||||
{
|
||||
stream: stream,
|
||||
model: model.id,
|
||||
messages: messages,
|
||||
...(messages.length > 0 ? { messages } : {}),
|
||||
params: {
|
||||
...$settings?.params,
|
||||
...params,
|
||||
|
|
@ -2337,7 +2327,7 @@
|
|||
filter_ids: selectedFilterIds.length > 0 ? selectedFilterIds : undefined,
|
||||
tool_ids: toolIds.length > 0 ? toolIds : undefined,
|
||||
skill_ids: skillIds.length > 0 ? skillIds : undefined,
|
||||
terminal_id: activeTerminalId ?? undefined,
|
||||
terminal_id: terminalEnabled ? (activeTerminalId ?? undefined) : undefined,
|
||||
tool_servers: [
|
||||
...($toolServers ?? []).filter(
|
||||
(server, idx) => toolServerIds.includes(idx) || toolServerIds.includes(server?.id)
|
||||
|
|
@ -2356,20 +2346,16 @@
|
|||
model_item: $models.find((m) => m.id === model.id),
|
||||
|
||||
session_id: $socket?.id,
|
||||
chat_id: $chatId,
|
||||
chat_id: _chatId || undefined,
|
||||
folder_id: $selectedFolder?.id ?? undefined,
|
||||
|
||||
id: responseMessageId,
|
||||
parent_id: userMessage?.id ?? null,
|
||||
parent_message: userMessage,
|
||||
...(messageIdsMap ? { message_ids: messageIdsMap } : {}),
|
||||
parent_id: userMessage?.parentId ?? null,
|
||||
user_message: userMessage,
|
||||
|
||||
background_tasks: {
|
||||
...(!$temporaryChatEnabled &&
|
||||
(messages.length == 1 ||
|
||||
(messages.length == 2 &&
|
||||
messages.at(0)?.role === 'system' &&
|
||||
messages.at(1)?.role === 'user')) &&
|
||||
(selectedModels[0] === model.id || atSelectedModel !== undefined)
|
||||
...(!$temporaryChatEnabled && !_chatId && (userMessage?.parentId ?? null) === null
|
||||
? {
|
||||
title_generation: $settings?.title?.auto ?? true,
|
||||
tags_generation: $settings?.autoTags ?? true
|
||||
|
|
@ -2418,10 +2404,22 @@
|
|||
if (res.error) {
|
||||
await handleOpenAIError(res.error, responseMessage);
|
||||
} else {
|
||||
// Backend returns task_ids (multi-model) or task_id (single model)
|
||||
const newTaskIds = res.task_ids ?? (res.task_id ? [res.task_id] : []);
|
||||
if (taskIds) {
|
||||
taskIds.push(res.task_id);
|
||||
taskIds.push(...newTaskIds);
|
||||
} else {
|
||||
taskIds = [res.task_id];
|
||||
taskIds = newTaskIds;
|
||||
}
|
||||
|
||||
// Backend returns chat_id for new chats — set store + URL
|
||||
if (res.chat_id && $chatId !== res.chat_id) {
|
||||
await chatId.set(res.chat_id);
|
||||
if (!$temporaryChatEnabled) {
|
||||
window.history.replaceState(history.state, '', `/c/${res.chat_id}`);
|
||||
currentChatPage.set(1);
|
||||
await chats.set(await getChatList(localStorage.token, $currentChatPage));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3013,7 +3011,7 @@
|
|||
if (e.detail || files.length > 0) {
|
||||
await tick();
|
||||
|
||||
submitHandler(e.detail.replaceAll('\n\n', '\n'));
|
||||
submitHandler(e.detail);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
@ -3056,7 +3054,7 @@
|
|||
clearDraft();
|
||||
if (e.detail || files.length > 0) {
|
||||
await tick();
|
||||
submitHandler(e.detail.replaceAll('\n\n', '\n'));
|
||||
submitHandler(e.detail);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { getContext, tick, onDestroy } from 'svelte';
|
||||
import { formatFileSize } from '$lib/utils';
|
||||
import type { FileEntry } from '$lib/apis/terminal';
|
||||
|
|
@ -8,6 +9,7 @@
|
|||
import EllipsisHorizontal from '../../icons/EllipsisHorizontal.svelte';
|
||||
import GarbageBin from '../../icons/GarbageBin.svelte';
|
||||
import Pencil from '../../icons/Pencil.svelte';
|
||||
import Clipboard from '../../icons/Clipboard.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
|
|
@ -315,6 +317,24 @@
|
|||
<div class="flex items-center">{$i18n.t('Download')}</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="select-none flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2 text-sm"
|
||||
on:click={(e) => {
|
||||
e.stopPropagation();
|
||||
const path =
|
||||
entry.type === 'directory'
|
||||
? `${currentPath}${entry.name}/`
|
||||
: `${currentPath}${entry.name}`;
|
||||
navigator.clipboard.writeText(path).then(() => {
|
||||
toast.success($i18n.t('Path copied'));
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Clipboard className="size-4" strokeWidth="1.5" />
|
||||
<div class="flex items-center">{$i18n.t('Copy Path')}</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="select-none flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2 text-sm"
|
||||
|
|
|
|||
|
|
@ -343,7 +343,9 @@
|
|||
}
|
||||
|
||||
chatInputElement?.setText(text);
|
||||
chatInputElement?.focus();
|
||||
if (!$showCallOverlay) {
|
||||
chatInputElement?.focus();
|
||||
}
|
||||
|
||||
if (text !== '') {
|
||||
text = await inputVariableHandler(text);
|
||||
|
|
@ -493,6 +495,11 @@
|
|||
$models.find((m) => m.id === model)?.info?.meta?.capabilities?.code_interpreter ?? true
|
||||
);
|
||||
|
||||
let terminalCapableModels = [];
|
||||
$: terminalCapableModels = (atSelectedModel?.id ? [atSelectedModel.id] : selectedModels).filter(
|
||||
(model) => $models.find((m) => m.id === model)?.info?.meta?.capabilities?.terminal ?? true
|
||||
);
|
||||
|
||||
let toggleFilters = [];
|
||||
$: toggleFilters = (atSelectedModel?.id ? [atSelectedModel.id] : selectedModels)
|
||||
.map((id) => ($models.find((model) => model.id === id) || {})?.filters ?? [])
|
||||
|
|
@ -528,6 +535,11 @@
|
|||
codeInterpreterEnabled = false;
|
||||
}
|
||||
|
||||
// Clear selected terminal when model doesn't support terminal
|
||||
$: if ($selectedTerminalId && terminalCapableModels.length === 0) {
|
||||
selectedTerminalId.set(null);
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
const element = document.getElementById('messages-container');
|
||||
element.scrollTo({
|
||||
|
|
@ -1745,7 +1757,19 @@
|
|||
<Tooltip content={filter?.name} placement="top">
|
||||
<button
|
||||
on:click|preventDefault={() => {
|
||||
selectedFilterIds = selectedFilterIds.filter((id) => id !== filterId);
|
||||
if (
|
||||
filter?.has_user_valves &&
|
||||
($_user?.role === 'admin' ||
|
||||
($_user?.permissions?.chat?.valves ?? true))
|
||||
) {
|
||||
selectedValvesType = 'function';
|
||||
selectedValvesItemId = filterId;
|
||||
showValvesModal = true;
|
||||
} else {
|
||||
selectedFilterIds = selectedFilterIds.filter(
|
||||
(id) => id !== filterId
|
||||
);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
class="group p-[7px] flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {selectedFilterIds.includes(
|
||||
|
|
@ -1768,7 +1792,18 @@
|
|||
{:else}
|
||||
<Sparkles className="size-4" strokeWidth="1.75" />
|
||||
{/if}
|
||||
<div class="hidden group-hover:block">
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="hidden group-hover:block"
|
||||
on:click={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
selectedFilterIds = selectedFilterIds.filter(
|
||||
(id) => id !== filterId
|
||||
);
|
||||
}}
|
||||
>
|
||||
<XMark className="size-4" strokeWidth="1.75" />
|
||||
</div>
|
||||
</button>
|
||||
|
|
@ -1906,7 +1941,7 @@
|
|||
|
||||
{#if !history?.currentId || history.messages[history.currentId]?.done == true}
|
||||
<!-- Terminal Server Selector -->
|
||||
{#if ($terminalServers ?? []).length > 0 || ($settings?.terminalServers ?? []).some((s) => s.url)}
|
||||
{#if terminalCapableModels.length > 0 && (($terminalServers ?? []).length > 0 || ($settings?.terminalServers ?? []).some((s) => s.url))}
|
||||
<TerminalMenu bind:show={showTerminalMenu} />
|
||||
{/if}
|
||||
|
||||
|
|
|
|||
|
|
@ -57,18 +57,124 @@
|
|||
|
||||
export let onSelect = (e) => {};
|
||||
|
||||
export let messagesCount: number | null = 20;
|
||||
export let messagesCount: number | null = 8;
|
||||
let messagesLoading = false;
|
||||
|
||||
// Off-screen message unloading. Heights are measured on scroll so spacers
|
||||
// always match real sizes — no scroll jumps, no feedback loops needed.
|
||||
const OVERSCAN = 3;
|
||||
const DEFAULT_HEIGHT = 150;
|
||||
let visibleStart = 0;
|
||||
let visibleEnd = 0;
|
||||
let messageHeights = new Map();
|
||||
let topSpacerHeight = 0;
|
||||
let bottomSpacerHeight = 0;
|
||||
let pendingCull = null;
|
||||
|
||||
// Helper: get height for a message (cached or default)
|
||||
const heightOf = (id) => messageHeights.get(id) ?? DEFAULT_HEIGHT;
|
||||
|
||||
/** Measure all currently rendered message elements and cache their heights */
|
||||
const measureMessageHeights = () => {
|
||||
const elements = document
|
||||
.getElementById('messages-container')
|
||||
?.querySelectorAll('[role="listitem"]');
|
||||
if (!elements) return;
|
||||
|
||||
messageHeights = new Map([
|
||||
...messageHeights,
|
||||
...Array.from(elements)
|
||||
.map((el, i) => [messages[visibleStart + i]?.id, el.getBoundingClientRect().height])
|
||||
.filter(([id]) => id != null)
|
||||
]);
|
||||
};
|
||||
|
||||
/** Compute visible range from current scroll position and apply */
|
||||
const updateVisibleRange = () => {
|
||||
const container = document.getElementById('messages-container');
|
||||
if (!container || messages.length === 0) return;
|
||||
|
||||
const st = container.scrollTop;
|
||||
const ch = container.clientHeight;
|
||||
|
||||
// Build prefix sums from measured heights
|
||||
const prefixSums = messages.reduce(
|
||||
(acc, m) => [...acc, acc[acc.length - 1] + heightOf(m.id)],
|
||||
[0]
|
||||
);
|
||||
|
||||
const firstVisible = Math.max(0, prefixSums.findIndex((h) => h > st) - 1);
|
||||
const lastVisible = prefixSums.findIndex((h) => h > st + ch);
|
||||
|
||||
// Only cull messages that have been measured (so spacer height is accurate)
|
||||
// findIndex returns -1 when all are measured → no limit on culling
|
||||
const firstUnmeasured = messages.findIndex((m) => !messageHeights.has(m.id));
|
||||
const cullLimit = firstUnmeasured === -1 ? messages.length : firstUnmeasured;
|
||||
|
||||
visibleStart = Math.max(0, Math.min(firstVisible - OVERSCAN, cullLimit));
|
||||
visibleEnd = Math.min(
|
||||
messages.length,
|
||||
(lastVisible === -1 ? messages.length : lastVisible) + OVERSCAN
|
||||
);
|
||||
topSpacerHeight = prefixSums[visibleStart] ?? 0;
|
||||
bottomSpacerHeight = (prefixSums[messages.length] ?? 0) - (prefixSums[visibleEnd] ?? 0);
|
||||
};
|
||||
|
||||
/** Scroll handler: measure every frame, cull via rAF (same throttle as pendingRebuild) */
|
||||
const handleContainerScroll = () => {
|
||||
measureMessageHeights();
|
||||
|
||||
// Don't cull during progressive loading
|
||||
if (messagesLoading) return;
|
||||
|
||||
if (!pendingCull) {
|
||||
pendingCull = requestAnimationFrame(() => {
|
||||
pendingCull = null;
|
||||
updateVisibleRange();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let scrollListenerAttached = false;
|
||||
|
||||
const attachScrollListener = () => {
|
||||
if (scrollListenerAttached) return;
|
||||
const container = document.getElementById('messages-container');
|
||||
if (!container) return;
|
||||
|
||||
container.addEventListener('scroll', handleContainerScroll, { passive: true });
|
||||
scrollListenerAttached = true;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
attachScrollListener();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
const container = document.getElementById('messages-container');
|
||||
if (container && scrollListenerAttached) {
|
||||
container.removeEventListener('scroll', handleContainerScroll);
|
||||
}
|
||||
cancelAnimationFrame(pendingCull);
|
||||
cancelAnimationFrame(pendingRebuild);
|
||||
});
|
||||
|
||||
const loadMoreMessages = async () => {
|
||||
// scroll slightly down to disable continuous loading
|
||||
const element = document.getElementById('messages-container');
|
||||
element.scrollTop = element.scrollTop + 100;
|
||||
|
||||
messagesLoading = true;
|
||||
messagesCount += 20;
|
||||
messagesCount += 8;
|
||||
|
||||
buildMessages();
|
||||
|
||||
// Show all messages during progressive loading (no culling)
|
||||
visibleStart = 0;
|
||||
visibleEnd = messages.length;
|
||||
topSpacerHeight = 0;
|
||||
bottomSpacerHeight = 0;
|
||||
|
||||
await tick();
|
||||
|
||||
messagesLoading = false;
|
||||
|
|
@ -95,6 +201,7 @@
|
|||
}
|
||||
|
||||
messages = _messages.reverse();
|
||||
visibleEnd = messages.length;
|
||||
};
|
||||
|
||||
// Throttle message list rebuilds to once per animation frame during streaming.
|
||||
|
|
@ -113,6 +220,8 @@
|
|||
cancelAnimationFrame(pendingRebuild);
|
||||
pendingRebuild = null;
|
||||
buildMessages();
|
||||
// No explicit culling needed — scrollToBottom will fire a scroll event,
|
||||
// which triggers handleContainerScroll → rAF → updateVisibleRange
|
||||
} else if (_messages) {
|
||||
// Content update (streaming) — throttle to once per frame
|
||||
if (!pendingRebuild) {
|
||||
|
|
@ -426,10 +535,6 @@
|
|||
showMessage({ id: parentMessageId }, false);
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
cancelAnimationFrame(pendingRebuild);
|
||||
});
|
||||
|
||||
const triggerScroll = () => {
|
||||
if (autoScroll) {
|
||||
const element = document.getElementById('messages-container');
|
||||
|
|
@ -465,7 +570,13 @@
|
|||
</Loader>
|
||||
{/if}
|
||||
<ul role="log" aria-live="polite" aria-relevant="additions" aria-atomic="false">
|
||||
{#each messages as message, messageIdx (message.id)}
|
||||
<!-- Top spacer: sum of cached heights for messages above visible range -->
|
||||
{#if topSpacerHeight > 0}
|
||||
<div style="height: {topSpacerHeight}px" aria-hidden="true" />
|
||||
{/if}
|
||||
|
||||
{#each messages.slice(visibleStart, visibleEnd) as message, i (message.id)}
|
||||
{@const messageIdx = visibleStart + i}
|
||||
<Message
|
||||
{chatId}
|
||||
bind:history
|
||||
|
|
@ -494,6 +605,11 @@
|
|||
{topPadding}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<!-- Bottom spacer: sum of cached heights for messages below visible range -->
|
||||
{#if bottomSpacerHeight > 0}
|
||||
<div style="height: {bottomSpacerHeight}px" aria-hidden="true" />
|
||||
{/if}
|
||||
</ul>
|
||||
</section>
|
||||
<div class="pb-18" />
|
||||
|
|
|
|||
|
|
@ -380,6 +380,7 @@
|
|||
<ToolCallDisplay
|
||||
id={`${id}-${tokenIdx}-${detailIdx}-tc`}
|
||||
attributes={detailToken.attributes}
|
||||
resultContent={getDetailTextContent(detailToken)}
|
||||
grouped={true}
|
||||
open={$settings?.expandDetails ?? false}
|
||||
className="w-full space-y-1"
|
||||
|
|
@ -428,6 +429,7 @@
|
|||
<ToolCallDisplay
|
||||
id={`${id}-${tokenIdx}-tc`}
|
||||
attributes={token.attributes}
|
||||
resultContent={getDetailTextContent(token)}
|
||||
open={$settings?.expandDetails ?? false}
|
||||
className="w-full space-y-1"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||
import ProfileImage from './ProfileImage.svelte';
|
||||
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||
import equal from 'fast-deep-equal';
|
||||
const i18n = getContext('i18n');
|
||||
dayjs.extend(localizedFormat);
|
||||
|
||||
|
|
@ -66,7 +67,7 @@
|
|||
if (source) {
|
||||
if (message.content !== source.content || message.done !== source.done) {
|
||||
message = structuredClone(source);
|
||||
} else if (JSON.stringify(message) !== JSON.stringify(source)) {
|
||||
} else if (!equal(message, source)) {
|
||||
message = structuredClone(source);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
removeAllDetails
|
||||
} from '$lib/utils';
|
||||
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
|
||||
import equal from 'fast-deep-equal';
|
||||
|
||||
import Name from './Name.svelte';
|
||||
import ProfileImage from './ProfileImage.svelte';
|
||||
|
|
@ -127,7 +128,7 @@
|
|||
// Avoids 2x O(n) JSON.stringify calls that are always true during streaming anyway
|
||||
if (message.content !== source.content || message.done !== source.done) {
|
||||
message = structuredClone(source);
|
||||
} else if (JSON.stringify(message) !== JSON.stringify(source)) {
|
||||
} else if (!equal(message, source)) {
|
||||
// Slow path: full comparison for infrequent changes (sources, annotations, status, etc.)
|
||||
message = structuredClone(source);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
const i18n = getContext('i18n');
|
||||
|
||||
import StatusItem from './StatusHistory/StatusItem.svelte';
|
||||
import equal from 'fast-deep-equal';
|
||||
export let statusHistory = [];
|
||||
export let expand = false;
|
||||
|
||||
|
|
@ -21,10 +22,7 @@
|
|||
status = history.at(-1);
|
||||
}
|
||||
|
||||
$: if (
|
||||
statusHistory.length !== history.length ||
|
||||
JSON.stringify(statusHistory) !== JSON.stringify(history)
|
||||
) {
|
||||
$: if (!equal(statusHistory, history)) {
|
||||
history = statusHistory;
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { user as _user } from '$lib/stores';
|
||||
import { copyToClipboard as _copyToClipboard, formatDate } from '$lib/utils';
|
||||
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
|
||||
import equal from 'fast-deep-equal';
|
||||
|
||||
import Name from './Name.svelte';
|
||||
import ProfileImage from './ProfileImage.svelte';
|
||||
|
|
@ -58,7 +59,7 @@
|
|||
if (source) {
|
||||
if (message.content !== source.content) {
|
||||
message = structuredClone(source);
|
||||
} else if (JSON.stringify(message) !== JSON.stringify(source)) {
|
||||
} else if (!equal(message, source)) {
|
||||
message = structuredClone(source);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -450,6 +450,7 @@
|
|||
});
|
||||
|
||||
if (res) {
|
||||
// $i18n.t('Model {{modelId}} not found')
|
||||
toast.success(
|
||||
$i18n.t('Model {{modelName}} deleted successfully', { modelName: model.name ?? model.id })
|
||||
);
|
||||
|
|
|
|||
|
|
@ -248,7 +248,7 @@
|
|||
|
||||
<hr class="border-gray-50 dark:border-gray-850/30 my-4" />
|
||||
|
||||
{#if $config?.features.enable_login_form}
|
||||
{#if $config?.features.enable_login_form && $config?.features.enable_password_change_form}
|
||||
<div class="mt-2">
|
||||
<UpdatePassword />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,24 +3,32 @@
|
|||
import { models, config } from '$lib/stores';
|
||||
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { deleteSharedChatById, getChatById, shareChatById } from '$lib/apis/chats';
|
||||
import {
|
||||
deleteSharedChatById,
|
||||
getChatById,
|
||||
shareChatById,
|
||||
getChatAccessGrants,
|
||||
updateChatAccessGrants
|
||||
} from '$lib/apis/chats';
|
||||
import { copyToClipboard } from '$lib/utils';
|
||||
|
||||
import Modal from '../common/Modal.svelte';
|
||||
import Link from '../icons/Link.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
import AccessControl from '$lib/components/workspace/common/AccessControl.svelte';
|
||||
|
||||
export let chatId;
|
||||
|
||||
let chat = null;
|
||||
let shareUrl = null;
|
||||
let accessGrants: any[] = [];
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
const shareLocalChat = async () => {
|
||||
const _chat = chat;
|
||||
|
||||
const sharedChat = await shareChatById(localStorage.token, chatId);
|
||||
shareUrl = `${window.location.origin}/s/${sharedChat.id}`;
|
||||
shareUrl = `${window.location.origin}/s/${sharedChat.share_id}`;
|
||||
console.log(shareUrl);
|
||||
chat = await getChatById(localStorage.token, chatId);
|
||||
|
||||
|
|
@ -54,6 +62,25 @@
|
|||
);
|
||||
};
|
||||
|
||||
const loadAccessGrants = async () => {
|
||||
if (!chatId) return;
|
||||
try {
|
||||
accessGrants = (await getChatAccessGrants(localStorage.token, chatId)) ?? [];
|
||||
} catch (e) {
|
||||
console.error('Failed to load access grants', e);
|
||||
accessGrants = [];
|
||||
}
|
||||
};
|
||||
|
||||
const saveAccessGrants = async () => {
|
||||
try {
|
||||
await updateChatAccessGrants(localStorage.token, chatId, accessGrants);
|
||||
toast.success($i18n.t('Access updated'));
|
||||
} catch (e) {
|
||||
toast.error(`${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
export let show = false;
|
||||
|
||||
const isDifferentChat = (_chat) => {
|
||||
|
|
@ -73,8 +100,10 @@
|
|||
if (isDifferentChat(_chat)) {
|
||||
chat = _chat;
|
||||
}
|
||||
await loadAccessGrants();
|
||||
} else {
|
||||
chat = null;
|
||||
accessGrants = [];
|
||||
console.log(chat);
|
||||
}
|
||||
})();
|
||||
|
|
@ -97,8 +126,8 @@
|
|||
</div>
|
||||
|
||||
{#if chat}
|
||||
<div class="px-5 pt-4 pb-5 w-full flex flex-col justify-center">
|
||||
<div class=" text-sm dark:text-gray-300 mb-1">
|
||||
<div class="px-5 pt-4 pb-5 w-full flex flex-col">
|
||||
<div class="text-sm dark:text-gray-300">
|
||||
{#if chat.share_id}
|
||||
<a href="/s/{chat.share_id}" target="_blank"
|
||||
>{$i18n.t('You have shared this chat')}
|
||||
|
|
@ -124,70 +153,69 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<div class="flex flex-col items-end space-x-1 mt-3">
|
||||
<div class="flex gap-1">
|
||||
{#if $config?.features.enable_community_sharing}
|
||||
<button
|
||||
class="self-center flex items-center gap-1 px-3.5 py-2 text-sm font-medium bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:text-white dark:hover:bg-gray-800 transition rounded-full"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
shareChat();
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
{$i18n.t('Share to Open WebUI Community')}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="self-center flex items-center gap-1 px-3.5 py-2 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-full"
|
||||
type="button"
|
||||
id="copy-and-share-chat-button"
|
||||
on:click={async () => {
|
||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||
|
||||
if (isSafari) {
|
||||
// Oh, Safari, you're so special, let's give you some extra love and attention
|
||||
console.log('isSafari');
|
||||
|
||||
const getUrlPromise = async () => {
|
||||
const url = await shareLocalChat();
|
||||
return new Blob([url], { type: 'text/plain' });
|
||||
};
|
||||
|
||||
navigator.clipboard
|
||||
.write([
|
||||
new ClipboardItem({
|
||||
'text/plain': getUrlPromise()
|
||||
})
|
||||
])
|
||||
.then(() => {
|
||||
console.log('Async: Copying to clipboard was successful!');
|
||||
return true;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Async: Could not copy text: ', error);
|
||||
return false;
|
||||
});
|
||||
} else {
|
||||
copyToClipboard(await shareLocalChat());
|
||||
}
|
||||
|
||||
toast.success($i18n.t('Copied shared chat URL to clipboard!'));
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
<Link />
|
||||
|
||||
{#if chat.share_id}
|
||||
{$i18n.t('Update and Copy Link')}
|
||||
{:else}
|
||||
{$i18n.t('Copy Link')}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{#if chat.share_id}
|
||||
<div class="mt-3">
|
||||
<AccessControl bind:accessGrants accessRoles={['read']} onChange={saveAccessGrants} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end gap-1 mt-3">
|
||||
{#if $config?.features.enable_community_sharing}
|
||||
<button
|
||||
class="flex items-center gap-1 px-3.5 py-2 text-sm font-medium bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:text-white dark:hover:bg-gray-800 transition rounded-full"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
shareChat();
|
||||
}}
|
||||
>
|
||||
{$i18n.t('Share to Open WebUI Community')}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="flex items-center gap-1 px-3.5 py-2 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-full"
|
||||
type="button"
|
||||
id="copy-and-share-chat-button"
|
||||
on:click={async () => {
|
||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||
|
||||
if (isSafari) {
|
||||
console.log('isSafari');
|
||||
|
||||
const getUrlPromise = async () => {
|
||||
const url = await shareLocalChat();
|
||||
return new Blob([url], { type: 'text/plain' });
|
||||
};
|
||||
|
||||
navigator.clipboard
|
||||
.write([
|
||||
new ClipboardItem({
|
||||
'text/plain': getUrlPromise()
|
||||
})
|
||||
])
|
||||
.then(() => {
|
||||
console.log('Async: Copying to clipboard was successful!');
|
||||
return true;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Async: Could not copy text: ', error);
|
||||
return false;
|
||||
});
|
||||
} else {
|
||||
copyToClipboard(await shareLocalChat());
|
||||
}
|
||||
|
||||
toast.success($i18n.t('Copied shared chat URL to clipboard!'));
|
||||
}}
|
||||
>
|
||||
<Link />
|
||||
|
||||
{#if chat.share_id}
|
||||
{$i18n.t('Update and Copy Link')}
|
||||
{:else}
|
||||
{$i18n.t('Copy Link')}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@
|
|||
}
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
show = false;
|
||||
if (modalElement) {
|
||||
if (document.body.contains(modalElement)) {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,18 @@
|
|||
});
|
||||
turndownService.escape = (string) => string;
|
||||
|
||||
// Produce single newlines between paragraphs instead of double.
|
||||
// TipTap wraps every line in <p> tags; the default Turndown rule emits
|
||||
// \n\n around each paragraph which then required a destructive
|
||||
// replaceAll('\n\n','\n') that also wiped blank lines inside code blocks.
|
||||
// This rule eliminates that hack so <pre><code> content is untouched.
|
||||
turndownService.addRule('singleNewlineParagraphs', {
|
||||
filter: 'p',
|
||||
replacement: function (content) {
|
||||
return '\n' + content + '\n';
|
||||
}
|
||||
});
|
||||
|
||||
// Use turndown-plugin-gfm for proper GFM table support
|
||||
turndownService.use(gfm);
|
||||
|
||||
|
|
@ -435,7 +447,6 @@
|
|||
|
||||
export const setText = (text: string) => {
|
||||
if (!editor || !editor.view) return;
|
||||
text = text.replaceAll('\n\n', '\n');
|
||||
|
||||
if (text === '') {
|
||||
editor.commands.clearContent();
|
||||
|
|
|
|||
|
|
@ -77,7 +77,9 @@
|
|||
}
|
||||
|
||||
$: args = decode(attributes?.arguments ?? '');
|
||||
$: result = decode(attributes?.result ?? '');
|
||||
export let resultContent: string = '';
|
||||
|
||||
$: result = resultContent || decode(attributes?.result ?? '');
|
||||
$: files = parseJSONString(decode(attributes?.files ?? ''));
|
||||
$: embeds = parseJSONString(decode(attributes?.embeds ?? ''));
|
||||
$: isDone = attributes?.done === 'true';
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
mobile,
|
||||
showArchivedChats,
|
||||
pinnedChats,
|
||||
pinnedNotes,
|
||||
scrollPaginationEnabled,
|
||||
currentChatPage,
|
||||
temporaryChatEnabled,
|
||||
|
|
@ -44,6 +45,8 @@
|
|||
} from '$lib/apis/chats';
|
||||
import { createNewFolder, getFolders, updateFolderParentIdById } from '$lib/apis/folders';
|
||||
import { checkActiveChats } from '$lib/apis/tasks';
|
||||
import { getPinnedNoteList, toggleNotePinnedStatusById } from '$lib/apis/notes';
|
||||
import { createNoteHandler } from '$lib/components/notes/utils';
|
||||
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
|
||||
|
||||
import ArchivedChatsModal from './ArchivedChatsModal.svelte';
|
||||
|
|
@ -86,6 +89,7 @@
|
|||
let pinnedModels = [];
|
||||
|
||||
let showPinnedModels = false;
|
||||
let showPinnedNotes = false;
|
||||
let showChannels = false;
|
||||
let showFolders = false;
|
||||
|
||||
|
|
@ -227,6 +231,16 @@
|
|||
const _pinnedChats = await getPinnedChatList(localStorage.token);
|
||||
pinnedChats.set(_pinnedChats);
|
||||
})(),
|
||||
await (async () => {
|
||||
if (
|
||||
$config?.features?.enable_notes &&
|
||||
($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))
|
||||
) {
|
||||
console.log('Init pinned notes');
|
||||
const _pinnedNotes = await getPinnedNoteList(localStorage.token).catch(() => []);
|
||||
pinnedNotes.set(_pinnedNotes);
|
||||
}
|
||||
})(),
|
||||
await (async () => {
|
||||
console.log('Init chat list');
|
||||
const _chats = await getChatList(localStorage.token, $currentChatPage);
|
||||
|
|
@ -1072,6 +1086,70 @@
|
|||
</Folder>
|
||||
{/if}
|
||||
|
||||
{#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true)) && $pinnedNotes.length > 0}
|
||||
<Folder
|
||||
id="sidebar-pinned-notes"
|
||||
bind:open={showPinnedNotes}
|
||||
className="px-2 mt-0.5"
|
||||
name={$i18n.t('Notes')}
|
||||
chevron={false}
|
||||
dragAndDrop={false}
|
||||
onAdd={async () => {
|
||||
const note = await createNoteHandler('New Note');
|
||||
if (note) {
|
||||
goto(`/notes/${note.id}`);
|
||||
}
|
||||
}}
|
||||
onAddLabel={$i18n.t('New Note')}
|
||||
>
|
||||
<div class="mt-0.5 pb-1.5">
|
||||
{#each $pinnedNotes as note (note.id)}
|
||||
<a
|
||||
class="w-full flex items-center gap-2.5 rounded-xl px-2.5 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-900 transition group text-sm"
|
||||
href={`/notes/${note.id}`}
|
||||
on:click={() => {
|
||||
itemClickHandler();
|
||||
}}
|
||||
draggable="false"
|
||||
>
|
||||
<div class="self-center">
|
||||
<Note className="size-4" strokeWidth="2" />
|
||||
</div>
|
||||
<div class="flex-1 text-ellipsis line-clamp-1">
|
||||
{note.title}
|
||||
</div>
|
||||
<button
|
||||
class="invisible group-hover:visible self-center p-0.5 hover:bg-gray-200 dark:hover:bg-gray-800 rounded-lg transition"
|
||||
on:click|preventDefault|stopPropagation={async () => {
|
||||
await toggleNotePinnedStatusById(localStorage.token, note.id);
|
||||
const _pinnedNotes = await getPinnedNoteList(localStorage.token).catch(
|
||||
() => []
|
||||
);
|
||||
pinnedNotes.set(_pinnedNotes);
|
||||
}}
|
||||
aria-label={$i18n.t('Unpin')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="size-3.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6 18 18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</Folder>
|
||||
{/if}
|
||||
|
||||
{#if $config?.features?.enable_channels && ($user?.role === 'admin' || ($user?.permissions?.features?.channels ?? true))}
|
||||
<Folder
|
||||
id="sidebar-channels"
|
||||
|
|
|
|||
|
|
@ -88,10 +88,21 @@
|
|||
|
||||
let mouseOver = false;
|
||||
|
||||
// Local state: tracks the last updatedAt seen while the user was viewing
|
||||
// this chat. Survives prop refreshes from sidebar data re-fetches that
|
||||
// would overwrite the `lastReadAt` prop with a stale server value.
|
||||
let viewedAt: number | null = null;
|
||||
|
||||
$: if (id === $chatId) {
|
||||
viewedAt = updatedAt ?? Date.now() / 1000;
|
||||
}
|
||||
|
||||
$: effectiveReadAt = Math.max(lastReadAt ?? 0, viewedAt ?? 0) || null;
|
||||
|
||||
$: unread =
|
||||
id !== $chatId &&
|
||||
!$activeChatIds.has(id) &&
|
||||
(lastReadAt === null || (updatedAt !== null && updatedAt > lastReadAt));
|
||||
(effectiveReadAt === null || (updatedAt !== null && updatedAt > effectiveReadAt));
|
||||
|
||||
const loadChat = async () => {
|
||||
if (!chat) {
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@
|
|||
showSidebar,
|
||||
socket,
|
||||
user,
|
||||
WEBUI_NAME
|
||||
WEBUI_NAME,
|
||||
pinnedNotes
|
||||
} from '$lib/stores';
|
||||
|
||||
import { downloadPdf } from './utils';
|
||||
|
|
@ -64,7 +65,9 @@
|
|||
deleteNoteById,
|
||||
getNoteById,
|
||||
updateNoteById,
|
||||
updateNoteAccessGrants
|
||||
updateNoteAccessGrants,
|
||||
toggleNotePinnedStatusById,
|
||||
getPinnedNoteList
|
||||
} from '$lib/apis/notes';
|
||||
|
||||
import RichTextInput from '../common/RichTextInput.svelte';
|
||||
|
|
@ -1088,6 +1091,12 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
|||
onDelete={() => {
|
||||
showDeleteConfirm = true;
|
||||
}}
|
||||
isPinned={note.is_pinned ?? false}
|
||||
onPin={async () => {
|
||||
await toggleNotePinnedStatusById(localStorage.token, note.id);
|
||||
note = await getNoteById(localStorage.token, note.id);
|
||||
pinnedNotes.set(await getPinnedNoteList(localStorage.token).catch(() => []));
|
||||
}}
|
||||
>
|
||||
<div class="p-1 bg-transparent hover:bg-white/5 transition rounded-lg">
|
||||
<EllipsisHorizontal className="size-5" />
|
||||
|
|
|
|||
|
|
@ -169,13 +169,18 @@ Based on the user's instruction, update and enhance the existing notes or select
|
|||
: '') +
|
||||
(selectedContent ? `\n<selection>${selectedContent?.text}</selection>` : '');
|
||||
|
||||
// Filter out empty assistant placeholder messages to avoid sending
|
||||
// an empty trailing assistant message as "response prefill", which is
|
||||
// incompatible with enable_thinking in llama.cpp and similar backends.
|
||||
const filteredMessages = messages.filter((m) => !(m.role === 'assistant' && m.content === ''));
|
||||
|
||||
const chatMessages = JSON.parse(
|
||||
JSON.stringify([
|
||||
{
|
||||
role: 'system',
|
||||
content: `${system}`
|
||||
},
|
||||
...messages
|
||||
...filteredMessages
|
||||
])
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -30,13 +30,15 @@
|
|||
$: loadLocale($i18n.languages);
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { WEBUI_NAME, config, user } from '$lib/stores';
|
||||
import { WEBUI_NAME, config, user, pinnedNotes } from '$lib/stores';
|
||||
import {
|
||||
createNewNote,
|
||||
deleteNoteById,
|
||||
getNoteById,
|
||||
getNoteList,
|
||||
searchNotes
|
||||
searchNotes,
|
||||
toggleNotePinnedStatusById,
|
||||
getPinnedNoteList
|
||||
} from '$lib/apis/notes';
|
||||
import { capitalizeFirstLetter, copyToClipboard, getTimeRange } from '$lib/utils';
|
||||
import { downloadPdf, createNoteHandler } from './utils';
|
||||
|
|
@ -540,6 +542,14 @@
|
|||
selectedNote = note;
|
||||
showDeleteConfirm = true;
|
||||
}}
|
||||
isPinned={note.is_pinned ?? false}
|
||||
onPin={async () => {
|
||||
await toggleNotePinnedStatusById(localStorage.token, note.id);
|
||||
pinnedNotes.set(
|
||||
await getPinnedNoteList(localStorage.token).catch(() => [])
|
||||
);
|
||||
init();
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="self-center w-fit text-sm p-1 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||
|
|
@ -602,6 +612,14 @@
|
|||
selectedNote = note;
|
||||
showDeleteConfirm = true;
|
||||
}}
|
||||
isPinned={note.is_pinned ?? false}
|
||||
onPin={async () => {
|
||||
await toggleNotePinnedStatusById(localStorage.token, note.id);
|
||||
pinnedNotes.set(
|
||||
await getPinnedNoteList(localStorage.token).catch(() => [])
|
||||
);
|
||||
init();
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="self-center w-fit text-sm p-1 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
|
||||
import Share from '$lib/components/icons/Share.svelte';
|
||||
import Link from '$lib/components/icons/Link.svelte';
|
||||
import Pin from '$lib/components/icons/Pin.svelte';
|
||||
import PinSlash from '$lib/components/icons/PinSlash.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
|
|
@ -16,6 +18,8 @@
|
|||
|
||||
export let onDownload = (type) => {};
|
||||
export let onDelete = () => {};
|
||||
export let onPin = null;
|
||||
export let isPinned = false;
|
||||
|
||||
export let onCopyLink = null;
|
||||
export let onCopyToClipboard = null;
|
||||
|
|
@ -110,6 +114,24 @@
|
|||
</DropdownSub>
|
||||
{/if}
|
||||
|
||||
{#if onPin}
|
||||
<button
|
||||
class="select-none 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 w-full"
|
||||
on:click={() => {
|
||||
onPin();
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
{#if isPinned}
|
||||
<PinSlash />
|
||||
<div class="flex items-center">{$i18n.t('Unpin')}</div>
|
||||
{:else}
|
||||
<Pin />
|
||||
<div class="flex items-center">{$i18n.t('Pin to Sidebar')}</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="select-none 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 w-full"
|
||||
on:click={() => {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
const dispatch = createEventDispatcher();
|
||||
|
||||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import RichTextInput from '$lib/components/common/RichTextInput.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
import MicSolid from '$lib/components/icons/MicSolid.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
|
|
@ -57,7 +56,7 @@
|
|||
<div class="shrink-0 w-full flex justify-between items-center">
|
||||
<div class="w-full">
|
||||
<input
|
||||
class="w-full text-3xl font-medium bg-transparent outline-hidden"
|
||||
class="w-full text-3xl bg-transparent outline-hidden"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder={$i18n.t('Title')}
|
||||
|
|
@ -67,18 +66,16 @@
|
|||
</div>
|
||||
|
||||
<div class=" flex-1 w-full h-full">
|
||||
<RichTextInput
|
||||
<textarea
|
||||
class="w-full h-full min-h-[200px] bg-transparent outline-none resize-none text-base leading-relaxed placeholder:text-gray-300 dark:placeholder:text-gray-600"
|
||||
bind:value={content}
|
||||
placeholder={$i18n.t('Write something...')}
|
||||
preserveBreaks={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-row items-center justify-end text-sm font-medium shrink-0 mt-1 p-4 gap-1.5"
|
||||
>
|
||||
<div class="flex flex-row items-center justify-end text-sm shrink-0 mt-1 p-4 gap-1.5">
|
||||
<div class="">
|
||||
{#if voiceInput}
|
||||
<div class=" max-w-full w-full">
|
||||
|
|
|
|||
|
|
@ -56,15 +56,6 @@
|
|||
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>
|
||||
|
|
@ -77,10 +68,12 @@
|
|||
<Checkbox
|
||||
state={builtinTools[tool] !== false ? 'checked' : 'unchecked'}
|
||||
on:change={(e) => {
|
||||
builtinTools = {
|
||||
...builtinTools,
|
||||
[tool]: e.detail === 'checked'
|
||||
};
|
||||
if (e.detail === 'checked') {
|
||||
delete builtinTools[tool];
|
||||
} else {
|
||||
builtinTools[tool] = false;
|
||||
}
|
||||
builtinTools = builtinTools;
|
||||
}}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,12 @@
|
|||
label: $i18n.t('Code Interpreter'),
|
||||
description: $i18n.t('Model can execute code and perform calculations')
|
||||
},
|
||||
terminal: {
|
||||
label: $i18n.t('Terminal'),
|
||||
description: $i18n.t(
|
||||
'Model can access Open Terminal for command execution and file management'
|
||||
)
|
||||
},
|
||||
usage: {
|
||||
label: $i18n.t('Usage'),
|
||||
description: $i18n.t(
|
||||
|
|
@ -60,6 +66,7 @@
|
|||
web_search?: boolean;
|
||||
image_generation?: boolean;
|
||||
code_interpreter?: boolean;
|
||||
terminal?: boolean;
|
||||
usage?: boolean;
|
||||
citations?: boolean;
|
||||
status_updates?: boolean;
|
||||
|
|
|
|||
|
|
@ -839,9 +839,11 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="my-4">
|
||||
<TerminalSelector bind:terminalId />
|
||||
</div>
|
||||
{#if capabilities.terminal}
|
||||
<div class="my-4">
|
||||
<TerminalSelector bind:terminalId />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="my-4">
|
||||
<div class="flex w-full justify-between mb-1">
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue