Merge branch 'open-webui:dev' into dev

This commit is contained in:
Kevin Rohn 2026-02-24 11:20:26 +01:00 • committed by GitHub
commit c6c2be5276
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
236 changed files with 6137 additions and 1711 deletions

View file

@ -95,6 +95,7 @@ jobs:
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
sbom: true
build-args: |
BUILD_HASH=${{ github.sha }}
@ -199,6 +200,7 @@ jobs:
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
sbom: true
build-args: |
BUILD_HASH=${{ github.sha }}
USE_CUDA=true
@ -304,6 +306,7 @@ jobs:
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
sbom: true
build-args: |
BUILD_HASH=${{ github.sha }}
USE_CUDA=true
@ -407,6 +410,7 @@ jobs:
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
sbom: true
build-args: |
BUILD_HASH=${{ github.sha }}
USE_OLLAMA=true
@ -509,6 +513,7 @@ jobs:
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
sbom: true
build-args: |
BUILD_HASH=${{ github.sha }}
USE_SLIM=true
@ -804,3 +809,109 @@ jobs:
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
# Copy images from GHCR to Docker Hub (best-effort, won't block GHCR)
copy-to-dockerhub:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
needs: [merge-main-images, merge-cuda-images, merge-cuda126-images, merge-ollama-images, merge-slim-images]
continue-on-error: true
strategy:
fail-fast: false
matrix:
include:
- variant: main
suffix: ""
- variant: cuda
suffix: "-cuda"
- variant: cuda126
suffix: "-cuda126"
- variant: ollama
suffix: "-ollama"
- variant: slim
suffix: "-slim"
steps:
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Determine source and destination tags
id: tags
run: |
DOCKERHUB_IMAGE="openwebui/open-webui"
SUFFIX="${{ matrix.suffix }}"
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
# For version tags: copy version tag and major.minor tag
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}"
MAJOR_MINOR="${VERSION%.*}"
echo "tags<<EOF" >> $GITHUB_OUTPUT
echo "${VERSION}${SUFFIX}" >> $GITHUB_OUTPUT
echo "${MAJOR_MINOR}${SUFFIX}" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
else
# For main branch
if [ -z "$SUFFIX" ]; then
echo "tags=latest" >> $GITHUB_OUTPUT
else
# e.g. latest-cuda -> also tag as just "cuda"
VARIANT_NAME="${SUFFIX#-}"
echo "tags<<EOF" >> $GITHUB_OUTPUT
echo "latest${SUFFIX}" >> $GITHUB_OUTPUT
echo "${VARIANT_NAME}" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
fi
fi
echo "dockerhub_image=${DOCKERHUB_IMAGE}" >> $GITHUB_OUTPUT
- name: Copy images from GHCR to Docker Hub
run: |
DOCKERHUB_IMAGE="${{ steps.tags.outputs.dockerhub_image }}"
SUFFIX="${{ matrix.suffix }}"
# Determine the source tag on GHCR
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}"
SOURCE_TAG="${VERSION}${SUFFIX}"
else
if [ -z "$SUFFIX" ]; then
SOURCE_TAG="latest"
else
SOURCE_TAG="latest${SUFFIX}"
fi
fi
SOURCE="${{ env.FULL_IMAGE_NAME }}:${SOURCE_TAG}"
echo "Copying from ${SOURCE} to Docker Hub..."
# Copy each destination tag
while IFS= read -r TAG; do
[ -z "$TAG" ] && continue
DEST="${DOCKERHUB_IMAGE}:${TAG}"
echo " -> ${DEST}"
docker buildx imagetools create -t "${DEST}" "${SOURCE}"
done <<< "${{ steps.tags.outputs.tags }}"

View file

@ -5,6 +5,109 @@ 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.8.5] - 2026-02-23
### Added
- ⌨️ **Voice dictation shortcut.** Users can now toggle voice dictation using Cmd+Shift+L (or Ctrl+Shift+L on Windows/Linux), making it faster to start and stop dictation without clicking the microphone button.
### Fixed
- 🚫 **Model access KeyError fix.** The /api/models endpoint no longer crashes with a 500 error when models have incomplete info metadata missing the user_id field (e.g. models using global default metadata).
- 🔄 **Frontend initialization resilience.** The app layout now gracefully handles individual API failures during initialization (getModels, getBanners, getTools, getUserSettings, setToolServers) instead of blocking the entire page load when any single call fails.
- 🛡️ **Backend config null safety.** Language detection during app initialization no longer crashes when the backend config fetch fails, preventing a secondary cause of infinite loading.
## [0.8.4] - 2026-02-23
### Added
- 🛜 **Provider URL suggestions.** The connection form now displays a dropdown with suggested URLs for popular AI providers, making it easier to configure connections. [Commit](https://github.com/open-webui/open-webui/commit/49c36238d01aaff5466344ecd316a6dd3edd74a3)
- ☁️ **Anthropic model fetching.** The system now properly fetches available models from the Anthropic API, ensuring all Anthropic models are accessible. [Commit](https://github.com/open-webui/open-webui/commit/e9d852545cc17f0eeb8bdcfa77575a80fed8706d)
- 💡 **No models prompt.** When no models are available, a helpful prompt now guides users to manage their provider connections. [Commit](https://github.com/open-webui/open-webui/commit/a0195cd5ae9b9915295839cd0a5fbac5a1b0bfa2)
- ⚙️ **Connection enable/disable toggles.** Individual provider connections can now be enabled or disabled from both admin and user settings. [Commit](https://github.com/open-webui/open-webui/commit/990c638f6cf91507b61898f454c26f9516114c36)
- ⏸️ **Prompt enable/disable toggle.** Users can now enable or disable prompts directly from the prompts list using a toggle switch, without needing to delete and recreate them. Inactive prompts display an "Inactive" badge and are still visible in the list. [Commit](https://github.com/open-webui/open-webui/commit/094ed0b48cb86b9b6aff3c93f522072d11230761)
- 🗑️ **Memory deletion.** Agents can now delete specific memories that are no longer relevant, duplicated, or incorrect, giving better control over stored memory content. [Commit](https://github.com/open-webui/open-webui/commit/094ed0b48cb86b9b6aff3c93f522072d11230761)
- 📋 **Memory listing.** Agents can now list all stored memories, enabling them to identify which memories to manage or delete based on the complete memory inventory. [Commit](https://github.com/open-webui/open-webui/commit/094ed0b48cb86b9b6aff3c93f522072d11230761)
- 📦 **Auto pip install toggle.** Administrators can now disable automatic pip package installation from function frontmatter requirements using the ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS environment variable, providing more control over function dependency management. [Commit](https://github.com/open-webui/open-webui/commit/8bfab327ec5f635f9fe93c26efd198712ff7116d)
- 🔗 **Anthropic Messages API proxy.** A new API endpoint now supports the Anthropic Messages API format, allowing tools like Claude Code to authenticate through Open WebUI and access configured models. Tool calls are now properly supported in streaming responses with correct multi-block indexing, and error status from tools is propagated correctly. The endpoint converts requests to OpenAI format internally, routes them through the existing chat pipeline, and returns responses in Anthropic format. [#21390](https://github.com/open-webui/open-webui/discussions/21390), [Commit](https://github.com/open-webui/open-webui/commit/91a0301c9e22e93295a7c471d83592a802560795), [Commit](https://github.com/open-webui/open-webui/commit/a9312d25373d3aa161788598f87180b8db11c5b6)
- 👥 **Multi-device OAuth sessions.** Users can now stay logged in on multiple devices simultaneously with OAuth, as re-logging in no longer terminates existing sessions. The oldest sessions are automatically pruned when the session limit is exceeded. [#21647](https://github.com/open-webui/open-webui/issues/21647), [Commit](https://github.com/open-webui/open-webui/commit/ae05586fdabf318d551b53ede41575355d3b9e2b)
- 🔐 **OAuth group default share setting.** Administrators can now configure the default sharing setting for OAuth-created groups using the OAUTH_GROUP_DEFAULT_SHARE environment variable, allowing control over whether new groups default to private or shared with members. [#21679](https://github.com/open-webui/open-webui/pull/21679), [Commit](https://github.com/open-webui/open-webui/commit/4b9f821b58007d4efa4aa16a4995b23126e08a88)
- 🔧 **Knowledge base import behavior.** The web content import endpoint now supports a configurable overwrite flag, allowing users to add multiple URLs to the same knowledge base instead of replacing existing content. [#21613](https://github.com/open-webui/open-webui/pull/21613), [#21336](https://github.com/open-webui/open-webui/issues/21336), [Commit](https://github.com/open-webui/open-webui/commit/4bef69cc6344ff809090441aa6bced573a2aa838)
- 🧩 **Skill JSON import support.** Skills can now be imported from both JSON and Markdown files. [#21511](https://github.com/open-webui/open-webui/issues/21511)
- 🔍 **You.com web search provider.** A new web search provider option for You.com is now available, giving users another search engine choice for web-enabled models. The You.com provider enriches search results by including both descriptions and snippets for better context. [#21599](https://github.com/open-webui/open-webui/pull/21599)
- 🚀 **Message list performance.** Loading conversation history when sending messages is now significantly faster, improving response latency before the model starts generating. This also speeds up chat search and RAG context building. [#21588](https://github.com/open-webui/open-webui/pull/21588)
- 🎯 **Concurrent embedding request control.** Administrators can now control the maximum number of concurrent embedding API requests using the RAG_EMBEDDING_CONCURRENT_REQUESTS environment variable, helping manage API rate limits while maintaining embedding performance. [#21662](https://github.com/open-webui/open-webui/pull/21662), [Commit](https://github.com/open-webui/open-webui/commit/5d4547f934b6fbe751bb2041f9597fe11ddf8e43)
- ⚡ **Message upsert optimization.** Loading chat data during message saving is now significantly faster by eliminating a redundant database call that occurred on every message upsert, which happens many times during streaming responses. [#21592](https://github.com/open-webui/open-webui/pull/21592)
- ⚡ **Message send optimization.** Loading chat data during message sending is now significantly faster by eliminating unnecessary full conversation history loads. The system now uses targeted queries that fetch only the needed data instead of loading entire chat objects with all message history. [#21596](https://github.com/open-webui/open-webui/pull/21596)
- 🚀 **Tag filtering optimization.** Chat search with tag filtering now uses more efficient database queries, making filtered searches significantly faster. [Commit](https://github.com/open-webui/open-webui/commit/139f02a9d9fa2ffffcc96aa0de8af8ef51b6bcf2)
- ⚡ **Shared chat loading optimization.** The shared chats endpoint now loads only the needed columns instead of the full conversation history, making shared chat listings significantly faster. [#21614](https://github.com/open-webui/open-webui/pull/21614)
- 🗂️ **Archived and pinned chat loading.** Loading archived and pinned chat lists is now significantly faster by loading only the needed columns instead of full conversation data. [#21591](https://github.com/open-webui/open-webui/pull/21591)
- 💨 **Chat title query optimization.** Retrieving chat titles now queries only the title column instead of the entire conversation history, making title lookups significantly faster and reducing database load. [#21590](https://github.com/open-webui/open-webui/pull/21590)
- 🗄️ **Batch access grants for multiple resources.** Loading channels, knowledge bases, models, notes, prompts, skills, and tools now uses batch database queries for access grants instead of individual queries per item, significantly reducing database load. For 30 items, this reduces approximately 31 queries to just 3. [#21616](https://github.com/open-webui/open-webui/pull/21616)
- 📋 **Notes list payload optimization.** Notes list and search endpoints now return only a 200-character preview instead of the full note content, reducing response payload from ~167 MB to ~10 KB for 60 notes and eliminating N+1 queries for access grants. The Notes tab now loads in seconds instead of tens of seconds. [#21549](https://github.com/open-webui/open-webui/pull/21549)
- ⚡ **Tools list performance.** Loading the tools list is now significantly faster by deferring content and specs fields from database queries, and using cached tool modules instead of reloading them for each request. [Commit](https://github.com/open-webui/open-webui/commit/b48594a16680cc77921a4ed1a11ffa07df7edc60)
- 📝 **Group description display.** The admin groups list now shows each group's description, making it easier for administrators to identify groups at a glance.
- 🏷️ **Sort by dropdown.** Administrators can now sort groups using a dropdown menu with options for Name or Members, replacing the previous clickable column headers.
- 📶 **Admin groups list sorting.** The Group and Users columns in the admin groups list are now clickable for sorting, allowing administrators to sort groups alphabetically by name or numerically by member count. [#21692](https://github.com/open-webui/open-webui/pull/21692)
- 🔽 **Rich UI auto-scroll.** The view now automatically scrolls to action-generated Rich UI content once it renders, ensuring users can see the results without manually scrolling. [#21698](https://github.com/open-webui/open-webui/pull/21698), [#21482](https://github.com/open-webui/open-webui/discussions/21482)
- 📊 **Admin analytics toggle.** Administrators can now enable or disable the analytics feature using the ENABLE_ADMIN_ANALYTICS environment variable, giving more control over available admin features. [#21651](https://github.com/open-webui/open-webui/pull/21651), [Commit](https://github.com/open-webui/open-webui/commit/35598b8017557258b8c9ee3469d320adb0140751)
- 📊 **Analytics sorting enhancement.** The Analytics dashboard now supports sorting by Tokens column for both Model Usage and User Usage tables, and the Share/Percentage columns are now clickable for sorting. Administrators can more easily identify the most token-consuming models and users. [Commit](https://github.com/open-webui/open-webui/commit/053a33631f575ae1ad3123190a9e820b4057f62d)
- 📑 **Fetch URL citation sources.** When models fetch URLs during tool calling, the fetched URLs now appear as clickable citation sources in the UI with content previews, matching the existing behavior of web search and knowledge file tools. [#21669](https://github.com/open-webui/open-webui/pull/21669)
- 🔗 **Admin settings tab navigation.** The admin settings sidebar now supports native browser tab opening, allowing users to middle-click or right-click to open settings pages in new tabs. The navigation was converted from button-based to anchor-based elements. [#21721](https://github.com/open-webui/open-webui/pull/21721)
- 🏷️ **Model visibility badges.** The Admin Settings Models page now displays Public or Private badges directly on each model, making it easy to identify model access levels at a glance without opening the edit screen. [#21732](https://github.com/open-webui/open-webui/issues/21732), [Commit](https://github.com/open-webui/open-webui/commit/29217cb430bd47827ebb20782b264ae7b0f233bb)
- 🛠️ **Global model defaults.** Administrators can now configure default metadata and parameters that automatically apply to all models, reducing manual configuration for newly discovered models. Default capabilities (like vision, web search, code interpreter) and parameters (like temperature, max_tokens) can be set globally in Admin Settings, with per-model overrides still available. [#20658](https://github.com/open-webui/open-webui/issues/20658), [Commit](https://github.com/open-webui/open-webui/commit/c341f97cfe15510b7d128bd84f1e607b5289b957)
- 💬 **Plaintext tool output display.** Tool outputs that are plain strings now display naturally in a monospace block instead of quoted/escaped format, making multi-line string outputs easier to read. [#21553](https://github.com/open-webui/open-webui/issues/21553), [Commit](https://github.com/open-webui/open-webui/commit/3ad2ea6f2839e97e53f00fd797a9e083ff78d88e)
- 🔐 **Event call input masking.** Functions can now request masked password input in confirmation dialogs, allowing sensitive data entry to be hidden from view. This extends the existing masking feature from user valves to event calls. [#21540](https://github.com/open-webui/open-webui/issues/21540), [Commit](https://github.com/open-webui/open-webui/commit/4853ededcabcd76d9bd2036181486cd3a41458a1)
- 🗂️ **JSON logging support.** Administrators can now enable JSON-formatted logging by setting the LOG_FORMAT environment variable to "json", making logs suitable for log aggregators like Loki, Fluentd, CloudWatch, and Datadog. [#21747](https://github.com/open-webui/open-webui/pull/21747)
- ♿ **UI accessibility improvements.** Screen reader users can now navigate the interface more easily with improved keyboard navigation in dialogs and proper ARIA labels on all interactive elements. Added aria-labels to close, back, and action buttons across various components, and improved semantic HTML and screen reader support across auth, sidebar, chat, and notification components, addressing WCAG compliance. Added aria-labels to search inputs, select fields, and modals in admin and user settings, and improved accessibility for text inputs, rating components, citations, and web search results. Added aria-labels to workspace components including Knowledge, Models, Prompts, Skills, and Tools pages for improved screen reader support. [#21706](https://github.com/open-webui/open-webui/pull/21706), [#21705](https://github.com/open-webui/open-webui/pull/21705), [#21710](https://github.com/open-webui/open-webui/pull/21710), [#21709](https://github.com/open-webui/open-webui/pull/21709), [#21717](https://github.com/open-webui/open-webui/pull/21717), [#21715](https://github.com/open-webui/open-webui/pull/21715), [#21708](https://github.com/open-webui/open-webui/pull/21708), [#21719](https://github.com/open-webui/open-webui/pull/21719)
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 Translations for Finnish, French, Portuguese (Brazil), Simplified Chinese, and Traditional Chinese were enhanced and expanded.
### Fixed
- 💥 **Admin functions page crash fix.** The admin Functions tab no longer crashes when clicked, fixing a null reference error that occurred while the functions list was loading. [#21661](https://github.com/open-webui/open-webui/pull/21661), [Commit](https://github.com/open-webui/open-webui/commit/8265422ba0660e7ba2192eb19efd70f8be652748)
- 💀 **Cyclic chat history deadlock fix.** Chat histories with circular parent-child message references no longer cause the backend to freeze when syncing usage stats. The system now detects and safely aborts when encountering cyclic message references. [#21681](https://github.com/open-webui/open-webui/pull/21681)
- 🔀 **Model fallback routing fix.** Custom model fallback now works correctly across all model types, preventing "Model not found" errors when the fallback model uses a different backend (pipe, Ollama, or OpenAI). [#21736](https://github.com/open-webui/open-webui/pull/21736)
- 🐛 **Default model selection fix.** Admin-configured default models are now properly respected when starting new chats instead of being overwritten by the first available model. [#21736](https://github.com/open-webui/open-webui/pull/21736)
- 👁️ **Scroll jumping fix.** Deleting a message pair after stopping generation no longer causes the chat to visually jump around, making message deletion smoother. [#21743](https://github.com/open-webui/open-webui/pull/21743), [Commit](https://github.com/open-webui/open-webui/commit/1f474187a77d2c8a392f00d86f48eb3cb3a18b88)
- 💬 **New chat message handling fix.** Fixed a bug where clicking "New Chat" after sending a message would silently drop subsequent messages. The system now properly clears pending message queues when starting a new conversation. [#21731](https://github.com/open-webui/open-webui/pull/21731)
- 🔍 **RAG template mutation fix.** Fixed a bug where RAG template text was recursively injected into user messages during multiple sequential tool calls, causing message content to grow exponentially and potentially confuse the model. The system now preserves the original user message before tool-calling loops and correctly accumulates citation sources. [#21663](https://github.com/open-webui/open-webui/issues/21663), [#21668](https://github.com/open-webui/open-webui/pull/21668), [Commit](https://github.com/open-webui/open-webui/commit/becac2b2b7af8aacadbfc9b7cee2024cf7ed6acc)
- 🔒 **Iframe sandbox security.** Embedded tools can no longer submit forms or access same-origin content by default, improving security for users. [#21529](https://github.com/open-webui/open-webui/pull/21529)
- 🔐 **Signup race condition fix.** Fixed a security vulnerability where multiple admin accounts could be created on fresh deployments when running multiple uvicorn workers. The signup handler now properly handles concurrent requests during first-user registration, preventing unauthorized admin privilege escalation. [#21631](https://github.com/open-webui/open-webui/pull/21631)
- 🔐 **LDAP optional fields fix.** LDAP configuration now properly accepts empty Application DN and password values, allowing LDAP authentication to work without these optional fields. Previously, empty values caused authentication failures. [Commit](https://github.com/open-webui/open-webui/commit/e1fa42d48a15c8b496a887ecfa32fc01cfd74b36)
- 🛠️ **API tools fix.** The /api/v1/chat/completions endpoint now properly respects caller-provided tools instead of overriding them with server-side tools, fixing issues where external agents like Claude Code or Cursor would receive unexpected tool advertisements. [#21557](https://github.com/open-webui/open-webui/issues/21557), [#21555](https://github.com/open-webui/open-webui/pull/21555)
- ⏱️ **Embeddings and proxy timeout fix.** The embeddings and OpenAI proxy endpoints now properly honor the AIOHTTP_CLIENT_TIMEOUT environment variable, instead of using default timeouts that could cause requests to hang. [#21558](https://github.com/open-webui/open-webui/pull/21558)
- 📄 **Text file type detection fix.** TypeScript and other text files that were mis-detected as video files based on their extension are now correctly identified and processed as text files, fixing upload rejections for .ts files. [#21454](https://github.com/open-webui/open-webui/issues/21454), [Commit](https://github.com/open-webui/open-webui/commit/f651809001ba8e40ba5f416773c1aa6f082a6c46)
- 🗄️ **File access control respect.** The files list and search endpoints now properly respect the BYPASS_ADMIN_ACCESS_CONTROL setting, ensuring admins only see their own files when the setting is disabled, consistent with other endpoints. [#21595](https://github.com/open-webui/open-webui/pull/21595), [#21589](https://github.com/open-webui/open-webui/issues/21589)
- 🗄️ **PostgreSQL workspace cloning.** Cloning workspace models now works correctly on PostgreSQL databases by generating proper unique IDs for access grants instead of using potentially duplicate or invalid IDs. [Commit](https://github.com/open-webui/open-webui/commit/3dd44c4f1931d13bfd46062291c6f23b33dde003)
- 🔓 **MCP SSL verification fix.** MCP tool connections now properly respect the AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL environment variable to disable SSL verification, instead of always verifying SSL certificates. [Commit](https://github.com/open-webui/open-webui/commit/af5661c2c807465f5600899e8c1a421f96cd7a8c), [#21481](https://github.com/open-webui/open-webui/issues/21481)
- 🔒 **Model default feature permissions.** Model default features like code interpreter, web search, and image generation now respect global configuration and user permission settings, preventing disabled features from appearing in the chat input. [#21690](https://github.com/open-webui/open-webui/pull/21690)
- 🔍 **Model selector typing fix.** The model selector list no longer disappears or becomes grayed out when typing quickly in the search field, thanks to improved virtual scroll handling. [#21659](https://github.com/open-webui/open-webui/pull/21659)
- ⛔ **Disabled model cloning prevention.** Disabled models can no longer be cloned as workspace models, preventing invalid empty configurations from being created. The Clone option is now hidden for inactive models. [#21724](https://github.com/open-webui/open-webui/pull/21724)
- 🔧 **SCIM parameter handling.** The SCIM Users and Groups endpoints now accept out-of-range startIndex and count values by clamping them to valid ranges instead of returning errors, in compliance with RFC 7644. [#21577](https://github.com/open-webui/open-webui/pull/21577)
- 🔍 **Hybrid search result fix.** Hybrid search now returns correct results after fixing a bug where query result unpacking order was mismatched, causing search results to appear empty. [#21562](https://github.com/open-webui/open-webui/pull/21562)
- 🛠️ **Imported items display.** Imported functions and tools now appear immediately in the list after import, without requiring a page reload. [#21593](https://github.com/open-webui/open-webui/issues/21593)
- 🔄 **WebSocket race condition fix.** Collaborative note saves no longer crash with errors when users disconnect before pending saves complete, preventing AttributeError exceptions and excessive logging. [#21601](https://github.com/open-webui/open-webui/issues/21601), [Commit](https://github.com/open-webui/open-webui/commit/0a700aafe46dfea2cf9721bb81725d2582b0d781)
- ✋ **Drag-and-drop overlay fix.** The "Add Files" overlay no longer remains stuck on screen when dragging files back out of the chat window in Mozilla Firefox. [#21664](https://github.com/open-webui/open-webui/pull/21664)
- 👁️ **Group search visibility fix.** Groups now appear correctly in access control search results, even when the search doesn't match any users. [#21691](https://github.com/open-webui/open-webui/pull/21691)
- 🖱️ **User menu drag and click fixes.** Fixed draggable ghost images when dragging menu items and eliminated phantom link clicks that occurred when dragging outside dropdown menus. [#21699](https://github.com/open-webui/open-webui/pull/21699)
- 🧭 **Admin and workspace nav drag fix.** Fixed ghost drag images when dragging top navigation tabs in the Admin and Workspace panels by adding proper drag constraints and text selection prevention. [#21701](https://github.com/open-webui/open-webui/pull/21701)
- 🎮 **Playground nav drag fix.** Fixed ghost drag images when dragging top navigation tabs in the Playground panel by adding proper drag constraints and text selection prevention. [#21704](https://github.com/open-webui/open-webui/pull/21704)
- ✋ **Dropdown menu drag fix.** Dropdown menu items can no longer be accidentally dragged as ghost images when highlighting text, making menu interactions smoother. [#21713](https://github.com/open-webui/open-webui/pull/21713)
- 🗂️ **Folder menu drag fix.** Folder dropdown menu items can no longer be accidentally highlighted or dragged as ghost images, making folder options behave like standard menus. [#21753](https://github.com/open-webui/open-webui/pull/21753)
- 📝 **Console log spam fix.** Requesting deleted or missing files no longer floods the backend console with Python traceback logs, thanks to proper exception handling for expected 404 errors. [#21687](https://github.com/open-webui/open-webui/pull/21687)
- 🐛 **Firefox avatar overflow fix.** Fixed a visual bug in Firefox where broken model or user avatar images would display overflowing alt text that overlapped adjacent labels on the Analytics and Leaderboard pages. Failed avatar images now properly show fallback icons instead. [#21730](https://github.com/open-webui/open-webui/pull/21730)
- 🎨 **Dark mode select background fix.** Fixed an issue where select inputs and dropdown menus had inconsistent lighter background colors in dark mode by removing conflicting dark theme overrides, ensuring a cohesive transparent look. [#21728](https://github.com/open-webui/open-webui/pull/21728)
- 💾 **Prompt import fix.** Importing prompts that were previously exported no longer fails with a "[object Object]" error toast, making prompt backup and restore work correctly. [#21594](https://github.com/open-webui/open-webui/issues/21594)
- 🔧 **Ollama reasoning effort fix.** Reasoning effort now works correctly with Ollama models that require string values ("low", "medium", "high") instead of boolean, fixing "invalid option provided" errors when using models like GPT-OSS. [#20921](https://github.com/open-webui/open-webui/issues/20921), [#20928](https://github.com/open-webui/open-webui/pull/20928), [Commit](https://github.com/open-webui/open-webui/commit/30a13b9b2fb2c6da7e1ddbf52edb93a58d09cc56)
- 🔍 **Hybrid search deduplication fix.** Hybrid search now correctly deduplicates results using content hashes, preventing duplicate chunks from appearing when using enriched text for BM25 search. [Commit](https://github.com/open-webui/open-webui/commit/d9fd2a3f30481efa24cc54193bf2f67fd0299b52)
- 📋 **SQLAlchemy warning fix.** Fixed a SQLAlchemy warning that appeared in logs when deleting shared chats, improving log clarity. [Commit](https://github.com/open-webui/open-webui/commit/0185f3340d2778f3b75a8036b0e81a0aec78037f)
### Changed
- 🎯 **Prompt suggestions relocated.** Prompt suggestions have been moved from Admin Panel - Settings - Interface to Admin Panel - Settings - Models, where they can now be configured per-model or globally via the new model defaults.
- 📢 **Banners relocated.** Banners configuration has been moved from Admin Panel - Settings - Interface to Admin Panel - Settings - General.
## [0.8.3] - 2026-02-17
### Added

View file

@ -12,7 +12,7 @@
![Open WebUI Banner](./banner.png)
**Open WebUI is an [extensible](https://docs.openwebui.com/features/plugin/), feature-rich, and user-friendly self-hosted AI platform designed to operate entirely offline.** It supports various LLM runners like **Ollama** and **OpenAI-compatible APIs**, with **built-in inference engine** for RAG, making it a **powerful AI deployment solution**.
**Open WebUI is an [extensible](https://docs.openwebui.com/features/extensibility/plugin), feature-rich, and user-friendly self-hosted AI platform designed to operate entirely offline.** It supports various LLM runners like **Ollama** and **OpenAI-compatible APIs**, with **built-in inference engine** for RAG, making it a **powerful AI deployment solution**.
Passionate about open-source AI? [Join our team →](https://careers.openwebui.com/)
@ -172,7 +172,7 @@ After installation, you can access Open WebUI at [http://localhost:3000](http://
We offer various installation alternatives, including non-Docker native installation methods, Docker Compose, Kustomize, and Helm. Visit our [Open WebUI Documentation](https://docs.openwebui.com/getting-started/) or join our [Discord community](https://discord.gg/5rJgQTnV4s) for comprehensive guidance.
Look at the [Local Development Guide](https://docs.openwebui.com/getting-started/advanced-topics/development) for instructions on setting up a local development environment.
Look at the [Local Development Guide](https://docs.openwebui.com/getting-started/development) for instructions on setting up a local development environment.
### Troubleshooting

View file

@ -1263,6 +1263,18 @@ MODEL_ORDER_LIST = PersistentConfig(
[],
)
DEFAULT_MODEL_METADATA = PersistentConfig(
"DEFAULT_MODEL_METADATA",
"models.default_metadata",
{},
)
DEFAULT_MODEL_PARAMS = PersistentConfig(
"DEFAULT_MODEL_PARAMS",
"models.default_params",
{},
)
DEFAULT_USER_ROLE = PersistentConfig(
"DEFAULT_USER_ROLE",
"ui.default_user_role",
@ -1421,6 +1433,10 @@ USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING = (
== "true"
)
USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS = (
os.environ.get("USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_CONTROLS = (
os.environ.get("USER_PERMISSIONS_CHAT_CONTROLS", "True").lower() == "true"
@ -1442,6 +1458,10 @@ USER_PERMISSIONS_CHAT_FILE_UPLOAD = (
os.environ.get("USER_PERMISSIONS_CHAT_FILE_UPLOAD", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_WEB_UPLOAD = (
os.environ.get("USER_PERMISSIONS_CHAT_WEB_UPLOAD", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_DELETE = (
os.environ.get("USER_PERMISSIONS_CHAT_DELETE", "True").lower() == "true"
)
@ -1574,12 +1594,16 @@ DEFAULT_USER_PERMISSIONS = {
"notes": USER_PERMISSIONS_NOTES_ALLOW_SHARING,
"public_notes": USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING,
},
"access_grants": {
"allow_users": USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS,
},
"chat": {
"controls": USER_PERMISSIONS_CHAT_CONTROLS,
"valves": USER_PERMISSIONS_CHAT_VALVES,
"system_prompt": USER_PERMISSIONS_CHAT_SYSTEM_PROMPT,
"params": USER_PERMISSIONS_CHAT_PARAMS,
"file_upload": USER_PERMISSIONS_CHAT_FILE_UPLOAD,
"web_upload": USER_PERMISSIONS_CHAT_WEB_UPLOAD,
"delete": USER_PERMISSIONS_CHAT_DELETE,
"delete_message": USER_PERMISSIONS_CHAT_DELETE_MESSAGE,
"continue_response": USER_PERMISSIONS_CHAT_CONTINUE_RESPONSE,

View file

@ -5,6 +5,9 @@ import os
import pkgutil
import sys
import shutil
import traceback
from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
from pathlib import Path
from cryptography.hazmat.primitives import serialization
@ -72,9 +75,51 @@ except Exception:
# LOGGING
####################################
_LEVEL_MAP = {
"DEBUG": "debug",
"INFO": "info",
"WARNING": "warn",
"ERROR": "error",
"CRITICAL": "fatal",
}
class JSONFormatter(logging.Formatter):
"""Format log records as single-line JSON objects for structured logging."""
def format(self, record: logging.LogRecord) -> str:
log_entry: dict[str, Any] = {
"ts": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(
timespec="milliseconds"
),
"level": _LEVEL_MAP.get(record.levelname, record.levelname.lower()),
"msg": record.getMessage(),
"caller": record.name,
}
if record.exc_info and record.exc_info[0] is not None:
log_entry["error"] = "".join(
traceback.format_exception(*record.exc_info)
).rstrip()
elif record.exc_text:
log_entry["error"] = record.exc_text
if record.stack_info:
log_entry["stacktrace"] = record.stack_info
return json.dumps(log_entry, ensure_ascii=False, default=str)
LOG_FORMAT = os.environ.get("LOG_FORMAT", "").lower()
GLOBAL_LOG_LEVEL = os.environ.get("GLOBAL_LOG_LEVEL", "").upper()
if GLOBAL_LOG_LEVEL in logging.getLevelNamesMapping():
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL, force=True)
if LOG_FORMAT == "json":
_handler = logging.StreamHandler(sys.stdout)
_handler.setFormatter(JSONFormatter())
logging.basicConfig(handlers=[_handler], level=GLOBAL_LOG_LEVEL, force=True)
else:
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL, force=True)
else:
GLOBAL_LOG_LEVEL = "INFO"

View file

@ -396,6 +396,8 @@ from open_webui.config import (
DEFAULT_PINNED_MODELS,
DEFAULT_ARENA_MODEL,
MODEL_ORDER_LIST,
DEFAULT_MODEL_METADATA,
DEFAULT_MODEL_PARAMS,
EVALUATION_ARENA_MODELS,
# WebUI (OAuth)
ENABLE_OAUTH_ROLE_MANAGEMENT,
@ -503,6 +505,7 @@ from open_webui.env import (
WEBUI_ADMIN_PASSWORD,
WEBUI_ADMIN_NAME,
ENABLE_EASTER_EGGS,
LOG_FORMAT,
)
@ -581,7 +584,8 @@ class SPAStaticFiles(StaticFiles):
raise ex
print(rf"""
if LOG_FORMAT != "json":
print(rf"""
██████╗ ██████╗ ███████╗███╗ ██╗ ██╗ ██╗███████╗██████╗ ██╗ ██╗██╗
██╔═══██╗██╔══██╗██╔════╝████╗ ██║ ██║ ██║██╔════╝██╔══██╗██║ ██║██║
██║ ██║██████╔╝█████╗ ██╔██╗ ██║ ██║ █╗ ██║█████╗ ██████╔╝██║ ██║██║
@ -824,6 +828,8 @@ app.state.config.ADMIN_EMAIL = ADMIN_EMAIL
app.state.config.DEFAULT_MODELS = DEFAULT_MODELS
app.state.config.DEFAULT_PINNED_MODELS = DEFAULT_PINNED_MODELS
app.state.config.MODEL_ORDER_LIST = MODEL_ORDER_LIST
app.state.config.DEFAULT_MODEL_METADATA = DEFAULT_MODEL_METADATA
app.state.config.DEFAULT_MODEL_PARAMS = DEFAULT_MODEL_PARAMS
app.state.config.DEFAULT_PROMPT_SUGGESTIONS = DEFAULT_PROMPT_SUGGESTIONS
@ -1692,9 +1698,18 @@ async def chat_completion(
request.state.direct = True
request.state.model = model
model_info_params = (
model_info.params.model_dump() if model_info and model_info.params else {}
# Model params: global defaults as base, per-model overrides win
default_model_params = (
getattr(request.app.state.config, "DEFAULT_MODEL_PARAMS", None) or {}
)
model_info_params = {
**default_model_params,
**(
model_info.params.model_dump()
if model_info and model_info.params
else {}
),
}
# Check base model existence for custom models
if model_info_params.get("base_model_id"):
@ -1709,8 +1724,13 @@ async def chat_completion(
default_models[0].strip() if default_models[0] else None
)
if fallback_model_id:
request.base_model_id = fallback_model_id
if (
fallback_model_id
and fallback_model_id in request.app.state.MODELS
):
# Update model and form_data so routing uses the fallback model's type
model = request.app.state.MODELS[fallback_model_id]
form_data["model"] = fallback_model_id
else:
raise Exception("Model not found")
else:

View file

@ -1,8 +1,9 @@
import logging
from logging.config import fileConfig
from alembic import context
from open_webui.models.auths import Auth
from open_webui.env import DATABASE_URL, DATABASE_PASSWORD
from open_webui.env import DATABASE_URL, DATABASE_PASSWORD, LOG_FORMAT
from sqlalchemy import engine_from_config, pool, create_engine
# this is the Alembic Config object, which provides
@ -14,6 +15,13 @@ config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name, disable_existing_loggers=False)
# Re-apply JSON formatter after fileConfig replaces handlers.
if LOG_FORMAT == "json":
from open_webui.env import JSONFormatter
for handler in logging.root.handlers:
handler.setFormatter(JSONFormatter())
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel

View file

@ -204,6 +204,33 @@ def has_public_read_access_grant(access_grants: Optional[list]) -> bool:
return False
def has_user_access_grant(access_grants: Optional[list]) -> bool:
"""
Returns True when a direct grant list includes any non-wildcard user grant.
"""
for grant in normalize_access_grants(access_grants):
if grant["principal_type"] == "user" and grant["principal_id"] != "*":
return True
return False
def strip_user_access_grants(access_grants: Optional[list]) -> list:
"""
Remove all non-wildcard user grants from the list.
Keeps group grants and the public wildcard (user:*) intact.
"""
if not access_grants:
return []
return [
grant
for grant in access_grants
if not (
(grant.get("principal_type") if isinstance(grant, dict) else getattr(grant, "principal_type", None)) == "user"
and (grant.get("principal_id") if isinstance(grant, dict) else getattr(grant, "principal_id", None)) != "*"
)
]
def grants_to_access_control(grants: list) -> Optional[dict]:
"""
Convert a list of grant objects (AccessGrantModel or AccessGrantResponse)

View file

@ -631,7 +631,9 @@ class ChatTable:
with get_db_context(db) as db:
# Use subquery to delete chat_messages for shared chats
shared_chat_id_subquery = (
db.query(Chat.id).filter_by(user_id=f"shared-{chat_id}").subquery()
db.query(Chat.id)
.filter_by(user_id=f"shared-{chat_id}")
.scalar_subquery()
)
db.query(ChatMessage).filter(
ChatMessage.chat_id.in_(shared_chat_id_subquery)

View file

@ -135,6 +135,7 @@ class OAuthSessionTable:
db.refresh(result)
if result:
db.expunge(result) # Detach so dict swap is never flushed
result.token = token # Return decrypted token
return OAuthSessionModel.model_validate(result)
else:

View file

@ -190,7 +190,11 @@ class ToolsTable:
return tools
def get_tools_by_user_id(
self, user_id: str, permission: str = "write", defer_content: bool = False, db: Optional[Session] = None
self,
user_id: str,
permission: str = "write",
defer_content: bool = False,
db: Optional[Session] = None,
) -> list[ToolUserModel]:
tools = self.get_tools(defer_content=defer_content, db=db)
user_group_ids = {

View file

@ -88,6 +88,14 @@ def get_content_from_url(request, url: str) -> str:
return content, docs
CHUNK_HASH_KEY = "_chunk_hash"
def _content_hash(text: str) -> str:
"""SHA-256 hash of text, used as a stable chunk identifier for RRF dedup."""
return hashlib.sha256(text.encode()).hexdigest()
class VectorSearchRetriever(BaseRetriever):
collection_name: Any
embedding_function: Any
@ -126,9 +134,11 @@ class VectorSearchRetriever(BaseRetriever):
results = []
for idx in range(len(ids)):
metadata = metadatas[idx]
metadata[CHUNK_HASH_KEY] = _content_hash(documents[idx])
results.append(
Document(
metadata=metadatas[idx],
metadata=metadata,
page_content=documents[idx],
)
)
@ -240,15 +250,21 @@ async def query_doc_with_hybrid_search(
log.debug(f"query_doc_with_hybrid_search:doc {collection_name}")
original_texts = collection_result.documents[0]
bm25_metadatas = [
{**meta, CHUNK_HASH_KEY: _content_hash(original_texts[idx])}
for idx, meta in enumerate(collection_result.metadatas[0])
]
bm25_texts = (
get_enriched_texts(collection_result)
if enable_enriched_texts
else collection_result.documents[0]
else original_texts
)
bm25_retriever = BM25Retriever.from_texts(
texts=bm25_texts,
metadatas=collection_result.metadatas[0],
metadatas=bm25_metadatas,
)
bm25_retriever.k = k
@ -258,18 +274,24 @@ async def query_doc_with_hybrid_search(
top_k=k,
)
# Use CHUNK_HASH_KEY for dedup so enriched BM25 texts don't defeat RRF
if hybrid_bm25_weight <= 0:
ensemble_retriever = EnsembleRetriever(
retrievers=[vector_search_retriever], weights=[1.0]
retrievers=[vector_search_retriever],
weights=[1.0],
id_key=CHUNK_HASH_KEY,
)
elif hybrid_bm25_weight >= 1:
ensemble_retriever = EnsembleRetriever(
retrievers=[bm25_retriever], weights=[1.0]
retrievers=[bm25_retriever],
weights=[1.0],
id_key=CHUNK_HASH_KEY,
)
else:
ensemble_retriever = EnsembleRetriever(
retrievers=[bm25_retriever, vector_search_retriever],
weights=[hybrid_bm25_weight, 1.0 - hybrid_bm25_weight],
id_key=CHUNK_HASH_KEY,
)
compressor = RerankCompressor(

View file

@ -1157,8 +1157,6 @@ async def update_ldap_server(
"host",
"attribute_for_mail",
"attribute_for_username",
"app_dn",
"app_dn_password",
"search_base",
]
for key in required_fields:
@ -1173,8 +1171,8 @@ async def update_ldap_server(
request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME = (
form_data.attribute_for_username
)
request.app.state.config.LDAP_APP_DN = form_data.app_dn
request.app.state.config.LDAP_APP_PASSWORD = form_data.app_dn_password
request.app.state.config.LDAP_APP_DN = form_data.app_dn or ""
request.app.state.config.LDAP_APP_PASSWORD = form_data.app_dn_password or ""
request.app.state.config.LDAP_SEARCH_BASE = form_data.search_base
request.app.state.config.LDAP_SEARCH_FILTERS = form_data.search_filters
request.app.state.config.LDAP_USE_TLS = form_data.use_tls

View file

@ -1134,7 +1134,7 @@ async def delete_chat_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
chat = Chats.get_chat_by_id(id, db=db)
chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
if not chat:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,

View file

@ -467,6 +467,8 @@ class ModelsConfigForm(BaseModel):
DEFAULT_MODELS: Optional[str]
DEFAULT_PINNED_MODELS: Optional[str]
MODEL_ORDER_LIST: Optional[list[str]]
DEFAULT_MODEL_METADATA: Optional[dict] = None
DEFAULT_MODEL_PARAMS: Optional[dict] = None
@router.get("/models", response_model=ModelsConfigForm)
@ -475,6 +477,8 @@ async def get_models_config(request: Request, user=Depends(get_admin_user)):
"DEFAULT_MODELS": request.app.state.config.DEFAULT_MODELS,
"DEFAULT_PINNED_MODELS": request.app.state.config.DEFAULT_PINNED_MODELS,
"MODEL_ORDER_LIST": request.app.state.config.MODEL_ORDER_LIST,
"DEFAULT_MODEL_METADATA": request.app.state.config.DEFAULT_MODEL_METADATA,
"DEFAULT_MODEL_PARAMS": request.app.state.config.DEFAULT_MODEL_PARAMS,
}
@ -485,10 +489,14 @@ async def set_models_config(
request.app.state.config.DEFAULT_MODELS = form_data.DEFAULT_MODELS
request.app.state.config.DEFAULT_PINNED_MODELS = form_data.DEFAULT_PINNED_MODELS
request.app.state.config.MODEL_ORDER_LIST = form_data.MODEL_ORDER_LIST
request.app.state.config.DEFAULT_MODEL_METADATA = form_data.DEFAULT_MODEL_METADATA
request.app.state.config.DEFAULT_MODEL_PARAMS = form_data.DEFAULT_MODEL_PARAMS
return {
"DEFAULT_MODELS": request.app.state.config.DEFAULT_MODELS,
"DEFAULT_PINNED_MODELS": request.app.state.config.DEFAULT_PINNED_MODELS,
"MODEL_ORDER_LIST": request.app.state.config.MODEL_ORDER_LIST,
"DEFAULT_MODEL_METADATA": request.app.state.config.DEFAULT_MODEL_METADATA,
"DEFAULT_MODEL_PARAMS": request.app.state.config.DEFAULT_MODEL_PARAMS,
}

View file

@ -121,6 +121,27 @@ def has_access_to_file(
############################
def _is_text_file(file_path: str, chunk_size: int = 8192) -> bool:
"""Check if a file is likely a text file by reading a chunk and validating UTF-8.
This catches files whose extensions are mis-mapped by mimetypes/browsers
(e.g. TypeScript .ts → video/mp2t) without maintaining an extension whitelist.
"""
try:
resolved = Storage.get_file(file_path)
with open(resolved, "rb") as f:
chunk = f.read(chunk_size)
if not chunk:
return False
# Null bytes are a strong indicator of binary content
if b"\x00" in chunk:
return False
chunk.decode("utf-8")
return True
except (UnicodeDecodeError, Exception):
return False
def process_uploaded_file(
request,
file,
@ -132,14 +153,19 @@ def process_uploaded_file(
):
def _process_handler(db_session):
try:
if file.content_type:
content_type = file.content_type
# Detect mis-labeled text files (e.g. .ts → video/mp2t)
if content_type and content_type.startswith(("image/", "video/")):
if _is_text_file(file_path):
content_type = "text/plain"
if content_type:
stt_supported_content_types = getattr(
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
)
if strict_match_mime_type(
stt_supported_content_types, file.content_type
):
if strict_match_mime_type(stt_supported_content_types, content_type):
file_path_processed = Storage.get_file(file_path)
result = transcribe(
request, file_path_processed, file_metadata, user
@ -153,7 +179,7 @@ def process_uploaded_file(
user=user,
db=db_session,
)
elif (not file.content_type.startswith(("image/", "video/"))) or (
elif (not content_type.startswith(("image/", "video/"))) or (
request.app.state.config.CONTENT_EXTRACTION_ENGINE == "external"
):
process_file(
@ -164,7 +190,7 @@ def process_uploaded_file(
)
else:
raise Exception(
f"File type {file.content_type} is not supported for processing"
f"File type {content_type} is not supported for processing"
)
else:
log.info(

View file

@ -641,7 +641,10 @@ async def image_generations(
for image in res["data"]:
if image_url := image.get("url", None):
image_data, content_type = get_image_data(image_url, headers)
image_data, content_type = get_image_data(
image_url,
{k: v for k, v in headers.items() if k != "Content-Type"},
)
else:
image_data, content_type = get_image_data(image["b64_json"])
@ -993,7 +996,10 @@ async def image_edits(
images = []
for image in res["data"]:
if image_url := image.get("url", None):
image_data, content_type = get_image_data(image_url, headers)
image_data, content_type = get_image_data(
image_url,
{k: v for k, v in headers.items() if k != "Content-Type"},
)
else:
image_data, content_type = get_image_data(image["b64_json"])

View file

@ -29,8 +29,8 @@ from open_webui.storage.provider import Storage
from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.auth import get_verified_user, get_admin_user
from open_webui.utils.access_control import has_permission
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
from open_webui.models.access_grants import AccessGrants
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
@ -251,7 +251,7 @@ async def create_new_knowledge(
user=Depends(get_verified_user),
):
# NOTE: We intentionally do NOT use Depends(get_session) here.
# Database operations (has_permission, insert_new_knowledge) manage their own sessions.
# Database operations (has_permission, filter_allowed_access_grants, insert_new_knowledge) manage their own sessions.
# This prevents holding a connection during embed_knowledge_base_metadata()
# which makes external embedding API calls (1-5+ seconds).
if user.role != "admin" and not has_permission(
@ -262,17 +262,13 @@ async def create_new_knowledge(
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
# Check if user can share publicly
if (
user.role != "admin"
and has_public_read_access_grant(form_data.access_grants)
and not has_permission(
user.id,
"sharing.public_knowledge",
request.app.state.config.USER_PERMISSIONS,
)
):
form_data.access_grants = []
form_data.access_grants = filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
"sharing.public_knowledge",
)
knowledge = Knowledges.insert_new_knowledge(user.id, form_data)
@ -482,17 +478,13 @@ async def update_knowledge_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
# Check if user can share publicly
if (
user.role != "admin"
and has_public_read_access_grant(form_data.access_grants)
and not has_permission(
user.id,
"sharing.public_knowledge",
request.app.state.config.USER_PERMISSIONS,
)
):
form_data.access_grants = []
form_data.access_grants = filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
"sharing.public_knowledge",
)
knowledge = Knowledges.update_knowledge_by_id(id=id, form_data=form_data)
if knowledge:
@ -554,24 +546,13 @@ async def update_knowledge_access_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
# Strip public sharing if user lacks permission
if (
user.role != "admin"
and has_public_read_access_grant(form_data.access_grants)
and not has_permission(
user.id,
"sharing.public_knowledge",
request.app.state.config.USER_PERMISSIONS,
)
):
form_data.access_grants = [
grant
for grant in form_data.access_grants
if not (
grant.get("principal_type") == "user"
and grant.get("principal_id") == "*"
)
]
form_data.access_grants = filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
"sharing.public_knowledge"
)
AccessGrants.set_access_grants("knowledge", id, form_data.access_grants, db=db)

View file

@ -17,7 +17,7 @@ from open_webui.models.models import (
ModelAccessResponse,
Models,
)
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
from open_webui.models.access_grants import AccessGrants
from pydantic import BaseModel
from open_webui.constants import ERROR_MESSAGES
@ -33,7 +33,7 @@ from fastapi.responses import FileResponse, StreamingResponse
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_permission
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, STATIC_DIR
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
@ -512,6 +512,7 @@ async def update_model_by_id(
class ModelAccessGrantsForm(BaseModel):
id: str
name: Optional[str] = None
access_grants: list[dict]
@ -535,7 +536,7 @@ async def update_model_access_by_id(
model = Models.insert_new_model(
ModelForm(
id=form_data.id,
name=form_data.id,
name=form_data.name or form_data.id,
meta=ModelMeta(),
params=ModelParams(),
),
@ -564,24 +565,13 @@ async def update_model_access_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
# Strip public sharing if user lacks permission
if (
user.role != "admin"
and has_public_read_access_grant(form_data.access_grants)
and not has_permission(
user.id,
"sharing.public_models",
request.app.state.config.USER_PERMISSIONS,
)
):
form_data.access_grants = [
grant
for grant in form_data.access_grants
if not (
grant.get("principal_type") == "user"
and grant.get("principal_id") == "*"
)
]
form_data.access_grants = filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
"sharing.public_models"
)
AccessGrants.set_access_grants(
"model", form_data.id, form_data.access_grants, db=db

View file

@ -27,8 +27,8 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_permission
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
from open_webui.models.access_grants import AccessGrants
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
@ -283,18 +283,14 @@ async def update_note_by_id(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
# Check if user can share publicly
if (
user.role != "admin"
and has_public_read_access_grant(form_data.access_grants)
and not has_permission(
user.id,
"sharing.public_notes",
request.app.state.config.USER_PERMISSIONS,
db=db,
)
):
form_data.access_grants = []
form_data.access_grants = filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
"sharing.public_notes",
db=db,
)
try:
note = Notes.update_note_by_id(id, form_data, db=db)
@ -357,24 +353,13 @@ async def update_note_access_by_id(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
# Strip public sharing if user lacks permission
if (
user.role != "admin"
and has_public_read_access_grant(form_data.access_grants)
and not has_permission(
user.id,
"sharing.public_notes",
request.app.state.config.USER_PERMISSIONS,
)
):
form_data.access_grants = [
grant
for grant in form_data.access_grants
if not (
grant.get("principal_type") == "user"
and grant.get("principal_id") == "*"
)
]
form_data.access_grants = filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
"sharing.public_notes"
)
AccessGrants.set_access_grants("note", id, form_data.access_grants, db=db)

View file

@ -9,7 +9,7 @@ from open_webui.models.prompts import (
PromptModel,
Prompts,
)
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
from open_webui.models.access_grants import AccessGrants
from open_webui.models.groups import Groups
from open_webui.models.prompt_history import (
PromptHistories,
@ -18,7 +18,7 @@ from open_webui.models.prompt_history import (
)
from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_permission
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
@ -473,24 +473,13 @@ async def update_prompt_access_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
# Strip public sharing if user lacks permission
if (
user.role != "admin"
and has_public_read_access_grant(form_data.access_grants)
and not has_permission(
user.id,
"sharing.public_prompts",
request.app.state.config.USER_PERMISSIONS,
)
):
form_data.access_grants = [
grant
for grant in form_data.access_grants
if not (
grant.get("principal_type") == "user"
and grant.get("principal_id") == "*"
)
]
form_data.access_grants = filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
"sharing.public_prompts"
)
AccessGrants.set_access_grants("prompt", prompt_id, form_data.access_grants, db=db)

View file

@ -17,7 +17,7 @@ from open_webui.models.skills import (
SkillAccessListResponse,
Skills,
)
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
from open_webui.models.access_grants import AccessGrants
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission
@ -341,24 +341,13 @@ async def update_skill_access_by_id(
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
# Strip public sharing if user lacks permission
if (
user.role != "admin"
and has_public_read_access_grant(form_data.access_grants)
and not has_permission(
user.id,
"sharing.public_skills",
request.app.state.config.USER_PERMISSIONS,
)
):
form_data.access_grants = [
grant
for grant in form_data.access_grants
if not (
grant.get("principal_type") == "user"
and grant.get("principal_id") == "*"
)
]
form_data.access_grants = filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
"sharing.public_skills"
)
AccessGrants.set_access_grants("skill", id, form_data.access_grants, db=db)

View file

@ -21,7 +21,7 @@ from open_webui.models.tools import (
ToolAccessResponse,
Tools,
)
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
from open_webui.models.access_grants import AccessGrants
from open_webui.utils.plugin import (
load_tool_module_by_id,
replace_imports,
@ -65,12 +65,18 @@ async def get_tools(
# Local Tools
for tool in Tools.get_tools(defer_content=True, db=db):
tool_module = request.app.state.TOOLS.get(tool.id) if hasattr(request.app.state, 'TOOLS') else None
tool_module = (
request.app.state.TOOLS.get(tool.id)
if hasattr(request.app.state, "TOOLS")
else None
)
tools.append(
ToolUserResponse(
**{
**tool.model_dump(),
"has_user_valves": hasattr(tool_module, "UserValves") if tool_module else False,
"has_user_valves": (
hasattr(tool_module, "UserValves") if tool_module else False
),
}
)
)
@ -212,8 +218,13 @@ async def get_tool_list(
or any(
g.permission == "write"
and (
(g.principal_type == "user" and (g.principal_id == user.id or g.principal_id == "*"))
or (g.principal_type == "group" and g.principal_id in user_group_ids)
(
g.principal_type == "user"
and (g.principal_id == user.id or g.principal_id == "*")
)
or (
g.principal_type == "group" and g.principal_id in user_group_ids
)
)
for g in tool.access_grants
)
@ -565,24 +576,13 @@ async def update_tool_access_by_id(
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
# Strip public sharing if user lacks permission
if (
user.role != "admin"
and has_public_read_access_grant(form_data.access_grants)
and not has_permission(
user.id,
"sharing.public_tools",
request.app.state.config.USER_PERMISSIONS,
)
):
form_data.access_grants = [
grant
for grant in form_data.access_grants
if not (
grant.get("principal_type") == "user"
and grant.get("principal_id") == "*"
)
]
form_data.access_grants = filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
"sharing.public_tools"
)
AccessGrants.set_access_grants("tool", id, form_data.access_grants, db=db)

View file

@ -196,12 +196,17 @@ class SharingPermissions(BaseModel):
public_notes: bool = True
class AccessGrantsPermissions(BaseModel):
allow_users: bool = True
class ChatPermissions(BaseModel):
controls: bool = True
valves: bool = True
system_prompt: bool = True
params: bool = True
file_upload: bool = True
web_upload: bool = True
delete: bool = True
delete_message: bool = True
continue_response: bool = True
@ -238,6 +243,7 @@ class SettingsPermissions(BaseModel):
class UserPermissions(BaseModel):
workspace: WorkspacePermissions
sharing: SharingPermissions
access_grants: AccessGrantsPermissions
chat: ChatPermissions
features: FeaturesPermissions
settings: SettingsPermissions
@ -252,6 +258,9 @@ async def get_default_user_permissions(request: Request, user=Depends(get_admin_
"sharing": SharingPermissions(
**request.app.state.config.USER_PERMISSIONS.get("sharing", {})
),
"access_grants": AccessGrantsPermissions(
**request.app.state.config.USER_PERMISSIONS.get("access_grants", {})
),
"chat": ChatPermissions(
**request.app.state.config.USER_PERMISSIONS.get("chat", {})
),

View file

@ -194,3 +194,53 @@ def migrate_access_control(
data[grants_key] = grants
data.pop(ac_key, None)
from open_webui.models.access_grants import (
has_public_read_access_grant,
has_user_access_grant,
strip_user_access_grants,
)
def filter_allowed_access_grants(
default_permissions: Dict[str, Any],
user_id: str,
user_role: str,
access_grants: list,
public_permission_key: str,
db: Optional[Any] = None,
) -> list:
"""
Checks if the user has the required permissions to grant access to a resource.
Returns the filtered list of access grants if permissions are missing.
"""
if user_role == "admin" or not access_grants:
return access_grants
# Check if user can share publicly
if has_public_read_access_grant(access_grants) and not has_permission(
user_id,
public_permission_key,
default_permissions,
db=db,
):
access_grants = [
grant
for grant in access_grants
if not (
(grant.get("principal_type") if isinstance(grant, dict) else getattr(grant, "principal_type", None)) == "user"
and (grant.get("principal_id") if isinstance(grant, dict) else getattr(grant, "principal_id", None)) == "*"
)
]
# Strip individual user sharing if user lacks permission
if has_user_access_grant(access_grants) and not has_permission(
user_id,
"access_grants.allow_users",
default_permissions,
db=db,
):
access_grants = strip_user_access_grants(access_grants)
return access_grants

View file

@ -12,13 +12,15 @@ from open_webui.env import (
AUDIT_LOG_FILE_ROTATION_SIZE,
AUDIT_LOG_LEVEL,
GLOBAL_LOG_LEVEL,
LOG_FORMAT,
AUDIT_UVICORN_LOGGER_NAMES,
ENABLE_OTEL,
ENABLE_OTEL_LOGS,
_LEVEL_MAP,
)
if TYPE_CHECKING:
from loguru import Record
from loguru import Message, Record
def stdout_format(record: "Record") -> str:
@ -43,6 +45,29 @@ def stdout_format(record: "Record") -> str:
)
def _json_sink(message: "Message") -> None:
"""Write log records as single-line JSON to stdout.
Used as a Loguru sink when LOG_FORMAT is set to "json".
"""
record = message.record
log_entry = {
"ts": record["time"].strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z",
"level": _LEVEL_MAP.get(record["level"].name, record["level"].name.lower()),
"msg": record["message"],
"caller": f"{record['name']}:{record['function']}:{record['line']}",
}
if record["extra"]:
log_entry["extra"] = record["extra"]
if record["exception"] is not None:
log_entry["error"] = "".join(record["exception"].format_exception()).rstrip()
sys.stdout.write(json.dumps(log_entry, ensure_ascii=False, default=str) + "\n")
sys.stdout.flush()
class InterceptHandler(logging.Handler):
"""
Intercepts log records from Python's standard logging module
@ -127,14 +152,22 @@ def start_logger():
"""
logger.remove()
logger.add(
sys.stdout,
level=GLOBAL_LOG_LEVEL,
format=stdout_format,
filter=lambda record: (
"auditable" not in record["extra"] if ENABLE_AUDIT_STDOUT else True
),
audit_filter = lambda record: (
True if ENABLE_AUDIT_STDOUT else "auditable" not in record["extra"]
)
if LOG_FORMAT == "json":
logger.add(
_json_sink,
level=GLOBAL_LOG_LEVEL,
filter=audit_filter,
)
else:
logger.add(
sys.stdout,
level=GLOBAL_LOG_LEVEL,
format=stdout_format,
filter=audit_filter,
)
if AUDIT_LOG_LEVEL != "NONE" and ENABLE_AUDIT_LOGS_FILE:
try:
logger.add(

View file

@ -86,6 +86,7 @@ from open_webui.utils.misc import (
get_message_list,
add_or_update_system_message,
add_or_update_user_message,
set_last_user_message_content,
get_last_user_message,
get_last_user_message_item,
get_last_assistant_message,
@ -827,10 +828,15 @@ def apply_source_context_to_messages(
messages: list,
sources: list,
user_message: str,
include_content: bool = True,
) -> list:
"""
Build source context from citation sources and apply to messages.
Uses RAG template to format context for model consumption.
When include_content is False, emit <source> tags with id/name but no
document body — useful when the content is already present elsewhere
(e.g. in a tool result message) and only citation markers are needed.
"""
if not sources or not user_message:
return messages
@ -844,10 +850,11 @@ def apply_source_context_to_messages(
if src_id not in citation_idx:
citation_idx[src_id] = len(citation_idx) + 1
src_name = source.get("source", {}).get("name")
body = doc if include_content else ""
context_string += (
f'<source id="{citation_idx[src_id]}"'
+ (f' name="{src_name}"' if src_name else "")
+ f">{doc}</source>\n"
+ f">{body}</source>\n"
)
context_string = context_string.strip()
@ -2220,6 +2227,10 @@ async def process_chat_payload(request, form_data, user, metadata, model):
tool_ids = form_data.pop("tool_ids", None)
files = form_data.pop("files", None)
# Caller-provided OpenAI-style tools take precedence over server-side
# tool resolution (tool_ids, MCP servers, builtin tools).
payload_tools = form_data.get("tools", None)
# Skills
user_skill_ids = set(form_data.pop("skill_ids", None) or [])
model_skill_ids = set(model.get("info", {}).get("meta", {}).get("skillIds", []))
@ -2291,238 +2302,244 @@ async def process_chat_payload(request, form_data, user, metadata, model):
}
form_data["metadata"] = metadata
# Server side tools
tool_ids = metadata.get("tool_ids", None)
# Client side tools
direct_tool_servers = metadata.get("tool_servers", None)
# When the caller provides an explicit OpenAI-style `tools` array in the
# request body, skip all server-side tool resolution and pass the caller's
# tools through to the model unchanged.
if not payload_tools:
# Server side tools
tool_ids = metadata.get("tool_ids", None)
# Client side tools
direct_tool_servers = metadata.get("tool_servers", None)
log.debug(f"{tool_ids=}")
log.debug(f"{direct_tool_servers=}")
log.debug(f"{tool_ids=}")
log.debug(f"{direct_tool_servers=}")
tools_dict = {}
tools_dict = {}
mcp_clients = {}
mcp_tools_dict = {}
mcp_clients = {}
mcp_tools_dict = {}
if tool_ids:
for tool_id in tool_ids:
if tool_id.startswith("server:mcp:"):
try:
server_id = tool_id[len("server:mcp:") :]
if tool_ids:
for tool_id in tool_ids:
if tool_id.startswith("server:mcp:"):
try:
server_id = tool_id[len("server:mcp:") :]
mcp_server_connection = None
for (
server_connection
) in request.app.state.config.TOOL_SERVER_CONNECTIONS:
if (
server_connection.get("type", "") == "mcp"
and server_connection.get("info", {}).get("id") == server_id
):
mcp_server_connection = server_connection
break
mcp_server_connection = None
for (
server_connection
) in request.app.state.config.TOOL_SERVER_CONNECTIONS:
if (
server_connection.get("type", "") == "mcp"
and server_connection.get("info", {}).get("id")
== server_id
):
mcp_server_connection = server_connection
break
if not mcp_server_connection:
log.error(f"MCP server with id {server_id} not found")
continue
if not mcp_server_connection:
log.error(f"MCP server with id {server_id} not found")
continue
# Check access control for MCP server
if not has_tool_server_access(user, mcp_server_connection):
log.warning(
f"Access denied to MCP server {server_id} for user {user.id}"
)
continue
# Check access control for MCP server
if not has_tool_server_access(user, mcp_server_connection):
log.warning(
f"Access denied to MCP server {server_id} for user {user.id}"
)
continue
auth_type = mcp_server_connection.get("auth_type", "")
headers = {}
if auth_type == "bearer":
headers["Authorization"] = (
f"Bearer {mcp_server_connection.get('key', '')}"
)
elif auth_type == "none":
# No authentication
pass
elif auth_type == "session":
headers["Authorization"] = (
f"Bearer {request.state.token.credentials}"
)
elif auth_type == "system_oauth":
oauth_token = extra_params.get("__oauth_token__", None)
if oauth_token:
auth_type = mcp_server_connection.get("auth_type", "")
headers = {}
if auth_type == "bearer":
headers["Authorization"] = (
f"Bearer {oauth_token.get('access_token', '')}"
f"Bearer {mcp_server_connection.get('key', '')}"
)
elif auth_type == "oauth_2.1":
try:
splits = server_id.split(":")
server_id = splits[-1] if len(splits) > 1 else server_id
oauth_token = await request.app.state.oauth_client_manager.get_oauth_token(
user.id, f"mcp:{server_id}"
elif auth_type == "none":
# No authentication
pass
elif auth_type == "session":
headers["Authorization"] = (
f"Bearer {request.state.token.credentials}"
)
elif auth_type == "system_oauth":
oauth_token = extra_params.get("__oauth_token__", None)
if oauth_token:
headers["Authorization"] = (
f"Bearer {oauth_token.get('access_token', '')}"
)
except Exception as e:
log.error(f"Error getting OAuth token: {e}")
oauth_token = None
elif auth_type == "oauth_2.1":
try:
splits = server_id.split(":")
server_id = splits[-1] if len(splits) > 1 else server_id
connection_headers = mcp_server_connection.get("headers", None)
if connection_headers and isinstance(connection_headers, dict):
for key, value in connection_headers.items():
headers[key] = value
# Add user info headers if enabled
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
if metadata and metadata.get("chat_id"):
headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = metadata.get(
"chat_id"
)
if metadata and metadata.get("message_id"):
headers[FORWARD_SESSION_INFO_HEADER_MESSAGE_ID] = (
metadata.get("message_id")
)
mcp_clients[server_id] = MCPClient()
await mcp_clients[server_id].connect(
url=mcp_server_connection.get("url", ""),
headers=headers if headers else None,
)
function_name_filter_list = mcp_server_connection.get(
"config", {}
).get("function_name_filter_list", "")
if isinstance(function_name_filter_list, str):
function_name_filter_list = function_name_filter_list.split(",")
tool_specs = await mcp_clients[server_id].list_tool_specs()
for tool_spec in tool_specs:
def make_tool_function(client, function_name):
async def tool_function(**kwargs):
return await client.call_tool(
function_name,
function_args=kwargs,
oauth_token = await request.app.state.oauth_client_manager.get_oauth_token(
user.id, f"mcp:{server_id}"
)
return tool_function
if oauth_token:
headers["Authorization"] = (
f"Bearer {oauth_token.get('access_token', '')}"
)
except Exception as e:
log.error(f"Error getting OAuth token: {e}")
oauth_token = None
if function_name_filter_list:
if not is_string_allowed(
tool_spec["name"], function_name_filter_list
):
# Skip this function
continue
connection_headers = mcp_server_connection.get("headers", None)
if connection_headers and isinstance(connection_headers, dict):
for key, value in connection_headers.items():
headers[key] = value
tool_function = make_tool_function(
mcp_clients[server_id], tool_spec["name"]
# Add user info headers if enabled
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
if metadata and metadata.get("chat_id"):
headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = (
metadata.get("chat_id")
)
if metadata and metadata.get("message_id"):
headers[FORWARD_SESSION_INFO_HEADER_MESSAGE_ID] = (
metadata.get("message_id")
)
mcp_clients[server_id] = MCPClient()
await mcp_clients[server_id].connect(
url=mcp_server_connection.get("url", ""),
headers=headers if headers else None,
)
mcp_tools_dict[f"{server_id}_{tool_spec['name']}"] = {
"spec": {
**tool_spec,
"name": f"{server_id}_{tool_spec['name']}",
},
"callable": tool_function,
"type": "mcp",
"client": mcp_clients[server_id],
"direct": False,
}
except Exception as e:
log.debug(e)
if event_emitter:
await event_emitter(
{
"type": "chat:message:error",
"data": {
"error": {
"content": f"Failed to connect to MCP server '{server_id}'"
}
function_name_filter_list = mcp_server_connection.get(
"config", {}
).get("function_name_filter_list", "")
if isinstance(function_name_filter_list, str):
function_name_filter_list = function_name_filter_list.split(
","
)
tool_specs = await mcp_clients[server_id].list_tool_specs()
for tool_spec in tool_specs:
def make_tool_function(client, function_name):
async def tool_function(**kwargs):
return await client.call_tool(
function_name,
function_args=kwargs,
)
return tool_function
if function_name_filter_list:
if not is_string_allowed(
tool_spec["name"], function_name_filter_list
):
# Skip this function
continue
tool_function = make_tool_function(
mcp_clients[server_id], tool_spec["name"]
)
mcp_tools_dict[f"{server_id}_{tool_spec['name']}"] = {
"spec": {
**tool_spec,
"name": f"{server_id}_{tool_spec['name']}",
},
"callable": tool_function,
"type": "mcp",
"client": mcp_clients[server_id],
"direct": False,
}
)
continue
except Exception as e:
log.debug(e)
if event_emitter:
await event_emitter(
{
"type": "chat:message:error",
"data": {
"error": {
"content": f"Failed to connect to MCP server '{server_id}'"
}
},
}
)
continue
tools_dict = await get_tools(
request,
tool_ids,
user,
{
**extra_params,
"__model__": models[task_model_id],
"__messages__": form_data["messages"],
"__files__": metadata.get("files", []),
},
)
tools_dict = await get_tools(
request,
tool_ids,
user,
{
**extra_params,
"__model__": models[task_model_id],
"__messages__": form_data["messages"],
"__files__": metadata.get("files", []),
},
)
if mcp_tools_dict:
tools_dict = {**tools_dict, **mcp_tools_dict}
if mcp_tools_dict:
tools_dict = {**tools_dict, **mcp_tools_dict}
if direct_tool_servers:
for tool_server in direct_tool_servers:
tool_specs = tool_server.pop("specs", [])
if direct_tool_servers:
for tool_server in direct_tool_servers:
tool_specs = tool_server.pop("specs", [])
for tool in tool_specs:
tools_dict[tool["name"]] = {
"spec": tool,
"direct": True,
"server": tool_server,
}
for tool in tool_specs:
tools_dict[tool["name"]] = {
"spec": tool,
"direct": True,
"server": tool_server,
}
if mcp_clients:
metadata["mcp_clients"] = mcp_clients
if mcp_clients:
metadata["mcp_clients"] = mcp_clients
# Inject builtin tools for native function calling based on enabled features and model capability
# Check if builtin_tools capability is enabled for this model (defaults to True if not specified)
builtin_tools_enabled = (
model.get("info", {}).get("meta", {}).get("capabilities") or {}
).get("builtin_tools", True)
if (
metadata.get("params", {}).get("function_calling") == "native"
and builtin_tools_enabled
):
# Add file context to user messages
chat_id = metadata.get("chat_id")
form_data["messages"] = add_file_context(
form_data.get("messages", []), chat_id, user
)
builtin_tools = get_builtin_tools(
request,
{
**extra_params,
"__event_emitter__": event_emitter,
"__skill_ids__": [
s.id for s in available_skills if s.id not in user_skill_ids
],
},
features,
model,
)
for name, tool_dict in builtin_tools.items():
if name not in tools_dict:
tools_dict[name] = tool_dict
# Inject builtin tools for native function calling based on enabled features and model capability
# Check if builtin_tools capability is enabled for this model (defaults to True if not specified)
builtin_tools_enabled = (
model.get("info", {}).get("meta", {}).get("capabilities") or {}
).get("builtin_tools", True)
if (
metadata.get("params", {}).get("function_calling") == "native"
and builtin_tools_enabled
):
# Add file context to user messages
chat_id = metadata.get("chat_id")
form_data["messages"] = add_file_context(
form_data.get("messages", []), chat_id, user
)
builtin_tools = get_builtin_tools(
request,
{
**extra_params,
"__event_emitter__": event_emitter,
"__skill_ids__": [
s.id for s in available_skills if s.id not in user_skill_ids
],
},
features,
model,
)
for name, tool_dict in builtin_tools.items():
if name not in tools_dict:
tools_dict[name] = tool_dict
if tools_dict:
if metadata.get("params", {}).get("function_calling") == "native":
# If the function calling is native, then call the tools function calling handler
metadata["tools"] = tools_dict
form_data["tools"] = [
{"type": "function", "function": tool.get("spec", {})}
for tool in tools_dict.values()
]
else:
# If the function calling is not native, then call the tools function calling handler
try:
form_data, flags = await chat_completion_tools_handler(
request, form_data, extra_params, user, models, tools_dict
)
sources.extend(flags.get("sources", []))
except Exception as e:
log.exception(e)
if tools_dict:
if metadata.get("params", {}).get("function_calling") == "native":
# If the function calling is native, then call the tools function calling handler
metadata["tools"] = tools_dict
form_data["tools"] = [
{"type": "function", "function": tool.get("spec", {})}
for tool in tools_dict.values()
]
else:
# If the function calling is not native, then call the tools function calling handler
try:
form_data, flags = await chat_completion_tools_handler(
request, form_data, extra_params, user, models, tools_dict
)
sources.extend(flags.get("sources", []))
except Exception as e:
log.exception(e)
# Check if file context extraction is enabled for this model (default True)
file_context_enabled = (
@ -3981,6 +3998,8 @@ async def streaming_chat_response_handler(response, ctx):
tool_call_retries = 0
tool_call_sources = [] # Track citation sources from tool results
all_tool_call_sources = [] # Accumulated sources across all iterations
user_message = get_last_user_message(form_data["messages"])
while (
len(tool_calls) > 0
@ -4217,16 +4236,22 @@ async def streaming_chat_response_handler(response, ctx):
await event_emitter({"type": "source", "data": source})
# Apply source context to messages for model
if tool_call_sources:
user_msg = get_last_user_message(form_data["messages"])
if user_msg:
form_data["messages"] = apply_source_context_to_messages(
request,
form_data["messages"],
tool_call_sources,
user_msg,
)
tool_call_sources.clear()
# Use metadata_only=True to avoid duplicating content
# that is already in the tool result message.
all_tool_call_sources.extend(tool_call_sources)
if all_tool_call_sources and user_message:
# Restore original user message before re-applying to avoid recursive nesting
set_last_user_message_content(
user_message, form_data["messages"]
)
form_data["messages"] = apply_source_context_to_messages(
request,
form_data["messages"],
all_tool_call_sources,
user_message,
include_content=False,
)
tool_call_sources.clear()
await event_emitter(
{
@ -4296,7 +4321,8 @@ async def streaming_chat_response_handler(response, ctx):
code = sanitize_code(code)
if CODE_INTERPRETER_BLOCKED_MODULES:
blocking_code = textwrap.dedent(f"""
blocking_code = textwrap.dedent(
f"""
import builtins
BLOCKED_MODULES = {CODE_INTERPRETER_BLOCKED_MODULES}
@ -4312,7 +4338,8 @@ async def streaming_chat_response_handler(response, ctx):
return _real_import(name, globals, locals, fromlist, level)
builtins.__import__ = restricted_import
""")
"""
)
code = blocking_code + "\n" + code
if (

View file

@ -277,6 +277,26 @@ def get_last_user_message(messages: list[dict]) -> Optional[str]:
return get_content_from_message(message)
def set_last_user_message_content(
content: str, messages: list[dict]
) -> list[dict]:
"""
Replace the text content of the last user message in-place.
Handles both plain-string and list-of-parts content formats.
"""
for message in reversed(messages):
if message.get("role") == "user":
if isinstance(message.get("content"), list):
for item in message["content"]:
if item.get("type") == "text":
item["text"] = content
break
else:
message["content"] = content
break
return messages
def get_last_assistant_message_item(messages: list[dict]) -> Optional[dict]:
for message in reversed(messages):
if message["role"] == "assistant":

View file

@ -1,3 +1,4 @@
import copy
import time
import logging
import asyncio
@ -307,12 +308,41 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
except Exception as e:
log.info(f"Failed to load function module for {function_id}: {e}")
# Apply global model defaults to all models
# Per-model overrides take precedence over global defaults
default_metadata = (
getattr(request.app.state.config, "DEFAULT_MODEL_METADATA", None) or {}
)
if default_metadata:
for model in models:
info = model.get("info")
if info is None:
model["info"] = {"meta": copy.deepcopy(default_metadata)}
continue
meta = info.setdefault("meta", {})
for key, value in default_metadata.items():
if key == "capabilities":
# Merge capabilities: defaults as base, per-model overrides win
existing = meta.get("capabilities") or {}
meta["capabilities"] = {**value, **existing}
elif meta.get(key) is None:
meta[key] = copy.deepcopy(value)
def get_action_priority(action_id):
valves = Functions.get_function_valves_by_id(action_id)
return valves.get("priority", 0) if valves else 0
for model in models:
action_ids = [
action_id
for action_id in list(set(model.pop("action_ids", []) + global_action_ids))
if action_id in enabled_action_ids
]
action_ids.sort(key=get_action_priority)
filter_ids = [
filter_id
for filter_id in list(set(model.pop("filter_ids", []) + global_filter_ids))
@ -435,7 +465,7 @@ def get_filtered_models(models, user, db=None):
if model_info:
if (
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == model_info["user_id"]
or user.id == model_info.get("user_id")
or model["id"] in accessible_model_ids
):
filtered_models.append(model)

View file

@ -1706,17 +1706,22 @@ class OAuthManager:
db=db,
)
response.set_cookie(
key="oauth_session_id",
value=session.id,
httponly=True,
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
if session:
response.set_cookie(
key="oauth_session_id",
value=session.id,
httponly=True,
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
log.info(
f"Stored OAuth session server-side for user {user.id}, provider {provider}"
)
log.info(
f"Stored OAuth session server-side for user {user.id}, provider {provider}"
)
else:
log.warning(
f"Failed to create OAuth session for user {user.id}, provider {provider}"
)
except Exception as e:
log.error(f"Failed to store OAuth session server-side: {e}")

View file

@ -187,7 +187,7 @@ def apply_model_params_to_body_ollama(params: dict, form_data: dict) -> dict:
ollama_root_params = {
"format": lambda x: parse_json(x),
"keep_alive": lambda x: parse_json(x),
"think": bool,
"think": lambda x: x,
}
for key, value in ollama_root_params.items():
@ -326,7 +326,7 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict:
ollama_root_params = {
"format": lambda x: parse_json(x),
"keep_alive": lambda x: parse_json(x),
"think": bool,
"think": lambda x: x,
}
# Ollama's options field can contain parameters that should be at the root level.

View file

@ -144,6 +144,7 @@ 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
async for data in ollama_streaming_response.body_iterator:
data = json.loads(data)
@ -155,6 +156,7 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
if tool_calls:
openai_tool_calls = convert_ollama_tool_call_to_openai(tool_calls)
has_tool_calls = True
done = data.get("done", False)
@ -166,7 +168,7 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
model, message_content, reasoning_content, openai_tool_calls, usage
)
if done and openai_tool_calls:
if done and has_tool_calls:
data["choices"][0]["finish_reason"] = "tool_calls"
line = f"data: {json.dumps(data)}\n\n"

View file

@ -560,6 +560,7 @@ def get_builtin_tools(
# Generate spec from function
pydantic_model = convert_function_to_pydantic_model(func)
spec = convert_pydantic_model_to_openai_function_spec(pydantic_model)
spec = clean_openai_tool_schema(spec)
tools_dict[func.__name__] = {
"tool_id": f"builtin:{func.__name__}",
@ -668,6 +669,44 @@ def convert_function_to_pydantic_model(func: Callable) -> type[BaseModel]:
return model
def clean_properties(schema: dict):
if not isinstance(schema, dict):
return
if "anyOf" in schema:
non_null_types = [t for t in schema["anyOf"] if t.get("type") != "null"]
if len(non_null_types) == 1:
schema.update(non_null_types[0])
del schema["anyOf"]
else:
schema["anyOf"] = non_null_types
if "default" in schema and schema["default"] is None:
del schema["default"]
# fix missing type
if "type" not in schema and "anyOf" not in schema and "properties" not in schema:
schema["type"] = "string"
if "properties" in schema:
for prop_name, prop_schema in schema["properties"].items():
clean_properties(prop_schema)
if "items" in schema:
clean_properties(schema["items"])
def clean_openai_tool_schema(spec: dict) -> dict:
import copy
cleaned_spec = copy.deepcopy(spec)
if "parameters" in cleaned_spec:
clean_properties(cleaned_spec["parameters"])
return cleaned_spec
def get_functions_from_tool(tool: object) -> list[Callable]:
return [
getattr(tool, func)
@ -690,7 +729,9 @@ def get_tool_specs(tool_module: object) -> list[dict]:
)
specs = [
convert_pydantic_model_to_openai_function_spec(function_model)
clean_openai_tool_schema(
convert_pydantic_model_to_openai_function_spec(function_model)
)
for function_model in function_models
]

14
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "open-webui",
"version": "0.8.3",
"version": "0.8.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-webui",
"version": "0.8.3",
"version": "0.8.5",
"dependencies": {
"@azure/msal-browser": "^4.5.0",
"@codemirror/lang-javascript": "^6.2.2",
@ -38,6 +38,7 @@
"@tiptap/pm": "^3.0.7",
"@tiptap/starter-kit": "^3.0.7",
"@tiptap/suggestion": "^3.4.2",
"@xterm/xterm": "^6.0.0",
"@xyflow/svelte": "^0.1.19",
"alpinejs": "^3.15.0",
"async": "^3.2.5",
@ -4619,6 +4620,15 @@
"node": ">=10.0.0"
}
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/@xyflow/svelte": {
"version": "0.1.19",
"resolved": "https://registry.npmjs.org/@xyflow/svelte/-/svelte-0.1.19.tgz",

View file

@ -1,6 +1,6 @@
{
"name": "open-webui",
"version": "0.8.3",
"version": "0.8.5",
"private": true,
"scripts": {
"dev": "npm run pyodide:fetch && vite dev --host",
@ -82,6 +82,7 @@
"@tiptap/pm": "^3.0.7",
"@tiptap/starter-kit": "^3.0.7",
"@tiptap/suggestion": "^3.4.2",
"@xterm/xterm": "^6.0.0",
"@xyflow/svelte": "^0.1.19",
"alpinejs": "^3.15.0",
"async": "^3.2.5",

View file

@ -26,7 +26,7 @@
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover"
content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover, interactive-widget=resizes-content"
/>
<meta name="theme-color" content="#171717" />
<meta name="robots" content="noindex,nofollow" />

View file

@ -281,7 +281,12 @@ export const updateModelById = async (token: string, id: string, model: object)
return res;
};
export const updateModelAccessGrants = async (token: string, id: string, accessGrants: any[]) => {
export const updateModelAccessGrants = async (
token: string,
id: string,
name: string,
accessGrants: any[]
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/models/model/access/update`, {
@ -291,7 +296,7 @@ export const updateModelAccessGrants = async (token: string, id: string, accessG
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ id, access_grants: accessGrants })
body: JSON.stringify({ id, name, access_grants: accessGrants })
})
.then(async (res) => {
if (!res.ok) throw await res.json();

View file

@ -329,7 +329,7 @@
<Tooltip content={$i18n.t('Verify Connection')} className="self-end -mb-1">
<button
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-850 rounded-lg transition"
on:click={() => {
verifyHandler();
}}

View file

@ -7,7 +7,7 @@
</script>
<div class="px-3">
<div class="text-center dark:text-white text-2xl font-medium z-50">
<div class="text-center dark:text-white text-2xl font-medium z-50" role="heading" aria-level="2">
{#if title}
{title}
{:else}

View file

@ -490,7 +490,7 @@
className="shrink-0 flex items-center mr-1"
>
<button
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-850 rounded-lg transition"
on:click={() => {
verifyHandler();
}}

View file

@ -38,15 +38,13 @@
<Modal bind:show size="xl">
<div class="px-6 pt-5 dark:text-white text-black">
<div class="flex justify-between items-start">
<div class="text-xl font-medium">
<h2 class="text-xl font-medium m-0">
{$i18n.t("What's New in")}
{$WEBUI_NAME}
<Confetti x={[-1, -0.25]} y={[0, 0.5]} />
</div>
</h2>
<button class="self-center" on:click={closeModal} aria-label={$i18n.t('Close')}>
<XMark className={'size-5'}>
<p class="sr-only">{$i18n.t('Close')}</p>
</XMark>
<XMark className={'size-5'} />
</button>
</div>
<div class="flex items-center mt-1">
@ -64,9 +62,9 @@
{#if changelog}
{#each Object.keys(changelog) as version}
<div class=" mb-3 pr-2">
<div class="font-semibold text-xl mb-1 dark:text-white">
<h3 class="font-semibold text-xl mb-1 dark:text-white m-0">
v{version} - {changelog[version].date}
</div>
</h3>
<hr class="border-gray-50/50 dark:border-gray-850/50 my-2" />

View file

@ -67,6 +67,7 @@
<div class=" text-lg font-medium self-center">{$i18n.t('Import')}</div>
<button
class="self-center"
aria-label={$i18n.t('Close')}
on:click={() => {
show = false;
}}

View file

@ -82,9 +82,9 @@
});
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
role="status"
aria-live="polite"
class="group relative flex gap-2.5 text-left min-w-[var(--width)] w-full dark:bg-gray-850 dark:text-white bg-white text-black border border-gray-100 dark:border-gray-800 rounded-3xl px-4 py-3.5 cursor-pointer select-none"
on:dragstart|preventDefault
on:pointerdown={onPointerDown}

View file

@ -87,15 +87,15 @@
<div class="flex justify-center mt-8">
<div class="flex flex-col justify-center items-center">
<button
aria-labelledby="get-started"
aria-label={$i18n.t('Get started')}
class="relative z-20 flex p-1 rounded-full bg-white/5 hover:bg-white/10 transition font-medium text-sm"
on:click={() => {
getStartedHandler();
}}
>
<ArrowRightCircle className="size-6" />
<ArrowRightCircle className="size-6" aria-hidden="true" />
</button>
<div id="get-started" class="mt-1.5 font-primary text-base font-medium">
<div class="mt-1.5 font-primary text-base font-medium" aria-hidden="true">
{$i18n.t(`Get started`)}
</div>
</div>

View file

@ -158,6 +158,11 @@
if (modelOrderBy === 'name') {
return modelDirection === 'asc' ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name);
}
if (modelOrderBy === 'tokens') {
const aTokens = tokenStats[a.model_id]?.total_tokens ?? 0;
const bTokens = tokenStats[b.model_id]?.total_tokens ?? 0;
return modelDirection === 'asc' ? aTokens - bTokens : bTokens - aTokens;
}
return modelDirection === 'asc' ? a.count - b.count : b.count - a.count;
});
@ -167,6 +172,11 @@
const nameB = b.name || b.user_id;
return userDirection === 'asc' ? nameA.localeCompare(nameB) : nameB.localeCompare(nameA);
}
if (userOrderBy === 'tokens') {
const aTokens = a.total_tokens ?? 0;
const bTokens = b.total_tokens ?? 0;
return userDirection === 'asc' ? aTokens - bTokens : bTokens - aTokens;
}
return userDirection === 'asc' ? a.count - b.count : b.count - a.count;
});
@ -191,7 +201,7 @@
{#if groups.length > 0}
<select
bind:value={selectedGroupId}
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-none text-right"
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-none text-right"
>
<option value={null}>{$i18n.t('All Users')}</option>
{#each groups as group}
@ -201,7 +211,7 @@
{/if}
<select
bind:value={selectedPeriod}
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-none text-right"
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-none text-right"
>
{#each periods as period}
<option value={period.value}>{$i18n.t(period.label)}</option>
@ -329,8 +339,42 @@
{/if}
</div>
</th>
<th scope="col" class="px-2.5 py-2 text-right">{$i18n.t('Tokens')}</th>
<th scope="col" class="px-2.5 py-2 text-right w-16">%</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right"
on:click={() => toggleModelSort('tokens')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Tokens')}
{#if modelOrderBy === 'tokens'}
<span class="font-normal">
{#if modelDirection === 'asc'}<ChevronUp
className="size-2"
/>{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right w-16"
on:click={() => toggleModelSort('percentage')}
>
<div class="flex gap-1.5 items-center justify-end">
%
{#if modelOrderBy === 'percentage'}
<span class="font-normal">
{#if modelDirection === 'asc'}<ChevronUp
className="size-2"
/>{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
</tr>
</thead>
<tbody>
@ -349,6 +393,9 @@
src="{WEBUI_API_BASE_URL}/models/model/profile/image?id={model.model_id}"
alt={model.name}
class="size-5 rounded-full object-cover shrink-0"
on:error={(e) => {
e.target.src = '/favicon.png';
}}
/>
<span class="truncate max-w-[150px]">{model.name}</span>
</div>
@ -422,7 +469,24 @@
{/if}
</div>
</th>
<th scope="col" class="px-2.5 py-2 text-right">{$i18n.t('Tokens')}</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right"
on:click={() => toggleUserSort('tokens')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Tokens')}
{#if userOrderBy === 'tokens'}
<span class="font-normal">
{#if userDirection === 'asc'}<ChevronUp
className="size-2"
/>{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
</tr>
</thead>
<tbody>
@ -435,6 +499,9 @@
src="{WEBUI_API_BASE_URL}/users/{user.user_id}/profile/image"
alt={user.name || 'User'}
class="size-5 rounded-full object-cover shrink-0"
on:error={(e) => {
e.target.src = '/user.png';
}}
/>
<span class="truncate max-w-[150px]"
>{user.name || user.email || user.user_id.substring(0, 8)}</span

View file

@ -114,7 +114,22 @@
{/if}
</div>
</th>
<th scope="col" class="px-2.5 py-2 text-right w-24">{$i18n.t('Share')}</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right w-24"
on:click={() => toggleSort('percentage')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Share')}
{#if orderBy === 'percentage'}
{#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown
className="size-2"
/>{/if}
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
</tr>
</thead>
<tbody>
@ -130,7 +145,10 @@
<img
src="{WEBUI_API_BASE_URL}/models/model/profile/image?id={model.model_id}"
alt={model.name}
class="size-5 rounded-full object-cover"
class="size-5 rounded-full object-cover shrink-0"
on:error={(e) => {
e.target.src = '/favicon.png';
}}
/>
<span class="font-medium text-gray-800 dark:text-gray-200">{model.name}</span>
</div>

View file

@ -109,7 +109,22 @@
{/if}
</div>
</th>
<th scope="col" class="px-2.5 py-2 text-right w-24">{$i18n.t('Share')}</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right w-24"
on:click={() => toggleSort('percentage')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Share')}
{#if orderBy === 'percentage'}
{#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown
className="size-2"
/>{/if}
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
</tr>
</thead>
<tbody>

View file

@ -54,15 +54,14 @@
id="users-tabs-container"
class="tabs mx-[16px] lg:mx-0 lg:px-[16px] flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-50 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
>
<button
<a
id="leaderboard"
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition {selectedTab ===
href="/admin/evaluations/leaderboard"
draggable="false"
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition select-none {selectedTab ===
'leaderboard'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
goto('/admin/evaluations/leaderboard');
}}
>
<div class=" self-center mr-2">
<svg
@ -79,17 +78,16 @@
</svg>
</div>
<div class=" self-center">{$i18n.t('Leaderboard')}</div>
</button>
</a>
<button
<a
id="feedback"
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition {selectedTab ===
href="/admin/evaluations/feedback"
draggable="false"
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition select-none {selectedTab ===
'feedback'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
goto('/admin/evaluations/feedback');
}}
>
<div class=" self-center mr-2">
<svg
@ -106,7 +104,7 @@
</svg>
</div>
<div class=" self-center">{$i18n.t('Feedback')}</div>
</button>
</a>
</div>
<div class="flex-1 mt-1 lg:mt-0 px-[16px] lg:pr-[16px] lg:pl-0 overflow-y-scroll">

View file

@ -32,7 +32,7 @@
transition={flyAndScale}
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
dispatch('delete');
show = false;

View file

@ -180,7 +180,10 @@
<img
src="{WEBUI_API_BASE_URL}/models/model/profile/image?id={model.id}"
alt={model.name}
class="size-5 rounded-full object-cover"
class="size-5 rounded-full object-cover shrink-0"
on:error={(e) => {
e.target.src = '/favicon.png';
}}
/>
<Tooltip content={`${model.name} (${model.id})`} placement="top-start">
<span class="font-medium text-gray-800 dark:text-gray-200 line-clamp-1"

View file

@ -67,7 +67,7 @@
{/if}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
editHandler();
}}
@ -91,7 +91,7 @@
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
shareHandler();
}}
@ -101,7 +101,7 @@
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
cloneHandler();
}}
@ -112,7 +112,7 @@
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
exportHandler();
}}
@ -125,7 +125,7 @@
<hr class="border-gray-50 dark:border-gray-850/30 my-1" />
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
deleteHandler();
}}

View file

@ -321,15 +321,14 @@
<!-- {$i18n.t('Pipelines')} -->
<!-- {$i18n.t('Database')} -->
{#each filteredSettings as tab (tab.id)}
<button
<a
id={tab.id}
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 lg:flex-none flex text-right transition {selectedTab ===
href={tab.route}
draggable="false"
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 lg:flex-none flex text-right transition select-none {selectedTab ===
tab.id
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
goto(tab.route);
}}
>
<div class=" self-center mr-2">
{#if tab.id === 'general'}
@ -500,7 +499,7 @@
{/if}
</div>
<div class=" self-center">{$i18n.t(tab.title)}</div>
</button>
</a>
{/each}
</div>

View file

@ -235,7 +235,7 @@
<div class=" self-center text-xs font-medium">{$i18n.t('Speech-to-Text Engine')}</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 cursor-pointer w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
class="cursor-pointer w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={STT_ENGINE}
placeholder={$i18n.t('Select an engine')}
>
@ -506,7 +506,7 @@
<div class=" self-center text-xs font-medium">{$i18n.t('Text-to-Speech Engine')}</div>
<div class="flex items-center relative">
<select
class=" dark:bg-gray-900 w-fit pr-8 cursor-pointer rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 cursor-pointer rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={TTS_ENGINE}
placeholder={$i18n.t('Select a mode')}
on:change={async (e) => {
@ -798,7 +798,7 @@
<div class="self-center text-xs font-medium">{$i18n.t('Response splitting')}</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 cursor-pointer rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 cursor-pointer rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
aria-label={$i18n.t('Select how to split message text for TTS requests')}
bind:value={TTS_SPLIT_ON}
>

View file

@ -60,7 +60,7 @@
<div class=" self-center text-xs font-medium">{$i18n.t('Code Execution Engine')}</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={config.CODE_EXECUTION_ENGINE}
placeholder={$i18n.t('Select a engine')}
required
@ -109,7 +109,7 @@
<div>
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-left"
class="w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-left"
bind:value={config.CODE_EXECUTION_JUPYTER_AUTH}
placeholder={$i18n.t('Select an auth method')}
>
@ -151,7 +151,7 @@
<div class="">
<Tooltip content={$i18n.t('Enter timeout in seconds')}>
<input
class="dark:bg-gray-900 w-fit rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
class="w-fit rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
type="number"
bind:value={config.CODE_EXECUTION_JUPYTER_TIMEOUT}
placeholder={$i18n.t('e.g. 60')}
@ -186,7 +186,7 @@
</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={config.CODE_INTERPRETER_ENGINE}
placeholder={$i18n.t('Select a engine')}
required
@ -235,7 +235,7 @@
<div>
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-left"
class="w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-left"
bind:value={config.CODE_INTERPRETER_JUPYTER_AUTH}
placeholder={$i18n.t('Select an auth method')}
>
@ -277,7 +277,7 @@
<div class="">
<Tooltip content={$i18n.t('Enter timeout in seconds')}>
<input
class="dark:bg-gray-900 w-fit rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
class="w-fit rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
type="number"
bind:value={config.CODE_INTERPRETER_JUPYTER_TIMEOUT}
placeholder={$i18n.t('e.g. 60')}

View file

@ -79,7 +79,7 @@
<div class="flex gap-1 items-center">
<Tooltip content={$i18n.t('Manage')} className="self-start">
<button
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-850 rounded-lg transition"
on:click={() => {
showManageModal = true;
}}
@ -91,7 +91,7 @@
<Tooltip content={$i18n.t('Configure')} className="self-start">
<button
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-850 rounded-lg transition"
on:click={() => {
showConfigModal = true;
}}

View file

@ -102,7 +102,7 @@
<div class="flex gap-1 items-center">
<Tooltip content={$i18n.t('Configure')} className="self-start">
<button
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-850 rounded-lg transition"
on:click={() => {
showConfigModal = true;
}}

View file

@ -340,7 +340,7 @@
</div>
<div class="">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={RAGConfig.CONTENT_EXTRACTION_ENGINE}
>
<option value="">{$i18n.t('Default')}</option>
@ -381,7 +381,7 @@
</div>
<div class="">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={RAGConfig.PDF_LOADER_MODE}
>
<option value="page">{$i18n.t('Page')}</option>
@ -552,7 +552,7 @@
</div>
<div class="">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={RAGConfig.DATALAB_MARKER_OUTPUT_FORMAT}
>
<option value="markdown">{$i18n.t('Markdown')}</option>
@ -686,7 +686,7 @@
{$i18n.t('API Mode')}
</div>
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden"
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden"
bind:value={RAGConfig.MINERU_API_MODE}
on:change={() => {
// Auto-update URL when switching modes if it's empty or matches the opposite mode's default
@ -792,7 +792,7 @@
<div class=" self-center text-xs font-medium">{$i18n.t('Text Splitter')}</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={RAGConfig.TEXT_SPLITTER}
>
<option value="">{$i18n.t('Default')} ({$i18n.t('Character')})</option>
@ -898,7 +898,7 @@
</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={RAG_EMBEDDING_ENGINE}
placeholder={$i18n.t('Select an embedding model engine')}
on:change={(e) => {
@ -1152,7 +1152,7 @@
</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={RAGConfig.RAG_RERANKING_ENGINE}
placeholder={$i18n.t('Select a reranking model engine')}
on:change={(e) => {

View file

@ -350,7 +350,7 @@
<div class="flex items-center">
<select
class="dark:bg-gray-900 w-full py-1 text-sm rounded-lg bg-transparent {selectedModelId
class="w-full py-1 text-sm rounded-lg bg-transparent {selectedModelId
? ''
: 'text-gray-500'} placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
bind:value={selectedModelId}

View file

@ -1,7 +1,8 @@
<script lang="ts">
import DOMPurify from 'dompurify';
import { v4 as uuidv4 } from 'uuid';
import { getVersionUpdates, getWebhookUrl, updateWebhookUrl } from '$lib/apis';
import { getBackendConfig, getVersionUpdates, getWebhookUrl, updateWebhookUrl } from '$lib/apis';
import {
getAdminConfig,
getLdapConfig,
@ -10,16 +11,19 @@
updateLdapConfig,
updateLdapServer
} from '$lib/apis/auths';
import { getBanners, setBanners } from '$lib/apis/configs';
import { getGroups } from '$lib/apis/groups';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import { WEBUI_BUILD_HASH, WEBUI_VERSION } from '$lib/constants';
import { config, showChangelog } from '$lib/stores';
import { banners as _banners, config, showChangelog } from '$lib/stores';
import type { Banner } from '$lib/types';
import { compareVersion } from '$lib/utils';
import { onMount, getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import Textarea from '$lib/components/common/Textarea.svelte';
import Banners from './Interface/Banners.svelte';
const i18n = getContext('i18n');
@ -35,6 +39,8 @@
let webhookUrl = '';
let groups = [];
let banners: Banner[] = [];
// LDAP
let ENABLE_LDAP = false;
let LDAP_SERVER = {
@ -78,12 +84,20 @@
}
};
const updateBanners = async () => {
_banners.set(await setBanners(localStorage.token, banners));
};
const updateHandler = async () => {
webhookUrl = await updateWebhookUrl(localStorage.token, webhookUrl);
const res = await updateAdminConfig(localStorage.token, adminConfig);
await updateLdapConfig(localStorage.token, ENABLE_LDAP);
await updateLdapServerHandler();
await updateBanners();
await config.set(await getBackendConfig());
if (res) {
saveHandler();
} else {
@ -114,6 +128,8 @@
const ldapConfig = await getLdapConfig(localStorage.token);
ENABLE_LDAP = ldapConfig.ENABLE_LDAP;
banners = await getBanners(localStorage.token);
});
</script>
@ -293,7 +309,7 @@
<div class=" self-center text-xs font-medium">{$i18n.t('Default User Role')}</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={adminConfig.DEFAULT_USER_ROLE}
placeholder={$i18n.t('Select a role')}
>
@ -308,7 +324,7 @@
<div class=" self-center text-xs font-medium">{$i18n.t('Default Group')}</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={adminConfig.DEFAULT_GROUP_ID}
placeholder={$i18n.t('Select a group')}
>
@ -526,7 +542,6 @@
>
<input
class="w-full bg-transparent outline-hidden py-0.5"
required
placeholder={$i18n.t('Enter Application DN')}
bind:value={LDAP_SERVER.app_dn}
/>
@ -538,6 +553,7 @@
</div>
<SensitiveInput
placeholder={$i18n.t('Enter Application DN Password')}
required={false}
bind:value={LDAP_SERVER.app_dn_password}
/>
</div>
@ -811,6 +827,53 @@
</div>
</div>
</div>
<div class="mb-3.5">
<div class=" mt-0.5 mb-2.5 text-base font-medium">{$i18n.t('UI')}</div>
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
<div class="mb-2.5">
<div class="flex w-full justify-between">
<div class=" self-center text-xs">
{$i18n.t('Banners')}
</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition"
type="button"
on:click={() => {
if (banners.length === 0 || banners.at(-1).content !== '') {
banners = [
...banners,
{
id: uuidv4(),
type: '',
title: '',
content: '',
dismissible: true,
timestamp: Math.floor(Date.now() / 1000)
}
];
}
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z"
/>
</svg>
</button>
</div>
<Banners bind:banners />
</div>
</div>
</div>
{/if}
</div>

View file

@ -402,7 +402,7 @@
</div>
<select
class=" dark:bg-gray-900 w-fit pr-8 cursor-pointer rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 cursor-pointer rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={config.IMAGE_GENERATION_ENGINE}
placeholder={$i18n.t('Select Engine')}
>
@ -867,7 +867,7 @@
</div>
<select
class=" dark:bg-gray-900 w-fit pr-8 cursor-pointer rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 cursor-pointer rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={config.IMAGES_GEMINI_ENDPOINT_METHOD}
placeholder={$i18n.t('Select Method')}
>
@ -950,7 +950,7 @@
</div>
<select
class=" dark:bg-gray-900 w-fit pr-8 cursor-pointer rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 cursor-pointer rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={config.IMAGE_EDIT_ENGINE}
placeholder={$i18n.t('Select Engine')}
>

View file

@ -1,27 +1,15 @@
<script lang="ts">
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import { v4 as uuidv4 } from 'uuid';
import { getModels, getTaskConfig, updateTaskConfig } from '$lib/apis';
import { config, settings } from '$lib/stores';
import { createEventDispatcher, onMount, getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import { getBackendConfig, getModels, getTaskConfig, updateTaskConfig } from '$lib/apis';
import { setDefaultPromptSuggestions } from '$lib/apis/configs';
import { config, settings, user } from '$lib/stores';
import { createEventDispatcher, onMount, getContext } from 'svelte';
import { banners as _banners } from '$lib/stores';
import type { Banner } from '$lib/types';
import { getBaseModels } from '$lib/apis/models';
import { getBanners, setBanners } from '$lib/apis/configs';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Banners from './Interface/Banners.svelte';
import PromptSuggestions from '$lib/components/workspace/Models/PromptSuggestions.svelte';
const dispatch = createEventDispatcher();
@ -46,21 +34,8 @@
VOICE_MODE_PROMPT_TEMPLATE: ''
};
let promptSuggestions = [];
let banners: Banner[] = [];
const updateInterfaceHandler = async () => {
taskConfig = await updateTaskConfig(localStorage.token, taskConfig);
promptSuggestions = promptSuggestions.filter((p) => p.content !== '');
promptSuggestions = await setDefaultPromptSuggestions(localStorage.token, promptSuggestions);
await updateBanners();
await config.set(await getBackendConfig());
};
const updateBanners = async () => {
_banners.set(await setBanners(localStorage.token, banners));
};
let workspaceModels = null;
@ -69,33 +44,37 @@
let models = null;
const init = async () => {
taskConfig = await getTaskConfig(localStorage.token);
promptSuggestions = $config?.default_prompt_suggestions ?? [];
banners = await getBanners(localStorage.token);
try {
taskConfig = await getTaskConfig(localStorage.token);
workspaceModels = await getBaseModels(localStorage.token);
baseModels = await getModels(localStorage.token, null, false);
workspaceModels = await getBaseModels(localStorage.token);
baseModels = await getModels(localStorage.token, null, false);
models = baseModels.map((m) => {
const workspaceModel = workspaceModels.find((wm) => wm.id === m.id);
models = baseModels.map((m) => {
const workspaceModel = workspaceModels.find((wm) => wm.id === m.id);
if (workspaceModel) {
return {
...m,
...workspaceModel
};
} else {
return {
...m,
id: m.id,
name: m.name,
if (workspaceModel) {
return {
...m,
...workspaceModel
};
} else {
return {
...m,
id: m.id,
name: m.name,
is_active: true
};
}
});
is_active: true
};
}
});
console.debug('models', models);
console.debug('models', models);
} catch (err) {
console.error('Failed to initialize Interface settings:', err);
toast.error(err?.detail ?? err?.message ?? $i18n.t('Failed to load Interface settings'));
models = [];
}
};
onMount(async () => {
@ -435,63 +414,6 @@
</Tooltip>
</div>
</div>
<div class="mb-3.5">
<div class=" mt-0.5 mb-2.5 text-base font-medium">{$i18n.t('UI')}</div>
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
<div class="mb-2.5">
<div class="flex w-full justify-between">
<div class=" self-center text-xs">
{$i18n.t('Banners')}
</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition"
type="button"
on:click={() => {
if (banners.length === 0 || banners.at(-1).content !== '') {
banners = [
...banners,
{
id: uuidv4(),
type: '',
title: '',
content: '',
dismissible: true,
timestamp: Math.floor(Date.now() / 1000)
}
];
}
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z"
/>
</svg>
</button>
</div>
<Banners bind:banners />
</div>
{#if $user?.role === 'admin'}
<PromptSuggestions bind:promptSuggestions />
{#if promptSuggestions.length > 0}
<div class="text-xs text-left w-full mt-2">
{$i18n.t('Adjusting these settings will apply changes universally to all users.')}
</div>
{/if}
{/if}
</div>
</div>
<div class="flex justify-end text-sm font-medium">

View file

@ -28,9 +28,10 @@
import ModelEditor from '$lib/components/workspace/Models/ModelEditor.svelte';
import { toast } from 'svelte-sonner';
import Badge from '$lib/components/common/Badge.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Cog6 from '$lib/components/icons/Cog6.svelte';
import ConfigureModelsModal from './Models/ConfigureModelsModal.svelte';
import ModelSettingsModal from './Models/ModelSettingsModal.svelte';
import Wrench from '$lib/components/icons/Wrench.svelte';
import Download from '$lib/components/icons/Download.svelte';
import ManageModelsModal from './Models/ManageModelsModal.svelte';
@ -68,6 +69,15 @@
const perPage = 30;
let currentPage = 1;
const isPublicModel = (model) => {
return (model?.access_grants ?? []).some(
(g) =>
g.principal_type === 'user' &&
g.principal_id === '*' &&
g.permission === 'read'
);
};
$: if (models) {
filteredModels = models
.filter((m) => searchValue === '' || m.name.toLowerCase().includes(searchValue.toLowerCase()))
@ -76,6 +86,8 @@
if (viewOption === 'disabled') return !(m?.is_active ?? true);
if (viewOption === 'visible') return !(m?.meta?.hidden ?? false);
if (viewOption === 'hidden') return m?.meta?.hidden === true;
if (viewOption === 'public') return isPublicModel(m);
if (viewOption === 'private') return !isPublicModel(m);
return true; // All
})
.sort((a, b) => {
@ -304,7 +316,7 @@
});
</script>
<ConfigureModelsModal bind:show={showConfigModal} initHandler={init} />
<ModelSettingsModal bind:show={showConfigModal} initHandler={init} />
<ManageModelsModal bind:show={showManageModal} />
{#if models !== null}
@ -322,6 +334,69 @@
</div>
<div class="flex w-full justify-end gap-1.5">
{#if $user?.role === 'admin'}
<input
id="models-import-input"
bind:this={modelsImportInputElement}
bind:files={importFiles}
type="file"
accept=".json"
hidden
on:change={() => {
if (importFiles.length > 0) {
const reader = new FileReader();
reader.onload = async (event) => {
modelsImportInProgress = true;
try {
const models = JSON.parse(String(event.target.result));
const res = await importModels(localStorage.token, models);
if (res) {
toast.success($i18n.t('Models imported successfully'));
await init();
} else {
toast.error($i18n.t('Failed to import models'));
}
} catch (e) {
toast.error(e?.detail ?? $i18n.t('Invalid JSON file'));
console.error(e);
}
modelsImportInProgress = false;
};
reader.readAsText(importFiles[0]);
}
}}
/>
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
disabled={modelsImportInProgress}
on:click={() => {
modelsImportInputElement.click();
}}
>
{#if modelsImportInProgress}
<Spinner className="size-3" />
{/if}
<div class=" self-center font-medium line-clamp-1">
{$i18n.t('Import')}
</div>
</button>
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
on:click={async () => {
downloadModels(models);
}}
>
<div class=" self-center font-medium line-clamp-1">
{$i18n.t('Export')}
</div>
</button>
{/if}
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
type="button"
@ -335,7 +410,7 @@
</button>
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-black hover:bg-gray-900 text-white dark:bg-white dark:hover:bg-gray-100 dark:text-black transition font-medium"
type="button"
on:click={() => {
showConfigModal = true;
@ -405,7 +480,7 @@
transition={flyAndScale}
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
enableAllHandler();
}}
@ -415,7 +490,7 @@
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
disableAllHandler();
}}
@ -445,7 +520,7 @@
selectedModelId = model.id;
}}
>
<div class=" self-center w-8">
<div class=" self-center w-9">
<div
class=" rounded-full object-cover {(model?.is_active ?? true)
? ''
@ -473,9 +548,32 @@
className=" w-fit"
placement="top-start"
>
<div class=" font-semibold line-clamp-1">{model.name}</div>
<div class="font-medium line-clamp-1 flex items-center gap-2">
{model.name}
<Badge
type={(model?.access_grants ?? []).some(
(g) =>
g.principal_type === 'user' &&
g.principal_id === '*' &&
g.permission === 'read'
)
? 'success'
: 'muted'}
content={(model?.access_grants ?? []).some(
(g) =>
g.principal_type === 'user' &&
g.principal_id === '*' &&
g.permission === 'read'
)
? $i18n.t('Public')
: $i18n.t('Private')}
/>
</div>
</Tooltip>
<div class=" text-xs overflow-hidden text-ellipsis line-clamp-1 text-gray-500">
<div
class=" text-xs overflow-hidden text-ellipsis line-clamp-1 flex items-center gap-1 text-gray-500"
>
<span class=" line-clamp-1">
{!!model?.meta?.description
? model?.meta?.description
@ -590,103 +688,6 @@
<Pagination bind:page={currentPage} count={filteredModels.length} {perPage} />
{/if}
</div>
{#if $user?.role === 'admin'}
<div class=" flex justify-end w-full mb-3">
<div class="flex space-x-1">
<input
id="models-import-input"
bind:this={modelsImportInputElement}
bind:files={importFiles}
type="file"
accept=".json"
hidden
on:change={() => {
if (importFiles.length > 0) {
const reader = new FileReader();
reader.onload = async (event) => {
modelsImportInProgress = true;
try {
const models = JSON.parse(String(event.target.result));
const res = await importModels(localStorage.token, models);
if (res) {
toast.success($i18n.t('Models imported successfully'));
await init();
} else {
toast.error($i18n.t('Failed to import models'));
}
} catch (e) {
toast.error(e?.detail ?? $i18n.t('Invalid JSON file'));
console.error(e);
}
modelsImportInProgress = false;
};
reader.readAsText(importFiles[0]);
}
}}
/>
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
disabled={modelsImportInProgress}
on:click={() => {
modelsImportInputElement.click();
}}
>
{#if modelsImportInProgress}
<Spinner className="size-3" />
{/if}
<div class=" self-center mr-2 font-medium line-clamp-1">
{$i18n.t('Import Presets')}
</div>
<div class=" self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-3.5 h-3.5"
>
<path
fill-rule="evenodd"
d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 9.5a.75.75 0 0 1-.75-.75V8.06l-.72.72a.75.75 0 0 1-1.06-1.06l2-2a.75.75 0 0 1 1.06 0l2 2a.75.75 0 1 1-1.06 1.06l-.72-.72v2.69a.75.75 0 0 1-.75.75Z"
clip-rule="evenodd"
/>
</svg>
</div>
</button>
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
on:click={async () => {
downloadModels(models);
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">
{$i18n.t('Export Presets')} ({models.length})
</div>
<div class=" self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-3.5 h-3.5"
>
<path
fill-rule="evenodd"
d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 3.5a.75.75 0 0 1 .75.75v2.69l.72-.72a.75.75 0 1 1 1.06 1.06l-2 2a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 0 1 1.06-1.06l.72.72V6.25A.75.75 0 0 1 8 5.5Z"
clip-rule="evenodd"
/>
</svg>
</div>
</button>
</div>
</div>
{/if}
{:else}
<ModelEditor
edit
@ -697,8 +698,9 @@
upsertModelHandler(model);
selectedModelId = null;
}}
onBack={() => {
onBack={async () => {
selectedModelId = null;
await init();
}}
/>
{/if}

View file

@ -16,7 +16,9 @@
{ value: 'enabled', label: $i18n.t('Enabled') },
{ value: 'disabled', label: $i18n.t('Disabled') },
{ value: 'visible', label: $i18n.t('Visible') },
{ value: 'hidden', label: $i18n.t('Hidden') }
{ value: 'hidden', label: $i18n.t('Hidden') },
{ value: 'public', label: $i18n.t('Public') },
{ value: 'private', label: $i18n.t('Private') }
];
</script>

View file

@ -1,242 +0,0 @@
<script>
import { toast } from 'svelte-sonner';
import { createEventDispatcher, getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
import { models } from '$lib/stores';
import { deleteAllModels } from '$lib/apis/models';
import { getModelsConfig, setModelsConfig } from '$lib/apis/configs';
import Modal from '$lib/components/common/Modal.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ModelList from './ModelList.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Minus from '$lib/components/icons/Minus.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import ModelSelector from './ModelSelector.svelte';
import Model from '../Evaluations/Model.svelte';
export let show = false;
export let initHandler = () => {};
let config = null;
let selectedModelId = '';
let defaultModelIds = [];
let selectedPinnedModelId = '';
let defaultPinnedModelIds = [];
let modelIds = [];
let sortKey = '';
let sortOrder = '';
let loading = false;
let showResetModal = false;
$: if (show) {
init();
}
const init = async () => {
config = await getModelsConfig(localStorage.token);
if (config?.DEFAULT_MODELS) {
defaultModelIds = (config?.DEFAULT_MODELS).split(',').filter((id) => id);
} else {
defaultModelIds = [];
}
if (config?.DEFAULT_PINNED_MODELS) {
defaultPinnedModelIds = (config?.DEFAULT_PINNED_MODELS).split(',').filter((id) => id);
} else {
defaultPinnedModelIds = [];
}
const modelOrderList = config.MODEL_ORDER_LIST || [];
const allModelIds = $models.map((model) => model.id);
// Create a Set for quick lookup of ordered IDs
const orderedSet = new Set(modelOrderList);
modelIds = [
// Add all IDs from MODEL_ORDER_LIST that exist in allModelIds
...modelOrderList.filter((id) => orderedSet.has(id) && allModelIds.includes(id)),
// Add remaining IDs not in MODEL_ORDER_LIST, sorted alphabetically
...allModelIds.filter((id) => !orderedSet.has(id)).sort((a, b) => a.localeCompare(b))
];
sortKey = '';
sortOrder = '';
};
const submitHandler = async () => {
loading = true;
const res = await setModelsConfig(localStorage.token, {
DEFAULT_MODELS: defaultModelIds.join(','),
DEFAULT_PINNED_MODELS: defaultPinnedModelIds.join(','),
MODEL_ORDER_LIST: modelIds
});
if (res) {
toast.success($i18n.t('Models configuration saved successfully'));
initHandler();
show = false;
} else {
toast.error($i18n.t('Failed to save models configuration'));
}
loading = false;
};
onMount(async () => {
init();
});
</script>
<ConfirmDialog
title={$i18n.t('Reset All Models')}
message={$i18n.t('This will delete all models including custom models and cannot be undone.')}
bind:show={showResetModal}
onConfirm={async () => {
const res = deleteAllModels(localStorage.token);
if (res) {
toast.success($i18n.t('All models deleted successfully'));
initHandler();
}
}}
/>
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
<div class=" text-lg font-medium self-center font-primary">
{$i18n.t('Settings')}
</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<XMark className={'size-5'} />
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-5 pb-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
{#if config}
<form
class="flex flex-col w-full"
on:submit|preventDefault={() => {
submitHandler();
}}
>
<div>
<div class="flex flex-col w-full">
<button
class="mb-1 flex gap-2"
type="button"
on:click={() => {
sortKey = 'model';
if (sortOrder === 'asc') {
sortOrder = 'desc';
} else {
sortOrder = 'asc';
}
modelIds = modelIds
.filter((id) => id !== '')
.sort((a, b) => {
const nameA = $models.find((model) => model.id === a)?.name || a;
const nameB = $models.find((model) => model.id === b)?.name || b;
return sortOrder === 'desc'
? nameA.localeCompare(nameB)
: nameB.localeCompare(nameA);
});
}}
>
<div class="text-xs text-gray-500">{$i18n.t('Reorder Models')}</div>
{#if sortKey === 'model'}
<span class="font-normal self-center">
{#if sortOrder === 'asc'}
<ChevronUp className="size-3" />
{:else}
<ChevronDown className="size-3" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-3" />
</span>
{/if}
</button>
<ModelList bind:modelIds />
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<ModelSelector
title={$i18n.t('Default Models')}
models={$models}
bind:modelIds={defaultModelIds}
/>
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<ModelSelector
title={$i18n.t('Default Pinned Models')}
models={$models}
bind:modelIds={defaultPinnedModelIds}
/>
<div class="flex justify-between pt-3 text-sm font-medium gap-1.5">
<Tooltip content={$i18n.t('This will delete all models including custom models')}>
<button
class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-gray-950 dark:text-white bg-white text-black hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
type="button"
on:click={() => {
showResetModal = true;
}}
>
<!-- {$i18n.t('Delete All Models')} -->
{$i18n.t('Reset All Models')}
</button>
</Tooltip>
<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 flex flex-row space-x-1 items-center {loading
? ' cursor-not-allowed'
: ''}"
type="submit"
disabled={loading}
>
{$i18n.t('Save')}
{#if loading}
<div class="ml-2 self-center">
<Spinner />
</div>
{/if}
</button>
</div>
</form>
{:else}
<div>
<Spinner className="size-5" />
</div>
{/if}
</div>
</div>
</div>
</Modal>

View file

@ -56,7 +56,7 @@
transition={flyAndScale}
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
hideHandler();
}}
@ -108,7 +108,7 @@
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
pinModelHandler(model?.id);
}}
@ -129,7 +129,7 @@
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
copyLinkHandler();
}}
@ -139,19 +139,21 @@
<div class="flex items-center">{$i18n.t('Copy Link')}</div>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
cloneHandler();
}}
>
<DocumentDuplicate />
{#if model?.is_active ?? true}
<DropdownMenu.Item
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
cloneHandler();
}}
>
<DocumentDuplicate />
<div class="flex items-center">{$i18n.t('Clone')}</div>
</DropdownMenu.Item>
<div class="flex items-center">{$i18n.t('Clone')}</div>
</DropdownMenu.Item>
{/if}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
exportHandler();
}}

View file

@ -3,8 +3,10 @@
const i18n = getContext('i18n');
import Minus from '$lib/components/icons/Minus.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
export let title = '';
export let tooltip = '';
export let models = [];
export let modelIds = [];
@ -14,12 +16,32 @@
<div>
<div class="flex flex-col w-full">
<div class="mb-1 flex justify-between">
<div class="text-xs text-gray-500">{title}</div>
<div class="text-xs text-gray-500 flex items-center gap-1">
{title}
{#if tooltip}
<Tooltip content={tooltip} className="cursor-help">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-3"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z"
/>
</svg>
</Tooltip>
{/if}
</div>
</div>
<div class="flex items-center -mr-1">
<select
class="dark:bg-gray-900 w-full py-1 text-sm rounded-lg bg-transparent {selectedModelId
class="w-full py-1 text-sm rounded-lg bg-transparent {selectedModelId
? ''
: 'text-gray-500'} placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
bind:value={selectedModelId}

View file

@ -0,0 +1,465 @@
<script>
import { toast } from 'svelte-sonner';
import { createEventDispatcher, getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
import { models, config as _config } from '$lib/stores';
import { DEFAULT_CAPABILITIES } from '$lib/constants';
import { deleteAllModels } from '$lib/apis/models';
import { getModelsConfig, setModelsConfig, setDefaultPromptSuggestions } from '$lib/apis/configs';
import { getBackendConfig } from '$lib/apis';
import Modal from '$lib/components/common/Modal.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ModelList from './ModelList.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Minus from '$lib/components/icons/Minus.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import ModelSelector from './ModelSelector.svelte';
import Model from '../Evaluations/Model.svelte';
import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte';
import Capabilities from '$lib/components/workspace/Models/Capabilities.svelte';
import DefaultFeatures from '$lib/components/workspace/Models/DefaultFeatures.svelte';
import BuiltinTools from '$lib/components/workspace/Models/BuiltinTools.svelte';
import PromptSuggestions from '$lib/components/workspace/Models/PromptSuggestions.svelte';
import AdjustmentsHorizontal from '$lib/components/icons/AdjustmentsHorizontal.svelte';
import Eye from '$lib/components/icons/Eye.svelte';
export let show = false;
export let initHandler = () => {};
let config = null;
let selectedTab = 'defaults';
let selectedModelId = '';
let defaultModelIds = [];
let selectedPinnedModelId = '';
let defaultPinnedModelIds = [];
let modelIds = [];
let sortKey = '';
let sortOrder = '';
let loading = false;
let showResetModal = false;
let showDefaultCapabilities = false;
let showDefaultParams = false;
let showDefaultPromptSuggestions = false;
let defaultCapabilities = {};
let defaultFeatureIds = [];
let defaultParams = {};
let builtinTools = {};
let promptSuggestions = [];
$: if (show) {
init();
}
const init = async () => {
config = await getModelsConfig(localStorage.token);
if (config?.DEFAULT_MODELS) {
defaultModelIds = (config?.DEFAULT_MODELS).split(',').filter((id) => id);
} else {
defaultModelIds = [];
}
if (config?.DEFAULT_PINNED_MODELS) {
defaultPinnedModelIds = (config?.DEFAULT_PINNED_MODELS).split(',').filter((id) => id);
} else {
defaultPinnedModelIds = [];
}
const modelOrderList = config.MODEL_ORDER_LIST || [];
const allModelIds = $models.map((model) => model.id);
// Create a Set for quick lookup of ordered IDs
const orderedSet = new Set(modelOrderList);
modelIds = [
// Add all IDs from MODEL_ORDER_LIST that exist in allModelIds
...modelOrderList.filter((id) => orderedSet.has(id) && allModelIds.includes(id)),
// Add remaining IDs not in MODEL_ORDER_LIST, sorted alphabetically
...allModelIds.filter((id) => !orderedSet.has(id)).sort((a, b) => a.localeCompare(b))
];
sortKey = '';
sortOrder = '';
const savedMeta = config?.DEFAULT_MODEL_METADATA;
if (savedMeta && Object.keys(savedMeta).length > 0) {
defaultCapabilities = savedMeta.capabilities ?? { ...DEFAULT_CAPABILITIES };
defaultFeatureIds = savedMeta.defaultFeatureIds ?? [];
builtinTools = savedMeta.builtinTools ?? {};
} else {
defaultCapabilities = { ...DEFAULT_CAPABILITIES };
defaultFeatureIds = [];
builtinTools = {};
}
defaultParams = config?.DEFAULT_MODEL_PARAMS ?? {};
promptSuggestions = $_config?.default_prompt_suggestions ?? [];
};
const submitHandler = async () => {
loading = true;
const metadata = {
capabilities: defaultCapabilities,
...(defaultFeatureIds.length > 0 ? { defaultFeatureIds } : {}),
...(Object.keys(builtinTools).length > 0 ? { builtinTools } : {})
};
const res = await setModelsConfig(localStorage.token, {
DEFAULT_MODELS: defaultModelIds.join(','),
DEFAULT_PINNED_MODELS: defaultPinnedModelIds.join(','),
MODEL_ORDER_LIST: modelIds,
DEFAULT_MODEL_METADATA: metadata,
DEFAULT_MODEL_PARAMS: Object.fromEntries(
Object.entries(defaultParams).filter(([_, v]) => v !== null && v !== '' && v !== undefined)
)
});
if (res) {
promptSuggestions = promptSuggestions.filter((p) => p.content !== '');
promptSuggestions = await setDefaultPromptSuggestions(localStorage.token, promptSuggestions);
await _config.set(await getBackendConfig());
toast.success($i18n.t('Models configuration saved successfully'));
initHandler();
show = false;
} else {
toast.error($i18n.t('Failed to save models configuration'));
}
loading = false;
};
onMount(async () => {
init();
});
</script>
<ConfirmDialog
title={$i18n.t('Reset All Models')}
message={$i18n.t('This will delete all models including custom models and cannot be undone.')}
bind:show={showResetModal}
onConfirm={async () => {
const res = deleteAllModels(localStorage.token);
if (res) {
toast.success($i18n.t('All models deleted successfully'));
initHandler();
}
}}
/>
<Modal size="lg" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
<div class=" text-lg font-medium self-center font-primary">
{$i18n.t('Settings')}
</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<XMark className={'size-5'} />
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
{#if config}
<form
class="flex flex-col w-full"
on:submit|preventDefault={() => {
submitHandler();
}}
>
<div class="flex flex-col lg:flex-row w-full h-full pb-2 lg:space-x-4">
<div
id="admin-settings-tabs-container"
class="tabs flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-40 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
>
<button
class="px-0.5 py-1 max-w-fit w-fit rounded-lg flex-1 lg:flex-none flex text-right transition {selectedTab ===
'defaults'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'defaults';
}}
type="button"
>
<div class=" self-center mr-2">
<AdjustmentsHorizontal />
</div>
<div class=" self-center">{$i18n.t('Defaults')}</div>
</button>
<button
class="px-0.5 py-1 max-w-fit w-fit rounded-lg flex-1 lg:flex-none flex text-right transition {selectedTab ===
'display'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'display';
}}
type="button"
>
<div class=" self-center mr-2">
<Eye />
</div>
<div class=" self-center">{$i18n.t('Display')}</div>
</button>
</div>
<div class="flex-1 mt-1 lg:mt-1 lg:h-[30rem] lg:max-h-[30rem] flex flex-col min-w-0">
<div class="w-full h-full overflow-y-auto overflow-x-hidden scrollbar-hidden">
{#if selectedTab === 'defaults'}
<ModelSelector
title={$i18n.t('Selected Models')}
tooltip={$i18n.t(
'Set the default models that are automatically selected for all users when a new chat is created.'
)}
models={$models}
bind:modelIds={defaultModelIds}
/>
<hr class=" border-gray-50 dark:border-gray-800/10 my-2.5 w-full" />
<ModelSelector
title={$i18n.t('Pinned Models')}
tooltip={$i18n.t(
'Set the models that are automatically pinned to the sidebar for all users.'
)}
models={$models}
bind:modelIds={defaultPinnedModelIds}
/>
<hr class=" border-gray-50 dark:border-gray-800/10 my-2.5 w-full" />
<div>
<button
class="flex w-full justify-between items-center"
type="button"
on:click={() => {
showDefaultPromptSuggestions = !showDefaultPromptSuggestions;
}}
>
<div class="text-xs text-gray-500 font-medium">
{$i18n.t('Prompt Suggestions')}
</div>
<div>
{#if showDefaultPromptSuggestions}
<ChevronUp className="size-3" />
{:else}
<ChevronDown className="size-3" />
{/if}
</div>
</button>
{#if showDefaultPromptSuggestions}
<div class="mt-2">
<PromptSuggestions bind:promptSuggestions />
{#if promptSuggestions.length > 0}
<div class="text-xs text-left w-full mt-2 text-gray-500">
{$i18n.t(
'Adjusting these settings will apply changes universally to all users.'
)}
</div>
{/if}
</div>
{/if}
</div>
<hr class=" border-gray-50 dark:border-gray-800/10 my-2.5 w-full" />
<div>
<button
class="flex w-full justify-between items-center"
type="button"
on:click={() => {
showDefaultCapabilities = !showDefaultCapabilities;
}}
>
<div class="text-xs text-gray-500 font-medium">
{$i18n.t('Model Capabilities')}
</div>
<div>
{#if showDefaultCapabilities}
<ChevronUp className="size-3" />
{:else}
<ChevronDown className="size-3" />
{/if}
</div>
</button>
{#if showDefaultCapabilities}
<div class="mt-2">
<Capabilities bind:capabilities={defaultCapabilities} />
{#if Object.keys(defaultCapabilities).filter((key) => defaultCapabilities[key]).length > 0}
{@const availableFeatures = Object.entries(defaultCapabilities)
.filter(
([key, value]) =>
value &&
['web_search', 'code_interpreter', 'image_generation'].includes(
key
)
)
.map(([key, value]) => key)}
{#if availableFeatures.length > 0}
<div class="mt-4">
<DefaultFeatures
{availableFeatures}
bind:featureIds={defaultFeatureIds}
/>
</div>
{/if}
{/if}
{#if defaultCapabilities.builtin_tools}
<div class="mt-4">
<BuiltinTools bind:builtinTools />
</div>
{/if}
</div>
{/if}
</div>
<hr class=" border-gray-50 dark:border-gray-800/10 my-2.5 w-full" />
<div>
<button
class="flex w-full justify-between items-center"
type="button"
on:click={() => {
showDefaultParams = !showDefaultParams;
}}
>
<div class="text-xs text-gray-500 font-medium">
{$i18n.t('Model Parameters')}
</div>
<div>
{#if showDefaultParams}
<ChevronUp className="size-3" />
{:else}
<ChevronDown className="size-3" />
{/if}
</div>
</button>
{#if showDefaultParams}
<div class="mt-2">
<AdvancedParams admin={true} bind:params={defaultParams} />
</div>
{/if}
</div>
{:else if selectedTab === 'display'}
<div>
<div class="flex flex-col w-full">
<button
class="mb-1 flex gap-2"
type="button"
on:click={() => {
sortKey = 'model';
if (sortOrder === 'asc') {
sortOrder = 'desc';
} else {
sortOrder = 'asc';
}
modelIds = modelIds
.filter((id) => id !== '')
.sort((a, b) => {
const nameA = $models.find((model) => model.id === a)?.name || a;
const nameB = $models.find((model) => model.id === b)?.name || b;
return sortOrder === 'desc'
? nameA.localeCompare(nameB)
: nameB.localeCompare(nameA);
});
}}
>
<div class="text-xs text-gray-500">{$i18n.t('Reorder Models')}</div>
{#if sortKey === 'model'}
<span class="font-normal self-center">
{#if sortOrder === 'asc'}
<ChevronUp className="size-3" />
{:else}
<ChevronDown className="size-3" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-3" />
</span>
{/if}
</button>
<ModelList bind:modelIds />
</div>
</div>
{/if}
</div>
<div class="flex justify-between items-center pt-3 text-sm font-medium gap-1.5">
<div>
<Tooltip
content={$i18n.t('This will delete all models including custom models')}
>
<button
class="text-sm font-normal text-gray-500 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300 transition hover:underline"
type="button"
on:click={() => {
showResetModal = true;
}}
>
{$i18n.t('Reset')}
</button>
</Tooltip>
</div>
<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 flex flex-row space-x-1 items-center {loading
? ' cursor-not-allowed'
: ''}"
type="submit"
disabled={loading}
>
{$i18n.t('Save')}
{#if loading}
<div class="ml-2 self-center">
<Spinner />
</div>
{/if}
</button>
</div>
</div>
</div>
</form>
{:else}
<div>
<Spinner className="size-5" />
</div>
{/if}
</div>
</div>
</div>
</Modal>

View file

@ -156,7 +156,7 @@
</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={webConfig.WEB_SEARCH_ENGINE}
placeholder={$i18n.t('Select a engine')}
required
@ -950,7 +950,7 @@
</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={webConfig.WEB_LOADER_ENGINE}
placeholder={$i18n.t('Select a engine')}
>

View file

@ -60,15 +60,14 @@
id="users-tabs-container"
class="mx-[16px] lg:mx-0 lg:px-[16px] flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-50 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
>
<button
<a
id="overview"
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition {selectedTab ===
href="/admin/users/overview"
draggable="false"
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition select-none {selectedTab ===
'overview'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
goto('/admin/users/overview');
}}
>
<div class=" self-center mr-2">
<svg
@ -83,17 +82,16 @@
</svg>
</div>
<div class=" self-center">{$i18n.t('Overview')}</div>
</button>
</a>
<button
<a
id="groups"
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition {selectedTab ===
href="/admin/users/groups"
draggable="false"
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition select-none {selectedTab ===
'groups'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
goto('/admin/users/groups');
}}
>
<div class=" self-center mr-2">
<svg
@ -108,7 +106,7 @@
</svg>
</div>
<div class=" self-center">{$i18n.t('Groups')}</div>
</button>
</a>
</div>
<div class="flex-1 mt-1 lg:mt-0 px-[16px] lg:pr-[16px] lg:pl-0 overflow-y-scroll">

View file

@ -157,12 +157,14 @@
<input
class="w-full text-sm py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
aria-label={$i18n.t('Search Groups')}
placeholder={$i18n.t('Search Groups')}
/>
{#if query}
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
aria-label={$i18n.t('Clear search')}
on:click={() => {
query = '';
}}
@ -244,6 +246,7 @@
<button
class="flex items-center justify-between rounded-lg w-full transition mt-4"
aria-haspopup="dialog"
on:click={() => {
showDefaultPermissionsModal = true;
}}

View file

@ -77,7 +77,7 @@
<div class="flex items-center gap-2 p-1">
<select
class="text-sm bg-transparent dark:bg-gray-900 outline-hidden rounded-lg px-2"
class="text-sm bg-transparent outline-hidden rounded-lg px-2"
value={data?.config?.share ?? true}
on:change={(e) => {
const value = e.target.value;

View file

@ -395,6 +395,28 @@
<hr class=" border-gray-100/30 dark:border-gray-850/30" />
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Access Grants')}</div>
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Sharing With Users')}
</div>
<Switch bind:state={permissions.access_grants.allow_users} />
</div>
{#if defaultPermissions?.access_grants?.allow_users && !permissions.access_grants.allow_users}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
</div>
<hr class=" border-gray-100/30 dark:border-gray-850/30" />
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Chat Permissions')}</div>
@ -414,6 +436,22 @@
{/if}
</div>
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Web Upload')}
</div>
<Switch bind:state={permissions.chat.web_upload} />
</div>
{#if defaultPermissions?.chat?.web_upload && !permissions.chat.web_upload}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">

View file

@ -208,6 +208,7 @@
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
aria-label={$i18n.t('Search')}
placeholder={$i18n.t('Search')}
/>
</div>
@ -357,6 +358,7 @@
<td class="px-3 py-1 min-w-[7rem] w-28">
<button
class=" translate-y-0.5"
aria-label={$i18n.t('Change User Role')}
on:click={() => {
selectedUser = user;
showEditUserModal = !showEditUserModal;
@ -408,6 +410,7 @@
<Tooltip content={$i18n.t('Chats')}>
<button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
aria-label={$i18n.t('Chats')}
on:click={async () => {
showUserChatsModal = !showUserChatsModal;
selectedUser = user;
@ -421,6 +424,7 @@
<Tooltip content={$i18n.t('Edit User')}>
<button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
aria-label={$i18n.t('Edit User')}
on:click={async () => {
showEditUserModal = !showEditUserModal;
selectedUser = user;
@ -447,6 +451,7 @@
<Tooltip content={$i18n.t('Delete User')}>
<button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
aria-label={$i18n.t('Delete User')}
on:click={async () => {
showDeleteConfirmDialog = true;
selectedUser = user;

View file

@ -133,6 +133,7 @@
<div class=" text-lg font-medium self-center">{$i18n.t('Add User')}</div>
<button
class="self-center"
aria-label={$i18n.t('Close')}
on:click={() => {
show = false;
}}
@ -180,8 +181,9 @@
<div class="flex-1">
<select
class="dark:bg-gray-900 w-full capitalize rounded-lg text-sm bg-transparent dark:disabled:text-gray-500 outline-hidden"
class="w-full capitalize rounded-lg text-sm bg-transparent dark:disabled:text-gray-500 outline-hidden"
bind:value={_user.role}
aria-label={$i18n.t('Role')}
placeholder={$i18n.t('Enter Your Role')}
required
>
@ -200,6 +202,7 @@
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
type="text"
bind:value={_user.name}
aria-label={$i18n.t('Name')}
placeholder={$i18n.t('Enter Your Full Name')}
autocomplete="off"
required
@ -217,6 +220,7 @@
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
type="email"
bind:value={_user.email}
aria-label={$i18n.t('Email')}
placeholder={$i18n.t('Enter Your Email')}
required
/>
@ -231,6 +235,7 @@
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
type="password"
bind:value={_user.password}
aria-label={$i18n.t('Password')}
placeholder={$i18n.t('Enter Your Password')}
autocomplete="off"
required

View file

@ -72,6 +72,7 @@
<div class=" text-lg font-medium self-center">{$i18n.t('Edit User')}</div>
<button
class="self-center"
aria-label={$i18n.t('Close')}
on:click={() => {
show = false;
}}
@ -138,8 +139,9 @@
<div class="flex-1">
<select
class="w-full dark:bg-gray-900 text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
bind:value={_user.role}
aria-label={$i18n.t('Role')}
disabled={_user.id == sessionUser.id}
required
>
@ -158,6 +160,7 @@
class="w-full text-sm bg-transparent outline-hidden"
type="text"
bind:value={_user.name}
aria-label={$i18n.t('Name')}
placeholder={$i18n.t('Enter Your Name')}
autocomplete="off"
required
@ -173,6 +176,7 @@
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
type="email"
bind:value={_user.email}
aria-label={$i18n.t('Email')}
placeholder={$i18n.t('Enter Your Email')}
autocomplete="off"
required
@ -202,6 +206,7 @@
<SensitiveInput
class="w-full text-sm bg-transparent outline-hidden"
type="password"
aria-label={$i18n.t('New Password')}
placeholder={$i18n.t('Enter New Password')}
bind:value={_user.password}
autocomplete="new-password"

View file

@ -6,7 +6,10 @@
let selected = '';
</script>
<div class="min-w-[4.5rem] bg-gray-50 dark:bg-gray-950 flex gap-2.5 flex-col pt-8">
<nav
aria-label="App navigation"
class="min-w-[4.5rem] bg-gray-50 dark:bg-gray-950 flex gap-2.5 flex-col pt-8"
>
<div class="flex justify-center relative">
{#if selected === 'home'}
<div class="absolute top-0 left-0 flex h-full">
@ -16,6 +19,7 @@
<Tooltip content="Home" placement="right">
<button
aria-label="Home"
class=" cursor-pointer {selected === 'home' ? 'rounded-2xl' : 'rounded-full'}"
on:click={() => {
selected = 'home';
@ -44,6 +48,7 @@
</div>
{/if}
<button
aria-label="Chat"
class=" cursor-pointer bg-transparent"
on:click={() => {
selected = '';
@ -63,4 +68,4 @@
<Plus className="size-4" strokeWidth="2" />
</button>
</div> -->
</div>
</nav>

View file

@ -54,7 +54,7 @@
transition={flyAndScale}
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl"
on:click={() => {
uploadFilesHandler();
}}
@ -64,7 +64,7 @@
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl"
on:click={() => {
screenCaptureHandler();
}}

View file

@ -120,6 +120,7 @@
let eventConfirmationInput = false;
let eventConfirmationInputPlaceholder = '';
let eventConfirmationInputValue = '';
let eventConfirmationInputType = '';
let eventCallback = null;
let chatIdUnsubscriber: Unsubscriber | undefined;
@ -356,9 +357,7 @@
}
};
const showMessage = async (message, ignoreSettings = false) => {
await tick();
const showMessage = async (message, scroll = true) => {
const _chatId = JSON.parse(JSON.stringify($chatId));
let _messageId = JSON.parse(JSON.stringify(message.id));
@ -378,18 +377,19 @@
history.currentId = _messageId;
await tick();
await tick();
await tick();
if (($settings?.scrollOnBranchChange ?? true) || ignoreSettings) {
if (($settings?.scrollOnBranchChange ?? true) && scroll) {
const messageElement = document.getElementById(`message-${message.id}`);
if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth' });
messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
await tick();
await tick();
await tick();
saveChatHandler(_chatId, history);
};
@ -525,6 +525,7 @@
eventConfirmationMessage = data.message;
eventConfirmationInputPlaceholder = data.placeholder;
eventConfirmationInputValue = data?.value ?? '';
eventConfirmationInputType = data?.type ?? '';
} else {
console.log('Unknown message type', data);
}
@ -832,6 +833,11 @@
};
const uploadWeb = async (urls) => {
if ($user?.role !== 'admin' && !($user?.permissions?.chat?.web_upload ?? true)) {
toast.error($i18n.t('You do not have permission to upload web content.'));
return;
}
if (!Array.isArray(urls)) {
urls = [urls];
}
@ -1032,12 +1038,16 @@
if (selectedModels.length === 0 || (selectedModels.length === 1 && selectedModels[0] === '')) {
if (availableModels.length > 0) {
if (defaultModels && defaultModels.length > 0) {
// Set from default models
selectedModels = defaultModels.filter((modelId) => availableModels.includes(modelId));
}
// Set to first available model
selectedModels = [availableModels?.at(0) ?? ''];
if (
selectedModels.length === 0 ||
(selectedModels.length === 1 && selectedModels[0] === '')
) {
// Only fall back to first available model if default models didn't resolve
selectedModels = [availableModels?.at(0) ?? ''];
}
} else {
selectedModels = [''];
}
@ -1065,6 +1075,8 @@
chatFiles = [];
params = {};
taskIds = null;
messageQueue = [];
if ($page.url.searchParams.get('youtube')) {
await uploadWeb(`https://www.youtube.com/watch?v=${$page.url.searchParams.get('youtube')}`);
@ -2535,6 +2547,7 @@
input={eventConfirmationInput}
inputPlaceholder={eventConfirmationInputPlaceholder}
inputValue={eventConfirmationInputValue}
inputType={eventConfirmationInputType}
on:confirm={(e) => {
if (e.detail) {
eventCallback(e.detail);

View file

@ -57,6 +57,7 @@
<button
class="self-center pointer-events-auto p-1 rounded-full bg-white dark:bg-gray-850"
aria-label="Close embed"
on:click={() => {
showControls.set(false);
showEmbeds.set(false);

View file

@ -254,6 +254,7 @@
>
{#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();
@ -291,6 +292,7 @@
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') {
@ -301,6 +303,7 @@
<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"

View file

@ -19,9 +19,10 @@
<div class=" dark:text-white">
<div class=" flex items-center justify-between dark:text-gray-100 mb-2">
<div class=" text-lg font-medium self-center font-primary">{$i18n.t('Chat Controls')}</div>
<div class=" text-md self-center font-primary">{$i18n.t('Controls')}</div>
<button
class="self-center"
aria-label={$i18n.t('Close chat controls')}
on:click={() => {
dispatch('close');
}}

View file

@ -824,6 +824,19 @@
shiftKey = true;
}
// Cmd/Ctrl+Shift+L to toggle dictation
if (e.key.toLowerCase() === 'l' && (e.metaKey || e.ctrlKey) && e.shiftKey) {
e.preventDefault();
if (recording) {
// Confirm and stop recording
document.getElementById('confirm-recording-button')?.click();
} else {
// Start recording (same logic as voice-input-button click)
document.getElementById('voice-input-button')?.click();
}
return;
}
if (e.key === 'Escape') {
console.log('Escape');
dragged = false;

View file

@ -56,6 +56,9 @@
fileUploadCapableModels.length === selectedModels.length &&
($user?.role === 'admin' || $user?.permissions?.chat?.file_upload);
let webUploadEnabled = true;
$: webUploadEnabled = $user?.role === 'admin' || ($user?.permissions?.chat?.web_upload ?? true);
$: if (!fileUploadEnabled && files.length > 0) {
files = [];
}
@ -138,7 +141,7 @@
className="w-full"
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
class="flex gap-2 items-center px-3 py-1.5 text-sm select-none cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
@ -162,7 +165,7 @@
className="w-full"
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
class="flex gap-2 items-center px-3 py-1.5 text-sm select-none cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
@ -185,19 +188,17 @@
</Tooltip>
<Tooltip
content={fileUploadCapableModels.length !== selectedModels.length
? $i18n.t('Model(s) do not support file upload')
: !fileUploadEnabled
? $i18n.t('You do not have permission to upload files.')
: ''}
content={!webUploadEnabled
? $i18n.t('You do not have permission to upload web content.')
: ''}
className="w-full"
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl {!fileUploadEnabled
class="flex gap-2 items-center px-3 py-1.5 text-sm select-none cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl {!webUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
if (fileUploadEnabled) {
if (webUploadEnabled) {
showAttachWebpageModal = true;
}
}}
@ -217,7 +218,7 @@
className="w-full"
>
<button
class="flex gap-2 w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
class="flex gap-2 w-full items-center px-3 py-1.5 text-sm select-none cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
@ -302,7 +303,7 @@
{#if fileUploadEnabled}
{#if $config?.features?.enable_google_drive_integration}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl"
class="flex gap-2 items-center px-3 py-1.5 text-sm select-none cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl"
on:click={() => {
uploadGoogleDriveHandler();
}}
@ -339,7 +340,7 @@
{#if $config?.features?.enable_onedrive_integration && ($config?.features?.enable_onedrive_personal || $config?.features?.enable_onedrive_business)}
<button
class="flex gap-2 w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
class="flex gap-2 w-full items-center px-3 py-1.5 text-sm select-none cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
@ -448,7 +449,7 @@
{:else if tab === 'knowledge'}
<div in:fly={{ x: 20, duration: 150 }}>
<button
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm select-none cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
on:click={() => {
tab = '';
}}
@ -467,7 +468,7 @@
{:else if tab === 'notes'}
<div in:fly={{ x: 20, duration: 150 }}>
<button
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm select-none cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
on:click={() => {
tab = '';
}}
@ -486,7 +487,7 @@
{:else if tab === 'chats'}
<div in:fly={{ x: 20, duration: 150 }}>
<button
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm select-none cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
on:click={() => {
tab = '';
}}
@ -505,7 +506,7 @@
{:else if tab === 'microsoft_onedrive'}
<div in:fly={{ x: 20, duration: 150 }}>
<button
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm select-none cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
on:click={() => {
tab = '';
}}
@ -521,7 +522,7 @@
{#if $config?.features?.enable_onedrive_personal}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl text-left"
class="flex gap-2 items-center px-3 py-1.5 text-sm select-none cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl text-left"
on:click={() => {
uploadOneDriveHandler('personal');
}}
@ -534,7 +535,7 @@
{#if $config?.features?.enable_onedrive_business}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl text-left"
class="flex gap-2 items-center px-3 py-1.5 text-sm select-none cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl text-left"
on:click={() => {
uploadOneDriveHandler('organizations');
}}

View file

@ -368,7 +368,17 @@
let maxVisibleItems = 300;
$: maxVisibleItems = Math.floor(containerWidth / 5); // 2px width + 0.5px gap
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
e.preventDefault();
stopRecording();
onCancel();
}
};
onMount(() => {
window.addEventListener('keydown', handleKeyDown);
// listen to width changes
resizeObserver = new ResizeObserver(() => {
VISUALIZER_BUFFER_LENGTH = Math.floor(window.innerWidth / 4);
@ -385,6 +395,7 @@
});
onDestroy(() => {
window.removeEventListener('keydown', handleKeyDown);
// remove resize observer
resizeObserver.disconnect();
});
@ -547,6 +558,7 @@
</div>
{:else}
<button
id="confirm-recording-button"
type="button"
class="p-1.5 bg-indigo-500 text-white dark:bg-indigo-500 dark:text-blue-950 rounded-full"
on:click={async () => {

View file

@ -392,12 +392,7 @@
delete history.messages[id];
});
await tick();
showMessage({ id: parentMessageId });
// Update the chat
await updateChat();
showMessage({ id: parentMessageId }, false);
};
const triggerScroll = () => {

View file

@ -163,6 +163,10 @@
<div class=" py-1 -mx-0.5 w-full flex gap-1 items-center flex-wrap">
<button
class="text-xs font-medium text-gray-600 dark:text-gray-300 px-3.5 h-8 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition flex items-center gap-1 border border-gray-50 dark:border-gray-850/30"
aria-label={citations.length === 1
? $i18n.t('Toggle 1 source')
: $i18n.t('Toggle {{COUNT}} sources', { COUNT: citations.length })}
aria-expanded={showCitations}
on:click={() => {
showCitations = !showCitations;
}}
@ -197,6 +201,9 @@
{#each citations as citation, idx}
<button
id={`source-${id}-${idx + 1}`}
aria-label={$i18n.t('View source: {{name}}', {
name: decodeString(citation.source.name)
})}
class="no-toggle outline-hidden flex dark:text-gray-300 bg-transparent text-gray-600 rounded-xl gap-1.5 items-center"
on:click={() => {
showCitationModal = true;

View file

@ -130,6 +130,7 @@
</div>
<button
class="self-center"
aria-label={$i18n.t('Close citation modal')}
on:click={() => {
show = false;
}}

View file

@ -1,6 +1,9 @@
<script lang="ts">
import { getContext } from 'svelte';
import { decodeString } from '$lib/utils';
const i18n = getContext('i18n');
export let id;
export let title: string = 'N/A';
@ -37,6 +40,7 @@
{#if title !== 'N/A'}
<button
aria-label={$i18n.t('View source: {{title}}', { title: formattedTitle(decodeString(title)) })}
class="text-[10px] w-fit translate-y-[2px] px-2 py-0.5 dark:bg-white/5 dark:text-white/80 dark:hover:text-white bg-gray-50 text-black/80 hover:text-black transition rounded-xl"
on:click={() => {
onClick(id);

View file

@ -48,6 +48,7 @@
<LinkPreview.Root openDelay={0} bind:open={openPreview}>
<LinkPreview.Trigger>
<button
aria-label={`${getDisplayTitle(formattedTitle(decodeString(sourceIds[token.ids[0] - 1])))} +${(token?.ids ?? []).length - 1} more sources`}
class="text-[10px] w-fit translate-y-[2px] px-2 py-0.5 dark:bg-white/5 dark:text-white/80 dark:hover:text-white bg-gray-50 text-black/80 hover:text-black transition rounded-xl"
on:click={() => {
openPreview = !openPreview;

View file

@ -121,6 +121,7 @@
<!-- <div class=" text-sm">{$i18n.t('Tell us more:')}</div> -->
<button
aria-label={$i18n.t('Close feedback')}
on:click={() => {
show = false;
}}
@ -135,6 +136,7 @@
<!-- 1-10 scale -->
{#each Array.from({ length: 10 }).map((_, i) => i + 1) as rating}
<button
aria-label={$i18n.t('Rate {{rating}} out of 10', { rating })}
class="size-7 text-sm border border-gray-100/30 dark:border-gray-850/30 hover:bg-gray-50 dark:hover:bg-gray-850 {detailedRating ===
rating
? 'bg-gray-100 dark:bg-gray-800'
@ -218,6 +220,7 @@
bind:value={comment}
class="w-full text-sm px-1 py-2 bg-transparent outline-hidden resize-none rounded-xl"
placeholder={$i18n.t('Feel free to add specific details')}
aria-label={$i18n.t('Additional feedback comments')}
rows="3"
/>
</div>

View file

@ -612,6 +612,7 @@
class=" flex w-full message-{message.id}"
id="message-{message.id}"
dir={$settings.chatDirection}
style="scroll-margin-top: 3rem;"
>
<div class={`shrink-0 ltr:mr-3 rtl:ml-3 hidden @lg:flex mt-1 `}>
<ProfileImage

View file

@ -15,18 +15,16 @@
<div class="flex flex-col text-left gap-1 mt-1.5">
{#each followUps as followUp, idx (idx)}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<Tooltip content={followUp} placement="top-start" className="line-clamp-1">
<div
class=" py-1.5 bg-transparent text-left text-sm flex items-center gap-2 text-gray-500 dark:text-gray-400 hover:text-black dark:hover:text-white transition cursor-pointer"
<button
class=" py-1.5 bg-transparent text-left text-sm flex items-center gap-2 text-gray-500 dark:text-gray-400 hover:text-black dark:hover:text-white transition cursor-pointer w-full"
on:click={() => onClick(followUp)}
aria-label={followUp}
aria-label={$i18n.t('Follow up: {{question}}', { question: followUp })}
>
<div class="line-clamp-1">
{followUp}
</div>
</div>
</button>
</Tooltip>
{#if idx < followUps.length - 1}

View file

@ -53,6 +53,7 @@
<div class="ml-2 self-center flex items-center">
<button
aria-label={$i18n.t('Submit suggestion')}
class="{inputValue !== ''
? '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 self-center"
@ -78,7 +79,7 @@
</div>
<hr class="border-gray-50/30 dark:border-gray-800/30 my-1 mx-2" />
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
onRegenerate();
show = false;
@ -103,7 +104,7 @@
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
onRegenerate($i18n.t('Add Details'));
}}
@ -113,7 +114,7 @@
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
onRegenerate($i18n.t('More Concise'));
}}

View file

@ -31,6 +31,8 @@
<div class="text-sm flex flex-col w-full">
<button
class="w-full"
aria-label={$i18n.t('Toggle status history')}
aria-expanded={showHistory}
on:click={() => {
showHistory = !showHistory;
}}

View file

@ -67,7 +67,7 @@
<div class="w-fit">
<img
src="https://www.google.com/s2/favicons?sz=32&domain={item.link}"
alt="favicon"
alt="{item?.title ?? item.link} favicon"
class="size-3.5"
/>
</div>
@ -107,7 +107,7 @@
<div class="w-fit">
<img
src="https://www.google.com/s2/favicons?sz=32&domain={url}"
alt="favicon"
alt="{url} favicon"
class="size-3.5"
/>
</div>

View file

@ -125,6 +125,7 @@
class=" flex w-full user-message group"
dir={$settings.chatDirection}
id="message-{message.id}"
style="scroll-margin-top: 3rem;"
>
{#if !($settings?.chatBubble ?? true)}
<div class={`shrink-0 ltr:mr-3 rtl:ml-3 mt-1`}>

View file

@ -57,7 +57,7 @@
{#if model?.preset || model?.info?.base_model_id ? model?.info?.user_id === $user?.id : $user?.role === 'admin'}
<DropdownMenu.Item
type="button"
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2"
class="select-none flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
@ -81,7 +81,7 @@
<DropdownMenu.Item
type="button"
aria-pressed={($settings?.pinnedModels ?? []).includes(model?.id)}
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2"
class="select-none flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
@ -107,7 +107,7 @@
<DropdownMenu.Item
type="button"
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2"
class="select-none flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
@ -126,7 +126,7 @@
<DropdownMenu.Item
type="button"
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2"
class="select-none flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();

View file

@ -402,7 +402,9 @@
class="relative w-full {($settings?.highContrastMode ?? false)
? ''
: 'outline-hidden focus:outline-hidden'}"
aria-label={selectedModel ? $i18n.t('Selected model: {{modelName}}', { modelName: selectedModel.label }) : placeholder}
aria-label={selectedModel
? $i18n.t('Selected model: {{modelName}}', { modelName: selectedModel.label })
: placeholder}
id="model-selector-{id}-button"
>
<div

View file

@ -133,7 +133,9 @@
await temporaryChatEnabled.set(!$temporaryChatEnabled);
}
await goto('/');
if ($page.url.pathname !== '/') {
await goto('/');
}
// add 'temporary-chat=true' to the URL
if ($temporaryChatEnabled) {

View file

@ -56,6 +56,9 @@
<button
class={data?.message?.favorite ? '' : 'invisible group-hover:visible'}
aria-label={data?.message?.favorite
? $i18n.t('Remove from favorites')
: $i18n.t('Add to favorites')}
on:click={() => {
data.message.favorite = !(data?.message?.favorite ?? false);
}}

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