mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-28 06:22:33 +00:00
Merge branch 'dev' into firecrawl/v2-direct-api-pr
This commit is contained in:
commit
281ab1e609
65 changed files with 3140 additions and 2758 deletions
21
CHANGELOG.md
21
CHANGELOG.md
|
|
@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.9.2] - 2026-04-24
|
||||
|
||||
### Added
|
||||
|
||||
- 🧠 **PaddleOCR-vl document extraction.** Administrators can now use PaddleOCR-vl as a content extraction engine for document processing, with configurable API URL and token settings in document retrieval configuration. [Commit](https://github.com/open-webui/open-webui/commit/04c7e9535d330906891eefa5eda516f661c1cf79..331b7520db719d2f1c76c4b06603a9a59a9b7c25)
|
||||
- 🧵 **Streaming markdown performance stability.** Streaming responses now stay more memory-efficient by preventing repeated cleanup callback registration during markdown updates. [#24048](https://github.com/open-webui/open-webui/pull/24048)
|
||||
- 📚 **Source overflow indicator.** The Sources button now shows a +N badge when more than three sources are available, so hidden sources are clearly indicated in chat responses. [#23918](https://github.com/open-webui/open-webui/pull/23918)
|
||||
- ⚡ **Model avatar cache reuse.** Default model profile images now reuse a shared static path to reduce repeated downloads and improve loading efficiency when multiple models use the fallback icon. [#24015](https://github.com/open-webui/open-webui/pull/24015)
|
||||
- 🚀 **Faster splash image loading.** Splash screen images are now prioritized earlier during page load, improving first-load LCP behavior and reducing delayed image discovery. [#24011](https://github.com/open-webui/open-webui/pull/24011)
|
||||
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
|
||||
- 🌐 **Translation updates.** Translations for Finnish, Korean, Portuguese (Brazil), and Dutch were enhanced and expanded.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🛠️ **Throttle request handling.** Request handling no longer fails when user activity status updates are throttled with a non-zero interval. [#23979](https://github.com/open-webui/open-webui/pull/23979)
|
||||
- ✍️ **Rich text extension conflicts.** Rich text editing no longer triggers duplicate extension conflicts for lists and code blocks, improving editor stability. [#24009](https://github.com/open-webui/open-webui/pull/24009)
|
||||
|
||||
### Changed
|
||||
|
||||
-
|
||||
|
||||
## [0.9.1] - 2026-04-21
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -135,6 +135,9 @@ RUN apt-get update && \
|
|||
# install python dependencies
|
||||
COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt
|
||||
|
||||
# Set UV_LINK_MODE to copy to prevent 0-byte file corruption in QEMU arm64 cross-builds
|
||||
ENV UV_LINK_MODE=copy
|
||||
|
||||
RUN set -e; \
|
||||
pip3 install --no-cache-dir uv; \
|
||||
if [ "$USE_CUDA" = "true" ]; then \
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ For more information, be sure to check out our [Open WebUI Documentation](https:
|
|||
|
||||
- 💾 **Persistent Artifact Storage**: Built-in key-value storage API for artifacts, enabling features like journals, trackers, leaderboards, and collaborative tools with both personal and shared data scopes across sessions.
|
||||
|
||||
- 📚 **Local RAG Integration**: Dive into the future of chat interactions with groundbreaking Retrieval Augmented Generation (RAG) support using your choice of 9 vector databases and multiple content extraction engines (Tika, Docling, Document Intelligence, Mistral OCR, External loaders). Load documents directly into chat or add files to your document library, effortlessly accessing them using the `#` command before a query.
|
||||
- 📚 **Local RAG Integration**: Dive into the future of chat interactions with groundbreaking Retrieval Augmented Generation (RAG) support using your choice of 9 vector databases and multiple content extraction engines (Tika, Docling, Document Intelligence, Mistral OCR, PaddleOCR-vl, External loaders). Load documents directly into chat or add files to your document library, effortlessly accessing them using the `#` command before a query.
|
||||
|
||||
- 🔍 **Web Search for RAG**: Perform web searches using 15+ providers including `SearXNG`, `Google PSE`, `Brave Search`, `Kagi`, `Mojeek`, `Tavily`, `Perplexity`, `serpstack`, `serper`, `Serply`, `DuckDuckGo`, `SearchApi`, `SerpApi`, `Bing`, `Jina`, `Exa`, `Sougou`, `Azure AI Search`, and `Ollama Cloud`, injecting results directly into your chat experience.
|
||||
|
||||
|
|
|
|||
|
|
@ -2827,6 +2827,18 @@ MISTRAL_OCR_API_KEY = PersistentConfig(
|
|||
os.getenv('MISTRAL_OCR_API_KEY', ''),
|
||||
)
|
||||
|
||||
PADDLEOCR_VL_BASE_URL = PersistentConfig(
|
||||
'PADDLEOCR_VL_BASE_URL',
|
||||
'rag.paddleocr_vl_base_url',
|
||||
os.getenv('PADDLEOCR_VL_BASE_URL', 'http://localhost:8080'),
|
||||
)
|
||||
|
||||
PADDLEOCR_VL_TOKEN = PersistentConfig(
|
||||
'PADDLEOCR_VL_TOKEN',
|
||||
'rag.paddleocr_vl_token',
|
||||
os.getenv('PADDLEOCR_VL_TOKEN', ''),
|
||||
)
|
||||
|
||||
BYPASS_EMBEDDING_AND_RETRIEVAL = PersistentConfig(
|
||||
'BYPASS_EMBEDDING_AND_RETRIEVAL',
|
||||
'rag.bypass_embedding_and_retrieval',
|
||||
|
|
|
|||
|
|
@ -525,6 +525,12 @@ WEBUI_AUTH_TRUSTED_NAME_HEADER = os.environ.get('WEBUI_AUTH_TRUSTED_NAME_HEADER'
|
|||
WEBUI_AUTH_TRUSTED_GROUPS_HEADER = os.environ.get('WEBUI_AUTH_TRUSTED_GROUPS_HEADER', None)
|
||||
WEBUI_AUTH_TRUSTED_ROLE_HEADER = os.environ.get('WEBUI_AUTH_TRUSTED_ROLE_HEADER', None)
|
||||
|
||||
# Custom header name for API key authentication. Defaults to 'x-api-key'.
|
||||
# Useful when Open WebUI sits behind a reverse proxy / API gateway that
|
||||
# already uses the Authorization header for its own authentication — set
|
||||
# this to a unique header (e.g. 'X-OpenWebUI-Key') so the middleware
|
||||
# checks the custom header instead and avoids the 401 short-circuit.
|
||||
CUSTOM_API_KEY_HEADER = os.environ.get('CUSTOM_API_KEY_HEADER', 'x-api-key')
|
||||
|
||||
ENABLE_PASSWORD_VALIDATION = os.environ.get('ENABLE_PASSWORD_VALIDATION', 'False').lower() == 'true'
|
||||
PASSWORD_VALIDATION_REGEX_PATTERN = os.environ.get(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import os
|
||||
import json
|
||||
import logging
|
||||
import ssl as _stdlib_ssl
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||
|
|
@ -37,90 +36,86 @@ from typing_extensions import Self
|
|||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def extract_ssl_mode_from_url(url: str) -> tuple[str, str | None]:
|
||||
# ── SSL URL normalization (used by sync engine & Alembic migrations) ─
|
||||
#
|
||||
# psycopg2 (sync) needs ``sslmode=`` in the connection string (it does
|
||||
# not recognise the bare ``ssl=`` key that some ORMs emit). The helpers
|
||||
# below strip all SSL-related query params, normalise them, and
|
||||
# reattach them in the canonical libpq form.
|
||||
#
|
||||
# The **async** engine now uses psycopg (v3), which speaks libpq
|
||||
# natively, so it needs no translation at all — the DATABASE_URL is
|
||||
# passed through as-is.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _pop_first(params: dict[str, list[str]], key: str) -> str | None:
|
||||
"""Pop a single-valued query param, returning ``None`` if absent."""
|
||||
values = params.pop(key, None)
|
||||
return values[0] if values else None
|
||||
|
||||
|
||||
def _is_postgres_url(url: str) -> bool:
|
||||
"""Return True if *url* looks like a PostgreSQL connection string."""
|
||||
return bool(url) and any(
|
||||
url.startswith(p) for p in ('postgresql://', 'postgresql+', 'postgres://')
|
||||
)
|
||||
|
||||
|
||||
def extract_ssl_params_from_url(url: str) -> tuple[str, dict[str, str]]:
|
||||
"""Strip SSL query-string parameters from a PostgreSQL URL.
|
||||
|
||||
asyncpg and psycopg2 use different query-string keys for SSL
|
||||
(``ssl`` vs ``sslmode``). This helper removes **both** from the
|
||||
URL so that each driver can receive the correct parameter through
|
||||
its own mechanism (query-string re-injection for psycopg2,
|
||||
``connect_args`` for asyncpg).
|
||||
|
||||
Returns
|
||||
-------
|
||||
(url_without_ssl, ssl_mode)
|
||||
*url_without_ssl* is the original URL with ``ssl`` / ``sslmode``
|
||||
query parameters removed. *ssl_mode* is the extracted mode
|
||||
string (e.g. ``'require'``), or ``None`` if neither parameter
|
||||
was present.
|
||||
|
||||
Non-PostgreSQL URLs are returned unchanged with ``ssl_mode=None``.
|
||||
Returns ``(url_without_ssl, ssl_dict)`` where *ssl_dict* maps
|
||||
canonical libpq key names (``sslmode``, ``sslrootcert``, …) to
|
||||
their values. Non-PostgreSQL URLs are returned unchanged with an
|
||||
empty dict.
|
||||
"""
|
||||
if not url or not any(url.startswith(prefix) for prefix in ('postgresql://', 'postgresql+', 'postgres://')):
|
||||
return url, None
|
||||
if not _is_postgres_url(url):
|
||||
return url, {}
|
||||
|
||||
parsed = urlparse(url)
|
||||
query_params = parse_qs(parsed.query, keep_blank_values=True)
|
||||
qp = parse_qs(parsed.query, keep_blank_values=True)
|
||||
|
||||
# Prefer sslmode (libpq canonical) over the asyncpg-only ssl key.
|
||||
ssl_mode: str | None = None
|
||||
for key in ('sslmode', 'ssl'):
|
||||
values = query_params.pop(key, None)
|
||||
if values and ssl_mode is None:
|
||||
ssl_mode = values[0]
|
||||
# Prefer sslmode (libpq canonical) over the bare ``ssl`` key.
|
||||
sslmode_val = _pop_first(qp, 'sslmode')
|
||||
ssl_val = _pop_first(qp, 'ssl')
|
||||
ssl_mode = sslmode_val or ssl_val
|
||||
|
||||
if ssl_mode is None:
|
||||
# Nothing to strip — return the URL untouched.
|
||||
return url, None
|
||||
ssl_dict: dict[str, str] = {}
|
||||
if ssl_mode:
|
||||
ssl_dict['sslmode'] = ssl_mode
|
||||
for key in ('sslrootcert', 'sslcert', 'sslkey', 'sslcrl'):
|
||||
val = _pop_first(qp, key)
|
||||
if val:
|
||||
ssl_dict[key] = val
|
||||
|
||||
# Rebuild the query string without the SSL keys.
|
||||
remaining_query = urlencode(query_params, doseq=True)
|
||||
url_without_ssl = urlunparse(parsed._replace(query=remaining_query))
|
||||
return url_without_ssl, ssl_mode
|
||||
if not ssl_dict:
|
||||
return url, ssl_dict
|
||||
|
||||
cleaned_query = urlencode(qp, doseq=True)
|
||||
return urlunparse(parsed._replace(query=cleaned_query)), ssl_dict
|
||||
|
||||
|
||||
def build_asyncpg_ssl_args(ssl_mode: str | None) -> dict:
|
||||
"""Convert a libpq-style SSL mode value to asyncpg ``connect_args``.
|
||||
def reattach_ssl_params_to_url(url_without_ssl: str, ssl_dict: dict[str, str]) -> str:
|
||||
"""Re-append SSL query-string parameters to a cleaned PostgreSQL URL.
|
||||
|
||||
Returns a dict suitable for unpacking into
|
||||
``create_async_engine(..., connect_args=...)``.
|
||||
Used for psycopg2/libpq consumers that expect ``sslmode`` and the
|
||||
certificate-file keys in the connection string.
|
||||
"""
|
||||
if ssl_mode is None:
|
||||
return {}
|
||||
|
||||
mode = ssl_mode.lower()
|
||||
if mode == 'disable':
|
||||
return {'connect_args': {'ssl': False}}
|
||||
if mode in ('allow', 'prefer'):
|
||||
# asyncpg has no direct equivalent — omit to let it try without.
|
||||
return {}
|
||||
if mode == 'require':
|
||||
# SSL required but no certificate verification (matches libpq).
|
||||
ctx = _stdlib_ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = _stdlib_ssl.CERT_NONE
|
||||
return {'connect_args': {'ssl': ctx}}
|
||||
if mode in ('verify-ca', 'verify-full'):
|
||||
# Full verification — use the system trust store.
|
||||
ctx = _stdlib_ssl.create_default_context()
|
||||
if mode == 'verify-ca':
|
||||
ctx.check_hostname = False
|
||||
return {'connect_args': {'ssl': ctx}}
|
||||
|
||||
# Unknown value — pass through as-is and let asyncpg decide.
|
||||
return {'connect_args': {'ssl': ssl_mode}}
|
||||
|
||||
|
||||
def reattach_ssl_mode_to_url(url_without_ssl: str, ssl_mode: str | None) -> str:
|
||||
"""Re-append ``sslmode=<value>`` to a cleaned PostgreSQL URL.
|
||||
|
||||
Used for psycopg2 / libpq consumers that expect the canonical
|
||||
``sslmode`` query-string key.
|
||||
"""
|
||||
if ssl_mode is None:
|
||||
if not ssl_dict:
|
||||
return url_without_ssl
|
||||
separator = '&' if '?' in url_without_ssl else '?'
|
||||
return f'{url_without_ssl}{separator}sslmode={ssl_mode}'
|
||||
|
||||
parts = [f'{k}={v}' for k, v in ssl_dict.items() if v]
|
||||
if not parts:
|
||||
return url_without_ssl
|
||||
|
||||
sep = '&' if '?' in url_without_ssl else '?'
|
||||
return f'{url_without_ssl}{sep}{"&".join(parts)}'
|
||||
|
||||
|
||||
# Backwards-compatible aliases for external callers.
|
||||
extract_ssl_mode_from_url = extract_ssl_params_from_url
|
||||
reattach_ssl_mode_to_url = reattach_ssl_params_to_url
|
||||
|
||||
|
||||
class JSONField(types.TypeDecorator):
|
||||
|
|
@ -150,9 +145,10 @@ class JSONField(types.TypeDecorator):
|
|||
def handle_peewee_migration(DATABASE_URL):
|
||||
db = None
|
||||
try:
|
||||
# Normalize SSL params so psycopg2 always sees `sslmode=` (never `ssl=`).
|
||||
url_without_ssl, ssl_mode = extract_ssl_mode_from_url(DATABASE_URL)
|
||||
normalized_url = reattach_ssl_mode_to_url(url_without_ssl, ssl_mode)
|
||||
# Normalize SSL params so psycopg2 always sees `sslmode=` (never `ssl=`)
|
||||
# and cert-file params are preserved in the connection string.
|
||||
url_without_ssl, ssl_params = extract_ssl_params_from_url(DATABASE_URL)
|
||||
normalized_url = reattach_ssl_params_to_url(url_without_ssl, ssl_params)
|
||||
|
||||
# Replace the postgresql:// with postgres:// to handle the peewee migration
|
||||
db = register_connection(normalized_url.replace('postgresql://', 'postgres://'))
|
||||
|
|
@ -179,32 +175,38 @@ if ENABLE_DB_MIGRATIONS:
|
|||
handle_peewee_migration(DATABASE_URL)
|
||||
|
||||
|
||||
# Normalize SSL params from the URL once; each engine branch re-injects
|
||||
# the driver-appropriate form.
|
||||
DATABASE_URL_WITHOUT_SSL, DATABASE_SSL_MODE = extract_ssl_mode_from_url(DATABASE_URL)
|
||||
# Normalize SSL params from the URL once; the sync engine needs them
|
||||
# reattached in canonical libpq form for psycopg2.
|
||||
_url_without_ssl, _ssl_dict = extract_ssl_params_from_url(DATABASE_URL)
|
||||
|
||||
# For psycopg2 (sync engine), re-append sslmode=<value>.
|
||||
# For psycopg2 (sync engine), re-append sslmode + cert-file params.
|
||||
SQLALCHEMY_DATABASE_URL = (
|
||||
reattach_ssl_mode_to_url(DATABASE_URL_WITHOUT_SSL, DATABASE_SSL_MODE) if DATABASE_SSL_MODE else DATABASE_URL
|
||||
reattach_ssl_params_to_url(_url_without_ssl, _ssl_dict) if _ssl_dict else DATABASE_URL
|
||||
)
|
||||
|
||||
|
||||
def _make_async_url(url: str) -> str:
|
||||
"""Convert a sync database URL to its async driver equivalent."""
|
||||
"""Convert a sync database URL to its async driver equivalent.
|
||||
|
||||
The async engine uses psycopg (v3) which speaks libpq natively,
|
||||
so all standard connection-string parameters (``sslmode``,
|
||||
``options``, ``target_session_attrs``, etc.) are passed through
|
||||
without any translation.
|
||||
"""
|
||||
if url.startswith('sqlite+sqlcipher://'):
|
||||
# SQLCipher has no async driver — not supported for async
|
||||
raise ValueError(
|
||||
'sqlite+sqlcipher:// URLs are not supported with async engine. '
|
||||
'Use standard sqlite:// or postgresql:// instead.'
|
||||
)
|
||||
if url.startswith('sqlite:///') or url.startswith('sqlite://'):
|
||||
return url.replace('sqlite://', 'sqlite+aiosqlite://', 1)
|
||||
# psycopg v3 — auto-selects async mode with create_async_engine
|
||||
if url.startswith('postgresql+psycopg2://'):
|
||||
return url.replace('postgresql+psycopg2://', 'postgresql+asyncpg://', 1)
|
||||
return url.replace('postgresql+psycopg2://', 'postgresql+psycopg://', 1)
|
||||
if url.startswith('postgresql://'):
|
||||
return url.replace('postgresql://', 'postgresql+asyncpg://', 1)
|
||||
return url.replace('postgresql://', 'postgresql+psycopg://', 1)
|
||||
if url.startswith('postgres://'):
|
||||
return url.replace('postgres://', 'postgresql+asyncpg://', 1)
|
||||
return url.replace('postgres://', 'postgresql+psycopg://', 1)
|
||||
# For other dialects, return as-is and let SQLAlchemy handle it
|
||||
return url
|
||||
|
||||
|
|
@ -329,10 +331,10 @@ get_db = contextmanager(get_session)
|
|||
# ASYNC ENGINE (used for ALL runtime database operations)
|
||||
# ============================================================
|
||||
|
||||
# Use the SSL-stripped URL for asyncpg — SSL is injected via connect_args.
|
||||
ASYNC_SQLALCHEMY_DATABASE_URL = _make_async_url(
|
||||
DATABASE_URL_WITHOUT_SSL if DATABASE_SSL_MODE else SQLALCHEMY_DATABASE_URL
|
||||
)
|
||||
# psycopg (v3) speaks libpq natively — the full DATABASE_URL is passed
|
||||
# through as-is. SSL params, ``options``, ``target_session_attrs``, etc.
|
||||
# all work without any stripping or translation.
|
||||
ASYNC_SQLALCHEMY_DATABASE_URL = _make_async_url(SQLALCHEMY_DATABASE_URL)
|
||||
|
||||
if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
|
||||
# Generous default — async coroutines + no session sharing = high connection demand.
|
||||
|
|
@ -350,10 +352,6 @@ if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
|
|||
def _set_sqlite_pragmas(dbapi_connection, connection_record):
|
||||
_apply_sqlite_pragmas(dbapi_connection)
|
||||
else:
|
||||
# Inject asyncpg-compatible SSL connect_args when the user specified
|
||||
# sslmode/ssl in DATABASE_URL.
|
||||
asyncpg_ssl_args = build_asyncpg_ssl_args(DATABASE_SSL_MODE)
|
||||
|
||||
if isinstance(DATABASE_POOL_SIZE, int):
|
||||
if DATABASE_POOL_SIZE > 0:
|
||||
async_engine = create_async_engine(
|
||||
|
|
@ -363,20 +361,17 @@ else:
|
|||
pool_timeout=DATABASE_POOL_TIMEOUT,
|
||||
pool_recycle=DATABASE_POOL_RECYCLE,
|
||||
pool_pre_ping=True,
|
||||
**asyncpg_ssl_args,
|
||||
)
|
||||
else:
|
||||
async_engine = create_async_engine(
|
||||
ASYNC_SQLALCHEMY_DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
poolclass=NullPool,
|
||||
**asyncpg_ssl_args,
|
||||
)
|
||||
else:
|
||||
async_engine = create_async_engine(
|
||||
ASYNC_SQLALCHEMY_DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
**asyncpg_ssl_args,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -303,6 +303,8 @@ from open_webui.config import (
|
|||
DOCUMENT_INTELLIGENCE_MODEL,
|
||||
MISTRAL_OCR_API_BASE_URL,
|
||||
MISTRAL_OCR_API_KEY,
|
||||
PADDLEOCR_VL_BASE_URL,
|
||||
PADDLEOCR_VL_TOKEN,
|
||||
RAG_TEXT_SPLITTER,
|
||||
ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER,
|
||||
TIKTOKEN_ENCODING_NAME,
|
||||
|
|
@ -1023,6 +1025,8 @@ app.state.config.DOCUMENT_INTELLIGENCE_KEY = DOCUMENT_INTELLIGENCE_KEY
|
|||
app.state.config.DOCUMENT_INTELLIGENCE_MODEL = DOCUMENT_INTELLIGENCE_MODEL
|
||||
app.state.config.MISTRAL_OCR_API_BASE_URL = MISTRAL_OCR_API_BASE_URL
|
||||
app.state.config.MISTRAL_OCR_API_KEY = MISTRAL_OCR_API_KEY
|
||||
app.state.config.PADDLEOCR_VL_BASE_URL = PADDLEOCR_VL_BASE_URL
|
||||
app.state.config.PADDLEOCR_VL_TOKEN = PADDLEOCR_VL_TOKEN
|
||||
app.state.config.MINERU_API_MODE = MINERU_API_MODE
|
||||
app.state.config.MINERU_API_URL = MINERU_API_URL
|
||||
app.state.config.MINERU_API_KEY = MINERU_API_KEY
|
||||
|
|
@ -1832,7 +1836,7 @@ async def chat_completion(
|
|||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
log.debug(f'Error processing chat metadata: {e}')
|
||||
log.warning(f'Error processing chat metadata: {e}')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
|
|
@ -1865,17 +1869,15 @@ async def chat_completion(
|
|||
except asyncio.CancelledError:
|
||||
log.info('Chat processing was cancelled')
|
||||
try:
|
||||
event_emitter = await get_event_emitter(metadata)
|
||||
if event_emitter:
|
||||
await asyncio.shield(
|
||||
event_emitter(
|
||||
{'type': 'chat:tasks:cancel'},
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
async def emit_cancel_event():
|
||||
event_emitter = await get_event_emitter(metadata)
|
||||
if event_emitter:
|
||||
await event_emitter({'type': 'chat:tasks:cancel'})
|
||||
|
||||
await asyncio.shield(emit_cancel_event())
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
raise # re-raise to ensure proper task cancellation handling
|
||||
raise # re-raise to ensure proper task cancellation handling
|
||||
except Exception as e:
|
||||
error_detail = e.detail if isinstance(e, HTTPException) else str(e)
|
||||
log.error('Error processing chat payload: %s', error_detail)
|
||||
|
|
@ -1906,37 +1908,53 @@ async def chat_completion(
|
|||
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# No chat_id/message_id → legacy/direct API path with no
|
||||
# WebSocket error channel. We must surface the error as
|
||||
# a proper HTTP response; without this the function would
|
||||
# return None which FastAPI serializes as null. #23924
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error_detail,
|
||||
)
|
||||
finally:
|
||||
# Clean up MCP clients. Shield the entire block from
|
||||
# CancelledError so disconnect() can finish even when the
|
||||
# task is being stopped. Each client is isolated so one
|
||||
# failure doesn't skip the rest.
|
||||
# MCP cleanup — MUST run in the SAME asyncio task as
|
||||
# connect() because the MCP SDK's streamablehttp_client
|
||||
# uses anyio task groups whose cancel scopes enforce
|
||||
# same-task exit. Do NOT wrap in asyncio.shield() or
|
||||
# asyncio.wait_for() — both create a new task.
|
||||
try:
|
||||
if mcp_clients := metadata.get('mcp_clients'):
|
||||
|
||||
async def _cleanup_mcp():
|
||||
for client in reversed(list(mcp_clients.values())):
|
||||
try:
|
||||
await client.disconnect()
|
||||
except Exception as e:
|
||||
log.debug(f'Error disconnecting MCP client: {e}')
|
||||
|
||||
await asyncio.wait_for(
|
||||
asyncio.shield(_cleanup_mcp()),
|
||||
timeout=10.0,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
log.warning('MCP client cleanup timed out after 10 s')
|
||||
for client in reversed(list(mcp_clients.values())):
|
||||
try:
|
||||
await client.disconnect()
|
||||
except Exception as e:
|
||||
log.debug(f'Error disconnecting MCP client: {e}')
|
||||
except asyncio.CancelledError:
|
||||
# Let the client close asynchronously by GC
|
||||
pass
|
||||
except Exception as e:
|
||||
log.debug(f'Error cleaning up MCP clients: {e}')
|
||||
# Emit chat:active=false when task completes
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
try:
|
||||
if metadata.get('chat_id'):
|
||||
event_emitter = await get_event_emitter(metadata, update_db=False)
|
||||
if event_emitter:
|
||||
await event_emitter({'type': 'chat:active', 'data': {'active': False}})
|
||||
except Exception as e:
|
||||
log.debug(f'Error emitting chat:active: {e}')
|
||||
async def emit_inactive_event():
|
||||
try:
|
||||
event_emitter = await get_event_emitter(metadata, update_db=False)
|
||||
if event_emitter:
|
||||
await event_emitter({'type': 'chat:active', 'data': {'active': False}})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Shield the event emission so it finishes even if the main task is cancelled
|
||||
await asyncio.shield(emit_inactive_event())
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fan out: one task per model
|
||||
if metadata.get('session_id') and metadata.get('chat_id'):
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from alembic import context
|
|||
from open_webui.models.auths import Auth
|
||||
from open_webui.models.calendar import Calendar, CalendarEvent, CalendarEventAttendee # noqa: F401
|
||||
from open_webui.env import DATABASE_URL, DATABASE_PASSWORD, LOG_FORMAT
|
||||
from open_webui.internal.db import extract_ssl_mode_from_url, reattach_ssl_mode_to_url
|
||||
from open_webui.internal.db import extract_ssl_params_from_url, reattach_ssl_params_to_url
|
||||
from sqlalchemy import engine_from_config, pool, create_engine
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
|
|
@ -37,9 +37,9 @@ target_metadata = Auth.metadata
|
|||
|
||||
DB_URL = DATABASE_URL
|
||||
|
||||
# Normalize SSL query params for psycopg2 (Alembic uses psycopg2, not asyncpg).
|
||||
url_without_ssl, ssl_mode = extract_ssl_mode_from_url(DB_URL)
|
||||
DB_URL = reattach_ssl_mode_to_url(url_without_ssl, ssl_mode) if ssl_mode else DB_URL
|
||||
# Normalize SSL query params for psycopg2 (Alembic uses psycopg2 for sync migrations).
|
||||
url_without_ssl, ssl_params = extract_ssl_params_from_url(DB_URL)
|
||||
DB_URL = reattach_ssl_params_to_url(url_without_ssl, ssl_params) if ssl_params else DB_URL
|
||||
|
||||
if DB_URL:
|
||||
config.set_main_option('sqlalchemy.url', DB_URL.replace('%', '%%'))
|
||||
|
|
|
|||
|
|
@ -143,6 +143,8 @@ class ModelAccessListResponse(BaseModel):
|
|||
|
||||
|
||||
class ModelForm(BaseModel):
|
||||
model_config = ConfigDict(extra='ignore')
|
||||
|
||||
id: str
|
||||
base_model_id: Optional[str] = None
|
||||
name: str
|
||||
|
|
@ -389,6 +391,56 @@ class ModelsTable:
|
|||
|
||||
return ModelListResponse(items=models, total=total)
|
||||
|
||||
async def get_model_meta_by_id(
|
||||
self, id: str, db: Optional[AsyncSession] = None
|
||||
) -> Optional[tuple[dict, int]]:
|
||||
"""Return (meta, updated_at) for a model, skipping access grant resolution."""
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
select(Model.meta, Model.updated_at).filter_by(id=id)
|
||||
)
|
||||
return result.first()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def get_all_tags(
|
||||
self,
|
||||
user_id: str,
|
||||
is_admin: bool = False,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> set[str]:
|
||||
"""Extract unique tag names from model meta, querying only the meta column."""
|
||||
async with get_async_db_context(db) as db:
|
||||
stmt = select(Model.meta).filter(Model.base_model_id != None)
|
||||
|
||||
if not is_admin:
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_group_ids = [group.id for group in user_groups]
|
||||
|
||||
filter_dict = {'user_id': user_id}
|
||||
if user_group_ids:
|
||||
filter_dict['group_ids'] = user_group_ids
|
||||
|
||||
stmt = self._has_permission(db, stmt, filter_dict, permission='read')
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = result.scalars().all()
|
||||
|
||||
tags_set: set[str] = set()
|
||||
for meta in rows:
|
||||
if not meta:
|
||||
continue
|
||||
for tag in meta.get('tags', []):
|
||||
try:
|
||||
name = tag.get('name') if isinstance(tag, dict) else str(tag)
|
||||
if name:
|
||||
tags_set.add(name)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return tags_set
|
||||
|
||||
async def get_model_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ModelModel]:
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
|
|
|
|||
|
|
@ -320,6 +320,21 @@ class OAuthSessionTable:
|
|||
log.error(f'Error deleting OAuth sessions by user ID: {e}')
|
||||
return False
|
||||
|
||||
async def delete_sessions_by_user_id_and_provider(
|
||||
self, user_id: str, provider: str, db: Optional[AsyncSession] = None
|
||||
) -> bool:
|
||||
"""Delete all OAuth sessions for a specific user and provider"""
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
delete(OAuthSession).filter_by(user_id=user_id, provider=provider)
|
||||
)
|
||||
await db.commit()
|
||||
return result.rowcount > 0
|
||||
except Exception as e:
|
||||
log.error(f'Error deleting OAuth sessions for user {user_id} and provider {provider}: {e}')
|
||||
return False
|
||||
|
||||
async def delete_sessions_by_provider(self, provider: str, db: Optional[AsyncSession] = None) -> bool:
|
||||
"""Delete all OAuth sessions for a provider"""
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -23,9 +23,9 @@ from open_webui.retrieval.loaders.external_document import ExternalDocumentLoade
|
|||
from open_webui.retrieval.loaders.mistral import MistralLoader
|
||||
from open_webui.retrieval.loaders.datalab_marker import DatalabMarkerLoader
|
||||
from open_webui.retrieval.loaders.mineru import MinerULoader
|
||||
from open_webui.retrieval.loaders.paddleocr_vl import PaddleOCRVLLoader
|
||||
|
||||
|
||||
from open_webui.env import GLOBAL_LOG_LEVEL, REQUESTS_VERIFY
|
||||
from open_webui.env import GLOBAL_LOG_LEVEL, REQUESTS_VERIFY, AIOHTTP_CLIENT_SESSION_SSL
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -205,6 +205,7 @@ class DoclingLoader:
|
|||
**self.params,
|
||||
},
|
||||
headers=headers,
|
||||
verify=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
)
|
||||
if r.ok:
|
||||
result = r.json()
|
||||
|
|
@ -399,6 +400,15 @@ class Loader:
|
|||
api_key=self.kwargs.get('MISTRAL_OCR_API_KEY'),
|
||||
file_path=file_path,
|
||||
)
|
||||
elif (
|
||||
self.engine == 'paddleocr_vl'
|
||||
and self.kwargs.get('PADDLEOCR_VL_TOKEN') != ''
|
||||
):
|
||||
loader = PaddleOCRVLLoader(
|
||||
api_url=self.kwargs.get('PADDLEOCR_VL_BASE_URL'),
|
||||
token=self.kwargs.get('PADDLEOCR_VL_TOKEN'),
|
||||
file_path=file_path,
|
||||
)
|
||||
else:
|
||||
if file_ext == 'pdf':
|
||||
loader = PyPDFLoader(
|
||||
|
|
|
|||
127
backend/open_webui/retrieval/loaders/paddleocr_vl.py
Normal file
127
backend/open_webui/retrieval/loaders/paddleocr_vl.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import base64
|
||||
import os
|
||||
import requests
|
||||
import logging
|
||||
import sys
|
||||
from typing import List
|
||||
|
||||
from langchain_core.documents import Document
|
||||
from open_webui.env import GLOBAL_LOG_LEVEL
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
class PaddleOCRVLLoader:
|
||||
"""Loader that uses PaddleOCR-vl API to extract text from PDF/images."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_url: str,
|
||||
token: str,
|
||||
file_path: str,
|
||||
):
|
||||
if not api_url or not token:
|
||||
raise ValueError("PaddleOCR-vl API URL and Token are required.")
|
||||
if not os.path.exists(file_path):
|
||||
raise FileNotFoundError(f"File not found at {file_path}")
|
||||
|
||||
self.api_url = api_url.rstrip('/')
|
||||
self.token = token
|
||||
self.file_path = file_path
|
||||
self.file_name = os.path.basename(file_path)
|
||||
|
||||
def load(self) -> List[Document]:
|
||||
log.info(f"Processing with PaddleOCR-vl: {self.file_path}")
|
||||
|
||||
try:
|
||||
with open(self.file_path, "rb") as file:
|
||||
file_bytes = file.read()
|
||||
file_data = base64.b64encode(file_bytes).decode("ascii")
|
||||
except Exception as e:
|
||||
log.error(f"Failed to read file {self.file_path}: {e}")
|
||||
raise
|
||||
|
||||
headers = {
|
||||
"Authorization": f"token {self.token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# Detect fileType based on file extension
|
||||
ext = self.file_path.lower().split('.')[-1]
|
||||
image_extensions = ['png', 'jpg', 'jpeg', 'bmp', 'tiff', 'webp']
|
||||
file_type = 1 if ext in image_extensions else 0
|
||||
|
||||
payload = {
|
||||
"file": file_data,
|
||||
"fileType": file_type,
|
||||
"useDocOrientationClassify": False,
|
||||
"useDocUnwarping": False,
|
||||
"useChartRecognition": False,
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(f"{self.api_url}/layout-parsing", json=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json().get("result", {})
|
||||
layout_results = result.get("layoutParsingResults", [])
|
||||
|
||||
documents = []
|
||||
total_pages = len(layout_results)
|
||||
skipped_pages = 0
|
||||
|
||||
for i, res in enumerate(layout_results):
|
||||
markdown_text = res.get("markdown", {}).get("text", "")
|
||||
|
||||
if isinstance(markdown_text, str):
|
||||
cleaned_content = markdown_text.strip()
|
||||
else:
|
||||
cleaned_content = str(markdown_text).strip()
|
||||
|
||||
if not cleaned_content:
|
||||
skipped_pages += 1
|
||||
continue
|
||||
|
||||
documents.append(
|
||||
Document(
|
||||
page_content=cleaned_content,
|
||||
metadata={
|
||||
"page": i,
|
||||
"page_label": i + 1,
|
||||
"total_pages": total_pages,
|
||||
"file_name": self.file_name,
|
||||
"processing_engine": "paddleocr-vl"
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
if skipped_pages > 0:
|
||||
log.info(f"PaddleOCR-vl: Processed {len(documents)} pages, skipped {skipped_pages} empty pages.")
|
||||
|
||||
if not documents:
|
||||
log.warning("No valid text content found by PaddleOCR-vl.")
|
||||
return [
|
||||
Document(
|
||||
page_content="No valid text content found in document",
|
||||
metadata={
|
||||
"error": "no_valid_pages",
|
||||
"file_name": self.file_name,
|
||||
"processing_engine": "paddleocr-vl"
|
||||
}
|
||||
)
|
||||
]
|
||||
|
||||
return documents
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error calling PaddleOCR-vl: {e}")
|
||||
return [
|
||||
Document(
|
||||
page_content=f"Error during OCR processing: {e}",
|
||||
metadata={
|
||||
"error": "processing_failed",
|
||||
"file_name": self.file_name,
|
||||
"processing_engine": "paddleocr-vl"
|
||||
}
|
||||
)
|
||||
]
|
||||
|
|
@ -114,6 +114,8 @@ def build_loader_from_config(request):
|
|||
DOCUMENT_INTELLIGENCE_MODEL=config.DOCUMENT_INTELLIGENCE_MODEL,
|
||||
MISTRAL_OCR_API_BASE_URL=config.MISTRAL_OCR_API_BASE_URL,
|
||||
MISTRAL_OCR_API_KEY=config.MISTRAL_OCR_API_KEY,
|
||||
PADDLEOCR_VL_BASE_URL=config.PADDLEOCR_VL_BASE_URL,
|
||||
PADDLEOCR_VL_TOKEN=config.PADDLEOCR_VL_TOKEN,
|
||||
MINERU_API_MODE=config.MINERU_API_MODE,
|
||||
MINERU_API_URL=config.MINERU_API_URL,
|
||||
MINERU_API_KEY=config.MINERU_API_KEY,
|
||||
|
|
@ -564,6 +566,13 @@ async def query_collection(
|
|||
log.exception(f'Error when querying the collection: {e}')
|
||||
return None, e
|
||||
|
||||
# Sanitize: filter out None/empty queries to prevent embedding crashes
|
||||
# (e.g. when get_last_user_message returns None)
|
||||
queries = [q for q in queries if q]
|
||||
if not queries:
|
||||
log.warning('query_collection: all queries were None or empty, returning empty results')
|
||||
return {'distances': [[]], 'documents': [[]], 'metadatas': [[]]}
|
||||
|
||||
# Generate all query embeddings (in one call)
|
||||
query_embeddings = await embedding_function(queries, prefix=RAG_EMBEDDING_QUERY_PREFIX)
|
||||
log.debug(f'query_collection: processing {len(queries)} queries across {len(collection_names)} collections')
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import socket
|
|||
import ssl
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
import requests
|
||||
from datetime import datetime, time, timedelta
|
||||
from typing import (
|
||||
Any,
|
||||
|
|
|
|||
|
|
@ -576,7 +576,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
|||
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
|
||||
mistral_payload = {
|
||||
'input': payload.get('input', ''),
|
||||
'model': request.app.state.config.TTS_MODEL or 'mistral-tts-latest',
|
||||
'model': request.app.state.config.TTS_MODEL or 'voxtral-mini-tts-2603',
|
||||
'voice_id': payload.get('voice', ''),
|
||||
'response_format': 'mp3',
|
||||
}
|
||||
|
|
@ -1345,7 +1345,7 @@ async def get_available_models(request: Request) -> list[dict]:
|
|||
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'}]
|
||||
available_models = [{'id': 'voxtral-mini-tts-2603'}]
|
||||
return available_models
|
||||
|
||||
|
||||
|
|
@ -1431,11 +1431,14 @@ async def get_available_voices(request) -> dict:
|
|||
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
|
||||
# Mistral returns a paginated response: {"items": [...], "page": ..., "total": ...}
|
||||
voices_list = voices_data.get('items', []) if isinstance(voices_data, dict) else voices_data
|
||||
for voice in voices_list:
|
||||
if isinstance(voice, dict):
|
||||
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)}')
|
||||
|
||||
|
|
|
|||
|
|
@ -172,10 +172,17 @@ async def get_session_user(
|
|||
user=Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
token = None
|
||||
auth_header = request.headers.get('Authorization')
|
||||
auth_token = get_http_authorization_cred(auth_header)
|
||||
token = auth_token.credentials
|
||||
data = decode_token(token)
|
||||
if auth_header:
|
||||
auth_token = get_http_authorization_cred(auth_header)
|
||||
if auth_token is not None:
|
||||
token = auth_token.credentials
|
||||
if token is None:
|
||||
token = request.cookies.get('token')
|
||||
if token is None and getattr(request.state, 'token', None):
|
||||
token = request.state.token.credentials
|
||||
data = decode_token(token) if token else None
|
||||
|
||||
expires_at = None
|
||||
|
||||
|
|
@ -773,8 +780,9 @@ async def signout(request: Request, response: Response, db: AsyncSession = Depen
|
|||
auth_header = request.headers.get('Authorization')
|
||||
if auth_header:
|
||||
auth_cred = get_http_authorization_cred(auth_header)
|
||||
token = auth_cred.credentials
|
||||
else:
|
||||
if auth_cred is not None:
|
||||
token = auth_cred.credentials
|
||||
if token is None:
|
||||
token = request.cookies.get('token')
|
||||
|
||||
if token:
|
||||
|
|
@ -853,6 +861,33 @@ async def signout(request: Request, response: Response, db: AsyncSession = Depen
|
|||
return JSONResponse(status_code=200, content={'status': True}, headers=response.headers)
|
||||
|
||||
|
||||
############################
|
||||
# OAuth Session Management
|
||||
############################
|
||||
|
||||
|
||||
@router.delete('/oauth/sessions/{provider:path}', response_model=bool)
|
||||
async def delete_oauth_session_by_provider(
|
||||
provider: str,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""
|
||||
Disconnect the current user's OAuth session for a specific provider.
|
||||
The provider string matches the 'provider' field in the oauth_session table
|
||||
(e.g. 'mcp:server-id' for MCP connections).
|
||||
"""
|
||||
result = await OAuthSessions.delete_sessions_by_user_id_and_provider(
|
||||
user.id, provider, db=db
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='No OAuth session found for this provider',
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
############################
|
||||
# AddUser
|
||||
############################
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import base64
|
|||
import json
|
||||
import asyncio
|
||||
import logging
|
||||
import posixpath
|
||||
from urllib.parse import unquote
|
||||
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.models import (
|
||||
|
|
@ -29,12 +31,12 @@ from fastapi import (
|
|||
status,
|
||||
Response,
|
||||
)
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from fastapi.responses import RedirectResponse, StreamingResponse
|
||||
|
||||
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, STATIC_DIR
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
from open_webui.internal.db import get_async_session
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
|
@ -43,6 +45,34 @@ log = logging.getLogger(__name__)
|
|||
router = APIRouter()
|
||||
|
||||
|
||||
def _safe_static_redirect_path(url: str) -> Optional[str]:
|
||||
"""
|
||||
If url is a same-origin static asset path, return a normalized path safe for
|
||||
RedirectResponse Location. Otherwise None (caller should fall back to default).
|
||||
Rejects traversal (..), encoded dots, query/fragment, and non-/static targets.
|
||||
"""
|
||||
if not url or not isinstance(url, str):
|
||||
return None
|
||||
path = url.split('?', 1)[0].split('#', 1)[0].strip()
|
||||
for _ in range(2):
|
||||
decoded = unquote(path)
|
||||
if decoded == path:
|
||||
break
|
||||
path = decoded
|
||||
if '\x00' in path or '\\' in path:
|
||||
return None
|
||||
if not path.startswith('/'):
|
||||
return None
|
||||
normalized = posixpath.normpath(path)
|
||||
if normalized in ('.', '/'):
|
||||
return None
|
||||
if not (normalized == '/static' or normalized.startswith('/static/')):
|
||||
return None
|
||||
if normalized == '/static':
|
||||
return '/static/'
|
||||
return normalized
|
||||
|
||||
|
||||
def is_valid_model_id(model_id: str) -> bool:
|
||||
return model_id and len(model_id) <= 256
|
||||
|
||||
|
|
@ -108,18 +138,25 @@ async def get_models(
|
|||
db=db,
|
||||
)
|
||||
|
||||
return ModelAccessListResponse(
|
||||
items=[
|
||||
# Strip profile_image_url from meta — images are served via /model/profile/image.
|
||||
items = []
|
||||
for model in result.items:
|
||||
data = model.model_dump()
|
||||
if data.get('meta'):
|
||||
data['meta'].pop('profile_image_url', None)
|
||||
items.append(
|
||||
ModelAccessResponse(
|
||||
**model.model_dump(),
|
||||
**data,
|
||||
write_access=(
|
||||
(user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == model.user_id
|
||||
or model.id in writable_model_ids
|
||||
),
|
||||
)
|
||||
for model in result.items
|
||||
],
|
||||
)
|
||||
|
||||
return ModelAccessListResponse(
|
||||
items=items,
|
||||
total=result.total,
|
||||
)
|
||||
|
||||
|
|
@ -141,25 +178,12 @@ async def get_base_models(user=Depends(get_admin_user), db: AsyncSession = Depen
|
|||
|
||||
@router.get('/tags', response_model=list[str])
|
||||
async def get_model_tags(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
||||
if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
models = await Models.get_models(db=db)
|
||||
else:
|
||||
models = await Models.get_models_by_user_id(user.id, db=db)
|
||||
|
||||
tags_set = set()
|
||||
for model in models:
|
||||
if model.meta:
|
||||
meta = model.meta.model_dump()
|
||||
for tag in meta.get('tags', []):
|
||||
try:
|
||||
name = tag.get('name') if isinstance(tag, dict) else str(tag)
|
||||
if name:
|
||||
tags_set.add(name)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
tags = sorted(tags_set)
|
||||
return tags
|
||||
tags = await Models.get_all_tags(
|
||||
user_id=user.id,
|
||||
is_admin=(user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL),
|
||||
db=db,
|
||||
)
|
||||
return sorted(tags)
|
||||
|
||||
|
||||
############################
|
||||
|
|
@ -436,39 +460,48 @@ async def get_model_profile_image(
|
|||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
model = await Models.get_model_by_id(id, db=db)
|
||||
model_meta = await Models.get_model_meta_by_id(id, db=db)
|
||||
|
||||
if model:
|
||||
etag = f'"{model.updated_at}"' if model.updated_at else None
|
||||
if model_meta:
|
||||
meta, updated_at = model_meta
|
||||
profile_image_url = (meta or {}).get('profile_image_url')
|
||||
|
||||
if model.meta.profile_image_url:
|
||||
if model.meta.profile_image_url.startswith('http'):
|
||||
if profile_image_url:
|
||||
if profile_image_url.startswith('http'):
|
||||
return Response(
|
||||
status_code=status.HTTP_302_FOUND,
|
||||
headers={'Location': model.meta.profile_image_url},
|
||||
headers={'Location': profile_image_url},
|
||||
)
|
||||
elif model.meta.profile_image_url.startswith('data:image'):
|
||||
elif profile_image_url.startswith('data:image'):
|
||||
try:
|
||||
header, base64_data = model.meta.profile_image_url.split(',', 1)
|
||||
header, base64_data = profile_image_url.split(',', 1)
|
||||
image_data = base64.b64decode(base64_data)
|
||||
image_buffer = io.BytesIO(image_data)
|
||||
media_type = header.split(';')[0].lstrip('data:')
|
||||
|
||||
headers = {'Content-Disposition': 'inline'}
|
||||
if etag:
|
||||
headers['ETag'] = etag
|
||||
if updated_at:
|
||||
headers['ETag'] = f'"{updated_at}"'
|
||||
|
||||
return StreamingResponse(
|
||||
image_buffer,
|
||||
media_type=media_type,
|
||||
headers=headers,
|
||||
)
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
safe_static = _safe_static_redirect_path(profile_image_url)
|
||||
if safe_static:
|
||||
return RedirectResponse(
|
||||
url=safe_static,
|
||||
status_code=status.HTTP_302_FOUND,
|
||||
)
|
||||
|
||||
return FileResponse(f'{STATIC_DIR}/favicon.png')
|
||||
else:
|
||||
return FileResponse(f'{STATIC_DIR}/favicon.png')
|
||||
return RedirectResponse(
|
||||
url='/static/favicon.png',
|
||||
status_code=status.HTTP_302_FOUND,
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
|
|
|
|||
|
|
@ -86,6 +86,17 @@ log = logging.getLogger(__name__)
|
|||
#
|
||||
##########################################
|
||||
|
||||
# Headers that become stale after aiohttp auto-decompresses the upstream
|
||||
# response body. Forwarding them verbatim causes desktop / programmatic
|
||||
# clients to attempt decompression of an already-decoded payload, resulting
|
||||
# in ZlibError. See https://github.com/aio-libs/aiohttp/issues/4462.
|
||||
_STRIP_PROXY_HEADERS = frozenset({'Content-Encoding', 'Content-Length', 'Transfer-Encoding'})
|
||||
|
||||
|
||||
def _clean_proxy_headers(raw_headers) -> dict:
|
||||
"""Return a copy of *raw_headers* with stale encoding headers removed."""
|
||||
return {k: v for k, v in raw_headers.items() if k not in _STRIP_PROXY_HEADERS}
|
||||
|
||||
|
||||
async def send_get_request(url, key=None, user: UserModel = None):
|
||||
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
|
||||
|
|
@ -163,7 +174,7 @@ async def send_request(
|
|||
r.raise_for_status()
|
||||
|
||||
if stream:
|
||||
response_headers = dict(r.headers)
|
||||
response_headers = _clean_proxy_headers(r.headers)
|
||||
if content_type:
|
||||
response_headers['Content-Type'] = content_type
|
||||
|
||||
|
|
|
|||
|
|
@ -76,6 +76,17 @@ log = logging.getLogger(__name__)
|
|||
#
|
||||
##########################################
|
||||
|
||||
# Headers that become stale after aiohttp auto-decompresses the upstream
|
||||
# response body. Forwarding them verbatim causes desktop / programmatic
|
||||
# clients to attempt decompression of an already-decoded payload, resulting
|
||||
# in ZlibError. See https://github.com/aio-libs/aiohttp/issues/4462.
|
||||
_STRIP_PROXY_HEADERS = frozenset({'Content-Encoding', 'Content-Length', 'Transfer-Encoding'})
|
||||
|
||||
|
||||
def _clean_proxy_headers(raw_headers) -> dict:
|
||||
"""Return a copy of *raw_headers* with stale encoding headers removed."""
|
||||
return {k: v for k, v in raw_headers.items() if k not in _STRIP_PROXY_HEADERS}
|
||||
|
||||
|
||||
async def send_get_request(
|
||||
request: Request = None,
|
||||
|
|
@ -1219,7 +1230,7 @@ async def generate_chat_completion(
|
|||
return StreamingResponse(
|
||||
stream_wrapper(r, content_handler=stream_chunks_handler),
|
||||
status_code=r.status,
|
||||
headers=dict(r.headers),
|
||||
headers=_clean_proxy_headers(r.headers),
|
||||
)
|
||||
else:
|
||||
try:
|
||||
|
|
@ -1304,7 +1315,7 @@ async def embeddings(request: Request, form_data: dict, user):
|
|||
return StreamingResponse(
|
||||
stream_wrapper(r),
|
||||
status_code=r.status,
|
||||
headers=dict(r.headers),
|
||||
headers=_clean_proxy_headers(r.headers),
|
||||
)
|
||||
else:
|
||||
try:
|
||||
|
|
@ -1425,7 +1436,7 @@ async def responses(
|
|||
return StreamingResponse(
|
||||
stream_wrapper(r),
|
||||
status_code=r.status,
|
||||
headers=dict(r.headers),
|
||||
headers=_clean_proxy_headers(r.headers),
|
||||
)
|
||||
else:
|
||||
try:
|
||||
|
|
@ -1542,7 +1553,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
|
|||
return StreamingResponse(
|
||||
stream_wrapper(r),
|
||||
status_code=r.status,
|
||||
headers=dict(r.headers),
|
||||
headers=_clean_proxy_headers(r.headers),
|
||||
)
|
||||
else:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -480,6 +480,8 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
|
|||
'DOCUMENT_INTELLIGENCE_MODEL': request.app.state.config.DOCUMENT_INTELLIGENCE_MODEL,
|
||||
'MISTRAL_OCR_API_BASE_URL': request.app.state.config.MISTRAL_OCR_API_BASE_URL,
|
||||
'MISTRAL_OCR_API_KEY': request.app.state.config.MISTRAL_OCR_API_KEY,
|
||||
'PADDLEOCR_VL_BASE_URL': request.app.state.config.PADDLEOCR_VL_BASE_URL,
|
||||
'PADDLEOCR_VL_TOKEN': request.app.state.config.PADDLEOCR_VL_TOKEN,
|
||||
# MinerU settings
|
||||
'MINERU_API_MODE': request.app.state.config.MINERU_API_MODE,
|
||||
'MINERU_API_URL': request.app.state.config.MINERU_API_URL,
|
||||
|
|
@ -686,6 +688,8 @@ class ConfigForm(BaseModel):
|
|||
DOCUMENT_INTELLIGENCE_MODEL: Optional[str] = None
|
||||
MISTRAL_OCR_API_BASE_URL: Optional[str] = None
|
||||
MISTRAL_OCR_API_KEY: Optional[str] = None
|
||||
PADDLEOCR_VL_BASE_URL: Optional[str] = None
|
||||
PADDLEOCR_VL_TOKEN: Optional[str] = None
|
||||
|
||||
# MinerU settings
|
||||
MINERU_API_MODE: Optional[str] = None
|
||||
|
|
@ -887,6 +891,16 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
|
|||
if form_data.MISTRAL_OCR_API_KEY is not None
|
||||
else request.app.state.config.MISTRAL_OCR_API_KEY
|
||||
)
|
||||
request.app.state.config.PADDLEOCR_VL_BASE_URL = (
|
||||
form_data.PADDLEOCR_VL_BASE_URL
|
||||
if form_data.PADDLEOCR_VL_BASE_URL is not None
|
||||
else request.app.state.config.PADDLEOCR_VL_BASE_URL
|
||||
)
|
||||
request.app.state.config.PADDLEOCR_VL_TOKEN = (
|
||||
form_data.PADDLEOCR_VL_TOKEN
|
||||
if form_data.PADDLEOCR_VL_TOKEN is not None
|
||||
else request.app.state.config.PADDLEOCR_VL_TOKEN
|
||||
)
|
||||
|
||||
# MinerU settings
|
||||
request.app.state.config.MINERU_API_MODE = (
|
||||
|
|
@ -1152,6 +1166,8 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
|
|||
'DOCUMENT_INTELLIGENCE_MODEL': request.app.state.config.DOCUMENT_INTELLIGENCE_MODEL,
|
||||
'MISTRAL_OCR_API_BASE_URL': request.app.state.config.MISTRAL_OCR_API_BASE_URL,
|
||||
'MISTRAL_OCR_API_KEY': request.app.state.config.MISTRAL_OCR_API_KEY,
|
||||
'PADDLEOCR_VL_BASE_URL': request.app.state.config.PADDLEOCR_VL_BASE_URL,
|
||||
'PADDLEOCR_VL_TOKEN': request.app.state.config.PADDLEOCR_VL_TOKEN,
|
||||
# MinerU settings
|
||||
'MINERU_API_MODE': request.app.state.config.MINERU_API_MODE,
|
||||
'MINERU_API_URL': request.app.state.config.MINERU_API_URL,
|
||||
|
|
|
|||
|
|
@ -917,3 +917,4 @@ async def update_tools_user_valves_by_id(
|
|||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -550,6 +550,8 @@ async def update_user_by_id(
|
|||
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
log.error(f'Error checking primary admin status: {e}')
|
||||
raise HTTPException(
|
||||
|
|
@ -631,6 +633,8 @@ async def delete_user_by_id(user_id: str, user=Depends(get_admin_user), db: Asyn
|
|||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
log.error(f'Error checking primary admin status: {e}')
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -238,9 +238,13 @@ async def fetch_url(
|
|||
content, _ = await asyncio.to_thread(get_content_from_url, __request__, url)
|
||||
|
||||
# Truncate if configured (WEB_FETCH_MAX_CONTENT_LENGTH)
|
||||
max_length = getattr(__request__.app.state.config, 'WEB_FETCH_MAX_CONTENT_LENGTH', None)
|
||||
if max_length and max_length > 0 and len(content) > max_length:
|
||||
content = content[:max_length] + '\n\n[Content truncated...]'
|
||||
# Guard: content may be None if the web loader silently failed
|
||||
if content is not None:
|
||||
max_length = getattr(__request__.app.state.config, 'WEB_FETCH_MAX_CONTENT_LENGTH', None)
|
||||
if max_length and max_length > 0 and len(content) > max_length:
|
||||
content = content[:max_length] + '\n\n[Content truncated...]'
|
||||
else:
|
||||
content = ''
|
||||
|
||||
return content
|
||||
except Exception as e:
|
||||
|
|
@ -2568,8 +2572,11 @@ async def create_automation(
|
|||
if not user:
|
||||
return json.dumps({'error': 'User not found'})
|
||||
|
||||
# Always use the calling model for the automation
|
||||
model_id = (__metadata__ or {}).get('model_id')
|
||||
# Fall back to model dict ID since __metadata__ may predate model_id assignment
|
||||
metadata = __metadata__ or {}
|
||||
model_id = metadata.get('model_id') or (
|
||||
metadata.get('model', {}).get('id') if isinstance(metadata.get('model'), dict) else None
|
||||
)
|
||||
if not model_id:
|
||||
return json.dumps({'error': 'Could not detect current model'})
|
||||
|
||||
|
|
@ -2888,6 +2895,9 @@ def _ns_to_dt(ns: int, tz) -> str:
|
|||
|
||||
def _event_to_dict(event, tz) -> dict:
|
||||
"""Convert a calendar event model to a human-friendly dict with local timestamps."""
|
||||
alert_minutes = None
|
||||
if event.meta and 'alert_minutes' in event.meta:
|
||||
alert_minutes = event.meta['alert_minutes']
|
||||
return {
|
||||
'id': event.id,
|
||||
'calendar_id': event.calendar_id,
|
||||
|
|
@ -2897,6 +2907,7 @@ def _event_to_dict(event, tz) -> dict:
|
|||
'end': _ns_to_dt(event.end_at, tz) if event.end_at else None,
|
||||
'all_day': event.all_day,
|
||||
'location': event.location or '',
|
||||
'reminder_minutes': alert_minutes if alert_minutes is not None else 10,
|
||||
'color': event.color,
|
||||
'is_cancelled': event.is_cancelled,
|
||||
}
|
||||
|
|
@ -3003,6 +3014,7 @@ async def create_calendar_event(
|
|||
calendar_id: Optional[str] = None,
|
||||
all_day: bool = False,
|
||||
location: Optional[str] = None,
|
||||
reminder_minutes: Optional[int] = None,
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
) -> str:
|
||||
|
|
@ -3017,6 +3029,7 @@ async def create_calendar_event(
|
|||
:param calendar_id: Target calendar ID (optional, uses default calendar if omitted)
|
||||
:param all_day: Whether this is an all-day event (default: false)
|
||||
:param location: Event location (optional)
|
||||
:param reminder_minutes: Minutes before the event to send a reminder notification (optional, default: 10). Use 0 for "at time of event", -1 for no reminder. Accepts any positive integer for custom timing (e.g. 120 for 2 hours before).
|
||||
:return: JSON with the created event details including id
|
||||
"""
|
||||
if __request__ is None:
|
||||
|
|
@ -3079,6 +3092,18 @@ async def create_calendar_event(
|
|||
# Default to 1 hour duration
|
||||
end_ns = start_ns + 3_600_000_000_000
|
||||
|
||||
# Build meta with reminder setting
|
||||
meta = {}
|
||||
if reminder_minutes is not None:
|
||||
if isinstance(reminder_minutes, str):
|
||||
try:
|
||||
reminder_minutes = int(reminder_minutes)
|
||||
except ValueError:
|
||||
reminder_minutes = 10
|
||||
meta['alert_minutes'] = reminder_minutes
|
||||
else:
|
||||
meta['alert_minutes'] = 10
|
||||
|
||||
form = CalendarEventForm(
|
||||
calendar_id=calendar_id,
|
||||
title=title,
|
||||
|
|
@ -3087,6 +3112,7 @@ async def create_calendar_event(
|
|||
end_at=end_ns,
|
||||
all_day=all_day,
|
||||
location=location,
|
||||
meta=meta,
|
||||
)
|
||||
|
||||
event = await CalendarEvents.insert_new_event(user_id, form)
|
||||
|
|
@ -3114,6 +3140,7 @@ async def update_calendar_event(
|
|||
all_day: Optional[bool] = None,
|
||||
location: Optional[str] = None,
|
||||
is_cancelled: Optional[bool] = None,
|
||||
reminder_minutes: Optional[int] = None,
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
) -> str:
|
||||
|
|
@ -3129,6 +3156,7 @@ async def update_calendar_event(
|
|||
:param all_day: Whether this is an all-day event (optional)
|
||||
:param location: New event location (optional)
|
||||
:param is_cancelled: Set to true to cancel the event (optional)
|
||||
:param reminder_minutes: Minutes before the event to send a reminder notification (optional). Use 0 for "at time of event", -1 for no reminder. Accepts any positive integer for custom timing (e.g. 120 for 2 hours before).
|
||||
:return: JSON with the updated event details
|
||||
"""
|
||||
if __request__ is None:
|
||||
|
|
@ -3183,6 +3211,17 @@ async def update_calendar_event(
|
|||
except (ValueError, TypeError) as e:
|
||||
return json.dumps({'error': f'Invalid end datetime: {e}'})
|
||||
|
||||
# Build meta update with reminder setting if provided
|
||||
meta = None
|
||||
if reminder_minutes is not None:
|
||||
if isinstance(reminder_minutes, str):
|
||||
try:
|
||||
reminder_minutes = int(reminder_minutes)
|
||||
except ValueError:
|
||||
reminder_minutes = None
|
||||
if reminder_minutes is not None:
|
||||
meta = {'alert_minutes': reminder_minutes}
|
||||
|
||||
form = CalendarEventUpdateForm(
|
||||
title=title,
|
||||
description=description,
|
||||
|
|
@ -3191,6 +3230,7 @@ async def update_calendar_event(
|
|||
all_day=all_day,
|
||||
location=location,
|
||||
is_cancelled=is_cancelled,
|
||||
meta=meta,
|
||||
)
|
||||
|
||||
updated = await CalendarEvents.update_event_by_id(event_id, form)
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ from starlette.datastructures import MutableHeaders
|
|||
from starlette.requests import Request
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
from open_webui.env import CUSTOM_API_KEY_HEADER
|
||||
from open_webui.internal.db import ScopedSession
|
||||
from open_webui.utils.auth import get_http_authorization_cred
|
||||
|
||||
|
|
@ -119,9 +120,16 @@ class CommitSessionMiddleware:
|
|||
|
||||
|
||||
class AuthTokenMiddleware:
|
||||
"""Extract the bearer/cookie/x-api-key credential and stash it on
|
||||
"""Extract the bearer/cookie/API-key credential and stash it on
|
||||
`request.state.token`.
|
||||
|
||||
The header used for API-key transport is controlled by the
|
||||
``CUSTOM_API_KEY_HEADER`` environment variable (default ``x-api-key``).
|
||||
This is useful when Open WebUI sits behind a reverse proxy that
|
||||
consumes the ``Authorization`` header for its own authentication —
|
||||
set the env var to a unique header (e.g. ``X-OpenWebUI-Key``) so
|
||||
the middleware checks that instead and avoids the 401 short-circuit.
|
||||
|
||||
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
|
||||
|
|
@ -146,7 +154,7 @@ class AuthTokenMiddleware:
|
|||
if cookie_token:
|
||||
token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=cookie_token)
|
||||
if token is None:
|
||||
api_key = request.headers.get('x-api-key')
|
||||
api_key = request.headers.get(CUSTOM_API_KEY_HEADER)
|
||||
if api_key:
|
||||
token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=api_key)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ async def get_function_module(request, function_id, load_from_db=True):
|
|||
"""
|
||||
Get the function module by its ID.
|
||||
"""
|
||||
function_module, _, _ = await get_function_module_from_cache(request, function_id, load_from_db)
|
||||
function_module, _, _ = await get_function_module_from_cache(request, function_id, load_from_db=load_from_db)
|
||||
return function_module
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ def create_httpx_client(headers=None, timeout=None, auth=None):
|
|||
return _build_httpx_client(headers=headers, timeout=timeout, auth=auth, verify=True)
|
||||
|
||||
|
||||
async def create_insecure_httpx_client(headers=None, timeout=None, auth=None):
|
||||
def create_insecure_httpx_client(headers=None, timeout=None, auth=None):
|
||||
return _build_httpx_client(headers=headers, timeout=timeout, auth=auth, verify=False)
|
||||
|
||||
|
||||
|
|
@ -155,20 +155,18 @@ class MCPClient:
|
|||
self.session = None
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.shield(exit_stack.aclose()),
|
||||
timeout=5.0,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
# IMPORTANT: Do NOT use asyncio.shield() or asyncio.wait_for()
|
||||
# because they create a new asyncio task, which violates the MCP SDK's
|
||||
# requirement that its TaskGroup be exited in the exact same task.
|
||||
# ALSO do NOT use anyio.CancelScope(shield=True) or anyio.fail_after(),
|
||||
# because they push a new cancel scope onto the task, violating LIFO
|
||||
# order when aclose() attempts to exit the inner TaskGroup.
|
||||
# We simply call aclose() directly. If the task is cancelled, the
|
||||
# sockets will eventually be cleaned up by garbage collection.
|
||||
await exit_stack.aclose()
|
||||
except TimeoutError:
|
||||
log.warning('MCPClient.disconnect() timed out after 5 s')
|
||||
except RuntimeError as exc:
|
||||
# The MCP SDK's streamable_http transport uses anyio task
|
||||
# groups and async generators internally. When we close
|
||||
# a session that was interrupted mid-flight these can
|
||||
# raise RuntimeError ("aclose(): asynchronous generator is
|
||||
# already running" or "Attempted to exit cancel scope in a
|
||||
# different task"). Swallowing the error here prevents the
|
||||
# orphaned coroutines from spinning at 100 % CPU.
|
||||
log.debug('MCPClient.disconnect() suppressed RuntimeError: %s', exc)
|
||||
except Exception as exc:
|
||||
log.debug('MCPClient.disconnect() error: %s', exc)
|
||||
|
|
|
|||
|
|
@ -1546,11 +1546,11 @@ async def chat_web_search_handler(request: Request, form_data: dict, extra_param
|
|||
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
queries = [user_message]
|
||||
queries = [user_message or '']
|
||||
|
||||
# Check if generated queries are empty
|
||||
if len(queries) == 1 and queries[0].strip() == '':
|
||||
queries = [user_message]
|
||||
queries = [user_message or '']
|
||||
|
||||
# Check if queries are not found
|
||||
if len(queries) == 0:
|
||||
|
|
@ -1991,7 +1991,7 @@ async def chat_completion_files_handler(
|
|||
)
|
||||
|
||||
if len(queries) == 0:
|
||||
queries = [get_last_user_message(body['messages'])]
|
||||
queries = [get_last_user_message(body['messages']) or '']
|
||||
|
||||
try:
|
||||
# Directly await async get_sources_from_items (no thread needed - fully async now)
|
||||
|
|
@ -2180,6 +2180,43 @@ def process_messages_with_output(messages: list[dict]) -> list[dict]:
|
|||
return processed
|
||||
|
||||
|
||||
SKILL_MENTION_RE = re.compile(r'<\$([^|>]+)\|?[^>]*>')
|
||||
|
||||
|
||||
def _get_text_parts(message: dict) -> list[str]:
|
||||
"""Return all text segments from a message's content."""
|
||||
content = message.get('content')
|
||||
if isinstance(content, str):
|
||||
return [content]
|
||||
if isinstance(content, list):
|
||||
return [p.get('text', '') for p in content if isinstance(p, dict) and p.get('type') == 'text']
|
||||
return []
|
||||
|
||||
|
||||
def extract_skill_ids_from_messages(messages: list[dict]) -> set[str]:
|
||||
"""Extract skill IDs from <$skillId|label> mention tags in messages."""
|
||||
ids: set[str] = set()
|
||||
for message in messages:
|
||||
for text in _get_text_parts(message):
|
||||
ids.update(m.group(1) for m in SKILL_MENTION_RE.finditer(text))
|
||||
return ids
|
||||
|
||||
|
||||
def strip_skill_mentions(messages: list[dict]) -> None:
|
||||
"""Strip <$skillId|label> mention tags from message content in-place."""
|
||||
strip_re = re.compile(r'<\$[^>]+>')
|
||||
for message in messages:
|
||||
content = message.get('content')
|
||||
if isinstance(content, str) and strip_re.search(content):
|
||||
message['content'] = strip_re.sub('', content).strip()
|
||||
elif isinstance(content, list):
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get('type') == 'text':
|
||||
text = part.get('text', '')
|
||||
if strip_re.search(text):
|
||||
part['text'] = strip_re.sub('', text).strip()
|
||||
|
||||
|
||||
async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
# Pipeline Inlet -> Filter Inlet -> Chat Memory -> Chat Web Search -> Chat Image Generation
|
||||
# -> Chat Code Interpreter (Form Data Update) -> (Default) Chat Tools Function Calling
|
||||
|
|
@ -2465,8 +2502,10 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
# tool resolution (tool_ids, MCP servers, builtin tools).
|
||||
payload_tools = form_data.get('tools', None)
|
||||
|
||||
# Skills
|
||||
# Skills — extract IDs from message content (<$skillId|label> tags) so
|
||||
# persisted chats work without relying on the frontend to send skill_ids.
|
||||
user_skill_ids = set(form_data.pop('skill_ids', None) or [])
|
||||
user_skill_ids |= extract_skill_ids_from_messages(form_data.get('messages', []))
|
||||
model_skill_ids = set(model.get('info', {}).get('meta', {}).get('skillIds', []))
|
||||
|
||||
all_skill_ids = user_skill_ids | model_skill_ids
|
||||
|
|
@ -2502,6 +2541,9 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
append=True,
|
||||
)
|
||||
|
||||
# Strip <$skillId|label> mention tags so the model doesn't see raw markup.
|
||||
strip_skill_mentions(form_data.get('messages', []))
|
||||
|
||||
prompt = get_last_user_message(form_data['messages'])
|
||||
# TODO: re-enable URL extraction from prompt
|
||||
# urls = []
|
||||
|
|
@ -4269,6 +4311,8 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
'data': data,
|
||||
}
|
||||
)
|
||||
except (asyncio.CancelledError, KeyboardInterrupt):
|
||||
raise
|
||||
except Exception as e:
|
||||
done = 'data: [DONE]' in line
|
||||
if done:
|
||||
|
|
@ -4656,6 +4700,7 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
**form_data,
|
||||
'model': model_id,
|
||||
'stream': True,
|
||||
'metadata': metadata,
|
||||
}
|
||||
|
||||
if ENABLE_RESPONSES_API_STATEFUL and last_response_id:
|
||||
|
|
@ -4879,6 +4924,7 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
**form_data,
|
||||
'model': model_id,
|
||||
'stream': True,
|
||||
'metadata': metadata,
|
||||
'messages': [
|
||||
*form_data['messages'],
|
||||
*convert_output_to_messages(output, raw=True),
|
||||
|
|
@ -4971,31 +5017,39 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
await outlet_filter_handler(ctx)
|
||||
except asyncio.CancelledError:
|
||||
log.warning('Task was cancelled!')
|
||||
try:
|
||||
await asyncio.shield(event_emitter({'type': 'chat:tasks:cancel'}))
|
||||
|
||||
# Close the response body iterator to trigger cleanup
|
||||
# in stream_wrapper's finally block and release the
|
||||
# upstream connection. Without this, the async
|
||||
# generator is orphaned and may spin in anyio internals.
|
||||
if hasattr(response, 'body_iterator') and hasattr(response.body_iterator, 'aclose'):
|
||||
try:
|
||||
await asyncio.shield(response.body_iterator.aclose())
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
async def save_cancelled_state():
|
||||
await event_emitter({'type': 'chat:tasks:cancel'})
|
||||
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,
|
||||
},
|
||||
)
|
||||
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 asyncio.shield(
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata['chat_id'],
|
||||
metadata['message_id'],
|
||||
{'done': True},
|
||||
)
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata['chat_id'],
|
||||
metadata['message_id'],
|
||||
{'done': True},
|
||||
)
|
||||
except Exception:
|
||||
|
||||
try:
|
||||
await asyncio.shield(save_cancelled_state())
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
raise # re-raise CancelledError for proper propagation
|
||||
|
||||
|
|
|
|||
|
|
@ -843,9 +843,9 @@ def throttle(interval: float = 10.0):
|
|||
last_calls = {}
|
||||
lock = threading.Lock()
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
async def wrapper(*args, **kwargs):
|
||||
if interval is None:
|
||||
return func(*args, **kwargs)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
key = (args, freeze(kwargs))
|
||||
now = time.time()
|
||||
|
|
@ -855,7 +855,7 @@ def throttle(interval: float = 10.0):
|
|||
if now - last_calls.get(key, 0) < interval:
|
||||
return None
|
||||
last_calls[key] = now
|
||||
return func(*args, **kwargs)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
|
|
|||
|
|
@ -287,9 +287,9 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
|||
# 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:
|
||||
for function_id, function in functions_by_id.items():
|
||||
try:
|
||||
await get_function_module_from_cache(request, function_id)
|
||||
await get_function_module_from_cache(request, function_id, function=function)
|
||||
except Exception as e:
|
||||
log.debug(f'Failed to load function module for {function_id}: {e}')
|
||||
|
||||
|
|
|
|||
|
|
@ -304,57 +304,67 @@ async def get_authorization_server_discovery_urls(server_url: str) -> list[str]:
|
|||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
if response.status == 401:
|
||||
resource_metadata_urls = []
|
||||
match = re.search(
|
||||
r'resource_metadata=(?:"([^"]+)"|([^\s,]+))',
|
||||
response.headers.get('WWW-Authenticate', ''),
|
||||
)
|
||||
if match:
|
||||
resource_metadata_url = match.group(1) or match.group(2)
|
||||
log.debug(f'Found resource_metadata URL: {resource_metadata_url}')
|
||||
resource_metadata_urls = [match.group(1) or match.group(2)]
|
||||
log.debug(f'Found resource_metadata URL: {resource_metadata_urls[0]}')
|
||||
else:
|
||||
# Fall back to well-known resource metadata URIs (RFC 9728 §4.2)
|
||||
parsed, base_url = get_parsed_and_base_url(server_url)
|
||||
if parsed.path and parsed.path != '/':
|
||||
path = parsed.path.rstrip('/')
|
||||
resource_metadata_urls.append(
|
||||
urllib.parse.urljoin(base_url, f'/.well-known/oauth-protected-resource{path}')
|
||||
)
|
||||
resource_metadata_urls.append(
|
||||
urllib.parse.urljoin(base_url, '/.well-known/oauth-protected-resource')
|
||||
)
|
||||
log.debug(f'No resource_metadata in header, trying well-known URIs: {resource_metadata_urls}')
|
||||
|
||||
# Step 2: Fetch Protected Resource metadata
|
||||
async with session.get(
|
||||
resource_metadata_url, ssl=AIOHTTP_CLIENT_SESSION_SSL
|
||||
) as resource_response:
|
||||
if resource_response.status == 200:
|
||||
resource_metadata = await resource_response.json()
|
||||
# Fetch Protected Resource metadata from candidate URLs
|
||||
for resource_metadata_url in resource_metadata_urls:
|
||||
try:
|
||||
async with session.get(
|
||||
resource_metadata_url, ssl=AIOHTTP_CLIENT_SESSION_SSL
|
||||
) as resource_response:
|
||||
if resource_response.status == 200:
|
||||
resource_metadata = await resource_response.json()
|
||||
|
||||
# Step 3: Extract authorization_servers
|
||||
servers = resource_metadata.get('authorization_servers', [])
|
||||
if servers:
|
||||
authorization_servers = servers
|
||||
log.debug(f'Discovered authorization servers: {servers}')
|
||||
servers = resource_metadata.get('authorization_servers', [])
|
||||
if servers:
|
||||
authorization_servers = servers
|
||||
log.debug(f'Discovered authorization servers: {servers}')
|
||||
break
|
||||
except Exception as e:
|
||||
log.debug(f'Failed to fetch resource metadata from {resource_metadata_url}: {e}')
|
||||
continue
|
||||
except Exception as e:
|
||||
log.debug(f'MCP Protected Resource discovery failed: {e}')
|
||||
|
||||
discovery_urls = []
|
||||
for auth_server in authorization_servers:
|
||||
auth_server = auth_server.rstrip('/')
|
||||
discovery_urls.extend(
|
||||
[
|
||||
f'{auth_server}/.well-known/oauth-authorization-server',
|
||||
f'{auth_server}/.well-known/openid-configuration',
|
||||
]
|
||||
)
|
||||
discovery_urls.extend(_build_well_known_urls(auth_server))
|
||||
|
||||
return discovery_urls
|
||||
|
||||
|
||||
async def get_discovery_urls(server_url) -> list[str]:
|
||||
urls = await get_authorization_server_discovery_urls(server_url)
|
||||
def _build_well_known_urls(server_url: str) -> list[str]:
|
||||
"""Build RFC 8414 / OIDC Discovery well-known URLs for a server URL."""
|
||||
parsed, base_url = get_parsed_and_base_url(server_url)
|
||||
urls = []
|
||||
|
||||
if parsed.path and parsed.path != '/':
|
||||
# Generate discovery URLs based on https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-server-metadata-discovery
|
||||
tenant = parsed.path.rstrip('/')
|
||||
path = parsed.path.rstrip('/')
|
||||
urls.extend(
|
||||
[
|
||||
urllib.parse.urljoin(
|
||||
base_url,
|
||||
f'/.well-known/oauth-authorization-server{tenant}',
|
||||
),
|
||||
urllib.parse.urljoin(base_url, f'/.well-known/openid-configuration{tenant}'),
|
||||
urllib.parse.urljoin(base_url, f'{tenant}/.well-known/openid-configuration'),
|
||||
urllib.parse.urljoin(base_url, f'/.well-known/oauth-authorization-server{path}'),
|
||||
urllib.parse.urljoin(base_url, f'/.well-known/openid-configuration{path}'),
|
||||
urllib.parse.urljoin(base_url, f'{path}/.well-known/openid-configuration'),
|
||||
]
|
||||
)
|
||||
|
||||
|
|
@ -368,6 +378,12 @@ async def get_discovery_urls(server_url) -> list[str]:
|
|||
return urls
|
||||
|
||||
|
||||
async def get_discovery_urls(server_url) -> list[str]:
|
||||
urls = await get_authorization_server_discovery_urls(server_url)
|
||||
urls.extend(_build_well_known_urls(server_url))
|
||||
return urls
|
||||
|
||||
|
||||
# TODO: Some OAuth providers require Initial Access Tokens (IATs) for dynamic client registration.
|
||||
# This is not currently supported.
|
||||
async def get_oauth_client_info_with_dynamic_client_registration(
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from open_webui.env import (
|
|||
OFFLINE_MODE,
|
||||
ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS,
|
||||
)
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.models.functions import FunctionModel, Functions
|
||||
from open_webui.models.tools import Tools
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -335,13 +335,14 @@ async def get_tool_module_from_cache(request, tool_id, load_from_db=True):
|
|||
return tool_module, frontmatter
|
||||
|
||||
|
||||
async def get_function_module_from_cache(request, function_id, load_from_db=True):
|
||||
async def get_function_module_from_cache(request, function_id, function: FunctionModel | None = None, load_from_db=True):
|
||||
if load_from_db:
|
||||
# Always load from the database by default
|
||||
# This is useful for hooks like "inlet" or "outlet" where the content might change
|
||||
# and we want to ensure the latest content is used.
|
||||
|
||||
function = await Functions.get_function_by_id(function_id)
|
||||
if function is None:
|
||||
function = await Functions.get_function_by_id(function_id)
|
||||
if not function:
|
||||
raise Exception(f'Function not found: {function_id}')
|
||||
content = function.content
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ from open_webui.env import (
|
|||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
MAX_RETRY_COUNT = REDIS_SENTINEL_MAX_RETRY_COUNT
|
||||
|
||||
|
||||
# Let not our connections be timed out but deliver them from
|
||||
# partition. For the cache and the socket and the uptime
|
||||
|
|
@ -38,7 +40,7 @@ class SentinelRedisProxy:
|
|||
def _master(self):
|
||||
return self._sentinel.master_for(self._service, **self._kw)
|
||||
|
||||
async def __getattr__(self, item):
|
||||
def __getattr__(self, item):
|
||||
master = self._master()
|
||||
orig_attr = getattr(master, item)
|
||||
|
||||
|
|
|
|||
|
|
@ -135,6 +135,9 @@ def convert_response_ollama_to_openai(ollama_response: dict) -> dict:
|
|||
|
||||
async def convert_streaming_response_ollama_to_openai(ollama_streaming_response):
|
||||
has_tool_calls = False
|
||||
# All chunks in a single completion must share the same id (OpenAI spec).
|
||||
completion_id = f'chatcmpl-{str(uuid4())}'
|
||||
first = True
|
||||
async for data in ollama_streaming_response.body_iterator:
|
||||
data = json.loads(data)
|
||||
|
||||
|
|
@ -155,6 +158,12 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
|
|||
usage = convert_ollama_usage_to_openai(data)
|
||||
|
||||
data = openai_chat_chunk_message_template(model, message_content, reasoning_content, openai_tool_calls, usage)
|
||||
data['id'] = completion_id
|
||||
|
||||
# First chunk must carry delta.role (OpenAI spec).
|
||||
if first:
|
||||
data['choices'][0]['delta']['role'] = 'assistant'
|
||||
first = False
|
||||
|
||||
if done and has_tool_calls:
|
||||
data['choices'][0]['finish_reason'] = 'tool_calls'
|
||||
|
|
|
|||
|
|
@ -17,8 +17,10 @@ high-cardinality label sets.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import time
|
||||
from typing import Dict, List, Sequence, Any
|
||||
from typing import Dict, Iterable, List, Optional
|
||||
from base64 import b64encode
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
|
|
@ -36,6 +38,8 @@ from opentelemetry.sdk.metrics.export import (
|
|||
PeriodicExportingMetricReader,
|
||||
)
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from sqlalchemy import Engine, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from open_webui.env import (
|
||||
OTEL_SERVICE_NAME,
|
||||
|
|
@ -46,7 +50,47 @@ from open_webui.env import (
|
|||
OTEL_METRICS_EXPORTER_OTLP_INSECURE,
|
||||
OTEL_METRICS_EXPORT_INTERVAL_MILLIS,
|
||||
)
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.models.users import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync DB helpers for OTel gauge callbacks
|
||||
#
|
||||
# The OTel Python SDK calls observable-instrument callbacks *synchronously*
|
||||
# from a background collection thread — async callbacks are NOT supported
|
||||
# (the SDK does not ``await`` the return value).
|
||||
#
|
||||
# Rather than bridging into the async event loop, we run plain synchronous
|
||||
# SQL queries using the sync engine that is already available at setup time.
|
||||
# This avoids any cross-thread / cross-loop concerns entirely.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _count_total_users(db_engine: Engine) -> Optional[int]:
|
||||
"""Return the total number of registered users (sync)."""
|
||||
with Session(db_engine) as session:
|
||||
return session.execute(select(func.count()).select_from(User)).scalar()
|
||||
|
||||
|
||||
def _count_active_users(db_engine: Engine) -> Optional[int]:
|
||||
"""Return the number of users active within the last 3 minutes (sync)."""
|
||||
three_minutes_ago = int(time.time()) - 180
|
||||
with Session(db_engine) as session:
|
||||
return session.execute(
|
||||
select(func.count()).select_from(User).filter(User.last_active_at >= three_minutes_ago)
|
||||
).scalar()
|
||||
|
||||
|
||||
def _count_users_active_today(db_engine: Engine) -> Optional[int]:
|
||||
"""Return the number of users active since midnight today (sync)."""
|
||||
now = int(datetime.datetime.now().timestamp())
|
||||
today_midnight = now - (now % 86400)
|
||||
with Session(db_engine) as session:
|
||||
return session.execute(
|
||||
select(func.count()).select_from(User).filter(User.last_active_at > today_midnight)
|
||||
).scalar()
|
||||
|
||||
|
||||
def _build_meter_provider(resource: Resource) -> MeterProvider:
|
||||
|
|
@ -106,7 +150,7 @@ def _build_meter_provider(resource: Resource) -> MeterProvider:
|
|||
return provider
|
||||
|
||||
|
||||
def setup_metrics(app: FastAPI, resource: Resource) -> None:
|
||||
def setup_metrics(app: FastAPI, resource: Resource, db_engine: Engine) -> None:
|
||||
"""Attach OTel metrics middleware to *app* and initialise provider."""
|
||||
|
||||
metrics.set_meter_provider(_build_meter_provider(resource))
|
||||
|
|
@ -124,32 +168,46 @@ def setup_metrics(app: FastAPI, resource: Resource) -> None:
|
|||
unit='ms',
|
||||
)
|
||||
|
||||
async def observe_active_users(
|
||||
options: metrics.CallbackOptions,
|
||||
) -> Sequence[metrics.Observation]:
|
||||
return [
|
||||
metrics.Observation(
|
||||
value=await Users.get_active_user_count(),
|
||||
)
|
||||
]
|
||||
# -- Observable gauge callbacks ----------------------------------------
|
||||
# These are called synchronously by the OTel SDK from a background
|
||||
# collection thread. They use the sync DB engine directly — no async
|
||||
# bridging required.
|
||||
|
||||
async def observe_total_registered_users(
|
||||
def observe_total_users(
|
||||
options: metrics.CallbackOptions,
|
||||
) -> Sequence[metrics.Observation]:
|
||||
# IMPORTANT: Use get_num_users() for efficient COUNT(*) query.
|
||||
# Do NOT use len(get_users()["users"]) - it loads ALL user records into memory,
|
||||
# causing connection pool exhaustion on high-latency databases (e.g., Aurora).
|
||||
return [
|
||||
metrics.Observation(
|
||||
value=await Users.get_num_users() or 0,
|
||||
)
|
||||
]
|
||||
) -> Iterable[metrics.Observation]:
|
||||
try:
|
||||
value = _count_total_users(db_engine)
|
||||
if value is not None:
|
||||
yield metrics.Observation(value=value)
|
||||
except Exception:
|
||||
logger.debug('Failed to observe total users', exc_info=True)
|
||||
|
||||
def observe_active_users(
|
||||
options: metrics.CallbackOptions,
|
||||
) -> Iterable[metrics.Observation]:
|
||||
try:
|
||||
value = _count_active_users(db_engine)
|
||||
if value is not None:
|
||||
yield metrics.Observation(value=value)
|
||||
except Exception:
|
||||
logger.debug('Failed to observe active users', exc_info=True)
|
||||
|
||||
def observe_users_active_today(
|
||||
options: metrics.CallbackOptions,
|
||||
) -> Iterable[metrics.Observation]:
|
||||
try:
|
||||
value = _count_users_active_today(db_engine)
|
||||
if value is not None:
|
||||
yield metrics.Observation(value=value)
|
||||
except Exception:
|
||||
logger.debug('Failed to observe users active today', exc_info=True)
|
||||
|
||||
meter.create_observable_gauge(
|
||||
name='webui.users.total',
|
||||
description='Total number of registered users',
|
||||
unit='users',
|
||||
callbacks=[observe_total_registered_users],
|
||||
callbacks=[observe_total_users],
|
||||
)
|
||||
|
||||
meter.create_observable_gauge(
|
||||
|
|
@ -159,11 +217,6 @@ def setup_metrics(app: FastAPI, resource: Resource) -> None:
|
|||
callbacks=[observe_active_users],
|
||||
)
|
||||
|
||||
async def observe_users_active_today(
|
||||
options: metrics.CallbackOptions,
|
||||
) -> Sequence[metrics.Observation]:
|
||||
return [metrics.Observation(value=await Users.get_num_users_active_today())]
|
||||
|
||||
meter.create_observable_gauge(
|
||||
name='webui.users.active.today',
|
||||
description='Number of users active since midnight today',
|
||||
|
|
|
|||
|
|
@ -55,4 +55,4 @@ def setup(app: FastAPI, db_engine: Engine):
|
|||
|
||||
# set up metrics only if enabled
|
||||
if ENABLE_OTEL_METRICS:
|
||||
setup_metrics(app, resource)
|
||||
setup_metrics(app, resource, db_engine)
|
||||
|
|
|
|||
|
|
@ -22,12 +22,13 @@ aiocache
|
|||
aiofiles
|
||||
starlette-compress==1.7.0
|
||||
Brotli==1.2.0
|
||||
brotlicffi==1.2.0.1
|
||||
httpx[socks,http2,zstd,cli,brotli]==0.28.1
|
||||
starsessions[redis]==2.2.1
|
||||
|
||||
sqlalchemy==2.0.48
|
||||
aiosqlite==0.21.0
|
||||
asyncpg==0.30.0
|
||||
psycopg[binary]==3.2.9
|
||||
alembic==1.18.4
|
||||
peewee==3.19.0
|
||||
peewee-migrate==1.14.3
|
||||
|
|
|
|||
|
|
@ -19,13 +19,14 @@ aiocache==0.12.3
|
|||
aiofiles==25.1.0
|
||||
starlette-compress==1.7.0
|
||||
Brotli==1.2.0
|
||||
brotlicffi==1.2.0.1
|
||||
httpx[socks,http2,zstd,cli,brotli]==0.28.1
|
||||
starsessions[redis]==2.2.1
|
||||
python-mimeparse==2.0.0
|
||||
|
||||
sqlalchemy==2.0.48
|
||||
sqlalchemy[asyncio]==2.0.48
|
||||
aiosqlite==0.21.0
|
||||
asyncpg==0.30.0
|
||||
psycopg[binary]==3.2.9
|
||||
alembic==1.18.4
|
||||
peewee==3.19.0
|
||||
peewee-migrate==1.14.3
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ IF NOT "%WEBUI_SECRET_KEY_FILE%" == "" (
|
|||
|
||||
IF "%PORT%"=="" SET PORT=8080
|
||||
IF "%HOST%"=="" SET HOST=0.0.0.0
|
||||
IF "%FORWARDED_ALLOW_IPS%"=="" SET "FORWARDED_ALLOW_IPS=*"
|
||||
IF "%FORWARDED_ALLOW_IPS%"=="" SET "FORWARDED_ALLOW_IPS='*'"
|
||||
SET "WEBUI_SECRET_KEY=%WEBUI_SECRET_KEY%"
|
||||
SET "WEBUI_JWT_SECRET_KEY=%WEBUI_JWT_SECRET_KEY%"
|
||||
|
||||
|
|
@ -47,5 +47,5 @@ IF "%WEBUI_SECRET_KEY% %WEBUI_JWT_SECRET_KEY%" == " " (
|
|||
:: Execute uvicorn
|
||||
SET "WEBUI_SECRET_KEY=%WEBUI_SECRET_KEY%"
|
||||
IF "%UVICORN_WORKERS%"=="" SET UVICORN_WORKERS=1
|
||||
uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips "%FORWARDED_ALLOW_IPS%" --workers %UVICORN_WORKERS% --ws auto
|
||||
uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips %FORWARDED_ALLOW_IPS% --workers %UVICORN_WORKERS% --ws auto
|
||||
:: For ssl user uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips '*' --ssl-keyfile "key.pem" --ssl-certfile "cert.pem" --ws auto
|
||||
|
|
|
|||
|
|
@ -26,14 +26,15 @@ dependencies = [
|
|||
"aiocache==0.12.3",
|
||||
"aiofiles==25.1.0",
|
||||
"starlette-compress==1.7.0",
|
||||
"Brotli==1.1.0",
|
||||
"Brotli==1.2.0",
|
||||
"brotlicffi==1.2.0.1",
|
||||
"httpx[socks,http2,zstd,cli,brotli]==0.28.1",
|
||||
"starsessions[redis]==2.2.1",
|
||||
"python-mimeparse==2.0.0",
|
||||
|
||||
"sqlalchemy==2.0.48",
|
||||
"sqlalchemy[asyncio]==2.0.48",
|
||||
"aiosqlite==0.21.0",
|
||||
"asyncpg==0.30.0",
|
||||
"psycopg[binary]==3.2.9",
|
||||
"alembic==1.18.4",
|
||||
"peewee==3.19.0",
|
||||
"peewee-migrate==1.14.3",
|
||||
|
|
|
|||
14
src/app.html
14
src/app.html
|
|
@ -71,6 +71,16 @@
|
|||
metaThemeColorTag.setAttribute('content', '#171717');
|
||||
}
|
||||
|
||||
const preloadHref = document.documentElement.classList.contains('dark')
|
||||
? '/static/splash-dark.png'
|
||||
: '/static/splash.png';
|
||||
const preload = document.createElement('link');
|
||||
preload.rel = 'preload';
|
||||
preload.as = 'image';
|
||||
preload.href = preloadHref;
|
||||
preload.setAttribute('fetchpriority', 'high');
|
||||
document.head.appendChild(preload);
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addListener((e) => {
|
||||
if (localStorage.theme === 'system') {
|
||||
if (e.matches) {
|
||||
|
|
@ -90,6 +100,8 @@
|
|||
logo.id = 'logo';
|
||||
logo.style =
|
||||
'position: absolute; width: auto; height: 6rem; top: 44%; left: 50%; transform: translateX(-50%); display:block;';
|
||||
logo.loading = 'eager';
|
||||
logo.fetchPriority = 'high';
|
||||
logo.src = isDarkMode ? '/static/splash-dark.png' : '/static/splash.png';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
|
|
@ -139,6 +151,8 @@
|
|||
id="logo-her"
|
||||
style="width: auto; height: 13rem"
|
||||
src="/static/splash.png"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
class="animate-pulse-fast"
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -712,3 +712,33 @@ export const deleteAPIKey = async (token: string) => {
|
|||
}
|
||||
return res;
|
||||
};
|
||||
|
||||
export const deleteOAuthSession = async (token: string, provider: string) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(
|
||||
`${WEBUI_API_BASE_URL}/auths/oauth/sessions/${encodeURIComponent(provider)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -647,3 +647,4 @@ export const setBanners = async (token: string, banners: Banner[]) => {
|
|||
|
||||
return res;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -152,6 +152,9 @@ export const getBaseModels = async (token: string = '') => {
|
|||
export const createNewModel = async (token: string, model: object) => {
|
||||
let error = null;
|
||||
|
||||
const { id, base_model_id, name, meta, params, access_grants, is_active } = model as any;
|
||||
const payload = { id, base_model_id, name, meta, params, access_grants, is_active };
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/models/create`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
|
@ -159,7 +162,7 @@ export const createNewModel = async (token: string, model: object) => {
|
|||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(model)
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
|
|
@ -251,6 +254,9 @@ export const toggleModelById = async (token: string, id: string) => {
|
|||
export const updateModelById = async (token: string, id: string, model: object) => {
|
||||
let error = null;
|
||||
|
||||
const { base_model_id, name, meta, params, access_grants, is_active } = model as any;
|
||||
const payload = { id, base_model_id, name, meta, params, access_grants, is_active };
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/models/model/update`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
|
@ -258,7 +264,7 @@ export const updateModelById = async (token: string, id: string, model: object)
|
|||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ ...model, id })
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
|
|
|
|||
|
|
@ -483,3 +483,4 @@ export const updateUserValvesById = async (token: string, id: string, valves: ob
|
|||
|
||||
return res;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -550,3 +550,4 @@ export const getUserGroupsById = async (token: string, userId: string) => {
|
|||
|
||||
return res;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -525,7 +525,7 @@
|
|||
TTS_MODEL = 'tts-1';
|
||||
} else if (e.target?.value === 'mistral') {
|
||||
TTS_VOICE = '';
|
||||
TTS_MODEL = 'mistral-tts-latest';
|
||||
TTS_MODEL = 'voxtral-mini-tts-2603';
|
||||
} else {
|
||||
TTS_VOICE = '';
|
||||
TTS_MODEL = '';
|
||||
|
|
|
|||
|
|
@ -184,6 +184,13 @@
|
|||
toast.error($i18n.t('Mistral OCR API Key required.'));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
RAGConfig.CONTENT_EXTRACTION_ENGINE === 'paddleocr_vl' &&
|
||||
RAGConfig.PADDLEOCR_VL_BASE_URL === ''
|
||||
) {
|
||||
toast.error($i18n.t('PaddleOCR-vl API URL required.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
RAGConfig.CONTENT_EXTRACTION_ENGINE === 'mineru' &&
|
||||
|
|
@ -356,6 +363,7 @@
|
|||
<option value="datalab_marker">{$i18n.t('Datalab Marker API')}</option>
|
||||
<option value="document_intelligence">{$i18n.t('Document Intelligence')}</option>
|
||||
<option value="mistral_ocr">{$i18n.t('Mistral OCR')}</option>
|
||||
<option value="paddleocr_vl">{$i18n.t('PaddleOCR-vl')}</option>
|
||||
<option value="mineru">{$i18n.t('MinerU')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
|
@ -657,6 +665,19 @@
|
|||
bind:value={RAGConfig.MISTRAL_OCR_API_KEY}
|
||||
/>
|
||||
</div>
|
||||
{:else if RAGConfig.CONTENT_EXTRACTION_ENGINE === 'paddleocr_vl'}
|
||||
<div class="my-0.5 flex gap-2 pr-2">
|
||||
<input
|
||||
class="flex-1 w-full text-sm bg-transparent outline-hidden"
|
||||
placeholder={$i18n.t('Enter PaddleOCR-vl API Base URL')}
|
||||
bind:value={RAGConfig.PADDLEOCR_VL_BASE_URL}
|
||||
/>
|
||||
<SensitiveInput
|
||||
placeholder={$i18n.t('Enter PaddleOCR-vl API Token')}
|
||||
bind:value={RAGConfig.PADDLEOCR_VL_TOKEN}
|
||||
required={false}
|
||||
/>
|
||||
</div>
|
||||
{:else if RAGConfig.CONTENT_EXTRACTION_ENGINE === 'mineru'}
|
||||
<!-- API Mode Selection -->
|
||||
<div class="flex w-full mt-2">
|
||||
|
|
@ -1348,7 +1369,14 @@
|
|||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
</div>
|
||||
|
||||
{#if RAGConfig.RAG_TEMPLATE && ((RAGConfig.RAG_TEMPLATE.match(/\[context\]/g) || []).length + (RAGConfig.RAG_TEMPLATE.match(/\{\{CONTEXT\}\}/g) || []).length) > 1}
|
||||
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
{$i18n.t('This template contains multiple context placeholders ([context] or {{CONTEXT}}). Context will be injected at each occurrence.')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,14 @@
|
|||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
import DOMPurify from 'dompurify';
|
||||
import { marked } from 'marked';
|
||||
|
||||
import { getContext, tick, onDestroy } from 'svelte';
|
||||
import { getContext, tick } from 'svelte';
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import { chatCompletion } from '$lib/apis/openai';
|
||||
|
||||
import ChatBubble from '$lib/components/icons/ChatBubble.svelte';
|
||||
import LightBulb from '$lib/components/icons/LightBulb.svelte';
|
||||
import Markdown from '../Messages/Markdown.svelte';
|
||||
import Skeleton from '../Messages/Skeleton.svelte';
|
||||
import { chatId, models, socket } from '$lib/stores';
|
||||
|
||||
export let id = '';
|
||||
export let messageId = '';
|
||||
|
||||
export let model = null;
|
||||
export let messages = [];
|
||||
export let actions = [];
|
||||
export let onAdd = (e) => {};
|
||||
export let onSetInputText = (text) => {};
|
||||
|
||||
let floatingInput = false;
|
||||
let selectedAction = null;
|
||||
|
|
@ -29,11 +16,6 @@
|
|||
let selectedText = '';
|
||||
let floatingInputValue = '';
|
||||
|
||||
let content = '';
|
||||
let responseContent = null;
|
||||
let responseDone = false;
|
||||
let controller = null;
|
||||
|
||||
$: if (actions.length === 0) {
|
||||
actions = DEFAULT_ACTIONS;
|
||||
}
|
||||
|
|
@ -54,25 +36,7 @@
|
|||
}
|
||||
];
|
||||
|
||||
const autoScroll = async () => {
|
||||
const responseContainer = document.getElementById('response-container');
|
||||
if (responseContainer) {
|
||||
// Scroll to bottom only if the scroll is at the bottom give 50px buffer
|
||||
if (
|
||||
responseContainer.scrollHeight - responseContainer.clientHeight <=
|
||||
responseContainer.scrollTop + 50
|
||||
) {
|
||||
responseContainer.scrollTop = responseContainer.scrollHeight;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const actionHandler = async (actionId) => {
|
||||
if (!model) {
|
||||
toast.error($i18n.t('Model not selected'));
|
||||
return;
|
||||
}
|
||||
|
||||
const actionHandler = (actionId) => {
|
||||
let selectedContent = selectedText
|
||||
.split('\n')
|
||||
.map((line) => `> ${line}`)
|
||||
|
|
@ -80,27 +44,20 @@
|
|||
|
||||
let selectedAction = actions.find((action) => action.id === actionId);
|
||||
if (!selectedAction) {
|
||||
toast.error($i18n.t('Action not found'));
|
||||
return;
|
||||
}
|
||||
|
||||
let prompt = selectedAction?.prompt ?? '';
|
||||
let toolIds = [];
|
||||
|
||||
// Handle: {{variableId|tool:id="toolId"}} pattern
|
||||
// This regex captures variableId and toolId from {{variableId|tool:id="toolId"}}
|
||||
const varToolPattern = /\{\{(.*?)\|tool:id="([^"]+)"\}\}/g;
|
||||
prompt = prompt.replace(varToolPattern, (match, variableId, toolId) => {
|
||||
toolIds.push(toolId);
|
||||
return variableId; // Replace with just variableId
|
||||
});
|
||||
|
||||
// legacy {{TOOL:toolId}} pattern (for backward compatibility)
|
||||
let toolIdPattern = /\{\{TOOL:([^\}]+)\}\}/g;
|
||||
let match;
|
||||
while ((match = toolIdPattern.exec(prompt)) !== null) {
|
||||
toolIds.push(match[1]);
|
||||
}
|
||||
|
||||
// Remove all TOOL placeholders from the prompt
|
||||
prompt = prompt.replace(toolIdPattern, '');
|
||||
|
|
@ -113,133 +70,17 @@
|
|||
prompt = prompt.replace('{{CONTENT}}', selectedText);
|
||||
prompt = prompt.replace('{{SELECTED_CONTENT}}', selectedContent);
|
||||
|
||||
content = prompt;
|
||||
responseContent = '';
|
||||
|
||||
let res;
|
||||
[res, controller] = await chatCompletion(localStorage.token, {
|
||||
model: model,
|
||||
model_item: $models.find((m) => m.id === model),
|
||||
session_id: $socket?.id,
|
||||
chat_id: $chatId,
|
||||
messages: [
|
||||
...messages,
|
||||
{
|
||||
role: 'user',
|
||||
content: content
|
||||
}
|
||||
].map((message) => ({
|
||||
role: message.role,
|
||||
content: message.content
|
||||
})),
|
||||
...(toolIds.length > 0
|
||||
? {
|
||||
tool_ids: toolIds
|
||||
// params: {
|
||||
// function_calling: 'native'
|
||||
// }
|
||||
}
|
||||
: {}),
|
||||
|
||||
stream: true // Enable streaming
|
||||
});
|
||||
|
||||
if (res && res.ok) {
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
const processStream = async () => {
|
||||
while (true) {
|
||||
// Read data chunks from the response stream
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Decode the received chunk
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
|
||||
// Process lines within the chunk
|
||||
const lines = chunk.split('\n').filter((line) => line.trim() !== '');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
if (line.startsWith('data: [DONE]')) {
|
||||
responseDone = true;
|
||||
|
||||
await tick();
|
||||
autoScroll();
|
||||
continue;
|
||||
} else {
|
||||
// Parse the JSON chunk
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
|
||||
// Append the `content` field from the "choices" object
|
||||
if (data.choices && data.choices[0]?.delta?.content) {
|
||||
responseContent += data.choices[0].delta.content;
|
||||
|
||||
autoScroll();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Process the stream in the background
|
||||
try {
|
||||
await processStream();
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
toast.error($i18n.t('An error occurred while fetching the explanation'));
|
||||
}
|
||||
};
|
||||
|
||||
const addHandler = async () => {
|
||||
const messages = [
|
||||
{
|
||||
role: 'user',
|
||||
content: content
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: responseContent
|
||||
}
|
||||
];
|
||||
|
||||
onAdd({
|
||||
modelId: model,
|
||||
parentId: messageId,
|
||||
messages: messages
|
||||
});
|
||||
// Prepopulate the main chat input instead of inline streaming
|
||||
onSetInputText(prompt);
|
||||
closeHandler();
|
||||
};
|
||||
|
||||
export const closeHandler = () => {
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
}
|
||||
|
||||
selectedAction = null;
|
||||
selectedText = '';
|
||||
responseContent = null;
|
||||
responseDone = false;
|
||||
floatingInput = false;
|
||||
floatingInputValue = '';
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
|
|
@ -247,120 +88,82 @@
|
|||
class="absolute rounded-lg mt-1 text-xs z-9999"
|
||||
style="display: none"
|
||||
>
|
||||
{#if responseContent === null}
|
||||
{#if !floatingInput}
|
||||
<div
|
||||
class="flex flex-row shrink-0 p-0.5 bg-white dark:bg-gray-850 dark:text-gray-100 text-medium rounded-xl shadow-xl border border-gray-100 dark:border-gray-800"
|
||||
>
|
||||
{#each actions as action}
|
||||
<button
|
||||
aria-label={action.label}
|
||||
class="px-1.5 py-[1px] hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl flex items-center gap-1 min-w-fit transition"
|
||||
on:click={async () => {
|
||||
selectedText = window.getSelection().toString();
|
||||
selectedAction = action;
|
||||
{#if !floatingInput}
|
||||
<div
|
||||
class="flex flex-row shrink-0 p-0.5 bg-white dark:bg-gray-850 dark:text-gray-100 text-medium rounded-xl shadow-xl border border-gray-100 dark:border-gray-800"
|
||||
>
|
||||
{#each actions as action}
|
||||
<button
|
||||
aria-label={action.label}
|
||||
class="px-1.5 py-[1px] hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl flex items-center gap-1 min-w-fit transition"
|
||||
on:click={async () => {
|
||||
selectedText = window.getSelection().toString();
|
||||
selectedAction = action;
|
||||
|
||||
if (action.prompt.includes('{{INPUT_CONTENT}}')) {
|
||||
floatingInput = true;
|
||||
floatingInputValue = '';
|
||||
if (action.prompt.includes('{{INPUT_CONTENT}}')) {
|
||||
floatingInput = true;
|
||||
floatingInputValue = '';
|
||||
|
||||
await tick();
|
||||
setTimeout(() => {
|
||||
const input = document.getElementById('floating-message-input');
|
||||
if (input) {
|
||||
input.focus();
|
||||
}
|
||||
}, 0);
|
||||
} else {
|
||||
actionHandler(action.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if action.icon}
|
||||
<svelte:component this={action.icon} className="size-3 shrink-0" />
|
||||
{/if}
|
||||
<div class="shrink-0">{action.label}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="py-1 flex dark:text-gray-100 bg-white dark:bg-gray-850 border border-gray-100 dark:border-gray-800 w-72 rounded-full shadow-xl"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="floating-message-input"
|
||||
class="ml-5 bg-transparent outline-hidden w-full flex-1 text-sm"
|
||||
placeholder={$i18n.t('Ask a question')}
|
||||
aria-label={$i18n.t('Ask a question')}
|
||||
bind:value={floatingInputValue}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
actionHandler(selectedAction?.id);
|
||||
await tick();
|
||||
setTimeout(() => {
|
||||
const input = document.getElementById('floating-message-input');
|
||||
if (input) {
|
||||
input.focus();
|
||||
}
|
||||
}, 0);
|
||||
} else {
|
||||
actionHandler(action.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="ml-1 mr-1">
|
||||
<button
|
||||
aria-label={$i18n.t('Submit question')}
|
||||
class="{floatingInputValue !== ''
|
||||
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
|
||||
: 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 m-0.5 self-center"
|
||||
on:click={() => {
|
||||
actionHandler(selectedAction?.id);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="size-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
>
|
||||
{#if action.icon}
|
||||
<svelte:component this={action.icon} className="size-3 shrink-0" />
|
||||
{/if}
|
||||
<div class="shrink-0">{action.label}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="bg-white dark:bg-gray-850 dark:text-gray-100 rounded-3xl shadow-xl w-80 max-w-full border border-gray-100 dark:border-gray-800"
|
||||
class="py-1 flex dark:text-gray-100 bg-white dark:bg-gray-850 border border-gray-100 dark:border-gray-800 w-72 rounded-full shadow-xl"
|
||||
>
|
||||
<div
|
||||
class="bg-white dark:bg-gray-850 dark:text-gray-100 text-medium rounded-3xl px-3.5 pt-3 w-full"
|
||||
>
|
||||
<div class="font-medium">
|
||||
<Markdown id={`${id}-float-prompt`} {content} />
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="floating-message-input"
|
||||
class="ml-5 bg-transparent outline-hidden w-full flex-1 text-sm"
|
||||
placeholder={$i18n.t('Ask a question')}
|
||||
aria-label={$i18n.t('Ask a question')}
|
||||
bind:value={floatingInputValue}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
actionHandler(selectedAction?.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="bg-white dark:bg-gray-850 dark:text-gray-100 text-medium rounded-4xl w-full">
|
||||
<div
|
||||
class=" max-h-80 overflow-y-auto w-full markdown-prose-xs px-3.5 py-3"
|
||||
id="response-container"
|
||||
<div class="ml-1 mr-1">
|
||||
<button
|
||||
aria-label={$i18n.t('Submit question')}
|
||||
class="{floatingInputValue !== ''
|
||||
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
|
||||
: 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 m-0.5 self-center"
|
||||
on:click={() => {
|
||||
actionHandler(selectedAction?.id);
|
||||
}}
|
||||
>
|
||||
{#if !responseContent || responseContent?.trim() === ''}
|
||||
<Skeleton size="sm" />
|
||||
{:else}
|
||||
<Markdown id={`${id}-float-response`} content={responseContent} />
|
||||
{/if}
|
||||
|
||||
{#if responseDone}
|
||||
<div class="flex justify-end pt-3 text-sm font-medium">
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
|
||||
on:click={addHandler}
|
||||
>
|
||||
{$i18n.t('Add')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="size-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -13,8 +13,11 @@
|
|||
} from '$lib/stores';
|
||||
|
||||
import { getOAuthClientAuthorizationUrl } from '$lib/apis/configs';
|
||||
import { deleteOAuthSession } from '$lib/apis/auths';
|
||||
import { getTools } from '$lib/apis/tools';
|
||||
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
import Knobs from '$lib/components/icons/Knobs.svelte';
|
||||
import Dropdown from '$lib/components/common/Dropdown.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
|
|
@ -27,6 +30,7 @@
|
|||
import Terminal from '$lib/components/icons/Terminal.svelte';
|
||||
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
|
||||
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
|
||||
import LinkSlash from '$lib/components/icons/LinkSlash.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
|
|
@ -375,6 +379,39 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{#if (tools[toolId]?.authenticated ?? true) && toolId.startsWith('server:mcp:')}
|
||||
<div class="shrink-0">
|
||||
<Tooltip content={$i18n.t('Disconnect OAuth')}>
|
||||
<button
|
||||
class="self-center w-fit text-sm text-gray-600 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition rounded-full"
|
||||
type="button"
|
||||
on:click={async (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const parts = toolId.split(':');
|
||||
const serverId = parts.at(-1) ?? toolId;
|
||||
const provider = `mcp:${serverId}`;
|
||||
|
||||
try {
|
||||
await deleteOAuthSession(localStorage.token, provider);
|
||||
toast.success($i18n.t('OAuth session disconnected'));
|
||||
|
||||
// Refresh tools to update authenticated state
|
||||
_tools.set(await getTools(localStorage.token));
|
||||
selectedToolIds = selectedToolIds.filter((id) => id !== toolId);
|
||||
await init();
|
||||
} catch (err) {
|
||||
toast.error(err ?? $i18n.t('Failed to disconnect'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LinkSlash className="size-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if tools[toolId]?.has_user_valves && ($user?.role === 'admin' || ($user?.permissions?.chat?.valves ?? true))}
|
||||
<div class=" shrink-0">
|
||||
<Tooltip content={$i18n.t('Valves')}>
|
||||
|
|
|
|||
|
|
@ -60,102 +60,7 @@
|
|||
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);
|
||||
});
|
||||
|
||||
|
|
@ -169,12 +74,6 @@
|
|||
|
||||
buildMessages();
|
||||
|
||||
// Show all messages during progressive loading (no culling)
|
||||
visibleStart = 0;
|
||||
visibleEnd = messages.length;
|
||||
topSpacerHeight = 0;
|
||||
bottomSpacerHeight = 0;
|
||||
|
||||
await tick();
|
||||
|
||||
messagesLoading = false;
|
||||
|
|
@ -201,7 +100,6 @@
|
|||
}
|
||||
|
||||
messages = _messages.reverse();
|
||||
visibleEnd = messages.length;
|
||||
};
|
||||
|
||||
// Throttle message list rebuilds to once per animation frame during streaming.
|
||||
|
|
@ -220,8 +118,6 @@
|
|||
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) {
|
||||
|
|
@ -570,13 +466,7 @@
|
|||
</Loader>
|
||||
{/if}
|
||||
<ul role="log" aria-live="polite" aria-relevant="additions" aria-atomic="false">
|
||||
<!-- 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}
|
||||
{#each messages as message, messageIdx (message.id)}
|
||||
<Message
|
||||
{chatId}
|
||||
bind:history
|
||||
|
|
@ -605,11 +495,6 @@
|
|||
{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" />
|
||||
|
|
|
|||
|
|
@ -183,6 +183,14 @@
|
|||
}}
|
||||
/>
|
||||
{/each}
|
||||
{#if citations.length > 3}
|
||||
<div
|
||||
class="size-4 rounded-full shrink-0 border border-white dark:border-gray-850 bg-gray-100 dark:bg-gray-800 flex items-center justify-center text-[8px] font-semibold text-gray-500 dark:text-gray-400"
|
||||
aria-hidden="true"
|
||||
>
|
||||
+{citations.length - Math.min(urlCitations.length, 3)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
export let onSave = (e) => {};
|
||||
export let onSourceClick = (e) => {};
|
||||
export let onTaskClick = (e) => {};
|
||||
export let onAddMessages = (e) => {};
|
||||
export let onSetInputText = (text) => {};
|
||||
|
||||
let contentContainerElement;
|
||||
let floatingButtonsElement;
|
||||
|
|
@ -140,20 +140,36 @@
|
|||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
if (floatingButtons) {
|
||||
contentContainerElement?.addEventListener('mouseup', updateButtonPosition);
|
||||
// Reactive listener attachment: re-attaches when floatingButtons
|
||||
// transitions from false → true (e.g. when message.done flips).
|
||||
let listenersAttached = false;
|
||||
|
||||
function attachListeners() {
|
||||
if (!listenersAttached && contentContainerElement) {
|
||||
contentContainerElement.addEventListener('mouseup', updateButtonPosition);
|
||||
document.addEventListener('mouseup', updateButtonPosition);
|
||||
document.addEventListener('keydown', keydownHandler);
|
||||
listenersAttached = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (floatingButtons) {
|
||||
function detachListeners() {
|
||||
if (listenersAttached) {
|
||||
contentContainerElement?.removeEventListener('mouseup', updateButtonPosition);
|
||||
document.removeEventListener('mouseup', updateButtonPosition);
|
||||
document.removeEventListener('keydown', keydownHandler);
|
||||
listenersAttached = false;
|
||||
}
|
||||
}
|
||||
|
||||
$: if (floatingButtons && contentContainerElement) {
|
||||
attachListeners();
|
||||
} else {
|
||||
detachListeners();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
detachListeners();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -201,17 +217,9 @@
|
|||
<FloatingButtons
|
||||
bind:this={floatingButtonsElement}
|
||||
{id}
|
||||
{messageId}
|
||||
actions={$settings?.floatingActionButtons ?? []}
|
||||
model={(selectedModels ?? []).includes(model?.id)
|
||||
? model?.id
|
||||
: (selectedModels ?? []).length > 0
|
||||
? selectedModels.at(0)
|
||||
: (model?.id ?? null)}
|
||||
messages={createMessagesList(history, messageId)}
|
||||
onAdd={({ modelId, parentId, messages }) => {
|
||||
console.log(modelId, parentId, messages);
|
||||
onAddMessages({ modelId, parentId, messages });
|
||||
onSetInputText={(text) => {
|
||||
onSetInputText(text);
|
||||
closeFloatingButtons();
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@
|
|||
$: updateHandler(content);
|
||||
|
||||
// Throttle parsing to once per animation frame while streaming
|
||||
$: onDestroy(() => {
|
||||
onDestroy(() => {
|
||||
cancelAnimationFrame(pendingUpdate);
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@
|
|||
role="listitem"
|
||||
class="flex flex-col justify-between px-5 mb-3 w-full {($settings?.widescreenMode ?? null)
|
||||
? 'max-w-full'
|
||||
: 'max-w-5xl'} mx-auto rounded-lg group"
|
||||
: 'max-w-5xl'} mx-auto rounded-lg group message-listitem"
|
||||
>
|
||||
{#if history.messages[messageId]}
|
||||
{#if history.messages[messageId].role === 'user'}
|
||||
|
|
@ -128,3 +128,14 @@
|
|||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Browser-native virtualization: skip rendering of off-screen messages
|
||||
without destroying their component trees. Replaces the JS-based
|
||||
culling that caused catastrophic mount/destroy thrashing. */
|
||||
.message-listitem {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 150px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
|
|||
|
|
@ -788,9 +788,6 @@
|
|||
<!-- unless message.error === true which is legacy error handling, where the error message is stored in message.content -->
|
||||
<ContentRenderer
|
||||
id={`${chatId}-${message.id}`}
|
||||
messageId={message.id}
|
||||
{history}
|
||||
{selectedModels}
|
||||
content={message.content}
|
||||
sources={message.sources}
|
||||
floatingButtons={message?.done &&
|
||||
|
|
@ -814,8 +811,8 @@
|
|||
citationsElement?.showSourceModal(id);
|
||||
}
|
||||
}}
|
||||
onAddMessages={({ modelId, parentId, messages }) => {
|
||||
addMessages({ modelId, parentId, messages });
|
||||
onSetInputText={(text) => {
|
||||
setInputText(text);
|
||||
}}
|
||||
onSave={({ raw, oldContent, newContent }) => {
|
||||
history.messages[message.id].content = history.messages[
|
||||
|
|
|
|||
|
|
@ -737,6 +737,17 @@
|
|||
StarterKit.configure({
|
||||
link: link,
|
||||
code: false, // Disabled in favor of FixedCode (see workaround above)
|
||||
// When rich text is on, ListKit + CodeBlockLowlight provide these.
|
||||
// Disable StarterKit's equivalents to avoid duplicate extension names.
|
||||
...(richText
|
||||
? {
|
||||
codeBlock: false,
|
||||
bulletList: false,
|
||||
orderedList: false,
|
||||
listItem: false,
|
||||
listKeymap: false
|
||||
}
|
||||
: {}),
|
||||
// When rich text is off, disable Strike from StarterKit so we can
|
||||
// re-add it below without its Mod-Shift-s shortcut (which conflicts
|
||||
// with the Toggle Sidebar shortcut). When rich text is on, the user
|
||||
|
|
|
|||
|
|
@ -601,6 +601,8 @@
|
|||
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${model.id}&lang=${$i18n.language}`}
|
||||
alt="modelfile profile"
|
||||
class=" rounded-2xl size-12 object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
on:error={(e) => {
|
||||
e.target.src = '/favicon.png';
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -775,6 +775,8 @@
|
|||
"Enter prompt here.": "",
|
||||
"Enter proxy URL (e.g. https://user:password@host:port)": "",
|
||||
"Enter reasoning effort": "",
|
||||
"Enter PaddleOCR-vl API Token": "",
|
||||
"Enter PaddleOCR-vl API Base URL": "",
|
||||
"Enter Score": "",
|
||||
"Enter SearchApi API Key": "",
|
||||
"Enter SearchApi Engine": "",
|
||||
|
|
@ -1518,6 +1520,7 @@
|
|||
"Output format": "",
|
||||
"Output Format": "",
|
||||
"Overview": "",
|
||||
"PaddleOCR-vl": "",
|
||||
"page": "",
|
||||
"Page": "",
|
||||
"Page mode creates one document per page. Single mode combines all pages into one document for better chunking across page boundaries.": "",
|
||||
|
|
|
|||
|
|
@ -33,13 +33,13 @@
|
|||
"{{user}}'s Chats": "{{user}}:n keskustelut",
|
||||
"{{webUIName}} Backend Required": "{{webUIName}}-backend vaaditaan",
|
||||
"*Prompt node ID(s) are required for image generation": "Kuvan luomiseen vaaditaan kehote-solmun ID(t)",
|
||||
"1 hour before": "",
|
||||
"1 hour before": "1 tunti ennen",
|
||||
"1 Source": "1 lähde",
|
||||
"10 minutes before": "",
|
||||
"15 minutes before": "",
|
||||
"10 minutes before": "10 minuuttia ennen",
|
||||
"15 minutes before": "15 minuuttia ennen",
|
||||
"1m_time_ago": "",
|
||||
"30 minutes before": "",
|
||||
"5 minutes before": "",
|
||||
"30 minutes before": "30 minuuttia ennen",
|
||||
"5 minutes before": "5 minuuttia ennen",
|
||||
"A collaboration channel where people join as members": "Yhteistyökanava, johon ihmiset liittyvät jäseninä",
|
||||
"A discussion channel where access is controlled by groups and permissions": "Keskustelukanava, johon pääsyä rajoitetaan ryhmillä ja käyttöoikeuksilla",
|
||||
"A new version (v{{LATEST_VERSION}}) is now available.": "Uusi versio (v{{LATEST_VERSION}}) on nyt saatavilla.",
|
||||
|
|
@ -52,7 +52,7 @@
|
|||
"Access Control": "Käyttöoikeuksien hallinta",
|
||||
"Access Grants": "Käyttöoikeudet",
|
||||
"Access List": "Pääsylista",
|
||||
"Access updated": "",
|
||||
"Access updated": "Käyttöoikeus päivitetty",
|
||||
"Accessible to all users": "Käytettävissä kaikille käyttäjille",
|
||||
"Account": "Tili",
|
||||
"Account Activation Pending": "Tilin aktivointi odottaa",
|
||||
|
|
@ -78,11 +78,11 @@
|
|||
"Add content here": "Lisää sisältöä tähän",
|
||||
"Add Custom Parameter": "Lisää mukautettu parametri",
|
||||
"Add Custom Prompt": "Lisää mukautettu kehote",
|
||||
"Add description": "",
|
||||
"Add description": "Lisää kuvaus",
|
||||
"Add Details": "Lisää yksityiskohtia",
|
||||
"Add Files": "Lisää tiedostoja",
|
||||
"Add Image": "Lisää kuva",
|
||||
"Add location": "",
|
||||
"Add location": "Lisää sijainti",
|
||||
"Add Member": "Lisää jäsen",
|
||||
"Add Members": "Lisää jäseniä",
|
||||
"Add Memory": "Lisää muistiin",
|
||||
|
|
@ -99,7 +99,7 @@
|
|||
"Add webpage": "Lisää verkkosivu",
|
||||
"Add your Open Terminal URL and API key in Settings → Integrations.": "Lisää Open Terminal verkko-osoite ja API-avain Asetukset → Integraatiot",
|
||||
"Additional Config": "Lisäasetukset",
|
||||
"Additional configuration options for marker. This should be a JSON string with key-value pairs. For example, '{\"key\": \"value\"}'. Supported keys include: disable_links, keep_pageheader_in_output, keep_pagefooter_in_output, filter_blank_pages, drop_repeated_text, layout_coverage_threshold, merge_threshold, height_tolerance, gap_threshold, image_threshold, min_line_length, level_count, default_level": "",
|
||||
"Additional configuration options for marker. This should be a JSON string with key-value pairs. For example, '{\"key\": \"value\"}'. Supported keys include: disable_links, keep_pageheader_in_output, keep_pagefooter_in_output, filter_blank_pages, drop_repeated_text, layout_coverage_threshold, merge_threshold, height_tolerance, gap_threshold, image_threshold, min_line_length, level_count, default_level": "Lisäasetukset merkille. Tämä tulisi olla JSON-merkkijono, jossa on avain-arvo-pareja. Esimerkiksi '{\"key\": \"value\"}'. Tuetut avaimet sisältävät: disable_links, keep_pageheader_in_output, keep_pagefooter_in_output, filter_blank_pages, drop_repeated_text, layout_coverage_threshold, merge_threshold, height_tolerance, gap_threshold, image_threshold, min_line_length, level_count, default_level.",
|
||||
"Additional feedback comments": "Lisäpalautteen kommentit",
|
||||
"Additional Parameters": "Lisäparametrit",
|
||||
"Adds filenames, titles, sections, and snippets into the BM25 text to improve lexical recall.": "Lisää tiedostonimiä, otsikoita, osioita ja katkelmia BM25-tekstiin leksikaalisen muistamisen parantamiseksi.",
|
||||
|
|
@ -118,7 +118,7 @@
|
|||
"AI": "AI",
|
||||
"All": "Kaikki",
|
||||
"All chats have been unarchived.": "Kaikki keskustelut poistettu arkistosta.",
|
||||
"All day": "",
|
||||
"All day": "Koko päivä",
|
||||
"All models are now hidden": "Kaikki mallit ovat nyt piilotettu",
|
||||
"All models are now visible": "Kaikki mallit ovat nyt näkyvissä",
|
||||
"All models deleted successfully": "Kaikki mallit poistettu onnistuneesti",
|
||||
|
|
@ -152,7 +152,7 @@
|
|||
"Allowed File Extensions": "Hyväksytyt tiedostomuodot",
|
||||
"Allowed file extensions for upload. Separate multiple extensions with commas. Leave empty for all file types.": "Hyväksyty tiedostomuodot. Erittele tiedostomuodot pilkulla. Jätä tyhjäksi kaikille tiedostomuodoille.",
|
||||
"Already have an account?": "Onko sinulla jo tili?",
|
||||
"Alternative to the top_p, and aims to ensure a balance of quality and variety. The parameter p represents the minimum probability for a token to be considered, relative to the probability of the most likely token. For example, with p=0.05 and the most likely token having a probability of 0.9, logits with a value less than 0.045 are filtered out.": "",
|
||||
"Alternative to the top_p, and aims to ensure a balance of quality and variety. The parameter p represents the minimum probability for a token to be considered, relative to the probability of the most likely token. For example, with p=0.05 and the most likely token having a probability of 0.9, logits with a value less than 0.045 are filtered out.": "Vaihtoehto top_p:lle, ja sen tavoitteena on varmistaa laadun ja monimuotoisuuden tasapaino. Parametri p edustaa vähimmäistodennäköisyyttä, jonka on oltava tokenin huomioimiseksi suhteessa todennäköisimmän tokenin todennäköisyyteen. Esimerkiksi, kun p=0.05 ja todennäköisin tokenilla on todennäköisyys 0.9, logitit, joiden arvo on alle 0.045, suodatetaan pois.",
|
||||
"Always": "Aina",
|
||||
"Always Collapse Code Blocks": "Pienennä aina koodilohkot",
|
||||
"Always Expand Details": "Laajenna aina tiedot",
|
||||
|
|
@ -191,7 +191,7 @@
|
|||
"Are you sure you want to archive all chats? This action cannot be undone.": "Haluatko varmasti arkistoida kaikki keskustelut? Tätä toimintoa ei voi peruuttaa.",
|
||||
"Are you sure you want to clear all memories? This action cannot be undone.": "Haluatko varmasti tyhjentää kaikki muistot? Tätä toimintoa ei voi peruuttaa.",
|
||||
"Are you sure you want to delete \"{{NAME}}\"?": "Haluatko varmasti poistaa \"{{NAME}}\"?",
|
||||
"Are you sure you want to delete **{{modelName}}**?": "",
|
||||
"Are you sure you want to delete **{{modelName}}**?": "Haluatko varmasti poistaa **{{modelName}}**?",
|
||||
"Are you sure you want to delete all chats? This action cannot be undone.": "Haluatko varmasti poistaa kaikki keskustelut? Tätä toimintoa ei voi peruuttaa.",
|
||||
"Are you sure you want to delete this channel?": "Haluatko varmasti poistaa tämän kanavan?",
|
||||
"Are you sure you want to delete this connection? This action cannot be undone.": "Haluatko varmasti poistaa yhteyden? Tätä toimintoa ei voi peruuttaa.",
|
||||
|
|
@ -207,9 +207,9 @@
|
|||
"Ask a question": "Kysy kysymys",
|
||||
"Assistant": "Avustaja",
|
||||
"Async Embedding Processing": "Asynkroninen upotus prosessointi",
|
||||
"At time of event": "",
|
||||
"At time of event": "Tapahtumahetkellä",
|
||||
"Attach File From Knowledge": "Liitä tiedosto tietämyksestä",
|
||||
"Attach Files": "",
|
||||
"Attach Files": "Liitä tiedostoja",
|
||||
"Attach Knowledge": "Liitä tietoa",
|
||||
"Attach Notes": "Liitä muistiinpanoja",
|
||||
"Attach Webpage": "Liitä verkkosivu",
|
||||
|
|
@ -232,13 +232,13 @@
|
|||
"AUTOMATIC1111 Base URL": "AUTOMATIC1111 verkko-osoite",
|
||||
"AUTOMATIC1111 Base URL is required.": "AUTOMATIC1111 verkko-osoite vaaditaan.",
|
||||
"Automatically inject system tools in native function calling mode (e.g., timestamps, memory, chat history, notes, etc.)": "Lisää järjestelmätyökaluja automaattisesti natiivissa toimintokutsutilassa (esim. aikaleimat, muisti, keskusteluhistoria, muistiinpanot jne.)",
|
||||
"Automation": "",
|
||||
"Automation created": "",
|
||||
"Automation Name": "",
|
||||
"Automation title": "",
|
||||
"Automation triggered": "",
|
||||
"Automation updated": "",
|
||||
"Automations": "",
|
||||
"Automation": "Automaatio",
|
||||
"Automation created": "Automaatio luotu",
|
||||
"Automation Name": "Automaation nimi",
|
||||
"Automation title": "Automaation otsikko",
|
||||
"Automation triggered": "Automaatio laukaistu",
|
||||
"Automation updated": "Automaatio päivitetty",
|
||||
"Automations": "Automaatiot",
|
||||
"Available list": "Käytettävissä oleva luettelo",
|
||||
"Available models": "Käytettävissä olevat mallit",
|
||||
"Available Tools": "Käytettävissä olevat työkalut",
|
||||
|
|
@ -269,7 +269,7 @@
|
|||
"Boosting or penalizing specific tokens for constrained responses. Bias values will be clamped between -100 and 100 (inclusive). (Default: none)": "Tiettyjen tokeneiden tehostaminen tai rankaiseminen rajoitetuista vastauksista. Poikkeaman arvot rajoitetaan välille -100 ja 100 (mukaan lukien). (Oletus: ei mitään)",
|
||||
"Brave": "",
|
||||
"Brave Search API Key": "Brave Search API -avain",
|
||||
"Break down complex requests into trackable steps": "",
|
||||
"Break down complex requests into trackable steps": "Pilko monimutkaiset pyynnöt seurattaviin vaiheisiin",
|
||||
"Browse and query knowledge bases": "Selaa ja hae tietokannoista",
|
||||
"Builtin Tools": "Sisäänrakennetut työkalut",
|
||||
"Bullet List": "Luettelo",
|
||||
|
|
@ -282,8 +282,8 @@
|
|||
"Bypass Web Loader": "Ohita verkkolataaja",
|
||||
"Cache Base Model List": "Malli luettelon välimuisti",
|
||||
"Calendar": "Kalenteri",
|
||||
"Calendar deleted": "",
|
||||
"Calendars": "",
|
||||
"Calendar deleted": "Kalenteri poistettu",
|
||||
"Calendars": "Kalenterit",
|
||||
"Call": "Puhelu",
|
||||
"Call feature is not supported when using Web STT engine": "Puhelutoimintoa ei tueta käytettäessä web-puheentunnistusmoottoria",
|
||||
"Camera": "Kamera",
|
||||
|
|
@ -405,7 +405,7 @@
|
|||
"Concurrent Requests": "Samanaikaiset pyynnöt",
|
||||
"Config": "Määritykset",
|
||||
"Config imported successfully": "Määritysten tuonti onnistui",
|
||||
"Configuration": "",
|
||||
"Configuration": "Määritys",
|
||||
"Configure": "Määritä",
|
||||
"Confirm": "Vahvista",
|
||||
"Confirm Password": "Vahvista salasana",
|
||||
|
|
@ -420,7 +420,7 @@
|
|||
"Connect to your own OpenAPI compatible external tool servers.": "Yhdistä omat ulkopuoliset OpenAPI yhteensopivat työkalu palvelimet.",
|
||||
"Connected ({{type}})": "Yhdistetty ({{type}})",
|
||||
"Connection failed": "Yhteys epäonnistui",
|
||||
"Connection lost. Reconnecting...": "",
|
||||
"Connection lost. Reconnecting...": "Yhteys katkaistu. Yhdistetään uudelleen...",
|
||||
"Connection successful": "Yhteys onnistui",
|
||||
"Connection Type": "Yhteystyyppi",
|
||||
"Connections": "Yhteydet",
|
||||
|
|
@ -452,7 +452,7 @@
|
|||
"Copy Last Response": "Kopioi viimeisin vastaus",
|
||||
"Copy link": "Kopioi linkki",
|
||||
"Copy Link": "Kopioi linkki",
|
||||
"Copy Path": "",
|
||||
"Copy Path": "Kopioi polku",
|
||||
"Copy Prompt": "Kopioi kehote",
|
||||
"Copy Share Link": "Kopioi jakolinkki",
|
||||
"Copy to clipboard": "Kopioi leikepöydälle",
|
||||
|
|
@ -468,7 +468,7 @@
|
|||
"Create a new note": "Luo uusi muistiinpano",
|
||||
"Create Account": "Luo tili",
|
||||
"Create Admin Account": "Luo ylläpitäjätili",
|
||||
"Create and manage scheduled automations": "",
|
||||
"Create and manage scheduled automations": "Luo ja hallinnoi aikataulutettuja automaatioita",
|
||||
"Create Channel": "Luo kanava",
|
||||
"Create Folder": "Luo kansio",
|
||||
"Create Image": "Luo kuva",
|
||||
|
|
@ -478,7 +478,7 @@
|
|||
"Create new secret key": "Luo uusi salainen avain",
|
||||
"Create note": "Luo muistiinpano",
|
||||
"Create Note": "Luo muistiinpano",
|
||||
"Create scheduled prompts that run automatically on a recurring basis.": "",
|
||||
"Create scheduled prompts that run automatically on a recurring basis.": "Luo aikataulutettuja kehotteita, jotka suoritetaan automaattisesti toistuvasti.",
|
||||
"Create your first note by clicking on the plus button below.": "Luo ensimmäinen muistiinpanosi painamalla alla olevaa plus painiketta.",
|
||||
"Created at": "Luotu",
|
||||
"Created At": "Luotu",
|
||||
|
|
@ -494,14 +494,14 @@
|
|||
"Custom Gender": "Muu sukupuoli",
|
||||
"Custom Parameter Name": "Mukautetun parametrin nimi",
|
||||
"Custom Parameter Value": "Mukautetun parametrin arvo",
|
||||
"Daily": "",
|
||||
"Daily": "Päivittäin",
|
||||
"Daily Messages": "Päivittäiset viestit",
|
||||
"Danger Zone": "Vaara-alue",
|
||||
"Dark": "Tumma",
|
||||
"Data Controls": "Datan hallinta",
|
||||
"Database": "Tietokanta",
|
||||
"Datalab Marker API": "Datalab Marker API",
|
||||
"Day": "",
|
||||
"Day": "Päivä",
|
||||
"DD/MM/YYYY": "DD/MM/YYYY",
|
||||
"DDGS Backend": "DDGS-taustajärjestelmä",
|
||||
"December": "joulukuu",
|
||||
|
|
@ -532,12 +532,12 @@
|
|||
"Delete All": "Poista kaikki",
|
||||
"Delete All Chats": "Poista kaikki keskustelut",
|
||||
"Delete all contents inside this folder": "Poista kaikki sisällöt tästä kansiosta",
|
||||
"Delete automation?": "",
|
||||
"Delete calendar": "",
|
||||
"Delete Calendar": "",
|
||||
"Delete automation?": "Poista automaatio?",
|
||||
"Delete calendar": "Poista kalenteri",
|
||||
"Delete Calendar": "Poista kalenteri",
|
||||
"Delete Chat": "Poista keskustelu",
|
||||
"Delete chat?": "Haluatko varmasti poistaa tämän keskustelun?",
|
||||
"Delete Event": "",
|
||||
"Delete Event": "Poista tapahtuma?",
|
||||
"Delete File": "Poista tiedosto",
|
||||
"Delete folder?": "Haluatko varmasti poistaa tämän kansion?",
|
||||
"Delete function?": "Haluatko varmasti poistaa tämän toiminnon?",
|
||||
|
|
@ -680,7 +680,7 @@
|
|||
"Embedding Concurrent Requests": "Samanaikaiset upotuspyynnöt",
|
||||
"Embedding Model": "Upotusmalli",
|
||||
"Embedding Model Engine": "Upotusmallin moottori",
|
||||
"Emojis": "",
|
||||
"Emojis": "Emojit",
|
||||
"Empty message": "Tyhjä viesti",
|
||||
"Enable All": "Ota kaikki käyttöön",
|
||||
"Enable API Keys": "Ota API-avaimet käyttöön",
|
||||
|
|
@ -772,7 +772,7 @@
|
|||
"Enter Perplexity Search API URL": "Aseta Perplexity Search API verkko-osoite",
|
||||
"Enter Playwright Timeout": "Aseta Playwright aikakatkaisu",
|
||||
"Enter Playwright WebSocket URL": "Aseta Playwright WebSocket-aikakatkaisu",
|
||||
"Enter prompt here.": "",
|
||||
"Enter prompt here.": "Kirjoita kehote tähän.",
|
||||
"Enter proxy URL (e.g. https://user:password@host:port)": "Kirjoita välityspalvelimen verkko-osoite (esim. https://käyttäjä:salasana@host:portti)",
|
||||
"Enter reasoning effort": "Kirjoita päättelyn määrä",
|
||||
"Enter Score": "Kirjoita pistemäärä",
|
||||
|
|
@ -797,7 +797,7 @@
|
|||
"Enter system prompt here": "Kirjoita järjestelmäkehote tähän",
|
||||
"Enter Tavily API Key": "Kirjoita Tavily API -avain",
|
||||
"Enter Tavily Extract Depth": "Kirjoita Tavily pominta syvyys",
|
||||
"Enter the prompt instructions for this automation...": "",
|
||||
"Enter the prompt instructions for this automation...": "Kirjoita kehotteen ohjeet tälle automaatiolle...",
|
||||
"Enter the public URL of your WebUI. This URL will be used to generate links in the notifications.": "Kirjoita julkinen WebUI verkko-osoitteesi. Verkko-osoitetta käytetään osoitteiden luontiin ilmoituksissa.",
|
||||
"Enter the URL of the function to import": "Kirjoita tuotavan toiminnon verkko-osoite",
|
||||
"Enter the URL to import": "Kirjoita tuotavan verkko-osoite",
|
||||
|
|
@ -831,23 +831,23 @@
|
|||
"Enter your webhook URL": "Kirjoita webhook osoitteesi",
|
||||
"Entra ID": "Entra ID",
|
||||
"Environment Variables": "Ympäristömuuttujat",
|
||||
"Ephemeral": "",
|
||||
"Ephemeral": "Tilapäinen",
|
||||
"Error": "Virhe",
|
||||
"ERROR": "VIRHE",
|
||||
"Error accessing directory": "Virhe hakemistoa avattaessa",
|
||||
"Error accessing Google Drive: {{error}}": "Virhe yhdistäessä Google Drive: {{error}}",
|
||||
"Error accessing media devices.": "Virhe medialaitteita käytettäessä.",
|
||||
"Error deleting model: {{error}}": "",
|
||||
"Error deleting model: {{error}}": "Virhe mallia poistaessa: {{error}}",
|
||||
"Error starting recording.": "Virhe nauhoitusta aloittaessa.",
|
||||
"Error unloading model: {{error}}": "Virhe mallia ladattaessa: {{error}}",
|
||||
"Error uploading file: {{error}}": "Virhe ladattaessa tiedostoa: {{error}}",
|
||||
"Error: A model with the ID '{{modelId}}' already exists. Please select a different ID to proceed.": "Virhe: Malli '{{modelId}}' on jo käytössä. Valitse toinen ID jatkaaksesi.",
|
||||
"Error: Model ID cannot be empty. Please enter a valid ID to proceed.": "Virhe: Mallin ID ei voi olla tyhjä. Kirjoita ID jatkaaksesi.",
|
||||
"Evaluations": "Arvioinnit",
|
||||
"Event created": "",
|
||||
"Event deleted": "",
|
||||
"Event title": "",
|
||||
"Event updated": "",
|
||||
"Event created": "Tapahtuma luotu",
|
||||
"Event deleted": "Tapahtuma poistettu",
|
||||
"Event title": "Tapahtuman otsikko",
|
||||
"Event updated": "Tapahtuma päivitetty",
|
||||
"Exa API Key": "Exa API -avain",
|
||||
"Example: (&(objectClass=inetOrgPerson)(uid=%s))": "Esimerkki: (&(objectClass=inetOrgPerson)(uid=%s))",
|
||||
"Example: ALL": "Esimerkki: KAIKKI",
|
||||
|
|
@ -859,7 +859,7 @@
|
|||
"Execute code": "Suorita koodi",
|
||||
"Execute code for analysis": "Suorita koodi analysointia varten",
|
||||
"Executing **{{NAME}}**...": "Suoritetaan **{{NAME}}**...",
|
||||
"Execution Logs": "",
|
||||
"Execution Logs": "Suorituslokit",
|
||||
"Expand": "Laajenna",
|
||||
"Experimental": "Kokeellinen",
|
||||
"Explain": "Selitä",
|
||||
|
|
@ -869,8 +869,8 @@
|
|||
"Export": "Vie",
|
||||
"Export All Archived Chats": "Vie kaikki arkistoidut keskustelut",
|
||||
"Export All Chats (All Users)": "Vie kaikki keskustelut (kaikki käyttäjät)",
|
||||
"Export as CSV": "",
|
||||
"Export as JSON": "",
|
||||
"Export as CSV": "Vie CSV-tiedostona",
|
||||
"Export as JSON": "Vie JSON:na",
|
||||
"Export chat (.json)": "Vie keskustelu (.json)",
|
||||
"Export Chats": "Vie keskustelut",
|
||||
"Export Config": "Vie asetukset",
|
||||
|
|
@ -896,7 +896,7 @@
|
|||
"Failed to connect to {{URL}} terminal server": "Yhdistäminen {{URL}} päätepalvelimeen epäonnistui",
|
||||
"Failed to copy link": "Linkin kopiointi epäonnistui",
|
||||
"Failed to create API Key.": "API-avaimen luonti epäonnistui.",
|
||||
"Failed to delete calendar": "",
|
||||
"Failed to delete calendar": "Kalenterin poistaminen epäonnistui",
|
||||
"Failed to delete note": "Muistiinpanon poistaminen epäonnistui",
|
||||
"Failed to download image": "Kuvan lataaminen epäonnistui",
|
||||
"Failed to extract content from the file: {{error}}": "Tiedoston sisällön pomiminen epäonnistui: {{error}}",
|
||||
|
|
@ -1068,7 +1068,7 @@
|
|||
"History": "Historia",
|
||||
"Home": "Koti",
|
||||
"Host": "Palvelin",
|
||||
"Hourly": "",
|
||||
"Hourly": "Tunneittain",
|
||||
"Hourly Messages": "Tuntikohtaiset viestit",
|
||||
"How can I help you today?": "Miten voin auttaa sinua tänään?",
|
||||
"How would you rate this response?": "Kuinka arvioisit tätä vastausta?",
|
||||
|
|
@ -1128,7 +1128,7 @@
|
|||
"Insert Suggestion Prompt to Input": "Lisää kehote ehdotus syötteeseen",
|
||||
"Install from Github URL": "Asenna Github-URL:stä",
|
||||
"Instant Auto-Send After Voice Transcription": "Heti automaattinen lähetys äänitunnistuksen jälkeen",
|
||||
"Instructions": "",
|
||||
"Instructions": "Ohjeistukset",
|
||||
"Integration": "Integrointi",
|
||||
"Integrations": "Integraatiot",
|
||||
"Interface": "Käyttöliittymä",
|
||||
|
|
@ -1188,7 +1188,7 @@
|
|||
"Last 90 days": "Viimeiset 90 päivää",
|
||||
"Last Active": "Viimeksi aktiivinen",
|
||||
"Last Modified": "Viimeksi muokattu",
|
||||
"Last ran": "",
|
||||
"Last ran": "Viimeksi suoritettu",
|
||||
"Last reply": "Viimeksi vastattu",
|
||||
"LDAP": "LDAP",
|
||||
"LDAP server updated": "LDAP-palvelin päivitetty",
|
||||
|
|
@ -1218,7 +1218,7 @@
|
|||
"Limit concurrent search queries. 0 = unlimited (default). Set to 1 for sequential execution (recommended for APIs with strict rate limits like Brave free tier).": "Rajoita samanaikaisia hakukyselyitä. 0 = rajoittamaton (oletus). Aseta arvoon 1 peräkkäistä suoritusta varten (suositellaan API-rajapinnoille, joilla on tiukat nopeusrajoitukset, kuten Brave-ilmaistaso).",
|
||||
"Limits the number of concurrent embedding requests. Set to 0 for unlimited.": "Rajoittaa samanaikaisten upotuspyyntöjen määrää. Arvolla 0 ei rajoituksia.",
|
||||
"List": "Lista",
|
||||
"List calendars, search, create, update, and delete calendar events": "",
|
||||
"List calendars, search, create, update, and delete calendar events": "Listaa kalenterit, hae, luo, päivitä ja poista kalenteritapahtumia",
|
||||
"Listening...": "Kuuntelee...",
|
||||
"Live": "Live",
|
||||
"Llama.cpp": "Llama.cpp",
|
||||
|
|
@ -1229,7 +1229,7 @@
|
|||
"local": "paikallinen",
|
||||
"Local": "Paikallinen",
|
||||
"Local Task Model": "Paikallinen työmalli",
|
||||
"Location": "",
|
||||
"Location": "Sijainti",
|
||||
"Location access not allowed": "Ei pääsyä sijaintitietoihin",
|
||||
"Lost": "Mennyt",
|
||||
"Low": "Matala",
|
||||
|
|
@ -1297,15 +1297,15 @@
|
|||
"Model": "Malli",
|
||||
"Model '{{modelName}}' has been successfully downloaded.": "Malli '{{modelName}}' ladattiin onnistuneesti.",
|
||||
"Model '{{modelTag}}' is already in queue for downloading.": "Malli '{{modelTag}}' on jo jonossa ladattavaksi.",
|
||||
"Model {{modelId}} not found": "",
|
||||
"Model {{modelName}} deleted successfully": "",
|
||||
"Model {{modelId}} not found": "Malli {{modelId}} ei löytynyt",
|
||||
"Model {{modelName}} deleted successfully": "Malli {{modelName}} poistettu onnistuneesti",
|
||||
"Model {{modelName}} is not vision capable": "Malli {{modelName}} ei kykene näkökykyyn",
|
||||
"Model {{name}} is now {{status}}": "Malli {{name}} on nyt {{status}}",
|
||||
"Model {{name}} is now hidden": "Malli {{name}} on nyt piilotettu",
|
||||
"Model {{name}} is now visible": "Malli {{name}} on nyt näkyvissä",
|
||||
"Model accepts file inputs": "Malli hyväksyy tiedostosyötteet",
|
||||
"Model accepts image inputs": "Malli hyväksyy kuvasyötteitä",
|
||||
"Model can access Open Terminal for command execution and file management": "",
|
||||
"Model can access Open Terminal for command execution and file management": "Malli voi käyttää Open Terminal-toimintoa komentojen suorittamiseen ja tiedostojen hallintaan.",
|
||||
"Model can execute code and perform calculations": "Malli voi suorittaa koodia ja laskelmia",
|
||||
"Model can generate images based on text prompts": "Malli voi luoda kuvia tekstikehotteiden perusteella",
|
||||
"Model can search the web for information": "Malli voi hakea tietoa verkosta",
|
||||
|
|
@ -1339,8 +1339,8 @@
|
|||
"Models Sharing": "Mallien jako",
|
||||
"Mojeek": "",
|
||||
"Mojeek Search API Key": "Mojeek Search API -avain",
|
||||
"Month": "",
|
||||
"Monthly": "",
|
||||
"Month": "Kuukausi",
|
||||
"Monthly": "Kuukausittain",
|
||||
"More": "Lisää",
|
||||
"More Concise": "Lyhyemmin",
|
||||
"More options": "Lisää vaihtoehtoja",
|
||||
|
|
@ -1351,14 +1351,14 @@
|
|||
"Name": "Nimi",
|
||||
"Name and ID are required, please fill them out": "Nimi ja ID vaaditaan, täytä puuttuvat kentät",
|
||||
"Name your knowledge base": "Anna tietokannalle nimi",
|
||||
"Name, prompt, and model are required": "",
|
||||
"Name, prompt, and model are required": "Nimi, kehote ja malli ovat pakollisia",
|
||||
"Native": "Natiivi",
|
||||
"Never": "",
|
||||
"Never": "Ei koskaan",
|
||||
"New": "Uusi",
|
||||
"New Automation": "",
|
||||
"New Automation": "Uusi automaatio",
|
||||
"New Button": "Uusi painike",
|
||||
"New Chat": "Uusi keskustelu",
|
||||
"New Event": "",
|
||||
"New Event": "Uusi tapahtuma",
|
||||
"New File": "Uusi tiedosto",
|
||||
"New Folder": "Uusi kansio",
|
||||
"New Function": "Uusi toiminto",
|
||||
|
|
@ -1375,11 +1375,11 @@
|
|||
"New Webhook": "Uusi Webhook",
|
||||
"new-channel": "uusi-kanava",
|
||||
"Next message": "Seuraava viesti",
|
||||
"Next run": "",
|
||||
"Next run": "Seuraava suoritus",
|
||||
"No access grants. Private to you.": "Ei käyttöoikeuksia. Yksityinen sinulle.",
|
||||
"No activity data": "Ei aktiivisuustietoja",
|
||||
"No authentication": "Ei todennusta",
|
||||
"No automations found": "",
|
||||
"No automations found": "Automaatioita ei löytynyt",
|
||||
"No chats found": "Keskuteluja ei löytynyt",
|
||||
"No chats found for this user.": "Käyttäjän keskusteluja ei löytynyt.",
|
||||
"No chats found.": "Keskusteluja ei löytynyt",
|
||||
|
|
@ -1390,7 +1390,7 @@
|
|||
"No data": "Ei dataa",
|
||||
"No data found": "Dataa ei löytynyt",
|
||||
"No distance available": "Etäisyyttä ei saatavilla",
|
||||
"No execution logs available yet": "",
|
||||
"No execution logs available yet": "Suorituslokeja ei saatavilla",
|
||||
"No expiration can pose security risks.": "Vanhenemisen laittamatta jättäminen voi altistaa tietoturvariskeille.",
|
||||
"No feedback found": "Ei palautetta",
|
||||
"No file selected": "Tiedostoa ei ole valittu",
|
||||
|
|
@ -1437,7 +1437,7 @@
|
|||
"Not factually correct": "Ei faktuaalisesti oikein",
|
||||
"Not helpful": "Ei hyödyllinen",
|
||||
"Not Registered": "Ei kirjautunut",
|
||||
"Not scheduled": "",
|
||||
"Not scheduled": "Ei aikataulutettu",
|
||||
"Note": "Muistiinpano",
|
||||
"Note deleted successfully": "Muistiinpano poistettiin onnistuneesti",
|
||||
"Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.": "Huomautus: Jos asetat vähimmäispistemäärän, haku palauttaa vain sellaiset asiakirjat, joiden pistemäärä on vähintään vähimmäismäärä.",
|
||||
|
|
@ -1462,7 +1462,7 @@
|
|||
"Ollama Cloud API Key": "Ollama Cloud API avain",
|
||||
"Ollama Version": "Ollama-versio",
|
||||
"On": "Päällä",
|
||||
"Once": "",
|
||||
"Once": "Kerran",
|
||||
"OneDrive": "OneDrive",
|
||||
"Only active when \"Paste Large Text as File\" setting is toggled on.": "Aktiivinen vain, kun \"Liitä suuri teksti tiedostona\" -asetus on käytössä.",
|
||||
"Only active when the chat input is in focus and an LLM is generating a response.": "Aktiivinen vain, kun tekstikenttä on kohdistettuna ja LLM luo vastausta.",
|
||||
|
|
@ -1528,8 +1528,8 @@
|
|||
"Password": "Salasana",
|
||||
"Passwords do not match.": "Salasanat eivät täsmää",
|
||||
"Paste Large Text as File": "Liitä suuri teksti tiedostona",
|
||||
"Path copied": "",
|
||||
"Paused": "",
|
||||
"Path copied": "Polku kopioitu",
|
||||
"Paused": "Keskeytetty",
|
||||
"PDF document (.pdf)": "PDF-asiakirja (.pdf)",
|
||||
"PDF Extract Images (OCR)": "Poimi kuvat PDF:stä (OCR)",
|
||||
"PDF Loader Mode": "PDF latausmoodi",
|
||||
|
|
@ -1548,7 +1548,7 @@
|
|||
"Persistent": "Pysyvä",
|
||||
"Personalization": "Personointi",
|
||||
"Pin": "Kiinnitä",
|
||||
"Pin to Sidebar": "",
|
||||
"Pin to Sidebar": "Kiinnitä sivupalkkiin",
|
||||
"Pinned": "Kiinnitetty",
|
||||
"Pinned Messages": "Kiinnitetyt viestit",
|
||||
"Pinned Models": "Kiinnitetyt mallit",
|
||||
|
|
@ -1635,8 +1635,8 @@
|
|||
"Reason": "Päättely",
|
||||
"Reasoning Effort": "Päättelyn määrä",
|
||||
"Reasoning Tags": "Päättely tagit",
|
||||
"Recently Used": "",
|
||||
"Reconnected": "",
|
||||
"Recently Used": "Viimeeksi käytetty",
|
||||
"Reconnected": "Yhdistetty",
|
||||
"Record": "Nauhoita",
|
||||
"Record voice": "Nauhoita ääntä",
|
||||
"Redirecting you to Open WebUI Community": "Ohjataan sinut OpenWebUI-yhteisöön",
|
||||
|
|
@ -1660,7 +1660,7 @@
|
|||
"Relevance": "Relevanssi",
|
||||
"Relevance Threshold": "Relevanssikynnys",
|
||||
"Remember Dismissal": "Muista sulkeminen",
|
||||
"Reminder": "",
|
||||
"Reminder": "Muistutus",
|
||||
"Remove": "Poista",
|
||||
"Remove {{MODELID}} from list.": "Poista {{MODELID}} listalta",
|
||||
"Remove action": "Poista toiminto",
|
||||
|
|
@ -1673,7 +1673,7 @@
|
|||
"Renamed to {{name}}": "Nimetty uudelleen {{name}}",
|
||||
"Render Markdown in Previews": "Renderöi Markdown esikatseluissa",
|
||||
"Reorder Models": "Uudelleenjärjestä malleja",
|
||||
"Repeats": "",
|
||||
"Repeats": "Toistot",
|
||||
"Reply": "Vastaa",
|
||||
"Reply in Thread": "Vastaa ketjussa",
|
||||
"Reply to thread...": "Vastaa ketjussa...",
|
||||
|
|
@ -1707,8 +1707,8 @@
|
|||
"RTL": "RTL",
|
||||
"Run": "Suorita",
|
||||
"Run All": "Suorita kaikki",
|
||||
"Run now": "",
|
||||
"Run Now": "",
|
||||
"Run now": "Suorita nyt",
|
||||
"Run Now": "Suorita nyt",
|
||||
"Running": "Käynnissä",
|
||||
"Running...": "Käynnissä...",
|
||||
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "Suorittaa upotustehtäviä samanaikaisesti käsittelyn nopeuttamiseksi. Poista käytöstä, jos kutsurajoituksesta tulee ongelma.",
|
||||
|
|
@ -1720,15 +1720,15 @@
|
|||
"Save Chat": "Tallenna keskustelu",
|
||||
"Saved": "Tallennettu",
|
||||
"Saving chat logs directly to your browser's storage is no longer supported. Please take a moment to download and delete your chat logs by clicking the button below. Don't worry, you can easily re-import your chat logs to the backend through": "Keskustelulokien tallentaminen suoraan selaimen tallennustilaan ei ole enää tuettu. Lataa ja poista keskustelulokit napsauttamalla alla olevaa painiketta. Älä huoli, voit helposti tuoda keskustelulokit takaisin backendiin",
|
||||
"Schedule": "",
|
||||
"Scheduled time must be in the future": "",
|
||||
"Schedule": "Aikataulu",
|
||||
"Scheduled time must be in the future": "Aikataulutetun ajan on oltava tulevaisuudessa",
|
||||
"Scroll On Branch Change": "Vieritä haaran vaihtoon",
|
||||
"Search": "Haku",
|
||||
"Search a model": "Hae mallia",
|
||||
"Search all emojis": "Hae emojeista",
|
||||
"Search and manage user memories": "Hae ja hallinnoi käyttäjien muistoja",
|
||||
"Search and view user chat history": "Hae ja tarkastele käyttäjän keskusteluhistoriaa",
|
||||
"Search Automations": "",
|
||||
"Search Automations": "Etsi automaatioita",
|
||||
"Search Base": "Hakupohja",
|
||||
"Search channels and channel messages": "Hae kanavia ja kanavaviestejä",
|
||||
"Search Chats": "Hae keskusteluja",
|
||||
|
|
@ -1798,7 +1798,7 @@
|
|||
"Select how to split message text for TTS requests": "Valitse, miten viestit jaetaan TTS-pyyntöjä varten",
|
||||
"Select Knowledge": "Valitse tietämys",
|
||||
"Select Method": "Valitse metodi",
|
||||
"Select model": "",
|
||||
"Select model": "Valitse malli",
|
||||
"Select only one model to call": "Valitse vain yksi malli kutsuttavaksi",
|
||||
"Select view": "Valitse näkymä",
|
||||
"Selected model: {{modelName}}": "Valittu malli: {{modelName}}",
|
||||
|
|
@ -1907,12 +1907,12 @@
|
|||
"Start a new conversation": "Aloita uusi keskustelu",
|
||||
"Start of the channel": "Kanavan alku",
|
||||
"Start Tag": "Aloitus tagi",
|
||||
"Starting in {{count}} minutes_one": "",
|
||||
"Starting in {{count}} minutes_other": "",
|
||||
"Starting in 1 minute": "",
|
||||
"Starting in {{count}} minutes_one": "Aloitetaan {{count}} minutes_one",
|
||||
"Starting in {{count}} minutes_other": "Aloitetaan {{count}} minutes_other",
|
||||
"Starting in 1 minute": "Aloitetaan minuutin kuluttua",
|
||||
"Starting kernel...": "Käynnistetään kerneliä...",
|
||||
"Starting now": "",
|
||||
"State": "",
|
||||
"Starting now": "Aloitetaan nyt",
|
||||
"State": "Tila",
|
||||
"Status": "Tila",
|
||||
"Status cleared successfully": "Tila poistettu onnistuneesti",
|
||||
"Status updated successfully": "Tila päivitetty onnistuneesti",
|
||||
|
|
@ -1923,7 +1923,7 @@
|
|||
"Stop Download": "Lopeta lataus",
|
||||
"Stop Generating": "Lopeta generointi",
|
||||
"Stop Sequence": "Lopetussekvenssi",
|
||||
"Storage": "",
|
||||
"Storage": "Käyttötila",
|
||||
"Stream Chat Response": "Striimaa keskusteluvastaus",
|
||||
"Stream Delta Chunk Size": "Striimin delta-lohkon koko",
|
||||
"Streamable HTTP": "Streamable HTTP",
|
||||
|
|
@ -1964,10 +1964,10 @@
|
|||
"Talk to Model": "Puhu mallille",
|
||||
"Tap to interrupt": "Napauta keskeyttääksesi",
|
||||
"Task List": "Tehtävälista",
|
||||
"Task Management": "",
|
||||
"Task Management": "Tehtävien hallinta",
|
||||
"Task Model": "Työmalli",
|
||||
"Tasks": "Tehtävät",
|
||||
"tasks completed": "",
|
||||
"tasks completed": "Tehtävät suoritettu",
|
||||
"Tavily API Key": "Tavily API -avain",
|
||||
"Tavily Extract Depth": "Tavily poiminta syvyys",
|
||||
"Tell us more:": "Kerro lisää:",
|
||||
|
|
@ -1999,7 +1999,7 @@
|
|||
"The score should be a value between 0.0 (0%) and 1.0 (100%).": "Pisteytyksen tulee olla arvo välillä 0,0 (0 %) ja 1,0 (100 %).",
|
||||
"The stream delta chunk size for the model. Increasing the chunk size will make the model respond with larger pieces of text at once.": "Mallin striimin delta-lohkon koko. Lohkon koon kasvattaminen saa mallin vastaamaan kerralla suuremmilla tekstipaloilla.",
|
||||
"The temperature of the model. Increasing the temperature will make the model answer more creatively.": "Mallin lämpötila. Lisäämällä lämpötilaa mallin vastaukset ovat luovempia.",
|
||||
"The Weight of BM25 Hybrid Search. 0 more semantic, 1 more lexical. Default 0.5": "",
|
||||
"The Weight of BM25 Hybrid Search. 0 more semantic, 1 more lexical. Default 0.5": "BM25-hybridihaun painoarvo. 0 semanttista, 1 leksikaalista. Oletusarvo 0,5",
|
||||
"The width in pixels to compress images to. Leave empty for no compression.": "Leveys pikseleinä, johon kuvat pakataan. Jätä tyhjäksi, jos et halua pakkausta.",
|
||||
"Theme": "Teema",
|
||||
"There was an error syncing your stats. Please try again.": "Tilastojen synkronoinnissa tapahtui virhe. Yritä uudelleen.",
|
||||
|
|
@ -2024,7 +2024,7 @@
|
|||
"This will delete <strong>{{NAME}}</strong> and <strong>all its contents</strong>.": "Tämä poistaa <strong>{{NAME}}</strong> ja <strong>kaikki sen sisällöt</strong>.",
|
||||
"This will delete all models including custom models": "Tämä poistaa kaikki mallit mukaan lukien mukautetut mallit",
|
||||
"This will delete all models including custom models and cannot be undone.": "Tämä poistaa kaikki mallit, mukaan lukien mukautetut mallit, eikä sitä voi peruuttaa.",
|
||||
"This will permanently delete the calendar \"{{name}}\" and all its events. This action cannot be undone.": "",
|
||||
"This will permanently delete the calendar \"{{name}}\" and all its events. This action cannot be undone.": "Tämä poistaa pysyvästi kalenterin \"{{name}}\" ja kaikki sen tapahtumat. Tätä toimintoa ei voi peruuttaa.",
|
||||
"This will reset the knowledge base and sync all files. Do you wish to continue?": "Tämä nollaa tietokannan ja synkronoi kaikki tiedostot. Haluatko jatkaa?",
|
||||
"Thorough explanation": "Perusteellinen selitys",
|
||||
"Thought": "Ajatus",
|
||||
|
|
@ -2036,7 +2036,7 @@
|
|||
"Tika": "Tika",
|
||||
"Tika Server URL required.": "Tika palvelimen verkko-osoite vaaditaan.",
|
||||
"Tiktoken": "Tiktoken",
|
||||
"Time": "",
|
||||
"Time": "Aika",
|
||||
"Time & Calculation": "Aika ja laskenta",
|
||||
"Timeout": "Aikakatkaisu",
|
||||
"Title": "Otsikko",
|
||||
|
|
@ -2044,7 +2044,7 @@
|
|||
"Title cannot be an empty string.": "Otsikko ei voi olla tyhjä merkkijono.",
|
||||
"Title Generation": "Otsikon luonti",
|
||||
"Title Generation Prompt": "Otsikon luontikehote",
|
||||
"Title is required": "",
|
||||
"Title is required": "Otsikko on pakollinen",
|
||||
"TLS": "TLS",
|
||||
"To access the available model names for downloading,": "Päästäksesi käsiksi ladattavissa oleviin mallinimiin,",
|
||||
"To access the GGUF models available for downloading,": "Päästäksesi käsiksi ladattavissa oleviin GGUF-malleihin,",
|
||||
|
|
@ -2055,7 +2055,7 @@
|
|||
"To select toolkits here, add them to the \"Tools\" workspace first.": "Valitaksesi työkalusettejä tässä, lisää ne ensin \"Työkalut\"-työtilaan.",
|
||||
"Toast notifications for new updates": "Ilmoituspopuppien näyttäminen uusista päivityksistä",
|
||||
"Today": "Tänään",
|
||||
"Today at": "",
|
||||
"Today at": "Tänään",
|
||||
"Today at {{LOCALIZED_TIME}}": "Tänään {{LOCALIZED_TIME}}",
|
||||
"Toggle {{COUNT}} sources": "Näytä/piilota {{COUNT}} lähdettä",
|
||||
"Toggle 1 source": "Näytä/piilota 1 lähde",
|
||||
|
|
@ -2111,7 +2111,7 @@
|
|||
"Unloads {{FROM_NOW}}": "Purkuja {{FROM_NOW}}",
|
||||
"Unlock mysteries": "Selvitä arvoituksia",
|
||||
"Unpin": "Irrota kiinnitys",
|
||||
"Unpin from Sidebar": "",
|
||||
"Unpin from Sidebar": "Irrota sivupalkista",
|
||||
"Unravel secrets": "Avaa salaisuuksia",
|
||||
"Unshare Chat": "Lopeta keskustelun jakaminen",
|
||||
"Unsupported file type.": "Ei tuettu tiedostotyyppi",
|
||||
|
|
@ -2196,7 +2196,7 @@
|
|||
"Waiting for upload...": "Odottaa latausta...",
|
||||
"Warning": "Varoitus",
|
||||
"Warning:": "Varoitus:",
|
||||
"Warning: Enabling this will allow users to run scheduled prompts automatically.": "",
|
||||
"Warning: Enabling this will allow users to run scheduled prompts automatically.": "Varoitus: Tämän käyttöönotto sallii käyttäjien suorittaa aikataulutettuja kehotteita automaattisesti.",
|
||||
"Warning: Enabling this will allow users to upload arbitrary code on the server.": "Varoitus: Tämän käyttöönotto sallii käyttäjien ladata mielivaltaista koodia palvelimelle.",
|
||||
"Warning: Jupyter execution enables arbitrary code execution, posing severe security risks—proceed with extreme caution.": "Varoitus: Jupyter käyttö voi mahdollistaa mielivaltaiseen koodin suorittamiseen, mikä voi aiheuttaa tietoturvariskejä - käytä äärimmäisen varoen.",
|
||||
"We_day_of_week": "",
|
||||
|
|
@ -2216,15 +2216,15 @@
|
|||
"WebUI will make requests to \"{{url}}\"": "WebUI lähettää pyyntöjä osoitteeseen \"{{url}}\"",
|
||||
"WebUI will make requests to \"{{url}}/api/chat\"": "WebUI lähettää pyyntöjä osoitteeseen \"{{url}}/api/chat\"",
|
||||
"WebUI will make requests to \"{{url}}/chat/completions\"": "WebUI lähettää pyyntöjä osoitteeseen \"{{url}}/chat/completions\"",
|
||||
"Week": "",
|
||||
"Weekly": "",
|
||||
"Week": "Viikko",
|
||||
"Weekly": "Viikoittain",
|
||||
"What are you trying to achieve?": "Mitä yrität saavuttaa?",
|
||||
"What are you working on?": "Mitä olet työskentelemässä?",
|
||||
"What is NOT shared:": "Mitä EI jaeta:",
|
||||
"What is shared:": "Mitä jaetaan:",
|
||||
"What's New in": "Mitä uutta",
|
||||
"What's on your mind?": "Mitä ajattelet?",
|
||||
"When": "",
|
||||
"When": "Milloin",
|
||||
"When enabled, the model will respond to each chat message in real-time, generating a response as soon as the user sends a message. This mode is useful for live chat applications, but may impact performance on slower hardware.": "Kun käytössä, malli vastaa jokaiseen chatviestiin reaaliajassa, tuottaen vastauksen heti kun käyttäjä lähettää viestin. Tämä tila on hyödyllinen reaaliaikaisissa chat-sovelluksissa, mutta voi vaikuttaa suorituskykyyn hitaammilla laitteistoilla.",
|
||||
"wherever you are": "missä tahansa oletkin",
|
||||
"Whether to paginate the output. Each page will be separated by a horizontal rule and page number. Defaults to False.": "Sivutetaanko tuloste. Jokainen sivu erotetaan toisistaan vaakasuoralla viivalla ja sivunumerolla. Oletusarvo ei käytössä.",
|
||||
|
|
@ -2235,7 +2235,7 @@
|
|||
"Width": "Leveys",
|
||||
"Wikipedia": "",
|
||||
"Won": "Voitti",
|
||||
"Working Directory": "",
|
||||
"Working Directory": "Työhakemisto",
|
||||
"Works together with top-k. A higher value (e.g., 0.95) will lead to more diverse text, while a lower value (e.g., 0.5) will generate more focused and conservative text.": "Toimii top-k:n kanssa. Korkeampi arvo (esim. 0.95) johtaa monipuolisempaan tekstiin, kun taas matalampi arvo (esim. 0.5) tuottaa kohdennetumpaa ja konservatiivisempaa teksti.",
|
||||
"Workspace": "Työtila",
|
||||
"Workspace Permissions": "Työtilan käyttöoikeudet",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -34,13 +34,13 @@
|
|||
"{{user}}'s Chats": "Chats de {{user}}",
|
||||
"{{webUIName}} Backend Required": "Backend {{webUIName}} necessário",
|
||||
"*Prompt node ID(s) are required for image generation": "*Prompt node ID(s) são obrigatórios para gerar imagens",
|
||||
"1 hour before": "",
|
||||
"1 hour before": "1 hora antes",
|
||||
"1 Source": "1 Origem",
|
||||
"10 minutes before": "",
|
||||
"15 minutes before": "",
|
||||
"10 minutes before": "10 minutos antes",
|
||||
"15 minutes before": "15 minutos antes",
|
||||
"1m_time_ago": "1m atrás",
|
||||
"30 minutes before": "",
|
||||
"5 minutes before": "",
|
||||
"30 minutes before": "30 minutos antes",
|
||||
"5 minutes before": "5 minutos antes",
|
||||
"A collaboration channel where people join as members": "Um canal de colaboração onde as pessoas se juntam como membros.",
|
||||
"A discussion channel where access is controlled by groups and permissions": "Um canal de discussão onde o acesso é controlado por grupos e permissões.",
|
||||
"A new version (v{{LATEST_VERSION}}) is now available.": "Uma nova versão (v{{LATEST_VERSION}}) está disponível.",
|
||||
|
|
@ -79,11 +79,11 @@
|
|||
"Add content here": "Adicionar conteúdo aqui",
|
||||
"Add Custom Parameter": "Adicionar parâmetro personalizado",
|
||||
"Add Custom Prompt": "Adicionar prompt personalizado",
|
||||
"Add description": "",
|
||||
"Add description": "Adicionar descrição",
|
||||
"Add Details": "Adicionar detalhes",
|
||||
"Add Files": "Adicionar Arquivos",
|
||||
"Add Image": "Adicionar imagem",
|
||||
"Add location": "",
|
||||
"Add location": "Adicionar localização",
|
||||
"Add Member": "Adicionar membro",
|
||||
"Add Members": "Adicionar membros",
|
||||
"Add Memory": "Adicionar Memória",
|
||||
|
|
@ -119,7 +119,7 @@
|
|||
"AI": "IA",
|
||||
"All": "Tudo",
|
||||
"All chats have been unarchived.": "Todos os chats foram desarquivados.",
|
||||
"All day": "",
|
||||
"All day": "O dia todo",
|
||||
"All models are now hidden": "Todos os modelos estão agora ocultos",
|
||||
"All models are now visible": "Todos os modelos estão agora visíveis",
|
||||
"All models deleted successfully": "Todos os modelos foram excluídos com sucesso",
|
||||
|
|
@ -208,7 +208,7 @@
|
|||
"Ask a question": "Faça uma pergunta",
|
||||
"Assistant": "Assistente",
|
||||
"Async Embedding Processing": "Processamento de Embedding assíncrono",
|
||||
"At time of event": "",
|
||||
"At time of event": "No horário do evento",
|
||||
"Attach File From Knowledge": "Anexar arquivo da base de conhecimento",
|
||||
"Attach Files": "Anexar arquivos",
|
||||
"Attach Knowledge": "Anexar Base de Conhecimento",
|
||||
|
|
@ -283,8 +283,8 @@
|
|||
"Bypass Web Loader": "Ignorar carregador da Web",
|
||||
"Cache Base Model List": "Lista de modelos base de cache",
|
||||
"Calendar": "Calendário",
|
||||
"Calendar deleted": "",
|
||||
"Calendars": "",
|
||||
"Calendar deleted": "Calendário excluído",
|
||||
"Calendars": "Calendários",
|
||||
"Call": "Chamada",
|
||||
"Call feature is not supported when using Web STT engine": "O recurso de chamada não é suportado ao usar o mecanismo Web STT",
|
||||
"Camera": "Câmera",
|
||||
|
|
@ -421,7 +421,7 @@
|
|||
"Connect to your own OpenAPI compatible external tool servers.": "Conecte-se aos seus próprios servidores de ferramentas externas compatíveis com OpenAPI.",
|
||||
"Connected ({{type}})": "Conectado ({{type}})",
|
||||
"Connection failed": "Falha na conexão",
|
||||
"Connection lost. Reconnecting...": "",
|
||||
"Connection lost. Reconnecting...": "Conexão perdida. Reconectando...",
|
||||
"Connection successful": "Conexão bem-sucedida",
|
||||
"Connection Type": "Tipo de conexão",
|
||||
"Connections": "Conexões",
|
||||
|
|
@ -534,11 +534,11 @@
|
|||
"Delete All Chats": "Excluir Todos os Chats",
|
||||
"Delete all contents inside this folder": "Apague todo o conteúdo desta pasta.",
|
||||
"Delete automation?": "Excluir automação?",
|
||||
"Delete calendar": "",
|
||||
"Delete Calendar": "",
|
||||
"Delete calendar": "Excluir calendário",
|
||||
"Delete Calendar": "Excluir Calendário",
|
||||
"Delete Chat": "Excluir Chat",
|
||||
"Delete chat?": "Excluir chat?",
|
||||
"Delete Event": "",
|
||||
"Delete Event": "Excluir Evento",
|
||||
"Delete File": "Excluir arquivo",
|
||||
"Delete folder?": "Excluir pasta?",
|
||||
"Delete function?": "Excluir função?",
|
||||
|
|
@ -845,10 +845,10 @@
|
|||
"Error: A model with the ID '{{modelId}}' already exists. Please select a different ID to proceed.": "Erro: Já existe um modelo com o ID '{{modelId}}'. Selecione um ID diferente para prosseguir.",
|
||||
"Error: Model ID cannot be empty. Please enter a valid ID to proceed.": "Erro: O ID do modelo não pode estar vazio. Insira um ID válido para prosseguir.",
|
||||
"Evaluations": "Avaliações",
|
||||
"Event created": "",
|
||||
"Event deleted": "",
|
||||
"Event title": "",
|
||||
"Event updated": "",
|
||||
"Event created": "Evento criado",
|
||||
"Event deleted": "Evento excluído",
|
||||
"Event title": "Título do evento",
|
||||
"Event updated": "Evento atualizado",
|
||||
"Exa API Key": "Chave da API Exa",
|
||||
"Example: (&(objectClass=inetOrgPerson)(uid=%s))": "Exemplo: (&(objectClass=inetOrgPerson)(uid=%s))",
|
||||
"Example: ALL": "Exemplo: ALL",
|
||||
|
|
@ -897,7 +897,7 @@
|
|||
"Failed to connect to {{URL}} terminal server": "Falha ao conectar ao servidor de terminal {{URL}}",
|
||||
"Failed to copy link": "Falha ao copiar o link",
|
||||
"Failed to create API Key.": "Falha ao criar a Chave API.",
|
||||
"Failed to delete calendar": "",
|
||||
"Failed to delete calendar": "Falha ao excluir calendário",
|
||||
"Failed to delete note": "Falha ao excluir a nota",
|
||||
"Failed to download image": "Falha ao baixar a imagem",
|
||||
"Failed to extract content from the file: {{error}}": "Falha ao extrair conteúdo do arquivo: {{error}}",
|
||||
|
|
@ -1219,7 +1219,7 @@
|
|||
"Limit concurrent search queries. 0 = unlimited (default). Set to 1 for sequential execution (recommended for APIs with strict rate limits like Brave free tier).": "Limitar consultas de pesquisa simultâneas. 0 = ilimitado (padrão). Defina como 1 para execução sequencial (recomendado para APIs com limites de taxa rígidos, como o nível gratuito do Brave).",
|
||||
"Limits the number of concurrent embedding requests. Set to 0 for unlimited.": "Limita o número de solicitações simultâneas de embedding. Defina como 0 para ilimitado.",
|
||||
"List": "Lista",
|
||||
"List calendars, search, create, update, and delete calendar events": "",
|
||||
"List calendars, search, create, update, and delete calendar events": "Listar calendários, pesquisar, criar, atualizar e excluir eventos do calendário",
|
||||
"Listening...": "Escutando...",
|
||||
"Live": "Ao vivo",
|
||||
"Llama.cpp": "Llama.cpp",
|
||||
|
|
@ -1230,7 +1230,7 @@
|
|||
"local": "local",
|
||||
"Local": "Local",
|
||||
"Local Task Model": "Modelo de Tarefa Local",
|
||||
"Location": "",
|
||||
"Location": "Localização",
|
||||
"Location access not allowed": "Acesso ao local não permitido",
|
||||
"Lost": "Perdeu",
|
||||
"Low": "Baixo",
|
||||
|
|
@ -1340,7 +1340,7 @@
|
|||
"Models Sharing": "Compartilhamento de Modelos",
|
||||
"Mojeek": "Mojeek",
|
||||
"Mojeek Search API Key": "Chave de API Mojeek Search",
|
||||
"Month": "",
|
||||
"Month": "Mês",
|
||||
"Monthly": "Mensal",
|
||||
"More": "Mais",
|
||||
"More Concise": "Mais conciso",
|
||||
|
|
@ -1359,7 +1359,7 @@
|
|||
"New Automation": "Nova Automação",
|
||||
"New Button": "Novo Botão",
|
||||
"New Chat": "Novo Chat",
|
||||
"New Event": "",
|
||||
"New Event": "Novo Evento",
|
||||
"New File": "Novo Arquivo",
|
||||
"New Folder": "Nova Pasta",
|
||||
"New Function": "Nova Função",
|
||||
|
|
@ -1637,7 +1637,7 @@
|
|||
"Reasoning Effort": "Esforço de raciocínio",
|
||||
"Reasoning Tags": "Tags de raciocínio",
|
||||
"Recently Used": "Usado recentemente",
|
||||
"Reconnected": "",
|
||||
"Reconnected": "Reconectado",
|
||||
"Record": "Gravar",
|
||||
"Record voice": "Gravar voz",
|
||||
"Redirecting you to Open WebUI Community": "Redirecionando você para a Comunidade OpenWebUI",
|
||||
|
|
@ -1661,7 +1661,7 @@
|
|||
"Relevance": "Relevância",
|
||||
"Relevance Threshold": "Limiar de Relevância",
|
||||
"Remember Dismissal": "Lembrar da dispensa",
|
||||
"Reminder": "",
|
||||
"Reminder": "Lembrete",
|
||||
"Remove": "Remover",
|
||||
"Remove {{MODELID}} from list.": "Remover {{MODELID}} da lista.",
|
||||
"Remove action": "Remover ação",
|
||||
|
|
@ -1909,12 +1909,12 @@
|
|||
"Start a new conversation": "Iniciar uma nova conversa",
|
||||
"Start of the channel": "Início do canal",
|
||||
"Start Tag": "Tag inicial",
|
||||
"Starting in {{count}} minutes_one": "",
|
||||
"Starting in {{count}} minutes_many": "",
|
||||
"Starting in {{count}} minutes_other": "",
|
||||
"Starting in 1 minute": "",
|
||||
"Starting in {{count}} minutes_one": "Começando em {{count}} minuto",
|
||||
"Starting in {{count}} minutes_many": "Começando em {{count}} minutos",
|
||||
"Starting in {{count}} minutes_other": "Começando em {{count}} minutos",
|
||||
"Starting in 1 minute": "Começando em 1 minuto",
|
||||
"Starting kernel...": "Iniciando kernel...",
|
||||
"Starting now": "",
|
||||
"Starting now": "Começando agora",
|
||||
"State": "Estado",
|
||||
"Status": "Status",
|
||||
"Status cleared successfully": "Status liberado com sucesso",
|
||||
|
|
@ -2027,7 +2027,7 @@
|
|||
"This will delete <strong>{{NAME}}</strong> and <strong>all its contents</strong>.": "Esta ação excluirá <strong>{{NAME}}</strong> e <strong>todos seus conteúdos</strong>.",
|
||||
"This will delete all models including custom models": "Isto vai excluir todos os modelos, incluindo personalizados",
|
||||
"This will delete all models including custom models and cannot be undone.": "Isto vai excluir todos os modelos, incluindo personalizados e não pode ser desfeito.",
|
||||
"This will permanently delete the calendar \"{{name}}\" and all its events. This action cannot be undone.": "",
|
||||
"This will permanently delete the calendar \"{{name}}\" and all its events. This action cannot be undone.": "Esta ação excluirá permanentemente o calendário \"{{name}}\" e todos os seus eventos. Esta ação não pode ser desfeita.",
|
||||
"This will reset the knowledge base and sync all files. Do you wish to continue?": "Esta ação resetará a base de conhecimento e sincronizará todos os arquivos. Deseja continuar?",
|
||||
"Thorough explanation": "Explicação detalhada",
|
||||
"Thought": "Pensamento",
|
||||
|
|
@ -2047,7 +2047,7 @@
|
|||
"Title cannot be an empty string.": "O Título não pode ser uma string vazia.",
|
||||
"Title Generation": "Geração de Títulos",
|
||||
"Title Generation Prompt": "Prompt de Geração de Título",
|
||||
"Title is required": "",
|
||||
"Title is required": "O título é obrigatório",
|
||||
"TLS": "TLS",
|
||||
"To access the available model names for downloading,": "Para acessar os nomes de modelos disponíveis para download,",
|
||||
"To access the GGUF models available for downloading,": "Para acessar os modelos GGUF disponíveis para download,",
|
||||
|
|
@ -2114,7 +2114,7 @@
|
|||
"Unloads {{FROM_NOW}}": "Descarrega {{FROM_NOW}}",
|
||||
"Unlock mysteries": "Desvendar mistérios",
|
||||
"Unpin": "Desfixar",
|
||||
"Unpin from Sidebar": "",
|
||||
"Unpin from Sidebar": "Desfixar da barra lateral",
|
||||
"Unravel secrets": "Desvendar segredos",
|
||||
"Unshare Chat": "Cancelar compartilhamento do chat",
|
||||
"Unsupported file type.": "Tipo de arquivo não suportado.",
|
||||
|
|
@ -2219,7 +2219,7 @@
|
|||
"WebUI will make requests to \"{{url}}\"": "A WebUI fará requisições para \"{{url}}\"",
|
||||
"WebUI will make requests to \"{{url}}/api/chat\"": "A WebUI fará requisições para \"{{url}}/api/chat\".",
|
||||
"WebUI will make requests to \"{{url}}/chat/completions\"": "A WebUI fará requisições para \"{{url}}/chat/completions\".",
|
||||
"Week": "",
|
||||
"Week": "Semana",
|
||||
"Weekly": "Semanal",
|
||||
"What are you trying to achieve?": "O que está tentando alcançar?",
|
||||
"What are you working on?": "No que está trabalhando?",
|
||||
|
|
@ -2227,7 +2227,7 @@
|
|||
"What is shared:": "O que é compartilhado",
|
||||
"What's New in": "O que há de novo em",
|
||||
"What's on your mind?": "O que você tem em mente?",
|
||||
"When": "",
|
||||
"When": "Quando",
|
||||
"When enabled, the model will respond to each chat message in real-time, generating a response as soon as the user sends a message. This mode is useful for live chat applications, but may impact performance on slower hardware.": "Quando habilitado, o modelo responderá a cada mensagem de chat em tempo real, gerando uma resposta assim que o usuário enviar uma mensagem. Este modo é útil para aplicativos de chat ao vivo, mas pode impactar o desempenho em hardware mais lento.",
|
||||
"wherever you are": "onde quer que você esteja.",
|
||||
"Whether to paginate the output. Each page will be separated by a horizontal rule and page number. Defaults to False.": "Se a saída deve ser paginada. Cada página será separada por uma régua horizontal e um número de página. O padrão é Falso.",
|
||||
|
|
|
|||
|
|
@ -774,6 +774,8 @@
|
|||
"Enter prompt here.": "在此输入提示词。",
|
||||
"Enter proxy URL (e.g. https://user:password@host:port)": "输入代理地址(例如:https://用户名:密码@主机名:端口)",
|
||||
"Enter reasoning effort": "输入推理努力",
|
||||
"Enter PaddleOCR-vl API Token": "输入 PaddleOCR-vl 接口密钥",
|
||||
"Enter PaddleOCR-vl API Base URL": "输入 PaddleOCR-vl API 基础地址",
|
||||
"Enter Score": "输入评分",
|
||||
"Enter SearchApi API Key": "输入 SearchApi 接口密钥",
|
||||
"Enter SearchApi Engine": "输入 SearchApi 引擎",
|
||||
|
|
@ -1517,6 +1519,7 @@
|
|||
"Output format": "输出格式",
|
||||
"Output Format": "输出格式",
|
||||
"Overview": "概述",
|
||||
"PaddleOCR-vl": "PaddleOCR-vl",
|
||||
"page": "页",
|
||||
"Page": "页模式",
|
||||
"Page mode creates one document per page. Single mode combines all pages into one document for better chunking across page boundaries.": "页模式将为每个页面创建一个文档;单文档模式则将所有页面合并为一个文档,以便更好地进行跨页分块。",
|
||||
|
|
|
|||
|
|
@ -489,7 +489,7 @@
|
|||
const displayTitle = title || $i18n.t('New Chat');
|
||||
|
||||
if (done) {
|
||||
if ($settings?.notificationSoundAlways ?? false) {
|
||||
if (($settings?.notificationSound ?? true) && ($settings?.notificationSoundAlways ?? false)) {
|
||||
playingNotificationSound.set(true);
|
||||
|
||||
const audio = new Audio(`/audio/notification.mp3`);
|
||||
|
|
|
|||
|
|
@ -4987,10 +4987,10 @@
|
|||
},
|
||||
"pathspec": {
|
||||
"name": "pathspec",
|
||||
"version": "1.0.4",
|
||||
"file_name": "pathspec-1.0.4-py3-none-any.whl",
|
||||
"version": "1.1.0",
|
||||
"file_name": "pathspec-1.1.0-py3-none-any.whl",
|
||||
"install_dir": "site",
|
||||
"sha256": "fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723",
|
||||
"sha256": "574b128f7456bd899045ccd142dd446af7e6cfd0072d63ad73fbc55fbb4aaa42",
|
||||
"package_type": "package",
|
||||
"imports": [
|
||||
"pathspec"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue