Compare commits

..
46 Commits
Author SHA1 Message Date
bhethermanandClaude Sonnet 5 966140c3ef Give the MTP llama-server 4 parallel slots instead of 1
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Canceled after 0s
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Canceled after 0s
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args: free_disk:false name:main suffix:]) (push) Canceled after 0s
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true USE_CUDA_VER=cu126 free_disk:true name:cuda126 suffix:-cuda126]) (push) Canceled after 0s
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Canceled after 0s
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Canceled after 0s
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Canceled after 0s
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args: free_disk:false name:main suffix:]) (push) Canceled after 0s
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true USE_CUDA_VER=cu126 free_disk:true name:cuda126 suffix:-cuda126]) (push) Canceled after 0s
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Canceled after 0s
Frontend Build / Format & Build (push) Canceled after 0s
Frontend Build / Unit Tests (push) Canceled after 0s
Release to PyPI / release (push) Canceled after 0s
Release / publish (push) Canceled after 0s
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Canceled after 0s
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Canceled after 0s
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Canceled after 0s
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Canceled after 0s
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Canceled after 0s
Create and publish Docker images with specific build args / notify-helm-charts (push) Canceled after 0s
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Canceled after 0s
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Canceled after 0s
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Canceled after 0s
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Canceled after 0s
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Canceled after 0s
With -np 1, open-webui's browser chat and the voice assistant (and
anything else hitting this OpenAI-compatible endpoint) contended for
a single slot -- whichever touched it last evicted the other's live
KV cache, forcing a multi-second full prompt reprocess on the next
request from the loser.

n_ctx_slot = n_ctx / n_parallel, so ctx is quadrupled to 131072 (this
model's native n_ctx_train) to keep each of the 4 slots at the same
32768 budget as before. Measured KV cache cost is only 271 MiB per
slot, so this only costs ~810 MiB more VRAM than the old config.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 13:40:50 -04:00
bhethermanandClaude Sonnet 5 6f583deff9 Add relaxed-MTP llama-server as a new connection, surface generation speed
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args: free_disk:false name:main suffix:]) (push) Canceled after 0s
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true USE_CUDA_VER=cu126 free_disk:true name:cuda126 suffix:-cuda126]) (push) Canceled after 0s
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Canceled after 0s
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Canceled after 0s
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Canceled after 0s
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args: free_disk:false name:main suffix:]) (push) Canceled after 0s
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true USE_CUDA_VER=cu126 free_disk:true name:cuda126 suffix:-cuda126]) (push) Canceled after 0s
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Canceled after 0s
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Canceled after 0s
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Canceled after 0s
Frontend Build / Format & Build (push) Canceled after 0s
Frontend Build / Unit Tests (push) Canceled after 0s
Release to PyPI / release (push) Canceled after 0s
Release / publish (push) Canceled after 0s
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Canceled after 0s
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Canceled after 0s
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Canceled after 0s
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Canceled after 0s
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Canceled after 0s
Create and publish Docker images with specific build args / notify-helm-charts (push) Canceled after 0s
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Canceled after 0s
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Canceled after 0s
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Canceled after 0s
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Canceled after 0s
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Canceled after 0s
Wires the custom relaxed-acceptance llama-server (see
../mtp-relaxed-decoding) into the stack as a new llama-mtp service,
registered as an additional OpenAI-compatible connection alongside
Ollama. ollama-auth now proxies to llama-mtp instead of the now-empty
Ollama, so LAN clients (e.g. Home Assistant's voice pipeline) keep
working against the same URL/token with no reconfiguration.

Also merges llama.cpp's `timings` extension (dropped by the generic
OpenAI response schema) into message.usage and surfaces it as a
labeled tok/s badge, so relaxed-MTP responses get the same visible
generation-speed info Ollama responses already had.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 11:33:12 -04:00
bhetherman 7eff527d16 Add Authentik OIDC login, ollama-auth LAN proxy, and asr/tts sidecar wiring
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args: free_disk:false name:main suffix:]) (push) Canceled after 0s
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true USE_CUDA_VER=cu126 free_disk:true name:cuda126 suffix:-cuda126]) (push) Canceled after 0s
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Canceled after 0s
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Canceled after 0s
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Canceled after 0s
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args: free_disk:false name:main suffix:]) (push) Canceled after 0s
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true USE_CUDA_VER=cu126 free_disk:true name:cuda126 suffix:-cuda126]) (push) Canceled after 0s
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Canceled after 0s
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Canceled after 0s
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Canceled after 0s
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Canceled after 0s
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Canceled after 0s
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Canceled after 0s
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Canceled after 0s
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Canceled after 0s
Create and publish Docker images with specific build args / notify-helm-charts (push) Canceled after 0s
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Canceled after 0s
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Canceled after 0s
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Canceled after 0s
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Canceled after 0s
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Canceled after 0s
Frontend Build / Format & Build (push) Canceled after 0s
Frontend Build / Unit Tests (push) Canceled after 0s
Release to PyPI / release (push) Canceled after 0s
Release / publish (push) Canceled after 0s
- open-webui: OIDC-only auth via Authentik (password/local login disabled),
  account linking by email
- ollama: GPU reservation, disable idle unload, read-only GGUF import mount
- ollama-auth: nginx bearer-token proxy in front of ollama for LAN access
- docker-compose.audio.yaml + docker-stack.sh: wrap base/gpu/audio compose
  files so asr/tts sidecars (../asr-server, ../tts-server) always come up
  together with the GPU override
2026-08-31 00:46:48 -04:00
Tim Baek d3e8bf3405 Merge pull request #29027 from open-webui/dev
0.11.1
2026-08-25 17:17:36 -04:00
Tim Baek 01f4282f1f Merge pull request #27590 from open-webui/dev
refac
2026-07-27 05:47:11 -05:00
Tim Baek f9590b8017 Merge pull request #27362 from open-webui/dev
0.11.0
2026-07-27 04:30:03 -05:00
ecd48e2f71 0.10.2 (#26642)
* i18n: add pt-BR translations for newly added UI items and consistency pass (#26391)

New **pt-BR** translations for items introduced in the latest releases, plus a consistency/quality pass across existing strings (grammar, tone, capitalization, pluralization). Placeholders and hotkeys preserved. No logic changes.

* refac

* i18n(th-TH): translate missing Thai keys/fix typo (#26406)

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* fix: use updated_at for sidebar chat timestamp (#26454)

The sidebar time-ago indicator rendered `created_at`, so the relative time stayed pinned to the chat's creation age and never reflected new activity. After sending a message the chat would jump to the top of the list (which sorts by `updated_at`) while still showing a stale label such as "3w", which is confusing.

The indicator was originally added using `updated_at` and was inadvertently switched to `created_at` during a later refactor. Restore `updated_at` (falling back to `created_at` when absent) so the timestamp matches the list ordering and updates whenever a chat is modified.

Fixes #26451

* fix: use absolute indexURL for pyodide sandbox (#26625)

* i18n: fix Spanish relative time labels (#26463)

* Update and fix Catalan translation.json (#26409)

* refac

* refac

Co-Authored-By: Syed Osama Ali Shah <86572800+osamaali313@users.noreply.github.com>

* refac

Co-Authored-By: Syed Osama Ali Shah <86572800+osamaali313@users.noreply.github.com>

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* fix: derive content from output for search (#26405)

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* Update CHANGELOG.md (#26641)

* Update CHANGELOG.md

* Update CHANGELOG.md

---------

Co-authored-by: Tim Baek <tim@openwebui.com>

---------

Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Sicknine <156204309+SNaytiP@users.noreply.github.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: JuanMa Diaz <torgus@gmail.com>
Co-authored-by: Aleix Dorca <aleixdorca@mac.com>
Co-authored-by: Syed Osama Ali Shah <86572800+osamaali313@users.noreply.github.com>
2026-07-01 03:40:54 -05:00
Tim Baek b711935dd5 Merge pull request #26388 from open-webui/dev
0.10.1
2026-06-29 14:38:32 -05:00
Tim Baek 4d2e13cf2b Merge pull request #26385 from open-webui/dev 2026-06-29 14:17:36 -05:00
Tim Baek 02dc3e689c Merge pull request #25590 from open-webui/dev
refac
2026-06-01 19:10:46 -07:00
Tim Baek b868da6bcd Merge pull request #25589 from open-webui/dev
refac
2026-06-01 19:05:05 -07:00
Tim Baek 90f4b4fcda Merge pull request #25524 from open-webui/dev
refac
2026-06-01 15:01:27 -07:00
+36 b3e9d16b6f 0.9.6 (#25497)
* refac

* refac

* refac: reorganize scripts and ci workflows

* chore: remove unused cypress tests

* chore: remove unused legacy test suite

* refac: deprecate peewee migration layer

The Alembic init migration (7e5b5dc7342b) already creates the
equivalent schema. Peewee migrations are no longer needed for
any version >= 0.3.6.

* refac: remove dead peewee connection wrappers

* refac

* refac

* style: ruff format

* style: standardize os.environ.get to os.getenv

* refac: modernize imports, standardize type hints and docstrings

* refac: modernize type annotations (PEP 604 / PEP 585)

* refac

* feat: kb_exec

* refac

* refac

* refac

* refac

* upd:i18n: es-ES Translation update v0.9.5 (#24651)

es-ES Translation. Update v0.9.5

Added translation of new strings.

* feat: knowledge directory

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* fix: German i18n translations (#24668)

* i18n: Update Swedish (sv-SE) translation — merge with latest dev (#24665)

Co-authored-by: Daniel Nylander <daniel@danielnylander.se>

* refac

* Fix translation for 'Authentication' in Danish (#24645)

* refac

* fix: korean i18n

* i18n: Update catalan translation.json (#24569)

* refac

* fix: validate folder_id ownership on chat create + folder-update endpoints (#24588)

POST /api/v1/chats/new and POST /api/v1/chats/{id}/folder accepted a
caller-supplied folder_id with no validation — neither ownership, nor
existence, nor UUID format. The row was persisted with the supplied
value verbatim, so the DB ended up with chat rows whose folder_id
referenced another user's folder, a non-existent UUID, or even a
non-UUID string.

No read path surfaces this across users — every chat-folder read is
user_id-filtered on both sides — so this is referential-integrity
hardening rather than a security boundary fix. But there's no reason
to accept dangling references either, and the downstream consumers
shouldn't have to assume the column is clean.

Add a Folders.get_folder_by_id_and_user_id() lookup at both writers:
if a folder_id is supplied, it must match a folder owned by the
caller. None remains allowed (chat-without-folder is the default).
Non-existent and non-UUID values fall through to 404.

Reported by ShigekiTsuchiyama in GHSA-4vrg-2vcq-q7jc.

Co-authored-by: ShigekiTsuchiyama <ShigekiTsuchiyama@users.noreply.github.com>

* refac

* refac

* refac

* refac

* fix: enforce features.direct_tool_servers on chat-completion tool_servers (#24693)

* fix: enforce features.direct_tool_servers on chat-completion tool_servers

The features.direct_tool_servers per-user permission was correctly
enforced on the storage path (routers/users.py user/settings/update,
which strips toolServers from saved settings when the caller lacks the
permission), but the inference path (/api/chat/completions) popped
tool_servers straight from the request body into metadata with no
permission check. The middleware (utils/middleware.py:2799) then
consumed direct_tool_servers to inject system_prompt into the message
array and register external tool specs that get invoked during the
completion. End result: any authenticated user could bypass the
admin-set per-user feature toggle and use inline tool_servers in their
chat-completion requests, even when admin had explicitly denied the
permission.

Default for USER_PERMISSIONS_FEATURES_DIRECT_TOOL_SERVERS is False
(config.py:2750), so under default config no regular user is supposed
to be able to use direct tool servers — making this a real boundary
bypass on out-of-the-box deployments rather than a corner case.

Mirror the storage-side behaviour at the inference entry point: pop
tool_servers from the request body, then silently drop the value if
the caller is non-admin and lacks features.direct_tool_servers. Admins
always pass; users with the explicit grant always pass; everyone else
gets None propagated into metadata, which the middleware already
handles as the no-tool-servers case.

Reported by berkant-koc in GHSA-f582-c373-jjf6.

Co-authored-by: berkant-koc <berkant-koc@users.noreply.github.com>

* chore: trim verbose comment on tool_servers permission check

---------

Co-authored-by: berkant-koc <berkant-koc@users.noreply.github.com>

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* fix: legacy peewee tables fk

* refac

Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>

* fix: default optional env vars used with bash `,,` in start.sh (#24683)

start.sh runs with `set -euo pipefail`, but three call sites added in
070ab2650 (refac: reorganize scripts and ci workflows) reference
optional env vars via bash's `,,` lowercase expansion without any
default. Containers that don't set these vars — the default for every
deployment that isn't explicitly opting into Playwright / bundled
Ollama / CUDA — crash on startup with:

    start.sh: line 15: WEB_LOADER_ENGINE: unbound variable

(and the same for USE_OLLAMA_DOCKER, USE_CUDA_DOCKER once the first
were set in turn.) Reported in open-webui#24560 by urbenlegend.

The same refactor correctly defaulted every other optional env var
with `${VAR:-…}`. The three `,,` references slipped through because
bash can't combine `:-default` with `,,` in a single substitution —
`${VAR:-default,,}` makes the default literal `,,`, not what's wanted.

Fix: normalise the three vars in a one-line preamble with `${VAR:=}`,
which assigns an empty default if unset. The downstream `${VAR,,}`
expressions stay exactly as Tim wrote them, preserving the file's
visual style and matching the existing `${VAR:-…}` idiom for "this
variable is optional".

* i18n: update Russian translations (#24728)

* Update SECURITY.md (#24726)

* fix: tag composite pk in migration (#24722)

* feat(ui): add emoji picker to rich text formatting toolbar (#24704)

* fix: wire workspace.skills into the sidebar + workspace-index gates (#24729)

Reported by bwgabrielsusai on #24719: granting a user only
`workspace.skills` doesn't show the Workspace menu, and visiting
`/workspace` directly bounces them to `/`.

The per-route guard in `/workspace/+layout.svelte` already covered
skills, but two earlier gates in the chain didn't:

* `Sidebar.svelte` case 'workspace' OR'd models/knowledge/prompts/tools
  to decide menu visibility — skills was missing, so the entry never
  rendered for skills-only users.
* `/workspace/+page.svelte` redirect chain picked the first available
  section — skills was missing, so the fallback `goto('/')` fired.

Adding skills to both.

* refac

* refac

* chore: pyodide

* fix: get_image_base64_from_file_id

* refac

* i18n: update Irish translation (#24883)

* refac

* refactor: remove dead frontend API wrappers with no backend route (#24792)

These 19 exported wrappers are dead: each appears exactly once in the
codebase (its own definition), nothing imports or calls any of them, and
none has a corresponding backend route. They are leftovers from settings
that were consolidated server-side into /auths/admin/config,
/openai/config, /ollama/config and /api/config:

- index.ts: getModelFilterConfig, updateModelFilterConfig,
  getCommunitySharingEnabledStatus, toggleCommunitySharingEnabledStatus,
  getModelConfig, updateModelConfig (+ orphaned GlobalModelConfig type)
- auths: getSignUpEnabledStatus, toggleSignUpEnabledStatus,
  getDefaultUserRole, updateDefaultUserRole, getJWTExpiresDuration,
  updateJWTExpiresDuration
- openai: getOpenAIUrls, updateOpenAIUrls, getOpenAIKeys, updateOpenAIKeys
- ollama: updateOllamaUrls
- prompts: restorePromptFromHistory
- folders: updateFolderItemsById (+ orphaned FolderItems type)

Shared types (ModelConfig/ModelMeta/ModelParams) and all live wrappers
are untouched. Removal is import-safe: nothing referenced these.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: remove unused GET /evaluations/feedbacks/all endpoint (#24778)

GET /evaluations/feedbacks/all returned the entire feedback table in a
single response (flagged as a Medium OOM risk for admins in
open-webui#22206). Its only frontend wrapper, getAllFeedbacks in
src/lib/apis/evaluations/index.ts, is dead: nothing imports or calls it
anywhere in the codebase. The endpoint is a redundant view-only twin of
GET /evaluations/feedbacks/all/export, which is what the admin Feedbacks
UI actually uses.

Removes the endpoint, the now-unused FeedbackResponse import in the
evaluations router, and the dead getAllFeedbacks frontend wrapper. The
shared Feedbacks.get_all_feedbacks data-layer method is kept, since the
live /feedbacks/all/export endpoint still uses it.

Ref: open-webui#22206

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: remove unused POST /api/v1/utils/markdown endpoint (#24779)

POST /utils/markdown rendered a markdown string to HTML server-side. Its
only frontend wrapper, getHTMLFromMarkdown in src/lib/apis/utils/index.ts,
is dead: nothing imports or calls it, the route is hit by no other code
path, and the path string appears nowhere else in the repo (no direct
fetch, no test, no docs). Markdown is rendered client-side in the UI, so
this endpoint was redundant.

Fully self-contained removal: the endpoint, its MarkdownForm model, the
now-orphaned 'import markdown' in the utils router (used only here), and
the dead getHTMLFromMarkdown wrapper. Nothing else depends on any of them.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refac

* refac

* refac

* refac

Co-Authored-By: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>

* refactor: remove unused DELETE /chats/{id}/tags/all endpoint (#24785)

The bulk-clear-chat-tags endpoint's only frontend wrapper,
deleteTagsById in src/lib/apis/chats/index.ts, is dead: nothing imports
or calls it, the path is referenced nowhere else, and the route handler
has no internal caller. Removes the route handler, the dead wrapper, and
the now-orphaned Chats.delete_all_tags_by_id_and_user_id model method
(its sole caller was this route). The shared
Chats.delete_orphan_tags_for_user method is untouched.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refac: audio

* fix: pass subscription_key and endpoint in bing.py CLI search_bing() call (#24768)

The __main__ block called search_bing() with 4 positional arguments, but
the function requires 5 (subscription_key, endpoint, locale, query,
count). Running `python -m open_webui.retrieval.web.bing` raised a
TypeError and, before failing, silently misrouted every argument. Read
the key/endpoint from environment variables, matching config.py defaults.

Closes #24765

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: check destination calendar write access on event update (#24764)

update_event only verified write access on the event's source calendar.
CalendarEventUpdateForm accepts a new calendar_id which the model layer
applies unconditionally, so a user with write access to their own calendar
could move (inject) an event into any other user's calendar. Mirror the
destination check create_event already performs.

* refactor: remove dead generateFollowUps frontend wrapper (#24794)

generateFollowUps in src/lib/apis/index.ts is dead: it appears only at
its own definition, nothing imports or calls it, and it targets a
non-existent path (/api/v1/tasks/follow_ups/completions, plural) while
the real route is /tasks/follow_up/completions (singular). Follow-up
suggestions are generated server-side in the chat-completion middleware
and delivered over the chat:message:follow_ups websocket event, so this
wrapper was never on the live path.

Removes only the dead wrapper. The backend POST /tasks/follow_up/completions
endpoint is intentionally kept: it is a member of the actively-used
/tasks/*/completions family (title, tags, emoji, queries, moa) and its
handler delegates to the core generate_follow_ups function.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: validate Playwright navigations and gate redirects in web loader (#24756)

SafePlaywrightURLLoader validated only the initially submitted URL and
then let the browser follow HTTP redirects and client-side navigations
without re-checking them, so a public URL could redirect into the
internal network (cloud metadata, RFC1918, loopback). Intercept
document-type requests, re-run validate_url on each, and apply the same
redirect policy as the requests loader (blocked unless
AIOHTTP_CLIENT_ALLOW_REDIRECTS). Sub-resource requests pass through
unchanged so page rendering performance is unaffected.

Co-authored-by: POV9en <POV9en@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refac

* refac

* refac

* enh: linkup

* fix: log expected fetch/transcript/tool-server failures as warnings (#24903)

* fix: emit [DONE] for AsyncGenerator pipe returns (#24763)

* fix: respect access_type in shared-chat file authorization branch (#24755)

has_access_to_file granted access whenever the file was attached to a
shared chat the user could read, ignoring the requested access_type. A
read-only shared-chat recipient therefore satisfied write and delete
checks and could delete or mutate the chat owner's attached file. Gate
the shared-chat branch on read access, matching the channels branch
directly above it.

Co-authored-by: oxsignal <oxsignal@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refac

* refac

* fix: mitigate DNS rebinding in web loader fetch paths (#24759)

validate_url() resolves DNS to check IPs but discards the result; the
HTTP client resolves again independently.  Between those two lookups an
attacker can swap the DNS record from a public IP to an internal one
(DNS rebinding).

Push the IP-is-global check into the actual connection layer so the
validated resolution is the one used for the TCP connect:

- aiohttp (_fetch): _SSRFSafeResolver wraps DefaultResolver and rejects
  non-global IPs at resolve time (zero TOCTOU window).
- requests (_scrape): _SSRFSafeAdapter mounts custom urllib3 connection
  classes whose _new_conn resolves, validates, and connects to the
  validated IP in one shot (zero TOCTOU window).

Both paths respect ENABLE_RAG_LOCAL_WEB_FETCH (skip validation when on).

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* fix: disable redirect following in OAuth picture fetch (SSRF) (#24809)

_process_picture_url validated the initial picture URL with validate_url()
but then aiohttp followed 3xx redirects without re-validating the target,
so a validate_url-passing public URL could 302 to an internal address and
the body was base64-stored in the user's profile_image_url. This is the
sixth call site of the CVE-2026-45401 redirect-bypass cohort; the other
five already pass allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS. Apply
the same.

* refac

Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>

* Run transcode_audio_to_mp3 in a thread to avoid blocking (#24876)

This incorporates the transcoding implementation in #24145.

* refac

Co-Authored-By: Sergey Zinchenko <sergey.zinchenko.rnd@gmail.com>

* refactor: remove unused GET /prompts/command/{command} endpoint (#24782)

The lookup-prompt-by-command endpoint's only frontend wrapper,
getPromptByCommand, is dead: nothing imports or calls it, the path is
referenced nowhere else, and the route handler has no internal caller
(slash-command resolution happens client-side from the loaded prompt
list). Removes the route handler, its section header, and the dead
wrapper.

The shared Prompts.get_prompt_by_command data-layer method is kept: it
is still used by create/update prompt validation (prompts.py:175, 275,
340). PromptAccessResponse and AccessGrants are untouched.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refac

* refac: kb sync

* refac

* refac

* refac

* refac

* refac

* refac: clean up Redis sentinel utilities and import grouping

* fix: resolve NameError for redis_sentinels in session_cleanup_lock

The variable was renamed to ws_sentinels but session_cleanup_lock
still referenced the old name, causing a startup crash.

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* Update knowledge.py (#25053)

* refac

* refac

* fix(prompts): resolve undefined session variable in _get_access_grants and _to_prompt_model (#25129)

Both _get_access_grants and _to_prompt_model referenced an undefined
local variable 'session' instead of the 'db' parameter passed to each
method. Because these helpers are called outside of any
'async with get_async_db_context()' block, 'session' did not exist in
their scope, causing a NameError on every prompt fetch.

The NameError was silently swallowed by the broad 'except Exception'
clause in get_prompt_by_id, which returned None — causing the frontend
[id]/+page.svelte to immediately redirect back to /workspace/prompts
rather than rendering the prompt editor.

Also adds the missing 'logging' import and module-level 'log' logger,
which was referenced (but never imported) in insert_new_prompt,
update_prompt_version, and delete_prompt_by_id.

* fix: add knowledge_id access check in search_knowledge_files (BOLA) (#25113)

When called without attached model knowledge and given a caller-supplied knowledge_id, search_knowledge_files passed it straight to Knowledges.search_files_by_id, which does not enforce ownership on knowledge_id. An authenticated user who happened to know a target UUID could enumerate file metadata (filename, file id, KB id, KB name, updated_at) from any knowledge base, bypassing the AccessGrants permission model.

Mirror the same admin/owner/AccessGrants check the attached-KB branch already uses, matching the sibling query_knowledge_files function.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refac

* fix(auth): use request.scope["path"] to prevent CVE-2026-48710 (BadHost) (#25123)

Starlette reconstructs request.url.path from the HTTP Host header without
validation. An attacker can inject a path into the Host header to make
request.url.path return a different value than the path Starlette routes on.

The API key endpoint restriction check was using request.url.path to decide
whether to allow or deny access — making it bypassable via a crafted Host
header on any Starlette version prior to 1.0.1.

Fix: replace request.url.path with request.scope["path"], which reads the
raw ASGI scope path that Starlette uses for routing. This value is set by
the ASGI server from the actual request path and cannot be injected via
HTTP headers, making it safe regardless of Starlette version.

Affected code path:
  get_current_user_by_api_key() in backend/open_webui/utils/auth.py
  (only triggered when ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS is enabled)

References:
  CVE-2026-48710 / BadHost
  https://arstechnica.com/information-technology/2026/05/millions-of-ai-agents-imperiled-by-critical-vulnerability-in-open-source-package/

* I18n/improve chinese translation (#25114)

* i18n: improve zh-CN translation

* i18n: improve zh-TW translation

* refac

* refac

* refac

* refac

* fix: harden model profile image against SVG stored XSS (#25060)

ModelMeta.profile_image_url now runs validate_profile_image_url, rejecting SVG/script data URIs (matching UserUpdateForm and ChannelWebhookForm). The /model/profile/image endpoint enforces the PROFILE_IMAGE_ALLOWED_MIME_TYPES allowlist and sets X-Content-Type-Options: nosniff, so an SVG data URI can no longer be served inline on-origin. Closes the fourth profile-image XSS sink missed by the user and webhook fixes.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: gate chat-file links by caller access + repair insert_chat_files db arg (#25054)

insert_chat_files() stored any caller-supplied file_id with no ownership
check, so a user could attach another user's file to their own chat and
then read it through the shared-chat access path in has_access_to_file().
Filter file_ids to those the caller owns, is admin for, or can read.

Also repairs an UnboundLocalError introduced in 260ead64d: the existing
duplicate-check referenced `session` before it was assigned (db=session),
so the function threw on every call and no chat_file rows were persisted.

* Update fi-FI translation.json (#24963)

Added missing translations and improved existing ones.

* Update main.py (#25271)

* fix(i18n): add missing Korean plural _one keys for selected/sources/minutes (#25228)

* fix: sanitize mermaid SVG output to prevent stored XSS in file preview (#25219)

renderMermaidDiagram returned raw mermaid SVG, which FilePreview.svelte injects
via wrapper.innerHTML = svg. Mermaid runs with securityLevel: 'loose', so it
neither sanitizes click hrefs (formatUrl skips sanitizeUrl) nor DOMPurifies its
output; a .md file with a click X href "javascript:..." directive (or an
HTML-label payload) therefore executes script in the app origin when previewed.
The chat path was already safe because SVGPanZoom DOMPurifies before rendering;
file preview was not.

Sanitize at the source: renderMermaidDiagram now returns DOMPurify-cleaned SVG
via a shared sanitizeSvg helper (same policy as SVGPanZoom), so every consumer
including the FilePreview innerHTML sink receives safe output.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix: preserve parent_id on chat_message upsert (#25205)

* fix(ui): include reasoning_tags in user settings advanced params save handler (#25204)

* fix(ui): prevent long usernames from overflowing Edit User modal, User Preview modal, and sidebar (#25185)

Long usernames overflow the Edit User modal, User Preview modal header,
and the sidebar user area because the flex containers lack width
constraints.

- EditUserModal: add min-w-0 to the flex-1 container so the existing
  truncate class takes effect
- UserPreviewModal: add min-w-0 and truncate to the title container,
  flex-shrink-0 to the close button so it stays visible
- Sidebar: add truncate to the username display and flex-shrink-0 to
  the avatar container to prevent it from being squeezed

* fix(ui): add voice mode mute shortcut to keyboard shortcuts modal (#25193)

* fix(types): add missing markdown rendering settings to Settings type (#25198)

* i18n: complete Turkish (tr-TR) translation (#25210)

* fix: remove hardcoded WEBUI_SECRET_KEY fallback, require key explicitly (#25218)

The 't0p-s3cr3t' default was dead code on every supported startup path:
start.sh, start_windows.bat and `open-webui serve` all set or
auto-generate WEBUI_SECRET_KEY before the backend imports env.py. It was
only ever reachable by invoking uvicorn directly, which is unsupported
and unsafe (the app would then sign tokens/cookies with a public,
hardcoded key). It also keeps getting reported as a vulnerability because
it looks dangerous, even though it is unreachable in practice.

Drop the fallback (default to '') so an unset key is caught by the
existing WEBUI_AUTH guard, and replace the vague error with a clear,
actionable message explaining that the key is a hard requirement and how
the supported start methods provide it. Exit cleanly via SystemExit
instead of raising a ValueError traceback.

WEBUI_AUTH=False keeps working unchanged (key defaults to '').

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: move bypass_system_prompt off query parameter onto request.state (#25156)

bypass_system_prompt is an internal flag used by utils/middleware.py and utils/chat.py to skip applying the model system prompt on recursive base-model calls, but it was still declared as a positional argument on the openai/ollama chat-completion route handlers, so FastAPI bound it from the query string. Move it to request.state so external clients cannot set it, matching how bypass_filter is handled.

Drop the argument from both route signatures and read getattr(request.state, 'bypass_system_prompt', False); utils/chat.py sets request.state.bypass_system_prompt alongside bypass_filter and drops the kwarg from the two route-handler calls (the recursive self-calls keep it). Mirrors c0385f60b.

Co-authored-by: anishgirianish <161533316+anishgirianish@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* i18n(pl-PL): add missing polish translations (#25176)

* fix(models): gracefully handle legacy svg profile_image_url in ModelMeta validator (#25173)

The SVG-XSS hardening introduced in f5f4b5895 correctly rejects
data:image/svg+xml URIs on new input, but also caused a
pydantic_core.ValidationError when reading pre-existing models from
the database that had SVG data URIs stored as their profile images.

This ValidationError propagated unhandled through _to_model_model and
get_all_models, crashing the entire /api/models endpoint with HTTP 500
and leaving users with no models available in the UI.

Fix:
- Wrap validate_profile_image_url() in a try/except ValueError inside
  ModelMeta.check_profile_image_url. Legacy entries are cleared to None
  with a warning log instead of raising — the /model/profile/image API
  endpoint already falls back to /static/favicon.png when the value is
  empty.
- Default ModelMeta.profile_image_url to None instead of hardcoding
  /static/favicon.png, since the serving endpoint handles the fallback.
- Add a per-model try/except in ModelsTable.get_all_models so that any
  future unexpected validation failure on a single record skips that
  model with an error log rather than aborting the entire list.

* refac

* fix: add null guards to channel Thread and PinnedMessagesModal components (#25209)

Thread.svelte: Add null check for messagesContainerElement in scrollToBottom()
to match the existing pattern in Channel.svelte. Prevents potential TypeError
when the DOM element is not yet bound during rapid thread switches.

PinnedMessagesModal.svelte: Move res.length check inside the if (res) block.
Previously, res.length was accessed unconditionally after a guarded block,
causing TypeError when the API call fails and the .catch() returns null.

* fix(settings): correct presence_penalty and repeat_penalty saving wrong values (#25183)

Both presence_penalty and repeat_penalty in the saveHandler read from
params.frequency_penalty instead of their own values due to a
copy-paste error. This causes users adjusting either parameter to
silently save the frequency_penalty value instead.

* fix(ui): enable custom parameters in user settings and admin model settings (#25200)

Add missing custom={true} prop to AdvancedParams in General.svelte and
ModelSettingsModal.svelte so the 'Add Custom Parameter' option appears
consistently across all advanced parameter surfaces.

Also forward custom_params in the General.svelte save handler so custom
parameters are persisted instead of silently dropped on save.

* i18n : (ms-MY) refine translation and standardise terminology (#25164)

* refac

* refac

* fix: decode terminal proxy path until stable to block multi-encoded traversal (#25157)

_sanitize_proxy_path decoded the proxy path once before the '..' check, so a double-encoded payload (%252e%252e) survived the check as %2e%2e and was then re-decoded into '..' by the upstream terminal server, defeating the traversal guard. Decode until stable so no encoded traversal sequence can reach the upstream. Single-encoded payloads were already rejected; this closes the double (and deeper) encoding bypass.

Co-authored-by: sermikr0 <230672901+sermikr0@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refac

* refac

* chore: Update CHANGELOG.md (#24680)

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* fix: use db instead of undefined session in chats model (#25455)

* fix(ui): use correct 'blur' event name instead of 'blur-sm' in window listeners (#25459)

* fix(ui): correct inverted high-contrast text colors for user message timestamp (#25461)

* refac

* refac

* refac

* refac

* fix: clear usage interval in finally so it cannot leak on send failure (#25478)

getChatEventEmitter starts a setInterval emitting a `usage` socket event
every second; clearInterval ran only after sendMessageSocket resolved, so
any throw/reject left the interval firing for the page lifetime. Each
failed send added another orphaned interval, inflating server-side usage
accounting and growing CPU/memory over a session.

Wrap the send in try/finally so the interval is always cleared, on both
the happy path and any thrown/rejected path. The exception still
propagates unchanged.

Fixes #25465

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: cap profile image data URI size to bound model/avatar bloat (#25476)

* feat: cap profile image data URI size to bound model/avatar bloat

validate_profile_image_url() validated data-URI format (MIME allowlist,
SVG rejection, scheme checks) but never its length, so a valid
data:image/...;base64,<huge> passed for both custom-model icons and user
avatars. Large inline images bloat Postgres and the Redis MODELS hash and
degrade model-list latency.

Add PROFILE_IMAGE_MAX_DATA_URI_SIZE (default 256 KiB, 0 disables) and
reject oversized data URIs in the shared validator, so both model meta
(ModelMeta.profile_image_url) and user avatars (UpdateProfileForm) are
bounded at one chokepoint. ModelMeta already clears invalid values to
None on read, so existing oversized icons stop propagating into the
MODELS hash on the next refresh.

Fixes #25468

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: default PROFILE_IMAGE_MAX_DATA_URI_SIZE to None (no cap)

Per review: opt-in rather than a 256 KiB default. Unset leaves data URIs
uncapped; the validator already skips the check on a falsy value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refac

* fix: don't hang terminal proxy when one forwarding pump exits first (#25479)

The proxy gathered _client_to_upstream and _upstream_to_client with
return_exceptions=True. When upstream sends a graceful CLOSE,
_upstream_to_client returns but gather keeps waiting on
_client_to_upstream, which is blocked in ws.receive() until the browser
disconnects. The handler stays pending and the finally: session.close()
cleanup is deferred, leaking a ClientSession and an open browser socket.

Use asyncio.wait(return_when=FIRST_COMPLETED) and cancel the pending
sibling, so the proxy unwinds as soon as either direction finishes. The
pumps' bare except Exception already lets CancelledError (a BaseException)
propagate, so cancellation is clean and they need no change.

Fixes #25464

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refac

Co-Authored-By: bannert <58707896+bannert1337@users.noreply.github.com>

* refac

Co-Authored-By: Boris Rybalkin <ribalkin@gmail.com>

* fix: cache path traversal via sibling-prefix bypass in serve_cache_file (#25086)

serve_cache_file gated the resolved path with file_path.startswith(os.path.abspath(CACHE_DIR)) without a trailing os.sep, so any path resolving to a sibling whose name starts with the cache-dir basename (e.g. cache_backup, cached_models) passed the prefix check. Authenticated users could read files from such siblings via /cache/../<sibling>/<file>. Appending os.sep to the prefix closes the bypass; deep traversal and absolute paths were already correctly blocked.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: persist outlet filter changes to message output (#24884)

* refac

* fix(images): pass content_type=None to r.json() to accept non-standard MIME types (#24838)

aiohttp's ClientResponse.json() validates the Content-Type header against
'application/json' by default and raises ContentTypeError for any other
value — including 'application/x-ndjson', which Ollama returns for its
OpenAI-compatible /v1/images/generations endpoint.

Pass content_type=None to skip this check while keeping all other parsing
behaviour unchanged.  The fix covers image generation (openai, gemini,
automatic1111 engines) and image editing (openai, gemini engines).

* fix(ui): guard JSON.parse(localStorage) calls with try/catch to prevent UI crashes (#25481)

* fix: don't crash on startup when stdout can't encode the banner (#25482)

The startup banner uses Unicode box-drawing characters. On a stdout that
can't encode them (Windows cp1252, or redirected/headless/pythonw output)
print() raises UnicodeEncodeError and aborts startup. This blocks running
open-webui serve headless on Windows.

Guard the banner print and fall back to a plain ASCII line so startup
always proceeds regardless of the console encoding.

Fixes #24965

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refac

* fix(knowledge): remove premature drag-and-drop upload toast (#25484)

* refac

* fix: don't block first-admin signup on stale ENABLE_SIGNUP (#24821)

Symptom
On a fresh install (zero users) the frontend shows the mandatory
"Create Admin Account" onboarding screen, but POST
/api/v1/auths/signup returns 403 ACCESS_PROHIBITED ("You do not have
permission to access this resource."). Wiping the database does not
help when the config layer is backed by Redis (the value survives in
the Redis/valkey volume), or when only the user table is cleared (the
config row survives in Postgres). The instance is then unrecoverable
through the UI.

Root cause
signup_handler() auto-sets request.app.state.config.ENABLE_SIGNUP =
False immediately after the first admin is created. That value is
persisted by the config layer (the Postgres config table, and Redis
when REDIS_URL is set). On a later zero-user database the persisted
False is read back, so ENABLE_SIGNUP resolves False even though no
users exist. The old gate was:

    if WEBUI_AUTH:
        if not ENABLE_SIGNUP or not ENABLE_LOGIN_FORM:
            if has_users or not ENABLE_INITIAL_ADMIN_SIGNUP:
                403

ENABLE_INITIAL_ADMIN_SIGNUP defaults to False, so with zero users the
inner test (has_users or not ENABLE_INITIAL_ADMIN_SIGNUP) is True, and
a stale ENABLE_SIGNUP=False trips the outer test, producing a 403 on
the only UI path that can create the first admin. The frontend decides
to show onboarding purely from user_count == 0, so frontend and
backend disagree and the instance bricks.

Change
Split the gate by has_users. Subsequent signups (has_users True) are
unchanged: still gated by ENABLE_SIGNUP and ENABLE_LOGIN_FORM. The
first user (has_users False, the bootstrap admin the onboarding screen
invites) is gated only by the admin-chosen ENABLE_LOGIN_FORM (the
documented SSO-only hard-disable) unless ENABLE_INITIAL_ADMIN_SIGNUP is
set. It is no longer gated by ENABLE_SIGNUP, which in the zero-user
state is never an admin decision but the post-first-admin auto-disable
leaking across a database reset.

Why this is safe (full case analysis)
For WEBUI_AUTH the gate has 16 input combinations over (has_users,
ENABLE_SIGNUP, ENABLE_LOGIN_FORM, ENABLE_INITIAL_ADMIN_SIGNUP). Old and
new are identical in 15 of them:
  * All 8 has_users=True cases: both reduce to "403 iff not
    ENABLE_SIGNUP or not ENABLE_LOGIN_FORM". Unchanged.
  * 7 of the 8 has_users=False cases: identical.
The only changed case is has_users=False, ENABLE_SIGNUP=False,
ENABLE_LOGIN_FORM=True, ENABLE_INITIAL_ADMIN_SIGNUP=False: old
behaviour 403, new behaviour allow. The new condition is a strict
subset of the old (new-403 implies old-403), so the change never newly
blocks any request that previously succeeded; it only stops blocking
that one bootstrap state.

That state has no legitimate deployment. With the login form enabled
and zero users the onboarding form is already served, and the only
operator-configurable way to keep the first signup closed (SSO-only:
ENABLE_LOGIN_FORM=False, optionally with ENABLE_INITIAL_ADMIN_SIGNUP)
is preserved byte for byte. ENABLE_SIGNUP=False with zero users is not
an operator choice, it is the automatic post-first-admin disable, so
the old behaviour there was purely a brick with no recovery path. No
security control is weakened: ENABLE_LOGIN_FORM and
ENABLE_INITIAL_ADMIN_SIGNUP keep their exact meaning, and the
WEBUI_AUTH=False path is untouched.

This is not Redis-specific: it reproduces with Redis disabled through
the Postgres config table alone (clear the user table, keep the config
row).

Verification
Drove the real signup endpoint across a 10-case matrix on freshly
migrated databases, including the full end-to-end first-admin creation
(returns role=admin, row persisted as admin) and the preserved
SSO-only, subsequent-signup and no-auth behaviours. All pass.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: don't revert replace/outlet content on chat save (#25485)

update_chat_by_id re-derived assistant content from `output` on every
save (serialize_output) so frontend edits to output items reflect in
content. But it ran unconditionally, so content set independently of
output — an __event_emitter__ {"type":"replace"} from an Action, or an
outlet filter footer — was reverted to the original output-derived text
on the next save. The reload reads chat.chat directly, so the change
vanished after navigating away (regression vs 0.9.2, which predates the
output mechanism).

Re-derive only when the message's `output` actually changed versus what's
stored, which still reflects genuine output edits but leaves
independently-set content intact.

Fixes #24585

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: reject collection names with unsafe characters in RAG ACL (#24982)

Open WebUI's collection ACL accepted any unknown name as a
legacy/ephemeral collection. In Milvus multi-tenancy mode that name
becomes the `resource_id` and is interpolated unescaped into a SQL-like
Milvus expression — `resource_id == '<name>'` — so a name like
  x' or resource_id != '' or resource_id == 'x
turns the filter into a tautology and returns every tenant's chunks
from the shared collection.

All collection names Open WebUI generates are UUIDs, SHA-256 hex
digests, or fixed-prefix variants of those — they all fit
[A-Za-z0-9_-]. Add a strict format check in
filter_accessible_collections (utils.py) that drops any name outside
that set before any ACL or vector-store lookup, applied even on the
admin bypass path. _validate_collection_access then surfaces the dropped
name as a 403.

As defense in depth, MilvusClient now validates resource_id at every
expression-construction site and escapes single quotes / backslashes in
any other string interpolated into a filter (delete ids, metadata
filter values). Non-string filter values are typed-checked instead of
str()-formatted.

Co-authored-by: Claude <noreply@anthropic.com>

* refac

* refac: mirror native FC code_interpreter authz gates onto legacy XML-tag path (#24724)

The native function-calling tool resolver in utils/tools.py applies five
gates before exposing execute_code as a builtin tool: builtin-category
enable, ENABLE_CODE_INTERPRETER global config, model capability,
features.code_interpreter request flag, and the per-user
features.code_interpreter permission.

The legacy XML-tag detection path in streaming_chat_response_handler
applied only the request-flag gate. Brings the legacy path to parity by
running the same five-gate check before activating tag detection.
Behaviour change is limited to deployments that previously relied on
the asymmetry — admins who set ENABLE_CODE_INTERPRETER=False or revoked
the per-user permission, on the legacy tool-calling mode, with the
client supplying features.code_interpreter=true. Any of those three
conditions met now correctly disables tag detection.

Co-authored-by: sfwani <sfwani@users.noreply.github.com>

* fix: handle list-shape data in Firecrawl /search response (#24712)

Firecrawl /search returns either `{"data": [...]}` (flat list — v1, and
what frost19k reported on #23966) or `{"data": {"web": [...]}}` (v2,
current production). The parser only handled the dict shape:

    data = response.get('data') or {}
    results = data.get('web') or []

On a list-shape response, `data.get('web')` raised AttributeError,
caught by the function's outer try/except, and `search_firecrawl`
silently returned []. Web search worked against v2 endpoints but is one
upstream-format-change away from failing closed again. Accept either.

* fix: bind prompt history/version ops to the authorized prompt (#25056)

The history diff, delete, and version-restore routes authorize the URL
prompt_id but then act on a caller-supplied history/version id without
checking it belongs to that prompt (IDOR). Filter by prompt_id in
compute_diff and delete_history_entry, and reject a cross-prompt version_id
in update_prompt_version.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refac

Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>

* feat: add support for Valkey vector database (#24769)

* feat: add support for Valkey vector database

Signed-off-by: Riley Des <riley.desserre@improving.com>

* feat: add CLIENT SETNAME to Valkey vector store connections

Set client_name on GlideClientConfiguration for both the main client
and batch client so connections are identifiable in CLIENT LIST,
monitoring dashboards, and CloudWatch metrics.

Signed-off-by: Riley Des <riley.desserre@improving.com>

---------

Signed-off-by: Riley Des <riley.desserre@improving.com>

* refac

* refac

Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>

* refac

Co-Authored-By: Zixin Yu <183055163+ivvi0927@users.noreply.github.com>

* refac

* refac

* refac

* Update Kagi API endpoint and request method (#25015)

Co-authored-by: russelg <russelg@users.noreply.github.com>

* feat: add skills management to chat component (#25037)

- Introduced skills functionality in Chat.svelte, MessageInput.svelte, and related components.
- Added SkillsModal for displaying and managing available skills.
- Updated state management to include selectedSkillIds and integrate skills API.
- Enhanced UI to show available skills and their descriptions.
- Updated translations to support skills-related text.

* refac

* refac

* refac

Co-Authored-By: Zaid Marji <91486926+zaid-marji@users.noreply.github.com>

* refac

* refac

* refac

* refac

* refac

* fix: apply RAG_EMBEDDING_QUERY_PREFIX to memory search queries (#24921)

The query_memory endpoint embeds the search query without the configured
RAG_EMBEDDING_QUERY_PREFIX, while every RAG retrieval path in
retrieval/utils.py correctly passes it. Instruction-tuned embedding
models (e.g. Qwen3-Embedding) produce poor results without the prefix,
causing memory search to return semantically unrelated results.

* refac

* fix: gate chat_completion channel: branch on channel access + message scoping (#24725)

* fix: gate chat_completion channel: branch on channel access + message scoping

When chat_id starts with 'channel:' the chat-completion handler skips
the chat ownership / storage block below it. Nothing replaced that
gate. The downstream channel emitter in socket/main.py:_make_channel_
emitter writes to Messages.update_message_by_id using a caller-supplied
message_id pulled from form_data['id'], with no membership check, no
write-access check, and no validation that the message_id belongs to
the channel.

Net effect: any authenticated user could submit
chat_id='channel:<any-channel-uuid>' + id='<any-message-uuid>' and
overwrite that message with attacker-controlled LLM output. Cross-
channel writes worked too — private channels, DMs, channels the
caller has no access to. Original author attribution stayed intact on
the overwritten row.

Add the missing checks at the channel: branch:

1. Channel must exist (404 otherwise).
2. Non-admin caller must have write access to the channel — membership
   for group/dm channels, AccessGrants permission='write' for others.
3. The message_id (if supplied) must belong to the same channel — a
   caller with write access to channel A cannot use this path to
   overwrite a message in channel B.

Behaviour change is limited to callers who were exploiting the gap:
legitimate flows that supply a message_id under their own channel
membership continue to work unchanged.

Co-authored-by: sfwani <sfwani@users.noreply.github.com>

* chore: trim verbose comment on channel: branch gate

---------

Co-authored-by: sfwani <sfwani@users.noreply.github.com>

* refac

* feat(retrieval): add Perplexity attribution header (#24833)

Signed-off-by: James Liounis <james.liounis@perplexity.ai>

* refac

* refac

* Update CHANGELOG.md (#25453)

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* fix: polyfill readable stream async iteration for Safari PDF extraction (#25473)

* refactor: move background tasks handler call to ensure consistent execution in chat response handlers (#24717)

* refac

* fix(oauth): use Protected Resource Metadata scopes in static OAuth 2.1 flow (#24690)

The static credentials OAuth flow currently sets scope=None, relying on
the OAuth provider's default scopes. This breaks providers like GitHub
that default to minimal/public-only access when no scope is requested.

This change reads scopes_supported from the Protected Resource Metadata
document (RFC 9728) and uses them in the authorization request. Unlike
the Authorization Server's scopes_supported (a full catalog of every
scope the AS can grant), the PRM scopes_supported represents what the
specific resource requires — making it safe to request without breaking
providers like Entra ID that reject broad scope requests.

Fixes the regression introduced in 349ea4ea where all scope handling was
removed from the static flow.

* refac

* chore: bump

* chore

* chore: format

* Update CHANGELOG.md (#25491)

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* refac

* refac

Co-Authored-By: Syed Mustafa Quadri <175467872+code-quad3@users.noreply.github.com>

* chore: format

* refac

Co-Authored-By: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>

* fix: delete Qdrant points by ID so memory deletions don't orphan vectors (#25495)

The Qdrant backends implemented delete(ids=...) as a payload filter on
metadata.id, but points are stored with the item id as the Qdrant point id
(see _create_points), and not every point carries an id in its payload.
Memory points store only {created_at} in metadata (KB metadata embeddings
likewise), so deleting a single memory matched nothing and left an orphaned
vector that kept being injected into RAG context.

Delete by point id instead: PointIdsList for the standard backend, and a
tenant-scoped HasIdCondition for multitenancy (point ids are unique, so tenant
isolation is preserved). Filter-based deletion is unchanged.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* i18n(fr-fr): update frensh translations (#24614)

Co-authored-by: Marina Pantazis <marina.pantazis@bit.admin.ch>
Co-authored-by: Tim Baek <tim@openwebui.com>

* fix: block private-IP webhook URLs to close SSRF on caller-controlled URL (#24587)

* fix: block private-IP webhook URLs to close SSRF on caller-controlled URL

post_webhook(url, ...) in utils/webhook.py forwards the URL straight to
aiohttp.ClientSession.post with no SSRF gate. The URL is caller-controlled
on two surfaces:

- User notification settings under ENABLE_USER_WEBHOOKS=true — any
  authenticated user can set the URL their notifications POST to.
- Automation notification triggers (calendar alerts, etc.).

Without a gate, the URL can target cloud metadata (169.254.169.254 /
fd00:ec2::254), localhost-bound services, RFC1918 internal hosts, or any
other private address reachable from the server process. Blind SSRF — no
response body returned to the caller — but enough to enumerate internal
services via response timing / status codes, and on cloud deployments
enough to issue requests against IMDSv1 if available.

Call validate_url() at the top of post_webhook. The function blocks
private/reserved IPs when ENABLE_RAG_LOCAL_WEB_FETCH is False (the
default), is the project's chosen SSRF gate, and is already applied to
the equivalent fetch surfaces (retrieval, image-load, OAuth profile
picture). Operators who legitimately need to webhook to private IPs
(internal monitoring, self-hosted Slack alternatives, etc.) can set
ENABLE_RAG_LOCAL_WEB_FETCH=True — same opt-out as the other gated
surfaces.

Scope intentionally limited to webhooks. The OAuth discovery and
external reranker paths cwanglab also flagged are admin-configured with
intentional private-IP defaults (reranker defaults to
http://localhost:8080/v1/rerank) and are out of scope per Rule 9 — the
admin owns the URL choice and the operator opt-out exists for them too.

Reported by cwanglab in GHSA-5x9f-85cg-w3hf (cluster canonical with six
closed siblings: g36v-23gj-j69x, 6j8f-h58v-xgmw, xpwv-52pm-p8hj,
v9gp-hv2c-9qv8, fw7w-jrw7-p3v9, x7xq-74rg-m8mf).

Co-authored-by: cwanglab <cwanglab@users.noreply.github.com>

* fix: also pass allow_redirects=False on webhook post_webhook session.post

Companion to the previous commit. validate_url() only validates the
initial URL; aiohttp's default allow_redirects=True would still follow
a 302 to a private-IP target. Same redirect-bypass class as the rh5x
cluster's five call sites, sixth call site to receive the same gate.

Co-authored-by: cwanglab <cwanglab@users.noreply.github.com>

---------

Co-authored-by: cwanglab <cwanglab@users.noreply.github.com>

* refac

* chore: format

* refac

* refac

* refac

---------

Signed-off-by: Riley Des <riley.desserre@improving.com>
Signed-off-by: James Liounis <james.liounis@perplexity.ai>
Co-authored-by: _00_ <131402327+rgaricano@users.noreply.github.com>
Co-authored-by: Taey <pythontogoplease@gmail.com>
Co-authored-by: Daniel Nylander <po@danielnylander.se>
Co-authored-by: Daniel Nylander <daniel@danielnylander.se>
Co-authored-by: Asbjørn Dyhrberg Thegler <asbjoern@dyhrbergthegler.dk>
Co-authored-by: Aleix Dorca <aleixdorca@mac.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: ShigekiTsuchiyama <ShigekiTsuchiyama@users.noreply.github.com>
Co-authored-by: berkant-koc <berkant-koc@users.noreply.github.com>
Co-authored-by: mayamsin <58122670+mayamsin@users.noreply.github.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: G30 <50341825+silentoplayz@users.noreply.github.com>
Co-authored-by: Aindriú Mac Giolla Eoin <aindriu80@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: POV9en <POV9en@users.noreply.github.com>
Co-authored-by: oxsignal <oxsignal@users.noreply.github.com>
Co-authored-by: Dara Adib <dara@quietapple.org>
Co-authored-by: Sergey Zinchenko <sergey.zinchenko.rnd@gmail.com>
Co-authored-by: Shirasawa <764798966@qq.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: maco <gosarmarcel7@gmail.com>
Co-authored-by: Sakıp Han Dursun <100518315+sakiphan@users.noreply.github.com>
Co-authored-by: anishgirianish <161533316+anishgirianish@users.noreply.github.com>
Co-authored-by: Mateusz Hajder <6783135+mhajder@users.noreply.github.com>
Co-authored-by: Amir Subhi <amirsubhi@hotmail.com>
Co-authored-by: sermikr0 <230672901+sermikr0@users.noreply.github.com>
Co-authored-by: bannert <58707896+bannert1337@users.noreply.github.com>
Co-authored-by: Boris Rybalkin <ribalkin@gmail.com>
Co-authored-by: HW <hwinkler@first-it-consulting.de>
Co-authored-by: sfwani <sfwani@users.noreply.github.com>
Co-authored-by: rileydes-improving <riley.desserre@improving.com>
Co-authored-by: Zixin Yu <183055163+ivvi0927@users.noreply.github.com>
Co-authored-by: Lukáš Kucharczyk <lukas@kucharczyk.xyz>
Co-authored-by: russelg <russelg@users.noreply.github.com>
Co-authored-by: Mr. Meowgi <ovehbe@gmail.com>
Co-authored-by: Zaid Marji <91486926+zaid-marji@users.noreply.github.com>
Co-authored-by: Craig <66838006+TipKnuckle@users.noreply.github.com>
Co-authored-by: James Liounis <james.liounis@perplexity.ai>
Co-authored-by: Chane Lu <2522992009@qq.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: Justin Williams <justinjohnwilliams@gmail.com>
Co-authored-by: Syed Mustafa Quadri <175467872+code-quad3@users.noreply.github.com>
Co-authored-by: hungryBird <pantazis.marina@gmail.com>
Co-authored-by: Marina Pantazis <marina.pantazis@bit.admin.ch>
Co-authored-by: cwanglab <cwanglab@users.noreply.github.com>
2026-06-01 14:56:48 -07:00
Tim Baek 3660bc00fd Merge pull request #24492 from open-webui/dev
0.9.5
2026-05-10 14:13:55 -04:00
Tim Baek f51d2b026f Merge pull request #24483 from open-webui/dev
0.9.4
2026-05-09 03:50:05 -04:00
+11 adc9076d17 0.9.3 (#24482)
* refac

* refac

* refac

* Merge pull request #24356 from Classic298/patch-1

doc/chore: Update SECURITY.md

* refac

* refac

* refac

* refac

* refac

* chore: Update SECURITY.md (#24363)

* Update SECURITY.md

* Update SECURITY.md

* Implement asynchronous database ping for health checks (#24380)

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* fix: prevent STT from blocking the uvicorn event loop (#24338)

The transcription endpoint was async but called the synchronous transcribe() function directly, blocking the single-threaded uvicorn event loop for the entire duration of inference. This caused all HTTP and WebSocket connections to stall for every user on the instance during STT processing.

- Add asyncio import

- Use async UploadFile.read() instead of synchronous file.file.read()

- Offload the blocking transcribe() call via asyncio.to_thread()

Closes #24169

* refac

* fix: open file content in new window when clicking file name in FileItemModal (#24125)

Previously, clicking the file name link did not open the file content
because the condition checked `!isPDF && item.url`, which failed for
`type === 'file'` items that use an ID-based URL path.

Update the condition to trigger on `item.type === 'file' || item.url`,
and resolve the correct URL by extracting `fileId` from `item.id` or
`item.tempId` instead of using `item.url` directly as the file
identifier.

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* Refactor file processing to use asyncio for transcribing, improving concurrency. (#24379)

* Apply validate_profile_image_url to ChannelWebhookForm.profile_image_url (#24370)

* refac

* refac

* refac

* refac

* refac

* fix: stream GET /chats/all to prevent OOM on large chat histories (#24461)

Convert the /chats/all endpoint from loading all user chats into memory
at once to a streaming NDJSON response that fetches chats in batches of
100. This prevents Out-of-Memory crashes for users with large chat
histories.

Backend: Added async generator that paginates through chats with
short-lived DB sessions per batch (critical for SQLite lock release).

Frontend: Updated getAllChats to consume the NDJSON stream via
ReadableStream reader, accumulating results for the export file.

Ref: open-webui#22206

* refac

* refac

* refac

* refac

* refac

* refac

* Enhance CommitSessionMiddleware to allow health probes to bypass session management, ensuring faster and more reliable responses. (#24384)

* refac

* refac

* refac

* refac

* refac

* refac

* refac: apply DOMPurify to excel and office HTML render assignments (#24468)

* I18n/improve chinese translation (#24194)

* i18n: improve zh-CN translation

* i18n: improve zh-TW translation

* perf(prompts): filter prompt list in SQL instead of N+1 has_access loop (#24288)

get_prompts_by_user_id used to fetch every active prompt (with users +
all access grants), then call AccessGrants.has_access() once per prompt
that the user did not own. With 600+ prompts this issued ~600 extra
round-trips per request and explained the multi-second delay reported in
the GET /api/v1/prompts and /api/v1/prompts/tags endpoints for non-admin
users.

Push the access check into a single SQL query via the existing
AccessGrants.has_permission_filter (EXISTS subquery), so only accessible
rows come back from the DB. Users and access grants for the surviving
rows are still batch-fetched, no N+1 anywhere on this path.

Co-authored-by: Claude <noreply@anthropic.com>

* refac

* refac

* Update catalan translation.json (#24174)

* perf(prompts): make /tags fetch only the tags column with SQL access filter (#24287)

Non-admin GET /api/v1/prompts/tags went through get_prompts_by_user_id,
which loaded every active prompt with its full content/data/meta plus
owner records and all access grants, then ran one has_access query per
prompt that wasn't owned by the caller - all so the endpoint could
collapse the result to a sorted tag list. With 600 prompts this took
several seconds while the admin path (a single SELECT) returned in <1s.

Add Prompts.get_tags_by_user_id which selects only the tags column and
applies the same EXISTS-based access filter used by /list. Also tighten
the admin get_tags to project just the tags column instead of full rows.
The endpoint is now one DB query (plus one for groups), no row hydration,
no N+1.

Co-authored-by: Claude <noreply@anthropic.com>

* refac

* refac

* i18n: Add Tagalog (Filipino) translation (#24254)

Signed-off-by: Adam Tao <tcx4c70@gmail.com>
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: Teay <pythontogoplease@gmail.com>
Co-authored-by: tcx4c70 <tcx4c70@gmail.com>
Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: RomualdYT <romuald@gameurnews.fr>
Co-authored-by: Lucas <lucas@vanosenbruggen.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Constantine <Runixer@gmail.com>

* refac

* refac

* refac

* style(env): satisfy ruff (datetime alias, line length, identity check) (#24118)

* Korean Translation Update (#24087)

Signed-off-by: Adam Tao <tcx4c70@gmail.com>
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: Teay <pythontogoplease@gmail.com>
Co-authored-by: tcx4c70 <tcx4c70@gmail.com>
Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: RomualdYT <romuald@gameurnews.fr>
Co-authored-by: Lucas <lucas@vanosenbruggen.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Constantine <Runixer@gmail.com>

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* feat: brave search llm context

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* fix(mcp): remove asyncio.wait_for/shield from MCP cleanup in chat handler (#24105)

asyncio.wait_for() and asyncio.shield() create new asyncio Tasks which
violate anyio cancel-scope task-ownership rules. The MCPClient's
exit_stack contains anyio resources (streamable_http transport) that
use anyio cancel scopes. When exited from a different task, anyio raises
'Attempted to exit a cancel scope that isn't the current task's current
cancel scope' as a BaseException.

This BaseException propagates through the finally block, discards the
completed response return value, and surfaces as a 500 Internal Server
Error / 'No response returned.' - silently swallowing successful MCP
tool calls and blocking the chat endpoint.

Fix: call client.disconnect() directly in a simple loop. MCPClient.disconnect()
already catches BaseException internally (see prior commit), so no
wrapper is needed.

Signed-off-by: Adam Tao <tcx4c70@gmail.com>
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: Teay <pythontogoplease@gmail.com>
Co-authored-by: tcx4c70 <tcx4c70@gmail.com>
Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: RomualdYT <romuald@gameurnews.fr>
Co-authored-by: Lucas <lucas@vanosenbruggen.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Constantine <Runixer@gmail.com>
Co-authored-by: Circe (Claude Code Sonnet 4.6) <circe@athena-council.org>
Co-authored-by: Claude <noreply@anthropic.com>

* fix:image url validation and signout post (#24420)

* refac(routers): reject external URLs in profile/model image handlers

* refac(ui): centralize image URL validation in safeImageUrl helper

* refac(auths): make signout POST-only

* refac: gate external profile image redirect behind ENABLE_PROFILE_IMAGE_URL_FORWARDING

Restore the 302 redirect for external http(s) profile image URLs in
the user and model profile-image endpoints, but gate it behind a new
ENABLE_PROFILE_IMAGE_URL_FORWARDING env flag (default: True).

Existing deployments that rely on external profile image forwarding
continue to work unchanged.  Operators who want to suppress the
redirect (to prevent client-side IP/UA/Referer leaks) can set the
flag to False.

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* chore: format

* chore: changelog (#24358)

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* doc: changelog

* refac

* refac

* refac

* chore: format

* refac

---------

Signed-off-by: Adam Tao <tcx4c70@gmail.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: Athanasios Oikonomou <athoik@gmail.com>
Co-authored-by: Shirasawa <764798966@qq.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Aleix Dorca <aleixdorca@mac.com>
Co-authored-by: Vincent Agra <agravj007@gmail.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: Teay <pythontogoplease@gmail.com>
Co-authored-by: tcx4c70 <tcx4c70@gmail.com>
Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com>
Co-authored-by: RomualdYT <romuald@gameurnews.fr>
Co-authored-by: Lucas <lucas@vanosenbruggen.com>
Co-authored-by: Constantine <Runixer@gmail.com>
Co-authored-by: Shamil <ashm.tech@proton.me>
Co-authored-by: Cyp <cypher9715@naver.com>
Co-authored-by: looselyhuman <fieldian@gmail.com>
Co-authored-by: Circe (Claude Code Sonnet 4.6) <circe@athena-council.org>
2026-05-09 03:17:07 -04:00
+2 8dae237a0b 0.9.2 (#24081)
* refac

* fix: remove reactive label from onDestroy in Markdown

* Update fi-FI translation.json (#24010)

Added missing translations.

* refac

* i18n: update ko-KR translations (conflict solved) (#23949)

* i18n: update ko-KR translations

* i18n: fix missing ko-KR translations and reviewed pr-bot recommendation

* i18n: add pt-BR translations for newly added UI items and consistency pass (#23954)

New **pt-BR** translations for items introduced in the latest releases, plus a consistency/quality pass across existing strings (grammar, tone, capitalization, pluralization). Placeholders and hotkeys preserved. No logic changes.

* fix(utils): Switch throttle decorator to async (#23979)

After migration to async db operations, the throttle decorator also
needs to support async. Since the decorator is only used for async funcs
now, we can just switch it to async instead of supporting sync and async
at the same time.

Signed-off-by: Adam Tao <tcx4c70@gmail.com>

* refac

* refac

* refac

* refac

* feat: add PaddleOCR-vl loader support and implement retrieval router infrastructure (#23945)

Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>

* refac

* refac

* Enhance image loading performance by adding preload links and setting loading attributes for logos in app.html (#24011)

* feat(ui): add citation source overflow badge (#23918)

* refac

* i18n: enhance and expand Dutch language translations (#23944)

* refac

* refac

* refac

* perf: redirect default model profile image to canonical static URL (#24015)

- Return 302 to /static/favicon.png instead of streaming the same PNG per
  model id so browsers can cache one asset for default avatars.
- Validate stored /static/ paths with decode, normpath, and /static
  prefix checks; invalid paths fall back to favicon.

Made-with: Cursor

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* feat: enhance RichTextInput configuration to prevent duplicate extensions when rich text is enabled (#24009)

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* changelog (#24072)

* refactor(firecrawl): use v2 API directly (#23934)

Co-authored-by: Tim Baek <tim@openwebui.com>

* chore: bump

* perf(chats): drop redundant db.refresh after commit in update_chat_by_id (#24024)

The chat table has no computed columns (no DEFAULT, SERIAL/IDENTITY,
or TRIGGER that populate server-side values on UPDATE), and every
column modified by update_chat_by_id is set explicitly from Python
values earlier in the function. db.refresh therefore issues a SELECT
that replaces those just-written Python values with the round-tripped
database representation of the same values, which is a no-op for
functional purposes but pulls the entire chat.chat JSON blob back over
the network and through the driver's JSON decoder.

On large, active chats where chat.chat can reach tens of megabytes,
skipping the refresh measurably reduces latency and eliminates one
~JSON-sized transient allocation per write.

* refac

* chore: format

* chore: i18n

* refac

---------

Signed-off-by: Adam Tao <tcx4c70@gmail.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: Teay <pythontogoplease@gmail.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: tcx4c70 <tcx4c70@gmail.com>
Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: RomualdYT <romuald@gameurnews.fr>
Co-authored-by: Lucas <lucas@vanosenbruggen.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Constantine <Runixer@gmail.com>
2026-04-24 05:56:03 -04:00
Tim Baek 0a8a620fb6 Merge pull request #23925 from open-webui/dev
0.9.1
2026-04-21 06:45:24 -04:00
Tim Baek f31768e20e Merge pull request #23318 from open-webui/dev
0.9.0
2026-04-21 03:56:01 -04:00
Tim Baek 9bd84258d0 Merge pull request #23120 from open-webui/dev
0.8.12
2026-03-27 04:26:39 +04:00
Tim Baek 4d058a125b Merge pull request #23035 from open-webui/dev
0.8.11
2026-03-26 02:49:59 +04:00
Tim Baek e4e69a10ec Merge pull request #22469 from open-webui/dev
0.8.10
2026-03-09 04:09:43 +04:00
Tim Baek 6c159a97b7 Merge pull request #22390 from open-webui/dev
refac
2026-03-08 06:56:22 +04:00
Tim Baek 947dcd34bd Merge pull request #22385 from open-webui/dev
0.8.9
2026-03-08 06:47:14 +04:00
Tim Baek 79f0437980 Merge pull request #22168 from open-webui/dev
0.8.8
2026-03-03 03:32:58 +04:00
Tim Baek 6137f7cb7e Merge pull request #22121 from open-webui/dev
0.8.7
2026-03-02 05:14:08 +04:00
Tim Baek 9c9a18d6d4 Merge pull request #21971 from open-webui/dev
0.8.6
2026-03-02 01:03:55 +04:00
Tim Baek 1ac3dd4a89 Merge pull request #21773 from open-webui/dev
0.8.5
2026-02-23 13:26:21 +04:00
Tim Baek 2ed3055c42 Merge pull request #21618 from open-webui/dev
0.8.4
2026-02-23 11:58:08 +04:00
Tim Baek b8112d72b9 Merge pull request #21507 from open-webui/dev
refac
2026-02-17 01:25:39 -06:00
Tim Baek 4225791313 Merge pull request #21458 from open-webui/dev
0.8.3
2026-02-17 01:19:39 -06:00
Tim Baek 7c7fe44328 Merge pull request #21443 from open-webui/dev
0.8.2
2026-02-16 01:37:13 -06:00
Tim Baek 883f1dda0f Merge pull request #21346 from open-webui/dev
0.8.1
2026-02-13 18:04:11 -06:00
Tim Baek 7a7a25766c Merge pull request #21314 from open-webui/dev
0.8.0
2026-02-12 17:42:25 -06:00
Tim Baek 2b26355002 Merge pull request #20560 from open-webui/dev
0.7.2
2026-01-11 01:00:01 +04:00
Tim Baek f2a360cb87 Merge pull request #20523 from open-webui/dev
chore
2026-01-10 00:57:39 +04:00
Tim Baek f9b0534e0c Merge pull request #20522 from open-webui/dev
0.7.1
2026-01-10 00:44:36 +04:00
Tim Baek 6adde203cd Merge pull request #20394 from open-webui/dev
0.7
2026-01-09 22:51:34 +04:00
Tim Baekandjoaoback a7271532f8 0.6.43 (#20093)
* refac

* fix: python pip install dep issue

* Merge pull request #20085 from joaoback/patch-19

Update translation.json (pt-BR)

* fix/refac: stt default content type

* fix/refac: temp chat image handling

* fix/refac: image action button

* chore: bump

---------

Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
2025-12-22 01:03:34 -05:00
Tim Baek d95f533214 Merge pull request #19729 from open-webui/dev
0.6.42
2025-12-21 16:08:58 -05:00
Tim Baek 6f1486ffd0 Merge pull request #19466 from open-webui/dev
0.6.41
2025-12-02 17:28:46 -05:00
Tim Baek 140605e660 Merge pull request #19462 from open-webui/dev
0.6.40
2025-11-25 06:01:33 -05:00
Tim Baek 9899293f05 Merge pull request #19448 from open-webui/dev
0.6.39
2025-11-25 05:31:34 -05:00
Tim Baek e3faec62c5 Merge pull request #19416 from open-webui/dev
0.6.38
2025-11-24 07:00:31 -05:00
Tim Baek fc05e0a6c5 Merge pull request #19405 from open-webui/dev
chore: format
2025-11-23 22:16:33 -05:00
Tim Baek fe6783c166 Merge pull request #19030 from open-webui/dev
0.6.37
2025-11-23 22:10:05 -05:00
148 changed files with 4368 additions and 5272 deletions
+89 -48
View File
@@ -1,5 +1,5 @@
name: Bug Report
description: Tell us what broke in Open WebUI.
description: Create a detailed bug report to help us improve Open WebUI.
title: 'issue: '
labels: ['bug', 'triage']
body:
@@ -8,28 +8,35 @@ body:
value: |
# Bug Report
Use this for real, reproducible bugs. A clear issue is the most useful contribution: include the affected workflow, the expected result, the actual result, and the details needed for someone else to reproduce it.
## Important Notes
Before submitting, search open and closed [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions). The issue may already be reported or fixed on `dev`.
- **Before submitting a bug report**: Please check the [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions) sections to see if a similar issue has already been reported. If unsure, start a discussion first, as this helps us efficiently focus on improving the project. Duplicates may be closed without notice. **Please search for existing issues AND discussions. No matter open or closed.**
Please do not open a code pull request for this report unless a maintainer asks for one, or the change is only i18n/localization. If you want to share code as reference, include it here as a local diff or patch. Actionable reproduction details are the most useful next step.
- Check for opened, **but also for (recently) CLOSED issues** as the issue you are trying to report **might already have been fixed on the dev branch!**
Security vulnerabilities must not be reported publicly. Use the [GitHub security page](https://github.com/open-webui/open-webui/security) instead.
- **Respectful collaboration**: Open WebUI is a volunteer-driven project with a single maintainer and contributors who also have full-time jobs. Please be constructive and respectful in your communication.
- **Contributing**: If you encounter an issue, consider submitting a pull request or forking the project. We prioritize preventing contributor burnout to maintain Open WebUI's quality.
- **Bug Reproducibility**: If a bug cannot be reproduced using a `:main` or `:dev` Docker setup or with `pip install` on Python 3.11, community assistance may be required. In such cases, we will move it to the "[Issues](https://github.com/open-webui/open-webui/discussions/categories/issues)" Discussions section. Your help is appreciated!
- **Scope**: If you want to report a SECURITY VULNERABILITY, then do so through our [GitHub security page](https://github.com/open-webui/open-webui/security).
- type: checkboxes
id: issue-check
attributes:
label: Before Submitting
label: Check Existing Issues
description: Confirm that you’ve checked for existing reports before submitting a new one.
options:
- label: I searched open and closed issues and discussions for an existing report.
- label: I have searched for any existing and/or related issues.
required: true
- label: I checked whether this is already fixed on the `dev` branch or latest source.
- label: I have searched for any existing and/or related discussions.
required: true
- label: I understand that maintainers want a well-written issue before any code pull request.
- label: I have also searched in the CLOSED issues AND CLOSED discussions and found no related items (your issue might already be addressed on the development branch!).
required: true
- label: I am using the latest available version of Open WebUI for my install method.
- label: I have checked whether this issue is already fixed on the `dev` branch or in the latest source.
required: true
- label: This is not a security vulnerability.
- label: I am using the latest version of Open WebUI.
required: true
- type: dropdown
@@ -38,9 +45,9 @@ body:
label: Installation Method
description: How did you install Open WebUI?
options:
- Docker
- Pip Install
- Git Clone
- Pip Install
- Docker
- Other
validations:
required: true
@@ -49,51 +56,67 @@ body:
id: open-webui-version
attributes:
label: Open WebUI Version
description: Specify the version, commit, or image tag.
placeholder: v0.11.0, dev commit SHA, or Docker tag
description: Specify the version (e.g., v0.11.0)
validations:
required: true
- type: input
id: ollama-version
attributes:
label: Ollama Version (if applicable)
description: Specify the version (e.g., v0.32.5, or v0.32.6-rc0)
validations:
required: false
- type: input
id: operating-system
attributes:
label: Operating System
description: Specify the OS and version.
placeholder: Windows 11, macOS Tahoe, Ubuntu 26.04, Debian 13
description: Specify the OS (e.g., Windows 11, macOS Tahoe, Ubuntu 26.04, Debian 13)
validations:
required: true
- type: input
id: browser
attributes:
label: Browser
description: If the bug appears in the browser, include browser and version.
placeholder: Chrome 151.0, Firefox 153.0.3
label: Browser (if applicable)
description: Specify the browser/version (e.g., Chrome 151.0, Firefox 153.0.3)
validations:
required: false
- type: input
id: ollama-version
- type: checkboxes
id: confirmation
attributes:
label: Ollama Version
description: Include this if Ollama is involved.
placeholder: v0.32.5
validations:
required: false
- type: textarea
id: summary
attributes:
label: Summary
description: What is wrong, in a few sentences?
validations:
required: true
label: Confirmation
description: Ensure the following prerequisites have been met.
options:
- label: I have read and followed all instructions in `README.md`.
required: true
- label: I am using the latest version of **both** Open WebUI and Ollama.
required: true
- label: I have included the browser console logs.
required: true
- label: I have included the Docker container logs.
required: true
- label: I have **provided every relevant configuration, setting, and environment variable used in my setup.**
required: true
- label: I have clearly **listed every relevant configuration, custom setting, environment variable, and command-line option that influences my setup** (such as Docker Compose overrides, .env values, browser settings, authentication configurations, etc).
required: true
- label: |
I have documented **step-by-step reproduction instructions that are precise, sequential, and leave nothing to interpretation**. My steps:
- Start with the initial platform/version/OS and dependencies used,
- Specify exact install/launch/configure commands,
- List URLs visited, user input (incl. example values/emails/passwords if needed),
- Describe all options and toggles enabled or changed,
- Include any files or environmental changes,
- Identify the expected and actual result at each stage,
- Ensure any reasonably skilled user can follow and hit the same issue.
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected Behavior
description: What should have happened?
description: Describe what should have happened.
validations:
required: true
@@ -101,7 +124,7 @@ body:
id: actual-behavior
attributes:
label: Actual Behavior
description: What actually happened?
description: Describe what actually happened.
validations:
required: true
@@ -109,21 +132,32 @@ body:
id: reproduction-steps
attributes:
label: Steps to Reproduce
description: Include the exact commands, settings, URLs, model/provider setup, and user actions needed to hit the bug.
description: |
Please provide a **very detailed, step-by-step guide** to reproduce the issue. Your instructions should be so clear and precise that anyone can follow them without guesswork. Include every relevant detail—settings, configuration options, exact commands used, values entered, and any prerequisites or environment variables.
**If full reproduction steps and all relevant settings are not provided, your issue may not be addressed.**
**If your steps to reproduction are incomplete, lacking detail or not reproducible, your issue can not be addressed.**
placeholder: |
1. Start Open WebUI with ...
2. Configure ...
3. Open ...
4. Click ...
5. See ...
Example (include every detail):
1. Start with a clean Ubuntu 26.04 install.
2. Install Docker v29.7.1 and start the service.
3. Clone the Open WebUI repo (git clone ...).
4. Use the Docker Compose file without modifications.
5. Open browser Chrome 151.0 in incognito mode.
6. Go to http://localhost:8080 and log in with user "test@example.com".
7. Set the language to "English" and theme to "Dark".
8. Attempt to connect to Ollama at "http://localhost:11434".
9. Observe that the error message "Connection refused" appears at the top right.
Please list each step carefully and include all relevant configuration, settings, and options.
validations:
required: true
- type: textarea
id: logs-screenshots
attributes:
label: Logs, Screenshots, and Config
description: Include relevant browser console logs, server/container logs, screenshots, and configuration. If something does not apply, say so.
label: Logs & Screenshots
description: Include relevant logs, errors, or screenshots to help diagnose the issue.
placeholder: 'Attach logs from the browser console, Docker logs, or error messages.'
validations:
required: true
@@ -131,6 +165,13 @@ body:
id: additional-info
attributes:
label: Additional Information
description: Anything else that might help us understand the report.
description: Provide any extra details that may assist in understanding the issue.
validations:
required: false
- type: markdown
attributes:
value: |
## Note
**If the bug report is incomplete, does not follow instructions or is lacking details it may not be addressed.** Ensure that you've followed all the **README.md** and **troubleshooting.md** guidelines, and provide all necessary information for us to reproduce the issue.
Thank you for contributing to Open WebUI!
+45 -40
View File
@@ -1,73 +1,78 @@
name: Feature Request
description: Describe what you would like Open WebUI to support.
description: Suggest a new feature or improvement
title: 'feat: '
labels: ['triage']
body:
- type: markdown
attributes:
value: |
# Feature Request
## Before Submitting
Describe the requested behavior, the problem it solves, and any examples, mockups, screenshots, or workflows that clarify the request. A clear issue or discussion is the most useful contribution.
Please check **open AND closed** [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions) for similar requests. If you find one, add your input there instead.
Search open and closed [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions) before submitting. If the request needs broad product, UX, architecture, compatibility, or maintenance discussion, please start in [Discussions](https://github.com/open-webui/open-webui/discussions) so the community can weigh in.
### Scope Guidelines
Please do not open a code pull request for this request unless a maintainer asks for one, or the change is only i18n/localization. If you want to share code as reference, include it here as a local diff or patch. Clear product context is the most useful next step.
Feature requests that require significant implementation effort should be posted in the **Ideas** section of [Discussions](https://github.com/open-webui/open-webui/discussions) instead. We will move oversized feature requests to Discussions to keep the Issues tab focused on actionable items.
Security vulnerabilities must not be reported publicly. Use the [GitHub security page](https://github.com/open-webui/open-webui/security) instead.
If your request might impact the broader community, please open a Discussion first so others can weigh in on the design.
### Be Respectful
Open WebUI is a volunteer-driven project maintained by a small team. We value constructive, positive communication. Please be mindful of maintainers' time and energy.
### Contributing
If you encounter an issue, we encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout and maintain project quality.
### Reproducibility
If a bug cannot be reproduced with a `:main` or `:dev` Docker setup, or a `pip install` with Python 3.11, it may be moved to the "Issues" section in Discussions for community assistance.
- type: checkboxes
id: existing-request
id: existing-issue
attributes:
label: Before Submitting
label: Check Existing Issues
description: Confirm you have searched for similar requests.
options:
- label: I searched open and closed issues and discussions for an existing request.
- label: I have searched all existing **open AND closed** issues and discussions and found none comparable to my request.
required: true
- label: I checked whether this already exists on the `dev` branch or latest source.
- label: I have checked whether this request is already implemented on the `dev` branch or in the latest source.
required: true
- label: I understand that maintainers want a well-written issue or discussion before any code pull request.
required: true
- label: This request is not a security vulnerability.
- type: checkboxes
id: feature-scope
attributes:
label: Verify Feature Scope
description: Confirm this request belongs in Issues rather than Discussions.
options:
- label: I believe this feature request is appropriately scoped for the Issues section as described above.
required: true
- type: textarea
id: problem-description
attributes:
label: Problem
description: What is missing, frustrating, confusing, or unnecessarily hard today?
placeholder: "I'm trying to..., but..."
label: Problem Description
description: Is this related to a problem? Describe the pain point clearly.
placeholder: "e.g., I'm frustrated when..."
validations:
required: true
- type: textarea
id: desired-behavior
id: solution-description
attributes:
label: Desired Behavior
description: What would you like to happen instead?
placeholder: "I would like Open WebUI to..."
label: Proposed Solution
description: Describe what you would like to happen.
validations:
required: true
- type: textarea
id: why-it-matters
attributes:
label: Why This Matters
description: Who benefits, and what workflow does this unlock or improve?
validations:
required: true
- type: textarea
id: examples
attributes:
label: Examples or References
description: Add mockups, screenshots, links, prompts, workflows, or examples from other tools.
validations:
required: false
- type: textarea
id: alternatives-considered
attributes:
label: Alternatives or Workarounds
description: What have you tried instead, if anything?
validations:
required: false
label: Alternatives Considered
description: Describe any alternative solutions or workarounds you have considered.
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Add any other context, mockups, or screenshots about the feature request.
+83 -51
View File
@@ -1,94 +1,126 @@
<!--
Important checks for contributors:
1. Target the `dev` branch. PRs targeting `main` will be closed.
2. Code pull requests are not the default contribution path.
3. Do not open a code PR as the first step. Start with a well-written Issue or Discussion unless a maintainer asked for the PR or the change is only i18n/localization.
4. Do not delete the Contributor License Agreement section at the bottom. The CLA bot requires it.
⚠️ CRITICAL CHECKS FOR CONTRIBUTORS (READ, DON'T DELETE) ⚠️
1. Target the `dev` branch. PRs targeting `main` will be automatically closed.
2. First-time contributors should not open pull requests directly unless the pull request contains only i18n/localization updates.
Do not open a PR as the first step.
For real, reproducible bugs, start with a well-described Issue that explains the problem, why it matters, and what outcome you are looking for.
For feature requests, enhancements, behavior changes, UI/UX changes, architecture changes, suspected fixes, or unconfirmed approaches, start with an active Discussion.
If you want to propose an implementation, include it only as a reference in the Issue or Discussion, such as a local diff, patch, or branch.
Opening an Issue or Discussion does not mean a PR is the right next step. Maintainers will confirm when a PR would be useful.
We ask for this because PRs, especially from first-time contributors, often need broader maintainer context on product direction, scope, architecture, UX, edge cases, compatibility, documentation, and long-term maintenance before implementation.
We may close unsolicited PRs without review.
Contributors with a history of successful merged PRs may be given more latitude.
3. Do NOT delete the CLA section at the bottom. It is required for the bot to accept your PR.
-->
# Pull Request
# Pull Request Checklist
Thanks for wanting to improve Open WebUI. The most useful contribution is usually a clear, well-written Issue, not an unsolicited code pull request.
### Do not open a pull request as the first step.
Open a code pull request only when a maintainer asks for one, or when the change is only i18n/localization. For real, reproducible bugs, start with a well-described [Issue](https://github.com/open-webui/open-webui/issues). For feature requests, UI/UX changes, behavior changes, architecture changes, suspected fixes, or unconfirmed approaches, start with an active [Discussion](https://github.com/open-webui/open-webui/discussions).
For real, reproducible bugs, start with a well-described [Issue](https://github.com/open-webui/open-webui/issues) that explains the problem, why it matters, and what outcome you are looking for.
Before continuing, make sure the linked Issue or Discussion explains the user-facing problem, the expected outcome, the affected workflow, and any examples, logs, screenshots, constraints, or reproduction details needed for maintainers to evaluate it.
For feature requests, enhancements, behavior changes, UI/UX changes, architecture changes, suspected fixes, or unconfirmed approaches, start with an active [Discussion](https://github.com/open-webui/open-webui/discussions). Merely opening a discussion is not enough; it needs to be actively discussed.
If you have implementation notes, include them as reference in the Issue or Discussion. If you want to share code as reference, include it there as a local diff, patch, or branch note. Do not open a pull request for reference code.
If you want to propose an implementation, include it only as a reference in the Issue or Discussion, such as a local diff, patch, or branch.
Unsolicited PRs may be closed without review, especially when they introduce product, architecture, compatibility, dependency, or maintenance decisions that have not been discussed.
Opening an Issue or Discussion does not mean a PR is the right next step. Maintainers will confirm when a PR would be useful.
## Checklist
We ask for this because PRs, especially from first-time contributors, often need broader maintainer context on product direction, scope, architecture, UX, edge cases, compatibility, documentation, and long-term maintenance before implementation.
- [ ] This PR targets the `dev` branch.
- [ ] This PR links to a well-described, confirmed Issue or active Discussion: `Closes #___` / `Relates to #___`.
- [ ] A maintainer explicitly asked me to open this PR, or this PR only updates i18n/localization.
- [ ] The change is one logical unit with no unrelated commits.
- [ ] I matched nearby code patterns and avoided unnecessary new settings, abstractions, or dependencies.
- [ ] I manually tested the changed workflow and any nearby behavior that could be affected.
- [ ] I updated relevant docs, including the [Open WebUI Docs Repository](https://github.com/open-webui/docs), if needed.
- [ ] I added screenshots for UI changes, and a recording when motion or interaction matters.
- [ ] I reviewed any AI-generated code before submitting it.
- [ ] The PR title uses one of the prefixes listed below.
Unsolicited PRs may be closed without review. Contributors with a history of successful merged PRs may be given more latitude.
## Title Prefix
<!--
### ⚠️ Important: Your PR is a contribution, not a guarantee of merge.
Use one of the following prefixes:
We appreciate thoughtful contributions. Pull requests are for implementation-ready changes that have already been requested, confirmed, or actively discussed in a linked Issue or Discussion. Feature ideas, behavior changes, UI/UX changes, architecture changes, suspected fixes, and unconfirmed approaches should start as an Issue or Discussion instead.
- **BREAKING CHANGE**: Changes affecting backward compatibility
- **build**: Build system or dependency changes
- **ci**: CI/CD workflow changes
- **chore**: Refactoring, cleanup, or non-functional changes
- **docs**: Documentation additions or updates
- **feat**: New features or enhancements
- **fix**: Bug fixes or corrections
- **i18n**: Internationalization or localization changes
- **perf**: Performance improvements
- **refactor**: Code restructuring
Before opening a PR, make sure the change has a clear linked problem, follows nearby patterns, has been manually tested, and accounts for related or downstream behavior. PRs that are ideas, prototypes, unresolved design questions, unchecked AI-generated code, symptom-only patches, one-off patches, or changes where affected paths have not been checked will usually be closed.
## Summary
The most impactful way to contribute to Open WebUI is through well-written bug reports, detailed feature discussions, and thoughtful ideas. These directly shape the project. If you do open a pull request, please know that Open WebUI is held to the highest standard of code quality, consistency, and architectural coherence, and every line merged becomes something the core team must own, maintain, and support indefinitely. Submitted code may be refactored, rewritten, or used as inspiration for a different implementation. This is not a reflection of your work's quality. It is how we ensure that a small team can deeply understand and evolve every part of the codebase.
-->
Describe the change, the problem it solves, and the impact on users.
**Before submitting, make sure you've checked and filled out the following:**
## Testing
- [ ] **Linked Issue/Discussion:** This PR references an existing, well-described [Issue](https://github.com/open-webui/open-webui/issues) for a real bug or an active, substantive [Discussion](https://github.com/open-webui/open-webui/discussions) for a feature request or enhancement — `Closes #___` / `Relates to #___`.
- [ ] **First-time contributor policy:** This is not my first contribution to Open WebUI, this PR contains only i18n/localization updates, or a maintainer explicitly asked me to open this PR after reviewing the linked Issue or Discussion.
- [ ] **Target branch:** The pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.**
- [ ] **Description:** A concise description of the changes is provided below.
- [ ] **Changelog:** A changelog entry following [Keep a Changelog](https://keepachangelog.com/) format is included at the bottom.
- [ ] **Documentation:** Relevant documentation has been added or updated in the [Open WebUI Docs Repository](https://github.com/open-webui/docs).
- [ ] **Dependencies:** Any new or updated dependencies are explained, tested, and documented.
- [ ] **Testing:** **Manual** end-to-end tests have been performed to verify the fix/feature works correctly and does not introduce regressions. Screenshots or recordings are included where applicable.
- [ ] **User-facing changes:** I have confirmed whether this PR changes the UI. If it does, screenshots are required, and a video recording is recommended.
- [ ] **No Unchecked AI Code:** This PR is either human-written or has undergone thorough human review AND manual testing. Unreviewed AI-generated PRs may be closed immediately.
- [ ] **Self-Review:** A self-review of the code has been performed, ensuring adherence to project coding standards.
- [ ] **Architecture:** Smart defaults are preferred over new settings. Local state is used for ephemeral UI logic. Major architectural or UX changes have been discussed first.
- [ ] **Git Hygiene:** The PR is atomic (one logical change), rebased on `dev`, and contains no unrelated commits.
- [ ] **Title Prefix:** The PR title uses one of the following prefixes:
- **BREAKING CHANGE**: Changes affecting backward compatibility
- **build**: Build system or dependency changes
- **ci**: CI/CD workflow changes
- **chore**: Refactoring, cleanup, or non-functional changes
- **docs**: Documentation additions or updates
- **feat**: New features or enhancements
- **fix**: Bug fixes or corrections
- **i18n**: Internationalization or localization changes
- **perf**: Performance improvements
- **refactor**: Code restructuring
List the exact manual checks you ran. Include commands, setup details, screenshots, or recordings where helpful.
# Changelog Entry
## Changelog Entry
### Description
- [Describe the changes, including motivation and impact]
### Added
-
- [New features, functionalities, or additions]
### Changed
-
- [Changes, updates, refactorings, or optimizations]
### Fixed
### Deprecated
-
- [Deprecated functionality or features]
### Removed
-
- [Removed features, files, or functionalities]
### Fixed
- [Bug fixes or corrections]
### Security
-
- [Security-related changes or vulnerability fixes]
### Breaking Changes
-
- **BREAKING CHANGE**: [Changes affecting compatibility or functionality]
## Additional Context
---
Add anything maintainers should know before review.
### Additional Information
## Contributor License Agreement
- [Any additional context, notes, or references to related issues/commits]
### Screenshots or Videos
- [Attach screenshots or videos for user-facing changes. For UI changes, screenshots are required, and a video recording is recommended.]
### Contributor License Agreement
<!--
DO NOT DELETE THIS SECTION.
Your PR will not be reviewed or merged until you check the box below confirming that you have read and agree to the CLA.
🚨 DO NOT DELETE THE TEXT BELOW 🚨
Keep the "Contributor License Agreement" confirmation text intact.
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
Your PR will NOT be reviewed or merged until you check the box below confirming that you have read and agree to the terms of the CLA.
-->
- [ ] By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
> [!NOTE]
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
+2 -12
View File
@@ -311,6 +311,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ !cancelled() && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) }}
needs: [merge]
continue-on-error: true
strategy:
fail-fast: false
matrix:
@@ -402,16 +403,5 @@ jobs:
[ -z "$TAG" ] && continue
DEST="${DOCKERHUB_IMAGE}:${TAG}"
echo " -> ${DEST}"
for ATTEMPT in 1 2 3; do
if docker buildx imagetools create -t "${DEST}" "${SOURCE}" && \
docker buildx imagetools inspect "${DEST}"; then
break
fi
if [ "${ATTEMPT}" = "3" ]; then
echo "Failed to copy ${DEST} after ${ATTEMPT} attempts"
exit 1
fi
echo "Copy attempt ${ATTEMPT} for ${DEST} failed, retrying in 15s..."
sleep 15
done
docker buildx imagetools create -t "${DEST}" "${SOURCE}"
done <<< "${{ steps.tags.outputs.tags }}"
+1 -1
View File
@@ -28,7 +28,7 @@ FROM --platform=$BUILDPLATFORM node:22-alpine3.20 AS build
ARG BUILD_HASH
# Set Node.js options (heap limit Allocation failed - JavaScript heap out of memory)
# ENV NODE_OPTIONS="--max-old-space-size=4096"
ENV NODE_OPTIONS="--max-old-space-size=8192"
WORKDIR /app
+1 -1
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import errno
from enum import Enum
_ERRNO_MESSAGES = {
errno.ENAMETOOLONG: 'File name is too long.',
errno.ENOSPC: 'The server is out of storage space.',
@@ -120,7 +121,6 @@ class ERROR_MESSAGES(str, Enum):
AUTOMATION_COUNT_REQUIRES_DTSTART = (
'RRULE with COUNT requires an explicit DTSTART line to anchor the occurrence window'
)
CALENDAR_RRULE_TOO_FREQUENT = 'Recurring events cannot repeat more often than daily'
FEATURE_DISABLED = lambda name='': f'{name} is disabled'
INPUT_TOO_LONG = lambda size='': f'Input prompt exceeds maximum length of {size}'
-40
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
import logging
import os
import re
import sys
from contextlib import asynccontextmanager, contextmanager
from datetime import datetime, timedelta, timezone
@@ -322,45 +321,6 @@ elif 'sqlite' in SQLALCHEMY_DATABASE_URL:
def _apply_sqlite_pragmas(dbapi_connection):
"""Apply all configured SQLite PRAGMAs to a raw DBAPI connection."""
# SQLite LIKE folds ASCII only; SQLAlchemy SQLite ILIKE compiles to lower(x) LIKE lower(?).
compiled_patterns = {}
def like(pattern, value, escape=None):
if pattern is None or value is None:
return None
pattern = str(pattern).lower()
escape = str(escape).lower() if escape is not None else None
key = (pattern, escape)
compiled = compiled_patterns.get(key)
if compiled is False:
return False
if compiled is None:
regex = []
escaped = False
for char in pattern:
if escape and not escaped and char == escape:
escaped = True
continue
regex.append(
'.*' if not escaped and char == '%' else '.' if not escaped and char == '_' else re.escape(char)
)
escaped = False
if escaped:
compiled = False
if len(compiled_patterns) >= 512:
compiled_patterns.clear()
compiled_patterns[key] = compiled
return False
compiled = re.compile(''.join(regex), re.DOTALL)
if len(compiled_patterns) >= 512:
compiled_patterns.clear()
compiled_patterns[key] = compiled
return compiled.fullmatch(str(value).lower()) is not None
dbapi_connection.create_function('like', 2, like, deterministic=True)
dbapi_connection.create_function('like', 3, like, deterministic=True)
cursor = dbapi_connection.cursor()
if DATABASE_ENABLE_SQLITE_WAL:
cursor.execute('PRAGMA journal_mode=WAL')
+1 -5
View File
@@ -2980,11 +2980,6 @@ async def check_db_health():
# --- static assets & files ---
# Windows registry entries can override these with text/plain, which breaks module and wasm loading
mimetypes.add_type('text/javascript', '.js')
mimetypes.add_type('text/javascript', '.mjs')
mimetypes.add_type('application/wasm', '.wasm')
# Serve build-time static assets (CSS, JS, images, favicon, etc.)
app.mount('/static', StaticFiles(directory=STATIC_DIR), name='static')
@@ -3030,6 +3025,7 @@ def swagger_ui_html(*args, **kwargs):
applications.get_swagger_ui_html = swagger_ui_html
if os.path.exists(FRONTEND_BUILD_DIR):
mimetypes.add_type('text/javascript', '.js')
pyodide_dir = FRONTEND_BUILD_DIR / 'pyodide'
if os.path.exists(pyodide_dir):
app.mount('/pyodide', CORSStaticFiles(directory=pyodide_dir), name='pyodide')
+2 -34
View File
@@ -4,12 +4,10 @@ from typing import Optional
from uuid import uuid4
from open_webui.internal.db import Base, get_async_db_context
from open_webui.constants import ERROR_MESSAGES
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from open_webui.models.groups import Groups
from open_webui.models.users import User, UserModel, UserResponse
from open_webui.utils.automations import rrule_interval_seconds
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import (
JSON,
BigInteger,
@@ -28,7 +26,6 @@ from sqlalchemy import (
from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
MIN_CALENDAR_RRULE_INTERVAL_SECONDS = 24 * 60 * 60
####################
@@ -194,20 +191,6 @@ class CalendarEventForm(BaseModel):
meta: Optional[dict] = None
attendees: Optional[list[dict]] = None
@field_validator('rrule')
@classmethod
def reject_sub_daily_rrule(cls, value: Optional[str]) -> Optional[str]:
if value:
try:
interval = rrule_interval_seconds(value)
except ValueError:
raise
except Exception as e:
raise ValueError(ERROR_MESSAGES.AUTOMATION_INVALID_RRULE(e))
if interval is not None and interval < MIN_CALENDAR_RRULE_INTERVAL_SECONDS:
raise ValueError(ERROR_MESSAGES.CALENDAR_RRULE_TOO_FREQUENT)
return value
class CalendarEventUpdateForm(BaseModel):
calendar_id: Optional[str] = None
@@ -224,20 +207,6 @@ class CalendarEventUpdateForm(BaseModel):
is_cancelled: Optional[bool] = None
attendees: Optional[list[dict]] = None
@field_validator('rrule')
@classmethod
def reject_sub_daily_rrule(cls, value: Optional[str]) -> Optional[str]:
if value:
try:
interval = rrule_interval_seconds(value)
except ValueError:
raise
except Exception as e:
raise ValueError(ERROR_MESSAGES.AUTOMATION_INVALID_RRULE(e))
if interval is not None and interval < MIN_CALENDAR_RRULE_INTERVAL_SECONDS:
raise ValueError(ERROR_MESSAGES.CALENDAR_RRULE_TOO_FREQUENT)
return value
class RSVPForm(BaseModel):
status: str # 'accepted' | 'declined' | 'tentative' | 'pending'
@@ -564,8 +533,7 @@ class CalendarEventTable:
& (CalendarEvent.start_at < end)
& or_(
CalendarEvent.end_at.is_(None) & (CalendarEvent.start_at >= start),
CalendarEvent.end_at.isnot(None)
& ((CalendarEvent.end_at > start) | (CalendarEvent.start_at >= start)),
CalendarEvent.end_at.isnot(None) & (CalendarEvent.end_at > start),
)
),
# Recurring: fetch all (expansion in Python)
+3 -4
View File
@@ -87,7 +87,7 @@ class PromptForm(BaseModel):
data: dict | None = None
meta: dict | None = None
tags: list[str | None] = None
access_grants: list[dict] | None = None
access_grants: list[dict | None] = None
version_id: str | None = None # Active version
commit_message: str | None = None # For history tracking
is_production: bool | None = True # Whether to set new version as production
@@ -100,7 +100,7 @@ class PromptsTable:
async def _to_prompt_model(
self,
prompt: Prompt,
access_grants: list[AccessGrantModel] | None = None,
access_grants: list[AccessGrantModel | None] = None,
db: AsyncSession | None = None,
) -> PromptModel:
prompt_model = PromptModel.model_validate(prompt)
@@ -334,9 +334,8 @@ class PromptsTable:
tag_lower = tag.lower()
if dialect_name == 'sqlite':
tag_lower = tag.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
tag_clause = text(
"EXISTS (SELECT 1 FROM json_each(prompt.tags) t WHERE t.value LIKE :tag_val ESCAPE '\\')"
'EXISTS (SELECT 1 FROM json_each(prompt.tags) t WHERE LOWER(t.value) = :tag_val)'
)
elif dialect_name == 'postgresql':
tag_clause = text(
+2 -2
View File
@@ -89,7 +89,7 @@ class ToolForm(BaseModel):
name: str
content: str
meta: ToolMeta
access_grants: list[dict] | None = None
access_grants: list[dict | None] = None
class ToolValves(BaseModel):
@@ -103,7 +103,7 @@ class ToolsTable:
async def _to_tool_model(
self,
tool: Tool,
access_grants: list[AccessGrantModel] | None = None,
access_grants: list[AccessGrantModel | None] = None,
db: AsyncSession | None = None,
) -> ToolModel:
tool_model = ToolModel.model_validate(tool)
+1 -3
View File
@@ -367,9 +367,7 @@ class UsersTable:
query = select(User).where(sub_expr == sub)
# SQLite preserves JSON numeric type here; Postgres ->> already compares numeric JSON as text.
if session.get_bind().dialect.name == 'sqlite' and sub.isdecimal():
sub_int = int(sub)
if str(sub_int) == sub and sub_int <= 2**63 - 1:
query = select(User).where(or_(sub_expr == sub, sub_expr == sub_int))
query = select(User).where(or_(sub_expr == sub, sub_expr == int(sub)))
row = (await session.execute(query)).scalars().first()
return UserModel.model_validate(row) if row else None
+17 -22
View File
@@ -552,12 +552,7 @@ async def upload_image(request, image_data, content_type, metadata, user, db=Non
)
url = request.app.url_path_for('get_file_content_by_id', id=file_item.id)
return file_item, {
'id': file_item.id,
'url': url,
'name': (file_item.meta or {}).get('name') or file_item.filename,
'content_type': (file_item.meta or {}).get('content_type'),
}
return file_item, url
@router.post('/generations')
@@ -673,8 +668,8 @@ async def image_generations(
else:
image_data, content_type = await get_image_data(image['b64_json'])
_, image_file = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
images.append(image_file)
_, url = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
images.append({'url': url})
return images
elif image_config.IMAGE_GENERATION_ENGINE == 'gemini':
@@ -717,21 +712,21 @@ async def image_generations(
if model.endswith(':predict'):
for image in res['predictions']:
image_data, content_type = await get_image_data(image['bytesBase64Encoded'])
_, image_file = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
images.append(image_file)
_, url = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
images.append({'url': url})
elif model.endswith(':generateContent'):
for image in res['candidates']:
for part in image['content']['parts']:
if part.get('inlineData', {}).get('data'):
image_data, content_type = await get_image_data(part['inlineData']['data'])
_, image_file = await upload_image(
_, url = await upload_image(
request,
image_data,
content_type,
{**data, **metadata},
user,
)
images.append(image_file)
images.append({'url': url})
return images
@@ -781,14 +776,14 @@ async def image_generations(
headers,
trusted_base_url=image_config.COMFYUI_BASE_URL,
)
_, image_file = await upload_image(
_, url = await upload_image(
request,
image_data,
content_type,
{**form_data.model_dump(exclude_none=True), **metadata},
user,
)
images.append(image_file)
images.append({'url': url})
return images
elif image_config.IMAGE_GENERATION_ENGINE == 'automatic1111' or image_config.IMAGE_GENERATION_ENGINE == '':
# Automatic1111 holds one checkpoint instance-wide, so set_image_model
@@ -828,14 +823,14 @@ async def image_generations(
for image in res['images']:
image_data, content_type = await get_image_data(image)
_, image_file = await upload_image(
_, url = await upload_image(
request,
image_data,
content_type,
{**data, 'info': res['info'], **metadata},
user,
)
images.append(image_file)
images.append({'url': url})
return images
except Exception as e:
error = e
@@ -1044,8 +1039,8 @@ async def image_edits(
else:
image_data, content_type = await get_image_data(image['b64_json'])
_, image_file = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
images.append(image_file)
_, url = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
images.append({'url': url})
return images
elif image_config.IMAGE_EDIT_ENGINE == 'gemini':
@@ -1094,14 +1089,14 @@ async def image_edits(
for part in image['content']['parts']:
if part.get('inlineData', {}).get('data'):
image_data, content_type = await get_image_data(part['inlineData']['data'])
_, image_file = await upload_image(
_, url = await upload_image(
request,
image_data,
content_type,
{**data, **metadata},
user,
)
images.append(image_file)
images.append({'url': url})
return images
@@ -1178,14 +1173,14 @@ async def image_edits(
headers,
trusted_base_url=image_config.IMAGES_EDIT_COMFYUI_BASE_URL,
)
_, image_file = await upload_image(
_, url = await upload_image(
request,
image_data,
content_type,
{**form_data.model_dump(exclude_none=True), **metadata},
user,
)
images.append(image_file)
images.append({'url': url})
return images
except Exception as e:
-6
View File
@@ -69,9 +69,6 @@ async def process_pipeline_inlet_filter(request, payload, user, models):
if 'pipeline' in model:
sorted_filters.append(model)
if not sorted_filters:
return payload
async with aiohttp.ClientSession(trust_env=True) as session:
for filter in sorted_filters:
urlIdx = filter.get('urlIdx')
@@ -135,9 +132,6 @@ async def process_pipeline_outlet_filter(request, payload, user, models):
if 'pipeline' in model:
sorted_filters = [model] + sorted_filters
if not sorted_filters:
return payload
async with aiohttp.ClientSession(trust_env=True) as session:
for filter in sorted_filters:
urlIdx = filter.get('urlIdx')
+3 -32
View File
@@ -938,26 +938,20 @@ async def _make_channel_emitter(request_info):
state = {'last_emit_at': 0.0, 'output': []}
THROTTLE_INTERVAL = 0.15 # ~6 updates/sec
async def _emit_channel_update(
content: str,
done: bool = False,
output: list | None = None,
data: dict | None = None,
):
async def _emit_channel_update(content: str, done: bool = False, output: list | None = None):
from open_webui.models.messages import MessageForm, Messages
msg = await Messages.get_message_by_id(message_id)
if not msg or msg.channel_id != channel_id:
return
update_data = data or ({'output': output} if output else None)
update_form = MessageForm(content=content, data=update_data)
update_form = MessageForm(content=content, data={'output': output} if output else None)
if done:
# Merge done flag into existing meta (preserve model_id etc.)
existing_meta = msg.meta or {}
update_form = MessageForm(
content=content,
data=update_data,
data={'output': output} if output else None,
meta={**existing_meta, 'done': True},
)
@@ -1006,29 +1000,6 @@ async def _make_channel_emitter(request_info):
state['last_emit_at'] = now
await _emit_channel_update(content, False, state['output'])
elif event_type in ('files', 'chat:message:files'):
from open_webui.models.messages import Messages
files = event_data.get('data', {}).get('files', [])
if not files:
return
msg = await Messages.get_message_by_id(message_id)
if not msg or msg.channel_id != channel_id:
return
existing_files = (msg.data or {}).get('files')
for file in files:
if isinstance(file, dict) and file.get('id'):
file['url'] = file['id']
await Channels.add_file_to_channel_by_id(channel_id, file['id'], msg.user_id)
await Channels.set_file_message_id_in_channel_by_id(channel_id, file['id'], message_id)
if isinstance(existing_files, list):
files.extend(existing_files)
await _emit_channel_update(msg.content, data={'files': files})
elif event_type == 'chat:message:error':
error = event_data.get('data', {}).get('error', {})
error_content = error.get('content', 'An error occurred') if isinstance(error, dict) else str(error)
-3
View File
@@ -32,9 +32,6 @@ async def redis_task_command_listener(app):
while True:
pubsub = None
try:
# RedisCluster can't route a pubsub subscribe until initialize() fills its slot cache.
await redis.initialize()
pubsub = redis.pubsub()
await pubsub.subscribe(REDIS_PUBSUB_CHANNEL)
reconnect_interval = REDIS_PUBSUB_RECONNECT_INTERVAL
+2 -12
View File
@@ -389,16 +389,11 @@ async def generate_image(
images = await image_generations(
request=__request__,
form_data=CreateImageForm(prompt=prompt),
metadata=(
{'channel_id': __chat_id__.removeprefix('channel:'), 'message_id': __message_id__}
if isinstance(__chat_id__, str) and __chat_id__.startswith('channel:')
else None
),
user=user,
)
# Prepare file entries for the images
image_files = [{'type': 'image', **img} for img in images]
image_files = [{'type': 'image', 'url': img['url']} for img in images]
# Persist files to DB if chat context is available
if is_saved_chat_id(__chat_id__) and __message_id__ and images:
@@ -462,16 +457,11 @@ async def edit_image(
images = await image_edits(
request=__request__,
form_data=EditImageForm(prompt=prompt, image=image_urls),
metadata=(
{'channel_id': __chat_id__.removeprefix('channel:'), 'message_id': __message_id__}
if isinstance(__chat_id__, str) and __chat_id__.startswith('channel:')
else None
),
user=user,
)
# Prepare file entries for the images
image_files = [{'type': 'image', **img} for img in images]
image_files = [{'type': 'image', 'url': img['url']} for img in images]
# Persist files to DB if chat context is available
if is_saved_chat_id(__chat_id__) and __message_id__ and images:
+60 -65
View File
@@ -6,20 +6,17 @@ from open_webui.utils.json_codec import JSONCodec
ASK_USER_NAME = 'ask_user'
def get_ask_user_tool_calls(tool_calls: list[dict]) -> tuple[list[dict], str | None]:
def get_ask_user_tool_call(tool_calls: list[dict]) -> tuple[dict | None, str | None]:
ask_user_calls = [
tool_call for tool_call in tool_calls if tool_call.get('function', {}).get('name') == ASK_USER_NAME
]
if not ask_user_calls:
return [], None
return None, None
if len(tool_calls) != 1:
return (
ask_user_calls,
'Error: ask_user must be the only tool call, so it did not run. Call ask_user on its own.',
)
return ask_user_calls[0], 'Error: ask_user must be called by itself after research.'
if len(ask_user_calls) != 1:
return ask_user_calls, 'Error: only one ask_user call is allowed per turn.'
return ask_user_calls, None
return ask_user_calls[0], 'Error: only one ask_user call is allowed per turn.'
return ask_user_calls[0], None
def normalize_ask_user_request(arguments: dict) -> dict:
@@ -80,70 +77,68 @@ def normalize_ask_user_request(arguments: dict) -> dict:
}
def stage_ask_user_tool_calls(
def stage_ask_user_tool_call(
tool_calls: list[dict],
output: list[dict],
make_output_id: Callable[[str], str],
) -> tuple[bool, str | None]:
ask_user_calls, error = get_ask_user_tool_calls(tool_calls)
if not ask_user_calls:
return False, None
) -> dict | None:
tool_call, error = get_ask_user_tool_call(tool_calls)
if not tool_call:
return None
for tool_call in ask_user_calls:
call_id = tool_call.get('id') or make_output_id('fc')
raw_arguments = tool_call.get('function', {}).get('arguments', '{}')
arguments = raw_arguments
call_id = tool_call.get('id') or make_output_id('fc')
raw_arguments = tool_call.get('function', {}).get('arguments', '{}')
arguments = raw_arguments
if not error:
try:
parsed_arguments = JSONCodec.loads(raw_arguments or '{}')
if not isinstance(parsed_arguments, dict):
raise ValueError('ask_user arguments must be an object.')
arguments = JSONCodec.dumps(normalize_ask_user_request(parsed_arguments))
except (JSONCodec.JSONDecodeError, TypeError, ValueError) as exc:
error = f'Error: {exc}'
if not error:
try:
parsed_arguments = JSONCodec.loads(raw_arguments or '{}')
if not isinstance(parsed_arguments, dict):
raise ValueError('ask_user arguments must be an object.')
arguments = JSONCodec.dumps(normalize_ask_user_request(parsed_arguments))
except (JSONCodec.JSONDecodeError, TypeError, ValueError) as exc:
error = f'Error: {exc}'
item = {
'type': 'function_call',
'id': call_id or make_output_id('fc'),
'call_id': call_id,
'name': ASK_USER_NAME,
'arguments': arguments,
'status': 'completed' if error else 'pending',
}
item = {
'type': 'function_call',
'id': call_id or make_output_id('fc'),
'call_id': call_id,
'name': ASK_USER_NAME,
'arguments': arguments,
'status': 'completed' if error else 'pending',
}
existing_item = next(
(
existing
for existing in output
if existing.get('type') == 'function_call'
and (
existing.get('call_id') == call_id
or existing.get('id') == tool_call.get('id')
or (
not existing.get('call_id')
and existing.get('name') == ASK_USER_NAME
and existing.get('status') not in {'rejected', 'failed'}
)
existing_item = next(
(
existing
for existing in output
if existing.get('type') == 'function_call'
and (
existing.get('call_id') == call_id
or existing.get('id') == tool_call.get('id')
or (
not existing.get('call_id')
and existing.get('name') == ASK_USER_NAME
and existing.get('status') not in {'rejected', 'failed'}
)
),
None,
)
if existing_item:
existing_item.update(item)
else:
output.append(item)
# Every invalid call needs its own result, or the UI waits on it forever.
if error:
output.append(
{
'type': 'function_call_output',
'id': make_output_id('fco'),
'call_id': call_id,
'output': [{'type': 'input_text', 'text': error}],
'status': 'completed',
}
)
),
None,
)
if existing_item:
existing_item.update(item)
else:
output.append(item)
return True, error
if error:
output.append(
{
'type': 'function_call_output',
'id': make_output_id('fco'),
'call_id': call_id,
'output': [{'type': 'input_text', 'text': error}],
'status': 'completed',
}
)
return {'call_id': call_id, 'error': error, 'item': item}
+14 -13
View File
@@ -8,7 +8,6 @@ import datetime as dt
import logging
from zoneinfo import ZoneInfo
from dateutil.rrule import rrulestr
from open_webui.utils.automations import _resolve_tz
log = logging.getLogger(__name__)
@@ -26,17 +25,18 @@ def expand_recurring_event(
Takes an event dict (from CalendarEventModel.model_dump()) and produces
one dict per occurrence, with adjusted start_at / end_at.
"""
from dateutil.rrule import rrulestr
rrule_str = event_dict.get('rrule')
if not rrule_str:
return [event_dict]
if 'EXRULE' in rrule_str.upper():
log.warning(f'EXRULE is not supported for event {event_dict.get("id")}: {rrule_str}')
return [event_dict]
user_timezone = _resolve_tz(tz)
def to_local_datetime(timestamp_ns: int) -> dt.datetime:
return dt.datetime.fromtimestamp(timestamp_ns / 1_000_000_000, tz=user_timezone).replace(tzinfo=None)
if user_timezone:
return dt.datetime.fromtimestamp(timestamp_ns / 1_000_000_000, tz=user_timezone).replace(tzinfo=None)
return dt.datetime.fromtimestamp(timestamp_ns / 1_000_000_000)
range_start = to_local_datetime(range_start_ns)
range_end = to_local_datetime(range_end_ns)
@@ -44,11 +44,10 @@ def expand_recurring_event(
original_start_ns = event_dict['start_at']
original_start = to_local_datetime(original_start_ns)
rule_str = '\n'.join(line for line in rrule_str.splitlines() if not line.upper().startswith('DTSTART')) or rrule_str
try:
# Anchor to the event's real start so day-of-week / day-of-month are correct
rule = rrulestr(rule_str, dtstart=original_start, ignoretz=True)
rule = rrulestr(rrule_str, dtstart=original_start, ignoretz=True)
except Exception:
log.warning(f'Failed to parse RRULE for event {event_dict.get("id")}: {rrule_str}')
return [event_dict]
@@ -57,13 +56,13 @@ def expand_recurring_event(
duration_ns = (original_end_ns - original_start_ns) if original_end_ns else None
instances = []
previous_start = None
for occurrence_start in rule.xafter(scan_start, count=max_instances, inc=True):
if occurrence_start >= range_end or occurrence_start == previous_start:
break
previous_start = occurrence_start
occurrence_start = rule.after(scan_start, inc=True)
instance_start_ns = int(occurrence_start.replace(tzinfo=user_timezone).timestamp() * 1_000_000_000)
while occurrence_start and occurrence_start < range_end and len(instances) < max_instances:
if user_timezone:
instance_start_ns = int(occurrence_start.replace(tzinfo=user_timezone).timestamp() * 1_000_000_000)
else:
instance_start_ns = int(occurrence_start.timestamp() * 1_000_000_000)
if instance_start_ns >= range_start_ns:
instance = {
@@ -74,6 +73,8 @@ def expand_recurring_event(
}
instances.append(instance)
occurrence_start = rule.after(occurrence_start)
return instances
+1 -1
View File
@@ -112,7 +112,7 @@ def _safe_field(key: str, definition: dict[str, Any]) -> dict[str, Any]:
'type',
}
field = {'key': key}
for field_key in sorted(allowed_keys):
for field_key in allowed_keys:
if field_key in definition:
field[field_key] = definition[field_key]
+2 -19
View File
@@ -54,16 +54,6 @@ _IMAGE_MIME_FALLBACK = {
async def get_image_base64_from_url(url: str, user=None) -> Optional[str]:
try:
if url.startswith('http'):
from open_webui.models.config import Config
max_bytes = None
try:
max_size_mb = int(await Config.get('rag.file.max_size') or 0)
except (TypeError, ValueError):
max_size_mb = 0
if max_size_mb > 0:
max_bytes = max_size_mb * 1024 * 1024
# Validate URL to prevent SSRF attacks against local/private networks.
# allow_redirects=False prevents redirect-based SSRF: validate_url() is
# called only on the originally-submitted URL; following 3xx redirects
@@ -77,13 +67,7 @@ async def get_image_base64_from_url(url: str, user=None) -> Optional[str]:
url, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS
) as response:
response.raise_for_status()
image_data = bytearray()
total = 0
async for chunk in response.content.iter_chunked(64 * 1024):
total += len(chunk)
if max_bytes is not None and total > max_bytes:
return None
image_data.extend(chunk)
image_data = await response.read()
encoded_string = base64.b64encode(image_data).decode('utf-8')
content_type = response.headers.get('Content-Type', 'image/png')
return f'data:{content_type};base64,{encoded_string}'
@@ -102,14 +86,13 @@ async def get_image_url_from_base64(request, base64_image_string, metadata, user
# Extract base64 image data from the line
image_data, content_type = await get_image_data(base64_image_string)
if image_data is not None:
_, image_file = await upload_image(
_, image_url = await upload_image(
request,
image_data,
content_type,
metadata,
user,
)
image_url = image_file['url']
return image_url
return None
+1 -13
View File
@@ -10,16 +10,10 @@ log = logging.getLogger(__name__)
class FilterContext:
def __init__(self):
self.active_filters = None
self.valves_by_id = None
self.function_valves = {}
self.user_valves = {}
async def get_active_filters(self):
if self.active_filters is None:
self.active_filters = await Functions.get_active_filter_ids()
return self.active_filters
async def get_function_valves(self, filter_ids, filter_id, Valves):
if filter_id not in self.function_valves:
if self.valves_by_id is None:
@@ -35,12 +29,6 @@ class FilterContext:
return self.user_valves[user_valves_key]
def get_filter_context(request):
if not hasattr(request.state, 'filter_context'):
request.state.filter_context = FilterContext()
return request.state.filter_context
async def get_user_valves(filter_id, user_id, UserValves):
user_valves_data = await Functions.get_user_valves_by_id_and_user_id(filter_id, user_id)
return UserValves(**(user_valves_data if user_valves_data else {}))
@@ -69,7 +57,7 @@ async def resolve_filter_pipeline(request, model: dict, enabled_filter_ids: list
if not ENABLE_PLUGINS:
return [], []
active_filters = await get_filter_context(request).get_active_filters()
active_filters = await Functions.get_active_filter_ids()
filter_ids = get_model_filter_ids(model, active_filters)
functions_by_id = {function.id: function for function in await Functions.get_functions_by_ids(filter_ids)}
+39 -32
View File
@@ -81,7 +81,7 @@ from open_webui.tasks import clear_response_stream, save_response_stream
from open_webui.utils.access_control import has_connection_access, has_permission
from open_webui.utils.access_control.files import get_owner_accessible_folder_files
from open_webui.utils.access_control.folders import has_folder_access
from open_webui.utils.ask_user import stage_ask_user_tool_calls
from open_webui.utils.ask_user import stage_ask_user_tool_call
from open_webui.utils.chat import generate_chat_completion
from open_webui.utils.chat_id import is_saved_chat_id
from open_webui.utils.code_interpreter import execute_code_jupyter
@@ -258,7 +258,12 @@ def build_terminal_file_tool_result(
if isinstance(tool_result, (list, tuple)) and tool_result and isinstance(tool_result[0], dict):
tool_result = tool_result[0]
if tool_function_name != 'display_file' or not isinstance(tool_result, dict) or tool_result.get('exists') is False:
if (
tool_function_name != 'display_file'
or tool_function_params.get('inline') is not True
or not isinstance(tool_result, dict)
or tool_result.get('exists') is False
):
return None
tool_id = (tool or {}).get('tool_id', '')
@@ -279,7 +284,7 @@ def build_terminal_file_tool_result(
**tool_result,
'type': 'file',
'source': 'open_terminal',
**({'displayed': True} if tool_function_params.get('inline') is True else {}),
'displayed': True,
'terminal_selector': terminal_selector,
**({'terminal_id': terminal_id} if terminal_id else {}),
**({'terminal_url': server_url} if server_url and not terminal_id else {}),
@@ -1766,12 +1771,6 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
if not chat_id or not isinstance(chat_id, str) or not __event_emitter__:
return form_data
is_channel_chat = chat_id.startswith('channel:')
image_metadata = {
'message_id': metadata.get('message_id', None),
**({'channel_id': chat_id.removeprefix('channel:')} if is_channel_chat else {'chat_id': chat_id}),
}
if not is_saved_chat_id(chat_id):
message_list = form_data.get('messages', [])
else:
@@ -1816,7 +1815,10 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
images = await image_edits(
request=request,
form_data=EditImageForm(**{'prompt': prompt, 'image': input_images}),
metadata=image_metadata,
metadata={
'chat_id': metadata.get('chat_id', None),
'message_id': metadata.get('message_id', None),
},
user=user,
)
@@ -1834,7 +1836,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
'files': [
{
'type': 'image',
**image,
'url': image['url'],
}
for image in images
]
@@ -1924,7 +1926,10 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
images = await image_generations(
request=request,
form_data=CreateImageForm(**{'prompt': prompt}),
metadata=image_metadata,
metadata={
'chat_id': metadata.get('chat_id', None),
'message_id': metadata.get('message_id', None),
},
user=user,
)
@@ -1942,7 +1947,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
'files': [
{
'type': 'image',
**image,
'url': image['url'],
}
for image in images
]
@@ -2260,8 +2265,7 @@ def sanitize_tool_pairs(messages: list[dict]) -> list[dict]:
return sanitized
# Ids are validated as [a-z0-9_-]+ on create; matching that keeps ordinary "<$..." text intact.
SKILL_MENTION_RE = re.compile(r'<(?:\$([a-z0-9_-]+)(?:\|[^>]*)?|/([a-z0-9_-]+)\|[^>]*)>')
SKILL_MENTION_RE = re.compile(r'<(?:\$([^|>]+)(?:\|[^>]*)?|/([^|>]+)\|[^>]*)>')
def _get_text_parts(message: dict) -> list[str]:
@@ -2283,7 +2287,7 @@ def extract_skill_ids_from_messages(messages: list[dict]) -> set[str]:
return ids
SKILL_MENTION_STRIP_RE = re.compile(r'<(?:\$[a-z0-9_-]+(?:\|([^>]*))?|/[a-z0-9_-]+\|([^>]*))>')
SKILL_MENTION_STRIP_RE = re.compile(r'<(?:\$[^|>]+(?:\|([^>]*))?|/[^|>]+\|([^>]*))>')
def strip_skill_mentions(messages: list[dict]) -> None:
@@ -5218,12 +5222,7 @@ async def streaming_chat_response_handler(response, ctx):
reasoning_detail_items,
)
await save_current_response_stream()
# Providers such as OpenRouter send reasoning_details
# alongside the reasoning text: only drop the event when
# the details were all there was to report, otherwise the
# reasoning delta never reaches the client.
if not reasoning_content:
data = None
data = None
if value:
if (
@@ -5531,14 +5530,12 @@ async def streaming_chat_response_handler(response, ctx):
tool_call_iterations += 1
response_tool_calls = tool_calls.pop(0)
ask_user_staged, ask_user_error = stage_ask_user_tool_calls(response_tool_calls, output, output_id)
if ask_user_error:
response_tool_calls = [
tool_call
for tool_call in response_tool_calls
if tool_call.get('function', {}).get('name') != 'ask_user'
]
elif ask_user_staged:
ask_user_stage = stage_ask_user_tool_call(response_tool_calls, output, output_id)
if ask_user_stage:
if ask_user_stage['error']:
await event_emitter({'type': 'chat:completion', 'data': {'output': full_output()}})
continue
if is_saved_chat_id(metadata.get('chat_id')) and metadata.get('message_id'):
await pause_for_tool_approval(
metadata['chat_id'],
@@ -5570,8 +5567,7 @@ async def streaming_chat_response_handler(response, ctx):
tool_approval_mode = metadata.get('params', {}).get('tool_approval_mode', 'full')
if (
response_tool_calls
and tool_approval_mode == 'ask'
tool_approval_mode == 'ask'
and is_saved_chat_id(metadata.get('chat_id'))
and metadata.get('message_id')
):
@@ -5793,6 +5789,17 @@ async def streaming_chat_response_handler(response, ctx):
item['arguments'] = tc.get('function', {}).get('arguments', '{}')
break
# Append a new empty message item for the next response
output.append(
{
'type': 'message',
'id': output_id('msg'),
'status': 'in_progress',
'role': 'assistant',
'content': [{'type': 'output_text', 'text': ''}],
}
)
# Emit citation sources to the frontend for display
if citations_enabled:
for source in tool_call_sources:
-2
View File
@@ -374,8 +374,6 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
for filter_id in set(model.pop('filter_ids', [])) | global_filter_ids
if filter_id in enabled_filter_ids
]
# Set order varies per process, and an unstable order defeats the RedisDict content signature.
filter_ids.sort()
model['actions'] = []
for action_id in action_ids:
+7 -16
View File
@@ -148,15 +148,15 @@ async def build_tool_server_headers(
cookies = {}
if auth_type == 'bearer':
headers.update(bearer_auth_header(connection.get('key', '')))
headers['Authorization'] = f'Bearer {connection.get("key", "")}'
elif auth_type == 'session':
cookies = request.cookies if hasattr(request, 'cookies') else {}
headers.update(bearer_auth_header(request.state.token.credentials))
headers['Authorization'] = f'Bearer {request.state.token.credentials}'
elif auth_type == 'system_oauth':
cookies = request.cookies if hasattr(request, 'cookies') else {}
oauth_token = extra_params.get('__oauth_token__', None)
if oauth_token:
headers.update(bearer_auth_header(oauth_token.get('access_token', '')))
headers['Authorization'] = f'Bearer {oauth_token.get("access_token", "")}'
elif auth_type in ('oauth_2.1', 'oauth_2.1_static'):
try:
splits = server_id.split(':')
@@ -166,7 +166,7 @@ async def build_tool_server_headers(
user.id, f'{connection_type}:{oauth_server_id}'
)
if oauth_token:
headers.update(bearer_auth_header(oauth_token.get('access_token', '')))
headers['Authorization'] = f'Bearer {oauth_token.get("access_token", "")}'
except Exception as e:
log.error(f'Error getting OAuth token: {e}')
@@ -1338,13 +1338,7 @@ async def get_terminal_servers(request: Request):
data = await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:terminal_servers')
if data is not None:
terminal_servers = JSONCodec.loads(data)
connections = await Config.get('terminal_server.connections', []) or []
if terminal_servers or not any(
connection.get('url') and connection.get('enabled', True) for connection in connections
):
request.app.state.TERMINAL_SERVERS = terminal_servers
else:
terminal_servers = None
request.app.state.TERMINAL_SERVERS = terminal_servers
except Exception as e:
log.error(f'Error fetching terminal_servers from Redis: {e}')
@@ -1368,10 +1362,7 @@ async def get_terminal_tools(
- Builds callables that route through the terminal proxy
"""
connections = await Config.get('terminal_server.connections', []) or []
connection = next(
(terminal_connection for terminal_connection in connections if terminal_connection.get('id') == terminal_id),
None,
)
connection = next((c for c in connections if c.get('id') == terminal_id), None)
if connection is None:
raise RuntimeError(f"Terminal server '{terminal_id}' not found")
if not connection.get('enabled', True):
@@ -1383,7 +1374,7 @@ async def get_terminal_tools(
# Find the cached spec data for this terminal
terminal_servers = await get_terminal_servers(request)
server_data = next((server for server in terminal_servers if server.get('id') == terminal_id), None)
server_data = next((s for s in terminal_servers if s.get('id') == terminal_id), None)
if server_data is None:
raise RuntimeError(f"Terminal server '{terminal_id}' is unavailable")
+22 -13
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
# ---------------------------------------------------------------------------
# Interactive docker compose launcher for Open WebUI.
@@ -100,38 +101,46 @@ done
# ── Drop mode ─────────────────────────────────────────────────────────────────
if [[ "$drop_project" == true ]]; then
docker compose down --remove-orphans
./docker-stack.sh down --remove-orphans
echo -e "${GREEN}${BOLD}Compose project stopped and cleaned up.${RESET}"
exit 0
fi
# ── Build compose command ─────────────────────────────────────────────────────
#
# The base + GPU + audio file set is owned by docker-stack.sh (the single
# source of truth for "what this stack always needs"), so it can't drift out
# of sync between the two entry points the way docker-compose.audio.yaml once
# did here. This script only ever layers its own optional extras (api/data/
# playwright) on top and delegates the actual `docker compose` call.
compose_files=("-f" "docker-compose.yaml")
if [[ "$enable_gpu" == true ]]; then
if ! [[ "$gpu_count" =~ ^([0-9]+|all)$ ]]; then
echo >&2 "Error: Invalid GPU count '${gpu_count}'. Must be a number or 'all'."
exit 1
fi
if [[ "$gpu_count" =~ ^([0-9]+|all)$ ]]; then
export OLLAMA_GPU_DRIVER
OLLAMA_GPU_DRIVER=$(detect_gpu_driver)
export OLLAMA_GPU_COUNT="$gpu_count"
compose_files+=("-f" "docker-compose.gpu.yaml")
elif [[ "$enable_gpu" == true ]]; then
echo >&2 "Error: Invalid GPU count '${gpu_count}'. Must be a number or 'all'."
exit 1
fi
# docker-stack.sh always merges docker-compose.gpu.yaml (defaults: nvidia,
# count 1, from that file's own ${VAR-default} fallbacks) -- GPU passthrough
# is no longer optional here, matching docker-stack.sh's behavior. --enable-gpu
# now only customizes the driver/count via the env vars set above.
extra_files=()
if [[ "$enable_api" == true ]]; then
export OLLAMA_WEBAPI_PORT="$api_port"
compose_files+=("-f" "docker-compose.api.yaml")
extra_files+=("-f" "docker-compose.api.yaml")
fi
if [[ -n "$data_dir" ]]; then
export OLLAMA_DATA_DIR="$data_dir"
compose_files+=("-f" "docker-compose.data.yaml")
extra_files+=("-f" "docker-compose.data.yaml")
fi
if [[ "$enable_playwright" == true ]]; then
compose_files+=("-f" "docker-compose.playwright.yaml")
extra_files+=("-f" "docker-compose.playwright.yaml")
fi
export OPEN_WEBUI_PORT="$webui_port"
@@ -163,7 +172,7 @@ fi
# ── Launch ────────────────────────────────────────────────────────────────────
if docker compose "${compose_files[@]}" "${up_args[@]}"; then
if ./docker-stack.sh "${extra_files[@]}" "${up_args[@]}"; then
echo
echo -e "${GREEN}${BOLD}${CHECK_MARK} Compose project started successfully.${RESET}"
else
+85
View File
@@ -0,0 +1,85 @@
services:
asr:
build:
context: ../asr-server
container_name: asr
restart: unless-stopped
ports:
- '8020:8020'
environment:
- 'ASR_AUTH_TOKEN=${ASR_AUTH_TOKEN}'
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
tts:
build:
context: ../tts-server
container_name: tts
restart: unless-stopped
ports:
- '8010:8010'
environment:
- 'TTS_AUTH_TOKEN=${TTS_AUTH_TOKEN}'
- 'HF_HOME=/app/data'
# Fallback voice for direct /v1/audio/speech calls that use
# AUDIO_TTS_MODEL=sopro-v2-turbo without a `voice` field. Set to the
# basename (no .wav) of a clip dropped into services/tts-server/voices/.
# - 'TTS_SOPRO_DEFAULT_VOICE=your_voice_name'
volumes:
# Caches the downloaded Kokoro + Sopro weights across rebuilds.
- tts-data:/app/data
# Sopro voice reference clips (<name>.wav); drop files in here on the
# host and reference them by name via AUDIO_TTS_VOICE / `voice`.
- ../tts-server/voices:/app/voices:ro
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
open-webui:
depends_on:
- asr
- tts
environment:
- 'AUDIO_STT_ENGINE=openai'
- 'AUDIO_STT_OPENAI_API_BASE_URL=http://asr:8020/v1'
- 'AUDIO_STT_OPENAI_API_KEY=${ASR_AUTH_TOKEN}'
- 'AUDIO_STT_MODEL=nemotron-3.5-asr-streaming-0.6b'
- 'AUDIO_TTS_ENGINE=openai'
- 'AUDIO_TTS_OPENAI_API_BASE_URL=http://tts:8010/v1'
- 'AUDIO_TTS_OPENAI_API_KEY=${TTS_AUTH_TOKEN}'
# To switch back to Kokoro (fixed presets), set:
# AUDIO_TTS_MODEL=kokoro-82m
# AUDIO_TTS_VOICE=af_heart
- 'AUDIO_TTS_MODEL=sopro-v2-turbo'
- 'AUDIO_TTS_VOICE=jarvis-target'
wyoming-openai:
image: ghcr.io/roryeckel/wyoming_openai:latest
container_name: wyoming-openai
restart: unless-stopped
depends_on:
- asr
- tts
ports:
- '10300:10300'
environment:
- 'STT_OPENAI_URL=http://asr:8020/v1'
- 'STT_OPENAI_KEY=${ASR_AUTH_TOKEN}'
- 'STT_MODELS=nemotron-3.5-asr-streaming-0.6b'
- 'TTS_OPENAI_URL=http://tts:8010/v1'
- 'TTS_OPENAI_KEY=${TTS_AUTH_TOKEN}'
- 'TTS_MODELS=sopro-v2-turbo'
- 'TTS_VOICES=jarvis-target'
- 'TTS_SPEED=1.2'
volumes:
tts-data: {}
+72
View File
@@ -0,0 +1,72 @@
services:
llama-mtp:
build:
context: ../mtp-relaxed-decoding
container_name: llama-mtp
restart: unless-stopped
ports:
- '8030:8030'
volumes:
# Same host dir ollama's /gguf-import mount points at -- target +
# MTP drafter already live there.
- /home/brian-llm/models/gguf:/models:ro
environment:
- 'MTP_AUTH_TOKEN=${MTP_AUTH_TOKEN}'
- 'MTP_TARGET_GGUF=/models/gemma-4-E2B-it-Q4_K_M.gguf'
- 'MTP_DRAFT_GGUF=/models/mtp-gemma-4-E2B-it.gguf'
# n_max beyond ~2 wasn't worth it for this drafter, and relaxed_top_n
# 2-20 all performed similarly -- see ../mtp-relaxed-decoding/README.md
# §6. Benchmarked on an RTX 5090 there; re-check on this Titan Xp
# before trusting these as tuned rather than just reasonable defaults.
- 'MTP_N_MAX=2'
- 'MTP_RELAXED_TOP_N=5'
# Titan Xp has 12GB VRAM shared with asr/tts/ollama -- batch size kept
# below the README's RTX-5090 sizing (1024) for headroom, but context
# was raised to match the README since Gemma-4's hybrid SWA
# architecture (most layers use a small fixed attention window, only
# every 5th layer is full-context) keeps KV cache growth with n_ctx
# much cheaper than a plain transformer's -- measured at only 271 MiB
# of KV cache (target + draft) per 32768-token slot.
#
# -np 4 gives open-webui's browser chat and the voice assistant (and
# anything else hitting this OpenAI-compatible endpoint) their own
# slot each instead of one contending for a single shared slot --
# with -np 1, whichever of them touched the slot last would evict the
# other's live KV cache, forcing a full prompt reprocess (multi-second
# stall) on the next request from the loser. n_ctx_slot = n_ctx /
# n_parallel, so total ctx is quadrupled to keep each slot at the same
# 32768 budget as before -- 131072 matches this model's native
# n_ctx_train exactly, and only costs ~810 MiB more KV cache VRAM
# than the old -np 1 config (still well under the ~7GB that's free).
- 'MTP_CTX_SIZE=131072'
- 'MTP_N_PARALLEL=4'
- 'MTP_BATCH_SIZE=512'
# Server-side default -- only applies when the client doesn't send its
# own temperature (Open WebUI won't unless you set it in the model's
# Advanced Params).
- 'MTP_TEMP=0.6'
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
open-webui:
depends_on:
- llama-mtp
environment:
# Registers this alongside (not instead of) the Ollama connection --
# Open WebUI lists both in the model picker.
- 'OPENAI_API_BASE_URLS=http://llama-mtp:8030/v1'
- 'OPENAI_API_KEYS=${MTP_AUTH_TOKEN}'
ollama-auth:
depends_on:
- llama-mtp
environment:
# ollama-auth/default.conf.template now proxies to llama-mtp (Ollama
# itself serves no models anymore) and swaps the client's
# OLLAMA_AUTH_TOKEN for this before forwarding upstream.
- 'MTP_AUTH_TOKEN=${MTP_AUTH_TOKEN}'
+89 -1
View File
@@ -2,11 +2,49 @@ services:
ollama:
volumes:
- ollama:/root/.ollama
# Read-only mount for importing custom GGUF files (e.g. via `ollama create`)
- /home/brian-llm/models/gguf:/gguf-import:ro
container_name: ollama
pull_policy: always
tty: true
restart: unless-stopped
image: ollama/ollama:${OLLAMA_DOCKER_TAG-latest}
environment:
# Never unload a loaded model on idle -- default is 5 minutes.
- 'OLLAMA_KEEP_ALIVE=-1'
- 'OLLAMA_NUM_PARALLEL=4'
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
# No ports published here -- Ollama has no auth of its own, so LAN access
# goes through the ollama-auth proxy below instead. open-webui still
# reaches it directly over the internal docker network (see
# OLLAMA_BASE_URL), which doesn't need the token.
ollama-auth:
image: nginx:alpine
container_name: ollama-auth
restart: unless-stopped
depends_on:
- ollama
volumes:
- ./ollama-auth/default.conf.template:/etc/nginx/templates/default.conf.template:ro
environment:
- 'OLLAMA_AUTH_TOKEN=${OLLAMA_AUTH_TOKEN}'
ports:
# Reachable from the LAN directly; restrict to the LAN subnet with the
# host firewall (see ufw rules) as defense in depth, but the real
# boundary is now the Bearer token check in ollama-auth/default.conf.template.
# This is plain HTTP, so the token is readable by anything already on
# the LAN segment that can sniff traffic -- accepted here since the
# trust boundary is "the LAN subnet," not "the wire." Revisit with TLS
# if that assumption ever stops holding (untrusted/guest/IoT devices
# sharing this LAN, etc).
- '11434:11434'
open-webui:
build:
@@ -19,14 +57,64 @@ services:
depends_on:
- ollama
ports:
- ${OPEN_WEBUI_PORT-3000}:8080
# Published on all interfaces since nginx runs on a separate LAN host
# (192.168.50.224) and must reach this port directly. Firewalling this
# to that host is still good hygiene (see ufw/DOCKER-USER notes) but is
# no longer the sole security boundary -- OIDC auth below is real
# authentication against Authentik regardless of network path.
- '${OPEN_WEBUI_PORT-3000}:8080'
environment:
- 'OLLAMA_BASE_URL=http://ollama:11434'
- 'WEBUI_SECRET_KEY='
# Only takes effect on a fresh DB -- once ui.default_models exists in
# the config table, that value wins (see open-webui's PersistentConfig
# seed_defaults behavior). Currently gemma-4-e2b-mtp was removed from
# Ollama in favor of the relaxed-MTP llama-mtp service; the live value
# is 'gemma-4-e2b,/models/gemma-4-E2B-it-Q4_K_M.gguf'.
- 'DEFAULT_MODELS=gemma-4-e2b,/models/gemma-4-E2B-it-Q4_K_M.gguf'
- 'ENABLE_OAUTH_SIGNUP=true'
# Links Authentik login to the existing local account with the same
# email instead of rejecting it as a collision -- safe here since this
# is a single-admin personal instance, not an open-signup multi-tenant one.
- 'OAUTH_MERGE_ACCOUNTS_BY_EMAIL=true'
# Fill these in after creating the OAuth2/OpenID Provider + Application
# in Authentik (see instructions) -- OAUTH_CLIENT_ID/SECRET come from
# that provider.
- 'OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID}'
- 'OAUTH_CLIENT_SECRET=${OAUTH_CLIENT_SECRET}'
- 'OPENID_PROVIDER_URL=https://auth.hetherman.cloud/application/o/ai-open-webui/.well-known/openid-configuration'
- 'OPENID_REDIRECT_URI=https://ai.hetherman.cloud/oauth/oidc/callback'
- 'OAUTH_SCOPES=openid email profile'
# SSO-only: Authentik becomes the sole way to authenticate, admin
# included. If Authentik is ever unreachable, nobody can log in until
# it's fixed -- no local password fallback.
- 'ENABLE_PASSWORD_AUTH=false'
- 'ENABLE_LOGIN_FORM=false'
- 'ENABLE_SIGNUP=false'
extra_hosts:
- host.docker.internal:host-gateway
restart: unless-stopped
# ollama-keepwarm:
# image: curlimages/curl:latest
# container_name: ollama-keepwarm
# restart: unless-stopped
# depends_on:
# - ollama
# # Pings Ollama every 60s to load the default model and refresh its
# # keep_alive timer -- covers first boot and re-loads it any time the
# # ollama container itself restarts (which flushes it from memory).
# # Keep this model name in sync with DEFAULT_MODELS above.
# entrypoint:
# - sh
# - -c
# - |
# while true; do
# curl -s -o /dev/null -X POST http://ollama:11434/api/generate \
# -d '{"model":"gemma-4-e2b","keep_alive":-1}'
# sleep 60
# done
volumes:
ollama: {}
open-webui: {}
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
# Wraps docker compose with the full file set for this stack (base + GPU
# passthrough + audio/ollama-auth services) so a plain "up"/"build" can't
# accidentally drop the ollama GPU override again.
readonly COMPOSE_FILES=(-f docker-compose.yaml -f docker-compose.gpu.yaml -f docker-compose.audio.yaml -f docker-compose.mtp.yaml)
if [[ $# -eq 0 ]]; then
exec docker compose "${COMPOSE_FILES[@]}" up -d --build
fi
exec docker compose "${COMPOSE_FILES[@]}" "$@"
+39
View File
@@ -0,0 +1,39 @@
map_hash_bucket_size 128;
map $http_authorization $ollama_auth_ok {
"Bearer ${OLLAMA_AUTH_TOKEN}" 1;
default 0;
}
server {
listen 11434;
location / {
if ($ollama_auth_ok = 0) {
return 401;
}
# Ollama itself now serves no models -- this proxy targets
# llama-mtp (the relaxed-MTP server) instead, so LAN clients that
# already trust this URL/token (e.g. Home Assistant's voice
# pipeline) don't need reconfiguring. Docker's embedded DNS
# (127.0.0.11) can reassign a container hostname to a new IP on
# restart; proxy_pass to a literal upstream caches that IP for the
# container's lifetime, so route through a variable to force
# nginx to re-resolve via this resolver on each request instead.
resolver 127.0.0.11 valid=10s;
set $mtp_upstream llama-mtp;
proxy_pass http://$mtp_upstream:8030;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
# Client presents OLLAMA_AUTH_TOKEN (validated above); llama-server
# behind this proxy expects its own MTP_AUTH_TOKEN instead, so swap
# it here rather than requiring every client to be reconfigured.
proxy_set_header Authorization "Bearer ${MTP_AUTH_TOKEN}";
proxy_buffering off;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
client_max_body_size 0;
}
}
+29 -91
View File
@@ -108,23 +108,23 @@ textarea::-webkit-scrollbar-corner {
}
.input-prose {
@apply prose dark:prose-invert !text-[0.9375rem] prose-headings:font-medium prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 whitespace-pre-line;
@apply prose dark:prose-invert !text-[0.9375rem] prose-headings:font-normal prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 whitespace-pre-line;
}
.input-prose-sm {
@apply prose dark:prose-invert prose-headings:font-medium prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-1 whitespace-pre-line text-sm;
@apply prose dark:prose-invert prose-headings:font-normal prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-1 whitespace-pre-line text-sm;
}
.markdown-prose {
@apply prose prose-sm dark:prose-invert max-w-none break-words !text-[0.9375rem] font-normal leading-relaxed prose-p:mt-0 prose-p:mb-2 prose-p:font-normal prose-p:leading-relaxed prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-medium prose-headings:leading-snug prose-h1:text-xl prose-h2:text-lg prose-h3:text-base prose-strong:font-medium prose-code:before:content-none prose-code:after:content-none prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5 prose-li:font-normal prose-pre:my-3 prose-table:my-0 prose-blockquote:my-3 prose-blockquote:font-normal prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850/30 prose-img:my-2 [&>:first-child]:mt-0 [&>:last-child]:mb-0 [&_p:first-child]:mt-0 [&_h1:first-child]:mt-0 [&_h2:first-child]:mt-0 [&_h3:first-child]:mt-0 [&_h4:first-child]:mt-0 [&_h5:first-child]:mt-0 [&_h6:first-child]:mt-0;
@apply prose prose-sm dark:prose-invert max-w-none break-words !text-[0.9375rem] font-normal leading-relaxed prose-p:mt-0 prose-p:mb-2 prose-p:font-normal prose-p:leading-relaxed prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-normal prose-headings:leading-snug prose-h1:text-xl prose-h2:text-lg prose-h3:text-base prose-strong:font-medium prose-code:before:content-none prose-code:after:content-none prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5 prose-li:font-normal prose-pre:my-3 prose-table:my-0 prose-blockquote:my-3 prose-blockquote:font-normal prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850/30 prose-img:my-2 [&>:first-child]:mt-0 [&>:last-child]:mb-0 [&_p:first-child]:mt-0 [&_h1:first-child]:mt-0 [&_h2:first-child]:mt-0 [&_h3:first-child]:mt-0 [&_h4:first-child]:mt-0 [&_h5:first-child]:mt-0 [&_h6:first-child]:mt-0;
}
.markdown-prose-sm {
@apply text-sm prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-medium prose-hr:my-2 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
@apply text-sm prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-normal prose-hr:my-2 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
}
.markdown-prose-xs {
@apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-medium prose-hr:my-0.5 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
@apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-normal prose-hr:my-0.5 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
}
.markdown a {
@@ -173,7 +173,6 @@ li p {
}
select {
color-scheme: light;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236B7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");
background-position: right 0rem center;
background-repeat: no-repeat;
@@ -187,10 +186,6 @@ select {
-webkit-appearance: none;
}
.dark select {
color-scheme: dark;
}
.dark select:not([class*='bg-transparent']) {
@apply bg-gray-900 text-gray-300;
}
@@ -870,134 +865,77 @@ body {
padding-bottom: 2rem; /* space for the bottom toolbar */
}
/* Accessibility Mode: placeholders bottom out at 1.58:1 (WCAG 1.4.3 wants 4.5:1). */
/* High Contrast Mode: placeholders bottom out at 1.58:1 (WCAG 1.4.3 wants 4.5:1). */
@layer utilities {
html.high-contrast:not(.dark) :is(input, textarea)::placeholder {
color: var(--color-gray-700);
color: var(--color-gray-600);
}
html.high-contrast.dark :is(input, textarea)::placeholder {
color: var(--color-gray-300);
color: var(--color-gray-500);
}
}
/* Accessibility Mode: muted text fails WCAG 1.4.3 (2.07:1 light, 3.12:1 dark).
/* High Contrast Mode: muted text fails WCAG 1.4.3 (2.07:1 light, 3.12:1 dark).
:where() keeps these at low specificity so hover variants still win. */
@layer utilities {
html:where(.high-contrast):not(:where(.dark)) .text-gray-400 {
color: var(--color-gray-700);
color: var(--color-gray-600);
}
html:where(.high-contrast).dark
:is(.dark\:text-gray-400, .dark\:text-gray-500, .dark\:text-gray-600) {
color: var(--color-gray-300);
}
html:where(.high-contrast).dark
:is(
.dark\:hover\:text-gray-100:hover,
.dark\:hover\:text-gray-200:hover,
.dark\:hover\:text-gray-300:hover,
.dark\:hover\:text-gray-400:hover
) {
color: #fff;
html:where(.high-contrast).dark .dark\:text-gray-600 {
color: var(--color-gray-400);
}
/* Elements that hover to a lighter grey than the new resting colour. */
html:where(.high-contrast):not(:where(.dark))
:is(
.hover\:text-gray-500:hover,
.hover\:text-gray-600:hover,
.hover\:text-gray-700:hover,
.hover\:text-gray-800:hover
) {
color: var(--color-gray-900);
html:where(.high-contrast):not(:where(.dark)) .hover\:text-gray-500:hover {
color: var(--color-gray-800);
}
}
/* Accessibility Mode: gray-500 muted text is 2.77:1 in light (WCAG 1.4.3 wants 4.5:1).
/* High Contrast Mode: gray-500 muted text is 2.77:1 in light (WCAG 1.4.3 wants 4.5:1).
The utility stays in the layer so hover variants still outrank it; the classes below
are unlayered, so their override has to be too. */
@layer utilities {
html:where(.high-contrast):not(:where(.dark)) :is(.text-gray-500, .text-gray-600) {
color: var(--color-gray-700);
html:where(.high-contrast):not(:where(.dark)) .text-gray-500 {
color: var(--color-gray-600);
}
}
html.high-contrast:not(.dark) :is(.app-muted, .app-icon-muted, .tiptap table) {
color: var(--color-gray-700);
color: var(--color-gray-600);
}
html.high-contrast.dark :is(.app-muted, .app-icon-muted, .tiptap table) {
color: var(--color-gray-300);
}
html.high-contrast:not(.dark)
:is(
#sidebar .hover\:bg-gray-100:hover,
#sidebar .group:hover .group-hover\:bg-gray-100,
.app-dropdown-menu :is(button, a):hover
) {
background-color: var(--color-gray-200) !important;
}
html.high-contrast.dark
:is(
#sidebar .dark\:hover\:bg-gray-900:hover,
#sidebar .group:hover .dark\:group-hover\:bg-gray-900,
.app-dropdown-menu :is(button, a):hover
) {
background-color: var(--color-gray-800) !important;
}
/* Accessibility Mode: the lightest muted pair, 1.58:1 in light and 2.14:1 in dark.
/* High Contrast Mode: the lightest muted pair, 1.58:1 in light and 2.14:1 in dark.
text-gray-400/70 is an icon colour, so 1.4.11 governs it, and 2.07:1 misses that too. */
@layer utilities {
html:where(.high-contrast):not(:where(.dark)) :is(.text-gray-300, .text-gray-400\/70) {
color: var(--color-gray-700);
color: var(--color-gray-600);
}
html:where(.high-contrast).dark :is(.dark\:text-gray-700, .dark\:text-gray-800) {
color: var(--color-gray-300);
html:where(.high-contrast).dark .dark\:text-gray-700 {
color: var(--color-gray-400);
}
/* Hover states that land lighter than the new resting colour. */
html:where(.high-contrast):not(:where(.dark))
:is(
.hover\:text-gray-500:hover,
.hover\:text-gray-600:hover,
.hover\:text-gray-700:hover,
.hover\:text-gray-800:hover
) {
color: var(--color-gray-900);
html:where(.high-contrast):not(:where(.dark)) .hover\:text-gray-500:hover {
color: var(--color-gray-800);
}
html:where(.high-contrast):not(:where(.dark))
.group:hover
:is(.group-hover\:text-gray-500, .group-hover\:text-gray-600, .group-hover\:text-gray-700) {
color: var(--color-gray-900);
}
html:where(.high-contrast).dark
.group:hover
:is(
.dark\:group-hover\:text-gray-200,
.dark\:group-hover\:text-gray-300,
.dark\:group-hover\:text-gray-400
) {
color: #fff;
html:where(.high-contrast):not(:where(.dark)) .group:hover .group-hover\:text-gray-500 {
color: var(--color-gray-800);
}
}
/* Autocompletion ghost text is 2.65:1 in light; the dark canvas already passes. */
html.high-contrast:not(.dark) .ai-autocompletion::after {
color: var(--color-gray-700);
color: var(--color-gray-600);
}
/* The shimmer gradient is 2.10:1 in light, and clipping it to the text leaves no
solid colour to raise; render it as flat text instead. */
html.high-contrast:not(.dark) .shimmer {
background: none;
color: var(--color-gray-800);
-webkit-text-fill-color: var(--color-gray-800);
color: var(--color-gray-700);
-webkit-text-fill-color: var(--color-gray-700);
}
-30
View File
@@ -407,36 +407,6 @@ export const userSignOut = async () => {
return res;
};
export const getLogoutRedirectUrl = (redirectUrl?: string | null) => {
const logoutUrl = new URL('/auth?state=logout', window.location.origin);
const postLogoutUrl = new URL('/auth', window.location.origin);
if (!redirectUrl) {
return logoutUrl.href;
}
const url = new URL(redirectUrl, window.location.origin);
if (url.origin === window.location.origin && url.pathname === '/auth') {
url.searchParams.set('state', 'logout');
return url.href;
}
const postLogoutRedirectUri = url.searchParams.get('post_logout_redirect_uri');
if (postLogoutRedirectUri) {
const configuredPostLogoutUrl = new URL(postLogoutRedirectUri, window.location.origin);
if (
configuredPostLogoutUrl.origin === window.location.origin &&
configuredPostLogoutUrl.pathname === '/auth'
) {
url.searchParams.set('state', 'logout');
}
return url.href;
}
url.searchParams.set('post_logout_redirect_uri', postLogoutUrl.href);
url.searchParams.set('state', 'logout');
return url.href;
};
export const addUser = async (
token: string,
name: string,
-23
View File
@@ -292,29 +292,6 @@ export const downloadFileBlob = async (
return { blob, filename };
};
export const downloadFilePreview = async (
baseUrl: string,
apiKey: string,
path: string,
sessionId?: string
): Promise<{ blob: Blob; filename: string } | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/view?path=${encodeURIComponent(path)}&preview=true`;
const headers: Record<string, string> = bearerHeaders(apiKey);
if (sessionId) headers['X-Session-Id'] = sessionId;
const res = await fetch(url, { headers }).catch(() => null);
if (!res) return null;
if (!res.ok) return null;
const contentType = res.headers.get('content-type') ?? '';
const filename = path.split('/').pop() ?? 'file';
if (!contentType.includes('application/pdf')) return null;
const blob = await res.blob().catch(() => null);
if (!blob) return null;
return { blob, filename };
};
export const archiveFromTerminal = async (
baseUrl: string,
apiKey: string,
@@ -14,7 +14,7 @@
<slot name="label">{label}</slot>
</div>
<div class="min-w-0">
<div class="shrink-0">
<slot {labelId} />
</div>
</div>
@@ -269,14 +269,6 @@
baseModels = await getBaseModels(localStorage.token, selectedTag);
allModels = await getModels(localStorage.token);
const providerModels = await getModels(localStorage.token, null, true);
const allModelIds = new Set<string>(allModels.map((model: ModelListItem) => model.id));
allModels = [
...allModels,
...providerModels.filter((model: ModelListItem) => !allModelIds.has(model.id))
];
const baseModelIds = new Set<string>(baseModels.map((model: ModelListItem) => model.id));
models = allModels
@@ -168,11 +168,7 @@
</button>
{#if showCapabilities}
<div
class="max-h-[24rem] overflow-y-auto pb-2 pr-1 scrollbar-hover"
on:click={updateDirty}
on:change={updateDirty}
>
<div class="pb-2" on:click={updateDirty} on:change={updateDirty}>
<Capabilities bind:capabilities={defaultCapabilities} />
{#if availableFeatures.length > 0}
@@ -235,12 +231,7 @@
</button>
{#if showPromptSuggestions}
<div
class="max-h-[24rem] overflow-y-auto pb-2 pr-1 scrollbar-hover"
on:click={updateDirty}
on:change={updateDirty}
on:input={updateDirty}
>
<div class="pb-2" on:click={updateDirty} on:change={updateDirty} on:input={updateDirty}>
<PromptSuggestions bind:promptSuggestions />
</div>
{/if}
+1 -4
View File
@@ -25,10 +25,6 @@
scrollToTab(selectedTab);
}
$: if (loaded && selectedTab) {
loadCounts();
}
const scrollToTab = (tabId) => {
const tabElement = document.getElementById(tabId);
if (tabElement) {
@@ -62,6 +58,7 @@
await goto('/');
}
await loadCounts();
loaded = true;
const containerElement = document.getElementById('users-tabs-container');
+1 -4
View File
@@ -48,10 +48,6 @@
return (b.member_count ?? 0) - (a.member_count ?? 0) || a.name.localeCompare(b.name);
});
$: if (loaded) {
adminGroupCount.set(filteredGroups.length);
}
/** @type {any} */
let defaultPermissions = {};
@@ -60,6 +56,7 @@
const setGroups = async () => {
groups = await getGroups(localStorage.token);
adminGroupCount.set(groups.length);
};
/** @param {any} updatedGroup */
@@ -95,7 +95,7 @@
};
const formatSchedule = (rrule: string): string => {
if (/COUNT=1(?!\d)/.test(rrule)) {
if (rrule.includes('COUNT=1')) {
const match = rrule.match(/DTSTART:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})/);
if (match) {
const d = new Date(`${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}`);
@@ -109,9 +109,6 @@
const parts: Record<string, string> = {};
rrule
.split(/\s+/)
.filter((line) => !line.toUpperCase().startsWith('DTSTART'))
.join('')
.replace('RRULE:', '')
.split(';')
.forEach((part) => {
@@ -106,7 +106,7 @@
export const parseRrule = (s: string) => {
// Detect ONCE (COUNT=1 with DTSTART)
if (/COUNT=1(?!\d)/.test(s)) {
if (s.includes('COUNT=1')) {
frequency = 'ONCE';
const match = s.match(/DTSTART:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})/);
if (match) {
@@ -116,10 +116,7 @@
return;
}
const parts: Record<string, string> = {};
s.split(/\s+/)
.filter((line) => !line.toUpperCase().startsWith('DTSTART'))
.join('')
.replace('RRULE:', '')
s.replace('RRULE:', '')
.split(';')
.forEach((p) => {
const [k, v] = p.split('=');
@@ -129,12 +129,7 @@
loading = true;
try {
const startNs = dateTimeToNs(startDate, allDay ? '00:00' : startTime);
let endNs = endDate ? dateTimeToNs(endDate, allDay ? '23:59' : endTime) : undefined;
if (endNs !== undefined && endNs < startNs) {
const duration =
event?.end_at && event.end_at > event.start_at ? event.end_at - event.start_at : 0;
endNs = startNs + duration;
}
const endNs = endDate ? dateTimeToNs(endDate, allDay ? '23:59' : endTime) : undefined;
if (event && !event.meta?.automation_id) {
const result = await updateCalendarEvent(localStorage.token, event.id, {
@@ -31,10 +31,7 @@
const startDate = new Date(startMs);
const endDate = new Date(endMs);
const d = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());
const last = Math.max(
d.getTime(),
new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate()).getTime()
);
const last = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate()).getTime();
while (d.getTime() <= last) {
const key = d.getTime().toString();
(map[key] ??= []).push(e);
@@ -93,7 +90,7 @@
const dayEndMs = dayStartMs + 86_400_000;
return filteredEvents.filter((e) => {
const startMs = e.start_at / NS;
const endMs = Math.max(startMs, (e.end_at || e.start_at) / NS);
const endMs = (e.end_at || e.start_at) / NS;
return startMs < dayEndMs && endMs >= dayStartMs;
});
}
+13 -14
View File
@@ -1144,7 +1144,6 @@
const terminalEventHandler = (type: string, data: any) => {
if (type === 'terminal:display_file') {
if (!data?.path) return;
if ($settings?.terminalFileDisplay === 'inline') return;
displayFileHandler(data.path, { showControls, showFileNavPath }, { page: data?.page });
} else if (type === 'terminal:write_file' || type === 'terminal:replace_file_content') {
if (!data?.path) return;
@@ -1220,11 +1219,7 @@
} else if (type === 'chat:active') {
if (!data?.active) {
taskIds = null;
if (
$chatId &&
!$temporaryChatEnabled &&
hasPendingAssistantLeaf(event?.message_id ?? null)
) {
if ($chatId && !$temporaryChatEnabled && hasPendingAssistantLeaf()) {
await loadChat();
}
if ($chatId && !$temporaryChatEnabled) {
@@ -1504,9 +1499,9 @@
} catch {}
};
const hasPendingAssistantLeaf = (messageId: string | null = null) =>
(messageId ? [history.messages[messageId]] : Object.values(history.messages)).some(
(message: any) =>
const hasPendingAssistantLeaf = () =>
Object.values(history.messages).some(
(message) =>
message?.role === 'assistant' && !message.done && (message.childrenIds?.length ?? 0) === 0
);
@@ -2118,9 +2113,8 @@
autoScroll = true;
// resetInput() must stay last: the selected model's defaults override the draft's selection.
await restoreChatInput(sessionStorage.getItem('chat-input'));
await resetInput();
await restoreChatInput(sessionStorage.getItem('chat-input'));
await chatId.set('');
await chatTitle.set('');
@@ -2750,7 +2744,8 @@
};
const chatCompletionEventHandler = async (data, message, chatId) => {
const { id, done, choices, content, output, sources, selected_model_id, error, usage } = data;
const { id, done, choices, content, output, sources, selected_model_id, error, usage, timings } =
data;
// Store raw OR-aligned output items from backend
if (output) {
@@ -2803,8 +2798,12 @@
message.arena = true;
}
if (usage) {
message.usage = usage;
if (usage || timings) {
// llama.cpp-compatible servers (e.g. our relaxed-MTP llama-server) send
// generation speed as a sibling `timings` object rather than inside
// `usage` -- merge it in so it surfaces in the same response-info
// tooltip Ollama's eval_count/eval_duration fields already populate.
message.usage = { ...(usage || {}), ...(timings || {}) };
}
history.messages[message.id] = message;
+20 -59
View File
@@ -2,10 +2,8 @@
// Persists across mount/unmount cycles (module-level, not per-instance)
let savedPath = '/';
let savedFileRoot = null;
/** @type {Map<string, string[]>} */
const treeExpandedCache = new Map();
/** @type {Map<string, [string, any[]][]>} */
const treeContentsCache = new Map();
const treeExpandedCache = new Map<string, string[]>();
const treeContentsCache = new Map<string, [string, any[]][]>();
</script>
<script lang="ts">
@@ -25,7 +23,6 @@
listFiles,
readFile,
downloadFileBlob,
downloadFilePreview,
archiveFromTerminal,
uploadToTerminal,
createDirectory,
@@ -868,37 +865,19 @@
);
if (result) fileSqliteData = await result.blob.arrayBuffer();
} else if (isOffice(filePath)) {
const ext = getFileExt(filePath);
try {
if (ext === 'docx') {
const preview = await downloadFilePreview(
terminal.url,
terminal.key,
filePath,
chatId ?? undefined
);
if (preview) {
filePdfData = await preview.blob.arrayBuffer();
} else {
const result = await downloadFileBlob(
terminal.url,
terminal.key,
filePath,
chatId ?? undefined
);
if (!result) throw new Error('Preview failed');
const arrayBuffer = await result.blob.arrayBuffer();
const result = await downloadFileBlob(
terminal.url,
terminal.key,
filePath,
chatId ?? undefined
);
if (result) {
const ext = getFileExt(filePath);
const arrayBuffer = await result.blob.arrayBuffer();
try {
if (ext === 'docx') {
fileDocxData = arrayBuffer;
}
} else if (ext === 'xlsx') {
const result = await downloadFileBlob(
terminal.url,
terminal.key,
filePath,
chatId ?? undefined
);
if (result) {
const arrayBuffer = await result.blob.arrayBuffer();
} else if (ext === 'xlsx') {
const XLSX = await import('xlsx');
const wb = XLSX.read(new Uint8Array(arrayBuffer), { type: 'array' });
excelWorkbook = wb;
@@ -910,34 +889,16 @@
const DOMPurify = (await import('dompurify')).default;
fileOfficeHtml = DOMPurify.sanitize(result.html);
}
}
} else if (ext === 'pptx') {
const preview = await downloadFilePreview(
terminal.url,
terminal.key,
filePath,
chatId ?? undefined
);
if (preview) {
filePdfData = await preview.blob.arrayBuffer();
} else {
const result = await downloadFileBlob(
terminal.url,
terminal.key,
filePath,
chatId ?? undefined
);
if (!result) throw new Error('Preview failed');
const arrayBuffer = await result.blob.arrayBuffer();
} else if (ext === 'pptx') {
const { pptxToImages } = await import('$lib/utils/pptxToHtml');
const fallback = await pptxToImages(arrayBuffer);
fileOfficeSlides = fallback.images;
const result = await pptxToImages(arrayBuffer);
fileOfficeSlides = result.images;
currentSlide = 0;
}
} catch (e) {
console.error('Failed to render Office file:', e);
fileContent = `Error previewing file: ${e instanceof Error ? e.message : 'Unknown error'}`;
}
} catch (e) {
console.error('Failed to render Office file:', e);
fileContent = `Error previewing file: ${e instanceof Error ? e.message : 'Unknown error'}`;
}
} else {
fileContent = await readFile(terminal.url, terminal.key, filePath, chatId ?? undefined);
@@ -7,7 +7,7 @@
import { isCodeFile } from '$lib/utils/codeHighlight';
import { initMermaid, renderMermaidDiagram } from '$lib/utils';
import Spinner from '../../common/Spinner.svelte';
import PdfPagesPreview from '../../common/PdfPagesPreview.svelte';
import PDFViewer from '../../common/PDFViewer.svelte';
import PanzoomContainer from '../../common/PanzoomContainer.svelte';
import DocxPreview from '../../common/DocxPreview.svelte';
import PptxPreview from '../../common/PptxPreview.svelte';
@@ -16,7 +16,7 @@
import SqliteView from './SqliteView.svelte';
import FileCodeEditor from './FileCodeEditor.svelte';
let pdfPagesPreviewRef: PdfPagesPreview;
let pdfViewerRef: PDFViewer;
let fileCodeEditorRef: FileCodeEditor;
const i18n = getContext('i18n');
@@ -116,7 +116,6 @@
$: isNotebook = getExt(selectedFile) === 'ipynb';
$: isCode = isCodeFile(selectedFile);
$: csvDelimiter = getExt(selectedFile) === 'tsv' ? '\t' : ',';
$: isPptx = getExt(selectedFile) === 'pptx';
// For HTML files on system terminals (proxy URL), use path-based serving
// so the iframe can resolve relative CSS/JS/image references via cookie auth.
@@ -280,14 +279,13 @@
};
export const resetPdfView = () => {
pdfPagesPreviewRef?.resetView();
pdfViewerRef?.resetView();
};
</script>
<div
class="flex-1 {fileImageUrl !== null ||
fileDocxData !== null ||
filePdfData !== null ||
(fileOfficeSlides !== null && fileOfficeSlides.length > 0)
? 'overflow-hidden'
: 'overflow-y-auto'} min-h-0 min-w-0 relative h-full"
@@ -323,16 +321,7 @@
</audio>
</div>
{:else if filePdfData !== null}
<PdfPagesPreview
bind:this={pdfPagesPreviewRef}
data={filePdfData}
bind:currentSlide
{targetPage}
singlePage={isPptx}
itemLabel={isPptx ? 'Slide' : 'Page'}
listLabel={isPptx ? 'Slides' : 'Pages'}
className="w-full h-full"
/>
<PDFViewer bind:this={pdfViewerRef} data={filePdfData} {targetPage} className="w-full h-full" />
{:else if fileSqliteData !== null}
<SqliteView data={fileSqliteData} />
{:else if fileDocxData !== null}
@@ -515,10 +504,9 @@
<div
class="absolute bottom-3 left-1/2 z-10 flex -translate-x-1/2 items-center gap-0.5 rounded-lg border border-gray-200/60 bg-white/90 px-1 py-0.5 shadow-lg backdrop-blur-sm dark:border-gray-700/60 dark:bg-gray-850/90"
>
<!-- Pinch covers in/out on coarse pointers; reset has no gesture, so it stays -->
<button
type="button"
class="inline-flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md p-1.5 text-gray-500 transition hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 pointer-coarse:hidden"
class="inline-flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md p-1.5 text-gray-500 transition hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
on:click={() => panzoomRef?.zoomOut()}
aria-label={$i18n.t('Zoom out')}
>
@@ -545,7 +533,7 @@
</button>
<button
type="button"
class="inline-flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md p-1.5 text-gray-500 transition hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 pointer-coarse:hidden"
class="inline-flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md p-1.5 text-gray-500 transition hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
on:click={() => panzoomRef?.zoomIn()}
aria-label={$i18n.t('Zoom in')}
>
+1 -4
View File
@@ -1858,10 +1858,7 @@
id="message-input-container"
class="flex-1 flex flex-col relative w-full shadow-lg rounded-3xl border {$temporaryChatEnabled
? 'border-dashed border-gray-100 dark:border-gray-800 hover:border-gray-200 focus-within:border-gray-200 hover:dark:border-gray-700 focus-within:dark:border-gray-700'
: ' border-gray-100/30 dark:border-gray-850/30 hover:border-gray-200 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800'} {($settings?.highContrastMode ??
false)
? 'focus-within:outline focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-blue-500 [&_.ProseMirror:focus-visible]:outline-none!'
: ''} transition px-0.5 bg-white/5 dark:bg-gray-500/5 backdrop-blur-sm dark:text-gray-100"
: ' border-gray-100/30 dark:border-gray-850/30 hover:border-gray-200 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800'} transition px-0.5 bg-white/5 dark:bg-gray-500/5 backdrop-blur-sm dark:text-gray-100"
dir={$settings?.chatDirection ?? 'auto'}
>
{#if atSelectedModel !== undefined}
@@ -138,7 +138,7 @@
function getMessageText(item: any): string {
return (item.content ?? [])
.filter((p: any) => p && (p.type === 'output_text' || 'text' in p))
.filter((p: any) => p.type === 'output_text' || 'text' in p)
.map((p: any) => p.text ?? '')
.join('\n');
}
@@ -146,9 +146,7 @@
function updateMessageText(idx: number, text: string) {
const next = [...output];
const item = { ...next[idx] };
const parts = (item.content ?? []).filter(
(p: any) => p && (p.type === 'output_text' || 'text' in p)
);
const parts = (item.content ?? []).filter((p: any) => p.type === 'output_text' || 'text' in p);
item.content = [{ ...(parts[0] ?? { type: 'output_text' }), text }];
next[idx] = item;
output = next;
@@ -157,7 +155,7 @@
function getReasoningText(item: any): string {
return (item.summary ?? item.content ?? [])
.filter((p: any) => p && 'text' in p)
.filter((p: any) => 'text' in p)
.map((p: any) => p.text ?? '')
.join('');
}
@@ -272,7 +270,7 @@
{#if di.type === 'message'}
<textarea
use:fitContent
class="w-full bg-transparent outline-hidden focus-visible:outline-none! resize-none overflow-hidden text-[0.9375rem] p-1.5 rounded-lg"
class="w-full bg-transparent outline-hidden resize-none overflow-hidden text-[0.9375rem] p-1.5 rounded-lg"
value={getMessageText(di.item)}
on:input={(e) => {
updateMessageText(di.indices[0], e.target.value);
@@ -284,7 +282,7 @@
{:else if di.type === 'reasoning'}
<textarea
use:fitContent
class="w-full bg-transparent outline-hidden focus-visible:outline-none! resize-none overflow-hidden text-[0.9375rem] text-gray-500 dark:text-gray-400 p-1.5 rounded-lg"
class="w-full bg-transparent outline-hidden resize-none overflow-hidden text-[0.9375rem] text-gray-500 dark:text-gray-400 p-1.5 rounded-lg"
value={getReasoningText(di.item)}
on:input={(e) => {
updateReasoningText(di.indices[0], e.target.value);
@@ -144,6 +144,23 @@
}
}
// Generation speed, normalized across backends: Ollama reports
// eval_count/eval_duration (nanoseconds), llama.cpp-compatible servers
// (e.g. our relaxed-MTP llama-server) report timings.predicted_per_second
// directly -- note that field name is llama.cpp's generic term for "the
// main generation loop's output," i.e. the actual response stream, NOT
// the MTP drafter in isolation (which is separately broken out as
// draft_n/draft_n_accepted).
const getTokensPerSecond = (usage: Record<string, unknown> | undefined | null) => {
if (!usage) return null;
if (typeof usage.predicted_per_second === 'number') return usage.predicted_per_second;
if (typeof usage.eval_count === 'number' && typeof usage.eval_duration === 'number' && usage.eval_duration > 0) {
return (usage.eval_count / usage.eval_duration) * 1e9;
}
return null;
};
$: tokensPerSecond = getTokensPerSecond(message.usage);
export let siblings;
export let setInputText: Function = () => {};
@@ -731,12 +748,7 @@
{/if}
{#if edit === true}
<div
class="w-full bg-gray-50 dark:bg-gray-800 rounded-3xl px-3 py-3 my-2 {($settings?.highContrastMode ??
false)
? 'focus-within:outline focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-blue-500'
: ''}"
>
<div class="w-full bg-gray-50 dark:bg-gray-800 rounded-3xl px-3 py-3 my-2">
{#if editedOutput}
<!-- Structured output editor (visual + JSON toggle) -->
<OutputEditView
@@ -750,7 +762,7 @@
<textarea
id="message-edit-{message.id}"
bind:this={editTextAreaElement}
class=" bg-transparent outline-hidden focus-visible:outline-none! w-full resize-none text-[0.9375rem]"
class=" bg-transparent outline-hidden w-full resize-none text-[0.9375rem]"
bind:value={editedContent}
on:input={(e) => {
const messagesContainer = document.getElementById('messages-container');
@@ -1194,6 +1206,14 @@
</Tooltip>
{/if}
{#if tokensPerSecond}
<span
class="text-xs text-gray-400 dark:text-gray-500 px-1 self-center whitespace-nowrap"
>
{tokensPerSecond.toFixed(1)} tok/s
</span>
{/if}
{#if message.usage}
<Tooltip
content={message.usage
@@ -163,9 +163,7 @@
</div>
</ConsecutiveDetailsGroup>
{:else if displayItem.type === 'file'}
{#if displayItem.item?.displayed || $settings?.terminalFileDisplay === 'inline'}
<TerminalOutputFile item={displayItem.item} {chatId} />
{/if}
<TerminalOutputFile item={displayItem.item} {chatId} />
{:else}
{@const detailToken = displayItem.token}
{#if detailToken.attributes?.type === 'tool_calls'}
@@ -10,7 +10,7 @@
showFileNavPath,
terminalServers
} from '$lib/stores';
import { downloadFileBlob, downloadFilePreview, readFile } from '$lib/apis/terminal';
import { downloadFileBlob, readFile } from '$lib/apis/terminal';
import FilePreview from '$lib/components/chat/FileNav/FilePreview.svelte';
import Icon from '$lib/components/chat/FileNav/Icon.svelte';
import { fileIconName } from '$lib/components/chat/FileNav/fileIcon';
@@ -143,59 +143,26 @@
else if (isVideo(path)) fileVideoUrl = url;
else fileAudioUrl = url;
} else if (isPdf(path) || isSqlite(path) || isOffice(path)) {
const result = await blobForPreview();
if (!result) throw new Error(t('Preview failed'));
const arrayBuffer = await result.blob.arrayBuffer();
const ext = getExt(path);
if (isPdf(path)) {
const result = await blobForPreview();
if (!result) throw new Error(t('Preview failed'));
const arrayBuffer = await result.blob.arrayBuffer();
filePdfData = arrayBuffer;
} else if (isSqlite(path)) {
const result = await blobForPreview();
if (!result) throw new Error(t('Preview failed'));
const arrayBuffer = await result.blob.arrayBuffer();
fileSqliteData = arrayBuffer;
} else if (ext === 'docx') {
const preview = await downloadFilePreview(
terminal.url,
terminal.key,
path,
chatId || undefined
);
if (preview) {
filePdfData = await preview.blob.arrayBuffer();
} else {
const result = await blobForPreview();
if (!result) throw new Error(t('Preview failed'));
fileDocxData = await result.blob.arrayBuffer();
}
fileDocxData = arrayBuffer;
} else if (ext === 'xlsx' || ext === 'xls') {
const result = await blobForPreview();
if (!result) throw new Error(t('Preview failed'));
const arrayBuffer = await result.blob.arrayBuffer();
const XLSX = await import('xlsx');
excelWorkbook = XLSX.read(new Uint8Array(arrayBuffer), { type: 'array' });
excelSheetNames = excelWorkbook.SheetNames;
if (excelSheetNames.length > 0) {
await loadExcelSheet(excelSheetNames[0]);
}
if (excelSheetNames.length > 0) await loadExcelSheet(excelSheetNames[0]);
} else if (ext === 'pptx') {
const preview = await downloadFilePreview(
terminal.url,
terminal.key,
path,
chatId || undefined
);
if (preview) {
filePdfData = await preview.blob.arrayBuffer();
} else {
const result = await blobForPreview();
if (!result) throw new Error(t('Preview failed'));
const arrayBuffer = await result.blob.arrayBuffer();
const { pptxToImages } = await import('$lib/utils/pptxToHtml');
const resultImages = await pptxToImages(arrayBuffer);
fileOfficeSlides = resultImages.images;
}
const { pptxToImages } = await import('$lib/utils/pptxToHtml');
const result = await pptxToImages(arrayBuffer);
fileOfficeSlides = result.images;
}
} else if (terminal) {
fileContent = await readFile(terminal.url, terminal.key, path, chatId || undefined);
@@ -202,12 +202,7 @@
{/if}
{#if edit === true}
<div
class=" w-full bg-gray-50 dark:bg-gray-800 rounded-3xl px-4 py-3 mb-2 {($settings?.highContrastMode ??
false)
? 'focus-within:outline focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-blue-500'
: ''}"
>
<div class=" w-full bg-gray-50 dark:bg-gray-800 rounded-3xl px-4 py-3 mb-2">
{#if (editedFiles ?? []).length > 0}
<div class="flex items-center flex-wrap gap-2 -mx-2 mb-1">
{#each editedFiles as file, fileIdx}
@@ -278,7 +273,7 @@
<textarea
id="message-edit-{message.id}"
bind:this={messageEditTextAreaElement}
class=" bg-transparent outline-hidden focus-visible:outline-none! w-full resize-none text-[0.9375rem]"
class=" bg-transparent outline-hidden w-full resize-none text-[0.9375rem]"
bind:value={editedContent}
on:input={(e) => {
const messagesContainer = document.getElementById('messages-container');
@@ -179,6 +179,7 @@ function getInlineFileFromToolOutput(callItem?: OutputItem, resultItem?: OutputI
typeof result !== 'object' ||
result.type !== 'file' ||
result.source !== 'open_terminal' ||
result.displayed !== true ||
result.exists === false ||
!result.path ||
!result.terminal_selector
@@ -373,11 +374,7 @@ export function buildOutputDisplayItems(output: OutputItem[] = []): OutputDispla
};
output.forEach((item, index) => {
if (!item) {
return;
}
if (item.type === 'function_call_output') {
if (item?.type === 'function_call_output') {
const inlineFile = getInlineFileFromToolOutput(toolCallByCallId[item.call_id ?? ''], item);
if (inlineFile) {
flushDetails();
@@ -391,14 +388,14 @@ export function buildOutputDisplayItems(output: OutputItem[] = []): OutputDispla
}
if (
item.type === 'function_call' &&
item?.type === 'function_call' &&
item.name === 'ask_user' &&
(item.status === 'pending' || item.status === 'in_progress')
) {
return;
}
if (item.type && GROUPABLE_OUTPUT_TYPES.has(item.type)) {
if (item?.type && GROUPABLE_OUTPUT_TYPES.has(item.type)) {
const token = buildDetailToken(item, index === output.length - 1, toolOutputByCallId);
if (token) {
currentDetailTokens.push(token);
@@ -406,7 +403,7 @@ export function buildOutputDisplayItems(output: OutputItem[] = []): OutputDispla
return;
}
if (item.type === 'message') {
if (item?.type === 'message') {
const text = getMessageText(item);
if (text.trim()) {
flushDetails();
@@ -465,12 +462,9 @@ function ensureOutputItem(
fallback?: OutputItem
): OutputItem {
while (output.length <= outputIndex) {
// Only the addressed slot gets the event's item; filler slots must not reuse its id.
const item =
output.length === outputIndex && fallback
? { ...fallback }
: { type: 'message', status: 'in_progress', role: 'assistant', content: [] };
output.push(item);
output.push(
fallback ?? { type: 'message', status: 'in_progress', role: 'assistant', content: [] }
);
}
output[outputIndex] = { ...output[outputIndex] };
return output[outputIndex];
@@ -484,17 +478,6 @@ function ensurePart(parts: OutputContentPart[], index: number, fallback?: Output
return parts[index];
}
function setPart(
parts: OutputContentPart[],
index: number,
part: OutputContentPart,
fallback?: OutputContentPart
): void {
// Assigning past the end leaves a hole that later spreads turn into undefined parts.
ensurePart(parts, index, fallback);
parts[index] = part;
}
function findOutputItemIndex(output: OutputItem[], item: OutputItem): number {
return output.findIndex(
(existing) =>
@@ -543,7 +526,7 @@ export function applyResponseStreamEvent(
} else if (outputIndex < nextOutput.length) {
nextOutput.splice(outputIndex, 0, item);
} else {
nextOutput.push(item);
nextOutput[outputIndex] = item;
}
return nextOutput;
}
@@ -554,13 +537,7 @@ export function applyResponseStreamEvent(
}
const item = { ...event.item };
const existingIndex = findOutputItemIndex(nextOutput, item);
if (existingIndex >= 0) {
nextOutput[existingIndex] = item;
} else if (outputIndex < nextOutput.length) {
nextOutput[outputIndex] = item;
} else {
nextOutput.push(item);
}
nextOutput[existingIndex >= 0 ? existingIndex : outputIndex] = item;
return nextOutput;
}
@@ -585,7 +562,7 @@ export function applyResponseStreamEvent(
return nextOutput;
}
item.content = [...(item.content ?? [])];
setPart(item.content, event.content_index ?? item.content.length, { ...event.part });
item.content[event.content_index ?? item.content.length] = { ...event.part };
return nextOutput;
}
@@ -594,8 +571,7 @@ export function applyResponseStreamEvent(
return nextOutput;
}
item.summary = [...(item.summary ?? [])];
const summaryIndex = event.summary_index ?? item.summary.length;
setPart(item.summary, summaryIndex, { ...event.part }, { type: 'summary_text', text: '' });
item.summary[event.summary_index ?? item.summary.length] = { ...event.part };
return nextOutput;
}
@@ -625,8 +601,7 @@ export function applyResponseStreamEvent(
const typeName = eventType.split('.')[1];
if (typeName === 'content_part' && event.part) {
item.content = [...(item.content ?? [])];
const contentIndex = event.content_index ?? Math.max(item.content.length - 1, 0);
setPart(item.content, contentIndex, { ...event.part });
item.content[event.content_index ?? Math.max(item.content.length - 1, 0)] = { ...event.part };
} else if (typeName === 'function_call_arguments' && event.arguments !== undefined) {
item.arguments = event.arguments;
} else if (
@@ -658,7 +633,7 @@ export function replaceOutputMessageText(
}
const partIndex = item.content.findIndex(
(part) => typeof part?.text === 'string' && part.text.includes(oldContent)
(part) => typeof part.text === 'string' && part.text.includes(oldContent)
);
if (partIndex === -1) {
return item;
@@ -1,7 +1,8 @@
<script lang="ts">
import { getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import { getLogoutRedirectUrl, updateUserPassword, userSignOut } from '$lib/apis/auths';
import { updateUserPassword, userSignOut } from '$lib/apis/auths';
import { user } from '$lib/stores';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
const i18n = getContext('i18n');
@@ -26,13 +27,17 @@
// This session is no longer trusted once the password it was issued under changes
toast.success($i18n.t('Password updated. Please sign in again.'));
localStorage.removeItem('token');
user.set(null);
const signOutRes = await userSignOut().catch((error) => {
console.error(error);
return null;
});
localStorage.removeItem('token');
location.href = getLogoutRedirectUrl(signOutRes?.redirect_url);
if (signOutRes?.redirect_url) {
location.href = signOutRes.redirect_url;
}
}
currentPassword = '';
@@ -10,7 +10,7 @@
<slot name="label">{label}</slot>
</div>
<div class="min-w-0">
<div class="shrink-0">
<slot />
</div>
</div>
@@ -268,8 +268,6 @@
'full width mode',
'haptic feedback',
'hapticfeedback',
'accessibility mode',
'accessibilitymode',
'high contrast mode',
'highcontrastmode',
'iframe sandbox allow forms',
@@ -4,7 +4,7 @@
</script>
<div
class={`app-dropdown-menu rounded-xl! p-0.5! border border-gray-100 dark:border-gray-800 z-50 bg-white text-gray-900 dark:bg-gray-850 dark:text-white shadow-lg ${className} [&_button:hover]:bg-gray-100! dark:[&_button:hover]:bg-gray-900! [&_a:hover]:bg-gray-100! dark:[&_a:hover]:bg-gray-900! [&_button:hover]:text-gray-900 dark:[&_button:hover]:text-gray-100 [&_a:hover]:text-gray-900 dark:[&_a:hover]:text-gray-100 [&>button]:flex [&>button]:w-full [&>button]:h-[1.6875rem]! [&>button]:items-center [&>button]:gap-2! [&>button]:rounded-xl! [&>button]:bg-transparent [&>button]:px-2! [&>button]:text-[0.8125rem]! [&>button]:font-normal! [&>a]:flex [&>a]:w-full [&>a]:h-[1.6875rem]! [&>a]:items-center [&>a]:gap-2! [&>a]:rounded-xl! [&>a]:bg-transparent [&>a]:px-2! [&>a]:text-[0.8125rem]! [&>a]:font-normal! [&_button_svg]:size-3.5! [&_a_svg]:size-3.5! [&>hr]:my-0.5 [&>hr]:mx-1`}
class={`rounded-xl! p-0.5! border border-gray-100 dark:border-gray-800 z-50 bg-white text-gray-900 dark:bg-gray-850 dark:text-white shadow-lg ${className} [&_button:hover]:bg-gray-50/40! dark:[&_button:hover]:bg-gray-800/40! [&_a:hover]:bg-gray-50/40! dark:[&_a:hover]:bg-gray-800/40! [&_button:hover]:text-gray-900 dark:[&_button:hover]:text-gray-100 [&_a:hover]:text-gray-900 dark:[&_a:hover]:text-gray-100 [&>button]:flex [&>button]:w-full [&>button]:h-[1.6875rem]! [&>button]:items-center [&>button]:gap-2! [&>button]:rounded-xl! [&>button]:bg-transparent [&>button]:px-2! [&>button]:text-[0.8125rem]! [&>button]:font-normal! [&>a]:flex [&>a]:w-full [&>a]:h-[1.6875rem]! [&>a]:items-center [&>a]:gap-2! [&>a]:rounded-xl! [&>a]:bg-transparent [&>a]:px-2! [&>a]:text-[0.8125rem]! [&>a]:font-normal! [&_button_svg]:size-3.5! [&_a_svg]:size-3.5! [&>hr]:my-0.5 [&>hr]:mx-1`}
{style}
>
<slot />
@@ -4,7 +4,7 @@
import { toast } from 'svelte-sonner';
import { updateUserInfo } from '$lib/apis/users';
import { getUserPosition } from '$lib/utils';
import { normalizeAppFontFamily, setAppFontFamily, setTextScale } from '$lib/utils/text-scale';
import { setTextScale } from '$lib/utils/text-scale';
import Minus from '$lib/components/icons/Minus.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
@@ -115,10 +115,7 @@
let showManageImageCompressionModal = false;
let textScale: number | null = null;
let fontFamily: string | null = null;
let fontFamilyInput = '';
let showTextScaleSlider = false;
let showFontFamilyInput = false;
const settingRowClass = 'flex items-center justify-between gap-2.5';
const settingLabelClass = 'min-w-0 text-xs text-gray-600 dark:text-gray-400';
const settingControlClass = 'flex shrink-0 items-center justify-end gap-1.5';
@@ -237,18 +234,9 @@
};
export const save = async () => {
const normalizedFontFamily = normalizeAppFontFamily(fontFamilyInput) || null;
fontFamily = normalizedFontFamily;
fontFamilyInput = normalizedFontFamily ?? '';
if (!externalSettings) {
setAppFontFamily(fontFamily || defaultFontFamily());
}
saveSettings({
models: [defaultModelId],
imageCompressionSize: imageCompressionSize,
fontFamily
imageCompressionSize: imageCompressionSize
});
};
@@ -267,51 +255,6 @@
saveSettings({ textScale });
};
const defaultFontFamily = () => normalizeAppFontFamily(defaultSettings.fontFamily) || null;
const previewFontFamily = (value: string) => {
fontFamilyInput = value;
fontFamily = normalizeAppFontFamily(value) || null;
if (!externalSettings) {
setAppFontFamily(fontFamily || defaultFontFamily());
}
};
const setFontFamilyHandler = (value: string | null) => {
const normalized = normalizeAppFontFamily(value);
fontFamily = normalized || null;
fontFamilyInput = normalized;
if (!externalSettings) {
setAppFontFamily(fontFamily || defaultFontFamily());
}
saveSettings({ fontFamily });
};
const toggleFontFamilyInput = () => {
if (!showFontFamilyInput && (fontFamily === null || isDefaultSetting('fontFamily'))) {
fontFamily = fontFamily ?? defaultFontFamily();
fontFamilyInput = fontFamily ?? '';
showFontFamilyInput = true;
if (!externalSettings) {
setAppFontFamily(fontFamily);
}
} else {
showFontFamilyInput = false;
fontFamily = null;
fontFamilyInput = '';
if (!externalSettings) {
setAppFontFamily(defaultFontFamily());
}
saveSettings({ fontFamily });
}
};
const init = () => {
titleAutoGenerate = currentSettings?.title?.auto ?? true;
autoTags = currentSettings?.autoTags ?? true;
@@ -395,10 +338,7 @@
webSearch = currentSettings?.webSearch ?? null;
textScale = currentSettings?.textScale ?? null;
fontFamily = normalizeAppFontFamily(currentSettings?.fontFamily) || null;
fontFamilyInput = fontFamily ?? '';
showTextScaleSlider = false;
showFontFamilyInput = false;
defaultUploadContext = currentSettings?.defaultUploadContext ?? 'focused';
};
@@ -550,53 +490,13 @@
<div>
<div class={settingRowClass}>
<label id="font-family-label" class={settingLabelClass} for="font-family-input">
{$i18n.t('Font Family')}
</label>
<div class={settingControlClass}>
<button
class={actionButtonClass}
aria-labelledby="font-family-label font-family-state"
type="button"
on:click={toggleFontFamilyInput}
>
<span id="font-family-state">
{!showFontFamilyInput && (fontFamily === null || isDefaultSetting('fontFamily'))
? $i18n.t('Default')
: $i18n.t('Custom')}
</span>
</button>
</div>
</div>
{#if showFontFamilyInput || (fontFamily !== null && !isDefaultSetting('fontFamily'))}
<div class="mt-1.5 flex items-center pb-1">
<input
id="font-family-input"
class="h-7 w-full rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500"
type="text"
placeholder="Aptos"
bind:value={fontFamilyInput}
on:input={(event) => previewFontFamily(event.currentTarget.value)}
on:change={(event) => setFontFamilyHandler(event.currentTarget.value)}
/>
</div>
{/if}
<p class={settingDescriptionClass}>
{$i18n.t('Use a local font family for the app interface.')}
</p>
</div>
<div>
<div class={settingRowClass}>
<div id="accessibility-mode-label" class={settingLabelClass}>
{$i18n.t('Accessibility Mode')}
<div id="high-contrast-mode-label" class={settingLabelClass}>
{$i18n.t('High Contrast Mode')}
</div>
<div class={settingControlClass}>
<Switch
ariaLabelledbyId="accessibility-mode-label"
ariaLabelledbyId="high-contrast-mode-label"
tooltip={true}
bind:state={highContrastMode}
inherited={isDefaultSetting('highContrastMode')}
@@ -62,20 +62,11 @@
return (
target instanceof Element &&
!!target.closest(
'input, textarea, select, [role="dialog"], [role="menu"], [contenteditable="true"], [data-sidebar-no-gesture]'
'input, textarea, select, [role="menu"], [contenteditable="true"], [data-sidebar-no-gesture]'
)
);
};
const hasTextSelection = () => {
if (typeof window === 'undefined') {
return false;
}
const selection = window.getSelection();
return !!selection && !selection.isCollapsed;
};
const canScrollHorizontally = (target: EventTarget | null, dx: number) => {
if (!(target instanceof Element) || dx === 0 || typeof window === 'undefined') {
return false;
@@ -170,7 +161,7 @@
};
const onTouchStart = (e: TouchEvent) => {
if (!enabled || e.touches.length !== 1 || shouldSkipSwipe(e.target) || hasTextSelection()) {
if (!enabled || e.touches.length !== 1 || shouldSkipSwipe(e.target)) {
return;
}
@@ -214,11 +205,6 @@
const absY = Math.abs(dy);
if (!locked) {
if (hasTextSelection()) {
cancelSwipe();
return;
}
if ((direction === 'open' && dx > 0) || (direction === 'close' && dx < 0)) {
swipeProgress = Math.max(0, Math.min(1, startProgress + dx / panelWidth));
}
+18 -255
View File
@@ -9,9 +9,6 @@
export let data: ArrayBuffer | Uint8Array | null = null;
export let className = 'w-full h-[70vh]';
export let targetPage: number | null = null;
export let singlePage = false;
export let itemLabel = 'Page';
export let onPageChange: ((page: number) => void) | null = null;
type PdfDocument = import('pdfjs-dist').PDFDocumentProxy;
type PdfTextLayer = InstanceType<typeof import('pdfjs-dist').TextLayer>;
@@ -25,28 +22,10 @@
let zoomLevel = 1;
let rerenderTimer: ReturnType<typeof setTimeout> | null = null;
let lastRenderedZoom = 1;
let pageCount = 0;
let renderedPage = 0;
let activePage = 1;
let loadToken = 0;
let renderToken = 0;
let scrollFrame: number | null = null;
let mounted = false;
let loadedSource: ArrayBuffer | Uint8Array | string | null = null;
let wheelDelta = 0;
let lastWheelNavigationAt = 0;
const wheelNavigationThreshold = 80;
const wheelNavigationCooldown = 450;
const pageShortcutKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'];
$: selectedPage = singlePage ? (clampDocumentTargetPage(targetPage, pageCount) ?? 1) : activePage;
// Keep a reference to TextLayer instances so we can update/cancel them
let textLayerInstances: PdfTextLayer[] = [];
const copyPdfData = (pdfData: ArrayBuffer | Uint8Array) =>
pdfData instanceof Uint8Array ? pdfData.slice() : pdfData.slice(0);
const cancelTextLayers = () => {
for (const tl of textLayerInstances) {
try {
@@ -74,11 +53,8 @@
}
return false;
},
beforeMouseDown: (e) => {
beforeMouseDown: () => {
// Only allow drag-to-pan when zoomed in (not at default scale)
if ((e?.target as HTMLElement | null)?.closest?.('.textLayer')) {
return true;
}
const transform = pzInstance?.getTransform();
if (transform && Math.abs(transform.scale - 1) < 0.01) {
return true; // cancel panzoom mouse handling at 1x — allow text selection / normal interaction
@@ -124,73 +100,16 @@
}
};
export const scrollToPage = async (page: number | null) => {
targetPage = page;
if (singlePage) {
if (targetPage) onPageChange?.(targetPage);
} else {
await scrollToTargetPage();
}
};
const selectPage = async (page: number) => {
if (!pdfDoc) return;
const nextPage = clampDocumentTargetPage(page, pdfDoc.numPages);
if (!nextPage || nextPage === selectedPage) return;
targetPage = nextPage;
activePage = nextPage;
onPageChange?.(nextPage);
if (!singlePage) await scrollToTargetPage();
};
const scrollToTargetPage = async () => {
if (!outerContainer || !sceneElement || !pdfDoc) return;
const page = clampDocumentTargetPage(targetPage, pdfDoc.numPages);
if (!page) return;
if (singlePage) return;
await tick();
const pageWrapper = sceneElement.querySelectorAll('.pdf-page-wrapper')[page - 1] as
| HTMLElement
| undefined;
pageWrapper?.scrollIntoView({ block: 'start' });
activePage = page;
onPageChange?.(page);
};
const syncVisiblePage = () => {
scrollFrame = null;
if (singlePage || !outerContainer || !sceneElement || !pdfDoc) return;
const marker = outerContainer.getBoundingClientRect().top + outerContainer.clientHeight * 0.35;
let bestPage = activePage;
let bestDistance = Number.POSITIVE_INFINITY;
for (const wrapper of sceneElement.querySelectorAll('.pdf-page-wrapper')) {
const el = wrapper as HTMLElement;
const page = Number(el.dataset.pageNumber);
if (!page) continue;
const rect = el.getBoundingClientRect();
const distance =
marker < rect.top ? rect.top - marker : marker > rect.bottom ? marker - rect.bottom : 0;
if (distance < bestDistance) {
bestDistance = distance;
bestPage = page;
}
}
if (bestPage !== activePage) {
activePage = bestPage;
onPageChange?.(bestPage);
}
};
const handleScroll = () => {
if (singlePage || scrollFrame !== null) return;
scrollFrame = requestAnimationFrame(syncVisiblePage);
};
// Re-render existing canvases at a new zoom level (preserves panzoom transform)
@@ -198,15 +117,16 @@
if (!pdfDoc || !sceneElement) return;
const pdfjs = await import('pdfjs-dist');
const dpr = window.devicePixelRatio || 1;
const containerWidth = outerContainer?.clientWidth || 800;
const pageWrappers = sceneElement.querySelectorAll('.pdf-page-wrapper');
cancelTextLayers();
for (let i = 0; i < pageWrappers.length; i++) {
const page = await pdfDoc.getPage(singlePage ? selectedPage : i + 1);
const page = await pdfDoc.getPage(i + 1);
const viewport = page.getViewport({ scale: 1 });
const cssScale = getCssScale(viewport);
const cssScale = containerWidth / viewport.width;
const renderScale = cssScale * forZoom * dpr;
const scaledViewport = page.getViewport({ scale: renderScale });
const cssViewport = page.getViewport({ scale: cssScale });
@@ -242,17 +162,8 @@
lastRenderedZoom = forZoom;
};
const getCssScale = (viewport: { width: number; height: number }) => {
if (!singlePage) return (outerContainer?.clientWidth || 800) / viewport.width;
const availableWidth = Math.max(320, (outerContainer?.clientWidth || 800) - 64);
const availableHeight = Math.max(220, (outerContainer?.clientHeight || 600) - 64);
return Math.min(1, availableWidth / viewport.width, availableHeight / viewport.height);
};
const renderAllPages = async () => {
if (!pdfDoc || !sceneElement) return;
const token = ++renderToken;
// Clear previous content
sceneElement.innerHTML = '';
@@ -261,17 +172,14 @@
const pdfjs = await import('pdfjs-dist');
const dpr = window.devicePixelRatio || 1;
const wrappers: HTMLElement[] = [];
const firstPage = singlePage ? selectedPage : 1;
const lastPage = singlePage ? selectedPage : pdfDoc.numPages;
for (let i = firstPage; i <= lastPage; i++) {
for (let i = 1; i <= pdfDoc.numPages; i++) {
const page = await pdfDoc.getPage(i);
if (token !== renderToken) return;
const viewport = page.getViewport({ scale: 1 });
// Scale to fit container width
const cssScale = getCssScale(viewport);
const containerWidth = outerContainer?.clientWidth || 800;
const cssScale = containerWidth / viewport.width;
const renderScale = cssScale * dpr;
const scaledViewport = page.getViewport({ scale: renderScale });
const cssViewport = page.getViewport({ scale: cssScale });
@@ -279,7 +187,6 @@
// Create page wrapper (positioned container for canvas + text layer)
const wrapper = document.createElement('div');
wrapper.className = 'pdf-page-wrapper';
wrapper.dataset.pageNumber = String(i);
wrapper.style.position = 'relative';
wrapper.style.width = `${Math.round(cssScale * viewport.width)}px`;
wrapper.style.height = `${Math.round(cssScale * viewport.height)}px`;
@@ -310,7 +217,6 @@
canvasContext: ctx,
viewport: scaledViewport
}).promise;
if (token !== renderToken) return;
// Create text layer overlay — pdfjs setLayerDimensions handles its sizing
const textLayerDiv = document.createElement('div');
@@ -324,87 +230,21 @@
viewport: cssViewport
});
await textLayer.render();
if (token !== renderToken) return;
textLayerInstances.push(textLayer);
wrappers.push(wrapper);
sceneElement.appendChild(wrapper);
}
sceneElement.replaceChildren(...wrappers);
lastRenderedZoom = 1;
renderedPage = singlePage ? selectedPage : 0;
initPanzoom();
await scrollToTargetPage();
syncVisiblePage();
};
const handleWheel = (e: WheelEvent) => {
if (!singlePage) return;
if (e.ctrlKey || e.metaKey) return;
const transform = pzInstance?.getTransform();
if (transform && Math.abs(transform.scale - 1) >= 0.01) {
e.preventDefault();
pzInstance?.moveBy(-e.deltaX, -e.deltaY, false);
zoomLevel = pzInstance?.getTransform()?.scale ?? 1;
return;
}
e.preventDefault();
if (pageCount <= 1) return;
const multiplier = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? outerContainer.clientHeight : 1;
const dominantDelta = Math.abs(e.deltaY) >= Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
wheelDelta += dominantDelta * multiplier;
const now = Date.now();
if (Math.abs(wheelDelta) < wheelNavigationThreshold) return;
if (now - lastWheelNavigationAt < wheelNavigationCooldown) {
wheelDelta = 0;
return;
}
lastWheelNavigationAt = now;
void selectPage(selectedPage + (wheelDelta > 0 ? 1 : -1));
wheelDelta = 0;
};
const handleKeyDown = (e: KeyboardEvent) => {
if (
!singlePage ||
e.defaultPrevented ||
e.altKey ||
e.ctrlKey ||
e.metaKey ||
pageCount <= 1 ||
!pageShortcutKeys.includes(e.key)
) {
return;
}
e.preventDefault();
void selectPage(selectedPage + (e.key === 'ArrowUp' || e.key === 'ArrowLeft' ? -1 : 1));
};
const focusViewer = () => {
if (!outerContainer?.contains(document.activeElement)) outerContainer?.focus();
};
const loadPdf = async () => {
if (!url && !data) return;
const source = data ?? url;
if (source === loadedSource && pdfDoc) return;
const token = ++loadToken;
loadedSource = source;
loading = true;
error = '';
renderedPage = 0;
pageCount = 0;
pzInstance?.dispose();
cancelTextLayers();
pdfDoc?.destroy();
pdfDoc = null;
try {
const pdfjs = await import('pdfjs-dist');
@@ -412,7 +252,7 @@
let pdfData: ArrayBuffer | Uint8Array;
if (data) {
pdfData = copyPdfData(data);
pdfData = data;
} else {
// Fetch with credentials so auth cookies are sent
const res = await fetch(url!, { credentials: 'include' });
@@ -420,42 +260,24 @@
pdfData = await res.arrayBuffer();
}
pdfDoc = await pdfjs.getDocument({ data: pdfData }).promise;
if (token !== loadToken) return;
pageCount = pdfDoc.numPages;
activePage = clampDocumentTargetPage(targetPage, pageCount) ?? 1;
targetPage = clampDocumentTargetPage(targetPage, pageCount) ?? 1;
await renderAllPages();
} catch (e) {
if (token === loadToken) {
console.error('PDF render error:', e);
error = 'Failed to load PDF.';
}
console.error('PDF render error:', e);
error = 'Failed to load PDF.';
} finally {
if (token === loadToken) loading = false;
loading = false;
}
};
onMount(() => {
mounted = true;
loadPdf();
});
$: if (mounted && (data || url)) {
void loadPdf();
}
$: if (!loading && pdfDoc && singlePage && targetPage && selectedPage !== renderedPage) {
void renderAllPages();
}
$: if (!loading && pdfDoc && !singlePage && targetPage) {
$: if (!loading && pdfDoc && targetPage) {
void scrollToTargetPage();
}
onDestroy(() => {
loadToken++;
renderToken++;
if (scrollFrame !== null) cancelAnimationFrame(scrollFrame);
if (rerenderTimer) clearTimeout(rerenderTimer);
pzInstance?.dispose();
cancelTextLayers();
@@ -477,76 +299,17 @@
</div>
{/if}
<div
class={singlePage
? 'overflow-hidden h-full flex items-center justify-center overscroll-contain'
: 'overflow-y-auto h-full'}
bind:this={outerContainer}
role="application"
aria-label={`${itemLabel} viewer`}
tabindex="0"
on:scroll={handleScroll}
on:wheel|nonpassive={handleWheel}
on:pointerdown={focusViewer}
on:keydown={handleKeyDown}
>
<div bind:this={sceneElement} class={singlePage ? '' : 'w-full'}></div>
<div class="overflow-y-auto h-full" bind:this={outerContainer}>
<div bind:this={sceneElement} class="w-full"></div>
</div>
{#if !error && pdfDoc}
{#if !loading && !error && pdfDoc}
<div
class="absolute bottom-3 left-1/2 -translate-x-1/2 z-10 flex items-center gap-0.5 rounded-lg bg-white/90 dark:bg-gray-850/90 backdrop-blur-sm shadow-lg border border-gray-200/60 dark:border-gray-700/60 px-1 py-0.5"
>
{#if singlePage}
<button
type="button"
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400 disabled:opacity-30"
disabled={selectedPage === 1}
on:click={() => selectPage(selectedPage - 1)}
aria-label={`Previous ${itemLabel.toLowerCase()}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z"
clip-rule="evenodd"
/>
</svg>
</button>
<span
class="shrink-0 min-w-12 text-center text-[0.6875rem] text-gray-500 dark:text-gray-400 tabular-nums"
>{selectedPage} / {pageCount}</span
>
<button
type="button"
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400 disabled:opacity-30"
disabled={selectedPage === pageCount}
on:click={() => selectPage(selectedPage + 1)}
aria-label={`Next ${itemLabel.toLowerCase()}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
</button>
{/if}
<!-- Pinch covers in/out on coarse pointers; reset has no gesture, so it stays -->
<button
type="button"
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400 pointer-coarse:hidden"
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400"
on:click={zoomOut}
aria-label="Zoom out"
>
@@ -573,7 +336,7 @@
</button>
<button
type="button"
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400 pointer-coarse:hidden"
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400"
on:click={zoomIn}
aria-label="Zoom in"
>
@@ -1,238 +0,0 @@
<script lang="ts">
import { onDestroy, onMount, tick } from 'svelte';
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.mjs?url';
import Spinner from './Spinner.svelte';
import PDFViewer from './PDFViewer.svelte';
export let data: ArrayBuffer | Uint8Array | null = null;
export let currentSlide = 0;
export let className = '';
export let targetPage: number | null = null;
export let singlePage = false;
export let itemLabel = 'Page';
export let listLabel = 'Pages';
type PdfDocument = import('pdfjs-dist').PDFDocumentProxy;
let rootEl: HTMLDivElement;
let pdfViewerRef: PDFViewer;
let viewerData: ArrayBuffer | Uint8Array | null = null;
let pageTarget: number | null = null;
let thumbsLoading = false;
let thumbnails: string[] = [];
let thumbnailButtons: Array<HTMLButtonElement | undefined> = [];
let hideThumbs = false;
let resizeObserver: ResizeObserver | null = null;
let pdfDoc: PdfDocument | null = null;
let loadToken = 0;
$: safePage = Math.min(Math.max(0, currentSlide), Math.max(0, thumbnails.length - 1));
const copyPdfData = (pdfData: ArrayBuffer | Uint8Array) =>
pdfData instanceof Uint8Array ? pdfData.slice() : pdfData.slice(0);
const updateLayout = () => {
hideThumbs = (rootEl?.clientWidth ?? window.innerWidth) < 720;
};
const trackThumbnail = (node: HTMLButtonElement, index: number) => {
thumbnailButtons[index] = node;
return {
destroy: () => {
if (thumbnailButtons[index] === node) thumbnailButtons[index] = undefined;
}
};
};
const scrollSelectedThumbnailIntoView = () => {
if (hideThumbs) return;
thumbnailButtons[safePage]?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
};
const selectPage = async (index: number) => {
currentSlide = Math.min(Math.max(0, index), Math.max(0, thumbnails.length - 1));
pageTarget = currentSlide + 1;
await tick();
await pdfViewerRef?.scrollToPage?.(pageTarget);
scrollSelectedThumbnailIntoView();
};
const loadThumbnails = async (pdfData: ArrayBuffer | Uint8Array) => {
const token = ++loadToken;
thumbsLoading = true;
thumbnails = [];
try {
const pdfjs = await import('pdfjs-dist');
pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
pdfDoc?.destroy();
pdfDoc = await pdfjs.getDocument({ data: pdfData }).promise;
const rendered: string[] = [];
for (let i = 1; i <= pdfDoc.numPages; i++) {
if (token !== loadToken) return;
const page = await pdfDoc.getPage(i);
const viewport = page.getViewport({ scale: 0.28 });
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext('2d');
if (!ctx) continue;
await page.render({ canvas, canvasContext: ctx, viewport }).promise;
rendered.push(canvas.toDataURL('image/png'));
}
if (token === loadToken) thumbnails = rendered;
} catch (e) {
console.error('PDF thumbnail render error:', e);
if (token === loadToken) thumbnails = [];
} finally {
if (token === loadToken) thumbsLoading = false;
}
};
$: if (data) {
viewerData = copyPdfData(data);
void loadThumbnails(copyPdfData(data));
} else {
viewerData = null;
thumbnails = [];
}
$: if (targetPage) {
currentSlide = Math.max(0, targetPage - 1);
pageTarget = targetPage;
}
$: if (thumbnails.length > 0) {
void tick().then(scrollSelectedThumbnailIntoView);
}
export const resetView = () => {
pdfViewerRef?.resetView();
};
const handlePageChange = (page: number) => {
currentSlide = page - 1;
if (singlePage) pageTarget = page;
void tick().then(scrollSelectedThumbnailIntoView);
};
onMount(() => {
resizeObserver = new ResizeObserver(updateLayout);
if (rootEl) resizeObserver.observe(rootEl);
updateLayout();
});
onDestroy(() => {
loadToken++;
resizeObserver?.disconnect();
pdfDoc?.destroy();
});
</script>
<div
bind:this={rootEl}
class="relative grid {hideThumbs
? 'grid-cols-[minmax(0,1fr)]'
: 'grid-cols-[144px_minmax(0,1fr)]'} min-h-0 bg-transparent text-gray-900 dark:text-gray-100 {className}"
>
<aside
class={hideThumbs
? 'hidden'
: 'thumbnail-sidebar overflow-y-auto px-2 pt-3 pb-16 border-r border-gray-50 dark:border-gray-850/30 bg-transparent'}
aria-label={listLabel}
>
{#if thumbsLoading && thumbnails.length === 0}
<div class="flex h-full items-center justify-center">
<Spinner className="size-4" />
</div>
{:else}
{#each thumbnails as thumbnail, index}
<button
use:trackThumbnail={index}
type="button"
class="grid grid-cols-[20px_minmax(0,1fr)] items-start gap-2 w-full mb-3 p-0 text-left text-gray-900 dark:text-gray-100"
on:click={() => selectPage(index)}
aria-label="{itemLabel} {index + 1}"
aria-current={safePage === index ? 'true' : undefined}
>
<span
class="pt-1.5 text-[0.6875rem] font-medium text-right {safePage === index
? 'text-gray-400 dark:text-gray-500'
: 'text-gray-300/70 dark:text-gray-700'}">{index + 1}</span
>
<span
class="block overflow-hidden rounded-md bg-transparent {safePage === index
? 'opacity-100'
: 'opacity-55 hover:opacity-80'}"
>
<img
src={thumbnail}
alt="{itemLabel} {index + 1} thumbnail"
class="block w-full h-full object-contain"
draggable="false"
/>
</span>
</button>
{/each}
{/if}
</aside>
<section class="min-w-0 min-h-0 overflow-hidden">
{#if viewerData}
<PDFViewer
bind:this={pdfViewerRef}
data={viewerData}
targetPage={pageTarget}
{singlePage}
{itemLabel}
onPageChange={handlePageChange}
className="w-full h-full"
/>
{:else}
<div class="flex h-full items-center justify-center">
<Spinner className="size-5" />
</div>
{/if}
</section>
</div>
<style>
.thumbnail-sidebar {
scrollbar-color: transparent transparent;
}
.thumbnail-sidebar:hover,
.thumbnail-sidebar:focus,
.thumbnail-sidebar:focus-within,
.thumbnail-sidebar:active {
scrollbar-color: rgba(215, 215, 215, 0.6) transparent;
}
:global(.dark) .thumbnail-sidebar:hover,
:global(.dark) .thumbnail-sidebar:focus,
:global(.dark) .thumbnail-sidebar:focus-within,
:global(.dark) .thumbnail-sidebar:active {
scrollbar-color: rgba(67, 67, 67, 0.6) transparent;
}
.thumbnail-sidebar::-webkit-scrollbar-thumb {
visibility: hidden;
}
.thumbnail-sidebar:hover::-webkit-scrollbar-thumb,
.thumbnail-sidebar:focus::-webkit-scrollbar-thumb,
.thumbnail-sidebar:focus-within::-webkit-scrollbar-thumb,
.thumbnail-sidebar:active::-webkit-scrollbar-thumb {
visibility: visible;
}
.thumbnail-sidebar::-webkit-scrollbar-corner {
display: none;
}
</style>
+26 -36
View File
@@ -7,8 +7,6 @@
export let currentSlide = 0;
export let className = '';
export let targetPage: number | null = null;
export let itemLabel = 'Slide';
export let listLabel = 'Slides';
let rootEl: HTMLDivElement;
let stageEl: HTMLElement;
@@ -121,10 +119,6 @@
}
};
const focusPreview = () => {
if (!rootEl?.contains(document.activeElement)) rootEl?.focus();
};
const zoomIn = () => {
if (!pzInstance || !stageEl) return;
pzInstance.zoomTo(stageEl.clientWidth / 2, stageEl.clientHeight / 2, 1.25);
@@ -231,13 +225,10 @@
});
</script>
<svelte:window on:keydown={handleKeyDown} />
<div
bind:this={rootEl}
role="application"
aria-label={`${itemLabel} preview`}
tabindex="0"
on:keydown={handleKeyDown}
on:pointerdown={focusPreview}
class="relative grid {hideThumbs
? 'grid-cols-[minmax(0,1fr)]'
: 'grid-cols-[144px_minmax(0,1fr)]'} min-h-0 bg-transparent text-gray-900 dark:text-gray-100 {className}"
@@ -245,8 +236,8 @@
<aside
class={hideThumbs
? 'hidden'
: 'thumbnail-sidebar overflow-y-auto px-2 pt-3 pb-16 border-r border-gray-50 dark:border-gray-850/30 bg-transparent'}
aria-label={listLabel}
: 'pptx-slide-rail overflow-y-auto px-2 pt-3 pb-16 border-r border-gray-50 dark:border-gray-850/30 bg-transparent'}
aria-label="Slides"
>
{#each slides as slide, index}
<button
@@ -254,7 +245,7 @@
type="button"
class="grid grid-cols-[20px_minmax(0,1fr)] items-start gap-2 w-full mb-3 p-0 text-left text-gray-900 dark:text-gray-100"
on:click={() => selectSlide(index)}
aria-label="{itemLabel} {index + 1}"
aria-label="Slide {index + 1}"
aria-current={safeSlide === index ? 'true' : undefined}
>
<span
@@ -269,7 +260,7 @@
>
<img
src={slide}
alt="{itemLabel} {index + 1} thumbnail"
alt="Slide {index + 1} thumbnail"
class="block w-full h-full object-contain"
draggable="false"
/>
@@ -292,7 +283,7 @@
<img
bind:this={slideImgEl}
src={selectedSlide}
alt="{itemLabel} {safeSlide + 1}"
alt="Slide {safeSlide + 1}"
class="block w-full h-full object-contain rounded"
draggable="false"
on:load={onSlideLoad}
@@ -312,7 +303,7 @@
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400 disabled:opacity-30"
disabled={safeSlide === 0}
on:click={() => selectSlide(safeSlide - 1)}
aria-label={`Previous ${itemLabel.toLowerCase()}`}
aria-label="Previous slide"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -336,7 +327,7 @@
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400 disabled:opacity-30"
disabled={safeSlide === slides.length - 1}
on:click={() => selectSlide(safeSlide + 1)}
aria-label={`Next ${itemLabel.toLowerCase()}`}
aria-label="Next slide"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -351,10 +342,9 @@
/>
</svg>
</button>
<!-- Pinch covers in/out on coarse pointers; reset has no gesture, so it stays -->
<button
type="button"
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400 pointer-coarse:hidden"
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400"
on:click={zoomOut}
aria-label="Zoom out"
>
@@ -381,7 +371,7 @@
</button>
<button
type="button"
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400 pointer-coarse:hidden"
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400"
on:click={zoomIn}
aria-label="Zoom in"
>
@@ -401,36 +391,36 @@
</div>
<style>
.thumbnail-sidebar {
.pptx-slide-rail {
scrollbar-color: transparent transparent;
}
.thumbnail-sidebar:hover,
.thumbnail-sidebar:focus,
.thumbnail-sidebar:focus-within,
.thumbnail-sidebar:active {
.pptx-slide-rail:hover,
.pptx-slide-rail:focus,
.pptx-slide-rail:focus-within,
.pptx-slide-rail:active {
scrollbar-color: rgba(215, 215, 215, 0.6) transparent;
}
:global(.dark) .thumbnail-sidebar:hover,
:global(.dark) .thumbnail-sidebar:focus,
:global(.dark) .thumbnail-sidebar:focus-within,
:global(.dark) .thumbnail-sidebar:active {
:global(.dark) .pptx-slide-rail:hover,
:global(.dark) .pptx-slide-rail:focus,
:global(.dark) .pptx-slide-rail:focus-within,
:global(.dark) .pptx-slide-rail:active {
scrollbar-color: rgba(67, 67, 67, 0.6) transparent;
}
.thumbnail-sidebar::-webkit-scrollbar-thumb {
.pptx-slide-rail::-webkit-scrollbar-thumb {
visibility: hidden;
}
.thumbnail-sidebar:hover::-webkit-scrollbar-thumb,
.thumbnail-sidebar:focus::-webkit-scrollbar-thumb,
.thumbnail-sidebar:focus-within::-webkit-scrollbar-thumb,
.thumbnail-sidebar:active::-webkit-scrollbar-thumb {
.pptx-slide-rail:hover::-webkit-scrollbar-thumb,
.pptx-slide-rail:focus::-webkit-scrollbar-thumb,
.pptx-slide-rail:focus-within::-webkit-scrollbar-thumb,
.pptx-slide-rail:active::-webkit-scrollbar-thumb {
visibility: visible;
}
.thumbnail-sidebar::-webkit-scrollbar-corner {
.pptx-slide-rail::-webkit-scrollbar-corner {
display: none;
}
</style>
@@ -15,8 +15,6 @@
let panelElement: HTMLDivElement | null = null;
let isResizing = false;
let activePointerId: number | null = null;
let activeResizer: HTMLElement | null = null;
let startClientX = 0;
let startWidth = 0;
@@ -50,31 +48,19 @@
onClose();
};
const resizeStartHandler = (e: PointerEvent) => {
const resizeStartHandler = (e: MouseEvent) => {
if (!open) return;
e.preventDefault();
isResizing = true;
activePointerId = e.pointerId;
activeResizer = e.currentTarget as HTMLElement;
activeResizer.setPointerCapture?.(e.pointerId);
startClientX = e.clientX;
startWidth = width;
document.body.style.userSelect = 'none';
};
const resizeEndHandler = (e?: PointerEvent) => {
const resizeEndHandler = () => {
if (!isResizing) return;
if (e && activePointerId !== null && e.pointerId !== activePointerId) return;
isResizing = false;
if (activePointerId !== null) {
if (activeResizer?.hasPointerCapture?.(activePointerId)) {
activeResizer.releasePointerCapture(activePointerId);
}
}
activePointerId = null;
activeResizer = null;
document.body.style.userSelect = '';
persistWidth();
};
@@ -123,16 +109,14 @@
</script>
<svelte:window
on:pointermove={(e) => {
on:mousemove={(e) => {
if (!isResizing) return;
if (activePointerId !== null && e.pointerId !== activePointerId) return;
resizeHandler(e.clientX);
}}
on:resize={() => {
if (open) width = clamp(width);
}}
on:pointerup={resizeEndHandler}
on:pointercancel={resizeEndHandler}
on:mouseup={resizeEndHandler}
/>
{#if open}
@@ -141,7 +125,7 @@
<div
class="relative flex items-center justify-center group border-l border-gray-50 dark:border-gray-850/30 hover:border-gray-200 dark:hover:border-gray-800 transition z-20 bg-transparent p-0 appearance-none"
id={resizerId}
on:pointerdown={resizeStartHandler}
on:mousedown={resizeStartHandler}
on:keydown={resizeKeyHandler}
role="separator"
tabindex="0"
@@ -150,7 +134,6 @@
>
<span
class="absolute -left-1.5 -right-1.5 -top-0 -bottom-0 z-20 cursor-col-resize bg-transparent"
style="touch-action: none;"
></span>
</div>
{/if}
@@ -164,7 +147,7 @@
<div
class="relative flex items-center justify-center group border-r border-gray-50 dark:border-gray-850/30 hover:border-gray-200 dark:hover:border-gray-800 transition z-20 bg-transparent p-0 appearance-none"
id={resizerId}
on:pointerdown={resizeStartHandler}
on:mousedown={resizeStartHandler}
on:keydown={resizeKeyHandler}
role="separator"
tabindex="0"
@@ -173,7 +156,6 @@
>
<span
class="absolute -left-1.5 -right-1.5 -top-0 -bottom-0 z-20 cursor-col-resize bg-transparent"
style="touch-action: none;"
></span>
</div>
{/if}
+2 -12
View File
@@ -74,21 +74,11 @@
contentEl.style.bottom = 'auto';
}
const contentWidth = contentEl.offsetWidth || 0;
if (align === 'end') {
let right = window.innerWidth - rect.right;
if (right + contentWidth > window.innerWidth) {
right = window.innerWidth - contentWidth - 16;
}
contentEl.style.right = `${Math.max(16, right)}px`;
contentEl.style.right = `${window.innerWidth - rect.right}px`;
contentEl.style.left = 'auto';
} else {
let left = rect.left;
if (left + contentWidth + 16 > window.innerWidth) {
left = window.innerWidth - contentWidth - 16;
}
contentEl.style.left = `${Math.max(16, left)}px`;
contentEl.style.left = `${rect.left}px`;
contentEl.style.right = 'auto';
}
}
+1 -1
View File
@@ -70,7 +70,7 @@
{#if (valves[property] ?? null) !== null}
<!-- {valves[property]} -->
<div class="flex mt-0.5 mb-0.5 space-x-2">
<div class=" flex-1 min-w-0">
<div class=" flex-1">
{#if valvesSpec.properties[property]?.enum ?? null}
<select
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30"
@@ -44,10 +44,6 @@
export let orderBy = 'updated_at';
export let direction = 'desc'; // 'asc' or 'desc'
$: if (!show) {
query = '';
}
export let chatList = null;
export let allChatsLoaded = false;
export let chatListLoading = false;
@@ -2,7 +2,7 @@
import DOMPurify from 'dompurify';
import { marked } from 'marked';
import { getAdminDetails, getLogoutRedirectUrl, userSignOut } from '$lib/apis/auths';
import { getAdminDetails, userSignOut } from '$lib/apis/auths';
import { onMount, tick, getContext } from 'svelte';
import { config } from '$lib/stores';
@@ -75,7 +75,7 @@
return null;
});
localStorage.removeItem('token');
location.href = getLogoutRedirectUrl(res?.redirect_url);
location.href = res?.redirect_url ?? '/auth';
}}>{$i18n.t('Sign Out')}</button
>
</div>
+22 -44
View File
@@ -600,20 +600,13 @@
const MAX_WIDTH = 480;
let isResizing = false;
let activePointerId: number | null = null;
let activeResizer: HTMLElement | null = null;
let startWidth = 0;
let startClientX = 0;
const resizeStartHandler = (e: PointerEvent) => {
const resizeStartHandler = (e: MouseEvent) => {
if ($mobile) return;
e.preventDefault();
isResizing = true;
activePointerId = e.pointerId;
activeResizer = e.currentTarget as HTMLElement;
activeResizer.setPointerCapture?.(e.pointerId);
startClientX = e.clientX;
startWidth = $sidebarWidth ?? 245;
@@ -621,23 +614,15 @@
document.body.style.userSelect = 'none';
};
const resizeEndHandler = (e?: PointerEvent) => {
const resizeEndHandler = () => {
if (!isResizing) return;
if (e && activePointerId !== null && e.pointerId !== activePointerId) return;
isResizing = false;
if (activePointerId !== null && activeResizer?.hasPointerCapture?.(activePointerId)) {
activeResizer.releasePointerCapture(activePointerId);
}
activePointerId = null;
activeResizer = null;
document.body.style.userSelect = '';
localStorage.setItem('sidebarWidth', String($sidebarWidth));
};
const resizeSidebarHandler = (endClientX: number) => {
const resizeSidebarHandler = (endClientX) => {
const dx = endClientX - startClientX;
const newSidebarWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + dx));
@@ -645,12 +630,6 @@
document.documentElement.style.setProperty('--sidebar-width', `${newSidebarWidth}px`);
};
onDestroy(() => {
if (isResizing) {
document.body.style.userSelect = '';
}
});
onMount(async () => {
try {
const width = Number(localStorage.getItem('sidebarWidth'));
@@ -882,13 +861,13 @@
/>
<svelte:window
on:pointermove={(e) => {
on:mousemove={(e) => {
if (!isResizing) return;
if (activePointerId !== null && e.pointerId !== activePointerId) return;
resizeSidebarHandler(e.clientX);
}}
on:pointerup={resizeEndHandler}
on:pointercancel={resizeEndHandler}
on:mouseup={() => {
resizeEndHandler();
}}
/>
<MobileSwipePanel
@@ -961,7 +940,7 @@
aria-label={$showSidebar ? $i18n.t('Close Sidebar') : $i18n.t('Open Sidebar')}
>
<div
class="self-center flex size-[calc(30px*var(--app-text-scale,1))] items-center justify-center rounded-lg transition group-hover:bg-gray-100 dark:group-hover:bg-gray-900"
class="self-center flex size-[calc(30px*var(--app-text-scale,1))] items-center justify-center rounded-lg transition group-hover:bg-gray-50 dark:group-hover:bg-gray-900"
>
<!-- LICENSE covers this Open WebUI sidebar logo.
Do not alter, remove, obscure, or replace it except as LICENSE permits:
@@ -995,7 +974,7 @@
aria-label={$i18n.t('New Chat')}
>
<div
class="self-center flex size-[calc(30px*var(--app-text-scale,1))] items-center justify-center rounded-lg transition group-hover:bg-gray-100 dark:group-hover:bg-gray-900"
class="self-center flex size-[calc(30px*var(--app-text-scale,1))] items-center justify-center rounded-lg transition group-hover:bg-gray-50 dark:group-hover:bg-gray-900"
>
<EditPencilIcon className="size-4" strokeWidth="1.5" />
</div>
@@ -1017,7 +996,7 @@
aria-label={$i18n.t('Search')}
>
<div
class="self-center flex size-[calc(30px*var(--app-text-scale,1))] items-center justify-center rounded-lg transition group-hover:bg-gray-100 dark:group-hover:bg-gray-900"
class="self-center flex size-[calc(30px*var(--app-text-scale,1))] items-center justify-center rounded-lg transition group-hover:bg-gray-50 dark:group-hover:bg-gray-900"
>
<SearchIcon className="size-4" strokeWidth="1.5" />
</div>
@@ -1048,7 +1027,7 @@
? ($settings?.highContrastMode ?? false)
? 'bg-black/[0.035] dark:bg-white/[0.06]'
: 'bg-black/[0.035] dark:bg-white/[0.045]'
: 'group-hover:bg-gray-100 dark:group-hover:bg-gray-900'}"
: 'group-hover:bg-gray-50 dark:group-hover:bg-gray-900'}"
>
{#if itemId === 'notes'}
<NotesIcon className="size-4" strokeWidth="1.5" />
@@ -1081,7 +1060,7 @@
aria-label={$i18n.t('User menu')}
>
<div
class="self-center relative flex size-[calc(30px*var(--app-text-scale,1))] items-center justify-center rounded-lg transition group-hover:bg-gray-100 dark:group-hover:bg-gray-900"
class="self-center relative flex size-[calc(30px*var(--app-text-scale,1))] items-center justify-center rounded-lg transition group-hover:bg-gray-50 dark:group-hover:bg-gray-900"
>
<img
src={`${WEBUI_API_BASE_URL}/users/${$user?.id}/profile/image`}
@@ -1144,7 +1123,7 @@
class="sidebar px-1 pt-1.5 pb-1 flex justify-between space-x-1 text-gray-600 dark:text-gray-400 sticky top-0 z-10 -mb-2"
>
<a
class="flex items-center rounded-xl size-8.5 h-full justify-center hover:bg-gray-100 dark:hover:bg-gray-900 transition no-drag-region"
class="flex items-center rounded-xl size-8.5 h-full justify-center hover:bg-gray-50 dark:hover:bg-gray-900 transition no-drag-region"
href="/"
draggable="false"
on:click={newChatHandler}
@@ -1176,7 +1155,7 @@
placement="bottom"
>
<button
class="flex size-[1.875rem] justify-center items-center rounded-lg hover:bg-gray-100 dark:hover:bg-gray-900 transition {isWindows
class="flex size-[1.875rem] justify-center items-center rounded-lg hover:bg-gray-50 dark:hover:bg-gray-900 transition {isWindows
? 'cursor-pointer'
: 'cursor-[w-resize]'}"
on:click={() => {
@@ -1211,7 +1190,7 @@
<div class="px-1 flex justify-center text-gray-700 dark:text-gray-300">
<a
id="sidebar-new-chat-button"
class="group grow flex items-center space-x-2 rounded-xl px-2 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-900 transition outline-none"
class="group grow flex items-center space-x-2 rounded-xl px-2 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-900 transition outline-none"
href="/"
draggable="false"
on:click={newChatHandler}
@@ -1232,7 +1211,7 @@
<div class="px-1 flex justify-center text-gray-700 dark:text-gray-300">
<button
id="sidebar-search-button"
class="group grow flex items-center space-x-2 rounded-xl px-2 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-900 transition outline-none"
class="group grow flex items-center space-x-2 rounded-xl px-2 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-900 transition outline-none"
on:click={() => {
showSearch.set(true);
}}
@@ -1265,7 +1244,7 @@
? ($settings?.highContrastMode ?? false)
? 'bg-black/[0.035] dark:bg-white/[0.06]'
: 'bg-black/[0.035] dark:bg-white/[0.045]'
: 'hover:bg-gray-100 dark:hover:bg-gray-900'}"
: 'hover:bg-gray-50 dark:hover:bg-gray-900'}"
href={meta.href}
on:click={itemClickHandler}
draggable="false"
@@ -1504,7 +1483,7 @@
<div slot="content">
<DropdownMenu className="min-w-[10.625rem]">
<button
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900"
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
on:click={markAllChatsReadHandler}
>
<CheckIcon className="size-3.5" />
@@ -1713,7 +1692,7 @@
>
<button
type="button"
class=" flex items-center rounded-xl py-1.5 px-1.5 w-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
class=" flex items-center rounded-xl py-1.5 px-1.5 w-full hover:bg-gray-50 dark:hover:bg-gray-900 transition"
aria-label={$i18n.t('User menu')}
>
<div class=" self-center mr-3 relative flex-shrink-0">
@@ -1747,15 +1726,14 @@
{#if !$mobile && visible}
<div
class="relative flex items-center justify-center group border-r border-gray-50 dark:border-gray-850/30 hover:border-gray-200 dark:hover:border-gray-800 transition z-20 bg-transparent p-0 appearance-none"
class="relative flex items-center justify-center group border-l border-gray-50 dark:border-gray-850/30 hover:border-gray-200 dark:hover:border-gray-800 transition z-20"
id="sidebar-resizer"
on:pointerdown={resizeStartHandler}
on:mousedown={resizeStartHandler}
role="separator"
>
<div
class=" absolute -left-1.5 -right-1.5 -top-0 -bottom-0 z-20 cursor-col-resize bg-transparent"
style="touch-action: none;"
></div>
/>
</div>
{/if}
{/if}
@@ -147,7 +147,7 @@
: 'bg-black/[0.035] dark:bg-white/[0.045] selected'
: $mobile
? ''
: ' hover:bg-gray-100 dark:hover:bg-gray-900 group-hover:bg-gray-100 dark:group-hover:bg-gray-900'
: ' hover:bg-gray-50 dark:hover:bg-gray-900 group-hover:bg-gray-50 dark:group-hover:bg-gray-900'
} whitespace-nowrap text-ellipsis transition`;
const selectChatHandler = (event?: MouseEvent) => {
@@ -609,7 +609,7 @@
? ($settings?.highContrastMode ?? false)
? 'bg-black/[0.035] dark:bg-white/[0.055] selected'
: 'bg-black/[0.035] dark:bg-white/[0.045] selected'
: 'hover:bg-gray-100 dark:hover:bg-gray-900 group-hover:bg-gray-100 dark:group-hover:bg-gray-900'} whitespace-nowrap text-ellipsis relative transition {generating
: 'hover:bg-gray-50 dark:hover:bg-gray-900 group-hover:bg-gray-50 dark:group-hover:bg-gray-900'} whitespace-nowrap text-ellipsis relative transition {generating
? 'cursor-not-allowed'
: ''}"
>
@@ -311,7 +311,7 @@
{#if $user?.role === 'admin' || ($user.permissions?.chat?.share ?? true)}
<button
draggable="false"
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 w-full"
on:click={() => {
shareHandler();
}}
@@ -326,7 +326,7 @@
<button
slot="trigger"
draggable="false"
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 w-full"
>
<DownloadIcon className="size-3.5" strokeWidth="1.5" />
<div class="flex items-center">{$i18n.t('Download')}</div>
@@ -334,7 +334,7 @@
<button
draggable="false"
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 w-full"
on:click={() => {
downloadJSONExport();
}}
@@ -344,7 +344,7 @@
<button
draggable="false"
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 w-full"
on:click={() => {
downloadTxt();
}}
@@ -354,7 +354,7 @@
<button
draggable="false"
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 select-none w-full"
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 select-none w-full"
on:click={() => {
downloadPdf();
}}
@@ -366,7 +366,7 @@
<button
draggable="false"
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 w-full"
on:click={() => {
dropdown.close();
renameHandler();
@@ -378,7 +378,7 @@
<button
draggable="false"
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 w-full"
on:click={() => {
dropdown.close();
markUnreadHandler();
@@ -392,7 +392,7 @@
<button
draggable="false"
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 w-full"
on:click={() => {
dropdown.close();
pinHandler();
@@ -410,7 +410,7 @@
{#if $user?.role === 'admin' || ($user?.permissions?.chat?.import ?? true)}
<button
draggable="false"
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 w-full"
on:click={() => {
dropdown.close();
cloneChatHandler();
@@ -426,7 +426,7 @@
<button
slot="trigger"
draggable="false"
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 select-none w-full"
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 select-none w-full"
>
<FolderIcon className="size-3.5" />
<div class="flex items-center">{$i18n.t('Move')}</div>
@@ -435,7 +435,7 @@
{#each $folders.sort((a, b) => b.updated_at - a.updated_at) as folder}
<button
draggable="false"
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 overflow-hidden w-full"
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 overflow-hidden w-full"
on:click={() => {
moveChatHandler(chatId, folder.id);
}}
@@ -452,7 +452,7 @@
<button
draggable="false"
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 w-full"
on:click={() => {
archiveChatHandler();
}}
@@ -464,7 +464,7 @@
{#if $user?.role === 'admin' || ($user?.permissions?.chat?.delete ?? true)}
<button
draggable="false"
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 w-full"
on:click={() => {
deleteHandler();
}}
@@ -48,7 +48,7 @@
<div slot="content">
<DropdownMenu className="min-w-[10.625rem]">
<button
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900"
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
on:click={() => {
onCreateSubFolder();
}}
@@ -60,7 +60,7 @@
<hr class="border-gray-50/30 dark:border-gray-800/30 mx-1 my-0.5" />
<button
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900"
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
on:click={() => {
show = false;
onMarkAllRead();
@@ -73,7 +73,7 @@
<hr class="border-gray-50/30 dark:border-gray-800/30 mx-1 my-0.5" />
<button
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900"
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
on:click={() => {
onEdit();
}}
@@ -83,7 +83,7 @@
</button>
<button
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900"
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
on:click={() => {
onExport();
}}
@@ -93,7 +93,7 @@
</button>
<button
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900"
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
on:click={() => {
onShare();
}}
@@ -105,7 +105,7 @@
<hr class="border-gray-50/30 dark:border-gray-800/30 mx-1 my-0.5" />
<button
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900"
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
on:click={() => {
onDelete();
}}
@@ -119,7 +119,6 @@
{/if}
</div>
<button
aria-label={$i18n.t('Close')}
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
on:click={() => {
show = false;
@@ -428,8 +428,6 @@
});
onDestroy(() => {
delete folderRegistry[folderId];
if (folderElement) {
folderElement.removeEventListener('dragover', onDragOver);
folderElement.removeEventListener('drop', onDrop);
@@ -744,7 +742,7 @@
<div class="w-full group">
<div
id="folder-{folderId}-button"
class="relative w-full py-1 px-1.5 rounded-xl flex items-center gap-1.5 hover:bg-gray-100 dark:hover:bg-gray-900 transition {$selectedFolder?.id ===
class="relative w-full py-1 px-1.5 rounded-xl flex items-center gap-1.5 hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition {$selectedFolder?.id ===
folderId
? 'bg-gray-100/80 dark:bg-gray-850/50 selected'
: ''}"
@@ -780,7 +778,7 @@
}}
>
<button
class="text-gray-600 dark:text-gray-400 transition-all p-1 hover:bg-gray-100 dark:hover:bg-gray-900 rounded-lg"
class="text-gray-600 dark:text-gray-400 transition-all p-1 hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-lg"
on:click={(e) => {
e.stopPropagation();
e.stopImmediatePropagation();
@@ -5,7 +5,7 @@
import { fade, slide } from 'svelte/transition';
import { getUsage } from '$lib/apis';
import { getLogoutRedirectUrl, getSessionUser, userSignOut } from '$lib/apis/auths';
import { getSessionUser, userSignOut } from '$lib/apis/auths';
import { showSettings, mobile, showSidebar, user, config, settings } from '$lib/stores';
@@ -118,7 +118,7 @@
{#if $user}
<div>
<button
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-xs w-full hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none text-left"
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-xs w-full hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none text-left"
type="button"
on:click={async () => {
show = false;
@@ -166,7 +166,7 @@
{#if $user?.status_emoji || $user?.status_message}
<div class="user-menu-status">
<button
class="w-full h-[1.6875rem] gap-2 rounded-xl px-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none text-xs flex items-center text-left"
class="w-full h-[1.6875rem] gap-2 rounded-xl px-2 hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none text-xs flex items-center text-left"
type="button"
on:click={() => {
show = false;
@@ -218,7 +218,7 @@
{:else}
<div class="user-menu-status">
<button
class="w-full h-[1.6875rem] gap-2 rounded-xl px-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none text-xs flex items-center text-left"
class="w-full h-[1.6875rem] gap-2 rounded-xl px-2 hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none text-xs flex items-center text-left"
type="button"
on:click={() => {
show = false;
@@ -243,7 +243,7 @@
<a
href="/workspace"
draggable="false"
class="flex flex-1 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
class="flex flex-1 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
on:click={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
@@ -287,7 +287,7 @@
<a
href="/notes"
draggable="false"
class="flex flex-1 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
class="flex flex-1 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
on:click={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
@@ -331,7 +331,7 @@
<a
href="/calendar"
draggable="false"
class="flex flex-1 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
class="flex flex-1 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
on:click={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
@@ -375,7 +375,7 @@
<a
href="/automations"
draggable="false"
class="flex flex-1 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
class="flex flex-1 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
on:click={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
@@ -419,7 +419,7 @@
<a
href="/playground"
draggable="false"
class="flex flex-1 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
class="flex flex-1 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
on:click={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
@@ -468,7 +468,7 @@
href="https://docs.openwebui.com"
target="_blank"
draggable="false"
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
id="chat-share-button"
on:click={() => {
show = false;
@@ -485,7 +485,7 @@
href="https://github.com/open-webui/open-webui/releases"
target="_blank"
draggable="false"
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
id="chat-share-button"
on:click={() => {
show = false;
@@ -499,7 +499,7 @@
{/if}
<button
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
type="button"
id="chat-share-button"
on:click={async () => {
@@ -525,7 +525,7 @@
<a
href="/admin"
draggable="false"
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
on:click={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
return;
@@ -547,7 +547,7 @@
{/if}
<button
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
type="button"
on:click={async () => {
show = false;
@@ -567,13 +567,14 @@
</button>
<button
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
type="button"
on:click={async () => {
const res = await userSignOut();
user.set(null);
localStorage.removeItem('token');
location.href = getLogoutRedirectUrl(res?.redirect_url);
location.href = res?.redirect_url ?? '/auth';
show = false;
}}
>
@@ -272,11 +272,6 @@
viewOption = localStorage?.workspaceViewOption || '';
sourceOption = localStorage?.workspaceKnowledgeSourceOption || '';
loaded = true;
await tick();
if (items === null && !itemsLoading) {
await init();
}
if (showCreateOnMount) {
showCreateModal = true;
@@ -619,51 +619,6 @@
return currentPath && path ? `${currentPath}/${path}` : currentPath || path;
};
const uploadManifestEntries = async (
entries: DirectoryManifestEntry[],
resolveDirectoryId: (entry: DirectoryManifestEntry) => string | null | undefined
) => {
let failedCount = 0;
for (const [index, entry] of entries.entries()) {
const displayPath = entry.path ? `${entry.path}/${entry.filename}` : entry.filename;
syncing = $i18n.t('Uploading {{current}}/{{total}}: {{file}}', {
current: index + 1,
total: entries.length,
file: displayPath
});
const fileObject = new File([entry.file], entry.filename, { type: entry.file.type });
const uploadedFile = await uploadFile(localStorage.token, fileObject, {
knowledge_id: knowledge.id,
file_hash: entry.checksum,
directory_id: resolveDirectoryId(entry)
}).catch((error) => ({ error }));
if (!uploadedFile || uploadedFile.error) {
const error = uploadedFile?.error;
const reason =
typeof error === 'string'
? error
: (error?.detail ?? error?.message ?? $i18n.t('Failed to upload file.'));
failedCount++;
console.error('Upload failed:', displayPath, reason);
}
}
if (failedCount > 0) {
toast.error(
$i18n.t('Upload failed for {{failed}} of {{total}} files.', {
failed: failedCount,
total: entries.length
})
);
}
return failedCount;
};
const uploadDirectoryEntries = async (entries: DirectoryFileEntry[]) => {
if (!knowledge) return;
@@ -690,14 +645,30 @@
const directoryIdByPath = await createMissingDirectories(diff);
const failedCount = await uploadManifestEntries(manifest, (entry) =>
entry.path ? directoryIdByPath[getDirectoryUploadPath(entry.path)] : currentDirectoryId
);
let uploadedCount = 0;
for (const entry of manifest) {
uploadedCount++;
const displayPath = entry.path ? `${entry.path}/${entry.filename}` : entry.filename;
syncing = $i18n.t('Uploading {{current}}/{{total}}: {{file}}', {
current: uploadedCount,
total: manifest.length,
file: displayPath
});
if (failedCount === 0) {
toast.success($i18n.t('File uploaded successfully'));
const fileObject = new File([entry.file], entry.filename, { type: entry.file.type });
await uploadFile(localStorage.token, fileObject, {
knowledge_id: knowledge.id,
file_hash: entry.checksum,
directory_id: entry.path
? directoryIdByPath[getDirectoryUploadPath(entry.path)]
: currentDirectoryId
}).catch((e) => {
toast.error(`${e}`);
return null;
});
}
toast.success($i18n.t('File uploaded successfully'));
init();
} catch (e) {
toast.error(`${e}`);
@@ -752,24 +723,36 @@
diff.modified.some((m: any) => m.filename === entry.filename && m.path === entry.path)
);
const failedCount = await uploadManifestEntries(filesToUpload, (entry) =>
entry.path ? directoryIdByPath[entry.path] : null
);
let uploadedCount = 0;
for (const entry of filesToUpload) {
uploadedCount++;
const displayPath = entry.path ? `${entry.path}/${entry.filename}` : entry.filename;
syncing = $i18n.t('Uploading {{current}}/{{total}}: {{file}}', {
current: uploadedCount,
total: filesToUpload.length,
file: displayPath
});
const fileObject = new File([entry.file], entry.filename, { type: entry.file.type });
await uploadFile(localStorage.token, fileObject, {
knowledge_id: knowledge.id,
file_hash: entry.checksum,
directory_id: entry.path ? directoryIdByPath[entry.path] : null
}).catch(() => null);
}
// ── 7. Report ──
if (failedCount === 0) {
toast.success(
$i18n.t(
'Sync complete: {{added}} added, {{modified}} modified, {{deleted}} deleted, {{unmodified}} unmodified',
{
added: diff.added.length,
modified: diff.modified.length,
deleted: diff.deleted.length,
unmodified: diff.unmodified_count
}
)
);
}
toast.success(
$i18n.t(
'Sync complete: {{added}} added, {{modified}} modified, {{deleted}} deleted, {{unmodified}} unmodified',
{
added: diff.added.length,
modified: diff.modified.length,
deleted: diff.deleted.length,
unmodified: diff.unmodified_count
}
)
);
init();
} catch (e) {
toast.error(`${e}`);
+2 -2
View File
@@ -735,14 +735,14 @@
<Tooltip content={model.name} className="min-w-0" placement="top-start">
<a
href={`/?model=${encodeURIComponent(model.id)}`}
class="block truncate text-[0.8125rem] leading-5 text-gray-800 group-hover:underline dark:text-gray-200"
class="truncate text-[0.8125rem] leading-5 text-gray-800 group-hover:underline dark:text-gray-200"
>
{model.name}
</a>
</Tooltip>
<div
class="hidden min-w-0 max-w-[40%] shrink-0 truncate text-[0.6875rem] leading-5 text-gray-500 sm:block"
class="min-w-0 max-w-[40%] shrink-0 truncate text-[0.6875rem] leading-5 text-gray-500"
>
{model.id}
</div>
+1 -3
View File
@@ -96,7 +96,6 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "",
"Account": "الحساب",
"Account Activation Pending": "",
@@ -1338,7 +1337,6 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "",
"Followed instructions perfectly": "اتبعت التعليمات على أكمل وجه",
"Font Family": "",
"for placeholders": "",
"Force OCR": "",
"Forking chat...": "",
@@ -1443,6 +1441,7 @@
"Hide Hidden Files": "",
"Hide Model": "",
"High": "",
"High Contrast Mode": "",
"History": "",
"Home": "",
"Host": "",
@@ -2959,7 +2958,6 @@
"Use": "",
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "",
"Use a Gemini API key for image generation.": "",
"Use a local font family for the app interface.": "",
"Use a valid event name or pattern like user.*": "",
"Use a wider chat layout on large displays.": "",
"Use an API key when your ComfyUI server requires one.": "",
+1 -3
View File
@@ -96,7 +96,6 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "متاح لجميع المستخدمين",
"Account": "الحساب",
"Account Activation Pending": "انتظار تفعيل الحساب",
@@ -1338,7 +1337,6 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "",
"Followed instructions perfectly": "اتبعت التعليمات على أكمل وجه",
"Font Family": "",
"for placeholders": "",
"Force OCR": "",
"Forking chat...": "",
@@ -1443,6 +1441,7 @@
"Hide Hidden Files": "",
"Hide Model": "",
"High": "",
"High Contrast Mode": "",
"History": "",
"Home": "الصفحة الرئيسية",
"Host": "المضيف",
@@ -2959,7 +2958,6 @@
"Use": "",
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "",
"Use a Gemini API key for image generation.": "",
"Use a local font family for the app interface.": "",
"Use a valid event name or pattern like user.*": "",
"Use a wider chat layout on large displays.": "",
"Use an API key when your ComfyUI server requires one.": "",
+1 -3
View File
@@ -68,7 +68,6 @@
"Access List": "Giriş siyahısı",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "Bütün istifadəçilər üçün əlçatandır",
"Account": "Hesab",
"Account Activation Pending": "Hesabın aktivləşdirilməsi gözlənilir",
@@ -1298,7 +1297,6 @@
"Follow up: {{question}}": "Davam sualı: {{question}}",
"Follow-Up Auto-Generation": "Davam suallarının avtomatik yaradılması",
"Followed instructions perfectly": "Təlimatlara mükəmməl əməl edildi",
"Font Family": "",
"for placeholders": "yer tutucular üçün",
"Force OCR": "Məcburi OCR",
"Forking chat...": "",
@@ -1403,6 +1401,7 @@
"Hide Hidden Files": "",
"Hide Model": "Modeli gizlət",
"High": "Yüksək",
"High Contrast Mode": "Yüksək kontrast rejimi",
"History": "Tarixçə",
"Home": "Ana səhifə",
"Host": "Host",
@@ -2903,7 +2902,6 @@
"Use": "İstifadə et",
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "Potensial olaraq daha yaxşı dəqiqlik üçün /v1/audio/transcriptions əvəzinə /v1/chat/completions son nöqtəsindən istifadə edin.",
"Use a Gemini API key for image generation.": "",
"Use a local font family for the app interface.": "",
"Use a valid event name or pattern like user.*": "",
"Use a wider chat layout on large displays.": "",
"Use an API key when your ComfyUI server requires one.": "",
+1 -3
View File
@@ -68,7 +68,6 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "Достъпно за всички потребители",
"Account": "Акаунт",
"Account Activation Pending": "Активирането на акаунта е в процес на изчакване",
@@ -1298,7 +1297,6 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "",
"Followed instructions perfectly": "Следвайте инструкциите перфектно",
"Font Family": "",
"for placeholders": "",
"Force OCR": "",
"Forking chat...": "",
@@ -1403,6 +1401,7 @@
"Hide Hidden Files": "",
"Hide Model": "",
"High": "",
"High Contrast Mode": "",
"History": "",
"Home": "Начало",
"Host": "Хост",
@@ -2903,7 +2902,6 @@
"Use": "",
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "",
"Use a Gemini API key for image generation.": "",
"Use a local font family for the app interface.": "",
"Use a valid event name or pattern like user.*": "",
"Use a wider chat layout on large displays.": "",
"Use an API key when your ComfyUI server requires one.": "",
+1 -3
View File
@@ -68,7 +68,6 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "",
"Account": "একাউন্ট",
"Account Activation Pending": "",
@@ -1298,7 +1297,6 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "",
"Followed instructions perfectly": "নির্দেশাবলী নিখুঁতভাবে অনুসরণ করা হয়েছে",
"Font Family": "",
"for placeholders": "",
"Force OCR": "",
"Forking chat...": "",
@@ -1403,6 +1401,7 @@
"Hide Hidden Files": "",
"Hide Model": "",
"High": "",
"High Contrast Mode": "",
"History": "",
"Home": "",
"Host": "",
@@ -2903,7 +2902,6 @@
"Use": "",
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "",
"Use a Gemini API key for image generation.": "",
"Use a local font family for the app interface.": "",
"Use a valid event name or pattern like user.*": "",
"Use a wider chat layout on large displays.": "",
"Use an API key when your ComfyUI server requires one.": "",
+1 -3
View File
@@ -61,7 +61,6 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "བེད་སྤྱོད་མཁན་ཡོངས་ལ་འཛུལ་སྤྱོད་ཆོག་པ།",
"Account": "རྩིས་ཁྲ།",
"Account Activation Pending": "རྩིས་ཁྲ་སྒུལ་བསྐྱོད་སྒུག་བཞིན་པ།",
@@ -1288,7 +1287,6 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "",
"Followed instructions perfectly": "ལམ་སྟོན་ཡང་དག་པར་བསྒྲུབས།",
"Font Family": "",
"for placeholders": "",
"Force OCR": "",
"Forking chat...": "",
@@ -1393,6 +1391,7 @@
"Hide Hidden Files": "",
"Hide Model": "དཔེ་དབྱིབས་སྦ་བ།",
"High": "",
"High Contrast Mode": "",
"History": "",
"Home": "གཙོ་ངོས།",
"Host": "Host",
@@ -2889,7 +2888,6 @@
"Use": "",
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "",
"Use a Gemini API key for image generation.": "",
"Use a local font family for the app interface.": "",
"Use a valid event name or pattern like user.*": "",
"Use a wider chat layout on large displays.": "",
"Use an API key when your ComfyUI server requires one.": "",
+1 -3
View File
@@ -75,7 +75,6 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "",
"Account": "Račun",
"Account Activation Pending": "",
@@ -1308,7 +1307,6 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "",
"Followed instructions perfectly": "Savršeno slijedio upute",
"Font Family": "",
"for placeholders": "",
"Force OCR": "",
"Forking chat...": "",
@@ -1413,6 +1411,7 @@
"Hide Hidden Files": "",
"Hide Model": "",
"High": "",
"High Contrast Mode": "",
"History": "",
"Home": "",
"Host": "",
@@ -2917,7 +2916,6 @@
"Use": "",
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "",
"Use a Gemini API key for image generation.": "",
"Use a local font family for the app interface.": "",
"Use a valid event name or pattern like user.*": "",
"Use a wider chat layout on large displays.": "",
"Use an API key when your ComfyUI server requires one.": "",
+124 -126
View File
@@ -75,7 +75,6 @@
"Access List": "Llista d'accés",
"Access prohibited": "Accés prohibit",
"Access updated": "Accés actualitzat",
"Accessibility Mode": "",
"Accessible to all users": "Accessible a tots els usuaris",
"Account": "Compte",
"Account Activation Pending": "Activació del compte pendent",
@@ -148,7 +147,7 @@
"Advanced MinerU parsing parameters in JSON format.": "Paràmetres avançats d'anàlisi de MinerU en format JSON.",
"Advanced Parameters": "Paràmetres avançats",
"Advanced Params": "Paràmetres avançats",
"After changing the embedding model, reindex knowledge, knowledge search, and memory vectors for changes to take effect.": "Després de canviar el model d'incrustació, torna a indexar el coneixement, la cerca de coneixement i els vectors de memòria perquè els canvis tinguin efecte.",
"After changing the embedding model, reindex knowledge, knowledge search, and memory vectors for changes to take effect.": "",
"All": "Tots",
"All Chats": "Tots els xats",
"All chats have been unarchived.": "Tots els xats han estat desarxivats.",
@@ -162,8 +161,8 @@
"All time": "Sempre",
"All Users": "Tots els usuaris",
"All users and system events": "Tots els usuaris i esdeveniments del sistema",
"Allow": "Permetre",
"Allow {{NAME}}?": "Vols permetre {{NAME}}?",
"Allow": "",
"Allow {{NAME}}?": "",
"Allow artifacts to access same-origin browser APIs inside the sandbox.": "Permetre que els artefactes accedeixin a les API del navegador del mateix origen dins del sandbox.",
"Allow automatic names for new chats.": "Permetre noms automàtics per a xats nous.",
"Allow browser notifications for completed responses.": "Permetre les notificacions del navegador per a les respostes completades.",
@@ -180,7 +179,7 @@
"Allow Continue Response": "Permetre continuar la resposta",
"Allow delegated sub-agents to keep running while the parent chat continues.": "Permetre que els subagents delegats continuïn funcionant mentre continua el xat principal.",
"Allow Delete Messages": "Permetre eliminar missatges",
"Allow downloads inside sandboxed iframes.": "Permet descàrregues dins d'iframes sandbox.",
"Allow downloads inside sandboxed iframes.": "",
"Allow File Upload": "Permetre la pujada d'arxius",
"Allow forms inside sandboxed artifact iframes.": "Permetre formularis dins d'iframes d'artefactes en sandbox.",
"Allow Google Drive as a document source.": "Permetre Google Drive com a font de documents.",
@@ -193,15 +192,15 @@
"Allow public write access": "Permetre accés públic d'escriptura",
"Allow Rate Response": "Permetre valorar les respostes",
"Allow Regenerate Response": "Permetre regenerar respostes",
"Allow scripts inside sandboxed iframes.": "Permetre scripts dins d'iframes en sandbox.",
"Allow scripts inside sandboxed iframes.": "",
"Allow Sharing With Groups": "Permetre compartir amb grups",
"Allow Sharing With Users": "Permetre compartir amb usuaris",
"Allow Speech to Text": "Permetre Parla a Text",
"Allow Temporary Chat": "Permetre el xat temporal",
"Allow terminal previews to access same-origin browser APIs.": "Permet que les previsualitzacions del terminal accedeixin a les API del navegador del mateix origen.",
"Allow terminal previews to access same-origin browser APIs.": "",
"Allow Text to Speech": "Permetre Text a Parla",
"Allow the AI to delegate tasks to sub-agents. Each sub-agent creates a real chat with full tool access. Uses additional LLM calls.": "Permetre que l'IA delegui tasques als subagents. Cada subagent crea un xat real amb accés complet a l'eina. Utilitza trucades addicionals de LLM.",
"Allow Tool Call": "Permetre la crida a l'eina",
"Allow Tool Call": "",
"Allow User Location": "Permetre la ubicació de l'usuari",
"Allow users to access calendar features.": "Permetre als usuaris accedir a les funcions del calendari.",
"Allow users to authenticate with an LDAP directory.": "Permetre als usuaris autenticar-se amb un directori LDAP.",
@@ -245,7 +244,7 @@
"API Key": "clau API",
"API Key / Token": "Clau de l'API/Token",
"API Key created.": "clau API creada.",
"API Key deleted.": "S'ha suprimit la clau de l'API.",
"API Key deleted.": "",
"API Key Endpoint Restrictions": "Restriccions del punt d'accés de la Clau API",
"API key for embedding requests.": "Clau API per incrustar sol·licituds.",
"API key sent to Docling.": "Clau de l'API enviada a Docling.",
@@ -299,7 +298,7 @@
"Ask a question": "Fer una pregunta",
"Ask a test question": "Fes una pregunta de prova",
"Ask this knowledge source a test question": "Fes una pregunta de prova a aquesta font de coneixement",
"Ask User": "Pregunta a l'usuari",
"Ask User": "",
"Assistant": "Assistent",
"Assistant messages": "Missatges de l'assistent",
"Async Embedding Processing": "Procés d'incrustat asíncron",
@@ -321,7 +320,7 @@
"Auth required": "Es requereix l'autenticació",
"Authenticate": "Autenticar",
"Authentication": "Autenticació",
"Authorize OAuth": "Autoritzar OAuth",
"Authorize OAuth": "",
"Auto": "Automàtic",
"Auto (Random)": "Automàtic (aleatori)",
"Auto Redirect": "Redirecció automàtica",
@@ -425,7 +424,7 @@
"Change User Role": "Canviar el rol d'usuari",
"Channel": "Canal",
"Channel deleted successfully": "Canal suprimit correctament",
"Channel is required": "El canal és obligatori",
"Channel is required": "",
"Channel Name": "Nom del canal",
"Channel name cannot be empty.": "El nom del canal no pot estar buit.",
"Channel name must be less than 128 characters": "El nom del canal ha de tenir menys de 128 caràcters",
@@ -435,7 +434,7 @@
"Character": "Personatge",
"Chat": "Xat",
"Chat actions": "Accions de xat",
"Chat archived.": "Xat arxivat",
"Chat archived.": "Xar arxivat",
"Chat Background Image": "Imatge de fons del xat",
"Chat Bubble UI": "Chat Bubble UI",
"Chat Completions": "Completacions de xat",
@@ -446,14 +445,14 @@
"Chat forked": "Xat bifurcat",
"Chat history": "Historial de xats",
"Chat History": "Historial de xats",
"Chat Hover Previews": "Previsualitzacions del xat",
"Chat Hover Previews": "",
"Chat ID": "ID del xat",
"Chat is too short to compact": "El xat és massa curt per compactar",
"Chat moved successfully": "El xat s'ha mogut correctament",
"Chat Permissions": "Permisos del xat",
"Chat Tags Auto-Generation": "Generació automàtica d'etiquetes del xat",
"Chat unshared successfully.": "El xat s'ha deixat de compartir correctament",
"Chat Uploads": "Càrregues de xat",
"Chat Uploads": "",
"Chat Variables": "Variables de xat",
"Chat Variables have conflicting model definitions": "Les variables de xat tenen definicions de model contradictòries",
"chats": "xats",
@@ -462,12 +461,12 @@
"Chats Public Sharing": "Compartició pública dels xats",
"Check Again": "Comprovar-ho de nou",
"Check for updates": "Comprovar si hi ha actualitzacions",
"Check OAuth Discovery": "Comprovar OAuth Discovery",
"Check OAuth Discovery": "",
"Checking for updates...": "Comprovant actualitzacions...",
"Choose a dedicated model for context compaction summaries. Current Model follows the active chat model.": "Escull un model dedicat per als resums de compactació de context. El model actual segueix el model de xat actiu.",
"Choose a model before saving...": "Triar un model abans de desar...",
"Choose automatic, left-to-right, or right-to-left text flow.": "Escull el flux de text automàtic, d'esquerra a dreta o de dreta a esquerra.",
"Choose channel": "Tria el canal",
"Choose channel": "",
"Choose fallback models for background tasks. Current Model follows the active chat model.": "Escull models alternatius per a tasques en segon pla. El model actual segueix el model de xat actiu.",
"Choose folder": "Escull la carpeta",
"Choose how extracted text is split before indexing.": "Escull com es divideix el text extret abans de la indexació.",
@@ -490,7 +489,7 @@
"Choose the speech provider used for assistant audio output.": "Escull el proveïdor de veu utilitzat per a la sortida d'àudio de l'assistent.",
"Choose the transcription provider used for audio input.": "Escull el proveïdor de transcripció utilitzat per a l'entrada d'àudio.",
"Choose where model responses to root-level channel mentions are posted.": "Escull on es publiquen les respostes del model a les mencions del canal arrel.",
"Choose where terminal display_file results appear by default.": "Tria on apareixen els resultats del fitxer_visualització del terminal per defecte.",
"Choose where terminal display_file results appear by default.": "",
"Choose whether Enter sends immediately or uses Ctrl+Enter.": "Escull si Enter envia immediatament o utilitza Ctrl+Enter.",
"Choose whether the app opens to the default home or chat view.": "Escull si l'aplicació s'obre a la vista d'inici o de xat predeterminada.",
"Chunk Min Size Target": "Objectiu de mida mínima del fragment",
@@ -511,7 +510,7 @@
"Clear Memory": "Esborrar la memòria",
"Clear search": "Esborrar la cerca",
"Clear status": "Esborrar l'estat",
"Clear vector storage and knowledge indexing data.": "Esborrar l'emmagatzematge de vectors i dades d'indexació de coneixement.",
"Clear vector storage and knowledge indexing data.": "Esboarrar l'emmagatzematge de vectors i dades d'indexació de coneixement.",
"click here": "prem aquí",
"Click here for filter guides.": "Clica aquí per l'ajuda dels filtres.",
"Click here for help.": "Clica aquí per obtenir ajuda.",
@@ -536,14 +535,14 @@
"Close": "Tancar",
"Close Banner": "Tancar el bàner",
"Close chat controls": "Tancar els controls de xat",
"Close citation modal": "Tancar la finestra modal de citació",
"Close citation modal": "Tancar el model de citatció",
"Close Configure Connection Modal": "Tancar la finestra de configuració de la connexió",
"Close feedback": "Tancar el retorn",
"Close modal": "Tancar el modal",
"Close Modal": "Tancar el modal",
"Close Sidebar": "Tancar la barra lateral",
"cloud": "núvol",
"CMU ARCTIC speaker embedding name": "Nom del parlant d'incrustació CMU ARCTIC",
"CMU ARCTIC speaker embedding name": "Nom de l'altaveu d'incrustació CMU ARCTIC",
"Code Block": "Bloc de codi",
"Code Editor": "Editor de codi",
"Code execution": "Execució de codi",
@@ -559,7 +558,7 @@
"Collapse code blocks by default.": "Col·lapsar els blocs de codi per defecte.",
"Collection": "Col·lecció",
"Collection Field": "Camp de col·lecció",
"Collections": "Col·leccions",
"Collections": "Col·lecccions",
"Color": "Color",
"Combine semantic and keyword retrieval.": "Combinar la recuperació semàntica i de paraules clau.",
"ComfyUI": "ComfyUI",
@@ -628,7 +627,7 @@
"Content Extraction Engine": "Motor d'extracció de contingut",
"Content Field": "Camp de contingut",
"Content lengths (character counts only)": "Mida del contingut (només els caràcters)",
"Content matches": "Coincidència de contingut",
"Content matches": "",
"Context": "Context",
"Context compacted": "Context compactat",
"Context Compaction": "Compactació del context",
@@ -653,7 +652,7 @@
"Conversation saved successfully": "Conversa desada correctament",
"Convert long pasted text into a file attachment.": "Converteix el text enganxat llargament en un fitxer adjunt.",
"Copied": "Copiat",
"Copied link to clipboard": "Enllaç copiat al porta-retalls",
"Copied link to clipboard": "Enllaç copiat al portaretalls",
"Copied shared chat URL to clipboard!": "S'ha copiat la URL compartida al porta-retalls!",
"Copied to clipboard": "Copiat al porta-retalls",
"Copy": "Copiar",
@@ -709,7 +708,7 @@
"Credential used for Document Intelligence.": "Credencial utilitzada per a Document Intelligence.",
"Credentials used to authenticate with the Jupyter server.": "Credencials utilitzades per autenticar-se amb el servidor Jupyter.",
"CSV Import": "Importar CSV",
"CSV: Name,Email": "CSV: Name,Email",
"CSV: Name,Email": "",
"Ctrl+Enter to Send": "Ctrl+Enter per enviar",
"Current": "Actual",
"Current Model": "Model actual",
@@ -734,7 +733,7 @@
"Database": "Base de dades",
"Datalab Marker API": "API de Datalab Marker",
"Datalab Marker service endpoint used for document parsing.": "Punt final del servei Datalab Marker utilitzat per a l'anàlisi de documents.",
"Date is required": "La data és necessària",
"Date is required": "",
"Date Modified": "Data de modificació",
"Day": "Dia",
"DD/MM/YYYY": "DD/MM/YYYY",
@@ -748,9 +747,9 @@
"Default action buttons will be used.": "S'utilitzaran els botons d'acció per defecte",
"Default description enabled": "Descripcions per defecte habilitades",
"Default Features": "Característiques per defecte",
"Default Filters": "Filtres per defecte",
"Default Filters": "Filres per defecte",
"Default Group": "Grup per defecte",
"Default Interface Settings": "Configuració de la interfície per defecte",
"Default Interface Settings": "",
"Default Model": "Model per defecte",
"Default model updated": "Model per defecte actualitzat",
"Default permissions": "Permisos per defecte",
@@ -772,7 +771,7 @@
"Delete All Chats": "Eliminar tots els xats",
"Delete all contents inside this directory": "Eliminar tot el contingut d'aquesta carpeta",
"Delete all contents inside this folder": "Eliminar tot el contingut d'aquesta carpeta",
"Delete API Key?": "Vols suprimir la clau de l'API?",
"Delete API Key?": "",
"Delete automation?": "Eliminar l'automatització",
"Delete calendar": "Eliminar el calendari",
"Delete Calendar": "Eliminar el calendari",
@@ -800,9 +799,9 @@
"Deleted {{name}}": "S'ha eliminat {{name}}",
"Deleted {{ok}} of {{total}} items": "S'han eliminat {{ok}} de {{total}} ítems",
"Deleted User": "Usuari eliminat",
"Denied {{NAME}}": "S'ha denegat {{NAME}}",
"Deny": "Denegar",
"Deny Tool Call": "Denegar la crida a l'eina",
"Denied {{NAME}}": "",
"Deny": "",
"Deny Tool Call": "",
"Deployment names are required for Azure OpenAI": "Els noms de desplegament són requerits per Azure OpenAI",
"Desc": "Descendent",
"Describe the edit...": "Descriu l'edició...",
@@ -811,7 +810,7 @@
"Describe your knowledge base and objectives": "Descriu la teva base de coneixement i objectius",
"Description": "Descripció",
"Deselect": "Deseleccionar",
"Destination": "Destinació",
"Destination": "",
"Detect Artifacts Automatically": "Detectar automàticament els artefactes",
"Detect generated artifacts and show them in the artifact workspace.": "Detectar els artefactes generats i mostrar-los a l'espai de treball d'artefactes.",
"Detected Variables": "Variables detectades",
@@ -878,27 +877,27 @@
"Don't like the style": "No t'agrada l'estil?",
"Done": "Fet",
"Download": "Descarregar",
"Download \"{{searchValue}}\"": "Descarrega \"{{searchValue}}\"",
"Download \"{{searchValue}}\" from {{provider}}": "Descarrega \"{{searchValue}}\" de {{provider}}",
"Download \"{{searchValue}}\"": "",
"Download \"{{searchValue}}\" from {{provider}}": "",
"Download & Delete": "Descarregar i suprimir",
"Download all users as CSV.": "Descarregar tots els usuaris com a CSV.",
"Download as JSON": "Descarregar com a JSON",
"Download as SVG": "Descarrega com a SVG",
"Download canceled": "Descàrrega cancel·lada",
"Download CSV Template": "Descarregar la plantilla CSV",
"Download CSV Template": "",
"Download every user's chat history as JSON.": "Descarregar l'historial de xat de cada usuari com a JSON.",
"Download failed": "La descarrega ha fallat",
"Download Model": "Descarregar model",
"Download Model": "",
"Download the application database when supported.": "Descarregar la base de dades de l'aplicació quan sigui compatible.",
"Download the current admin configuration as JSON.": "Descarregar la configuració actual de l'administració com a JSON.",
"Download your chat history as a JSON export.": "Descarregar el teu historial de xat com a exportació JSON.",
"Downloading \"{{searchValue}}\"": "Descarregant \"{{searchValue}}\"",
"Downloading \"{{searchValue}}\"": "",
"Downloading stats...": "Descarregant estadístiques...",
"Drag to reorder": "Arrossegar per reordenar",
"Draw": "Dibuixar",
"Drop any files here to upload": "Arrossega aquí qualsevol fitxer per pujar-lo",
"Drop files here": "Arrossega els arxius aquí",
"Drop to upload": "Arrossega per carregar",
"Drop to upload": "",
"DuckDuckGo": "DuckDuckGo",
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "p. ex. '30s','10m'. Les unitats de temps vàlides són 's', 'm', 'h'.",
"e.g. 'low', 'medium', 'high'": "p. ex. 'baix', 'mitjà', 'alt'",
@@ -916,7 +915,7 @@
"e.g. my_tools": "p. ex. les_meves_eines",
"e.g. pdf, docx, txt": "p. ex. pdf, docx, txt",
"e.g. Step-by-step instructions for code reviews": "p. ex. instruccions pas a pas per a revisions de codi",
"e.g. Tell me a fun fact": "p. ex. digues-me quelcom divertit",
"e.g. Tell me a fun fact": "p. ex. digues-me quelcome divertit",
"e.g. Tell me a fun fact about the Roman Empire": "p. ex. digues-me quelcom divertit sobre l'imperi Romà",
"e.g. Tools for performing various operations": "p. ex. Eines per dur a terme operacions",
"e.g., 3, 4, 5 (leave blank for default)": "p. ex. 3, 4, 5 (deixa-ho en blanc per utilitzar el per defecte)",
@@ -957,7 +956,7 @@
"Emojis": "Emojis",
"Empty": "Buit",
"Empty message": "Missatge buit",
"Enable accessibility-focused visual enhancements.": "Activa les millores visuals centrades en l'accessibilitat.",
"Enable accessibility-focused visual enhancements.": "",
"Enable All": "Habilitar tot",
"Enable background sub-agents": "Activar els subagents en segon pla",
"Enable Code Execution": "Permetre l'execució de codi",
@@ -1050,7 +1049,7 @@
"Enter New Password": "Introdueix un nova contrasenya",
"Enter Number of Steps (e.g. 50)": "Introdueix el nombre de passos (p. ex. 50)",
"Enter Ollama Cloud API Key": "Introdueix la clau API de Ollama Cloud",
"Enter OpenSERP Base URL": "Introduïu l'URL base d'OpenSERP",
"Enter OpenSERP Base URL": "",
"Enter PaddleOCR-vl API Base URL": "Introdueix la URL Base de PaddleOCR-vl API",
"Enter PaddleOCR-vl API Token": "Introdueix el Token de PaddleOCR-vl API",
"Enter Perplexity API Key": "Introdueix la clau API de Perplexity",
@@ -1219,7 +1218,7 @@
"Failed to load DOCX file. Please try downloading it instead.": "No s'ha pogut carregar el fitxer DOCX. Si us plau, prova de descarregar-lo.",
"Failed to load Excel/CSV file. Please try downloading it instead.": "No s'ha pogut carregar el fitxer Excel/CSV. Si us plau, prova de descarregar-lo.",
"Failed to load file content.": "No s'ha pogut carregar el contingut del fitxer",
"Failed to load folder": "No s'ha pogut carregar la carpeta",
"Failed to load folder": "",
"Failed to load Interface settings": "No s'ha pogut carregar la configuració de la interfície",
"Failed to load policy: {{error}}": "No s'ha pogut carregar la política: {{error}}",
"Failed to load PPTX file. Please try downloading it instead.": "No s'ha pogut carregar el fitxer PPTX. Si us plau, prova de descarregar-lo.",
@@ -1238,7 +1237,7 @@
"Failed to save policy: {{error}}": "No s'ha pogut desar la política: {{error}}",
"Failed to save terminal servers": "No s'han pogut desar els servidors de terminal",
"Failed to save webhook": "No s'ha pogut desar el webhook",
"Failed to search files": "No s'han pogut cercar els fitxers",
"Failed to search files": "",
"Failed to unshare chat.": "No s'ha pogut deixar de compartir el xat.",
"Failed to update settings": "No s'han pogut actualitzar les preferències",
"Failed to update status": "No s'ha pogut actualitzar l'estat",
@@ -1273,9 +1272,9 @@
"File Upload": "Pujar arxiu",
"File uploaded successfully": "Arxiu pujat satisfactòriament",
"Filename": "Nom de l'arxiu",
"Filename matches": "El nom del fitxer coincideix",
"Filename matches": "",
"Files": "Arxius",
"Filesystem": "Sistema de fitxers",
"Filesystem": "",
"Fill the required fields first.": "Omple primer els camps obligatoris.",
"Fill the source fields and test query first.": "Omple primer els camps de la font i la consulta de prova.",
"Filter": "Filtre",
@@ -1308,7 +1307,6 @@
"Follow up: {{question}}": "Continuació: {{question}}",
"Follow-Up Auto-Generation": "Generació automàtica d'indicacions de continuació",
"Followed instructions perfectly": "S'han seguit les instruccions perfectament",
"Font Family": "",
"for placeholders": "per a marcadors de posició",
"Force OCR": "Forçar OCR",
"Forking chat...": "S'està bifurcant el xat...",
@@ -1410,9 +1408,10 @@
"Hide": "Amaga",
"Hide All": "Amagar tots",
"Hide from Sidebar": "Amagar de la barra lateral",
"Hide Hidden Files": "Amaga els fitxers ocults",
"Hide Hidden Files": "",
"Hide Model": "Amagar el model",
"High": "Alt",
"High Contrast Mode": "Mode d'alt contrast",
"History": "Historial",
"Home": "Inici",
"Host": "Servidor",
@@ -1427,14 +1426,14 @@
"I acknowledge that I have read and I understand the implications of my action. I am aware of the risks associated with executing arbitrary code and I have verified the trustworthiness of the source.": "Afirmo que he llegit i entenc les implicacions de la meva acció. Soc conscient dels riscos associats a l'execució de codi arbitrari i he verificat la fiabilitat de la font.",
"ID": "ID",
"ID cannot contain \":\" or \"|\" characters": "L'ID no pot contenir caràcters \":\" ni \"|\"",
"ID copied to clipboard": "L'ID s'ha copiat al porta-retalls",
"ID copied to clipboard": "L'ID s'ha copiat al portaretalls",
"Identity audit": "Auditoria d'identitat",
"Idle only": "Només inactiu",
"Idle Timeout": "Temps d'espera d'inactivitat",
"iframe Sandbox Allow Downloads": "Permetre descàrregues iframe Sandbox",
"iframe Sandbox Allow Downloads": "",
"iframe Sandbox Allow Forms": "Permetre formularis sandbox iframe",
"iframe Sandbox Allow Same Origin": "Permetre same-origin sandbox iframe",
"iframe Sandbox Allow Scripts": "Permetre scripts sandbox iframe",
"iframe Sandbox Allow Scripts": "",
"Image": "Imatge",
"Image Compression": "Compressió d'imatges",
"Image Compression Height": "Alçada de la compressió d'imatges",
@@ -1449,7 +1448,7 @@
"Image Prompt Generation": "Generació d'indicacions d'imatge",
"Image Prompt Generation Prompt": "Indicació per a la generació d'indicacions d'imatge",
"Image Size": "Mida de la imatge",
"Image unavailable": "Imatge no disponible",
"Image unavailable": "",
"image/*, video/*": "imatge/*, vídeo/*",
"Images": "Imatges",
"Import": "Importar",
@@ -1457,7 +1456,7 @@
"Import chat history from a JSON export file.": "Importar l'historial de xats a partir d'un fitxer JSON",
"Import Chats": "Importar xats",
"Import Config": "Importar la configuració",
"Import CSV": "Importar CSV",
"Import CSV": "",
"Import From Link": "Importar des d'un enllaç",
"Import JSON": "Importar JSON",
"Import Models": "Importar models",
@@ -1469,7 +1468,7 @@
"Important Update": "Actualització important",
"Imported automations successfully": "Automatitzacions importades correctament",
"Imported notes successfully": "Notes importades correctament",
"Importing...": "S'està important...",
"Importing...": "",
"Inactive": "No actiu",
"Include": "Incloure",
"Include saved memories in the system context.": "Incloure les memòries desades al context del sistema.",
@@ -1483,10 +1482,10 @@
"Inject stored memories into conversation context": "Injectar la memòria emmagatzemats al context de la conversa",
"Inject the entire content as context for comprehensive processing, this is recommended for complex queries.": "Injectar tot el contingut com a context per a un processament complet, això es recomana per a consultes complexes.",
"Inject the entire content as context for comprehensive processing.": "Injectar tot el contingut com a context per a un processament complet.",
"Inline": "En línia",
"Inline": "",
"Input": "Entrada",
"Input Key (e.g. text, unet_name, steps)": "Entra la clau (p. ex. text, unet_name, steps)",
"Input needed": "Es necessita entrada",
"Input needed": "",
"Input Variables": "Variables d'entrada",
"Insert": "Inserir",
"Insert Follow-Up Prompt to Input": "Inserir un missatge de continuació per a l'entrada",
@@ -1503,7 +1502,7 @@
"Interface": "Interfície",
"Interface Settings Access": "Accés a la configuració de la interfície",
"Invalid file format.": "Format d'arxiu no vàlid.",
"Invalid format.": "Format no vàlid.",
"Invalid format.": "",
"Invalid JSON file": "Arxiu JSON no vàlid",
"Invalid JSON format": "Format JSON no vàlid",
"Invalid JSON format for ComfyUI Edit Workflow.": "Format JSON no vàlid per al Workflow de ComfyUI d'edició",
@@ -1544,7 +1543,7 @@
"Knowledge Access": "Accés al coneixement",
"Knowledge Base": "Base de coneixement",
"Knowledge base has been reset": "La base de coneixement s'ha reiniciat",
"Knowledge base not found.": "No s'ha trobat la base de coneixement.",
"Knowledge base not found.": "",
"Knowledge created successfully.": "Coneixement creat correctament.",
"Knowledge deleted successfully.": "Coneixement eliminat correctament.",
"Knowledge Description": "Descripció del coneixement",
@@ -1618,8 +1617,8 @@
"Live": "Live",
"llama.cpp": "llama.cpp",
"LLMs can make mistakes. Verify important information.": "Els models de llenguatge poden cometre errors. Verifica la informació important.",
"LM Studio": "LM Studio",
"Load Model": "Carregar model",
"LM Studio": "",
"Load Model": "",
"Loaded": "Carregat",
"Loader": "Carregador",
"Loading Kokoro.js...": "Carregant Kokoro.js",
@@ -1665,7 +1664,7 @@
"Max concurrent": "Màxim concurrents",
"Max iterations": "Màxim d'iteracions",
"Max output": "Sortida màxima",
"Max Speakers": "Nombre màxim de parlants",
"Max Speakers": "Nombre màxim d'altaveus",
"Max tokens to retrieve (1024-32768, default 8192)": "Nombre màxim de tokens a obtenir (1024-32768, per defecte 8192)",
"Max Upload Count": "Nombre màxim de càrregues",
"Max Upload Size": "Mida màxima de càrrega",
@@ -1748,18 +1747,18 @@
"Model Capabilities": "Capacitats del model",
"Model created successfully!": "Model creat correctament",
"Model Defaults": "Models per defecte",
"Model deleted successfully": "El model s'ha suprimit correctament",
"Model download started": "S'ha iniciat la descàrrega del model",
"Model deleted successfully": "",
"Model download started": "",
"Model filesystem path detected. Model shortname is required for update, cannot continue.": "S'ha detectat el camí del sistema de fitxers del model. És necessari un nom curt del model per actualitzar, no es pot continuar.",
"Model Filtering": "Filtrat de models",
"Model ID": "Identificador del model",
"Model ID is already added": "L'identificador del model ja s'ha afegit",
"Model ID is already added": "",
"Model ID is required.": "L'ID del model és necessari",
"Model IDs": "Identificadors del model",
"Model is now private": "El model ara és privat",
"Model is now public": "El model ara és públic",
"Model list refreshed": "S'ha actualitzat la llista de models",
"Model loaded successfully": "El model s'ha carregat correctament",
"Model list refreshed": "",
"Model loaded successfully": "",
"Model Name": "Nom del model",
"Model name already exists, please choose a different one": "Aquest nom de model ja existeix, si us plau, tria'n un altre",
"Model Name is required.": "El nom del model és necessari",
@@ -1772,7 +1771,7 @@
"Model Parameters": "Paràmetres del model",
"Model Params": "Paràmetres del model",
"Model Permissions": "Permisos dels models",
"Model providers": "Proveïdors de models",
"Model providers": "",
"Model removed from pinned models": "Model eliminat dels models fixats",
"Model removed from selected models": "S'ha eliminat el model dels models seleccionats",
"Model Response Mode": "Mode de resposta del model",
@@ -1838,7 +1837,7 @@
"New Tool": "Nova eina",
"New Webhook": "Nou webhook",
"new-channel": "nou-canal",
"Next": "Següent",
"Next": "",
"Next message": "Missatge següent",
"Next month": "El mes vinent",
"Next page": "Pàgina següent",
@@ -1850,12 +1849,12 @@
"No additional headers are sent unless configured.": "No s'envia cap capçalera addicional tret que estigui configurada.",
"No authentication": "Sense autenticació",
"No automations found": "No s'ha trobat cap automatització",
"No channels": "Sense canals",
"No channels": "",
"No chat alerts": "No hi ha alertes de xat",
"No chat history": "No hi ha historial de xat",
"No chat to compact": "No hi ha xat per compactar",
"No chat to fork": "No hi ha cap xat a bifurcar",
"No chats": "No hi ha xats",
"No chats": "",
"No chats found": "No s'han trobat xats",
"No chats found for this user.": "No s'han trobat xats per a aquest usuari.",
"No chats found.": "No s'ha trobat xats.",
@@ -1947,7 +1946,7 @@
"OAuth 2.1 (Static)": "OAuth 2.1 (Estàtic)",
"OAuth client identifier from the provider.": "Identificador de client OAuth del proveïdor.",
"OAuth client secret from the provider.": "Secret del client OAuth del proveïdor.",
"OAuth discovery successful": "La descoberta d'OAuth ha estat correcta",
"OAuth discovery successful": "",
"OAuth ID": "ID OAuth",
"OAuth Resource Parameter": "Paràmetre de recurs OAuth",
"OAuth Scopes": "Àmbits d'OAuth",
@@ -1972,7 +1971,7 @@
"OneDrive": "OneDrive",
"Only active during Voice Mode.": "Només actiu en el mode de Veu.",
"Only active when \"Paste Large Text as File\" setting is toggled on.": "Només està actiu quan l'opció \"Enganxa text gran com a fitxer\" està activada.",
"Only active when a tool call is waiting for approval.": "Només està actiu quan una crida d'eina està esperant aprovació.",
"Only active when a tool call is waiting for approval.": "",
"Only active when the chat input is in focus and an LLM is generating a response.": "Només s'activa quan l'entrada del xat està en focus i un LLM està generant una resposta.",
"Only active when the chat input is in focus.": "Només actiu quan l'entrada del xat està en focus.",
"Only alphanumeric characters and hyphens are allowed": "Només es permeten caràcters alfanumèrics i guions",
@@ -1994,7 +1993,7 @@
"Open create menu": "Obrir el menú de creació",
"Open detail blocks by default.": "Obrir blocs de detalls per defecte.",
"Open file": "Obrir arxiu",
"Open Folder": "Obre la carpeta",
"Open Folder": "",
"Open in full screen": "Obrir en pantalla complerta",
"Open in new tab": "Obrir en una nova pestanya",
"Open link": "Obrir l'enllaç",
@@ -2025,12 +2024,12 @@
"OpenAPI Spec": "Especificació OpenAPI",
"openapi.json URL or Path": "Camí o URL a openapi.json",
"OpenID discovery URL for this provider.": "URL de descobriment d'OpenID per a aquest proveïdor.",
"OpenSERP URL": "URL d'OpenSERP",
"OpenSERP URL": "",
"optional": "opcional",
"Optional": "Opcional",
"Optional API key for Ollama requests.": "Clau API opcional per a peticions a Ollama.",
"or": "o",
"Orchestrator": "Orquestrador",
"Orchestrator": "",
"Ordered List": "Llista ordenada",
"Other": "Altres",
"out of": "de",
@@ -2054,13 +2053,13 @@
"Passthrough params": "Paràmetres de pas",
"Password": "Contrasenya",
"Password for the bind DN.": "Contrasenya per al DN d'enllaç.",
"Password updated. Please sign in again.": "Contrasenya actualitzada. Si us plau, torna a iniciar la sessió.",
"Password updated. Please sign in again.": "",
"Passwords do not match.": "Les contrasenyes no coincideixen",
"Paste inserted prompts as rich text when possible.": "Enganxar les sol·licituds inserides com a text enriquit quan sigui possible.",
"Paste Large Text as File": "Enganxa un text llarg com a fitxer",
"Path": "Camí",
"Path copied": "Camí copiat",
"Pause a response to ask the user a clarifying question": "Posa en pausa una resposta per fer una pregunta d'aclariment a l'usuari",
"Pause a response to ask the user a clarifying question": "",
"Paused": "Pausat",
"PDF document (.pdf)": "Document PDF (.pdf)",
"PDF Extract Images (OCR)": "Extreu imatges del PDF (OCR)",
@@ -2072,8 +2071,8 @@
"Pending Accounts": "Comptes pendents",
"Pending User Overlay Content": "Contingut de la finestra d'usuari pendent",
"Pending User Overlay Title": "Títol de la finestra d'usuari pendent",
"Per automation": "Per automatització",
"Per chat": "Per xat",
"Per automation": "",
"Per chat": "",
"Percentage of recent messages to keep after older messages are summarized.": "Percentatge de missatges recents que cal conservar després de resumir els missatges més antics.",
"Permanently delete every chat after confirmation.": "Suprimeix permanentment cada xat després de la confirmació.",
"Permission denied when accessing media devices": "Permís denegat en accedir a dispositius multimèdia",
@@ -2121,7 +2120,7 @@
"Please enter Client ID and Client Secret": "Si us plau, introdueix l'ID de client i el secret del client.",
"Please fill in all fields.": "Emplena tots els camps, si us plau.",
"Please register the OAuth client": "Si us plau, registra el client OAuth",
"Please save the connection before authorizing OAuth": "Si us plau, desa la connexió abans d'autoritzar OAuth",
"Please save the connection before authorizing OAuth": "",
"Please save the connection to persist the OAuth client information and do not change the ID": "Si us plau, desa la connexió per conservar la informació del client OAuth i no canviïs l'ID.",
"Please select a model.": "Si us plau, selecciona un model.",
"Please select a reason": "Si us plau, selecciona una raó",
@@ -2139,14 +2138,14 @@
"Preferred transcript language codes, separated by commas.": "Codis de llengua de transcripció preferits, separats per comes.",
"Prefix ID": "Identificador del prefix",
"Prefix ID is used to avoid conflicts with other connections by adding a prefix to the model IDs - leave empty to disable": "L'identificador de prefix s'utilitza per evitar conflictes amb altres connexions afegint un prefix als ID de model; deixa'l en blanc per desactivar-lo.",
"Preparing {{NAME}}...": "S'està preparant {{NAME}}...",
"Preparing {{NAME}}...": "",
"Preparing download...": "Preparant la descàrrega...",
"Press keys...": "Prem una tecla...",
"Prevent File Creation": "Prevenir la creació d'arxius",
"Preview": "Previsualització",
"Preview Access": "Previsualitza l'accés",
"Preview failed": "La previsualització ha fallat",
"Previous": "Anterior",
"Preview failed": "",
"Previous": "",
"Previous 30 days": "30 dies anteriors",
"Previous 7 days": "7 dies anteriors",
"Previous message": "Missatge anterior",
@@ -2206,13 +2205,13 @@
"Reasoning Effort": "Esforç de raonament",
"Reasoning Tags": "Etiqueta de raonament",
"Reasoning text...": "Text de raonament...",
"Rebuild knowledge file, knowledge search, and memory vectors using the current embedding model.": "Reconstruir el fitxer de coneixement, la cerca de coneixement i els vectors de memòria utilitzant el model d'inserció actual.",
"Rebuild vectors for existing knowledge files, knowledge search, and memories.": "Reconstruir vectors per a fitxers de coneixement existents, cerca de coneixement i memòries.",
"Rebuild knowledge file, knowledge search, and memory vectors using the current embedding model.": "",
"Rebuild vectors for existing knowledge files, knowledge search, and memories.": "",
"Receives matching events across the instance, including system/config events and events associated with any user.": "Rep esdeveniments coincidents a tota la instància, inclosos els esdeveniments del sistema/configuració i els esdeveniments associats a qualsevol usuari.",
"Receives matching events that are not associated with a user.": "Rep esdeveniments coincidents que no estan associats a un usuari.",
"Receives matching user-associated events only when the actor, user subject, or user data matches these users or current group members. System/config events are not sent.": "Rep esdeveniments coincidents associats a l'usuari només quan l'actor, el subjecte de l'usuari o les dades de l'usuari coincideixen amb aquests usuaris o membres del grup actual. No s'envien esdeveniments de sistema/configuració.",
"Recently Used": "Recentment utilitzat",
"Recommended": "Recomanat",
"Recommended": "",
"Reconnected": "Reconnectat",
"Record": "Enregistrar",
"Record voice": "Enregistrar la veu",
@@ -2239,8 +2238,8 @@
"Registration failed": "El registre ha fallat",
"Registration successful": "Registre correcte",
"Reindex": "Reindexar",
"Reindex Embedding Data": "Reindexar les dades d'incrustació",
"Reindex Knowledge and Memory Vectors": "Reindexar els vectors de coneixement i memòria",
"Reindex Embedding Data": "",
"Reindex Knowledge and Memory Vectors": "",
"Release Notes": "Notes de la versió",
"Releases": "Versions",
"Relevance": "Rellevància",
@@ -2290,7 +2289,7 @@
"Reset Upload Directory": "Restableix el directori de pujades",
"Reset Vector Storage/Knowledge": "Restableix el Repositori de vectors/Coneixement",
"Reset view": "Netejar la vista",
"Reset zoom": "Restableix el zoom",
"Reset zoom": "",
"Response": "Resposta",
"Response Auto-Scroll": "Desplaçament automàtic de resposta",
"Response notifications cannot be activated as the website permissions have been denied. Please visit your browser settings to grant the necessary access.": "Les notificacions de resposta no es poden activar perquè els permisos del lloc web han estat rebutjats. Comprova les preferències del navegador per donar l'accés necessari.",
@@ -2358,12 +2357,12 @@
"Search and view user chat history": "Cerca i mostra l'historial de xats",
"Search Automations": "Cercar automatitzacions",
"Search Base": "Base de cerca",
"Search channels": "Cerca canals",
"Search channels": "",
"Search channels and channel messages": "Cerca els canals i els missatges als canals",
"Search Chats": "Cercar xats",
"Search Collection": "Cercar col·leccions",
"Search Files": "Cerca arxius",
"Search files and contents": "Cerca fitxers i continguts",
"Search files and contents": "",
"Search filters": "Filtres de cerca",
"Search Filters": "Filtres de cerca",
"Search folders": "Cercar carpetes",
@@ -2430,7 +2429,7 @@
"Select an embedding model engine": "Seleccionar un motor d'incrustació",
"Select an engine": "Seleccionar un motor",
"Select an installed pipeline to configure or remove.": "Seleccionar una pipeline instal·lada per configurar o eliminar",
"Select an instance": "Selecciona una instància",
"Select an instance": "",
"Select an Ollama instance": "Seleccionar una instància d'Ollama",
"Select an option": "Seleccionar una opció",
"Select an output format": "Seleccionar un format de sortida",
@@ -2449,7 +2448,7 @@
"Select the Gemini endpoint method to call.": "Seleccionar el punt de connexió de Gemini a cridar.",
"Select the local model precision used by Kokoro.js.": "Seleccionar la precisió del model local utilitzat per Kohoro.js.",
"Select the Pipelines server to manage.": "Seleccionar el servidor de Pipelines a gestionar.",
"Select the Tika server API version.": "Selecciona la versió de l'API del servidor Tika.",
"Select the Tika server API version.": "",
"Select Tool": "Selecionar eina",
"Select view": "Seleccionar una vista",
"Selected": "Seleccionat",
@@ -2495,7 +2494,7 @@
"Set embedding model": "Establir el model d'incrustació",
"Set embedding model (e.g. {{model}})": "Establir el model d'incrustació (p. ex. {{model}})",
"Set reranking model (e.g. {{model}})": "Establir el model de reavaluació (p. ex. {{model}})",
"Set system-wide interface defaults for every account. Personal settings override these defaults.": "Establir els valors predeterminats de la interfície a tot el sistema per a cada compte. La configuració personal anul·la aquests valors predeterminats.",
"Set system-wide interface defaults for every account. Personal settings override these defaults.": "",
"Set the birth date saved with your profile.": "Establir la data de naixement desada amb el teu perfil.",
"Set the default system prompt for new chats.": "Establir la sol·licitud predeterminada del sistema per a nous xats.",
"Set the display name shown across your account.": "Establir el nom de visualització que es mostra al teu compte.",
@@ -2513,13 +2512,13 @@
"Sets the stop sequences to use. When this pattern is encountered, the LLM will stop generating text and return. Multiple stop patterns may be set by specifying multiple separate stop parameters in a modelfile.": "Establir les seqüències d'aturada a utilitzar. Quan es trobi aquest patró, el LLM deixarà de generar text. Es poden establir diversos patrons de parada especificant diversos paràmetres de parada separats en un fitxer model.",
"Setting": "Preferència",
"Settings": "Preferències",
"settings configured": "configuració establerta",
"settings configured": "",
"Settings Permissions": "Preferències de permisos",
"Settings saved successfully!": "Les preferències s'han desat correctament",
"Shapes the short label generated for each chat.": "Donar forma a l'etiqueta curta generada per a cada xat.",
"Share": "Compartir",
"Share Chat": "Compartir el xat",
"Share link copied to clipboard.": "Compartir l'enllaç copiat al porta-retalls",
"Share link copied to clipboard.": "Compartir l'enllaç copiat al portaretalls",
"Share to Open WebUI Community": "Compartir amb la comunitat OpenWebUI",
"Share your background and interests": "Compartir la teva informació i interessos",
"Share your current location with features that can use it.": "Compartir la teva ubicació actual amb funcions que la puguin utilitzar.",
@@ -2530,7 +2529,7 @@
"Sharing Permissions": "Compartir els permisos",
"Show": "Mostrar",
"Show \"What's New\" Modal on Login": "Veure 'Què hi ha de nou' a l'entrada",
"Show a floating preview of recent messages when hovering a chat in the sidebar.": "Mostra una vista prèvia flotant dels missatges recents quan passes el cursor per un xat a la barra lateral.",
"Show a floating preview of recent messages when hovering a chat in the sidebar.": "",
"Show admin contact details while an account waits for approval.": "Mostrar les dades de contacte de l'administrador mentre un compte espera l'aprovació.",
"Show All": "Mostrar tot",
"Show all ({{COUNT}} characters)": "Mostra tot ({{COUNT}} caràcters)",
@@ -2539,8 +2538,8 @@
"Show Files on Terminal Select": "Mostra fitxers en seleccionar terminal",
"Show formatting controls in the rich text composer.": "Mostrar els controls de format a l'editor de text enriquit.",
"Show Formatting Toolbar": "Mostrar la barra de format",
"Show Full access and Ask for approval in the chat input menu.": "Mostra «Accés complet» i «Demana aprovació» al menú d'entrada del xat.",
"Show Hidden Files": "Mostra els fitxers ocults",
"Show Full access and Ask for approval in the chat input menu.": "",
"Show Hidden Files": "",
"Show image preview": "Mostrar la previsualització de la imatge",
"Show Model": "Mostrar el model",
"Show more": "Mostrar-ne més",
@@ -2555,7 +2554,7 @@
"Show your support!": "Mostra el teu suport!",
"Show your username label instead of You in chat bubbles.": "Mostrar l'etiqueta del teu nom d'usuari en lloc de Tu a les bombolles de xat.",
"Showcased creativity": "Creativitat mostrada",
"Sidebar": "Barra lateral",
"Sidebar": "",
"Sign in": "Iniciar sessió",
"Sign in to {{WEBUI_NAME}}": "Iniciar sessió a {{WEBUI_NAME}}",
"Sign in to {{WEBUI_NAME}} with LDAP": "Iniciar sessió a {{WEBUI_NAME}} amb LDAP",
@@ -2566,7 +2565,7 @@
"simultaneous sub-agents": "subagents simultanis",
"Single": "Únic",
"Sink List": "Enfonsar la llista",
"Size": "Mida",
"Size": "",
"sk-1234": "sk-1234",
"Skill created successfully": "Habilitat creada correctament",
"Skill deleted successfully": "Habilitat suprimida correctament",
@@ -2606,7 +2605,7 @@
"Start new chats as temporary unless changed.": "Iniciar xats nous com a temporals tret que es modifiqui.",
"Start of the channel": "Inici del canal",
"Start Tag": "Etiqueta d'inici",
"Start the chat to use this terminal.": "Inicia el xat per utilitzar aquest terminal.",
"Start the chat to use this terminal.": "",
"Starting in {{count}} minutes_one": "Començant en {{count}} minut",
"Starting in {{count}} minutes_many": "Començant en {{count}} minuts",
"Starting in {{count}} minutes_other": "Començant en {{count}} minuts",
@@ -2636,7 +2635,7 @@
"Su_day_of_week": "Diumenge",
"Sub Claim": "Reclamació sub",
"Sub-agents": "Subagents",
"Submit answers": "Envia les respostes",
"Submit answers": "",
"Submit question": "Enviar la pregunta",
"Submit suggestion": "Enviar un suggeriment",
"Subtitle": "Subtítol",
@@ -2684,7 +2683,7 @@
"Task List": "Llista de tasques",
"Task Management": "Gestió de tasques",
"Task Model": "Model de tasques",
"Task Model Parameters": "Paràmetres del model de tasca",
"Task Model Parameters": "",
"Tasks": "Tasques",
"tasks completed": "tasques completades",
"Tavily API Key": "Clau API de Tavily",
@@ -2694,9 +2693,9 @@
"Temporary Chat": "Xat temporal",
"Temporary Chat by Default": "Xat temporal per defecte",
"Terminal": "Terminal",
"Terminal Contexts": "Contextos del terminal",
"Terminal File Display": "Visualització de fitxers de terminal",
"Terminal Preview Allow Same Origin": "Vista prèvia del terminal permet el mateix origen",
"Terminal Contexts": "",
"Terminal File Display": "",
"Terminal Preview Allow Same Origin": "",
"Terminal servers saved": "Servidors de terminal desats",
"Test": "Prova",
"Test must pass before a source is created.": "El test s'ha de passar abans de crear una font.",
@@ -2728,7 +2727,7 @@
"The temperature of the model. Increasing the temperature will make the model answer more creatively.": "La temperatura del model. Augmentar la temperatura farà que el model respongui de manera més creativa.",
"Theme": "Tema",
"There was an error syncing your stats. Please try again.": "S'ha produït un error en sincronitzar les estadístiques. Torna-ho a provar.",
"These settings are read from environment variables and cannot be edited here while {{ENV_VAR}} is disabled.": "Aquests paràmetres es llegeixen des de variables d'entorn i no es poden editar aquí mentre {{ENV_VAR}} estigui desactivat.",
"These settings are read from environment variables and cannot be edited here while {{ENV_VAR}} is disabled.": "",
"thinking, output_config": "pensant, output_config",
"Thinking...": "Pensant...",
"This action cannot be undone. Do you wish to continue?": "Aquesta acció no es pot desfer. Vols continuar?",
@@ -2749,10 +2748,10 @@
"This will delete": "Això eliminarà",
"This will delete <strong>{{NAME}}</strong> and <strong>all its contents</strong>.": "Això eliminarà <strong>{{NAME}}</strong> i <strong>tots els continguts</strong>.",
"This will delete all models including custom models and cannot be undone.": "Això eliminarà tots els models incloent els personalitzats i no es pot desfer",
"This will delete the cached model and cannot be undone.": "Això suprimirà el model a la memòria cau i no es pot desfer.",
"This will delete the cached model and cannot be undone.": "",
"This will permanently delete the calendar \"{{name}}\" and all its events. This action cannot be undone.": "Això eliminarà permanentment el calendari \"{{name}}\" i tots els seus esdeveniments. Aquesta acció no es pot desfer.",
"This will remove all files and directories from this knowledge base. This action cannot be undone.": "Això eliminarà tots els fitxers i carpetes d'aquesta base de coneixement. Aquesta acció no es pot desfer.",
"This will revoke the current API key.": "Això revocarà la clau de l'API actual.",
"This will revoke the current API key.": "",
"Thorough explanation": "Explicació en detall",
"Thought": "Pensament",
"Thought for {{DURATION}}": "He pensat durant {{DURATION}}",
@@ -2762,12 +2761,12 @@
"Through": "A través de",
"Thumbs up/down ratings from users on model responses": "Valoracions de 'm'agrada'/'no' dels usuaris sobre les respostes del model",
"Tika": "Tika",
"Tika 3.x": "Tika 3.x",
"Tika 4.x": "Tika 4.x",
"Tika 3.x": "",
"Tika 4.x": "",
"Tika server endpoint used for content extraction.": "Punt final del servidor Tika utilitzat per a l'extracció de contingut.",
"Tika Server URL": "URL del servidor Tika",
"Tika Server URL required.": "La URL del servidor Tika és obligatòria.",
"Tika Server Version": "Versió del servidor Tika",
"Tika Server Version": "",
"Tiktoken": "Tiktoken",
"Time": "Temps",
"Time & Calculation": "Temps i càlculs",
@@ -2787,7 +2786,7 @@
"To attach knowledge base here, add them to the \"Knowledge\" workspace first.": "Per adjuntar la base de coneixement aquí, afegeix-la primer a l'espai de treball \"Coneixement\".",
"To learn more about available endpoints, visit our documentation.": "Per obtenir més informació sobre els punts d'accés disponibles, visita la nostra documentació.",
"To learn more about SpeechT5,": "Per obtenir més informació sobre SpeechT5,",
"To see the available CMU Arctic speaker embeddings,": "Per veure les incrustacions dels parlants CMU Arctic disponibles,",
"To see the available CMU Arctic speaker embeddings,": "Per veure les incrustacions d'altaveus CMU Arctic disponibles,",
"To select actions here, add them to the \"Functions\" workspace first.": "Per seleccionar accions aquí, afegeix-les primer a l'espai de treball \"Funcions\".",
"To select default filters here, enable toggleable filters for this model first.": "Per seleccionar els filtres predeterminats aquí, primer activa els filtres que es poden canviar per a aquest model.",
"To select filters here, add them to the \"Functions\" workspace first.": "Per seleccionar filtres aquí, afegeix-los primer a l'espai de treball \"Funcions\".",
@@ -2815,7 +2814,7 @@
"tokens": "tokens",
"Tokens": "Tokens",
"Too verbose": "Massa explícit",
"tool": "eina",
"tool": "",
"Tool created successfully": "Eina creada correctament",
"Tool deleted successfully": "Eina eliminada correctament",
"Tool Description": "Descripció de l'eina",
@@ -2824,7 +2823,7 @@
"tool loops per sub-agent": "bucles d'eina per subagent",
"Tool Menu": "Menú d'eines",
"Tool Name": "Nom de l'eina",
"Tool Permissions": "Permisos d'eina",
"Tool Permissions": "",
"Tool Servers": "Servidors d'eines",
"Tool updated successfully": "Eina actualitzada correctament",
"Tools": "Eines",
@@ -2849,10 +2848,10 @@
"TTS Voice": "Veu TTS",
"Tu_day_of_week": "Dimarts",
"Type": "Tipus",
"Type a model ref": "Escriu un model de referència",
"Type a model ref": "",
"Type here...": "Escriu aquí...",
"Type Hugging Face Resolve (Download) URL": "Escriu la URL de Resolució (Descàrrega) de Hugging Face",
"Type your answer": "Escriu la teva resposta",
"Type your answer": "",
"Uh-oh! There was an issue with the response.": "Vaja! Hi ha hagut una incidència amb la resposta.",
"UI": "UI",
"UI Scale": "Mida de la interfície gràfica",
@@ -2863,7 +2862,7 @@
"unknown": "desconegut",
"Unknown": "Desconegut",
"Unknown User": "Usuari desconegut",
"Unload Model": "Descarregar model",
"Unload Model": "",
"Unloads {{FROM_NOW}}": "Es descarrega {{FROM_NOW}}",
"Unmute": "No silenciar",
"Unpin": "Alliberar",
@@ -2897,7 +2896,7 @@
"Upload a workflow.json file exported as API format from ComfyUI.": "Carregar un fitxer workflow.json exportat com a format API des de ComfyUI.",
"Upload Audio": "Pujar àudio",
"Upload directory": "Pujar directori",
"Upload failed": "La càrrega ha fallat",
"Upload failed": "",
"Upload files": "Pujar fitxers",
"Upload Files": "Pujar fitxers",
"Upload Model": "Pujar model",
@@ -2917,7 +2916,6 @@
"Use": "Ús",
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "Fes servir l'endpoint /v1/chat/completions en comptes de /v1/audio/transcriptions per a una precisió potencialment millor.",
"Use a Gemini API key for image generation.": "Utilitzar una clau API Gemini per a la generació d'imatges.",
"Use a local font family for the app interface.": "",
"Use a valid event name or pattern like user.*": "Utilitza un nom o un patró d'esdeveniment vàlid com ara usuari.*",
"Use a wider chat layout on large displays.": "Utilitzar un disseny de xat més ampli en pantalles grans.",
"Use an API key when your ComfyUI server requires one.": "Utilitzar una clau API quan el teu servidor ComfyUI ho requereixi.",
@@ -2948,7 +2946,7 @@
"User location successfully retrieved.": "Ubicació de l'usuari obtinguda correctament",
"User menu": "Menú d'usuari",
"User messages": "Missatges d'usuari",
"User not found.": "No s'ha trobat l'usuari.",
"User not found.": "",
"User Preview": "Previsualitzar l'usuari",
"User ratings (thumbs up/down)": "Valoracions dels usuaris (polze amunt/avall)",
"User Status": "Estats d'usuari",
@@ -2983,7 +2981,7 @@
"Version": "Versió",
"Version {{selectedVersion}} of {{totalVersions}}": "Versió {{selectedVersion}} de {{totalVersions}}",
"Version deleted": "Versió eliminada",
"View channel": "Veure canal",
"View channel": "",
"View chat": "Veure xat",
"View Replies": "Veure les respostes",
"View Result from {{NAME}}": "Mostra el resultat de {{NAME}}",
@@ -3003,7 +3001,7 @@
"Voice Mode Prompt": "Indicació per al mode de veu",
"Wait for the current response to finish before compacting.": "Esperar que finalitzi la resposta actual abans de compactar.",
"Wait for the current response to finish before forking.": "Esperar que finalitzi la resposta actual abans de bifurcar.",
"Waiting for upload": "Esperant la pujada",
"Waiting for upload": "",
"Waiting for upload...": "Esperant per pujar...",
"Warning": "Avís",
"Warning:": "Avís:",
@@ -3104,6 +3102,6 @@
"YouTube": "YouTube",
"Youtube Language": "Idioma de YouTube",
"Youtube Proxy URL": "URL de Proxy de Youtube",
"Zoom in": "Apropa",
"Zoom out": "Allunya"
"Zoom in": "",
"Zoom out": ""
}
+1 -3
View File
@@ -68,7 +68,6 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "",
"Account": "Account",
"Account Activation Pending": "",
@@ -1298,7 +1297,6 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "",
"Followed instructions perfectly": "",
"Font Family": "",
"for placeholders": "",
"Force OCR": "",
"Forking chat...": "",
@@ -1403,6 +1401,7 @@
"Hide Hidden Files": "",
"Hide Model": "",
"High": "",
"High Contrast Mode": "",
"History": "",
"Home": "",
"Host": "",
@@ -2903,7 +2902,6 @@
"Use": "",
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "",
"Use a Gemini API key for image generation.": "",
"Use a local font family for the app interface.": "",
"Use a valid event name or pattern like user.*": "",
"Use a wider chat layout on large displays.": "",
"Use an API key when your ComfyUI server requires one.": "",
+1 -3
View File
@@ -82,7 +82,6 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "Přístupné pro všechny uživatele",
"Account": "Účet",
"Account Activation Pending": "Čeká se na aktivaci účtu",
@@ -1318,7 +1317,6 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "Automatické generování následných otázek",
"Followed instructions perfectly": "Dokonale dodržel pokyny",
"Font Family": "",
"for placeholders": "",
"Force OCR": "Vynutit OCR",
"Forking chat...": "",
@@ -1423,6 +1421,7 @@
"Hide Hidden Files": "",
"Hide Model": "Skrýt model",
"High": "Vysoká",
"High Contrast Mode": "Režim vysokého kontrastu",
"History": "",
"Home": "Domů",
"Host": "Hostitel",
@@ -2931,7 +2930,6 @@
"Use": "",
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "",
"Use a Gemini API key for image generation.": "",
"Use a local font family for the app interface.": "",
"Use a valid event name or pattern like user.*": "",
"Use a wider chat layout on large displays.": "",
"Use an API key when your ComfyUI server requires one.": "",
+1 -3
View File
@@ -68,7 +68,6 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "Tilgængelig for alle brugere",
"Account": "Profil",
"Account Activation Pending": "Aktivering af profil afventer",
@@ -1298,7 +1297,6 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "Automatisk opfølgnings generering",
"Followed instructions perfectly": "Fulgte instruktionerne perfekt",
"Font Family": "",
"for placeholders": "",
"Force OCR": "Gennemtving OCR",
"Forking chat...": "",
@@ -1403,6 +1401,7 @@
"Hide Hidden Files": "",
"Hide Model": "Skjul model",
"High": "Høj",
"High Contrast Mode": "Høj kontrast tilstand",
"History": "",
"Home": "Hjem",
"Host": "Vært",
@@ -2903,7 +2902,6 @@
"Use": "",
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "Brug /v1/chat/completions endpointet i stedet for /v1/audio/transcriptions for potentielt øget nøjagtighed.",
"Use a Gemini API key for image generation.": "",
"Use a local font family for the app interface.": "",
"Use a valid event name or pattern like user.*": "",
"Use a wider chat layout on large displays.": "",
"Use an API key when your ComfyUI server requires one.": "",
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -68,7 +68,6 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "",
"Account": "Account",
"Account Activation Pending": "",
@@ -1298,7 +1297,6 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "",
"Followed instructions perfectly": "",
"Font Family": "",
"for placeholders": "",
"Force OCR": "",
"Forking chat...": "",
@@ -1403,6 +1401,7 @@
"Hide Hidden Files": "",
"Hide Model": "",
"High": "",
"High Contrast Mode": "",
"History": "",
"Home": "",
"Host": "",
@@ -2903,7 +2902,6 @@
"Use": "",
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "",
"Use a Gemini API key for image generation.": "",
"Use a local font family for the app interface.": "",
"Use a valid event name or pattern like user.*": "",
"Use a wider chat layout on large displays.": "",
"Use an API key when your ComfyUI server requires one.": "",
+1 -3
View File
@@ -68,7 +68,6 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "Προσβάσιμο σε όλους τους χρήστες",
"Account": "Λογαριασμός",
"Account Activation Pending": "Ενεργοποίηση Λογαριασμού Εκκρεμεί",
@@ -1298,7 +1297,6 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "Αυτόματη Δημιουργία Συνέχισης",
"Followed instructions perfectly": "Ακολούθησε τις οδηγίες τέλεια",
"Font Family": "",
"for placeholders": "",
"Force OCR": "",
"Forking chat...": "",
@@ -1403,6 +1401,7 @@
"Hide Hidden Files": "",
"Hide Model": "Απόκρυψη Μοντέλου",
"High": "",
"High Contrast Mode": "Λειτουργία Υψηλής Αντίθεσης",
"History": "",
"Home": "Αρχική",
"Host": "Διακομιστής",
@@ -2903,7 +2902,6 @@
"Use": "",
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "",
"Use a Gemini API key for image generation.": "",
"Use a local font family for the app interface.": "",
"Use a valid event name or pattern like user.*": "",
"Use a wider chat layout on large displays.": "",
"Use an API key when your ComfyUI server requires one.": "",
+1 -3
View File
@@ -68,7 +68,6 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "",
"Account": "",
"Account Activation Pending": "",
@@ -1298,7 +1297,6 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "",
"Followed instructions perfectly": "",
"Font Family": "",
"for placeholders": "",
"Force OCR": "",
"Forking chat...": "",
@@ -1403,6 +1401,7 @@
"Hide Hidden Files": "",
"Hide Model": "",
"High": "",
"High Contrast Mode": "",
"History": "",
"Home": "",
"Host": "",
@@ -2903,7 +2902,6 @@
"Use": "",
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "",
"Use a Gemini API key for image generation.": "",
"Use a local font family for the app interface.": "",
"Use a valid event name or pattern like user.*": "",
"Use a wider chat layout on large displays.": "",
"Use an API key when your ComfyUI server requires one.": "",
+1 -4
View File
@@ -68,7 +68,6 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "",
"Account": "",
"Account Activation Pending": "",
@@ -1298,7 +1297,6 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "",
"Followed instructions perfectly": "",
"Font Family": "",
"for placeholders": "",
"Force OCR": "",
"Forking chat...": "",
@@ -1403,6 +1401,7 @@
"Hide Hidden Files": "",
"Hide Model": "",
"High": "",
"High Contrast Mode": "",
"History": "",
"Home": "",
"Host": "",
@@ -2884,7 +2883,6 @@
"Upload Audio": "",
"Upload directory": "",
"Upload failed": "",
"Upload failed for {{failed}} of {{total}} files.": "",
"Upload files": "",
"Upload Files": "",
"Upload Model": "",
@@ -2904,7 +2902,6 @@
"Use": "",
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "",
"Use a Gemini API key for image generation.": "",
"Use a local font family for the app interface.": "",
"Use a valid event name or pattern like user.*": "",
"Use a wider chat layout on large displays.": "",
"Use an API key when your ComfyUI server requires one.": "",
+1 -3
View File
@@ -75,7 +75,6 @@
"Access List": "Lista de Permisos",
"Access prohibited": "",
"Access updated": "Permisos actualizados",
"Accessibility Mode": "",
"Accessible to all users": "Accesible para todos los usuarios",
"Account": "Cuenta",
"Account Activation Pending": "Activación de cuenta Pendiente",
@@ -1308,7 +1307,6 @@
"Follow up: {{question}}": "Seguimiento: {{question}}",
"Follow-Up Auto-Generation": "Seguimiento Auto-Generación",
"Followed instructions perfectly": "Siguió las instrucciones perfectamente",
"Font Family": "",
"for placeholders": "para marcadores de posición",
"Force OCR": "Forzar OCR",
"Forking chat...": "",
@@ -1413,6 +1411,7 @@
"Hide Hidden Files": "",
"Hide Model": "Ocultar Modelo",
"High": "Alto",
"High Contrast Mode": "Modo Alto Contraste",
"History": "Historial",
"Home": "Inicio",
"Host": "Host",
@@ -2917,7 +2916,6 @@
"Use": "Usar",
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "Usar el endpoint /v1/chat/completions en vez de /v1/audio/transcriptions para una (posible) mayor precisión.",
"Use a Gemini API key for image generation.": "",
"Use a local font family for the app interface.": "",
"Use a valid event name or pattern like user.*": "",
"Use a wider chat layout on large displays.": "",
"Use an API key when your ComfyUI server requires one.": "",
+1 -3
View File
@@ -68,7 +68,6 @@
"Access List": "Juurdepääsu nimekiri",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "Kättesaadav kõigile kasutajatele",
"Account": "Konto",
"Account Activation Pending": "Konto aktiveerimine ootel",
@@ -1298,7 +1297,6 @@
"Follow up: {{question}}": "Järelküsimus: {{question}}",
"Follow-Up Auto-Generation": "Järelküsimuste automaatne genereerimine",
"Followed instructions perfectly": "Järgis juhiseid täiuslikult",
"Font Family": "",
"for placeholders": "kohatäidete jaoks",
"Force OCR": "Sunni OCR",
"Forking chat...": "",
@@ -1403,6 +1401,7 @@
"Hide Hidden Files": "",
"Hide Model": "Peida mudel",
"High": "Kõrge",
"High Contrast Mode": "Kõrge kontrastsuse režiim",
"History": "Ajalugu",
"Home": "Avaleht",
"Host": "Host",
@@ -2903,7 +2902,6 @@
"Use": "Kasuta",
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "Kasuta /v1/chat/completions lõpp-punkti /v1/audio/transcriptions asemel potentsiaalselt parema täpsuse saavutamiseks.",
"Use a Gemini API key for image generation.": "",
"Use a local font family for the app interface.": "",
"Use a valid event name or pattern like user.*": "",
"Use a wider chat layout on large displays.": "",
"Use an API key when your ComfyUI server requires one.": "",
+1 -3
View File
@@ -68,7 +68,6 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "Erabiltzaile guztientzat eskuragarri",
"Account": "Kontua",
"Account Activation Pending": "Kontuaren Aktibazioa Zain",
@@ -1298,7 +1297,6 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "",
"Followed instructions perfectly": "Jarraibideak perfektuki jarraitu ditu",
"Font Family": "",
"for placeholders": "",
"Force OCR": "",
"Forking chat...": "",
@@ -1403,6 +1401,7 @@
"Hide Hidden Files": "",
"Hide Model": "",
"High": "",
"High Contrast Mode": "",
"History": "",
"Home": "",
"Host": "Ostalaria",
@@ -2903,7 +2902,6 @@
"Use": "",
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "",
"Use a Gemini API key for image generation.": "",
"Use a local font family for the app interface.": "",
"Use a valid event name or pattern like user.*": "",
"Use a wider chat layout on large displays.": "",
"Use an API key when your ComfyUI server requires one.": "",
+1 -3
View File
@@ -68,7 +68,6 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "قابل دسترسی برای همه کاربران",
"Account": "حساب کاربری",
"Account Activation Pending": "فعال\u200cسازی حساب در حال انتظار",
@@ -1298,7 +1297,6 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "تولید خودکار پیگیری",
"Followed instructions perfectly": "دستورالعمل ها را کاملا دنبال کرد",
"Font Family": "",
"for placeholders": "",
"Force OCR": "اجبار به OCR",
"Forking chat...": "",
@@ -1403,6 +1401,7 @@
"Hide Hidden Files": "",
"Hide Model": "پنهان کردن مدل",
"High": "بالا",
"High Contrast Mode": "حالت کنتراست بالا",
"History": "",
"Home": "خانه",
"Host": "میزبان",
@@ -2903,7 +2902,6 @@
"Use": "",
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "از نقطه پایانی /v1/chat/completions به جای /v1/audio/transcriptions برای دقت بالقوه بهتر استفاده کنید.",
"Use a Gemini API key for image generation.": "",
"Use a local font family for the app interface.": "",
"Use a valid event name or pattern like user.*": "",
"Use a wider chat layout on large displays.": "",
"Use an API key when your ComfyUI server requires one.": "",
+1 -3
View File
@@ -68,7 +68,6 @@
"Access List": "Pääsylista",
"Access prohibited": "Pääsy kielletty",
"Access updated": "Käyttöoikeus päivitetty",
"Accessibility Mode": "",
"Accessible to all users": "Käytettävissä kaikille käyttäjille",
"Account": "Tili",
"Account Activation Pending": "Tilin aktivointi odottaa",
@@ -1298,7 +1297,6 @@
"Follow up: {{question}}": "Jatkokysymys: {{question}}",
"Follow-Up Auto-Generation": "Jatkokysymysten automaattinen luonti",
"Followed instructions perfectly": "Noudatti ohjeita täydellisesti",
"Font Family": "",
"for placeholders": "",
"Force OCR": "Pakota OCR",
"Forking chat...": "Haarautetaan keskustelua...",
@@ -1403,6 +1401,7 @@
"Hide Hidden Files": "",
"Hide Model": "Piilota malli",
"High": "Korkea",
"High Contrast Mode": "Korkean kontrastin tila",
"History": "Historia",
"Home": "Koti",
"Host": "Palvelin",
@@ -2903,7 +2902,6 @@
"Use": "Käytä",
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "Käytä /v1/chat/completions-päätteitä/v1/audio/transcriptions-pääteen sijaan mahdollisesti paremman tarkkuuden saavuttamiseksi.",
"Use a Gemini API key for image generation.": "",
"Use a local font family for the app interface.": "",
"Use a valid event name or pattern like user.*": "Käytä kelvollista tapahtuman nimeä tai mallia, kuten user.*",
"Use a wider chat layout on large displays.": "Käytä laajempaa keskustelun asettelua suurilla näytöillä.",
"Use an API key when your ComfyUI server requires one.": "Käytä API-avainta, kun ComfyUI-palvelimesi vaatii sitä.",
+1 -3
View File
@@ -69,7 +69,6 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "",
"Account": "Account",
"Account Activation Pending": "",
@@ -1299,7 +1298,6 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "",
"Followed instructions perfectly": "",
"Font Family": "",
"for placeholders": "",
"Force OCR": "",
"Forking chat...": "",
@@ -1404,6 +1402,7 @@
"Hide Hidden Files": "",
"Hide Model": "",
"High": "",
"High Contrast Mode": "",
"History": "Kasaysayan",
"Home": "Tahanan",
"Host": "",
@@ -2906,7 +2905,6 @@
"Use": "",
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "",
"Use a Gemini API key for image generation.": "",
"Use a local font family for the app interface.": "",
"Use a valid event name or pattern like user.*": "",
"Use a wider chat layout on large displays.": "",
"Use an API key when your ComfyUI server requires one.": "",
+1 -3
View File
@@ -68,7 +68,6 @@
"Access List": "Atgongdarlisti",
"Access prohibited": "Atgongd bannað",
"Access updated": "Atgongd dagførd",
"Accessibility Mode": "",
"Accessible to all users": "Atkomuligt hjá øllum brúkarum",
"Account": "Konta",
"Account Activation Pending": "Virkjan av kontu bíðar",
@@ -1298,7 +1297,6 @@
"Follow up: {{question}}": "Uppfylging: {{question}}",
"Follow-Up Auto-Generation": "Sjálvvirkandi gerð av uppfylging",
"Followed instructions perfectly": "Fylgdi vegleiðingunum fullkomiliga",
"Font Family": "",
"for placeholders": "til plásshaldarar",
"Force OCR": "Tvinga OCR",
"Forking chat...": "Avgreinar kjatt...",
@@ -1403,6 +1401,7 @@
"Hide Hidden Files": "",
"Hide Model": "Fjal modell",
"High": "Høgt",
"High Contrast Mode": "Háur kontrastur",
"History": "Søga",
"Home": "Heim",
"Host": "Vertur",
@@ -2903,7 +2902,6 @@
"Use": "Nýt",
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "Nýt /v1/chat/completions-endpointið í staðin fyri /v1/audio/transcriptions fyri møguliga betri neyvleika.",
"Use a Gemini API key for image generation.": "Nýt ein Gemini API-lykil til myndagerð.",
"Use a local font family for the app interface.": "",
"Use a valid event name or pattern like user.*": "Nýt eitt gyldugt hendingarnavn ella mynstur sum user.*",
"Use a wider chat layout on large displays.": "Nýt eitt breiðari kjattsnið á stórum skermum.",
"Use an API key when your ComfyUI server requires one.": "Nýt ein API-lykil, tá ComfyUI-tænastarin krevur tað.",

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