Compare commits

...
77 Commits
Author SHA1 Message Date
Timothy Jaeryang Baek e8bdbd716b refac 2026-08-31 00:33:43 -04:00
Timothy Jaeryang Baek e96b6464b4 refac 2026-08-31 00:33:05 -04:00
Timothy Jaeryang Baek 7a11154182 refac 2026-08-31 00:32:46 -04:00
Classic298 be958d7b04 fix: a rejected ask_user call ending the turn with no reply (#29252)
* fix: a rejected ask_user call ending the turn with no reply

The documented behaviour of the built-in ask_user tool is that a call breaking its rules comes back to the model as an error. Instead the reply stopped there: the error was recorded as the tool result, the model was never asked again, and the user was left with a dead chat and no answer.

The rejection is now handed back like any other failed tool result, so the model sees it and can correct itself within the normal tool-call iteration limit. Any ordinary tool the model emitted in the same turn still runs.

A call rejected for arriving alongside other ask_user calls also left those siblings without a result, which the UI shows as a tool call stuck on "Executing..." forever. Every invalid call now gets its own result. Two ask_user calls on their own also reported the wrong reason, saying the call must be made by itself rather than that only one is allowed per turn.

Fixes #29077

* Keep the original ask_user validation order

Restores the pre-existing check order and the unchanged output id fallback, so this change only alters the return shape needed for staging, and trims a comment that narrated the lines below it.

* Correct the ask_user sibling-call error message

* Shorten the ask_user sibling-call error message

* Drop the untrue sibling-call claim from the ask_user error

The ask_user error text told the user and the model "The others ran.", but that sentence is written into the turn output before any sibling tool call has executed, so it can be plainly false. Under a saved chat with tool approval set to ask, the turn pauses right afterwards and the siblings sit at pending/queued, so the user reads "The others ran" directly above the approval prompt for tools that have not run, and reads it again beside the rejection result if they decline. When the model sends two ask_user calls and nothing else, nothing runs at all and the sentence is emitted twice.

The staging helper cannot see what happens to the sibling calls, so it no longer narrates it. The remaining two sentences hold in every flow: ask_user really is dropped from the executed calls whenever this error is set, and calling it on its own is always the right retry.
2026-08-31 00:17:21 -04:00
G30 95032b6c61 fix: let the model defaults capability and prompt suggestion sections scroll (#29235) 2026-08-31 00:11:34 -04:00
Timothy Jaeryang Baek 756241b34a refac 2026-08-31 00:11:13 -04:00
Timothy Jaeryang Baek 84d0940da1 refac 2026-08-31 00:11:04 -04:00
G30 9a669197c8 fix: let setting row controls shrink so long values do not squeeze the label (#29229) 2026-08-31 00:06:37 -04:00
Timothy Jaeryang Baek 09163ccc73 refac 2026-08-31 00:06:05 -04:00
Timothy Jaeryang Baek 2140c189e1 refac 2026-08-31 00:05:34 -04:00
Timothy Jaeryang Baek 81b9afb731 refac 2026-08-31 00:03:51 -04:00
Timothy Jaeryang Baek 64e6c9f010 refac 2026-08-30 23:56:01 -04:00
Timothy Jaeryang Baek 97c5f52bbc refac 2026-08-30 23:55:29 -04:00
Timothy Jaeryang Baek e4dbfb1276 refac 2026-08-30 23:50:30 -04:00
Timothy Jaeryang Baek aeb126b95d refac 2026-08-30 23:46:02 -04:00
Timothy Jaeryang Baek df495a7945 refac 2026-08-30 23:42:35 -04:00
G30 039976ef24 fix: unregister a sidebar folder from the registry when it unmounts (#29121) 2026-08-30 23:35:34 -04:00
Timothy Jaeryang Baek d5e35ea6f4 refac 2026-08-30 23:32:42 -04:00
Timothy Jaeryang Baek 6c2e0d3fe8 chore: format 2026-08-30 23:31:23 -04:00
Timothy Jaeryang Baek 7d694570aa refac 2026-08-30 21:47:28 -04:00
Timothy Jaeryang BaekandG30 b3ba6823a9 refac
Co-Authored-By: G30 <50341825+silentoplayz@users.noreply.github.com>
2026-08-30 21:41:03 -04:00
Timothy Jaeryang Baek ddc886fdc1 refac 2026-08-30 21:39:13 -04:00
Timothy Jaeryang Baek 49aab7451c refac 2026-08-30 21:36:17 -04:00
Classic298 d8133c905a fix: serve module scripts and wasm assets with the correct MIME type (#29139)
On Windows hosts the built-in code interpreter fails immediately with "Failed to fetch dynamically imported module: .../pyodide/pyodide.asm.mjs", and the browser console shows the server answered with a MIME type of "text/plain". Code execution is unusable for those users.

Python's mimetypes module reads the Windows registry after loading its own table, so a stray registry entry silently replaces the correct type for an extension and Starlette then labels the file with it. Browsers enforce strict MIME checking for module scripts and streaming WASM compilation, so the pyodide loader gets refused. The same workaround already existed for .js; this extends it to the two other extensions pyodide ships, and moves it out of the frontend-build branch so the unconditionally mounted /static assets are covered as well.

Fixes #29133
2026-08-30 21:32:31 -04:00
Timothy Jaeryang Baek 120409ef01 refac 2026-08-30 17:45:50 -04:00
Timothy Jaeryang Baek e4694f82eb refac 2026-08-30 17:45:18 -04:00
Timothy Jaeryang Baek b356b80f8c refac 2026-08-30 17:44:39 -04:00
Timothy Jaeryang Baek 492ccf3ac0 refac 2026-08-30 17:39:56 -04:00
Timothy Jaeryang Baek 78d8c9166f refac 2026-08-30 17:38:45 -04:00
Timothy Jaeryang Baek e250be48ee refac 2026-08-30 17:30:11 -04:00
Timothy Jaeryang Baek f0ffa7508e refac 2026-08-30 17:26:03 -04:00
Classic298 0e65c65cc7 fix: stop counted schedules being rewritten as one-offs on save (#29263)
* fix: stop labelling a counted schedule as a one-off

The schedule label treated any rule whose text contained COUNT=1 as a single run, so counts such as 10, 12 and 14 were shown as "Once" together with the date of the first run, on the automations list and on the automation page alike. The label now matches a count of exactly one.

This covers the two places that render the label. The schedule editor reads the count the same way and changes separately. Rules that carry a start date still fall through to the raw rule text, exactly as they already did without a count; that parsing gap changes separately too.

Verified in a browser against the same build without these lines: ten ordinary schedules render identically in both places, and a genuine single-run schedule is still labelled as one.

* fix: stop counted schedules being rewritten as one-offs on save

The schedule editor decides that a rule is a one-off by looking for the text COUNT=1 anywhere in it. A rule that runs ten times carries COUNT=10, which contains that text, so opening such an automation shows it as a single run and saving writes a genuine one-off rule back. One open and save is enough to silently turn a ten run schedule into a one run schedule, with whatever date happened to sit in the rule. The check now requires that no further digit follows, the same test the two schedule labels already use.

The same screens also failed to read counted rules at all. Both label helpers and the editor parser split the stored rule on semicolons after stripping the RRULE prefix, so when the rule carries a DTSTART line the first piece is that whole line and the frequency is never found. The automations list then printed the raw rule text where a human label belongs, and the editor fell back to a plain daily schedule, quietly discarding the weekly or monthly settings on the next save. All three sites now drop the DTSTART part before splitting. They split on whitespace, so the newline form and the space separated form are both handled, matching how the backend already strips it.
2026-08-30 16:12:43 -04:00
Classic298 b8f279b8fb perf: stabilize the model registry signature across workers (#29264)
The Redis-backed model registry skips its write when the content signature matches what is already stored. That skip has never worked across processes. Two of the values it hashes come out of Python sets, and set iteration order varies with each process's hash seed, so every worker computed a different signature for identical content and every worker rewrote the whole registry on every refresh.

Sorting both makes the signature depend on content alone. Measured on a 120 model registry, 522 KiB serialized: a refresh whose content already matches drops from GET, HKEYS, HSET and SET at 5.1 ms to a single GET at 2.2 ms per worker, and the 522 KiB write leaves the wire entirely.

Verified across 12 child processes with 12 distinct hash seeds: 12 different signatures before, 1 after. Filter execution order is unaffected, because the filter pipeline re-sorts by priority and id before running.
2026-08-30 16:12:31 -04:00
Timothy Jaeryang Baek cfa2d25317 refac 2026-08-30 12:42:13 -04:00
Timothy Jaeryang Baek b9765fe979 refac 2026-08-30 12:36:16 -04:00
Timothy Jaeryang Baek 22379ded1a refac 2026-08-30 12:22:32 -04:00
Timothy Jaeryang Baek 58a3fadbf3 refac 2026-08-30 12:13:06 -04:00
Timothy Jaeryang Baek 26f37426b7 refac 2026-08-30 12:09:34 -04:00
G30 6fa50a6558 fix: keep select dropdowns inside the viewport (#29226) 2026-08-30 12:06:27 -04:00
joaoback f76fd904b3 i18n: add pt-BR translations for newly added UI items and consistency pass (#29217)
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.
2026-08-30 11:58:03 -04:00
Aindriú Mac Giolla Eoin 1c59d2b1b0 i18n: Updated Irish translation (#29247) 2026-08-30 11:56:59 -04:00
Classic298 1f529c4eb3 fix: structured output renderer crashing on an empty output slot (#29250)
A chat could hard-fail in the browser with "TypeError: can't access property content" and stop rendering the assistant message until a reload.

Streamed response items and content parts were placed at the index the provider reports. That index is not bounded by the length of the array the client has built up, so an entry could land past the end and leave a gap behind. Spreading the array on the next event turned that gap into a real empty entry, and the renderer then dereferenced it while looking for message text.

Output items that would land past the end are now appended, since later events locate them by id anyway. Content and summary parts are padded up to the index instead, because a part carries no id and the text streamed for it is addressed by that same index. The renderer and the structured editor now skip an empty entry as well, so chats already saved in the broken state still display and edit.

Fixes #29244
2026-08-30 11:56:41 -04:00
Timothy Jaeryang Baek a93c508038 refac 2026-08-29 16:14:32 -04:00
Timothy Jaeryang Baek 348751b6a5 refac 2026-08-29 15:07:48 -04:00
Classic298 a5ea8b0b8a fix: stopping a response across instances on Redis Cluster (#29165)
On Redis Cluster deployments the stop button never stopped a running response when the request landed on a different instance than the one streaming it. The pub/sub listener that carries the stop signal between instances never managed to subscribe, so the command was published to a channel nobody was listening on.

The listener subscribes through a cluster client that connects lazily, and redis-py resolves the pub/sub node from a slot cache that is still empty at that point, which fails with a bare KeyError. Awaiting initialize() first fills that cache. It is a no-op on standalone and Sentinel clients, so nothing has to branch on the deployment type, and it stays inside the reconnect loop so a failover refreshes the cache instead of resubscribing against a stale one.

Before 0.11.1 the listener died on that first exception and cross-instance stop never worked at all. The reconnect loop added in 0.11.1 turned it into a startup window plus KeyError retry spam in the logs. Reported upstream as redis/redis-py#4296.

Fixes #19840
2026-08-29 15:00:52 -04:00
Classic298 26074e0a46 i18n: complete and correct German (de-DE) translations (#29179)
Fills the two remaining untranslated strings in the German catalog and corrects a number of existing entries.

The catalog addresses the user formally with "Sie" in over two hundred strings but had drifted to the informal "du" in around thirty, including "Wählen Sie ein Modell" sitting directly alongside "Wähle eine Option". Those now use "Sie", or the infinitive where the surrounding labels already use it. The "Du" chat bubble label and the two model-facing system prompts are deliberately left informal.

"Explored" carried a trailing ": " that the English source and every other translated locale lack. The summary text beside it only renders when there are tool calls or code interpreter runs, so a details group containing only reasoning items rendered a dangling "Untersucht:" in German.

"Persistent" was left in English beside its already translated sibling option "Ephemeral" ("Flüchtig"), leaving a half translated storage dropdown. The calendar tool description promised listing, searching, creating, updating and deleting calendars, when it acts on calendar events.

The remaining changes fix compounds written as two words ("Skill Beschreibung", "Datei upload", "Audio tag"), replace the non-word "managen" with "verwalten", correct a grammatical gender and a plural, and normalize the only two ellipsis characters in the file to the three periods used by every other entry.

Only de-DE is touched. No catalog regeneration, no other locale files.
2026-08-29 14:53:42 -04:00
G30 fe947f7e68 fix: prevent valve inputs from overflowing the valves modal (#29203) 2026-08-29 14:52:29 -04:00
TOM b2050bcd4d Polish translation update (#29184) 2026-08-28 20:31:09 -04:00
Classic298 83556188f5 fix: hide file preview zoom buttons on touch devices (#29176)
On a phone the file preview zoom bar shows plus and minus buttons that duplicate pinch-to-zoom and sit on top of an already small preview. They are hidden on coarse pointers now, so touch users get the preview area back and still zoom the way they expect.

The check is the pointer type rather than the viewport width, because what matters is whether the person can pinch, not how narrow their window is. A narrow desktop window keeps the buttons, a tablet does not.

The zoom percentage doubles as the reset control and has no gesture equivalent, so it stays visible, along with all page and slide navigation. Keyboard zoom is unaffected. Word document previews are left alone: they have no pinch support at all, so hiding their buttons would remove zooming entirely.

Fixes #29152
2026-08-28 18:46:53 -04:00
Timothy Jaeryang Baek a235bf076c refac 2026-08-28 17:50:37 -04:00
spoofy afb9f3c2d1 fix: name the Folder modal close button (WCAG 4.1.2) (#29160) 2026-08-28 17:49:22 -04:00
Timothy Jaeryang Baek 233681464f refac 2026-08-28 13:45:56 -04:00
Timothy Jaeryang Baek 797b4c51d1 refac 2026-08-28 13:40:45 -04:00
Timothy Jaeryang Baek 99cb8257f7 refac 2026-08-28 13:34:10 -04:00
G30 12d4b4ac59 fix: give the underline marked extension a renderer (#29118) 2026-08-28 12:23:06 -04:00
Classic298 88bbe4e1d7 perf: skip pipeline filter session setup when no filters exist (#29146)
process_pipeline_inlet_filter() and its outlet counterpart construct and tear
down an aiohttp ClientSession, with its own connector and cookie jar, on
every chat completion and every task generation request just to iterate an
empty filter list. On deployments without pipelines, which is the default,
that is wasted setup on every message.

Both functions now return the payload untouched before the session is
created when there is nothing to call. The per-call saving is small, a few
microseconds of object construction per request on the pinned aiohttp; the
point is that requests stop paying setup for a feature that is not
configured.
2026-08-28 12:22:23 -04:00
Timothy Jaeryang Baek e6031ea6da refac 2026-08-28 12:18:37 -04:00
Timothy Jaeryang BaekandG30 62e5eab60b refac
Co-Authored-By: G30 <50341825+silentoplayz@users.noreply.github.com>
2026-08-28 12:18:23 -04:00
fengyufeiyang-dotandfengyufeiyang-dot 95492cc0b4 i18n: complete zh-CN translation for Open WebUI v0.11.1 (#29151)
Co-authored-by: fengyufeiyang-dot <fengyufeiyang-dot@users.noreply.github.com>
2026-08-28 11:28:55 -04:00
Classic298andClaude b3591e60b1 fix: apply the selected model's tools and skills when starting a chat from the sidebar (#29058)
Clicking a pinned model in the sidebar started a new chat with that model but sent the previous model's tool_ids and skill_ids, and they stayed until the page was reloaded. The model dropdown was unaffected, and so was temporary chat.

A pinned entry links to /?model=<id>, which runs the new-chat path. That path applied the model's defaults and then restored the composer draft over the top, and the draft still held the selection from whichever model was active when it was written. The draft save is debounced, so the stale value was reliably the one read back.

The draft is now restored before the defaults are applied, so the model always decides which tools and skills are active while the unsent prompt, files and approval mode are still kept. Starting a new chat with several models selected now clears the selection instead of carrying the draft's over, since there are no per-model defaults to apply in that case.

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-27 19:24:19 -04:00
Timothy Jaeryang Baek 17cc566707 refac 2026-08-27 19:23:44 -04:00
Classic298 9277879bcf i18n: complete the German (de-DE) translation (#29108)
173 keys in the de-DE catalog still had empty values, so German users saw those strings rendered in English: the whole terminal file browser, the tool-call approval prompts, chat variables, the model manager, calendar navigation, the accessibility labels for zoom, camera and call controls, and several admin settings panels.

Each string was translated against its actual call site rather than in isolation, so the grammatical form fits the widget it renders in (imperatives on buttons, nouns on labels and select options, participles on toasts). Terminology and the formal "Sie" register follow what the catalog already uses elsewhere, and technical literals were left alone on purpose: the CSV header hint mirrors the file that "Download CSV Template" actually produces, and the MIME pattern, the snake_case variable placeholder and the product names stay verbatim.

Only value strings changed; key order and formatting are untouched.
2026-08-27 18:26:04 -04:00
Classic298andClaude Opus 5 3749e7dc74 fix: stop streaming responses breaking on a duplicate output key (#29053)
* fix: stop streaming responses breaking on a duplicate output key

With reasoning-capable models the chat froze mid-stream: the first chunk of the answer appeared, nothing followed, and the whole message only showed up once generation finished. The browser console showed a Svelte each_key_duplicate error.

When a stream event addresses an output slot past the end of the array, the missing slots were filled with the event's own item, id included, so a gap of two left two entries claiming the same id. The next chunk for that item was matched by id, landed in the first of the two, and the rendered list ended up with two items sharing a key, which Svelte refuses to update.

Only the addressed slot now takes the event's item, and the slots before it are anonymous placeholders. Replayed the reported event sequence against the real code: keys are unique again and the chunks stay in order instead of being split across the copies.

* fix: stream reasoning deltas when the provider also sends reasoning_details

Providers such as OpenRouter emit reasoning_details alongside the reasoning
text on the same delta. Merging those details cleared the pending event
unconditionally, discarding the response.reasoning_text.delta that had just
been built, so the client received no reasoning until the response completed
and the thinking block only appeared after generation finished.

The event is now only dropped when the details were all there was to report.
Details persistence is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uuEg4AXPs9zE3vVUfN1Fj

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-27 18:25:49 -04:00
Classic298andClaude 87bed3f0b3 fix: stream post-tool-call thinking into the Thoughts section (#29052)
After a tool call, the model's thinking was streamed into the chat as if it were the main response, and only jumped into the collapsed Thoughts section once the turn finished. Every further tool call repeated it.

Each tool round appended an empty placeholder message item to the output and sent it to the browser, then dropped it again from the copy used to offset the next round's item indices. The browser therefore held one item more than the backend counted, so the first thinking chunk of the next round was written into that leftover message item and rendered as normal text until the finished output replaced it.

The placeholder is removed. It was never needed: a message item is already created when actual content arrives, and dropping it also stops an empty assistant message being sent back to the model on the follow-up request.

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-27 16:56:00 -04:00
G30 fa7de50c60 refac: align the admin Users tab counts with the workspace tab count behavior (#29080) 2026-08-27 16:45:34 -04:00
G30 1c5128c4aa fix: keep a calendar event visible after its date is moved (#29085) 2026-08-27 16:44:58 -04:00
Classic298andClaude 0afe69e1a7 fix: stop deleting user text that looks like a skill mention (#29051)
Any `<$...>` run in a chat message was treated as an inline skill mention and removed before the request reached the model, so text like `<$(=MonthStart($(vMaxMonthEndINC)))"}, [Registration day] >` silently vanished mid-message and the model only saw the part before it.

The mention regexes accepted any character except `|` and `>` as the skill id, so they matched far more than real mentions. Skill ids are already validated as `[a-z0-9_-]+` when a skill is created, so both regexes now require that charset. Ordinary text passes through untouched while `<$id>`, `<$id|Label>` and `</id|Label>` still resolve and strip as before.

Verified against the reported message (now preserved verbatim) and the three mention forms.

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-27 16:44:33 -04:00
Aleix Dorca f7db201ff5 i18n: Update catalan translation.json (new strings and typos) (#29043) 2026-08-27 16:43:58 -04:00
G30 98c0ac9355 fix: stop the workspace model id and timestamp overlaying the model name on narrow screens (#29084) 2026-08-27 16:43:36 -04:00
G30 aad1072874 refac: reset the shared chats modal search when the modal closes (#29082) 2026-08-27 16:43:23 -04:00
Timothy Jaeryang Baek ae549d3e4a refac 2026-08-27 16:41:43 -04:00
Timothy Jaeryang Baek 9d95a0148b refac 2026-08-27 16:06:25 -04:00
Timothy Jaeryang Baek 061fb43432 refac 2026-08-27 15:34:20 -04:00
Timothy Jaeryang Baek 240c795efe refac 2026-08-27 13:12:55 -04:00
Timothy Jaeryang Baek 56d296ef1b refac 2026-08-25 21:45:34 -04:00
joaoback 2bc122e05e i18n: add pt-BR translations for newly added UI items and consistency pass (#29029)
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.
2026-08-25 21:44:12 -04:00
G30 673938fd49 Merge pull request #29037 from silentoplayz/fix/admin-models-disabled-hidden
fix: restore visibility of disabled models visible in the admin Models list
2026-08-25 21:43:51 -04:00
141 changed files with 5256 additions and 4015 deletions
+48 -89
View File
@@ -1,5 +1,5 @@
name: Bug Report
description: Create a detailed bug report to help us improve Open WebUI.
description: Tell us what broke in Open WebUI.
title: 'issue: '
labels: ['bug', 'triage']
body:
@@ -8,35 +8,28 @@ body:
value: |
# Bug Report
## Important Notes
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.
- **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.**
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`.
- 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!**
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.
- **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).
Security vulnerabilities must not be reported publicly. Use the [GitHub security page](https://github.com/open-webui/open-webui/security) instead.
- type: checkboxes
id: issue-check
attributes:
label: Check Existing Issues
description: Confirm that you’ve checked for existing reports before submitting a new one.
label: Before Submitting
options:
- label: I have searched for any existing and/or related issues.
- label: I searched open and closed issues and discussions for an existing report.
required: true
- label: I have searched for any existing and/or related discussions.
- label: I checked whether this is already fixed on the `dev` branch or latest source.
required: true
- 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!).
- label: I understand that maintainers want a well-written issue before any code pull request.
required: true
- label: I have checked whether this issue is already fixed on the `dev` branch or in the latest source.
- label: I am using the latest available version of Open WebUI for my install method.
required: true
- label: I am using the latest version of Open WebUI.
- label: This is not a security vulnerability.
required: true
- type: dropdown
@@ -45,9 +38,9 @@ body:
label: Installation Method
description: How did you install Open WebUI?
options:
- Git Clone
- Pip Install
- Docker
- Pip Install
- Git Clone
- Other
validations:
required: true
@@ -56,67 +49,51 @@ body:
id: open-webui-version
attributes:
label: Open WebUI Version
description: Specify the version (e.g., v0.11.0)
description: Specify the version, commit, or image tag.
placeholder: v0.11.0, dev commit SHA, or Docker tag
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 (e.g., Windows 11, macOS Tahoe, Ubuntu 26.04, Debian 13)
description: Specify the OS and version.
placeholder: Windows 11, macOS Tahoe, Ubuntu 26.04, Debian 13
validations:
required: true
- type: input
id: browser
attributes:
label: Browser (if applicable)
description: Specify the browser/version (e.g., Chrome 151.0, Firefox 153.0.3)
label: Browser
description: If the bug appears in the browser, include browser and version.
placeholder: Chrome 151.0, Firefox 153.0.3
validations:
required: false
- type: checkboxes
id: confirmation
- type: input
id: ollama-version
attributes:
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
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
- type: textarea
id: expected-behavior
attributes:
label: Expected Behavior
description: Describe what should have happened.
description: What should have happened?
validations:
required: true
@@ -124,7 +101,7 @@ body:
id: actual-behavior
attributes:
label: Actual Behavior
description: Describe what actually happened.
description: What actually happened?
validations:
required: true
@@ -132,32 +109,21 @@ body:
id: reproduction-steps
attributes:
label: Steps to Reproduce
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.**
description: Include the exact commands, settings, URLs, model/provider setup, and user actions needed to hit the bug.
placeholder: |
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.
1. Start Open WebUI with ...
2. Configure ...
3. Open ...
4. Click ...
5. See ...
validations:
required: true
- type: textarea
id: logs-screenshots
attributes:
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.'
label: Logs, Screenshots, and Config
description: Include relevant browser console logs, server/container logs, screenshots, and configuration. If something does not apply, say so.
validations:
required: true
@@ -165,13 +131,6 @@ body:
id: additional-info
attributes:
label: Additional Information
description: Provide any extra details that may assist in understanding the issue.
description: Anything else that might help us understand the report.
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!
+40 -45
View File
@@ -1,78 +1,73 @@
name: Feature Request
description: Suggest a new feature or improvement
description: Describe what you would like Open WebUI to support.
title: 'feat: '
labels: ['triage']
body:
- type: markdown
attributes:
value: |
## Before Submitting
# Feature Request
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.
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.
### Scope Guidelines
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.
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.
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.
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.
Security vulnerabilities must not be reported publicly. Use the [GitHub security page](https://github.com/open-webui/open-webui/security) instead.
- type: checkboxes
id: existing-issue
id: existing-request
attributes:
label: Check Existing Issues
description: Confirm you have searched for similar requests.
label: Before Submitting
options:
- label: I have searched all existing **open AND closed** issues and discussions and found none comparable to my request.
- label: I searched open and closed issues and discussions for an existing request.
required: true
- label: I have checked whether this request is already implemented on the `dev` branch or in the latest source.
- label: I checked whether this already exists on the `dev` branch or latest source.
required: true
- 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.
- 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.
required: true
- type: textarea
id: problem-description
attributes:
label: Problem Description
description: Is this related to a problem? Describe the pain point clearly.
placeholder: "e.g., I'm frustrated when..."
label: Problem
description: What is missing, frustrating, confusing, or unnecessarily hard today?
placeholder: "I'm trying to..., but..."
validations:
required: true
- type: textarea
id: solution-description
id: desired-behavior
attributes:
label: Proposed Solution
description: Describe what you would like to happen.
label: Desired Behavior
description: What would you like to happen instead?
placeholder: "I would like Open WebUI to..."
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 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.
label: Alternatives or Workarounds
description: What have you tried instead, if anything?
validations:
required: false
+53 -85
View File
@@ -1,126 +1,94 @@
<!--
⚠️ 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.
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.
-->
# Pull Request Checklist
# Pull Request
### Do not open a pull request as the first step.
Thanks for wanting to improve Open WebUI. The most useful contribution is usually a clear, well-written Issue, not an unsolicited code pull request.
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.
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 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.
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.
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.
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.
Opening an Issue or Discussion does not mean a PR is the right next step. Maintainers will confirm when a PR would be useful.
Unsolicited PRs may be closed without review, especially when they introduce product, architecture, compatibility, dependency, or maintenance decisions that have not been discussed.
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.
## Checklist
Unsolicited PRs may be closed without review. Contributors with a history of successful merged PRs may be given more latitude.
- [ ] 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.
<!--
### ⚠️ Important: Your PR is a contribution, not a guarantee of merge.
## Title Prefix
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.
Use one of the following prefixes:
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.
- **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
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.
-->
## Summary
**Before submitting, make sure you've checked and filled out the following:**
Describe the change, the problem it solves, and the impact on users.
- [ ] **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
## Testing
# Changelog Entry
List the exact manual checks you ran. Include commands, setup details, screenshots, or recordings where helpful.
### Description
- [Describe the changes, including motivation and impact]
## Changelog Entry
### Added
- [New features, functionalities, or additions]
-
### Changed
- [Changes, updates, refactorings, or optimizations]
### Deprecated
- [Deprecated functionality or features]
### Removed
- [Removed features, files, or functionalities]
-
### Fixed
- [Bug fixes or corrections]
-
### Removed
-
### Security
- [Security-related changes or vulnerability fixes]
-
### Breaking Changes
- **BREAKING CHANGE**: [Changes affecting compatibility or functionality]
-
---
## Additional Context
### Additional Information
Add anything maintainers should know before review.
- [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
## Contributor License Agreement
<!--
🚨 DO NOT DELETE THE TEXT BELOW 🚨
Keep the "Contributor License Agreement" confirmation text intact.
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
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.
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.
-->
- [ ] 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.
+12 -2
View File
@@ -311,7 +311,6 @@ 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:
@@ -403,5 +402,16 @@ jobs:
[ -z "$TAG" ] && continue
DEST="${DOCKERHUB_IMAGE}:${TAG}"
echo " -> ${DEST}"
docker buildx imagetools create -t "${DEST}" "${SOURCE}"
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
done <<< "${{ steps.tags.outputs.tags }}"
+1 -1
View File
@@ -3,7 +3,6 @@ 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.',
@@ -121,6 +120,7 @@ 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,6 +2,7 @@ from __future__ import annotations
import logging
import os
import re
import sys
from contextlib import asynccontextmanager, contextmanager
from datetime import datetime, timedelta, timezone
@@ -321,6 +322,45 @@ 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')
+5 -1
View File
@@ -2980,6 +2980,11 @@ 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')
@@ -3025,7 +3030,6 @@ 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')
+34 -2
View File
@@ -4,10 +4,12 @@ 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 pydantic import BaseModel, ConfigDict, Field
from open_webui.utils.automations import rrule_interval_seconds
from pydantic import BaseModel, ConfigDict, Field, field_validator
from sqlalchemy import (
JSON,
BigInteger,
@@ -26,6 +28,7 @@ from sqlalchemy import (
from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
MIN_CALENDAR_RRULE_INTERVAL_SECONDS = 24 * 60 * 60
####################
@@ -191,6 +194,20 @@ 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
@@ -207,6 +224,20 @@ 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'
@@ -533,7 +564,8 @@ 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.end_at.isnot(None)
& ((CalendarEvent.end_at > start) | (CalendarEvent.start_at >= start)),
)
),
# Recurring: fetch all (expansion in Python)
+4 -3
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,8 +334,9 @@ 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 LOWER(t.value) = :tag_val)'
"EXISTS (SELECT 1 FROM json_each(prompt.tags) t WHERE t.value LIKE :tag_val ESCAPE '\\')"
)
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)
+3 -1
View File
@@ -367,7 +367,9 @@ 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():
query = select(User).where(or_(sub_expr == sub, sub_expr == int(sub)))
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))
row = (await session.execute(query)).scalars().first()
return UserModel.model_validate(row) if row else None
+22 -17
View File
@@ -552,7 +552,12 @@ 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, url
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'),
}
@router.post('/generations')
@@ -668,8 +673,8 @@ async def image_generations(
else:
image_data, content_type = await get_image_data(image['b64_json'])
_, url = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
images.append({'url': url})
_, image_file = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
images.append(image_file)
return images
elif image_config.IMAGE_GENERATION_ENGINE == 'gemini':
@@ -712,21 +717,21 @@ async def image_generations(
if model.endswith(':predict'):
for image in res['predictions']:
image_data, content_type = await get_image_data(image['bytesBase64Encoded'])
_, url = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
images.append({'url': url})
_, image_file = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
images.append(image_file)
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'])
_, url = await upload_image(
_, image_file = await upload_image(
request,
image_data,
content_type,
{**data, **metadata},
user,
)
images.append({'url': url})
images.append(image_file)
return images
@@ -776,14 +781,14 @@ async def image_generations(
headers,
trusted_base_url=image_config.COMFYUI_BASE_URL,
)
_, url = await upload_image(
_, image_file = await upload_image(
request,
image_data,
content_type,
{**form_data.model_dump(exclude_none=True), **metadata},
user,
)
images.append({'url': url})
images.append(image_file)
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
@@ -823,14 +828,14 @@ async def image_generations(
for image in res['images']:
image_data, content_type = await get_image_data(image)
_, url = await upload_image(
_, image_file = await upload_image(
request,
image_data,
content_type,
{**data, 'info': res['info'], **metadata},
user,
)
images.append({'url': url})
images.append(image_file)
return images
except Exception as e:
error = e
@@ -1039,8 +1044,8 @@ async def image_edits(
else:
image_data, content_type = await get_image_data(image['b64_json'])
_, url = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
images.append({'url': url})
_, image_file = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
images.append(image_file)
return images
elif image_config.IMAGE_EDIT_ENGINE == 'gemini':
@@ -1089,14 +1094,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'])
_, url = await upload_image(
_, image_file = await upload_image(
request,
image_data,
content_type,
{**data, **metadata},
user,
)
images.append({'url': url})
images.append(image_file)
return images
@@ -1173,14 +1178,14 @@ async def image_edits(
headers,
trusted_base_url=image_config.IMAGES_EDIT_COMFYUI_BASE_URL,
)
_, url = await upload_image(
_, image_file = await upload_image(
request,
image_data,
content_type,
{**form_data.model_dump(exclude_none=True), **metadata},
user,
)
images.append({'url': url})
images.append(image_file)
return images
except Exception as e:
+6
View File
@@ -69,6 +69,9 @@ 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')
@@ -132,6 +135,9 @@ 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')
+32 -3
View File
@@ -938,20 +938,26 @@ 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):
async def _emit_channel_update(
content: str,
done: bool = False,
output: list | None = None,
data: dict | 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_form = MessageForm(content=content, data={'output': output} if output else None)
update_data = data or ({'output': output} if output else None)
update_form = MessageForm(content=content, data=update_data)
if done:
# Merge done flag into existing meta (preserve model_id etc.)
existing_meta = msg.meta or {}
update_form = MessageForm(
content=content,
data={'output': output} if output else None,
data=update_data,
meta={**existing_meta, 'done': True},
)
@@ -1000,6 +1006,29 @@ 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,6 +32,9 @@ 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
+12 -2
View File
@@ -389,11 +389,16 @@ 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', 'url': img['url']} for img in images]
image_files = [{'type': 'image', **img} 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:
@@ -457,11 +462,16 @@ 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', 'url': img['url']} for img in images]
image_files = [{'type': 'image', **img} 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:
+65 -60
View File
@@ -6,17 +6,20 @@ from open_webui.utils.json_codec import JSONCodec
ASK_USER_NAME = 'ask_user'
def get_ask_user_tool_call(tool_calls: list[dict]) -> tuple[dict | None, str | None]:
def get_ask_user_tool_calls(tool_calls: list[dict]) -> tuple[list[dict], 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, None
return [], None
if len(tool_calls) != 1:
return ask_user_calls[0], 'Error: ask_user must be called by itself after research.'
return (
ask_user_calls,
'Error: ask_user must be the only tool call, so it did not run. Call ask_user on its own.',
)
if len(ask_user_calls) != 1:
return ask_user_calls[0], 'Error: only one ask_user call is allowed per turn.'
return ask_user_calls[0], None
return ask_user_calls, 'Error: only one ask_user call is allowed per turn.'
return ask_user_calls, None
def normalize_ask_user_request(arguments: dict) -> dict:
@@ -77,68 +80,70 @@ def normalize_ask_user_request(arguments: dict) -> dict:
}
def stage_ask_user_tool_call(
def stage_ask_user_tool_calls(
tool_calls: list[dict],
output: list[dict],
make_output_id: Callable[[str], str],
) -> dict | None:
tool_call, error = get_ask_user_tool_call(tool_calls)
if not tool_call:
return None
) -> tuple[bool, str | None]:
ask_user_calls, error = get_ask_user_tool_calls(tool_calls)
if not ask_user_calls:
return False, None
call_id = tool_call.get('id') or make_output_id('fc')
raw_arguments = tool_call.get('function', {}).get('arguments', '{}')
arguments = raw_arguments
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
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)
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 {'call_id': call_id, 'error': error, 'item': 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',
}
)
return True, error
+13 -14
View File
@@ -8,6 +8,7 @@ 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__)
@@ -25,18 +26,17 @@ 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:
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)
return dt.datetime.fromtimestamp(timestamp_ns / 1_000_000_000, tz=user_timezone).replace(tzinfo=None)
range_start = to_local_datetime(range_start_ns)
range_end = to_local_datetime(range_end_ns)
@@ -44,10 +44,11 @@ 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(rrule_str, dtstart=original_start, ignoretz=True)
rule = rrulestr(rule_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]
@@ -56,13 +57,13 @@ def expand_recurring_event(
duration_ns = (original_end_ns - original_start_ns) if original_end_ns else None
instances = []
occurrence_start = rule.after(scan_start, inc=True)
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
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)
instance_start_ns = int(occurrence_start.replace(tzinfo=user_timezone).timestamp() * 1_000_000_000)
if instance_start_ns >= range_start_ns:
instance = {
@@ -73,8 +74,6 @@ 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 allowed_keys:
for field_key in sorted(allowed_keys):
if field_key in definition:
field[field_key] = definition[field_key]
+19 -2
View File
@@ -54,6 +54,16 @@ _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
@@ -67,7 +77,13 @@ 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 = await response.read()
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)
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}'
@@ -86,13 +102,14 @@ 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_url = await upload_image(
_, image_file = await upload_image(
request,
image_data,
content_type,
metadata,
user,
)
image_url = image_file['url']
return image_url
return None
+13 -1
View File
@@ -10,10 +10,16 @@ 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:
@@ -29,6 +35,12 @@ 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 {}))
@@ -57,7 +69,7 @@ async def resolve_filter_pipeline(request, model: dict, enabled_filter_ids: list
if not ENABLE_PLUGINS:
return [], []
active_filters = await Functions.get_active_filter_ids()
active_filters = await get_filter_context(request).get_active_filters()
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)}
+32 -39
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_call
from open_webui.utils.ask_user import stage_ask_user_tool_calls
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,12 +258,7 @@ 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 tool_function_params.get('inline') is not True
or not isinstance(tool_result, dict)
or tool_result.get('exists') is False
):
if tool_function_name != 'display_file' or not isinstance(tool_result, dict) or tool_result.get('exists') is False:
return None
tool_id = (tool or {}).get('tool_id', '')
@@ -284,7 +279,7 @@ def build_terminal_file_tool_result(
**tool_result,
'type': 'file',
'source': 'open_terminal',
'displayed': True,
**({'displayed': True} if tool_function_params.get('inline') is True else {}),
'terminal_selector': terminal_selector,
**({'terminal_id': terminal_id} if terminal_id else {}),
**({'terminal_url': server_url} if server_url and not terminal_id else {}),
@@ -1771,6 +1766,12 @@ 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:
@@ -1815,10 +1816,7 @@ 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={
'chat_id': metadata.get('chat_id', None),
'message_id': metadata.get('message_id', None),
},
metadata=image_metadata,
user=user,
)
@@ -1836,7 +1834,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
'files': [
{
'type': 'image',
'url': image['url'],
**image,
}
for image in images
]
@@ -1926,10 +1924,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
images = await image_generations(
request=request,
form_data=CreateImageForm(**{'prompt': prompt}),
metadata={
'chat_id': metadata.get('chat_id', None),
'message_id': metadata.get('message_id', None),
},
metadata=image_metadata,
user=user,
)
@@ -1947,7 +1942,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
'files': [
{
'type': 'image',
'url': image['url'],
**image,
}
for image in images
]
@@ -2265,7 +2260,8 @@ def sanitize_tool_pairs(messages: list[dict]) -> list[dict]:
return sanitized
SKILL_MENTION_RE = re.compile(r'<(?:\$([^|>]+)(?:\|[^>]*)?|/([^|>]+)\|[^>]*)>')
# 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_-]+)\|[^>]*)>')
def _get_text_parts(message: dict) -> list[str]:
@@ -2287,7 +2283,7 @@ def extract_skill_ids_from_messages(messages: list[dict]) -> set[str]:
return ids
SKILL_MENTION_STRIP_RE = re.compile(r'<(?:\$[^|>]+(?:\|([^>]*))?|/[^|>]+\|([^>]*))>')
SKILL_MENTION_STRIP_RE = re.compile(r'<(?:\$[a-z0-9_-]+(?:\|([^>]*))?|/[a-z0-9_-]+\|([^>]*))>')
def strip_skill_mentions(messages: list[dict]) -> None:
@@ -5222,7 +5218,12 @@ async def streaming_chat_response_handler(response, ctx):
reasoning_detail_items,
)
await save_current_response_stream()
data = None
# 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
if value:
if (
@@ -5530,12 +5531,14 @@ async def streaming_chat_response_handler(response, ctx):
tool_call_iterations += 1
response_tool_calls = tool_calls.pop(0)
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
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:
if is_saved_chat_id(metadata.get('chat_id')) and metadata.get('message_id'):
await pause_for_tool_approval(
metadata['chat_id'],
@@ -5567,7 +5570,8 @@ async def streaming_chat_response_handler(response, ctx):
tool_approval_mode = metadata.get('params', {}).get('tool_approval_mode', 'full')
if (
tool_approval_mode == 'ask'
response_tool_calls
and tool_approval_mode == 'ask'
and is_saved_chat_id(metadata.get('chat_id'))
and metadata.get('message_id')
):
@@ -5789,17 +5793,6 @@ 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,6 +374,8 @@ 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:
+16 -7
View File
@@ -148,15 +148,15 @@ async def build_tool_server_headers(
cookies = {}
if auth_type == 'bearer':
headers['Authorization'] = f'Bearer {connection.get("key", "")}'
headers.update(bearer_auth_header(connection.get('key', '')))
elif auth_type == 'session':
cookies = request.cookies if hasattr(request, 'cookies') else {}
headers['Authorization'] = f'Bearer {request.state.token.credentials}'
headers.update(bearer_auth_header(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['Authorization'] = f'Bearer {oauth_token.get("access_token", "")}'
headers.update(bearer_auth_header(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['Authorization'] = f'Bearer {oauth_token.get("access_token", "")}'
headers.update(bearer_auth_header(oauth_token.get('access_token', '')))
except Exception as e:
log.error(f'Error getting OAuth token: {e}')
@@ -1338,7 +1338,13 @@ 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)
request.app.state.TERMINAL_SERVERS = terminal_servers
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
except Exception as e:
log.error(f'Error fetching terminal_servers from Redis: {e}')
@@ -1362,7 +1368,10 @@ async def get_terminal_tools(
- Builds callables that route through the terminal proxy
"""
connections = await Config.get('terminal_server.connections', []) or []
connection = next((c for c in connections if c.get('id') == terminal_id), None)
connection = next(
(terminal_connection for terminal_connection in connections if terminal_connection.get('id') == terminal_id),
None,
)
if connection is None:
raise RuntimeError(f"Terminal server '{terminal_id}' not found")
if not connection.get('enabled', True):
@@ -1374,7 +1383,7 @@ async def get_terminal_tools(
# Find the cached spec data for this terminal
terminal_servers = await get_terminal_servers(request)
server_data = next((s for s in terminal_servers if s.get('id') == terminal_id), None)
server_data = next((server for server in terminal_servers if server.get('id') == terminal_id), None)
if server_data is None:
raise RuntimeError(f"Terminal server '{terminal_id}' is unavailable")
+91 -29
View File
@@ -108,23 +108,23 @@ textarea::-webkit-scrollbar-corner {
}
.input-prose {
@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;
@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;
}
.input-prose-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;
@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;
}
.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-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;
@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;
}
.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-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;
@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;
}
.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-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;
@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;
}
.markdown a {
@@ -173,6 +173,7 @@ 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;
@@ -186,6 +187,10 @@ select {
-webkit-appearance: none;
}
.dark select {
color-scheme: dark;
}
.dark select:not([class*='bg-transparent']) {
@apply bg-gray-900 text-gray-300;
}
@@ -865,77 +870,134 @@ body {
padding-bottom: 2rem; /* space for the bottom toolbar */
}
/* High Contrast Mode: placeholders bottom out at 1.58:1 (WCAG 1.4.3 wants 4.5:1). */
/* Accessibility 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-600);
color: var(--color-gray-700);
}
html.high-contrast.dark :is(input, textarea)::placeholder {
color: var(--color-gray-500);
color: var(--color-gray-300);
}
}
/* High Contrast Mode: muted text fails WCAG 1.4.3 (2.07:1 light, 3.12:1 dark).
/* Accessibility 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-600);
color: var(--color-gray-700);
}
html:where(.high-contrast).dark .dark\:text-gray-600 {
color: var(--color-gray-400);
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;
}
/* Elements that hover to a lighter grey than the new resting colour. */
html:where(.high-contrast):not(:where(.dark)) .hover\:text-gray-500:hover {
color: var(--color-gray-800);
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);
}
}
/* High Contrast Mode: gray-500 muted text is 2.77:1 in light (WCAG 1.4.3 wants 4.5:1).
/* Accessibility 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)) .text-gray-500 {
color: var(--color-gray-600);
html:where(.high-contrast):not(:where(.dark)) :is(.text-gray-500, .text-gray-600) {
color: var(--color-gray-700);
}
}
html.high-contrast:not(.dark) :is(.app-muted, .app-icon-muted, .tiptap table) {
color: var(--color-gray-600);
color: var(--color-gray-700);
}
/* High Contrast Mode: the lightest muted pair, 1.58:1 in light and 2.14:1 in dark.
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.
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-600);
color: var(--color-gray-700);
}
html:where(.high-contrast).dark .dark\:text-gray-700 {
color: var(--color-gray-400);
html:where(.high-contrast).dark :is(.dark\:text-gray-700, .dark\:text-gray-800) {
color: var(--color-gray-300);
}
/* Hover states that land lighter than the new resting colour. */
html:where(.high-contrast):not(:where(.dark)) .hover\:text-gray-500:hover {
color: var(--color-gray-800);
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)) .group:hover .group-hover\:text-gray-500 {
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;
}
}
/* 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-600);
color: var(--color-gray-700);
}
/* 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-700);
-webkit-text-fill-color: var(--color-gray-700);
color: var(--color-gray-800);
-webkit-text-fill-color: var(--color-gray-800);
}
+30
View File
@@ -407,6 +407,36 @@ 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,6 +292,29 @@ 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="shrink-0">
<div class="min-w-0">
<slot {labelId} />
</div>
</div>
@@ -269,6 +269,14 @@
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,7 +168,11 @@
</button>
{#if showCapabilities}
<div class="pb-2" on:click={updateDirty} on:change={updateDirty}>
<div
class="max-h-[24rem] overflow-y-auto pb-2 pr-1 scrollbar-hover"
on:click={updateDirty}
on:change={updateDirty}
>
<Capabilities bind:capabilities={defaultCapabilities} />
{#if availableFeatures.length > 0}
@@ -231,7 +235,12 @@
</button>
{#if showPromptSuggestions}
<div class="pb-2" on:click={updateDirty} on:change={updateDirty} on:input={updateDirty}>
<div
class="max-h-[24rem] overflow-y-auto pb-2 pr-1 scrollbar-hover"
on:click={updateDirty}
on:change={updateDirty}
on:input={updateDirty}
>
<PromptSuggestions bind:promptSuggestions />
</div>
{/if}
+4 -1
View File
@@ -25,6 +25,10 @@
scrollToTab(selectedTab);
}
$: if (loaded && selectedTab) {
loadCounts();
}
const scrollToTab = (tabId) => {
const tabElement = document.getElementById(tabId);
if (tabElement) {
@@ -58,7 +62,6 @@
await goto('/');
}
await loadCounts();
loaded = true;
const containerElement = document.getElementById('users-tabs-container');
+4 -1
View File
@@ -48,6 +48,10 @@
return (b.member_count ?? 0) - (a.member_count ?? 0) || a.name.localeCompare(b.name);
});
$: if (loaded) {
adminGroupCount.set(filteredGroups.length);
}
/** @type {any} */
let defaultPermissions = {};
@@ -56,7 +60,6 @@
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 (rrule.includes('COUNT=1')) {
if (/COUNT=1(?!\d)/.test(rrule)) {
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,6 +109,9 @@
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 (s.includes('COUNT=1')) {
if (/COUNT=1(?!\d)/.test(s)) {
frequency = 'ONCE';
const match = s.match(/DTSTART:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})/);
if (match) {
@@ -116,7 +116,10 @@
return;
}
const parts: Record<string, string> = {};
s.replace('RRULE:', '')
s.split(/\s+/)
.filter((line) => !line.toUpperCase().startsWith('DTSTART'))
.join('')
.replace('RRULE:', '')
.split(';')
.forEach((p) => {
const [k, v] = p.split('=');
@@ -129,7 +129,12 @@
loading = true;
try {
const startNs = dateTimeToNs(startDate, allDay ? '00:00' : startTime);
const endNs = endDate ? dateTimeToNs(endDate, allDay ? '23:59' : endTime) : undefined;
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;
}
if (event && !event.meta?.automation_id) {
const result = await updateCalendarEvent(localStorage.token, event.id, {
@@ -31,7 +31,10 @@
const startDate = new Date(startMs);
const endDate = new Date(endMs);
const d = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());
const last = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate()).getTime();
const last = Math.max(
d.getTime(),
new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate()).getTime()
);
while (d.getTime() <= last) {
const key = d.getTime().toString();
(map[key] ??= []).push(e);
@@ -90,7 +93,7 @@
const dayEndMs = dayStartMs + 86_400_000;
return filteredEvents.filter((e) => {
const startMs = e.start_at / NS;
const endMs = (e.end_at || e.start_at) / NS;
const endMs = Math.max(startMs, (e.end_at || e.start_at) / NS);
return startMs < dayEndMs && endMs >= dayStartMs;
});
}
+11 -5
View File
@@ -1144,6 +1144,7 @@
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;
@@ -1219,7 +1220,11 @@
} else if (type === 'chat:active') {
if (!data?.active) {
taskIds = null;
if ($chatId && !$temporaryChatEnabled && hasPendingAssistantLeaf()) {
if (
$chatId &&
!$temporaryChatEnabled &&
hasPendingAssistantLeaf(event?.message_id ?? null)
) {
await loadChat();
}
if ($chatId && !$temporaryChatEnabled) {
@@ -1499,9 +1504,9 @@
} catch {}
};
const hasPendingAssistantLeaf = () =>
Object.values(history.messages).some(
(message) =>
const hasPendingAssistantLeaf = (messageId: string | null = null) =>
(messageId ? [history.messages[messageId]] : Object.values(history.messages)).some(
(message: any) =>
message?.role === 'assistant' && !message.done && (message.childrenIds?.length ?? 0) === 0
);
@@ -2113,8 +2118,9 @@
autoScroll = true;
await resetInput();
// resetInput() must stay last: the selected model's defaults override the draft's selection.
await restoreChatInput(sessionStorage.getItem('chat-input'));
await resetInput();
await chatId.set('');
await chatTitle.set('');
+59 -20
View File
@@ -2,8 +2,10 @@
// Persists across mount/unmount cycles (module-level, not per-instance)
let savedPath = '/';
let savedFileRoot = null;
const treeExpandedCache = new Map<string, string[]>();
const treeContentsCache = new Map<string, [string, any[]][]>();
/** @type {Map<string, string[]>} */
const treeExpandedCache = new Map();
/** @type {Map<string, [string, any[]][]>} */
const treeContentsCache = new Map();
</script>
<script lang="ts">
@@ -23,6 +25,7 @@
listFiles,
readFile,
downloadFileBlob,
downloadFilePreview,
archiveFromTerminal,
uploadToTerminal,
createDirectory,
@@ -865,19 +868,37 @@
);
if (result) fileSqliteData = await result.blob.arrayBuffer();
} else if (isOffice(filePath)) {
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') {
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();
fileDocxData = arrayBuffer;
} else if (ext === 'xlsx') {
}
} else if (ext === 'xlsx') {
const result = await downloadFileBlob(
terminal.url,
terminal.key,
filePath,
chatId ?? undefined
);
if (result) {
const arrayBuffer = await result.blob.arrayBuffer();
const XLSX = await import('xlsx');
const wb = XLSX.read(new Uint8Array(arrayBuffer), { type: 'array' });
excelWorkbook = wb;
@@ -889,16 +910,34 @@
const DOMPurify = (await import('dompurify')).default;
fileOfficeHtml = DOMPurify.sanitize(result.html);
}
} else if (ext === 'pptx') {
}
} 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();
const { pptxToImages } = await import('$lib/utils/pptxToHtml');
const result = await pptxToImages(arrayBuffer);
fileOfficeSlides = result.images;
const fallback = await pptxToImages(arrayBuffer);
fileOfficeSlides = fallback.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 PDFViewer from '../../common/PDFViewer.svelte';
import PdfPagesPreview from '../../common/PdfPagesPreview.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 pdfViewerRef: PDFViewer;
let pdfPagesPreviewRef: PdfPagesPreview;
let fileCodeEditorRef: FileCodeEditor;
const i18n = getContext('i18n');
@@ -116,6 +116,7 @@
$: 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.
@@ -279,13 +280,14 @@
};
export const resetPdfView = () => {
pdfViewerRef?.resetView();
pdfPagesPreviewRef?.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"
@@ -321,7 +323,16 @@
</audio>
</div>
{:else if filePdfData !== null}
<PDFViewer bind:this={pdfViewerRef} data={filePdfData} {targetPage} className="w-full h-full" />
<PdfPagesPreview
bind:this={pdfPagesPreviewRef}
data={filePdfData}
bind:currentSlide
{targetPage}
singlePage={isPptx}
itemLabel={isPptx ? 'Slide' : 'Page'}
listLabel={isPptx ? 'Slides' : 'Pages'}
className="w-full h-full"
/>
{:else if fileSqliteData !== null}
<SqliteView data={fileSqliteData} />
{:else if fileDocxData !== null}
@@ -504,9 +515,10 @@
<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"
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"
on:click={() => panzoomRef?.zoomOut()}
aria-label={$i18n.t('Zoom out')}
>
@@ -533,7 +545,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"
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"
on:click={() => panzoomRef?.zoomIn()}
aria-label={$i18n.t('Zoom in')}
>
+4 -1
View File
@@ -1858,7 +1858,10 @@
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'} 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'} {($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"
dir={$settings?.chatDirection ?? 'auto'}
>
{#if atSelectedModel !== undefined}
@@ -138,7 +138,7 @@
function getMessageText(item: any): string {
return (item.content ?? [])
.filter((p: any) => p.type === 'output_text' || 'text' in p)
.filter((p: any) => p && (p.type === 'output_text' || 'text' in p))
.map((p: any) => p.text ?? '')
.join('\n');
}
@@ -146,7 +146,9 @@
function updateMessageText(idx: number, text: string) {
const next = [...output];
const item = { ...next[idx] };
const parts = (item.content ?? []).filter((p: any) => p.type === 'output_text' || 'text' in p);
const parts = (item.content ?? []).filter(
(p: any) => p && (p.type === 'output_text' || 'text' in p)
);
item.content = [{ ...(parts[0] ?? { type: 'output_text' }), text }];
next[idx] = item;
output = next;
@@ -155,7 +157,7 @@
function getReasoningText(item: any): string {
return (item.summary ?? item.content ?? [])
.filter((p: any) => 'text' in p)
.filter((p: any) => p && 'text' in p)
.map((p: any) => p.text ?? '')
.join('');
}
@@ -270,7 +272,7 @@
{#if di.type === 'message'}
<textarea
use:fitContent
class="w-full bg-transparent outline-hidden resize-none overflow-hidden text-[0.9375rem] p-1.5 rounded-lg"
class="w-full bg-transparent outline-hidden focus-visible:outline-none! 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);
@@ -282,7 +284,7 @@
{:else if di.type === 'reasoning'}
<textarea
use:fitContent
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"
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"
value={getReasoningText(di.item)}
on:input={(e) => {
updateReasoningText(di.indices[0], e.target.value);
@@ -731,7 +731,12 @@
{/if}
{#if edit === true}
<div class="w-full bg-gray-50 dark:bg-gray-800 rounded-3xl px-3 py-3 my-2">
<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'
: ''}"
>
{#if editedOutput}
<!-- Structured output editor (visual + JSON toggle) -->
<OutputEditView
@@ -745,7 +750,7 @@
<textarea
id="message-edit-{message.id}"
bind:this={editTextAreaElement}
class=" bg-transparent outline-hidden w-full resize-none text-[0.9375rem]"
class=" bg-transparent outline-hidden focus-visible:outline-none! w-full resize-none text-[0.9375rem]"
bind:value={editedContent}
on:input={(e) => {
const messagesContainer = document.getElementById('messages-container');
@@ -163,7 +163,9 @@
</div>
</ConsecutiveDetailsGroup>
{:else if displayItem.type === 'file'}
<TerminalOutputFile item={displayItem.item} {chatId} />
{#if displayItem.item?.displayed || $settings?.terminalFileDisplay === 'inline'}
<TerminalOutputFile item={displayItem.item} {chatId} />
{/if}
{:else}
{@const detailToken = displayItem.token}
{#if detailToken.attributes?.type === 'tool_calls'}
@@ -10,7 +10,7 @@
showFileNavPath,
terminalServers
} from '$lib/stores';
import { downloadFileBlob, readFile } from '$lib/apis/terminal';
import { downloadFileBlob, downloadFilePreview, 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,26 +143,59 @@
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') {
fileDocxData = arrayBuffer;
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();
}
} 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 { pptxToImages } = await import('$lib/utils/pptxToHtml');
const result = await pptxToImages(arrayBuffer);
fileOfficeSlides = result.images;
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;
}
}
} else if (terminal) {
fileContent = await readFile(terminal.url, terminal.key, path, chatId || undefined);
@@ -202,7 +202,12 @@
{/if}
{#if edit === true}
<div class=" w-full bg-gray-50 dark:bg-gray-800 rounded-3xl px-4 py-3 mb-2">
<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'
: ''}"
>
{#if (editedFiles ?? []).length > 0}
<div class="flex items-center flex-wrap gap-2 -mx-2 mb-1">
{#each editedFiles as file, fileIdx}
@@ -273,7 +278,7 @@
<textarea
id="message-edit-{message.id}"
bind:this={messageEditTextAreaElement}
class=" bg-transparent outline-hidden w-full resize-none text-[0.9375rem]"
class=" bg-transparent outline-hidden focus-visible:outline-none! w-full resize-none text-[0.9375rem]"
bind:value={editedContent}
on:input={(e) => {
const messagesContainer = document.getElementById('messages-container');
@@ -179,7 +179,6 @@ 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
@@ -374,7 +373,11 @@ export function buildOutputDisplayItems(output: OutputItem[] = []): OutputDispla
};
output.forEach((item, index) => {
if (item?.type === 'function_call_output') {
if (!item) {
return;
}
if (item.type === 'function_call_output') {
const inlineFile = getInlineFileFromToolOutput(toolCallByCallId[item.call_id ?? ''], item);
if (inlineFile) {
flushDetails();
@@ -388,14 +391,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);
@@ -403,7 +406,7 @@ export function buildOutputDisplayItems(output: OutputItem[] = []): OutputDispla
return;
}
if (item?.type === 'message') {
if (item.type === 'message') {
const text = getMessageText(item);
if (text.trim()) {
flushDetails();
@@ -462,9 +465,12 @@ function ensureOutputItem(
fallback?: OutputItem
): OutputItem {
while (output.length <= outputIndex) {
output.push(
fallback ?? { type: 'message', status: 'in_progress', role: 'assistant', content: [] }
);
// 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[outputIndex] = { ...output[outputIndex] };
return output[outputIndex];
@@ -478,6 +484,17 @@ 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) =>
@@ -526,7 +543,7 @@ export function applyResponseStreamEvent(
} else if (outputIndex < nextOutput.length) {
nextOutput.splice(outputIndex, 0, item);
} else {
nextOutput[outputIndex] = item;
nextOutput.push(item);
}
return nextOutput;
}
@@ -537,7 +554,13 @@ export function applyResponseStreamEvent(
}
const item = { ...event.item };
const existingIndex = findOutputItemIndex(nextOutput, item);
nextOutput[existingIndex >= 0 ? existingIndex : outputIndex] = item;
if (existingIndex >= 0) {
nextOutput[existingIndex] = item;
} else if (outputIndex < nextOutput.length) {
nextOutput[outputIndex] = item;
} else {
nextOutput.push(item);
}
return nextOutput;
}
@@ -562,7 +585,7 @@ export function applyResponseStreamEvent(
return nextOutput;
}
item.content = [...(item.content ?? [])];
item.content[event.content_index ?? item.content.length] = { ...event.part };
setPart(item.content, event.content_index ?? item.content.length, { ...event.part });
return nextOutput;
}
@@ -571,7 +594,8 @@ export function applyResponseStreamEvent(
return nextOutput;
}
item.summary = [...(item.summary ?? [])];
item.summary[event.summary_index ?? item.summary.length] = { ...event.part };
const summaryIndex = event.summary_index ?? item.summary.length;
setPart(item.summary, summaryIndex, { ...event.part }, { type: 'summary_text', text: '' });
return nextOutput;
}
@@ -601,7 +625,8 @@ export function applyResponseStreamEvent(
const typeName = eventType.split('.')[1];
if (typeName === 'content_part' && event.part) {
item.content = [...(item.content ?? [])];
item.content[event.content_index ?? Math.max(item.content.length - 1, 0)] = { ...event.part };
const contentIndex = event.content_index ?? Math.max(item.content.length - 1, 0);
setPart(item.content, contentIndex, { ...event.part });
} else if (typeName === 'function_call_arguments' && event.arguments !== undefined) {
item.arguments = event.arguments;
} else if (
@@ -633,7 +658,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,8 +1,7 @@
<script lang="ts">
import { getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import { updateUserPassword, userSignOut } from '$lib/apis/auths';
import { user } from '$lib/stores';
import { getLogoutRedirectUrl, updateUserPassword, userSignOut } from '$lib/apis/auths';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
const i18n = getContext('i18n');
@@ -27,17 +26,13 @@
// 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;
});
if (signOutRes?.redirect_url) {
location.href = signOutRes.redirect_url;
}
localStorage.removeItem('token');
location.href = getLogoutRedirectUrl(signOutRes?.redirect_url);
}
currentPassword = '';
@@ -10,7 +10,7 @@
<slot name="label">{label}</slot>
</div>
<div class="shrink-0">
<div class="min-w-0">
<slot />
</div>
</div>
@@ -268,6 +268,8 @@
'full width mode',
'haptic feedback',
'hapticfeedback',
'accessibility mode',
'accessibilitymode',
'high contrast mode',
'highcontrastmode',
'iframe sandbox allow forms',
@@ -4,7 +4,7 @@
</script>
<div
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`}
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`}
{style}
>
<slot />
@@ -4,7 +4,7 @@
import { toast } from 'svelte-sonner';
import { updateUserInfo } from '$lib/apis/users';
import { getUserPosition } from '$lib/utils';
import { setTextScale } from '$lib/utils/text-scale';
import { normalizeAppFontFamily, setAppFontFamily, setTextScale } from '$lib/utils/text-scale';
import Minus from '$lib/components/icons/Minus.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
@@ -115,7 +115,10 @@
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';
@@ -234,9 +237,18 @@
};
export const save = async () => {
const normalizedFontFamily = normalizeAppFontFamily(fontFamilyInput) || null;
fontFamily = normalizedFontFamily;
fontFamilyInput = normalizedFontFamily ?? '';
if (!externalSettings) {
setAppFontFamily(fontFamily || defaultFontFamily());
}
saveSettings({
models: [defaultModelId],
imageCompressionSize: imageCompressionSize
imageCompressionSize: imageCompressionSize,
fontFamily
});
};
@@ -255,6 +267,51 @@
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;
@@ -338,7 +395,10 @@
webSearch = currentSettings?.webSearch ?? null;
textScale = currentSettings?.textScale ?? null;
fontFamily = normalizeAppFontFamily(currentSettings?.fontFamily) || null;
fontFamilyInput = fontFamily ?? '';
showTextScaleSlider = false;
showFontFamilyInput = false;
defaultUploadContext = currentSettings?.defaultUploadContext ?? 'focused';
};
@@ -490,13 +550,53 @@
<div>
<div class={settingRowClass}>
<div id="high-contrast-mode-label" class={settingLabelClass}>
{$i18n.t('High Contrast Mode')}
<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>
<div class={settingControlClass}>
<Switch
ariaLabelledbyId="high-contrast-mode-label"
ariaLabelledbyId="accessibility-mode-label"
tooltip={true}
bind:state={highContrastMode}
inherited={isDefaultSetting('highContrastMode')}
@@ -62,11 +62,20 @@
return (
target instanceof Element &&
!!target.closest(
'input, textarea, select, [role="menu"], [contenteditable="true"], [data-sidebar-no-gesture]'
'input, textarea, select, [role="dialog"], [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;
@@ -161,7 +170,7 @@
};
const onTouchStart = (e: TouchEvent) => {
if (!enabled || e.touches.length !== 1 || shouldSkipSwipe(e.target)) {
if (!enabled || e.touches.length !== 1 || shouldSkipSwipe(e.target) || hasTextSelection()) {
return;
}
@@ -205,6 +214,11 @@
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));
}
+255 -18
View File
@@ -9,6 +9,9 @@
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>;
@@ -22,10 +25,28 @@
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 {
@@ -53,8 +74,11 @@
}
return false;
},
beforeMouseDown: () => {
beforeMouseDown: (e) => {
// 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
@@ -100,16 +124,73 @@
}
};
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)
@@ -117,16 +198,15 @@
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(i + 1);
const page = await pdfDoc.getPage(singlePage ? selectedPage : i + 1);
const viewport = page.getViewport({ scale: 1 });
const cssScale = containerWidth / viewport.width;
const cssScale = getCssScale(viewport);
const renderScale = cssScale * forZoom * dpr;
const scaledViewport = page.getViewport({ scale: renderScale });
const cssViewport = page.getViewport({ scale: cssScale });
@@ -162,8 +242,17 @@
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 = '';
@@ -172,14 +261,17 @@
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 = 1; i <= pdfDoc.numPages; i++) {
for (let i = firstPage; i <= lastPage; i++) {
const page = await pdfDoc.getPage(i);
if (token !== renderToken) return;
const viewport = page.getViewport({ scale: 1 });
// Scale to fit container width
const containerWidth = outerContainer?.clientWidth || 800;
const cssScale = containerWidth / viewport.width;
const cssScale = getCssScale(viewport);
const renderScale = cssScale * dpr;
const scaledViewport = page.getViewport({ scale: renderScale });
const cssViewport = page.getViewport({ scale: cssScale });
@@ -187,6 +279,7 @@
// 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`;
@@ -217,6 +310,7 @@
canvasContext: ctx,
viewport: scaledViewport
}).promise;
if (token !== renderToken) return;
// Create text layer overlay — pdfjs setLayerDimensions handles its sizing
const textLayerDiv = document.createElement('div');
@@ -230,21 +324,87 @@
viewport: cssViewport
});
await textLayer.render();
if (token !== renderToken) return;
textLayerInstances.push(textLayer);
sceneElement.appendChild(wrapper);
wrappers.push(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');
@@ -252,7 +412,7 @@
let pdfData: ArrayBuffer | Uint8Array;
if (data) {
pdfData = data;
pdfData = copyPdfData(data);
} else {
// Fetch with credentials so auth cookies are sent
const res = await fetch(url!, { credentials: 'include' });
@@ -260,24 +420,42 @@
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) {
console.error('PDF render error:', e);
error = 'Failed to load PDF.';
if (token === loadToken) {
console.error('PDF render error:', e);
error = 'Failed to load PDF.';
}
} finally {
loading = false;
if (token === loadToken) loading = false;
}
};
onMount(() => {
mounted = true;
loadPdf();
});
$: if (!loading && pdfDoc && targetPage) {
$: if (mounted && (data || url)) {
void loadPdf();
}
$: if (!loading && pdfDoc && singlePage && targetPage && selectedPage !== renderedPage) {
void renderAllPages();
}
$: if (!loading && pdfDoc && !singlePage && targetPage) {
void scrollToTargetPage();
}
onDestroy(() => {
loadToken++;
renderToken++;
if (scrollFrame !== null) cancelAnimationFrame(scrollFrame);
if (rerenderTimer) clearTimeout(rerenderTimer);
pzInstance?.dispose();
cancelTextLayers();
@@ -299,17 +477,76 @@
</div>
{/if}
<div class="overflow-y-auto h-full" bind:this={outerContainer}>
<div bind:this={sceneElement} class="w-full"></div>
<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>
{#if !loading && !error && pdfDoc}
{#if !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"
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"
on:click={zoomOut}
aria-label="Zoom out"
>
@@ -336,7 +573,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"
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"
on:click={zoomIn}
aria-label="Zoom in"
>
@@ -0,0 +1,238 @@
<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>
+36 -26
View File
@@ -7,6 +7,8 @@
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;
@@ -119,6 +121,10 @@
}
};
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);
@@ -225,10 +231,13 @@
});
</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}"
@@ -236,8 +245,8 @@
<aside
class={hideThumbs
? 'hidden'
: '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"
: '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}
>
{#each slides as slide, index}
<button
@@ -245,7 +254,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="Slide {index + 1}"
aria-label="{itemLabel} {index + 1}"
aria-current={safeSlide === index ? 'true' : undefined}
>
<span
@@ -260,7 +269,7 @@
>
<img
src={slide}
alt="Slide {index + 1} thumbnail"
alt="{itemLabel} {index + 1} thumbnail"
class="block w-full h-full object-contain"
draggable="false"
/>
@@ -283,7 +292,7 @@
<img
bind:this={slideImgEl}
src={selectedSlide}
alt="Slide {safeSlide + 1}"
alt="{itemLabel} {safeSlide + 1}"
class="block w-full h-full object-contain rounded"
draggable="false"
on:load={onSlideLoad}
@@ -303,7 +312,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 slide"
aria-label={`Previous ${itemLabel.toLowerCase()}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -327,7 +336,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 slide"
aria-label={`Next ${itemLabel.toLowerCase()}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -342,9 +351,10 @@
/>
</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"
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"
on:click={zoomOut}
aria-label="Zoom out"
>
@@ -371,7 +381,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"
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"
on:click={zoomIn}
aria-label="Zoom in"
>
@@ -391,36 +401,36 @@
</div>
<style>
.pptx-slide-rail {
.thumbnail-sidebar {
scrollbar-color: transparent transparent;
}
.pptx-slide-rail:hover,
.pptx-slide-rail:focus,
.pptx-slide-rail:focus-within,
.pptx-slide-rail:active {
.thumbnail-sidebar:hover,
.thumbnail-sidebar:focus,
.thumbnail-sidebar:focus-within,
.thumbnail-sidebar:active {
scrollbar-color: rgba(215, 215, 215, 0.6) transparent;
}
: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 {
: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;
}
.pptx-slide-rail::-webkit-scrollbar-thumb {
.thumbnail-sidebar::-webkit-scrollbar-thumb {
visibility: hidden;
}
.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 {
.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;
}
.pptx-slide-rail::-webkit-scrollbar-corner {
.thumbnail-sidebar::-webkit-scrollbar-corner {
display: none;
}
</style>
@@ -15,6 +15,8 @@
let panelElement: HTMLDivElement | null = null;
let isResizing = false;
let activePointerId: number | null = null;
let activeResizer: HTMLElement | null = null;
let startClientX = 0;
let startWidth = 0;
@@ -48,19 +50,31 @@
onClose();
};
const resizeStartHandler = (e: MouseEvent) => {
const resizeStartHandler = (e: PointerEvent) => {
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 = () => {
const resizeEndHandler = (e?: PointerEvent) => {
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();
};
@@ -109,14 +123,16 @@
</script>
<svelte:window
on:mousemove={(e) => {
on:pointermove={(e) => {
if (!isResizing) return;
if (activePointerId !== null && e.pointerId !== activePointerId) return;
resizeHandler(e.clientX);
}}
on:resize={() => {
if (open) width = clamp(width);
}}
on:mouseup={resizeEndHandler}
on:pointerup={resizeEndHandler}
on:pointercancel={resizeEndHandler}
/>
{#if open}
@@ -125,7 +141,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:mousedown={resizeStartHandler}
on:pointerdown={resizeStartHandler}
on:keydown={resizeKeyHandler}
role="separator"
tabindex="0"
@@ -134,6 +150,7 @@
>
<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}
@@ -147,7 +164,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:mousedown={resizeStartHandler}
on:pointerdown={resizeStartHandler}
on:keydown={resizeKeyHandler}
role="separator"
tabindex="0"
@@ -156,6 +173,7 @@
>
<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}
+12 -2
View File
@@ -74,11 +74,21 @@
contentEl.style.bottom = 'auto';
}
const contentWidth = contentEl.offsetWidth || 0;
if (align === 'end') {
contentEl.style.right = `${window.innerWidth - rect.right}px`;
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.left = 'auto';
} else {
contentEl.style.left = `${rect.left}px`;
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.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">
<div class=" flex-1 min-w-0">
{#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,6 +44,10 @@
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, userSignOut } from '$lib/apis/auths';
import { getAdminDetails, getLogoutRedirectUrl, 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 = res?.redirect_url ?? '/auth';
location.href = getLogoutRedirectUrl(res?.redirect_url);
}}>{$i18n.t('Sign Out')}</button
>
</div>
+44 -22
View File
@@ -600,13 +600,20 @@
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: MouseEvent) => {
const resizeStartHandler = (e: PointerEvent) => {
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;
@@ -614,15 +621,23 @@
document.body.style.userSelect = 'none';
};
const resizeEndHandler = () => {
const resizeEndHandler = (e?: PointerEvent) => {
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) => {
const resizeSidebarHandler = (endClientX: number) => {
const dx = endClientX - startClientX;
const newSidebarWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + dx));
@@ -630,6 +645,12 @@
document.documentElement.style.setProperty('--sidebar-width', `${newSidebarWidth}px`);
};
onDestroy(() => {
if (isResizing) {
document.body.style.userSelect = '';
}
});
onMount(async () => {
try {
const width = Number(localStorage.getItem('sidebarWidth'));
@@ -861,13 +882,13 @@
/>
<svelte:window
on:mousemove={(e) => {
on:pointermove={(e) => {
if (!isResizing) return;
if (activePointerId !== null && e.pointerId !== activePointerId) return;
resizeSidebarHandler(e.clientX);
}}
on:mouseup={() => {
resizeEndHandler();
}}
on:pointerup={resizeEndHandler}
on:pointercancel={resizeEndHandler}
/>
<MobileSwipePanel
@@ -940,7 +961,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-50 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-100 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:
@@ -974,7 +995,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-50 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-100 dark:group-hover:bg-gray-900"
>
<EditPencilIcon className="size-4" strokeWidth="1.5" />
</div>
@@ -996,7 +1017,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-50 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-100 dark:group-hover:bg-gray-900"
>
<SearchIcon className="size-4" strokeWidth="1.5" />
</div>
@@ -1027,7 +1048,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-50 dark:group-hover:bg-gray-900'}"
: 'group-hover:bg-gray-100 dark:group-hover:bg-gray-900'}"
>
{#if itemId === 'notes'}
<NotesIcon className="size-4" strokeWidth="1.5" />
@@ -1060,7 +1081,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-50 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-100 dark:group-hover:bg-gray-900"
>
<img
src={`${WEBUI_API_BASE_URL}/users/${$user?.id}/profile/image`}
@@ -1123,7 +1144,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-50 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-100 dark:hover:bg-gray-900 transition no-drag-region"
href="/"
draggable="false"
on:click={newChatHandler}
@@ -1155,7 +1176,7 @@
placement="bottom"
>
<button
class="flex size-[1.875rem] justify-center items-center rounded-lg hover:bg-gray-50 dark:hover:bg-gray-900 transition {isWindows
class="flex size-[1.875rem] justify-center items-center rounded-lg hover:bg-gray-100 dark:hover:bg-gray-900 transition {isWindows
? 'cursor-pointer'
: 'cursor-[w-resize]'}"
on:click={() => {
@@ -1190,7 +1211,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-50 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-100 dark:hover:bg-gray-900 transition outline-none"
href="/"
draggable="false"
on:click={newChatHandler}
@@ -1211,7 +1232,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-50 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-100 dark:hover:bg-gray-900 transition outline-none"
on:click={() => {
showSearch.set(true);
}}
@@ -1244,7 +1265,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-50 dark:hover:bg-gray-900'}"
: 'hover:bg-gray-100 dark:hover:bg-gray-900'}"
href={meta.href}
on:click={itemClickHandler}
draggable="false"
@@ -1483,7 +1504,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-50/40 dark:hover:bg-gray-800/40"
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"
on:click={markAllChatsReadHandler}
>
<CheckIcon className="size-3.5" />
@@ -1692,7 +1713,7 @@
>
<button
type="button"
class=" flex items-center rounded-xl py-1.5 px-1.5 w-full hover:bg-gray-50 dark:hover:bg-gray-900 transition"
class=" flex items-center rounded-xl py-1.5 px-1.5 w-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
aria-label={$i18n.t('User menu')}
>
<div class=" self-center mr-3 relative flex-shrink-0">
@@ -1726,14 +1747,15 @@
{#if !$mobile && visible}
<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"
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="sidebar-resizer"
on:mousedown={resizeStartHandler}
on:pointerdown={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-50 dark:hover:bg-gray-900 group-hover:bg-gray-50 dark:group-hover:bg-gray-900'
: ' hover:bg-gray-100 dark:hover:bg-gray-900 group-hover:bg-gray-100 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-50 dark:hover:bg-gray-900 group-hover:bg-gray-50 dark:group-hover:bg-gray-900'} whitespace-nowrap text-ellipsis relative transition {generating
: '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
? '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-50/40 dark:hover:bg-gray-800/40 w-full"
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"
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-50/40 dark:hover:bg-gray-800/40 w-full"
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"
>
<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-50/40 dark:hover:bg-gray-800/40 w-full"
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"
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-50/40 dark:hover:bg-gray-800/40 w-full"
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"
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-50/40 dark:hover:bg-gray-800/40 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-100 dark:hover:bg-gray-900 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-50/40 dark:hover:bg-gray-800/40 w-full"
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"
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-50/40 dark:hover:bg-gray-800/40 w-full"
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"
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-50/40 dark:hover:bg-gray-800/40 w-full"
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"
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-50/40 dark:hover:bg-gray-800/40 w-full"
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"
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-50/40 dark:hover:bg-gray-800/40 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-100 dark:hover:bg-gray-900 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-50/40 dark:hover:bg-gray-800/40 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-100 dark:hover:bg-gray-900 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-50/40 dark:hover:bg-gray-800/40 w-full"
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"
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-50/40 dark:hover:bg-gray-800/40 w-full"
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"
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-50/40 dark:hover:bg-gray-800/40"
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"
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-50/40 dark:hover:bg-gray-800/40"
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"
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-50/40 dark:hover:bg-gray-800/40"
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"
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-50/40 dark:hover:bg-gray-800/40"
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"
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-50/40 dark:hover:bg-gray-800/40"
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"
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-50/40 dark:hover:bg-gray-800/40"
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"
on:click={() => {
onDelete();
}}
@@ -119,6 +119,7 @@
{/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,6 +428,8 @@
});
onDestroy(() => {
delete folderRegistry[folderId];
if (folderElement) {
folderElement.removeEventListener('dragover', onDragOver);
folderElement.removeEventListener('drop', onDrop);
@@ -742,7 +744,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-50/40 dark:hover:bg-gray-800/40 transition {$selectedFolder?.id ===
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 ===
folderId
? 'bg-gray-100/80 dark:bg-gray-850/50 selected'
: ''}"
@@ -778,7 +780,7 @@
}}
>
<button
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"
class="text-gray-600 dark:text-gray-400 transition-all p-1 hover:bg-gray-100 dark:hover:bg-gray-900 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 { getSessionUser, userSignOut } from '$lib/apis/auths';
import { getLogoutRedirectUrl, 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-50/40 dark:hover:bg-gray-800/40 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-100 dark:hover:bg-gray-900 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-50/40 dark:hover:bg-gray-800/40 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-100 dark:hover:bg-gray-900 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-50/40 dark:hover:bg-gray-800/40 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-100 dark:hover:bg-gray-900 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-50/40 dark:hover:bg-gray-800/40 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-100 dark:hover:bg-gray-900 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-50/40 dark:hover:bg-gray-800/40 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-100 dark:hover:bg-gray-900 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-50/40 dark:hover:bg-gray-800/40 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-100 dark:hover:bg-gray-900 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-50/40 dark:hover:bg-gray-800/40 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-100 dark:hover:bg-gray-900 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-50/40 dark:hover:bg-gray-800/40 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-100 dark:hover:bg-gray-900 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-50/40 dark:hover:bg-gray-800/40 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-100 dark:hover:bg-gray-900 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-50/40 dark:hover:bg-gray-800/40 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-100 dark:hover:bg-gray-900 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-50/40 dark:hover:bg-gray-800/40 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-100 dark:hover:bg-gray-900 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-50/40 dark:hover:bg-gray-800/40 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-100 dark:hover:bg-gray-900 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-50/40 dark:hover:bg-gray-800/40 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-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
type="button"
on:click={async () => {
show = false;
@@ -567,14 +567,13 @@
</button>
<button
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"
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"
type="button"
on:click={async () => {
const res = await userSignOut();
user.set(null);
localStorage.removeItem('token');
location.href = res?.redirect_url ?? '/auth';
location.href = getLogoutRedirectUrl(res?.redirect_url);
show = false;
}}
>
@@ -272,6 +272,11 @@
viewOption = localStorage?.workspaceViewOption || '';
sourceOption = localStorage?.workspaceKnowledgeSourceOption || '';
loaded = true;
await tick();
if (items === null && !itemsLoading) {
await init();
}
if (showCreateOnMount) {
showCreateModal = true;
@@ -619,6 +619,51 @@
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;
@@ -645,30 +690,14 @@
const directoryIdByPath = await createMissingDirectories(diff);
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
});
const failedCount = await uploadManifestEntries(manifest, (entry) =>
entry.path ? directoryIdByPath[getDirectoryUploadPath(entry.path)] : currentDirectoryId
);
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;
});
if (failedCount === 0) {
toast.success($i18n.t('File uploaded successfully'));
}
toast.success($i18n.t('File uploaded successfully'));
init();
} catch (e) {
toast.error(`${e}`);
@@ -723,36 +752,24 @@
diff.modified.some((m: any) => m.filename === entry.filename && m.path === entry.path)
);
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);
}
const failedCount = await uploadManifestEntries(filesToUpload, (entry) =>
entry.path ? directoryIdByPath[entry.path] : null
);
// ── 7. Report ──
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
}
)
);
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
}
)
);
}
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="truncate text-[0.8125rem] leading-5 text-gray-800 group-hover:underline dark:text-gray-200"
class="block truncate text-[0.8125rem] leading-5 text-gray-800 group-hover:underline dark:text-gray-200"
>
{model.name}
</a>
</Tooltip>
<div
class="min-w-0 max-w-[40%] shrink-0 truncate text-[0.6875rem] leading-5 text-gray-500"
class="hidden min-w-0 max-w-[40%] shrink-0 truncate text-[0.6875rem] leading-5 text-gray-500 sm:block"
>
{model.id}
</div>
+3 -1
View File
@@ -96,6 +96,7 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "",
"Account": "الحساب",
"Account Activation Pending": "",
@@ -1337,6 +1338,7 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "",
"Followed instructions perfectly": "اتبعت التعليمات على أكمل وجه",
"Font Family": "",
"for placeholders": "",
"Force OCR": "",
"Forking chat...": "",
@@ -1441,7 +1443,6 @@
"Hide Hidden Files": "",
"Hide Model": "",
"High": "",
"High Contrast Mode": "",
"History": "",
"Home": "",
"Host": "",
@@ -2958,6 +2959,7 @@
"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.": "",
+3 -1
View File
@@ -96,6 +96,7 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "متاح لجميع المستخدمين",
"Account": "الحساب",
"Account Activation Pending": "انتظار تفعيل الحساب",
@@ -1337,6 +1338,7 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "",
"Followed instructions perfectly": "اتبعت التعليمات على أكمل وجه",
"Font Family": "",
"for placeholders": "",
"Force OCR": "",
"Forking chat...": "",
@@ -1441,7 +1443,6 @@
"Hide Hidden Files": "",
"Hide Model": "",
"High": "",
"High Contrast Mode": "",
"History": "",
"Home": "الصفحة الرئيسية",
"Host": "المضيف",
@@ -2958,6 +2959,7 @@
"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.": "",
+3 -1
View File
@@ -68,6 +68,7 @@
"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",
@@ -1297,6 +1298,7 @@
"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...": "",
@@ -1401,7 +1403,6 @@
"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",
@@ -2902,6 +2903,7 @@
"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.": "",
+3 -1
View File
@@ -68,6 +68,7 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "Достъпно за всички потребители",
"Account": "Акаунт",
"Account Activation Pending": "Активирането на акаунта е в процес на изчакване",
@@ -1297,6 +1298,7 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "",
"Followed instructions perfectly": "Следвайте инструкциите перфектно",
"Font Family": "",
"for placeholders": "",
"Force OCR": "",
"Forking chat...": "",
@@ -1401,7 +1403,6 @@
"Hide Hidden Files": "",
"Hide Model": "",
"High": "",
"High Contrast Mode": "",
"History": "",
"Home": "Начало",
"Host": "Хост",
@@ -2902,6 +2903,7 @@
"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.": "",
+3 -1
View File
@@ -68,6 +68,7 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "",
"Account": "একাউন্ট",
"Account Activation Pending": "",
@@ -1297,6 +1298,7 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "",
"Followed instructions perfectly": "নির্দেশাবলী নিখুঁতভাবে অনুসরণ করা হয়েছে",
"Font Family": "",
"for placeholders": "",
"Force OCR": "",
"Forking chat...": "",
@@ -1401,7 +1403,6 @@
"Hide Hidden Files": "",
"Hide Model": "",
"High": "",
"High Contrast Mode": "",
"History": "",
"Home": "",
"Host": "",
@@ -2902,6 +2903,7 @@
"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.": "",
+3 -1
View File
@@ -61,6 +61,7 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "བེད་སྤྱོད་མཁན་ཡོངས་ལ་འཛུལ་སྤྱོད་ཆོག་པ།",
"Account": "རྩིས་ཁྲ།",
"Account Activation Pending": "རྩིས་ཁྲ་སྒུལ་བསྐྱོད་སྒུག་བཞིན་པ།",
@@ -1287,6 +1288,7 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "",
"Followed instructions perfectly": "ལམ་སྟོན་ཡང་དག་པར་བསྒྲུབས།",
"Font Family": "",
"for placeholders": "",
"Force OCR": "",
"Forking chat...": "",
@@ -1391,7 +1393,6 @@
"Hide Hidden Files": "",
"Hide Model": "དཔེ་དབྱིབས་སྦ་བ།",
"High": "",
"High Contrast Mode": "",
"History": "",
"Home": "གཙོ་ངོས།",
"Host": "Host",
@@ -2888,6 +2889,7 @@
"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.": "",
+3 -1
View File
@@ -75,6 +75,7 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "",
"Account": "Račun",
"Account Activation Pending": "",
@@ -1307,6 +1308,7 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "",
"Followed instructions perfectly": "Savršeno slijedio upute",
"Font Family": "",
"for placeholders": "",
"Force OCR": "",
"Forking chat...": "",
@@ -1411,7 +1413,6 @@
"Hide Hidden Files": "",
"Hide Model": "",
"High": "",
"High Contrast Mode": "",
"History": "",
"Home": "",
"Host": "",
@@ -2916,6 +2917,7 @@
"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.": "",
+126 -124
View File
@@ -75,6 +75,7 @@
"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",
@@ -147,7 +148,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.": "",
"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.",
"All": "Tots",
"All Chats": "Tots els xats",
"All chats have been unarchived.": "Tots els xats han estat desarxivats.",
@@ -161,8 +162,8 @@
"All time": "Sempre",
"All Users": "Tots els usuaris",
"All users and system events": "Tots els usuaris i esdeveniments del sistema",
"Allow": "",
"Allow {{NAME}}?": "",
"Allow": "Permetre",
"Allow {{NAME}}?": "Vols permetre {{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.",
@@ -179,7 +180,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.": "",
"Allow downloads inside sandboxed iframes.": "Permet descàrregues dins d'iframes sandbox.",
"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.",
@@ -192,15 +193,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.": "",
"Allow scripts inside sandboxed iframes.": "Permetre scripts dins d'iframes en sandbox.",
"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.": "",
"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 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": "",
"Allow Tool Call": "Permetre la crida a l'eina",
"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.",
@@ -244,7 +245,7 @@
"API Key": "clau API",
"API Key / Token": "Clau de l'API/Token",
"API Key created.": "clau API creada.",
"API Key deleted.": "",
"API Key deleted.": "S'ha suprimit la clau de l'API.",
"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.",
@@ -298,7 +299,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": "",
"Ask User": "Pregunta a l'usuari",
"Assistant": "Assistent",
"Assistant messages": "Missatges de l'assistent",
"Async Embedding Processing": "Procés d'incrustat asíncron",
@@ -320,7 +321,7 @@
"Auth required": "Es requereix l'autenticació",
"Authenticate": "Autenticar",
"Authentication": "Autenticació",
"Authorize OAuth": "",
"Authorize OAuth": "Autoritzar OAuth",
"Auto": "Automàtic",
"Auto (Random)": "Automàtic (aleatori)",
"Auto Redirect": "Redirecció automàtica",
@@ -424,7 +425,7 @@
"Change User Role": "Canviar el rol d'usuari",
"Channel": "Canal",
"Channel deleted successfully": "Canal suprimit correctament",
"Channel is required": "",
"Channel is required": "El canal és obligatori",
"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",
@@ -434,7 +435,7 @@
"Character": "Personatge",
"Chat": "Xat",
"Chat actions": "Accions de xat",
"Chat archived.": "Xar arxivat",
"Chat archived.": "Xat arxivat",
"Chat Background Image": "Imatge de fons del xat",
"Chat Bubble UI": "Chat Bubble UI",
"Chat Completions": "Completacions de xat",
@@ -445,14 +446,14 @@
"Chat forked": "Xat bifurcat",
"Chat history": "Historial de xats",
"Chat History": "Historial de xats",
"Chat Hover Previews": "",
"Chat Hover Previews": "Previsualitzacions del xat",
"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": "",
"Chat Uploads": "Càrregues de xat",
"Chat Variables": "Variables de xat",
"Chat Variables have conflicting model definitions": "Les variables de xat tenen definicions de model contradictòries",
"chats": "xats",
@@ -461,12 +462,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": "",
"Check OAuth Discovery": "Comprovar 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": "",
"Choose channel": "Tria el canal",
"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ó.",
@@ -489,7 +490,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.": "",
"Choose where terminal display_file results appear by default.": "Tria on apareixen els resultats del fitxer_visualització del terminal per defecte.",
"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",
@@ -510,7 +511,7 @@
"Clear Memory": "Esborrar la memòria",
"Clear search": "Esborrar la cerca",
"Clear status": "Esborrar l'estat",
"Clear vector storage and knowledge indexing data.": "Esboarrar l'emmagatzematge de vectors i dades d'indexació de coneixement.",
"Clear vector storage and knowledge indexing data.": "Esborrar 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.",
@@ -535,14 +536,14 @@
"Close": "Tancar",
"Close Banner": "Tancar el bàner",
"Close chat controls": "Tancar els controls de xat",
"Close citation modal": "Tancar el model de citatció",
"Close citation modal": "Tancar la finestra modal de citació",
"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 de l'altaveu d'incrustació CMU ARCTIC",
"CMU ARCTIC speaker embedding name": "Nom del parlant d'incrustació CMU ARCTIC",
"Code Block": "Bloc de codi",
"Code Editor": "Editor de codi",
"Code execution": "Execució de codi",
@@ -558,7 +559,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·lecccions",
"Collections": "Col·leccions",
"Color": "Color",
"Combine semantic and keyword retrieval.": "Combinar la recuperació semàntica i de paraules clau.",
"ComfyUI": "ComfyUI",
@@ -627,7 +628,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": "",
"Content matches": "Coincidència de contingut",
"Context": "Context",
"Context compacted": "Context compactat",
"Context Compaction": "Compactació del context",
@@ -652,7 +653,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 portaretalls",
"Copied link to clipboard": "Enllaç copiat al porta-retalls",
"Copied shared chat URL to clipboard!": "S'ha copiat la URL compartida al porta-retalls!",
"Copied to clipboard": "Copiat al porta-retalls",
"Copy": "Copiar",
@@ -708,7 +709,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",
@@ -733,7 +734,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": "",
"Date is required": "La data és necessària",
"Date Modified": "Data de modificació",
"Day": "Dia",
"DD/MM/YYYY": "DD/MM/YYYY",
@@ -747,9 +748,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": "Filres per defecte",
"Default Filters": "Filtres per defecte",
"Default Group": "Grup per defecte",
"Default Interface Settings": "",
"Default Interface Settings": "Configuració de la interfície per defecte",
"Default Model": "Model per defecte",
"Default model updated": "Model per defecte actualitzat",
"Default permissions": "Permisos per defecte",
@@ -771,7 +772,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?": "",
"Delete API Key?": "Vols suprimir la clau de l'API?",
"Delete automation?": "Eliminar l'automatització",
"Delete calendar": "Eliminar el calendari",
"Delete Calendar": "Eliminar el calendari",
@@ -799,9 +800,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}}": "",
"Deny": "",
"Deny Tool Call": "",
"Denied {{NAME}}": "S'ha denegat {{NAME}}",
"Deny": "Denegar",
"Deny Tool Call": "Denegar la crida a l'eina",
"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ó...",
@@ -810,7 +811,7 @@
"Describe your knowledge base and objectives": "Descriu la teva base de coneixement i objectius",
"Description": "Descripció",
"Deselect": "Deseleccionar",
"Destination": "",
"Destination": "Destinació",
"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",
@@ -877,27 +878,27 @@
"Don't like the style": "No t'agrada l'estil?",
"Done": "Fet",
"Download": "Descarregar",
"Download \"{{searchValue}}\"": "",
"Download \"{{searchValue}}\" from {{provider}}": "",
"Download \"{{searchValue}}\"": "Descarrega \"{{searchValue}}\"",
"Download \"{{searchValue}}\" from {{provider}}": "Descarrega \"{{searchValue}}\" de {{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": "",
"Download CSV Template": "Descarregar la plantilla CSV",
"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": "",
"Download Model": "Descarregar 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}}\"": "",
"Downloading \"{{searchValue}}\"": "Descarregant \"{{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": "",
"Drop to upload": "Arrossega per carregar",
"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'",
@@ -915,7 +916,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 quelcome divertit",
"e.g. Tell me a fun fact": "p. ex. digues-me quelcom 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)",
@@ -956,7 +957,7 @@
"Emojis": "Emojis",
"Empty": "Buit",
"Empty message": "Missatge buit",
"Enable accessibility-focused visual enhancements.": "",
"Enable accessibility-focused visual enhancements.": "Activa les millores visuals centrades en l'accessibilitat.",
"Enable All": "Habilitar tot",
"Enable background sub-agents": "Activar els subagents en segon pla",
"Enable Code Execution": "Permetre l'execució de codi",
@@ -1049,7 +1050,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": "",
"Enter OpenSERP Base URL": "Introduïu l'URL base d'OpenSERP",
"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",
@@ -1218,7 +1219,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": "",
"Failed to load folder": "No s'ha pogut carregar la carpeta",
"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.",
@@ -1237,7 +1238,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": "",
"Failed to search files": "No s'han pogut cercar els fitxers",
"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",
@@ -1272,9 +1273,9 @@
"File Upload": "Pujar arxiu",
"File uploaded successfully": "Arxiu pujat satisfactòriament",
"Filename": "Nom de l'arxiu",
"Filename matches": "",
"Filename matches": "El nom del fitxer coincideix",
"Files": "Arxius",
"Filesystem": "",
"Filesystem": "Sistema de fitxers",
"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",
@@ -1307,6 +1308,7 @@
"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...",
@@ -1408,10 +1410,9 @@
"Hide": "Amaga",
"Hide All": "Amagar tots",
"Hide from Sidebar": "Amagar de la barra lateral",
"Hide Hidden Files": "",
"Hide Hidden Files": "Amaga els fitxers ocults",
"Hide Model": "Amagar el model",
"High": "Alt",
"High Contrast Mode": "Mode d'alt contrast",
"History": "Historial",
"Home": "Inici",
"Host": "Servidor",
@@ -1426,14 +1427,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 portaretalls",
"ID copied to clipboard": "L'ID s'ha copiat al porta-retalls",
"Identity audit": "Auditoria d'identitat",
"Idle only": "Només inactiu",
"Idle Timeout": "Temps d'espera d'inactivitat",
"iframe Sandbox Allow Downloads": "",
"iframe Sandbox Allow Downloads": "Permetre descàrregues iframe Sandbox",
"iframe Sandbox Allow Forms": "Permetre formularis sandbox iframe",
"iframe Sandbox Allow Same Origin": "Permetre same-origin sandbox iframe",
"iframe Sandbox Allow Scripts": "",
"iframe Sandbox Allow Scripts": "Permetre scripts sandbox iframe",
"Image": "Imatge",
"Image Compression": "Compressió d'imatges",
"Image Compression Height": "Alçada de la compressió d'imatges",
@@ -1448,7 +1449,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": "",
"Image unavailable": "Imatge no disponible",
"image/*, video/*": "imatge/*, vídeo/*",
"Images": "Imatges",
"Import": "Importar",
@@ -1456,7 +1457,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": "",
"Import CSV": "Importar CSV",
"Import From Link": "Importar des d'un enllaç",
"Import JSON": "Importar JSON",
"Import Models": "Importar models",
@@ -1468,7 +1469,7 @@
"Important Update": "Actualització important",
"Imported automations successfully": "Automatitzacions importades correctament",
"Imported notes successfully": "Notes importades correctament",
"Importing...": "",
"Importing...": "S'està important...",
"Inactive": "No actiu",
"Include": "Incloure",
"Include saved memories in the system context.": "Incloure les memòries desades al context del sistema.",
@@ -1482,10 +1483,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": "",
"Inline": "En línia",
"Input": "Entrada",
"Input Key (e.g. text, unet_name, steps)": "Entra la clau (p. ex. text, unet_name, steps)",
"Input needed": "",
"Input needed": "Es necessita entrada",
"Input Variables": "Variables d'entrada",
"Insert": "Inserir",
"Insert Follow-Up Prompt to Input": "Inserir un missatge de continuació per a l'entrada",
@@ -1502,7 +1503,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.": "",
"Invalid format.": "Format no vàlid.",
"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ó",
@@ -1543,7 +1544,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.": "",
"Knowledge base not found.": "No s'ha trobat la base de coneixement.",
"Knowledge created successfully.": "Coneixement creat correctament.",
"Knowledge deleted successfully.": "Coneixement eliminat correctament.",
"Knowledge Description": "Descripció del coneixement",
@@ -1617,8 +1618,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": "",
"Load Model": "",
"LM Studio": "LM Studio",
"Load Model": "Carregar model",
"Loaded": "Carregat",
"Loader": "Carregador",
"Loading Kokoro.js...": "Carregant Kokoro.js",
@@ -1664,7 +1665,7 @@
"Max concurrent": "Màxim concurrents",
"Max iterations": "Màxim d'iteracions",
"Max output": "Sortida màxima",
"Max Speakers": "Nombre màxim d'altaveus",
"Max Speakers": "Nombre màxim de parlants",
"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",
@@ -1747,18 +1748,18 @@
"Model Capabilities": "Capacitats del model",
"Model created successfully!": "Model creat correctament",
"Model Defaults": "Models per defecte",
"Model deleted successfully": "",
"Model download started": "",
"Model deleted successfully": "El model s'ha suprimit correctament",
"Model download started": "S'ha iniciat la descàrrega del model",
"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": "",
"Model ID is already added": "L'identificador del model ja s'ha afegit",
"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": "",
"Model loaded successfully": "",
"Model list refreshed": "S'ha actualitzat la llista de models",
"Model loaded successfully": "El model s'ha carregat correctament",
"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",
@@ -1771,7 +1772,7 @@
"Model Parameters": "Paràmetres del model",
"Model Params": "Paràmetres del model",
"Model Permissions": "Permisos dels models",
"Model providers": "",
"Model providers": "Proveïdors de models",
"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",
@@ -1837,7 +1838,7 @@
"New Tool": "Nova eina",
"New Webhook": "Nou webhook",
"new-channel": "nou-canal",
"Next": "",
"Next": "Següent",
"Next message": "Missatge següent",
"Next month": "El mes vinent",
"Next page": "Pàgina següent",
@@ -1849,12 +1850,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": "",
"No channels": "Sense canals",
"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 chats": "No hi ha xats",
"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.",
@@ -1946,7 +1947,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": "",
"OAuth discovery successful": "La descoberta d'OAuth ha estat correcta",
"OAuth ID": "ID OAuth",
"OAuth Resource Parameter": "Paràmetre de recurs OAuth",
"OAuth Scopes": "Àmbits d'OAuth",
@@ -1971,7 +1972,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.": "",
"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 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",
@@ -1993,7 +1994,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": "",
"Open Folder": "Obre la carpeta",
"Open in full screen": "Obrir en pantalla complerta",
"Open in new tab": "Obrir en una nova pestanya",
"Open link": "Obrir l'enllaç",
@@ -2024,12 +2025,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": "",
"OpenSERP URL": "URL d'OpenSERP",
"optional": "opcional",
"Optional": "Opcional",
"Optional API key for Ollama requests.": "Clau API opcional per a peticions a Ollama.",
"or": "o",
"Orchestrator": "",
"Orchestrator": "Orquestrador",
"Ordered List": "Llista ordenada",
"Other": "Altres",
"out of": "de",
@@ -2053,13 +2054,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.": "",
"Password updated. Please sign in again.": "Contrasenya actualitzada. Si us plau, torna a iniciar la sessió.",
"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": "",
"Pause a response to ask the user a clarifying question": "Posa en pausa una resposta per fer una pregunta d'aclariment a l'usuari",
"Paused": "Pausat",
"PDF document (.pdf)": "Document PDF (.pdf)",
"PDF Extract Images (OCR)": "Extreu imatges del PDF (OCR)",
@@ -2071,8 +2072,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 chat": "",
"Per automation": "Per automatització",
"Per chat": "Per xat",
"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",
@@ -2120,7 +2121,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": "",
"Please save the connection before authorizing OAuth": "Si us plau, desa la connexió abans d'autoritzar 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ó",
@@ -2138,14 +2139,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}}...": "",
"Preparing {{NAME}}...": "S'està preparant {{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": "",
"Previous": "",
"Preview failed": "La previsualització ha fallat",
"Previous": "Anterior",
"Previous 30 days": "30 dies anteriors",
"Previous 7 days": "7 dies anteriors",
"Previous message": "Missatge anterior",
@@ -2205,13 +2206,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.": "",
"Rebuild vectors for existing knowledge files, knowledge search, and memories.": "",
"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.",
"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": "",
"Recommended": "Recomanat",
"Reconnected": "Reconnectat",
"Record": "Enregistrar",
"Record voice": "Enregistrar la veu",
@@ -2238,8 +2239,8 @@
"Registration failed": "El registre ha fallat",
"Registration successful": "Registre correcte",
"Reindex": "Reindexar",
"Reindex Embedding Data": "",
"Reindex Knowledge and Memory Vectors": "",
"Reindex Embedding Data": "Reindexar les dades d'incrustació",
"Reindex Knowledge and Memory Vectors": "Reindexar els vectors de coneixement i memòria",
"Release Notes": "Notes de la versió",
"Releases": "Versions",
"Relevance": "Rellevància",
@@ -2289,7 +2290,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": "",
"Reset zoom": "Restableix el 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.",
@@ -2357,12 +2358,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": "",
"Search channels": "Cerca canals",
"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": "",
"Search files and contents": "Cerca fitxers i continguts",
"Search filters": "Filtres de cerca",
"Search Filters": "Filtres de cerca",
"Search folders": "Cercar carpetes",
@@ -2429,7 +2430,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": "",
"Select an instance": "Selecciona una instància",
"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",
@@ -2448,7 +2449,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.": "",
"Select the Tika server API version.": "Selecciona la versió de l'API del servidor Tika.",
"Select Tool": "Selecionar eina",
"Select view": "Seleccionar una vista",
"Selected": "Seleccionat",
@@ -2494,7 +2495,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.": "",
"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 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.",
@@ -2512,13 +2513,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": "",
"settings configured": "configuració establerta",
"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 portaretalls",
"Share link copied to clipboard.": "Compartir l'enllaç copiat al porta-retalls",
"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.",
@@ -2529,7 +2530,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.": "",
"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 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)",
@@ -2538,8 +2539,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.": "",
"Show Hidden Files": "",
"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 image preview": "Mostrar la previsualització de la imatge",
"Show Model": "Mostrar el model",
"Show more": "Mostrar-ne més",
@@ -2554,7 +2555,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": "",
"Sidebar": "Barra lateral",
"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",
@@ -2565,7 +2566,7 @@
"simultaneous sub-agents": "subagents simultanis",
"Single": "Únic",
"Sink List": "Enfonsar la llista",
"Size": "",
"Size": "Mida",
"sk-1234": "sk-1234",
"Skill created successfully": "Habilitat creada correctament",
"Skill deleted successfully": "Habilitat suprimida correctament",
@@ -2605,7 +2606,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.": "",
"Start the chat to use this terminal.": "Inicia el xat per utilitzar aquest 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",
@@ -2635,7 +2636,7 @@
"Su_day_of_week": "Diumenge",
"Sub Claim": "Reclamació sub",
"Sub-agents": "Subagents",
"Submit answers": "",
"Submit answers": "Envia les respostes",
"Submit question": "Enviar la pregunta",
"Submit suggestion": "Enviar un suggeriment",
"Subtitle": "Subtítol",
@@ -2683,7 +2684,7 @@
"Task List": "Llista de tasques",
"Task Management": "Gestió de tasques",
"Task Model": "Model de tasques",
"Task Model Parameters": "",
"Task Model Parameters": "Paràmetres del model de tasca",
"Tasks": "Tasques",
"tasks completed": "tasques completades",
"Tavily API Key": "Clau API de Tavily",
@@ -2693,9 +2694,9 @@
"Temporary Chat": "Xat temporal",
"Temporary Chat by Default": "Xat temporal per defecte",
"Terminal": "Terminal",
"Terminal Contexts": "",
"Terminal File Display": "",
"Terminal Preview Allow Same Origin": "",
"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 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.",
@@ -2727,7 +2728,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.": "",
"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.",
"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?",
@@ -2748,10 +2749,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.": "",
"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 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.": "",
"This will revoke the current API key.": "Això revocarà la clau de l'API actual.",
"Thorough explanation": "Explicació en detall",
"Thought": "Pensament",
"Thought for {{DURATION}}": "He pensat durant {{DURATION}}",
@@ -2761,12 +2762,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 4.x": "",
"Tika 3.x": "Tika 3.x",
"Tika 4.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": "",
"Tika Server Version": "Versió del servidor Tika",
"Tiktoken": "Tiktoken",
"Time": "Temps",
"Time & Calculation": "Temps i càlculs",
@@ -2786,7 +2787,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 d'altaveus CMU Arctic disponibles,",
"To see the available CMU Arctic speaker embeddings,": "Per veure les incrustacions dels parlants 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\".",
@@ -2814,7 +2815,7 @@
"tokens": "tokens",
"Tokens": "Tokens",
"Too verbose": "Massa explícit",
"tool": "",
"tool": "eina",
"Tool created successfully": "Eina creada correctament",
"Tool deleted successfully": "Eina eliminada correctament",
"Tool Description": "Descripció de l'eina",
@@ -2823,7 +2824,7 @@
"tool loops per sub-agent": "bucles d'eina per subagent",
"Tool Menu": "Menú d'eines",
"Tool Name": "Nom de l'eina",
"Tool Permissions": "",
"Tool Permissions": "Permisos d'eina",
"Tool Servers": "Servidors d'eines",
"Tool updated successfully": "Eina actualitzada correctament",
"Tools": "Eines",
@@ -2848,10 +2849,10 @@
"TTS Voice": "Veu TTS",
"Tu_day_of_week": "Dimarts",
"Type": "Tipus",
"Type a model ref": "",
"Type a model ref": "Escriu un model de referència",
"Type here...": "Escriu aquí...",
"Type Hugging Face Resolve (Download) URL": "Escriu la URL de Resolució (Descàrrega) de Hugging Face",
"Type your answer": "",
"Type your answer": "Escriu la teva resposta",
"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",
@@ -2862,7 +2863,7 @@
"unknown": "desconegut",
"Unknown": "Desconegut",
"Unknown User": "Usuari desconegut",
"Unload Model": "",
"Unload Model": "Descarregar model",
"Unloads {{FROM_NOW}}": "Es descarrega {{FROM_NOW}}",
"Unmute": "No silenciar",
"Unpin": "Alliberar",
@@ -2896,7 +2897,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": "",
"Upload failed": "La càrrega ha fallat",
"Upload files": "Pujar fitxers",
"Upload Files": "Pujar fitxers",
"Upload Model": "Pujar model",
@@ -2916,6 +2917,7 @@
"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.",
@@ -2946,7 +2948,7 @@
"User location successfully retrieved.": "Ubicació de l'usuari obtinguda correctament",
"User menu": "Menú d'usuari",
"User messages": "Missatges d'usuari",
"User not found.": "",
"User not found.": "No s'ha trobat l'usuari.",
"User Preview": "Previsualitzar l'usuari",
"User ratings (thumbs up/down)": "Valoracions dels usuaris (polze amunt/avall)",
"User Status": "Estats d'usuari",
@@ -2981,7 +2983,7 @@
"Version": "Versió",
"Version {{selectedVersion}} of {{totalVersions}}": "Versió {{selectedVersion}} de {{totalVersions}}",
"Version deleted": "Versió eliminada",
"View channel": "",
"View channel": "Veure canal",
"View chat": "Veure xat",
"View Replies": "Veure les respostes",
"View Result from {{NAME}}": "Mostra el resultat de {{NAME}}",
@@ -3001,7 +3003,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": "",
"Waiting for upload": "Esperant la pujada",
"Waiting for upload...": "Esperant per pujar...",
"Warning": "Avís",
"Warning:": "Avís:",
@@ -3102,6 +3104,6 @@
"YouTube": "YouTube",
"Youtube Language": "Idioma de YouTube",
"Youtube Proxy URL": "URL de Proxy de Youtube",
"Zoom in": "",
"Zoom out": ""
"Zoom in": "Apropa",
"Zoom out": "Allunya"
}
+3 -1
View File
@@ -68,6 +68,7 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "",
"Account": "Account",
"Account Activation Pending": "",
@@ -1297,6 +1298,7 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "",
"Followed instructions perfectly": "",
"Font Family": "",
"for placeholders": "",
"Force OCR": "",
"Forking chat...": "",
@@ -1401,7 +1403,6 @@
"Hide Hidden Files": "",
"Hide Model": "",
"High": "",
"High Contrast Mode": "",
"History": "",
"Home": "",
"Host": "",
@@ -2902,6 +2903,7 @@
"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.": "",
+3 -1
View File
@@ -82,6 +82,7 @@
"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",
@@ -1317,6 +1318,7 @@
"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...": "",
@@ -1421,7 +1423,6 @@
"Hide Hidden Files": "",
"Hide Model": "Skrýt model",
"High": "Vysoká",
"High Contrast Mode": "Režim vysokého kontrastu",
"History": "",
"Home": "Domů",
"Host": "Hostitel",
@@ -2930,6 +2931,7 @@
"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.": "",
+3 -1
View File
@@ -68,6 +68,7 @@
"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",
@@ -1297,6 +1298,7 @@
"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...": "",
@@ -1401,7 +1403,6 @@
"Hide Hidden Files": "",
"Hide Model": "Skjul model",
"High": "Høj",
"High Contrast Mode": "Høj kontrast tilstand",
"History": "",
"Home": "Hjem",
"Host": "Vært",
@@ -2902,6 +2903,7 @@
"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
+3 -1
View File
@@ -68,6 +68,7 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "",
"Account": "Account",
"Account Activation Pending": "",
@@ -1297,6 +1298,7 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "",
"Followed instructions perfectly": "",
"Font Family": "",
"for placeholders": "",
"Force OCR": "",
"Forking chat...": "",
@@ -1401,7 +1403,6 @@
"Hide Hidden Files": "",
"Hide Model": "",
"High": "",
"High Contrast Mode": "",
"History": "",
"Home": "",
"Host": "",
@@ -2902,6 +2903,7 @@
"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.": "",
+3 -1
View File
@@ -68,6 +68,7 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "Προσβάσιμο σε όλους τους χρήστες",
"Account": "Λογαριασμός",
"Account Activation Pending": "Ενεργοποίηση Λογαριασμού Εκκρεμεί",
@@ -1297,6 +1298,7 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "Αυτόματη Δημιουργία Συνέχισης",
"Followed instructions perfectly": "Ακολούθησε τις οδηγίες τέλεια",
"Font Family": "",
"for placeholders": "",
"Force OCR": "",
"Forking chat...": "",
@@ -1401,7 +1403,6 @@
"Hide Hidden Files": "",
"Hide Model": "Απόκρυψη Μοντέλου",
"High": "",
"High Contrast Mode": "Λειτουργία Υψηλής Αντίθεσης",
"History": "",
"Home": "Αρχική",
"Host": "Διακομιστής",
@@ -2902,6 +2903,7 @@
"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.": "",
+3 -1
View File
@@ -68,6 +68,7 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "",
"Account": "",
"Account Activation Pending": "",
@@ -1297,6 +1298,7 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "",
"Followed instructions perfectly": "",
"Font Family": "",
"for placeholders": "",
"Force OCR": "",
"Forking chat...": "",
@@ -1401,7 +1403,6 @@
"Hide Hidden Files": "",
"Hide Model": "",
"High": "",
"High Contrast Mode": "",
"History": "",
"Home": "",
"Host": "",
@@ -2902,6 +2903,7 @@
"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.": "",
+4 -1
View File
@@ -68,6 +68,7 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "",
"Account": "",
"Account Activation Pending": "",
@@ -1297,6 +1298,7 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "",
"Followed instructions perfectly": "",
"Font Family": "",
"for placeholders": "",
"Force OCR": "",
"Forking chat...": "",
@@ -1401,7 +1403,6 @@
"Hide Hidden Files": "",
"Hide Model": "",
"High": "",
"High Contrast Mode": "",
"History": "",
"Home": "",
"Host": "",
@@ -2883,6 +2884,7 @@
"Upload Audio": "",
"Upload directory": "",
"Upload failed": "",
"Upload failed for {{failed}} of {{total}} files.": "",
"Upload files": "",
"Upload Files": "",
"Upload Model": "",
@@ -2902,6 +2904,7 @@
"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.": "",
+3 -1
View File
@@ -75,6 +75,7 @@
"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",
@@ -1307,6 +1308,7 @@
"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...": "",
@@ -1411,7 +1413,6 @@
"Hide Hidden Files": "",
"Hide Model": "Ocultar Modelo",
"High": "Alto",
"High Contrast Mode": "Modo Alto Contraste",
"History": "Historial",
"Home": "Inicio",
"Host": "Host",
@@ -2916,6 +2917,7 @@
"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.": "",
+3 -1
View File
@@ -68,6 +68,7 @@
"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",
@@ -1297,6 +1298,7 @@
"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...": "",
@@ -1401,7 +1403,6 @@
"Hide Hidden Files": "",
"Hide Model": "Peida mudel",
"High": "Kõrge",
"High Contrast Mode": "Kõrge kontrastsuse režiim",
"History": "Ajalugu",
"Home": "Avaleht",
"Host": "Host",
@@ -2902,6 +2903,7 @@
"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.": "",
+3 -1
View File
@@ -68,6 +68,7 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "Erabiltzaile guztientzat eskuragarri",
"Account": "Kontua",
"Account Activation Pending": "Kontuaren Aktibazioa Zain",
@@ -1297,6 +1298,7 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "",
"Followed instructions perfectly": "Jarraibideak perfektuki jarraitu ditu",
"Font Family": "",
"for placeholders": "",
"Force OCR": "",
"Forking chat...": "",
@@ -1401,7 +1403,6 @@
"Hide Hidden Files": "",
"Hide Model": "",
"High": "",
"High Contrast Mode": "",
"History": "",
"Home": "",
"Host": "Ostalaria",
@@ -2902,6 +2903,7 @@
"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.": "",
+3 -1
View File
@@ -68,6 +68,7 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "قابل دسترسی برای همه کاربران",
"Account": "حساب کاربری",
"Account Activation Pending": "فعال\u200cسازی حساب در حال انتظار",
@@ -1297,6 +1298,7 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "تولید خودکار پیگیری",
"Followed instructions perfectly": "دستورالعمل ها را کاملا دنبال کرد",
"Font Family": "",
"for placeholders": "",
"Force OCR": "اجبار به OCR",
"Forking chat...": "",
@@ -1401,7 +1403,6 @@
"Hide Hidden Files": "",
"Hide Model": "پنهان کردن مدل",
"High": "بالا",
"High Contrast Mode": "حالت کنتراست بالا",
"History": "",
"Home": "خانه",
"Host": "میزبان",
@@ -2902,6 +2903,7 @@
"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.": "",
+3 -1
View File
@@ -68,6 +68,7 @@
"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",
@@ -1297,6 +1298,7 @@
"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...",
@@ -1401,7 +1403,6 @@
"Hide Hidden Files": "",
"Hide Model": "Piilota malli",
"High": "Korkea",
"High Contrast Mode": "Korkean kontrastin tila",
"History": "Historia",
"Home": "Koti",
"Host": "Palvelin",
@@ -2902,6 +2903,7 @@
"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ä.",
+3 -1
View File
@@ -69,6 +69,7 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "",
"Account": "Account",
"Account Activation Pending": "",
@@ -1298,6 +1299,7 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "",
"Followed instructions perfectly": "",
"Font Family": "",
"for placeholders": "",
"Force OCR": "",
"Forking chat...": "",
@@ -1402,7 +1404,6 @@
"Hide Hidden Files": "",
"Hide Model": "",
"High": "",
"High Contrast Mode": "",
"History": "Kasaysayan",
"Home": "Tahanan",
"Host": "",
@@ -2905,6 +2906,7 @@
"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.": "",
+3 -1
View File
@@ -68,6 +68,7 @@
"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",
@@ -1297,6 +1298,7 @@
"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...",
@@ -1401,7 +1403,6 @@
"Hide Hidden Files": "",
"Hide Model": "Fjal modell",
"High": "Høgt",
"High Contrast Mode": "Háur kontrastur",
"History": "Søga",
"Home": "Heim",
"Host": "Vertur",
@@ -2902,6 +2903,7 @@
"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ð.",
+3 -1
View File
@@ -75,6 +75,7 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "Accessible à tous les utilisateurs",
"Account": "Compte",
"Account Activation Pending": "Activation du compte en attente",
@@ -1307,6 +1308,7 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "Suivi de l'auto-génération",
"Followed instructions perfectly": "A parfaitement suivi les instructions",
"Font Family": "",
"for placeholders": "",
"Force OCR": "Forcer l'OCR",
"Forking chat...": "",
@@ -1411,7 +1413,6 @@
"Hide Hidden Files": "",
"Hide Model": "Cache le Model",
"High": "",
"High Contrast Mode": "Mode avec contraste accentué",
"History": "",
"Home": "Home",
"Host": "Hôte",
@@ -2916,6 +2917,7 @@
"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.": "",
+3 -1
View File
@@ -75,6 +75,7 @@
"Access List": "Liste des accès",
"Access prohibited": "",
"Access updated": "Accès mis à jour",
"Accessibility Mode": "",
"Accessible to all users": "Accessible à tous les utilisateurs",
"Account": "Compte",
"Account Activation Pending": "Activation du compte en attente",
@@ -1307,6 +1308,7 @@
"Follow up: {{question}}": "Question de suivi : {{question}}",
"Follow-Up Auto-Generation": "Génération automatique de questions de suivi",
"Followed instructions perfectly": "A parfaitement suivi les instructions",
"Font Family": "",
"for placeholders": "pour les variables dynamiques",
"Force OCR": "Forcer l'OCR",
"Forking chat...": "",
@@ -1411,7 +1413,6 @@
"Hide Hidden Files": "",
"Hide Model": "Cache le Model",
"High": "Elevé",
"High Contrast Mode": "Mode avec contraste accentué",
"History": "Historique",
"Home": "Home",
"Host": "Hôte",
@@ -2916,6 +2917,7 @@
"Use": "Utilisez",
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "Utilise le endpoint /v1/chat/completions au lieu de /v1/audio/transcriptions pour une meilleure précision potentielle.",
"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.": "",
+3 -1
View File
@@ -68,6 +68,7 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "Accesible para todos os usuarios",
"Account": "Conta",
"Account Activation Pending": "Activación da conta pendente",
@@ -1297,6 +1298,7 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "",
"Followed instructions perfectly": "Segiu as instruccions perfectamente",
"Font Family": "",
"for placeholders": "",
"Force OCR": "",
"Forking chat...": "",
@@ -1401,7 +1403,6 @@
"Hide Hidden Files": "",
"Hide Model": "",
"High": "",
"High Contrast Mode": "",
"History": "",
"Home": "",
"Host": "Host",
@@ -2902,6 +2903,7 @@
"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.": "",
+3 -1
View File
@@ -75,6 +75,7 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "",
"Account": "חשבון",
"Account Activation Pending": "",
@@ -1307,6 +1308,7 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "",
"Followed instructions perfectly": "עקב אחר ההוראות במושלמות",
"Font Family": "",
"for placeholders": "",
"Force OCR": "",
"Forking chat...": "",
@@ -1411,7 +1413,6 @@
"Hide Hidden Files": "",
"Hide Model": "הסתר מודל",
"High": "",
"High Contrast Mode": "",
"History": "",
"Home": "בית",
"Host": "",
@@ -2916,6 +2917,7 @@
"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.": "",
+3 -1
View File
@@ -68,6 +68,7 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "",
"Account": "खाता",
"Account Activation Pending": "",
@@ -1297,6 +1298,7 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "",
"Followed instructions perfectly": "निर्देशों का पूर्णतः पालन किया",
"Font Family": "",
"for placeholders": "",
"Force OCR": "",
"Forking chat...": "",
@@ -1401,7 +1403,6 @@
"Hide Hidden Files": "",
"Hide Model": "",
"High": "",
"High Contrast Mode": "",
"History": "",
"Home": "",
"Host": "",
@@ -2902,6 +2903,7 @@
"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.": "",
+3 -1
View File
@@ -75,6 +75,7 @@
"Access List": "",
"Access prohibited": "",
"Access updated": "",
"Accessibility Mode": "",
"Accessible to all users": "",
"Account": "Račun",
"Account Activation Pending": "",
@@ -1307,6 +1308,7 @@
"Follow up: {{question}}": "",
"Follow-Up Auto-Generation": "",
"Followed instructions perfectly": "Savršeno slijedio upute",
"Font Family": "",
"for placeholders": "",
"Force OCR": "",
"Forking chat...": "",
@@ -1411,7 +1413,6 @@
"Hide Hidden Files": "",
"Hide Model": "",
"High": "",
"High Contrast Mode": "",
"History": "",
"Home": "",
"Host": "",
@@ -2916,6 +2917,7 @@
"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.": "",
+3 -1
View File
@@ -68,6 +68,7 @@
"Access List": "Hozzáférési lista",
"Access prohibited": "Hozzáférés megtagadva",
"Access updated": "Hozzáférés frissítve",
"Accessibility Mode": "",
"Accessible to all users": "Minden felhasználó számára elérhető",
"Account": "Fiók",
"Account Activation Pending": "Fiók aktiválása folyamatban",
@@ -1297,6 +1298,7 @@
"Follow up: {{question}}": "Következő kérdés: {{question}}",
"Follow-Up Auto-Generation": "Következő kérdések automatikus generálása",
"Followed instructions perfectly": "Tökéletesen követte az utasításokat",
"Font Family": "",
"for placeholders": "helyőrzőkhöz",
"Force OCR": "OCR kényszerítése",
"Forking chat...": "Beszélgetés elágaztatása...",
@@ -1401,7 +1403,6 @@
"Hide Hidden Files": "",
"Hide Model": "Modell elrejtése",
"High": "Magas",
"High Contrast Mode": "Nagy kontrasztú mód",
"History": "Előzmények",
"Home": "Kezdőlap",
"Host": "Hoszt",
@@ -2902,6 +2903,7 @@
"Use": "Használat",
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "A /v1/chat/completions végpont használata a /v1/audio/transcriptions helyett a potenciálisan jobb pontosság érdekében.",
"Use a Gemini API key for image generation.": "Gemini API-kulcs használata képgeneráláshoz.",
"Use a local font family for the app interface.": "",
"Use a valid event name or pattern like user.*": "Használjon érvényes eseménynevet vagy mintát, például user.*",
"Use a wider chat layout on large displays.": "Szélesebb beszélgetési elrendezés használata nagy kijelzőkön.",
"Use an API key when your ComfyUI server requires one.": "API-kulcs használata, ha a ComfyUI-szerver megköveteli.",

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