Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
966140c3ef | ||
|
|
6f583deff9 | ||
|
|
7eff527d16 | ||
|
|
d3e8bf3405 | ||
|
|
01f4282f1f | ||
|
|
f9590b8017 | ||
|
|
ecd48e2f71 | ||
|
|
b711935dd5 | ||
|
|
4d2e13cf2b | ||
|
|
02dc3e689c | ||
|
|
b868da6bcd | ||
|
|
90f4b4fcda | ||
|
+36 |
b3e9d16b6f | ||
|
|
3660bc00fd | ||
|
|
f51d2b026f | ||
|
+11 |
adc9076d17 | ||
|
+2 |
8dae237a0b | ||
|
|
0a8a620fb6 | ||
|
|
f31768e20e | ||
|
|
9bd84258d0 | ||
|
|
4d058a125b | ||
|
|
e4e69a10ec | ||
|
|
6c159a97b7 | ||
|
|
947dcd34bd | ||
|
|
79f0437980 | ||
|
|
6137f7cb7e | ||
|
|
9c9a18d6d4 | ||
|
|
1ac3dd4a89 | ||
|
|
2ed3055c42 | ||
|
|
b8112d72b9 | ||
|
|
4225791313 | ||
|
|
7c7fe44328 | ||
|
|
883f1dda0f | ||
|
|
7a7a25766c | ||
|
|
2b26355002 | ||
|
|
f2a360cb87 | ||
|
|
f9b0534e0c | ||
|
|
6adde203cd | ||
|
|
a7271532f8 | ||
|
|
d95f533214 | ||
|
|
6f1486ffd0 | ||
|
|
140605e660 | ||
|
|
9899293f05 | ||
|
|
e3faec62c5 | ||
|
|
fc05e0a6c5 | ||
|
|
fe6783c166 |
@@ -1,5 +1,5 @@
|
||||
name: Bug Report
|
||||
description: Tell us what broke in Open WebUI.
|
||||
description: Create a detailed bug report to help us improve Open WebUI.
|
||||
title: 'issue: '
|
||||
labels: ['bug', 'triage']
|
||||
body:
|
||||
@@ -8,28 +8,35 @@ body:
|
||||
value: |
|
||||
# Bug Report
|
||||
|
||||
Use this for real, reproducible bugs. A clear issue is the most useful contribution: include the affected workflow, the expected result, the actual result, and the details needed for someone else to reproduce it.
|
||||
## Important Notes
|
||||
|
||||
Before submitting, search open and closed [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions). The issue may already be reported or fixed on `dev`.
|
||||
- **Before submitting a bug report**: Please check the [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions) sections to see if a similar issue has already been reported. If unsure, start a discussion first, as this helps us efficiently focus on improving the project. Duplicates may be closed without notice. **Please search for existing issues AND discussions. No matter open or closed.**
|
||||
|
||||
Please do not open a code pull request for this report unless a maintainer asks for one, or the change is only i18n/localization. If you want to share code as reference, include it here as a local diff or patch. Actionable reproduction details are the most useful next step.
|
||||
- Check for opened, **but also for (recently) CLOSED issues** as the issue you are trying to report **might already have been fixed on the dev branch!**
|
||||
|
||||
Security vulnerabilities must not be reported publicly. Use the [GitHub security page](https://github.com/open-webui/open-webui/security) instead.
|
||||
- **Respectful collaboration**: Open WebUI is a volunteer-driven project with a single maintainer and contributors who also have full-time jobs. Please be constructive and respectful in your communication.
|
||||
|
||||
- **Contributing**: If you encounter an issue, consider submitting a pull request or forking the project. We prioritize preventing contributor burnout to maintain Open WebUI's quality.
|
||||
|
||||
- **Bug Reproducibility**: If a bug cannot be reproduced using a `:main` or `:dev` Docker setup or with `pip install` on Python 3.11, community assistance may be required. In such cases, we will move it to the "[Issues](https://github.com/open-webui/open-webui/discussions/categories/issues)" Discussions section. Your help is appreciated!
|
||||
|
||||
- **Scope**: If you want to report a SECURITY VULNERABILITY, then do so through our [GitHub security page](https://github.com/open-webui/open-webui/security).
|
||||
|
||||
- type: checkboxes
|
||||
id: issue-check
|
||||
attributes:
|
||||
label: Before Submitting
|
||||
label: Check Existing Issues
|
||||
description: Confirm that you’ve checked for existing reports before submitting a new one.
|
||||
options:
|
||||
- label: I searched open and closed issues and discussions for an existing report.
|
||||
- label: I have searched for any existing and/or related issues.
|
||||
required: true
|
||||
- label: I checked whether this is already fixed on the `dev` branch or latest source.
|
||||
- label: I have searched for any existing and/or related discussions.
|
||||
required: true
|
||||
- label: I understand that maintainers want a well-written issue before any code pull request.
|
||||
- label: I have also searched in the CLOSED issues AND CLOSED discussions and found no related items (your issue might already be addressed on the development branch!).
|
||||
required: true
|
||||
- label: I am using the latest available version of Open WebUI for my install method.
|
||||
- label: I have checked whether this issue is already fixed on the `dev` branch or in the latest source.
|
||||
required: true
|
||||
- label: This is not a security vulnerability.
|
||||
- label: I am using the latest version of Open WebUI.
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
@@ -38,9 +45,9 @@ body:
|
||||
label: Installation Method
|
||||
description: How did you install Open WebUI?
|
||||
options:
|
||||
- Docker
|
||||
- Pip Install
|
||||
- Git Clone
|
||||
- Pip Install
|
||||
- Docker
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
@@ -49,51 +56,67 @@ body:
|
||||
id: open-webui-version
|
||||
attributes:
|
||||
label: Open WebUI Version
|
||||
description: Specify the version, commit, or image tag.
|
||||
placeholder: v0.11.0, dev commit SHA, or Docker tag
|
||||
description: Specify the version (e.g., v0.11.0)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: ollama-version
|
||||
attributes:
|
||||
label: Ollama Version (if applicable)
|
||||
description: Specify the version (e.g., v0.32.5, or v0.32.6-rc0)
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: operating-system
|
||||
attributes:
|
||||
label: Operating System
|
||||
description: Specify the OS and version.
|
||||
placeholder: Windows 11, macOS Tahoe, Ubuntu 26.04, Debian 13
|
||||
description: Specify the OS (e.g., Windows 11, macOS Tahoe, Ubuntu 26.04, Debian 13)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: browser
|
||||
attributes:
|
||||
label: Browser
|
||||
description: If the bug appears in the browser, include browser and version.
|
||||
placeholder: Chrome 151.0, Firefox 153.0.3
|
||||
label: Browser (if applicable)
|
||||
description: Specify the browser/version (e.g., Chrome 151.0, Firefox 153.0.3)
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: ollama-version
|
||||
- type: checkboxes
|
||||
id: confirmation
|
||||
attributes:
|
||||
label: Ollama Version
|
||||
description: Include this if Ollama is involved.
|
||||
placeholder: v0.32.5
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Summary
|
||||
description: What is wrong, in a few sentences?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
label: Confirmation
|
||||
description: Ensure the following prerequisites have been met.
|
||||
options:
|
||||
- label: I have read and followed all instructions in `README.md`.
|
||||
required: true
|
||||
- label: I am using the latest version of **both** Open WebUI and Ollama.
|
||||
required: true
|
||||
- label: I have included the browser console logs.
|
||||
required: true
|
||||
- label: I have included the Docker container logs.
|
||||
required: true
|
||||
- label: I have **provided every relevant configuration, setting, and environment variable used in my setup.**
|
||||
required: true
|
||||
- label: I have clearly **listed every relevant configuration, custom setting, environment variable, and command-line option that influences my setup** (such as Docker Compose overrides, .env values, browser settings, authentication configurations, etc).
|
||||
required: true
|
||||
- label: |
|
||||
I have documented **step-by-step reproduction instructions that are precise, sequential, and leave nothing to interpretation**. My steps:
|
||||
- Start with the initial platform/version/OS and dependencies used,
|
||||
- Specify exact install/launch/configure commands,
|
||||
- List URLs visited, user input (incl. example values/emails/passwords if needed),
|
||||
- Describe all options and toggles enabled or changed,
|
||||
- Include any files or environmental changes,
|
||||
- Identify the expected and actual result at each stage,
|
||||
- Ensure any reasonably skilled user can follow and hit the same issue.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What should have happened?
|
||||
description: Describe what should have happened.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -101,7 +124,7 @@ body:
|
||||
id: actual-behavior
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
description: What actually happened?
|
||||
description: Describe what actually happened.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -109,21 +132,32 @@ body:
|
||||
id: reproduction-steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Include the exact commands, settings, URLs, model/provider setup, and user actions needed to hit the bug.
|
||||
description: |
|
||||
Please provide a **very detailed, step-by-step guide** to reproduce the issue. Your instructions should be so clear and precise that anyone can follow them without guesswork. Include every relevant detail—settings, configuration options, exact commands used, values entered, and any prerequisites or environment variables.
|
||||
**If full reproduction steps and all relevant settings are not provided, your issue may not be addressed.**
|
||||
**If your steps to reproduction are incomplete, lacking detail or not reproducible, your issue can not be addressed.**
|
||||
|
||||
placeholder: |
|
||||
1. Start Open WebUI with ...
|
||||
2. Configure ...
|
||||
3. Open ...
|
||||
4. Click ...
|
||||
5. See ...
|
||||
Example (include every detail):
|
||||
1. Start with a clean Ubuntu 26.04 install.
|
||||
2. Install Docker v29.7.1 and start the service.
|
||||
3. Clone the Open WebUI repo (git clone ...).
|
||||
4. Use the Docker Compose file without modifications.
|
||||
5. Open browser Chrome 151.0 in incognito mode.
|
||||
6. Go to http://localhost:8080 and log in with user "test@example.com".
|
||||
7. Set the language to "English" and theme to "Dark".
|
||||
8. Attempt to connect to Ollama at "http://localhost:11434".
|
||||
9. Observe that the error message "Connection refused" appears at the top right.
|
||||
|
||||
Please list each step carefully and include all relevant configuration, settings, and options.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs-screenshots
|
||||
attributes:
|
||||
label: Logs, Screenshots, and Config
|
||||
description: Include relevant browser console logs, server/container logs, screenshots, and configuration. If something does not apply, say so.
|
||||
label: Logs & Screenshots
|
||||
description: Include relevant logs, errors, or screenshots to help diagnose the issue.
|
||||
placeholder: 'Attach logs from the browser console, Docker logs, or error messages.'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -131,6 +165,13 @@ body:
|
||||
id: additional-info
|
||||
attributes:
|
||||
label: Additional Information
|
||||
description: Anything else that might help us understand the report.
|
||||
description: Provide any extra details that may assist in understanding the issue.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Note
|
||||
**If the bug report is incomplete, does not follow instructions or is lacking details it may not be addressed.** Ensure that you've followed all the **README.md** and **troubleshooting.md** guidelines, and provide all necessary information for us to reproduce the issue.
|
||||
Thank you for contributing to Open WebUI!
|
||||
|
||||
@@ -1,73 +1,78 @@
|
||||
name: Feature Request
|
||||
description: Describe what you would like Open WebUI to support.
|
||||
description: Suggest a new feature or improvement
|
||||
title: 'feat: '
|
||||
labels: ['triage']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# Feature Request
|
||||
## Before Submitting
|
||||
|
||||
Describe the requested behavior, the problem it solves, and any examples, mockups, screenshots, or workflows that clarify the request. A clear issue or discussion is the most useful contribution.
|
||||
Please check **open AND closed** [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions) for similar requests. If you find one, add your input there instead.
|
||||
|
||||
Search open and closed [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions) before submitting. If the request needs broad product, UX, architecture, compatibility, or maintenance discussion, please start in [Discussions](https://github.com/open-webui/open-webui/discussions) so the community can weigh in.
|
||||
### Scope Guidelines
|
||||
|
||||
Please do not open a code pull request for this request unless a maintainer asks for one, or the change is only i18n/localization. If you want to share code as reference, include it here as a local diff or patch. Clear product context is the most useful next step.
|
||||
Feature requests that require significant implementation effort should be posted in the **Ideas** section of [Discussions](https://github.com/open-webui/open-webui/discussions) instead. We will move oversized feature requests to Discussions to keep the Issues tab focused on actionable items.
|
||||
|
||||
Security vulnerabilities must not be reported publicly. Use the [GitHub security page](https://github.com/open-webui/open-webui/security) instead.
|
||||
If your request might impact the broader community, please open a Discussion first so others can weigh in on the design.
|
||||
|
||||
### Be Respectful
|
||||
|
||||
Open WebUI is a volunteer-driven project maintained by a small team. We value constructive, positive communication. Please be mindful of maintainers' time and energy.
|
||||
|
||||
### Contributing
|
||||
|
||||
If you encounter an issue, we encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout and maintain project quality.
|
||||
|
||||
### Reproducibility
|
||||
|
||||
If a bug cannot be reproduced with a `:main` or `:dev` Docker setup, or a `pip install` with Python 3.11, it may be moved to the "Issues" section in Discussions for community assistance.
|
||||
|
||||
- type: checkboxes
|
||||
id: existing-request
|
||||
id: existing-issue
|
||||
attributes:
|
||||
label: Before Submitting
|
||||
label: Check Existing Issues
|
||||
description: Confirm you have searched for similar requests.
|
||||
options:
|
||||
- label: I searched open and closed issues and discussions for an existing request.
|
||||
- label: I have searched all existing **open AND closed** issues and discussions and found none comparable to my request.
|
||||
required: true
|
||||
- label: I checked whether this already exists on the `dev` branch or latest source.
|
||||
- label: I have checked whether this request is already implemented on the `dev` branch or in the latest source.
|
||||
required: true
|
||||
- label: I understand that maintainers want a well-written issue or discussion before any code pull request.
|
||||
required: true
|
||||
- label: This request is not a security vulnerability.
|
||||
|
||||
- type: checkboxes
|
||||
id: feature-scope
|
||||
attributes:
|
||||
label: Verify Feature Scope
|
||||
description: Confirm this request belongs in Issues rather than Discussions.
|
||||
options:
|
||||
- label: I believe this feature request is appropriately scoped for the Issues section as described above.
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem-description
|
||||
attributes:
|
||||
label: Problem
|
||||
description: What is missing, frustrating, confusing, or unnecessarily hard today?
|
||||
placeholder: "I'm trying to..., but..."
|
||||
label: Problem Description
|
||||
description: Is this related to a problem? Describe the pain point clearly.
|
||||
placeholder: "e.g., I'm frustrated when..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: desired-behavior
|
||||
id: solution-description
|
||||
attributes:
|
||||
label: Desired Behavior
|
||||
description: What would you like to happen instead?
|
||||
placeholder: "I would like Open WebUI to..."
|
||||
label: Proposed Solution
|
||||
description: Describe what you would like to happen.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: why-it-matters
|
||||
attributes:
|
||||
label: Why This Matters
|
||||
description: Who benefits, and what workflow does this unlock or improve?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: examples
|
||||
attributes:
|
||||
label: Examples or References
|
||||
description: Add mockups, screenshots, links, prompts, workflows, or examples from other tools.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: alternatives-considered
|
||||
attributes:
|
||||
label: Alternatives or Workarounds
|
||||
description: What have you tried instead, if anything?
|
||||
validations:
|
||||
required: false
|
||||
label: Alternatives Considered
|
||||
description: Describe any alternative solutions or workarounds you have considered.
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, mockups, or screenshots about the feature request.
|
||||
|
||||
@@ -1,94 +1,126 @@
|
||||
<!--
|
||||
Important checks for contributors:
|
||||
1. Target the `dev` branch. PRs targeting `main` will be closed.
|
||||
2. Code pull requests are not the default contribution path.
|
||||
3. Do not open a code PR as the first step. Start with a well-written Issue or Discussion unless a maintainer asked for the PR or the change is only i18n/localization.
|
||||
4. Do not delete the Contributor License Agreement section at the bottom. The CLA bot requires it.
|
||||
⚠️ CRITICAL CHECKS FOR CONTRIBUTORS (READ, DON'T DELETE) ⚠️
|
||||
1. Target the `dev` branch. PRs targeting `main` will be automatically closed.
|
||||
2. First-time contributors should not open pull requests directly unless the pull request contains only i18n/localization updates.
|
||||
Do not open a PR as the first step.
|
||||
For real, reproducible bugs, start with a well-described Issue that explains the problem, why it matters, and what outcome you are looking for.
|
||||
For feature requests, enhancements, behavior changes, UI/UX changes, architecture changes, suspected fixes, or unconfirmed approaches, start with an active Discussion.
|
||||
If you want to propose an implementation, include it only as a reference in the Issue or Discussion, such as a local diff, patch, or branch.
|
||||
Opening an Issue or Discussion does not mean a PR is the right next step. Maintainers will confirm when a PR would be useful.
|
||||
We ask for this because PRs, especially from first-time contributors, often need broader maintainer context on product direction, scope, architecture, UX, edge cases, compatibility, documentation, and long-term maintenance before implementation.
|
||||
We may close unsolicited PRs without review.
|
||||
Contributors with a history of successful merged PRs may be given more latitude.
|
||||
3. Do NOT delete the CLA section at the bottom. It is required for the bot to accept your PR.
|
||||
-->
|
||||
|
||||
# Pull Request
|
||||
# Pull Request Checklist
|
||||
|
||||
Thanks for wanting to improve Open WebUI. The most useful contribution is usually a clear, well-written Issue, not an unsolicited code pull request.
|
||||
### Do not open a pull request as the first step.
|
||||
|
||||
Open a code pull request only when a maintainer asks for one, or when the change is only i18n/localization. For real, reproducible bugs, start with a well-described [Issue](https://github.com/open-webui/open-webui/issues). For feature requests, UI/UX changes, behavior changes, architecture changes, suspected fixes, or unconfirmed approaches, start with an active [Discussion](https://github.com/open-webui/open-webui/discussions).
|
||||
For real, reproducible bugs, start with a well-described [Issue](https://github.com/open-webui/open-webui/issues) that explains the problem, why it matters, and what outcome you are looking for.
|
||||
|
||||
Before continuing, make sure the linked Issue or Discussion explains the user-facing problem, the expected outcome, the affected workflow, and any examples, logs, screenshots, constraints, or reproduction details needed for maintainers to evaluate it.
|
||||
For feature requests, enhancements, behavior changes, UI/UX changes, architecture changes, suspected fixes, or unconfirmed approaches, start with an active [Discussion](https://github.com/open-webui/open-webui/discussions). Merely opening a discussion is not enough; it needs to be actively discussed.
|
||||
|
||||
If you have implementation notes, include them as reference in the Issue or Discussion. If you want to share code as reference, include it there as a local diff, patch, or branch note. Do not open a pull request for reference code.
|
||||
If you want to propose an implementation, include it only as a reference in the Issue or Discussion, such as a local diff, patch, or branch.
|
||||
|
||||
Unsolicited PRs may be closed without review, especially when they introduce product, architecture, compatibility, dependency, or maintenance decisions that have not been discussed.
|
||||
Opening an Issue or Discussion does not mean a PR is the right next step. Maintainers will confirm when a PR would be useful.
|
||||
|
||||
## Checklist
|
||||
We ask for this because PRs, especially from first-time contributors, often need broader maintainer context on product direction, scope, architecture, UX, edge cases, compatibility, documentation, and long-term maintenance before implementation.
|
||||
|
||||
- [ ] This PR targets the `dev` branch.
|
||||
- [ ] This PR links to a well-described, confirmed Issue or active Discussion: `Closes #___` / `Relates to #___`.
|
||||
- [ ] A maintainer explicitly asked me to open this PR, or this PR only updates i18n/localization.
|
||||
- [ ] The change is one logical unit with no unrelated commits.
|
||||
- [ ] I matched nearby code patterns and avoided unnecessary new settings, abstractions, or dependencies.
|
||||
- [ ] I manually tested the changed workflow and any nearby behavior that could be affected.
|
||||
- [ ] I updated relevant docs, including the [Open WebUI Docs Repository](https://github.com/open-webui/docs), if needed.
|
||||
- [ ] I added screenshots for UI changes, and a recording when motion or interaction matters.
|
||||
- [ ] I reviewed any AI-generated code before submitting it.
|
||||
- [ ] The PR title uses one of the prefixes listed below.
|
||||
Unsolicited PRs may be closed without review. Contributors with a history of successful merged PRs may be given more latitude.
|
||||
|
||||
## Title Prefix
|
||||
<!--
|
||||
### ⚠️ Important: Your PR is a contribution, not a guarantee of merge.
|
||||
|
||||
Use one of the following prefixes:
|
||||
We appreciate thoughtful contributions. Pull requests are for implementation-ready changes that have already been requested, confirmed, or actively discussed in a linked Issue or Discussion. Feature ideas, behavior changes, UI/UX changes, architecture changes, suspected fixes, and unconfirmed approaches should start as an Issue or Discussion instead.
|
||||
|
||||
- **BREAKING CHANGE**: Changes affecting backward compatibility
|
||||
- **build**: Build system or dependency changes
|
||||
- **ci**: CI/CD workflow changes
|
||||
- **chore**: Refactoring, cleanup, or non-functional changes
|
||||
- **docs**: Documentation additions or updates
|
||||
- **feat**: New features or enhancements
|
||||
- **fix**: Bug fixes or corrections
|
||||
- **i18n**: Internationalization or localization changes
|
||||
- **perf**: Performance improvements
|
||||
- **refactor**: Code restructuring
|
||||
Before opening a PR, make sure the change has a clear linked problem, follows nearby patterns, has been manually tested, and accounts for related or downstream behavior. PRs that are ideas, prototypes, unresolved design questions, unchecked AI-generated code, symptom-only patches, one-off patches, or changes where affected paths have not been checked will usually be closed.
|
||||
|
||||
## Summary
|
||||
The most impactful way to contribute to Open WebUI is through well-written bug reports, detailed feature discussions, and thoughtful ideas. These directly shape the project. If you do open a pull request, please know that Open WebUI is held to the highest standard of code quality, consistency, and architectural coherence, and every line merged becomes something the core team must own, maintain, and support indefinitely. Submitted code may be refactored, rewritten, or used as inspiration for a different implementation. This is not a reflection of your work's quality. It is how we ensure that a small team can deeply understand and evolve every part of the codebase.
|
||||
-->
|
||||
|
||||
Describe the change, the problem it solves, and the impact on users.
|
||||
**Before submitting, make sure you've checked and filled out the following:**
|
||||
|
||||
## Testing
|
||||
- [ ] **Linked Issue/Discussion:** This PR references an existing, well-described [Issue](https://github.com/open-webui/open-webui/issues) for a real bug or an active, substantive [Discussion](https://github.com/open-webui/open-webui/discussions) for a feature request or enhancement — `Closes #___` / `Relates to #___`.
|
||||
- [ ] **First-time contributor policy:** This is not my first contribution to Open WebUI, this PR contains only i18n/localization updates, or a maintainer explicitly asked me to open this PR after reviewing the linked Issue or Discussion.
|
||||
- [ ] **Target branch:** The pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.**
|
||||
- [ ] **Description:** A concise description of the changes is provided below.
|
||||
- [ ] **Changelog:** A changelog entry following [Keep a Changelog](https://keepachangelog.com/) format is included at the bottom.
|
||||
- [ ] **Documentation:** Relevant documentation has been added or updated in the [Open WebUI Docs Repository](https://github.com/open-webui/docs).
|
||||
- [ ] **Dependencies:** Any new or updated dependencies are explained, tested, and documented.
|
||||
- [ ] **Testing:** **Manual** end-to-end tests have been performed to verify the fix/feature works correctly and does not introduce regressions. Screenshots or recordings are included where applicable.
|
||||
- [ ] **User-facing changes:** I have confirmed whether this PR changes the UI. If it does, screenshots are required, and a video recording is recommended.
|
||||
- [ ] **No Unchecked AI Code:** This PR is either human-written or has undergone thorough human review AND manual testing. Unreviewed AI-generated PRs may be closed immediately.
|
||||
- [ ] **Self-Review:** A self-review of the code has been performed, ensuring adherence to project coding standards.
|
||||
- [ ] **Architecture:** Smart defaults are preferred over new settings. Local state is used for ephemeral UI logic. Major architectural or UX changes have been discussed first.
|
||||
- [ ] **Git Hygiene:** The PR is atomic (one logical change), rebased on `dev`, and contains no unrelated commits.
|
||||
- [ ] **Title Prefix:** The PR title uses one of the following prefixes:
|
||||
- **BREAKING CHANGE**: Changes affecting backward compatibility
|
||||
- **build**: Build system or dependency changes
|
||||
- **ci**: CI/CD workflow changes
|
||||
- **chore**: Refactoring, cleanup, or non-functional changes
|
||||
- **docs**: Documentation additions or updates
|
||||
- **feat**: New features or enhancements
|
||||
- **fix**: Bug fixes or corrections
|
||||
- **i18n**: Internationalization or localization changes
|
||||
- **perf**: Performance improvements
|
||||
- **refactor**: Code restructuring
|
||||
|
||||
List the exact manual checks you ran. Include commands, setup details, screenshots, or recordings where helpful.
|
||||
# Changelog Entry
|
||||
|
||||
## Changelog Entry
|
||||
### Description
|
||||
|
||||
- [Describe the changes, including motivation and impact]
|
||||
|
||||
### Added
|
||||
|
||||
-
|
||||
- [New features, functionalities, or additions]
|
||||
|
||||
### Changed
|
||||
|
||||
-
|
||||
- [Changes, updates, refactorings, or optimizations]
|
||||
|
||||
### Fixed
|
||||
### Deprecated
|
||||
|
||||
-
|
||||
- [Deprecated functionality or features]
|
||||
|
||||
### Removed
|
||||
|
||||
-
|
||||
- [Removed features, files, or functionalities]
|
||||
|
||||
### Fixed
|
||||
|
||||
- [Bug fixes or corrections]
|
||||
|
||||
### Security
|
||||
|
||||
-
|
||||
- [Security-related changes or vulnerability fixes]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
-
|
||||
- **BREAKING CHANGE**: [Changes affecting compatibility or functionality]
|
||||
|
||||
## Additional Context
|
||||
---
|
||||
|
||||
Add anything maintainers should know before review.
|
||||
### Additional Information
|
||||
|
||||
## Contributor License Agreement
|
||||
- [Any additional context, notes, or references to related issues/commits]
|
||||
|
||||
### Screenshots or Videos
|
||||
|
||||
- [Attach screenshots or videos for user-facing changes. For UI changes, screenshots are required, and a video recording is recommended.]
|
||||
|
||||
### Contributor License Agreement
|
||||
|
||||
<!--
|
||||
DO NOT DELETE THIS SECTION.
|
||||
Your PR will not be reviewed or merged until you check the box below confirming that you have read and agree to the CLA.
|
||||
🚨 DO NOT DELETE THE TEXT BELOW 🚨
|
||||
Keep the "Contributor License Agreement" confirmation text intact.
|
||||
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
|
||||
|
||||
Your PR will NOT be reviewed or merged until you check the box below confirming that you have read and agree to the terms of the CLA.
|
||||
-->
|
||||
|
||||
- [ ] By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
|
||||
|
||||
> [!NOTE]
|
||||
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
|
||||
|
||||
@@ -311,6 +311,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !cancelled() && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) }}
|
||||
needs: [merge]
|
||||
continue-on-error: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -402,16 +403,5 @@ jobs:
|
||||
[ -z "$TAG" ] && continue
|
||||
DEST="${DOCKERHUB_IMAGE}:${TAG}"
|
||||
echo " -> ${DEST}"
|
||||
for ATTEMPT in 1 2 3; do
|
||||
if docker buildx imagetools create -t "${DEST}" "${SOURCE}" && \
|
||||
docker buildx imagetools inspect "${DEST}"; then
|
||||
break
|
||||
fi
|
||||
if [ "${ATTEMPT}" = "3" ]; then
|
||||
echo "Failed to copy ${DEST} after ${ATTEMPT} attempts"
|
||||
exit 1
|
||||
fi
|
||||
echo "Copy attempt ${ATTEMPT} for ${DEST} failed, retrying in 15s..."
|
||||
sleep 15
|
||||
done
|
||||
docker buildx imagetools create -t "${DEST}" "${SOURCE}"
|
||||
done <<< "${{ steps.tags.outputs.tags }}"
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ FROM --platform=$BUILDPLATFORM node:22-alpine3.20 AS build
|
||||
ARG BUILD_HASH
|
||||
|
||||
# Set Node.js options (heap limit Allocation failed - JavaScript heap out of memory)
|
||||
# ENV NODE_OPTIONS="--max-old-space-size=4096"
|
||||
ENV NODE_OPTIONS="--max-old-space-size=8192"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import errno
|
||||
from enum import Enum
|
||||
|
||||
|
||||
_ERRNO_MESSAGES = {
|
||||
errno.ENAMETOOLONG: 'File name is too long.',
|
||||
errno.ENOSPC: 'The server is out of storage space.',
|
||||
@@ -120,7 +121,6 @@ class ERROR_MESSAGES(str, Enum):
|
||||
AUTOMATION_COUNT_REQUIRES_DTSTART = (
|
||||
'RRULE with COUNT requires an explicit DTSTART line to anchor the occurrence window'
|
||||
)
|
||||
CALENDAR_RRULE_TOO_FREQUENT = 'Recurring events cannot repeat more often than daily'
|
||||
|
||||
FEATURE_DISABLED = lambda name='': f'{name} is disabled'
|
||||
INPUT_TOO_LONG = lambda size='': f'Input prompt exceeds maximum length of {size}'
|
||||
|
||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -322,45 +321,6 @@ elif 'sqlite' in SQLALCHEMY_DATABASE_URL:
|
||||
|
||||
def _apply_sqlite_pragmas(dbapi_connection):
|
||||
"""Apply all configured SQLite PRAGMAs to a raw DBAPI connection."""
|
||||
# SQLite LIKE folds ASCII only; SQLAlchemy SQLite ILIKE compiles to lower(x) LIKE lower(?).
|
||||
compiled_patterns = {}
|
||||
|
||||
def like(pattern, value, escape=None):
|
||||
if pattern is None or value is None:
|
||||
return None
|
||||
|
||||
pattern = str(pattern).lower()
|
||||
escape = str(escape).lower() if escape is not None else None
|
||||
key = (pattern, escape)
|
||||
compiled = compiled_patterns.get(key)
|
||||
if compiled is False:
|
||||
return False
|
||||
if compiled is None:
|
||||
regex = []
|
||||
escaped = False
|
||||
for char in pattern:
|
||||
if escape and not escaped and char == escape:
|
||||
escaped = True
|
||||
continue
|
||||
regex.append(
|
||||
'.*' if not escaped and char == '%' else '.' if not escaped and char == '_' else re.escape(char)
|
||||
)
|
||||
escaped = False
|
||||
if escaped:
|
||||
compiled = False
|
||||
if len(compiled_patterns) >= 512:
|
||||
compiled_patterns.clear()
|
||||
compiled_patterns[key] = compiled
|
||||
return False
|
||||
compiled = re.compile(''.join(regex), re.DOTALL)
|
||||
if len(compiled_patterns) >= 512:
|
||||
compiled_patterns.clear()
|
||||
compiled_patterns[key] = compiled
|
||||
|
||||
return compiled.fullmatch(str(value).lower()) is not None
|
||||
|
||||
dbapi_connection.create_function('like', 2, like, deterministic=True)
|
||||
dbapi_connection.create_function('like', 3, like, deterministic=True)
|
||||
cursor = dbapi_connection.cursor()
|
||||
if DATABASE_ENABLE_SQLITE_WAL:
|
||||
cursor.execute('PRAGMA journal_mode=WAL')
|
||||
|
||||
@@ -2980,11 +2980,6 @@ async def check_db_health():
|
||||
|
||||
|
||||
# --- static assets & files ---
|
||||
# Windows registry entries can override these with text/plain, which breaks module and wasm loading
|
||||
mimetypes.add_type('text/javascript', '.js')
|
||||
mimetypes.add_type('text/javascript', '.mjs')
|
||||
mimetypes.add_type('application/wasm', '.wasm')
|
||||
|
||||
# Serve build-time static assets (CSS, JS, images, favicon, etc.)
|
||||
app.mount('/static', StaticFiles(directory=STATIC_DIR), name='static')
|
||||
|
||||
@@ -3030,6 +3025,7 @@ def swagger_ui_html(*args, **kwargs):
|
||||
applications.get_swagger_ui_html = swagger_ui_html
|
||||
|
||||
if os.path.exists(FRONTEND_BUILD_DIR):
|
||||
mimetypes.add_type('text/javascript', '.js')
|
||||
pyodide_dir = FRONTEND_BUILD_DIR / 'pyodide'
|
||||
if os.path.exists(pyodide_dir):
|
||||
app.mount('/pyodide', CORSStaticFiles(directory=pyodide_dir), name='pyodide')
|
||||
|
||||
@@ -4,12 +4,10 @@ from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from open_webui.internal.db import Base, get_async_db_context
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.users import User, UserModel, UserResponse
|
||||
from open_webui.utils.automations import rrule_interval_seconds
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
@@ -28,7 +26,6 @@ from sqlalchemy import (
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
MIN_CALENDAR_RRULE_INTERVAL_SECONDS = 24 * 60 * 60
|
||||
|
||||
|
||||
####################
|
||||
@@ -194,20 +191,6 @@ class CalendarEventForm(BaseModel):
|
||||
meta: Optional[dict] = None
|
||||
attendees: Optional[list[dict]] = None
|
||||
|
||||
@field_validator('rrule')
|
||||
@classmethod
|
||||
def reject_sub_daily_rrule(cls, value: Optional[str]) -> Optional[str]:
|
||||
if value:
|
||||
try:
|
||||
interval = rrule_interval_seconds(value)
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise ValueError(ERROR_MESSAGES.AUTOMATION_INVALID_RRULE(e))
|
||||
if interval is not None and interval < MIN_CALENDAR_RRULE_INTERVAL_SECONDS:
|
||||
raise ValueError(ERROR_MESSAGES.CALENDAR_RRULE_TOO_FREQUENT)
|
||||
return value
|
||||
|
||||
|
||||
class CalendarEventUpdateForm(BaseModel):
|
||||
calendar_id: Optional[str] = None
|
||||
@@ -224,20 +207,6 @@ class CalendarEventUpdateForm(BaseModel):
|
||||
is_cancelled: Optional[bool] = None
|
||||
attendees: Optional[list[dict]] = None
|
||||
|
||||
@field_validator('rrule')
|
||||
@classmethod
|
||||
def reject_sub_daily_rrule(cls, value: Optional[str]) -> Optional[str]:
|
||||
if value:
|
||||
try:
|
||||
interval = rrule_interval_seconds(value)
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise ValueError(ERROR_MESSAGES.AUTOMATION_INVALID_RRULE(e))
|
||||
if interval is not None and interval < MIN_CALENDAR_RRULE_INTERVAL_SECONDS:
|
||||
raise ValueError(ERROR_MESSAGES.CALENDAR_RRULE_TOO_FREQUENT)
|
||||
return value
|
||||
|
||||
|
||||
class RSVPForm(BaseModel):
|
||||
status: str # 'accepted' | 'declined' | 'tentative' | 'pending'
|
||||
@@ -564,8 +533,7 @@ class CalendarEventTable:
|
||||
& (CalendarEvent.start_at < end)
|
||||
& or_(
|
||||
CalendarEvent.end_at.is_(None) & (CalendarEvent.start_at >= start),
|
||||
CalendarEvent.end_at.isnot(None)
|
||||
& ((CalendarEvent.end_at > start) | (CalendarEvent.start_at >= start)),
|
||||
CalendarEvent.end_at.isnot(None) & (CalendarEvent.end_at > start),
|
||||
)
|
||||
),
|
||||
# Recurring: fetch all (expansion in Python)
|
||||
|
||||
@@ -87,7 +87,7 @@ class PromptForm(BaseModel):
|
||||
data: dict | None = None
|
||||
meta: dict | None = None
|
||||
tags: list[str | None] = None
|
||||
access_grants: list[dict] | None = None
|
||||
access_grants: list[dict | None] = None
|
||||
version_id: str | None = None # Active version
|
||||
commit_message: str | None = None # For history tracking
|
||||
is_production: bool | None = True # Whether to set new version as production
|
||||
@@ -100,7 +100,7 @@ class PromptsTable:
|
||||
async def _to_prompt_model(
|
||||
self,
|
||||
prompt: Prompt,
|
||||
access_grants: list[AccessGrantModel] | None = None,
|
||||
access_grants: list[AccessGrantModel | None] = None,
|
||||
db: AsyncSession | None = None,
|
||||
) -> PromptModel:
|
||||
prompt_model = PromptModel.model_validate(prompt)
|
||||
@@ -334,9 +334,8 @@ class PromptsTable:
|
||||
tag_lower = tag.lower()
|
||||
|
||||
if dialect_name == 'sqlite':
|
||||
tag_lower = tag.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
|
||||
tag_clause = text(
|
||||
"EXISTS (SELECT 1 FROM json_each(prompt.tags) t WHERE t.value LIKE :tag_val ESCAPE '\\')"
|
||||
'EXISTS (SELECT 1 FROM json_each(prompt.tags) t WHERE LOWER(t.value) = :tag_val)'
|
||||
)
|
||||
elif dialect_name == 'postgresql':
|
||||
tag_clause = text(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -367,9 +367,7 @@ class UsersTable:
|
||||
query = select(User).where(sub_expr == sub)
|
||||
# SQLite preserves JSON numeric type here; Postgres ->> already compares numeric JSON as text.
|
||||
if session.get_bind().dialect.name == 'sqlite' and sub.isdecimal():
|
||||
sub_int = int(sub)
|
||||
if str(sub_int) == sub and sub_int <= 2**63 - 1:
|
||||
query = select(User).where(or_(sub_expr == sub, sub_expr == sub_int))
|
||||
query = select(User).where(or_(sub_expr == sub, sub_expr == int(sub)))
|
||||
row = (await session.execute(query)).scalars().first()
|
||||
return UserModel.model_validate(row) if row else None
|
||||
|
||||
|
||||
@@ -552,12 +552,7 @@ async def upload_image(request, image_data, content_type, metadata, user, db=Non
|
||||
)
|
||||
|
||||
url = request.app.url_path_for('get_file_content_by_id', id=file_item.id)
|
||||
return file_item, {
|
||||
'id': file_item.id,
|
||||
'url': url,
|
||||
'name': (file_item.meta or {}).get('name') or file_item.filename,
|
||||
'content_type': (file_item.meta or {}).get('content_type'),
|
||||
}
|
||||
return file_item, url
|
||||
|
||||
|
||||
@router.post('/generations')
|
||||
@@ -673,8 +668,8 @@ async def image_generations(
|
||||
else:
|
||||
image_data, content_type = await get_image_data(image['b64_json'])
|
||||
|
||||
_, image_file = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
|
||||
images.append(image_file)
|
||||
_, url = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
|
||||
images.append({'url': url})
|
||||
return images
|
||||
|
||||
elif image_config.IMAGE_GENERATION_ENGINE == 'gemini':
|
||||
@@ -717,21 +712,21 @@ async def image_generations(
|
||||
if model.endswith(':predict'):
|
||||
for image in res['predictions']:
|
||||
image_data, content_type = await get_image_data(image['bytesBase64Encoded'])
|
||||
_, image_file = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
|
||||
images.append(image_file)
|
||||
_, url = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
|
||||
images.append({'url': url})
|
||||
elif model.endswith(':generateContent'):
|
||||
for image in res['candidates']:
|
||||
for part in image['content']['parts']:
|
||||
if part.get('inlineData', {}).get('data'):
|
||||
image_data, content_type = await get_image_data(part['inlineData']['data'])
|
||||
_, image_file = await upload_image(
|
||||
_, url = await upload_image(
|
||||
request,
|
||||
image_data,
|
||||
content_type,
|
||||
{**data, **metadata},
|
||||
user,
|
||||
)
|
||||
images.append(image_file)
|
||||
images.append({'url': url})
|
||||
|
||||
return images
|
||||
|
||||
@@ -781,14 +776,14 @@ async def image_generations(
|
||||
headers,
|
||||
trusted_base_url=image_config.COMFYUI_BASE_URL,
|
||||
)
|
||||
_, image_file = await upload_image(
|
||||
_, url = await upload_image(
|
||||
request,
|
||||
image_data,
|
||||
content_type,
|
||||
{**form_data.model_dump(exclude_none=True), **metadata},
|
||||
user,
|
||||
)
|
||||
images.append(image_file)
|
||||
images.append({'url': url})
|
||||
return images
|
||||
elif image_config.IMAGE_GENERATION_ENGINE == 'automatic1111' or image_config.IMAGE_GENERATION_ENGINE == '':
|
||||
# Automatic1111 holds one checkpoint instance-wide, so set_image_model
|
||||
@@ -828,14 +823,14 @@ async def image_generations(
|
||||
|
||||
for image in res['images']:
|
||||
image_data, content_type = await get_image_data(image)
|
||||
_, image_file = await upload_image(
|
||||
_, url = await upload_image(
|
||||
request,
|
||||
image_data,
|
||||
content_type,
|
||||
{**data, 'info': res['info'], **metadata},
|
||||
user,
|
||||
)
|
||||
images.append(image_file)
|
||||
images.append({'url': url})
|
||||
return images
|
||||
except Exception as e:
|
||||
error = e
|
||||
@@ -1044,8 +1039,8 @@ async def image_edits(
|
||||
else:
|
||||
image_data, content_type = await get_image_data(image['b64_json'])
|
||||
|
||||
_, image_file = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
|
||||
images.append(image_file)
|
||||
_, url = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
|
||||
images.append({'url': url})
|
||||
return images
|
||||
|
||||
elif image_config.IMAGE_EDIT_ENGINE == 'gemini':
|
||||
@@ -1094,14 +1089,14 @@ async def image_edits(
|
||||
for part in image['content']['parts']:
|
||||
if part.get('inlineData', {}).get('data'):
|
||||
image_data, content_type = await get_image_data(part['inlineData']['data'])
|
||||
_, image_file = await upload_image(
|
||||
_, url = await upload_image(
|
||||
request,
|
||||
image_data,
|
||||
content_type,
|
||||
{**data, **metadata},
|
||||
user,
|
||||
)
|
||||
images.append(image_file)
|
||||
images.append({'url': url})
|
||||
|
||||
return images
|
||||
|
||||
@@ -1178,14 +1173,14 @@ async def image_edits(
|
||||
headers,
|
||||
trusted_base_url=image_config.IMAGES_EDIT_COMFYUI_BASE_URL,
|
||||
)
|
||||
_, image_file = await upload_image(
|
||||
_, url = await upload_image(
|
||||
request,
|
||||
image_data,
|
||||
content_type,
|
||||
{**form_data.model_dump(exclude_none=True), **metadata},
|
||||
user,
|
||||
)
|
||||
images.append(image_file)
|
||||
images.append({'url': url})
|
||||
|
||||
return images
|
||||
except Exception as e:
|
||||
|
||||
@@ -69,9 +69,6 @@ async def process_pipeline_inlet_filter(request, payload, user, models):
|
||||
if 'pipeline' in model:
|
||||
sorted_filters.append(model)
|
||||
|
||||
if not sorted_filters:
|
||||
return payload
|
||||
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
for filter in sorted_filters:
|
||||
urlIdx = filter.get('urlIdx')
|
||||
@@ -135,9 +132,6 @@ async def process_pipeline_outlet_filter(request, payload, user, models):
|
||||
if 'pipeline' in model:
|
||||
sorted_filters = [model] + sorted_filters
|
||||
|
||||
if not sorted_filters:
|
||||
return payload
|
||||
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
for filter in sorted_filters:
|
||||
urlIdx = filter.get('urlIdx')
|
||||
|
||||
@@ -938,26 +938,20 @@ async def _make_channel_emitter(request_info):
|
||||
state = {'last_emit_at': 0.0, 'output': []}
|
||||
THROTTLE_INTERVAL = 0.15 # ~6 updates/sec
|
||||
|
||||
async def _emit_channel_update(
|
||||
content: str,
|
||||
done: bool = False,
|
||||
output: list | None = None,
|
||||
data: dict | None = None,
|
||||
):
|
||||
async def _emit_channel_update(content: str, done: bool = False, output: list | None = None):
|
||||
from open_webui.models.messages import MessageForm, Messages
|
||||
|
||||
msg = await Messages.get_message_by_id(message_id)
|
||||
if not msg or msg.channel_id != channel_id:
|
||||
return
|
||||
|
||||
update_data = data or ({'output': output} if output else None)
|
||||
update_form = MessageForm(content=content, data=update_data)
|
||||
update_form = MessageForm(content=content, data={'output': output} if output else None)
|
||||
if done:
|
||||
# Merge done flag into existing meta (preserve model_id etc.)
|
||||
existing_meta = msg.meta or {}
|
||||
update_form = MessageForm(
|
||||
content=content,
|
||||
data=update_data,
|
||||
data={'output': output} if output else None,
|
||||
meta={**existing_meta, 'done': True},
|
||||
)
|
||||
|
||||
@@ -1006,29 +1000,6 @@ async def _make_channel_emitter(request_info):
|
||||
state['last_emit_at'] = now
|
||||
await _emit_channel_update(content, False, state['output'])
|
||||
|
||||
elif event_type in ('files', 'chat:message:files'):
|
||||
from open_webui.models.messages import Messages
|
||||
|
||||
files = event_data.get('data', {}).get('files', [])
|
||||
if not files:
|
||||
return
|
||||
|
||||
msg = await Messages.get_message_by_id(message_id)
|
||||
if not msg or msg.channel_id != channel_id:
|
||||
return
|
||||
|
||||
existing_files = (msg.data or {}).get('files')
|
||||
for file in files:
|
||||
if isinstance(file, dict) and file.get('id'):
|
||||
file['url'] = file['id']
|
||||
await Channels.add_file_to_channel_by_id(channel_id, file['id'], msg.user_id)
|
||||
await Channels.set_file_message_id_in_channel_by_id(channel_id, file['id'], message_id)
|
||||
|
||||
if isinstance(existing_files, list):
|
||||
files.extend(existing_files)
|
||||
|
||||
await _emit_channel_update(msg.content, data={'files': files})
|
||||
|
||||
elif event_type == 'chat:message:error':
|
||||
error = event_data.get('data', {}).get('error', {})
|
||||
error_content = error.get('content', 'An error occurred') if isinstance(error, dict) else str(error)
|
||||
|
||||
@@ -32,9 +32,6 @@ async def redis_task_command_listener(app):
|
||||
while True:
|
||||
pubsub = None
|
||||
try:
|
||||
# RedisCluster can't route a pubsub subscribe until initialize() fills its slot cache.
|
||||
await redis.initialize()
|
||||
|
||||
pubsub = redis.pubsub()
|
||||
await pubsub.subscribe(REDIS_PUBSUB_CHANNEL)
|
||||
reconnect_interval = REDIS_PUBSUB_RECONNECT_INTERVAL
|
||||
|
||||
@@ -389,16 +389,11 @@ async def generate_image(
|
||||
images = await image_generations(
|
||||
request=__request__,
|
||||
form_data=CreateImageForm(prompt=prompt),
|
||||
metadata=(
|
||||
{'channel_id': __chat_id__.removeprefix('channel:'), 'message_id': __message_id__}
|
||||
if isinstance(__chat_id__, str) and __chat_id__.startswith('channel:')
|
||||
else None
|
||||
),
|
||||
user=user,
|
||||
)
|
||||
|
||||
# Prepare file entries for the images
|
||||
image_files = [{'type': 'image', **img} for img in images]
|
||||
image_files = [{'type': 'image', 'url': img['url']} for img in images]
|
||||
|
||||
# Persist files to DB if chat context is available
|
||||
if is_saved_chat_id(__chat_id__) and __message_id__ and images:
|
||||
@@ -462,16 +457,11 @@ async def edit_image(
|
||||
images = await image_edits(
|
||||
request=__request__,
|
||||
form_data=EditImageForm(prompt=prompt, image=image_urls),
|
||||
metadata=(
|
||||
{'channel_id': __chat_id__.removeprefix('channel:'), 'message_id': __message_id__}
|
||||
if isinstance(__chat_id__, str) and __chat_id__.startswith('channel:')
|
||||
else None
|
||||
),
|
||||
user=user,
|
||||
)
|
||||
|
||||
# Prepare file entries for the images
|
||||
image_files = [{'type': 'image', **img} for img in images]
|
||||
image_files = [{'type': 'image', 'url': img['url']} for img in images]
|
||||
|
||||
# Persist files to DB if chat context is available
|
||||
if is_saved_chat_id(__chat_id__) and __message_id__ and images:
|
||||
|
||||
@@ -6,20 +6,17 @@ from open_webui.utils.json_codec import JSONCodec
|
||||
ASK_USER_NAME = 'ask_user'
|
||||
|
||||
|
||||
def get_ask_user_tool_calls(tool_calls: list[dict]) -> tuple[list[dict], str | None]:
|
||||
def get_ask_user_tool_call(tool_calls: list[dict]) -> tuple[dict | None, str | None]:
|
||||
ask_user_calls = [
|
||||
tool_call for tool_call in tool_calls if tool_call.get('function', {}).get('name') == ASK_USER_NAME
|
||||
]
|
||||
if not ask_user_calls:
|
||||
return [], None
|
||||
return None, None
|
||||
if len(tool_calls) != 1:
|
||||
return (
|
||||
ask_user_calls,
|
||||
'Error: ask_user must be the only tool call, so it did not run. Call ask_user on its own.',
|
||||
)
|
||||
return ask_user_calls[0], 'Error: ask_user must be called by itself after research.'
|
||||
if len(ask_user_calls) != 1:
|
||||
return ask_user_calls, 'Error: only one ask_user call is allowed per turn.'
|
||||
return ask_user_calls, None
|
||||
return ask_user_calls[0], 'Error: only one ask_user call is allowed per turn.'
|
||||
return ask_user_calls[0], None
|
||||
|
||||
|
||||
def normalize_ask_user_request(arguments: dict) -> dict:
|
||||
@@ -80,70 +77,68 @@ def normalize_ask_user_request(arguments: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def stage_ask_user_tool_calls(
|
||||
def stage_ask_user_tool_call(
|
||||
tool_calls: list[dict],
|
||||
output: list[dict],
|
||||
make_output_id: Callable[[str], str],
|
||||
) -> tuple[bool, str | None]:
|
||||
ask_user_calls, error = get_ask_user_tool_calls(tool_calls)
|
||||
if not ask_user_calls:
|
||||
return False, None
|
||||
) -> dict | None:
|
||||
tool_call, error = get_ask_user_tool_call(tool_calls)
|
||||
if not tool_call:
|
||||
return None
|
||||
|
||||
for tool_call in ask_user_calls:
|
||||
call_id = tool_call.get('id') or make_output_id('fc')
|
||||
raw_arguments = tool_call.get('function', {}).get('arguments', '{}')
|
||||
arguments = raw_arguments
|
||||
call_id = tool_call.get('id') or make_output_id('fc')
|
||||
raw_arguments = tool_call.get('function', {}).get('arguments', '{}')
|
||||
arguments = raw_arguments
|
||||
|
||||
if not error:
|
||||
try:
|
||||
parsed_arguments = JSONCodec.loads(raw_arguments or '{}')
|
||||
if not isinstance(parsed_arguments, dict):
|
||||
raise ValueError('ask_user arguments must be an object.')
|
||||
arguments = JSONCodec.dumps(normalize_ask_user_request(parsed_arguments))
|
||||
except (JSONCodec.JSONDecodeError, TypeError, ValueError) as exc:
|
||||
error = f'Error: {exc}'
|
||||
if not error:
|
||||
try:
|
||||
parsed_arguments = JSONCodec.loads(raw_arguments or '{}')
|
||||
if not isinstance(parsed_arguments, dict):
|
||||
raise ValueError('ask_user arguments must be an object.')
|
||||
arguments = JSONCodec.dumps(normalize_ask_user_request(parsed_arguments))
|
||||
except (JSONCodec.JSONDecodeError, TypeError, ValueError) as exc:
|
||||
error = f'Error: {exc}'
|
||||
|
||||
item = {
|
||||
'type': 'function_call',
|
||||
'id': call_id or make_output_id('fc'),
|
||||
'call_id': call_id,
|
||||
'name': ASK_USER_NAME,
|
||||
'arguments': arguments,
|
||||
'status': 'completed' if error else 'pending',
|
||||
}
|
||||
item = {
|
||||
'type': 'function_call',
|
||||
'id': call_id or make_output_id('fc'),
|
||||
'call_id': call_id,
|
||||
'name': ASK_USER_NAME,
|
||||
'arguments': arguments,
|
||||
'status': 'completed' if error else 'pending',
|
||||
}
|
||||
|
||||
existing_item = next(
|
||||
(
|
||||
existing
|
||||
for existing in output
|
||||
if existing.get('type') == 'function_call'
|
||||
and (
|
||||
existing.get('call_id') == call_id
|
||||
or existing.get('id') == tool_call.get('id')
|
||||
or (
|
||||
not existing.get('call_id')
|
||||
and existing.get('name') == ASK_USER_NAME
|
||||
and existing.get('status') not in {'rejected', 'failed'}
|
||||
)
|
||||
existing_item = next(
|
||||
(
|
||||
existing
|
||||
for existing in output
|
||||
if existing.get('type') == 'function_call'
|
||||
and (
|
||||
existing.get('call_id') == call_id
|
||||
or existing.get('id') == tool_call.get('id')
|
||||
or (
|
||||
not existing.get('call_id')
|
||||
and existing.get('name') == ASK_USER_NAME
|
||||
and existing.get('status') not in {'rejected', 'failed'}
|
||||
)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if existing_item:
|
||||
existing_item.update(item)
|
||||
else:
|
||||
output.append(item)
|
||||
|
||||
# Every invalid call needs its own result, or the UI waits on it forever.
|
||||
if error:
|
||||
output.append(
|
||||
{
|
||||
'type': 'function_call_output',
|
||||
'id': make_output_id('fco'),
|
||||
'call_id': call_id,
|
||||
'output': [{'type': 'input_text', 'text': error}],
|
||||
'status': 'completed',
|
||||
}
|
||||
)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if existing_item:
|
||||
existing_item.update(item)
|
||||
else:
|
||||
output.append(item)
|
||||
|
||||
return True, error
|
||||
if error:
|
||||
output.append(
|
||||
{
|
||||
'type': 'function_call_output',
|
||||
'id': make_output_id('fco'),
|
||||
'call_id': call_id,
|
||||
'output': [{'type': 'input_text', 'text': error}],
|
||||
'status': 'completed',
|
||||
}
|
||||
)
|
||||
|
||||
return {'call_id': call_id, 'error': error, 'item': item}
|
||||
|
||||
@@ -8,7 +8,6 @@ import datetime as dt
|
||||
import logging
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from dateutil.rrule import rrulestr
|
||||
from open_webui.utils.automations import _resolve_tz
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -26,17 +25,18 @@ def expand_recurring_event(
|
||||
Takes an event dict (from CalendarEventModel.model_dump()) and produces
|
||||
one dict per occurrence, with adjusted start_at / end_at.
|
||||
"""
|
||||
from dateutil.rrule import rrulestr
|
||||
|
||||
rrule_str = event_dict.get('rrule')
|
||||
if not rrule_str:
|
||||
return [event_dict]
|
||||
if 'EXRULE' in rrule_str.upper():
|
||||
log.warning(f'EXRULE is not supported for event {event_dict.get("id")}: {rrule_str}')
|
||||
return [event_dict]
|
||||
|
||||
user_timezone = _resolve_tz(tz)
|
||||
|
||||
def to_local_datetime(timestamp_ns: int) -> dt.datetime:
|
||||
return dt.datetime.fromtimestamp(timestamp_ns / 1_000_000_000, tz=user_timezone).replace(tzinfo=None)
|
||||
if user_timezone:
|
||||
return dt.datetime.fromtimestamp(timestamp_ns / 1_000_000_000, tz=user_timezone).replace(tzinfo=None)
|
||||
return dt.datetime.fromtimestamp(timestamp_ns / 1_000_000_000)
|
||||
|
||||
range_start = to_local_datetime(range_start_ns)
|
||||
range_end = to_local_datetime(range_end_ns)
|
||||
@@ -44,11 +44,10 @@ def expand_recurring_event(
|
||||
|
||||
original_start_ns = event_dict['start_at']
|
||||
original_start = to_local_datetime(original_start_ns)
|
||||
rule_str = '\n'.join(line for line in rrule_str.splitlines() if not line.upper().startswith('DTSTART')) or rrule_str
|
||||
|
||||
try:
|
||||
# Anchor to the event's real start so day-of-week / day-of-month are correct
|
||||
rule = rrulestr(rule_str, dtstart=original_start, ignoretz=True)
|
||||
rule = rrulestr(rrule_str, dtstart=original_start, ignoretz=True)
|
||||
except Exception:
|
||||
log.warning(f'Failed to parse RRULE for event {event_dict.get("id")}: {rrule_str}')
|
||||
return [event_dict]
|
||||
@@ -57,13 +56,13 @@ def expand_recurring_event(
|
||||
duration_ns = (original_end_ns - original_start_ns) if original_end_ns else None
|
||||
|
||||
instances = []
|
||||
previous_start = None
|
||||
for occurrence_start in rule.xafter(scan_start, count=max_instances, inc=True):
|
||||
if occurrence_start >= range_end or occurrence_start == previous_start:
|
||||
break
|
||||
previous_start = occurrence_start
|
||||
occurrence_start = rule.after(scan_start, inc=True)
|
||||
|
||||
instance_start_ns = int(occurrence_start.replace(tzinfo=user_timezone).timestamp() * 1_000_000_000)
|
||||
while occurrence_start and occurrence_start < range_end and len(instances) < max_instances:
|
||||
if user_timezone:
|
||||
instance_start_ns = int(occurrence_start.replace(tzinfo=user_timezone).timestamp() * 1_000_000_000)
|
||||
else:
|
||||
instance_start_ns = int(occurrence_start.timestamp() * 1_000_000_000)
|
||||
|
||||
if instance_start_ns >= range_start_ns:
|
||||
instance = {
|
||||
@@ -74,6 +73,8 @@ def expand_recurring_event(
|
||||
}
|
||||
instances.append(instance)
|
||||
|
||||
occurrence_start = rule.after(occurrence_start)
|
||||
|
||||
return instances
|
||||
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ def _safe_field(key: str, definition: dict[str, Any]) -> dict[str, Any]:
|
||||
'type',
|
||||
}
|
||||
field = {'key': key}
|
||||
for field_key in sorted(allowed_keys):
|
||||
for field_key in allowed_keys:
|
||||
if field_key in definition:
|
||||
field[field_key] = definition[field_key]
|
||||
|
||||
|
||||
@@ -54,16 +54,6 @@ _IMAGE_MIME_FALLBACK = {
|
||||
async def get_image_base64_from_url(url: str, user=None) -> Optional[str]:
|
||||
try:
|
||||
if url.startswith('http'):
|
||||
from open_webui.models.config import Config
|
||||
|
||||
max_bytes = None
|
||||
try:
|
||||
max_size_mb = int(await Config.get('rag.file.max_size') or 0)
|
||||
except (TypeError, ValueError):
|
||||
max_size_mb = 0
|
||||
if max_size_mb > 0:
|
||||
max_bytes = max_size_mb * 1024 * 1024
|
||||
|
||||
# Validate URL to prevent SSRF attacks against local/private networks.
|
||||
# allow_redirects=False prevents redirect-based SSRF: validate_url() is
|
||||
# called only on the originally-submitted URL; following 3xx redirects
|
||||
@@ -77,13 +67,7 @@ async def get_image_base64_from_url(url: str, user=None) -> Optional[str]:
|
||||
url, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
image_data = bytearray()
|
||||
total = 0
|
||||
async for chunk in response.content.iter_chunked(64 * 1024):
|
||||
total += len(chunk)
|
||||
if max_bytes is not None and total > max_bytes:
|
||||
return None
|
||||
image_data.extend(chunk)
|
||||
image_data = await response.read()
|
||||
encoded_string = base64.b64encode(image_data).decode('utf-8')
|
||||
content_type = response.headers.get('Content-Type', 'image/png')
|
||||
return f'data:{content_type};base64,{encoded_string}'
|
||||
@@ -102,14 +86,13 @@ async def get_image_url_from_base64(request, base64_image_string, metadata, user
|
||||
# Extract base64 image data from the line
|
||||
image_data, content_type = await get_image_data(base64_image_string)
|
||||
if image_data is not None:
|
||||
_, image_file = await upload_image(
|
||||
_, image_url = await upload_image(
|
||||
request,
|
||||
image_data,
|
||||
content_type,
|
||||
metadata,
|
||||
user,
|
||||
)
|
||||
image_url = image_file['url']
|
||||
|
||||
return image_url
|
||||
return None
|
||||
|
||||
@@ -10,16 +10,10 @@ log = logging.getLogger(__name__)
|
||||
|
||||
class FilterContext:
|
||||
def __init__(self):
|
||||
self.active_filters = None
|
||||
self.valves_by_id = None
|
||||
self.function_valves = {}
|
||||
self.user_valves = {}
|
||||
|
||||
async def get_active_filters(self):
|
||||
if self.active_filters is None:
|
||||
self.active_filters = await Functions.get_active_filter_ids()
|
||||
return self.active_filters
|
||||
|
||||
async def get_function_valves(self, filter_ids, filter_id, Valves):
|
||||
if filter_id not in self.function_valves:
|
||||
if self.valves_by_id is None:
|
||||
@@ -35,12 +29,6 @@ class FilterContext:
|
||||
return self.user_valves[user_valves_key]
|
||||
|
||||
|
||||
def get_filter_context(request):
|
||||
if not hasattr(request.state, 'filter_context'):
|
||||
request.state.filter_context = FilterContext()
|
||||
return request.state.filter_context
|
||||
|
||||
|
||||
async def get_user_valves(filter_id, user_id, UserValves):
|
||||
user_valves_data = await Functions.get_user_valves_by_id_and_user_id(filter_id, user_id)
|
||||
return UserValves(**(user_valves_data if user_valves_data else {}))
|
||||
@@ -69,7 +57,7 @@ async def resolve_filter_pipeline(request, model: dict, enabled_filter_ids: list
|
||||
if not ENABLE_PLUGINS:
|
||||
return [], []
|
||||
|
||||
active_filters = await get_filter_context(request).get_active_filters()
|
||||
active_filters = await Functions.get_active_filter_ids()
|
||||
filter_ids = get_model_filter_ids(model, active_filters)
|
||||
functions_by_id = {function.id: function for function in await Functions.get_functions_by_ids(filter_ids)}
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ from open_webui.tasks import clear_response_stream, save_response_stream
|
||||
from open_webui.utils.access_control import has_connection_access, has_permission
|
||||
from open_webui.utils.access_control.files import get_owner_accessible_folder_files
|
||||
from open_webui.utils.access_control.folders import has_folder_access
|
||||
from open_webui.utils.ask_user import stage_ask_user_tool_calls
|
||||
from open_webui.utils.ask_user import stage_ask_user_tool_call
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.utils.chat_id import is_saved_chat_id
|
||||
from open_webui.utils.code_interpreter import execute_code_jupyter
|
||||
@@ -258,7 +258,12 @@ def build_terminal_file_tool_result(
|
||||
if isinstance(tool_result, (list, tuple)) and tool_result and isinstance(tool_result[0], dict):
|
||||
tool_result = tool_result[0]
|
||||
|
||||
if tool_function_name != 'display_file' or not isinstance(tool_result, dict) or tool_result.get('exists') is False:
|
||||
if (
|
||||
tool_function_name != 'display_file'
|
||||
or tool_function_params.get('inline') is not True
|
||||
or not isinstance(tool_result, dict)
|
||||
or tool_result.get('exists') is False
|
||||
):
|
||||
return None
|
||||
|
||||
tool_id = (tool or {}).get('tool_id', '')
|
||||
@@ -279,7 +284,7 @@ def build_terminal_file_tool_result(
|
||||
**tool_result,
|
||||
'type': 'file',
|
||||
'source': 'open_terminal',
|
||||
**({'displayed': True} if tool_function_params.get('inline') is True else {}),
|
||||
'displayed': True,
|
||||
'terminal_selector': terminal_selector,
|
||||
**({'terminal_id': terminal_id} if terminal_id else {}),
|
||||
**({'terminal_url': server_url} if server_url and not terminal_id else {}),
|
||||
@@ -1766,12 +1771,6 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
|
||||
if not chat_id or not isinstance(chat_id, str) or not __event_emitter__:
|
||||
return form_data
|
||||
|
||||
is_channel_chat = chat_id.startswith('channel:')
|
||||
image_metadata = {
|
||||
'message_id': metadata.get('message_id', None),
|
||||
**({'channel_id': chat_id.removeprefix('channel:')} if is_channel_chat else {'chat_id': chat_id}),
|
||||
}
|
||||
|
||||
if not is_saved_chat_id(chat_id):
|
||||
message_list = form_data.get('messages', [])
|
||||
else:
|
||||
@@ -1816,7 +1815,10 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
|
||||
images = await image_edits(
|
||||
request=request,
|
||||
form_data=EditImageForm(**{'prompt': prompt, 'image': input_images}),
|
||||
metadata=image_metadata,
|
||||
metadata={
|
||||
'chat_id': metadata.get('chat_id', None),
|
||||
'message_id': metadata.get('message_id', None),
|
||||
},
|
||||
user=user,
|
||||
)
|
||||
|
||||
@@ -1834,7 +1836,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
|
||||
'files': [
|
||||
{
|
||||
'type': 'image',
|
||||
**image,
|
||||
'url': image['url'],
|
||||
}
|
||||
for image in images
|
||||
]
|
||||
@@ -1924,7 +1926,10 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
|
||||
images = await image_generations(
|
||||
request=request,
|
||||
form_data=CreateImageForm(**{'prompt': prompt}),
|
||||
metadata=image_metadata,
|
||||
metadata={
|
||||
'chat_id': metadata.get('chat_id', None),
|
||||
'message_id': metadata.get('message_id', None),
|
||||
},
|
||||
user=user,
|
||||
)
|
||||
|
||||
@@ -1942,7 +1947,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
|
||||
'files': [
|
||||
{
|
||||
'type': 'image',
|
||||
**image,
|
||||
'url': image['url'],
|
||||
}
|
||||
for image in images
|
||||
]
|
||||
@@ -2260,8 +2265,7 @@ def sanitize_tool_pairs(messages: list[dict]) -> list[dict]:
|
||||
return sanitized
|
||||
|
||||
|
||||
# Ids are validated as [a-z0-9_-]+ on create; matching that keeps ordinary "<$..." text intact.
|
||||
SKILL_MENTION_RE = re.compile(r'<(?:\$([a-z0-9_-]+)(?:\|[^>]*)?|/([a-z0-9_-]+)\|[^>]*)>')
|
||||
SKILL_MENTION_RE = re.compile(r'<(?:\$([^|>]+)(?:\|[^>]*)?|/([^|>]+)\|[^>]*)>')
|
||||
|
||||
|
||||
def _get_text_parts(message: dict) -> list[str]:
|
||||
@@ -2283,7 +2287,7 @@ def extract_skill_ids_from_messages(messages: list[dict]) -> set[str]:
|
||||
return ids
|
||||
|
||||
|
||||
SKILL_MENTION_STRIP_RE = re.compile(r'<(?:\$[a-z0-9_-]+(?:\|([^>]*))?|/[a-z0-9_-]+\|([^>]*))>')
|
||||
SKILL_MENTION_STRIP_RE = re.compile(r'<(?:\$[^|>]+(?:\|([^>]*))?|/[^|>]+\|([^>]*))>')
|
||||
|
||||
|
||||
def strip_skill_mentions(messages: list[dict]) -> None:
|
||||
@@ -5218,12 +5222,7 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
reasoning_detail_items,
|
||||
)
|
||||
await save_current_response_stream()
|
||||
# Providers such as OpenRouter send reasoning_details
|
||||
# alongside the reasoning text: only drop the event when
|
||||
# the details were all there was to report, otherwise the
|
||||
# reasoning delta never reaches the client.
|
||||
if not reasoning_content:
|
||||
data = None
|
||||
data = None
|
||||
|
||||
if value:
|
||||
if (
|
||||
@@ -5531,14 +5530,12 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
tool_call_iterations += 1
|
||||
|
||||
response_tool_calls = tool_calls.pop(0)
|
||||
ask_user_staged, ask_user_error = stage_ask_user_tool_calls(response_tool_calls, output, output_id)
|
||||
if ask_user_error:
|
||||
response_tool_calls = [
|
||||
tool_call
|
||||
for tool_call in response_tool_calls
|
||||
if tool_call.get('function', {}).get('name') != 'ask_user'
|
||||
]
|
||||
elif ask_user_staged:
|
||||
ask_user_stage = stage_ask_user_tool_call(response_tool_calls, output, output_id)
|
||||
if ask_user_stage:
|
||||
if ask_user_stage['error']:
|
||||
await event_emitter({'type': 'chat:completion', 'data': {'output': full_output()}})
|
||||
continue
|
||||
|
||||
if is_saved_chat_id(metadata.get('chat_id')) and metadata.get('message_id'):
|
||||
await pause_for_tool_approval(
|
||||
metadata['chat_id'],
|
||||
@@ -5570,8 +5567,7 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
|
||||
tool_approval_mode = metadata.get('params', {}).get('tool_approval_mode', 'full')
|
||||
if (
|
||||
response_tool_calls
|
||||
and tool_approval_mode == 'ask'
|
||||
tool_approval_mode == 'ask'
|
||||
and is_saved_chat_id(metadata.get('chat_id'))
|
||||
and metadata.get('message_id')
|
||||
):
|
||||
@@ -5793,6 +5789,17 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
item['arguments'] = tc.get('function', {}).get('arguments', '{}')
|
||||
break
|
||||
|
||||
# Append a new empty message item for the next response
|
||||
output.append(
|
||||
{
|
||||
'type': 'message',
|
||||
'id': output_id('msg'),
|
||||
'status': 'in_progress',
|
||||
'role': 'assistant',
|
||||
'content': [{'type': 'output_text', 'text': ''}],
|
||||
}
|
||||
)
|
||||
|
||||
# Emit citation sources to the frontend for display
|
||||
if citations_enabled:
|
||||
for source in tool_call_sources:
|
||||
|
||||
@@ -374,8 +374,6 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
||||
for filter_id in set(model.pop('filter_ids', [])) | global_filter_ids
|
||||
if filter_id in enabled_filter_ids
|
||||
]
|
||||
# Set order varies per process, and an unstable order defeats the RedisDict content signature.
|
||||
filter_ids.sort()
|
||||
|
||||
model['actions'] = []
|
||||
for action_id in action_ids:
|
||||
|
||||
@@ -148,15 +148,15 @@ async def build_tool_server_headers(
|
||||
cookies = {}
|
||||
|
||||
if auth_type == 'bearer':
|
||||
headers.update(bearer_auth_header(connection.get('key', '')))
|
||||
headers['Authorization'] = f'Bearer {connection.get("key", "")}'
|
||||
elif auth_type == 'session':
|
||||
cookies = request.cookies if hasattr(request, 'cookies') else {}
|
||||
headers.update(bearer_auth_header(request.state.token.credentials))
|
||||
headers['Authorization'] = f'Bearer {request.state.token.credentials}'
|
||||
elif auth_type == 'system_oauth':
|
||||
cookies = request.cookies if hasattr(request, 'cookies') else {}
|
||||
oauth_token = extra_params.get('__oauth_token__', None)
|
||||
if oauth_token:
|
||||
headers.update(bearer_auth_header(oauth_token.get('access_token', '')))
|
||||
headers['Authorization'] = f'Bearer {oauth_token.get("access_token", "")}'
|
||||
elif auth_type in ('oauth_2.1', 'oauth_2.1_static'):
|
||||
try:
|
||||
splits = server_id.split(':')
|
||||
@@ -166,7 +166,7 @@ async def build_tool_server_headers(
|
||||
user.id, f'{connection_type}:{oauth_server_id}'
|
||||
)
|
||||
if oauth_token:
|
||||
headers.update(bearer_auth_header(oauth_token.get('access_token', '')))
|
||||
headers['Authorization'] = f'Bearer {oauth_token.get("access_token", "")}'
|
||||
except Exception as e:
|
||||
log.error(f'Error getting OAuth token: {e}')
|
||||
|
||||
@@ -1338,13 +1338,7 @@ async def get_terminal_servers(request: Request):
|
||||
data = await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:terminal_servers')
|
||||
if data is not None:
|
||||
terminal_servers = JSONCodec.loads(data)
|
||||
connections = await Config.get('terminal_server.connections', []) or []
|
||||
if terminal_servers or not any(
|
||||
connection.get('url') and connection.get('enabled', True) for connection in connections
|
||||
):
|
||||
request.app.state.TERMINAL_SERVERS = terminal_servers
|
||||
else:
|
||||
terminal_servers = None
|
||||
request.app.state.TERMINAL_SERVERS = terminal_servers
|
||||
except Exception as e:
|
||||
log.error(f'Error fetching terminal_servers from Redis: {e}')
|
||||
|
||||
@@ -1368,10 +1362,7 @@ async def get_terminal_tools(
|
||||
- Builds callables that route through the terminal proxy
|
||||
"""
|
||||
connections = await Config.get('terminal_server.connections', []) or []
|
||||
connection = next(
|
||||
(terminal_connection for terminal_connection in connections if terminal_connection.get('id') == terminal_id),
|
||||
None,
|
||||
)
|
||||
connection = next((c for c in connections if c.get('id') == terminal_id), None)
|
||||
if connection is None:
|
||||
raise RuntimeError(f"Terminal server '{terminal_id}' not found")
|
||||
if not connection.get('enabled', True):
|
||||
@@ -1383,7 +1374,7 @@ async def get_terminal_tools(
|
||||
|
||||
# Find the cached spec data for this terminal
|
||||
terminal_servers = await get_terminal_servers(request)
|
||||
server_data = next((server for server in terminal_servers if server.get('id') == terminal_id), None)
|
||||
server_data = next((s for s in terminal_servers if s.get('id') == terminal_id), None)
|
||||
if server_data is None:
|
||||
raise RuntimeError(f"Terminal server '{terminal_id}' is unavailable")
|
||||
|
||||
|
||||
+22
-13
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interactive docker compose launcher for Open WebUI.
|
||||
@@ -100,38 +101,46 @@ done
|
||||
# ── Drop mode ─────────────────────────────────────────────────────────────────
|
||||
|
||||
if [[ "$drop_project" == true ]]; then
|
||||
docker compose down --remove-orphans
|
||||
./docker-stack.sh down --remove-orphans
|
||||
echo -e "${GREEN}${BOLD}Compose project stopped and cleaned up.${RESET}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Build compose command ─────────────────────────────────────────────────────
|
||||
#
|
||||
# The base + GPU + audio file set is owned by docker-stack.sh (the single
|
||||
# source of truth for "what this stack always needs"), so it can't drift out
|
||||
# of sync between the two entry points the way docker-compose.audio.yaml once
|
||||
# did here. This script only ever layers its own optional extras (api/data/
|
||||
# playwright) on top and delegates the actual `docker compose` call.
|
||||
|
||||
compose_files=("-f" "docker-compose.yaml")
|
||||
|
||||
if [[ "$enable_gpu" == true ]]; then
|
||||
if ! [[ "$gpu_count" =~ ^([0-9]+|all)$ ]]; then
|
||||
echo >&2 "Error: Invalid GPU count '${gpu_count}'. Must be a number or 'all'."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$gpu_count" =~ ^([0-9]+|all)$ ]]; then
|
||||
export OLLAMA_GPU_DRIVER
|
||||
OLLAMA_GPU_DRIVER=$(detect_gpu_driver)
|
||||
export OLLAMA_GPU_COUNT="$gpu_count"
|
||||
compose_files+=("-f" "docker-compose.gpu.yaml")
|
||||
elif [[ "$enable_gpu" == true ]]; then
|
||||
echo >&2 "Error: Invalid GPU count '${gpu_count}'. Must be a number or 'all'."
|
||||
exit 1
|
||||
fi
|
||||
# docker-stack.sh always merges docker-compose.gpu.yaml (defaults: nvidia,
|
||||
# count 1, from that file's own ${VAR-default} fallbacks) -- GPU passthrough
|
||||
# is no longer optional here, matching docker-stack.sh's behavior. --enable-gpu
|
||||
# now only customizes the driver/count via the env vars set above.
|
||||
|
||||
extra_files=()
|
||||
|
||||
if [[ "$enable_api" == true ]]; then
|
||||
export OLLAMA_WEBAPI_PORT="$api_port"
|
||||
compose_files+=("-f" "docker-compose.api.yaml")
|
||||
extra_files+=("-f" "docker-compose.api.yaml")
|
||||
fi
|
||||
|
||||
if [[ -n "$data_dir" ]]; then
|
||||
export OLLAMA_DATA_DIR="$data_dir"
|
||||
compose_files+=("-f" "docker-compose.data.yaml")
|
||||
extra_files+=("-f" "docker-compose.data.yaml")
|
||||
fi
|
||||
|
||||
if [[ "$enable_playwright" == true ]]; then
|
||||
compose_files+=("-f" "docker-compose.playwright.yaml")
|
||||
extra_files+=("-f" "docker-compose.playwright.yaml")
|
||||
fi
|
||||
|
||||
export OPEN_WEBUI_PORT="$webui_port"
|
||||
@@ -163,7 +172,7 @@ fi
|
||||
|
||||
# ── Launch ────────────────────────────────────────────────────────────────────
|
||||
|
||||
if docker compose "${compose_files[@]}" "${up_args[@]}"; then
|
||||
if ./docker-stack.sh "${extra_files[@]}" "${up_args[@]}"; then
|
||||
echo
|
||||
echo -e "${GREEN}${BOLD}${CHECK_MARK} Compose project started successfully.${RESET}"
|
||||
else
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
services:
|
||||
asr:
|
||||
build:
|
||||
context: ../asr-server
|
||||
container_name: asr
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '8020:8020'
|
||||
environment:
|
||||
- 'ASR_AUTH_TOKEN=${ASR_AUTH_TOKEN}'
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
|
||||
tts:
|
||||
build:
|
||||
context: ../tts-server
|
||||
container_name: tts
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '8010:8010'
|
||||
environment:
|
||||
- 'TTS_AUTH_TOKEN=${TTS_AUTH_TOKEN}'
|
||||
- 'HF_HOME=/app/data'
|
||||
# Fallback voice for direct /v1/audio/speech calls that use
|
||||
# AUDIO_TTS_MODEL=sopro-v2-turbo without a `voice` field. Set to the
|
||||
# basename (no .wav) of a clip dropped into services/tts-server/voices/.
|
||||
# - 'TTS_SOPRO_DEFAULT_VOICE=your_voice_name'
|
||||
volumes:
|
||||
# Caches the downloaded Kokoro + Sopro weights across rebuilds.
|
||||
- tts-data:/app/data
|
||||
# Sopro voice reference clips (<name>.wav); drop files in here on the
|
||||
# host and reference them by name via AUDIO_TTS_VOICE / `voice`.
|
||||
- ../tts-server/voices:/app/voices:ro
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
|
||||
open-webui:
|
||||
depends_on:
|
||||
- asr
|
||||
- tts
|
||||
environment:
|
||||
- 'AUDIO_STT_ENGINE=openai'
|
||||
- 'AUDIO_STT_OPENAI_API_BASE_URL=http://asr:8020/v1'
|
||||
- 'AUDIO_STT_OPENAI_API_KEY=${ASR_AUTH_TOKEN}'
|
||||
- 'AUDIO_STT_MODEL=nemotron-3.5-asr-streaming-0.6b'
|
||||
- 'AUDIO_TTS_ENGINE=openai'
|
||||
- 'AUDIO_TTS_OPENAI_API_BASE_URL=http://tts:8010/v1'
|
||||
- 'AUDIO_TTS_OPENAI_API_KEY=${TTS_AUTH_TOKEN}'
|
||||
# To switch back to Kokoro (fixed presets), set:
|
||||
# AUDIO_TTS_MODEL=kokoro-82m
|
||||
# AUDIO_TTS_VOICE=af_heart
|
||||
- 'AUDIO_TTS_MODEL=sopro-v2-turbo'
|
||||
- 'AUDIO_TTS_VOICE=jarvis-target'
|
||||
|
||||
wyoming-openai:
|
||||
image: ghcr.io/roryeckel/wyoming_openai:latest
|
||||
container_name: wyoming-openai
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- asr
|
||||
- tts
|
||||
ports:
|
||||
- '10300:10300'
|
||||
environment:
|
||||
- 'STT_OPENAI_URL=http://asr:8020/v1'
|
||||
- 'STT_OPENAI_KEY=${ASR_AUTH_TOKEN}'
|
||||
- 'STT_MODELS=nemotron-3.5-asr-streaming-0.6b'
|
||||
- 'TTS_OPENAI_URL=http://tts:8010/v1'
|
||||
- 'TTS_OPENAI_KEY=${TTS_AUTH_TOKEN}'
|
||||
- 'TTS_MODELS=sopro-v2-turbo'
|
||||
- 'TTS_VOICES=jarvis-target'
|
||||
- 'TTS_SPEED=1.2'
|
||||
|
||||
volumes:
|
||||
tts-data: {}
|
||||
@@ -0,0 +1,72 @@
|
||||
services:
|
||||
llama-mtp:
|
||||
build:
|
||||
context: ../mtp-relaxed-decoding
|
||||
container_name: llama-mtp
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '8030:8030'
|
||||
volumes:
|
||||
# Same host dir ollama's /gguf-import mount points at -- target +
|
||||
# MTP drafter already live there.
|
||||
- /home/brian-llm/models/gguf:/models:ro
|
||||
environment:
|
||||
- 'MTP_AUTH_TOKEN=${MTP_AUTH_TOKEN}'
|
||||
- 'MTP_TARGET_GGUF=/models/gemma-4-E2B-it-Q4_K_M.gguf'
|
||||
- 'MTP_DRAFT_GGUF=/models/mtp-gemma-4-E2B-it.gguf'
|
||||
# n_max beyond ~2 wasn't worth it for this drafter, and relaxed_top_n
|
||||
# 2-20 all performed similarly -- see ../mtp-relaxed-decoding/README.md
|
||||
# §6. Benchmarked on an RTX 5090 there; re-check on this Titan Xp
|
||||
# before trusting these as tuned rather than just reasonable defaults.
|
||||
- 'MTP_N_MAX=2'
|
||||
- 'MTP_RELAXED_TOP_N=5'
|
||||
# Titan Xp has 12GB VRAM shared with asr/tts/ollama -- batch size kept
|
||||
# below the README's RTX-5090 sizing (1024) for headroom, but context
|
||||
# was raised to match the README since Gemma-4's hybrid SWA
|
||||
# architecture (most layers use a small fixed attention window, only
|
||||
# every 5th layer is full-context) keeps KV cache growth with n_ctx
|
||||
# much cheaper than a plain transformer's -- measured at only 271 MiB
|
||||
# of KV cache (target + draft) per 32768-token slot.
|
||||
#
|
||||
# -np 4 gives open-webui's browser chat and the voice assistant (and
|
||||
# anything else hitting this OpenAI-compatible endpoint) their own
|
||||
# slot each instead of one contending for a single shared slot --
|
||||
# with -np 1, whichever of them touched the slot last would evict the
|
||||
# other's live KV cache, forcing a full prompt reprocess (multi-second
|
||||
# stall) on the next request from the loser. n_ctx_slot = n_ctx /
|
||||
# n_parallel, so total ctx is quadrupled to keep each slot at the same
|
||||
# 32768 budget as before -- 131072 matches this model's native
|
||||
# n_ctx_train exactly, and only costs ~810 MiB more KV cache VRAM
|
||||
# than the old -np 1 config (still well under the ~7GB that's free).
|
||||
- 'MTP_CTX_SIZE=131072'
|
||||
- 'MTP_N_PARALLEL=4'
|
||||
- 'MTP_BATCH_SIZE=512'
|
||||
# Server-side default -- only applies when the client doesn't send its
|
||||
# own temperature (Open WebUI won't unless you set it in the model's
|
||||
# Advanced Params).
|
||||
- 'MTP_TEMP=0.6'
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
|
||||
open-webui:
|
||||
depends_on:
|
||||
- llama-mtp
|
||||
environment:
|
||||
# Registers this alongside (not instead of) the Ollama connection --
|
||||
# Open WebUI lists both in the model picker.
|
||||
- 'OPENAI_API_BASE_URLS=http://llama-mtp:8030/v1'
|
||||
- 'OPENAI_API_KEYS=${MTP_AUTH_TOKEN}'
|
||||
|
||||
ollama-auth:
|
||||
depends_on:
|
||||
- llama-mtp
|
||||
environment:
|
||||
# ollama-auth/default.conf.template now proxies to llama-mtp (Ollama
|
||||
# itself serves no models anymore) and swaps the client's
|
||||
# OLLAMA_AUTH_TOKEN for this before forwarding upstream.
|
||||
- 'MTP_AUTH_TOKEN=${MTP_AUTH_TOKEN}'
|
||||
+89
-1
@@ -2,11 +2,49 @@ services:
|
||||
ollama:
|
||||
volumes:
|
||||
- ollama:/root/.ollama
|
||||
# Read-only mount for importing custom GGUF files (e.g. via `ollama create`)
|
||||
- /home/brian-llm/models/gguf:/gguf-import:ro
|
||||
container_name: ollama
|
||||
pull_policy: always
|
||||
tty: true
|
||||
restart: unless-stopped
|
||||
image: ollama/ollama:${OLLAMA_DOCKER_TAG-latest}
|
||||
environment:
|
||||
# Never unload a loaded model on idle -- default is 5 minutes.
|
||||
- 'OLLAMA_KEEP_ALIVE=-1'
|
||||
- 'OLLAMA_NUM_PARALLEL=4'
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
# No ports published here -- Ollama has no auth of its own, so LAN access
|
||||
# goes through the ollama-auth proxy below instead. open-webui still
|
||||
# reaches it directly over the internal docker network (see
|
||||
# OLLAMA_BASE_URL), which doesn't need the token.
|
||||
|
||||
ollama-auth:
|
||||
image: nginx:alpine
|
||||
container_name: ollama-auth
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- ollama
|
||||
volumes:
|
||||
- ./ollama-auth/default.conf.template:/etc/nginx/templates/default.conf.template:ro
|
||||
environment:
|
||||
- 'OLLAMA_AUTH_TOKEN=${OLLAMA_AUTH_TOKEN}'
|
||||
ports:
|
||||
# Reachable from the LAN directly; restrict to the LAN subnet with the
|
||||
# host firewall (see ufw rules) as defense in depth, but the real
|
||||
# boundary is now the Bearer token check in ollama-auth/default.conf.template.
|
||||
# This is plain HTTP, so the token is readable by anything already on
|
||||
# the LAN segment that can sniff traffic -- accepted here since the
|
||||
# trust boundary is "the LAN subnet," not "the wire." Revisit with TLS
|
||||
# if that assumption ever stops holding (untrusted/guest/IoT devices
|
||||
# sharing this LAN, etc).
|
||||
- '11434:11434'
|
||||
|
||||
open-webui:
|
||||
build:
|
||||
@@ -19,14 +57,64 @@ services:
|
||||
depends_on:
|
||||
- ollama
|
||||
ports:
|
||||
- ${OPEN_WEBUI_PORT-3000}:8080
|
||||
# Published on all interfaces since nginx runs on a separate LAN host
|
||||
# (192.168.50.224) and must reach this port directly. Firewalling this
|
||||
# to that host is still good hygiene (see ufw/DOCKER-USER notes) but is
|
||||
# no longer the sole security boundary -- OIDC auth below is real
|
||||
# authentication against Authentik regardless of network path.
|
||||
- '${OPEN_WEBUI_PORT-3000}:8080'
|
||||
environment:
|
||||
- 'OLLAMA_BASE_URL=http://ollama:11434'
|
||||
- 'WEBUI_SECRET_KEY='
|
||||
# Only takes effect on a fresh DB -- once ui.default_models exists in
|
||||
# the config table, that value wins (see open-webui's PersistentConfig
|
||||
# seed_defaults behavior). Currently gemma-4-e2b-mtp was removed from
|
||||
# Ollama in favor of the relaxed-MTP llama-mtp service; the live value
|
||||
# is 'gemma-4-e2b,/models/gemma-4-E2B-it-Q4_K_M.gguf'.
|
||||
- 'DEFAULT_MODELS=gemma-4-e2b,/models/gemma-4-E2B-it-Q4_K_M.gguf'
|
||||
- 'ENABLE_OAUTH_SIGNUP=true'
|
||||
# Links Authentik login to the existing local account with the same
|
||||
# email instead of rejecting it as a collision -- safe here since this
|
||||
# is a single-admin personal instance, not an open-signup multi-tenant one.
|
||||
- 'OAUTH_MERGE_ACCOUNTS_BY_EMAIL=true'
|
||||
# Fill these in after creating the OAuth2/OpenID Provider + Application
|
||||
# in Authentik (see instructions) -- OAUTH_CLIENT_ID/SECRET come from
|
||||
# that provider.
|
||||
- 'OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID}'
|
||||
- 'OAUTH_CLIENT_SECRET=${OAUTH_CLIENT_SECRET}'
|
||||
- 'OPENID_PROVIDER_URL=https://auth.hetherman.cloud/application/o/ai-open-webui/.well-known/openid-configuration'
|
||||
- 'OPENID_REDIRECT_URI=https://ai.hetherman.cloud/oauth/oidc/callback'
|
||||
- 'OAUTH_SCOPES=openid email profile'
|
||||
# SSO-only: Authentik becomes the sole way to authenticate, admin
|
||||
# included. If Authentik is ever unreachable, nobody can log in until
|
||||
# it's fixed -- no local password fallback.
|
||||
- 'ENABLE_PASSWORD_AUTH=false'
|
||||
- 'ENABLE_LOGIN_FORM=false'
|
||||
- 'ENABLE_SIGNUP=false'
|
||||
extra_hosts:
|
||||
- host.docker.internal:host-gateway
|
||||
restart: unless-stopped
|
||||
|
||||
# ollama-keepwarm:
|
||||
# image: curlimages/curl:latest
|
||||
# container_name: ollama-keepwarm
|
||||
# restart: unless-stopped
|
||||
# depends_on:
|
||||
# - ollama
|
||||
# # Pings Ollama every 60s to load the default model and refresh its
|
||||
# # keep_alive timer -- covers first boot and re-loads it any time the
|
||||
# # ollama container itself restarts (which flushes it from memory).
|
||||
# # Keep this model name in sync with DEFAULT_MODELS above.
|
||||
# entrypoint:
|
||||
# - sh
|
||||
# - -c
|
||||
# - |
|
||||
# while true; do
|
||||
# curl -s -o /dev/null -X POST http://ollama:11434/api/generate \
|
||||
# -d '{"model":"gemma-4-e2b","keep_alive":-1}'
|
||||
# sleep 60
|
||||
# done
|
||||
|
||||
volumes:
|
||||
ollama: {}
|
||||
open-webui: {}
|
||||
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||
|
||||
# Wraps docker compose with the full file set for this stack (base + GPU
|
||||
# passthrough + audio/ollama-auth services) so a plain "up"/"build" can't
|
||||
# accidentally drop the ollama GPU override again.
|
||||
readonly COMPOSE_FILES=(-f docker-compose.yaml -f docker-compose.gpu.yaml -f docker-compose.audio.yaml -f docker-compose.mtp.yaml)
|
||||
|
||||
if [[ $# -eq 0 ]]; then
|
||||
exec docker compose "${COMPOSE_FILES[@]}" up -d --build
|
||||
fi
|
||||
|
||||
exec docker compose "${COMPOSE_FILES[@]}" "$@"
|
||||
@@ -0,0 +1,39 @@
|
||||
map_hash_bucket_size 128;
|
||||
|
||||
map $http_authorization $ollama_auth_ok {
|
||||
"Bearer ${OLLAMA_AUTH_TOKEN}" 1;
|
||||
default 0;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 11434;
|
||||
|
||||
location / {
|
||||
if ($ollama_auth_ok = 0) {
|
||||
return 401;
|
||||
}
|
||||
|
||||
# Ollama itself now serves no models -- this proxy targets
|
||||
# llama-mtp (the relaxed-MTP server) instead, so LAN clients that
|
||||
# already trust this URL/token (e.g. Home Assistant's voice
|
||||
# pipeline) don't need reconfiguring. Docker's embedded DNS
|
||||
# (127.0.0.11) can reassign a container hostname to a new IP on
|
||||
# restart; proxy_pass to a literal upstream caches that IP for the
|
||||
# container's lifetime, so route through a variable to force
|
||||
# nginx to re-resolve via this resolver on each request instead.
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
set $mtp_upstream llama-mtp;
|
||||
proxy_pass http://$mtp_upstream:8030;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
# Client presents OLLAMA_AUTH_TOKEN (validated above); llama-server
|
||||
# behind this proxy expects its own MTP_AUTH_TOKEN instead, so swap
|
||||
# it here rather than requiring every client to be reconfigured.
|
||||
proxy_set_header Authorization "Bearer ${MTP_AUTH_TOKEN}";
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
client_max_body_size 0;
|
||||
}
|
||||
}
|
||||
+29
-91
@@ -108,23 +108,23 @@ textarea::-webkit-scrollbar-corner {
|
||||
}
|
||||
|
||||
.input-prose {
|
||||
@apply prose dark:prose-invert !text-[0.9375rem] prose-headings:font-medium prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 whitespace-pre-line;
|
||||
@apply prose dark:prose-invert !text-[0.9375rem] prose-headings:font-normal prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 whitespace-pre-line;
|
||||
}
|
||||
|
||||
.input-prose-sm {
|
||||
@apply prose dark:prose-invert prose-headings:font-medium prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-1 whitespace-pre-line text-sm;
|
||||
@apply prose dark:prose-invert prose-headings:font-normal prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-1 whitespace-pre-line text-sm;
|
||||
}
|
||||
|
||||
.markdown-prose {
|
||||
@apply prose prose-sm dark:prose-invert max-w-none break-words !text-[0.9375rem] font-normal leading-relaxed prose-p:mt-0 prose-p:mb-2 prose-p:font-normal prose-p:leading-relaxed prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-medium prose-headings:leading-snug prose-h1:text-xl prose-h2:text-lg prose-h3:text-base prose-strong:font-medium prose-code:before:content-none prose-code:after:content-none prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5 prose-li:font-normal prose-pre:my-3 prose-table:my-0 prose-blockquote:my-3 prose-blockquote:font-normal prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850/30 prose-img:my-2 [&>:first-child]:mt-0 [&>:last-child]:mb-0 [&_p:first-child]:mt-0 [&_h1:first-child]:mt-0 [&_h2:first-child]:mt-0 [&_h3:first-child]:mt-0 [&_h4:first-child]:mt-0 [&_h5:first-child]:mt-0 [&_h6:first-child]:mt-0;
|
||||
@apply prose prose-sm dark:prose-invert max-w-none break-words !text-[0.9375rem] font-normal leading-relaxed prose-p:mt-0 prose-p:mb-2 prose-p:font-normal prose-p:leading-relaxed prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-normal prose-headings:leading-snug prose-h1:text-xl prose-h2:text-lg prose-h3:text-base prose-strong:font-medium prose-code:before:content-none prose-code:after:content-none prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5 prose-li:font-normal prose-pre:my-3 prose-table:my-0 prose-blockquote:my-3 prose-blockquote:font-normal prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850/30 prose-img:my-2 [&>:first-child]:mt-0 [&>:last-child]:mb-0 [&_p:first-child]:mt-0 [&_h1:first-child]:mt-0 [&_h2:first-child]:mt-0 [&_h3:first-child]:mt-0 [&_h4:first-child]:mt-0 [&_h5:first-child]:mt-0 [&_h6:first-child]:mt-0;
|
||||
}
|
||||
|
||||
.markdown-prose-sm {
|
||||
@apply text-sm prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-medium prose-hr:my-2 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
||||
@apply text-sm prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-normal prose-hr:my-2 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
||||
}
|
||||
|
||||
.markdown-prose-xs {
|
||||
@apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-medium prose-hr:my-0.5 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
||||
@apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-normal prose-hr:my-0.5 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
||||
}
|
||||
|
||||
.markdown a {
|
||||
@@ -173,7 +173,6 @@ li p {
|
||||
}
|
||||
|
||||
select {
|
||||
color-scheme: light;
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236B7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");
|
||||
background-position: right 0rem center;
|
||||
background-repeat: no-repeat;
|
||||
@@ -187,10 +186,6 @@ select {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.dark select {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.dark select:not([class*='bg-transparent']) {
|
||||
@apply bg-gray-900 text-gray-300;
|
||||
}
|
||||
@@ -870,134 +865,77 @@ body {
|
||||
padding-bottom: 2rem; /* space for the bottom toolbar */
|
||||
}
|
||||
|
||||
/* Accessibility Mode: placeholders bottom out at 1.58:1 (WCAG 1.4.3 wants 4.5:1). */
|
||||
/* High Contrast Mode: placeholders bottom out at 1.58:1 (WCAG 1.4.3 wants 4.5:1). */
|
||||
@layer utilities {
|
||||
html.high-contrast:not(.dark) :is(input, textarea)::placeholder {
|
||||
color: var(--color-gray-700);
|
||||
color: var(--color-gray-600);
|
||||
}
|
||||
|
||||
html.high-contrast.dark :is(input, textarea)::placeholder {
|
||||
color: var(--color-gray-300);
|
||||
color: var(--color-gray-500);
|
||||
}
|
||||
}
|
||||
|
||||
/* Accessibility Mode: muted text fails WCAG 1.4.3 (2.07:1 light, 3.12:1 dark).
|
||||
/* High Contrast Mode: muted text fails WCAG 1.4.3 (2.07:1 light, 3.12:1 dark).
|
||||
:where() keeps these at low specificity so hover variants still win. */
|
||||
@layer utilities {
|
||||
html:where(.high-contrast):not(:where(.dark)) .text-gray-400 {
|
||||
color: var(--color-gray-700);
|
||||
color: var(--color-gray-600);
|
||||
}
|
||||
|
||||
html:where(.high-contrast).dark
|
||||
:is(.dark\:text-gray-400, .dark\:text-gray-500, .dark\:text-gray-600) {
|
||||
color: var(--color-gray-300);
|
||||
}
|
||||
|
||||
html:where(.high-contrast).dark
|
||||
:is(
|
||||
.dark\:hover\:text-gray-100:hover,
|
||||
.dark\:hover\:text-gray-200:hover,
|
||||
.dark\:hover\:text-gray-300:hover,
|
||||
.dark\:hover\:text-gray-400:hover
|
||||
) {
|
||||
color: #fff;
|
||||
html:where(.high-contrast).dark .dark\:text-gray-600 {
|
||||
color: var(--color-gray-400);
|
||||
}
|
||||
|
||||
/* Elements that hover to a lighter grey than the new resting colour. */
|
||||
html:where(.high-contrast):not(:where(.dark))
|
||||
:is(
|
||||
.hover\:text-gray-500:hover,
|
||||
.hover\:text-gray-600:hover,
|
||||
.hover\:text-gray-700:hover,
|
||||
.hover\:text-gray-800:hover
|
||||
) {
|
||||
color: var(--color-gray-900);
|
||||
html:where(.high-contrast):not(:where(.dark)) .hover\:text-gray-500:hover {
|
||||
color: var(--color-gray-800);
|
||||
}
|
||||
}
|
||||
|
||||
/* Accessibility Mode: gray-500 muted text is 2.77:1 in light (WCAG 1.4.3 wants 4.5:1).
|
||||
/* High Contrast Mode: gray-500 muted text is 2.77:1 in light (WCAG 1.4.3 wants 4.5:1).
|
||||
The utility stays in the layer so hover variants still outrank it; the classes below
|
||||
are unlayered, so their override has to be too. */
|
||||
@layer utilities {
|
||||
html:where(.high-contrast):not(:where(.dark)) :is(.text-gray-500, .text-gray-600) {
|
||||
color: var(--color-gray-700);
|
||||
html:where(.high-contrast):not(:where(.dark)) .text-gray-500 {
|
||||
color: var(--color-gray-600);
|
||||
}
|
||||
}
|
||||
|
||||
html.high-contrast:not(.dark) :is(.app-muted, .app-icon-muted, .tiptap table) {
|
||||
color: var(--color-gray-700);
|
||||
color: var(--color-gray-600);
|
||||
}
|
||||
|
||||
html.high-contrast.dark :is(.app-muted, .app-icon-muted, .tiptap table) {
|
||||
color: var(--color-gray-300);
|
||||
}
|
||||
|
||||
html.high-contrast:not(.dark)
|
||||
:is(
|
||||
#sidebar .hover\:bg-gray-100:hover,
|
||||
#sidebar .group:hover .group-hover\:bg-gray-100,
|
||||
.app-dropdown-menu :is(button, a):hover
|
||||
) {
|
||||
background-color: var(--color-gray-200) !important;
|
||||
}
|
||||
|
||||
html.high-contrast.dark
|
||||
:is(
|
||||
#sidebar .dark\:hover\:bg-gray-900:hover,
|
||||
#sidebar .group:hover .dark\:group-hover\:bg-gray-900,
|
||||
.app-dropdown-menu :is(button, a):hover
|
||||
) {
|
||||
background-color: var(--color-gray-800) !important;
|
||||
}
|
||||
|
||||
/* Accessibility Mode: the lightest muted pair, 1.58:1 in light and 2.14:1 in dark.
|
||||
/* High Contrast Mode: the lightest muted pair, 1.58:1 in light and 2.14:1 in dark.
|
||||
text-gray-400/70 is an icon colour, so 1.4.11 governs it, and 2.07:1 misses that too. */
|
||||
@layer utilities {
|
||||
html:where(.high-contrast):not(:where(.dark)) :is(.text-gray-300, .text-gray-400\/70) {
|
||||
color: var(--color-gray-700);
|
||||
color: var(--color-gray-600);
|
||||
}
|
||||
|
||||
html:where(.high-contrast).dark :is(.dark\:text-gray-700, .dark\:text-gray-800) {
|
||||
color: var(--color-gray-300);
|
||||
html:where(.high-contrast).dark .dark\:text-gray-700 {
|
||||
color: var(--color-gray-400);
|
||||
}
|
||||
|
||||
/* Hover states that land lighter than the new resting colour. */
|
||||
html:where(.high-contrast):not(:where(.dark))
|
||||
:is(
|
||||
.hover\:text-gray-500:hover,
|
||||
.hover\:text-gray-600:hover,
|
||||
.hover\:text-gray-700:hover,
|
||||
.hover\:text-gray-800:hover
|
||||
) {
|
||||
color: var(--color-gray-900);
|
||||
html:where(.high-contrast):not(:where(.dark)) .hover\:text-gray-500:hover {
|
||||
color: var(--color-gray-800);
|
||||
}
|
||||
|
||||
html:where(.high-contrast):not(:where(.dark))
|
||||
.group:hover
|
||||
:is(.group-hover\:text-gray-500, .group-hover\:text-gray-600, .group-hover\:text-gray-700) {
|
||||
color: var(--color-gray-900);
|
||||
}
|
||||
|
||||
html:where(.high-contrast).dark
|
||||
.group:hover
|
||||
:is(
|
||||
.dark\:group-hover\:text-gray-200,
|
||||
.dark\:group-hover\:text-gray-300,
|
||||
.dark\:group-hover\:text-gray-400
|
||||
) {
|
||||
color: #fff;
|
||||
html:where(.high-contrast):not(:where(.dark)) .group:hover .group-hover\:text-gray-500 {
|
||||
color: var(--color-gray-800);
|
||||
}
|
||||
}
|
||||
|
||||
/* Autocompletion ghost text is 2.65:1 in light; the dark canvas already passes. */
|
||||
html.high-contrast:not(.dark) .ai-autocompletion::after {
|
||||
color: var(--color-gray-700);
|
||||
color: var(--color-gray-600);
|
||||
}
|
||||
|
||||
/* The shimmer gradient is 2.10:1 in light, and clipping it to the text leaves no
|
||||
solid colour to raise; render it as flat text instead. */
|
||||
html.high-contrast:not(.dark) .shimmer {
|
||||
background: none;
|
||||
color: var(--color-gray-800);
|
||||
-webkit-text-fill-color: var(--color-gray-800);
|
||||
color: var(--color-gray-700);
|
||||
-webkit-text-fill-color: var(--color-gray-700);
|
||||
}
|
||||
|
||||
@@ -407,36 +407,6 @@ export const userSignOut = async () => {
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getLogoutRedirectUrl = (redirectUrl?: string | null) => {
|
||||
const logoutUrl = new URL('/auth?state=logout', window.location.origin);
|
||||
const postLogoutUrl = new URL('/auth', window.location.origin);
|
||||
if (!redirectUrl) {
|
||||
return logoutUrl.href;
|
||||
}
|
||||
|
||||
const url = new URL(redirectUrl, window.location.origin);
|
||||
if (url.origin === window.location.origin && url.pathname === '/auth') {
|
||||
url.searchParams.set('state', 'logout');
|
||||
return url.href;
|
||||
}
|
||||
|
||||
const postLogoutRedirectUri = url.searchParams.get('post_logout_redirect_uri');
|
||||
if (postLogoutRedirectUri) {
|
||||
const configuredPostLogoutUrl = new URL(postLogoutRedirectUri, window.location.origin);
|
||||
if (
|
||||
configuredPostLogoutUrl.origin === window.location.origin &&
|
||||
configuredPostLogoutUrl.pathname === '/auth'
|
||||
) {
|
||||
url.searchParams.set('state', 'logout');
|
||||
}
|
||||
return url.href;
|
||||
}
|
||||
|
||||
url.searchParams.set('post_logout_redirect_uri', postLogoutUrl.href);
|
||||
url.searchParams.set('state', 'logout');
|
||||
return url.href;
|
||||
};
|
||||
|
||||
export const addUser = async (
|
||||
token: string,
|
||||
name: string,
|
||||
|
||||
@@ -292,29 +292,6 @@ export const downloadFileBlob = async (
|
||||
return { blob, filename };
|
||||
};
|
||||
|
||||
export const downloadFilePreview = async (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
path: string,
|
||||
sessionId?: string
|
||||
): Promise<{ blob: Blob; filename: string } | null> => {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/files/view?path=${encodeURIComponent(path)}&preview=true`;
|
||||
const headers: Record<string, string> = bearerHeaders(apiKey);
|
||||
if (sessionId) headers['X-Session-Id'] = sessionId;
|
||||
const res = await fetch(url, { headers }).catch(() => null);
|
||||
|
||||
if (!res) return null;
|
||||
if (!res.ok) return null;
|
||||
|
||||
const contentType = res.headers.get('content-type') ?? '';
|
||||
const filename = path.split('/').pop() ?? 'file';
|
||||
if (!contentType.includes('application/pdf')) return null;
|
||||
|
||||
const blob = await res.blob().catch(() => null);
|
||||
if (!blob) return null;
|
||||
return { blob, filename };
|
||||
};
|
||||
|
||||
export const archiveFromTerminal = async (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<slot name="label">{label}</slot>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="shrink-0">
|
||||
<slot {labelId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -269,14 +269,6 @@
|
||||
|
||||
baseModels = await getBaseModels(localStorage.token, selectedTag);
|
||||
allModels = await getModels(localStorage.token);
|
||||
|
||||
const providerModels = await getModels(localStorage.token, null, true);
|
||||
const allModelIds = new Set<string>(allModels.map((model: ModelListItem) => model.id));
|
||||
allModels = [
|
||||
...allModels,
|
||||
...providerModels.filter((model: ModelListItem) => !allModelIds.has(model.id))
|
||||
];
|
||||
|
||||
const baseModelIds = new Set<string>(baseModels.map((model: ModelListItem) => model.id));
|
||||
|
||||
models = allModels
|
||||
|
||||
@@ -168,11 +168,7 @@
|
||||
</button>
|
||||
|
||||
{#if showCapabilities}
|
||||
<div
|
||||
class="max-h-[24rem] overflow-y-auto pb-2 pr-1 scrollbar-hover"
|
||||
on:click={updateDirty}
|
||||
on:change={updateDirty}
|
||||
>
|
||||
<div class="pb-2" on:click={updateDirty} on:change={updateDirty}>
|
||||
<Capabilities bind:capabilities={defaultCapabilities} />
|
||||
|
||||
{#if availableFeatures.length > 0}
|
||||
@@ -235,12 +231,7 @@
|
||||
</button>
|
||||
|
||||
{#if showPromptSuggestions}
|
||||
<div
|
||||
class="max-h-[24rem] overflow-y-auto pb-2 pr-1 scrollbar-hover"
|
||||
on:click={updateDirty}
|
||||
on:change={updateDirty}
|
||||
on:input={updateDirty}
|
||||
>
|
||||
<div class="pb-2" on:click={updateDirty} on:change={updateDirty} on:input={updateDirty}>
|
||||
<PromptSuggestions bind:promptSuggestions />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -25,10 +25,6 @@
|
||||
scrollToTab(selectedTab);
|
||||
}
|
||||
|
||||
$: if (loaded && selectedTab) {
|
||||
loadCounts();
|
||||
}
|
||||
|
||||
const scrollToTab = (tabId) => {
|
||||
const tabElement = document.getElementById(tabId);
|
||||
if (tabElement) {
|
||||
@@ -62,6 +58,7 @@
|
||||
await goto('/');
|
||||
}
|
||||
|
||||
await loadCounts();
|
||||
loaded = true;
|
||||
|
||||
const containerElement = document.getElementById('users-tabs-container');
|
||||
|
||||
@@ -48,10 +48,6 @@
|
||||
return (b.member_count ?? 0) - (a.member_count ?? 0) || a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
$: if (loaded) {
|
||||
adminGroupCount.set(filteredGroups.length);
|
||||
}
|
||||
|
||||
/** @type {any} */
|
||||
let defaultPermissions = {};
|
||||
|
||||
@@ -60,6 +56,7 @@
|
||||
|
||||
const setGroups = async () => {
|
||||
groups = await getGroups(localStorage.token);
|
||||
adminGroupCount.set(groups.length);
|
||||
};
|
||||
|
||||
/** @param {any} updatedGroup */
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
};
|
||||
|
||||
const formatSchedule = (rrule: string): string => {
|
||||
if (/COUNT=1(?!\d)/.test(rrule)) {
|
||||
if (rrule.includes('COUNT=1')) {
|
||||
const match = rrule.match(/DTSTART:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})/);
|
||||
if (match) {
|
||||
const d = new Date(`${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}`);
|
||||
@@ -109,9 +109,6 @@
|
||||
|
||||
const parts: Record<string, string> = {};
|
||||
rrule
|
||||
.split(/\s+/)
|
||||
.filter((line) => !line.toUpperCase().startsWith('DTSTART'))
|
||||
.join('')
|
||||
.replace('RRULE:', '')
|
||||
.split(';')
|
||||
.forEach((part) => {
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
|
||||
export const parseRrule = (s: string) => {
|
||||
// Detect ONCE (COUNT=1 with DTSTART)
|
||||
if (/COUNT=1(?!\d)/.test(s)) {
|
||||
if (s.includes('COUNT=1')) {
|
||||
frequency = 'ONCE';
|
||||
const match = s.match(/DTSTART:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})/);
|
||||
if (match) {
|
||||
@@ -116,10 +116,7 @@
|
||||
return;
|
||||
}
|
||||
const parts: Record<string, string> = {};
|
||||
s.split(/\s+/)
|
||||
.filter((line) => !line.toUpperCase().startsWith('DTSTART'))
|
||||
.join('')
|
||||
.replace('RRULE:', '')
|
||||
s.replace('RRULE:', '')
|
||||
.split(';')
|
||||
.forEach((p) => {
|
||||
const [k, v] = p.split('=');
|
||||
|
||||
@@ -129,12 +129,7 @@
|
||||
loading = true;
|
||||
try {
|
||||
const startNs = dateTimeToNs(startDate, allDay ? '00:00' : startTime);
|
||||
let endNs = endDate ? dateTimeToNs(endDate, allDay ? '23:59' : endTime) : undefined;
|
||||
if (endNs !== undefined && endNs < startNs) {
|
||||
const duration =
|
||||
event?.end_at && event.end_at > event.start_at ? event.end_at - event.start_at : 0;
|
||||
endNs = startNs + duration;
|
||||
}
|
||||
const endNs = endDate ? dateTimeToNs(endDate, allDay ? '23:59' : endTime) : undefined;
|
||||
|
||||
if (event && !event.meta?.automation_id) {
|
||||
const result = await updateCalendarEvent(localStorage.token, event.id, {
|
||||
|
||||
@@ -31,10 +31,7 @@
|
||||
const startDate = new Date(startMs);
|
||||
const endDate = new Date(endMs);
|
||||
const d = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());
|
||||
const last = Math.max(
|
||||
d.getTime(),
|
||||
new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate()).getTime()
|
||||
);
|
||||
const last = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate()).getTime();
|
||||
while (d.getTime() <= last) {
|
||||
const key = d.getTime().toString();
|
||||
(map[key] ??= []).push(e);
|
||||
@@ -93,7 +90,7 @@
|
||||
const dayEndMs = dayStartMs + 86_400_000;
|
||||
return filteredEvents.filter((e) => {
|
||||
const startMs = e.start_at / NS;
|
||||
const endMs = Math.max(startMs, (e.end_at || e.start_at) / NS);
|
||||
const endMs = (e.end_at || e.start_at) / NS;
|
||||
return startMs < dayEndMs && endMs >= dayStartMs;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1144,7 +1144,6 @@
|
||||
const terminalEventHandler = (type: string, data: any) => {
|
||||
if (type === 'terminal:display_file') {
|
||||
if (!data?.path) return;
|
||||
if ($settings?.terminalFileDisplay === 'inline') return;
|
||||
displayFileHandler(data.path, { showControls, showFileNavPath }, { page: data?.page });
|
||||
} else if (type === 'terminal:write_file' || type === 'terminal:replace_file_content') {
|
||||
if (!data?.path) return;
|
||||
@@ -1220,11 +1219,7 @@
|
||||
} else if (type === 'chat:active') {
|
||||
if (!data?.active) {
|
||||
taskIds = null;
|
||||
if (
|
||||
$chatId &&
|
||||
!$temporaryChatEnabled &&
|
||||
hasPendingAssistantLeaf(event?.message_id ?? null)
|
||||
) {
|
||||
if ($chatId && !$temporaryChatEnabled && hasPendingAssistantLeaf()) {
|
||||
await loadChat();
|
||||
}
|
||||
if ($chatId && !$temporaryChatEnabled) {
|
||||
@@ -1504,9 +1499,9 @@
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const hasPendingAssistantLeaf = (messageId: string | null = null) =>
|
||||
(messageId ? [history.messages[messageId]] : Object.values(history.messages)).some(
|
||||
(message: any) =>
|
||||
const hasPendingAssistantLeaf = () =>
|
||||
Object.values(history.messages).some(
|
||||
(message) =>
|
||||
message?.role === 'assistant' && !message.done && (message.childrenIds?.length ?? 0) === 0
|
||||
);
|
||||
|
||||
@@ -2118,9 +2113,8 @@
|
||||
|
||||
autoScroll = true;
|
||||
|
||||
// resetInput() must stay last: the selected model's defaults override the draft's selection.
|
||||
await restoreChatInput(sessionStorage.getItem('chat-input'));
|
||||
await resetInput();
|
||||
await restoreChatInput(sessionStorage.getItem('chat-input'));
|
||||
await chatId.set('');
|
||||
await chatTitle.set('');
|
||||
|
||||
@@ -2750,7 +2744,8 @@
|
||||
};
|
||||
|
||||
const chatCompletionEventHandler = async (data, message, chatId) => {
|
||||
const { id, done, choices, content, output, sources, selected_model_id, error, usage } = data;
|
||||
const { id, done, choices, content, output, sources, selected_model_id, error, usage, timings } =
|
||||
data;
|
||||
|
||||
// Store raw OR-aligned output items from backend
|
||||
if (output) {
|
||||
@@ -2803,8 +2798,12 @@
|
||||
message.arena = true;
|
||||
}
|
||||
|
||||
if (usage) {
|
||||
message.usage = usage;
|
||||
if (usage || timings) {
|
||||
// llama.cpp-compatible servers (e.g. our relaxed-MTP llama-server) send
|
||||
// generation speed as a sibling `timings` object rather than inside
|
||||
// `usage` -- merge it in so it surfaces in the same response-info
|
||||
// tooltip Ollama's eval_count/eval_duration fields already populate.
|
||||
message.usage = { ...(usage || {}), ...(timings || {}) };
|
||||
}
|
||||
|
||||
history.messages[message.id] = message;
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
// Persists across mount/unmount cycles (module-level, not per-instance)
|
||||
let savedPath = '/';
|
||||
let savedFileRoot = null;
|
||||
/** @type {Map<string, string[]>} */
|
||||
const treeExpandedCache = new Map();
|
||||
/** @type {Map<string, [string, any[]][]>} */
|
||||
const treeContentsCache = new Map();
|
||||
const treeExpandedCache = new Map<string, string[]>();
|
||||
const treeContentsCache = new Map<string, [string, any[]][]>();
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -25,7 +23,6 @@
|
||||
listFiles,
|
||||
readFile,
|
||||
downloadFileBlob,
|
||||
downloadFilePreview,
|
||||
archiveFromTerminal,
|
||||
uploadToTerminal,
|
||||
createDirectory,
|
||||
@@ -868,37 +865,19 @@
|
||||
);
|
||||
if (result) fileSqliteData = await result.blob.arrayBuffer();
|
||||
} else if (isOffice(filePath)) {
|
||||
const ext = getFileExt(filePath);
|
||||
try {
|
||||
if (ext === 'docx') {
|
||||
const preview = await downloadFilePreview(
|
||||
terminal.url,
|
||||
terminal.key,
|
||||
filePath,
|
||||
chatId ?? undefined
|
||||
);
|
||||
if (preview) {
|
||||
filePdfData = await preview.blob.arrayBuffer();
|
||||
} else {
|
||||
const result = await downloadFileBlob(
|
||||
terminal.url,
|
||||
terminal.key,
|
||||
filePath,
|
||||
chatId ?? undefined
|
||||
);
|
||||
if (!result) throw new Error('Preview failed');
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
const result = await downloadFileBlob(
|
||||
terminal.url,
|
||||
terminal.key,
|
||||
filePath,
|
||||
chatId ?? undefined
|
||||
);
|
||||
if (result) {
|
||||
const ext = getFileExt(filePath);
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
try {
|
||||
if (ext === 'docx') {
|
||||
fileDocxData = arrayBuffer;
|
||||
}
|
||||
} else if (ext === 'xlsx') {
|
||||
const result = await downloadFileBlob(
|
||||
terminal.url,
|
||||
terminal.key,
|
||||
filePath,
|
||||
chatId ?? undefined
|
||||
);
|
||||
if (result) {
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
} else if (ext === 'xlsx') {
|
||||
const XLSX = await import('xlsx');
|
||||
const wb = XLSX.read(new Uint8Array(arrayBuffer), { type: 'array' });
|
||||
excelWorkbook = wb;
|
||||
@@ -910,34 +889,16 @@
|
||||
const DOMPurify = (await import('dompurify')).default;
|
||||
fileOfficeHtml = DOMPurify.sanitize(result.html);
|
||||
}
|
||||
}
|
||||
} else if (ext === 'pptx') {
|
||||
const preview = await downloadFilePreview(
|
||||
terminal.url,
|
||||
terminal.key,
|
||||
filePath,
|
||||
chatId ?? undefined
|
||||
);
|
||||
if (preview) {
|
||||
filePdfData = await preview.blob.arrayBuffer();
|
||||
} else {
|
||||
const result = await downloadFileBlob(
|
||||
terminal.url,
|
||||
terminal.key,
|
||||
filePath,
|
||||
chatId ?? undefined
|
||||
);
|
||||
if (!result) throw new Error('Preview failed');
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
} else if (ext === 'pptx') {
|
||||
const { pptxToImages } = await import('$lib/utils/pptxToHtml');
|
||||
const fallback = await pptxToImages(arrayBuffer);
|
||||
fileOfficeSlides = fallback.images;
|
||||
const result = await pptxToImages(arrayBuffer);
|
||||
fileOfficeSlides = result.images;
|
||||
currentSlide = 0;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to render Office file:', e);
|
||||
fileContent = `Error previewing file: ${e instanceof Error ? e.message : 'Unknown error'}`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to render Office file:', e);
|
||||
fileContent = `Error previewing file: ${e instanceof Error ? e.message : 'Unknown error'}`;
|
||||
}
|
||||
} else {
|
||||
fileContent = await readFile(terminal.url, terminal.key, filePath, chatId ?? undefined);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { isCodeFile } from '$lib/utils/codeHighlight';
|
||||
import { initMermaid, renderMermaidDiagram } from '$lib/utils';
|
||||
import Spinner from '../../common/Spinner.svelte';
|
||||
import PdfPagesPreview from '../../common/PdfPagesPreview.svelte';
|
||||
import PDFViewer from '../../common/PDFViewer.svelte';
|
||||
import PanzoomContainer from '../../common/PanzoomContainer.svelte';
|
||||
import DocxPreview from '../../common/DocxPreview.svelte';
|
||||
import PptxPreview from '../../common/PptxPreview.svelte';
|
||||
@@ -16,7 +16,7 @@
|
||||
import SqliteView from './SqliteView.svelte';
|
||||
import FileCodeEditor from './FileCodeEditor.svelte';
|
||||
|
||||
let pdfPagesPreviewRef: PdfPagesPreview;
|
||||
let pdfViewerRef: PDFViewer;
|
||||
let fileCodeEditorRef: FileCodeEditor;
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
@@ -116,7 +116,6 @@
|
||||
$: isNotebook = getExt(selectedFile) === 'ipynb';
|
||||
$: isCode = isCodeFile(selectedFile);
|
||||
$: csvDelimiter = getExt(selectedFile) === 'tsv' ? '\t' : ',';
|
||||
$: isPptx = getExt(selectedFile) === 'pptx';
|
||||
|
||||
// For HTML files on system terminals (proxy URL), use path-based serving
|
||||
// so the iframe can resolve relative CSS/JS/image references via cookie auth.
|
||||
@@ -280,14 +279,13 @@
|
||||
};
|
||||
|
||||
export const resetPdfView = () => {
|
||||
pdfPagesPreviewRef?.resetView();
|
||||
pdfViewerRef?.resetView();
|
||||
};
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex-1 {fileImageUrl !== null ||
|
||||
fileDocxData !== null ||
|
||||
filePdfData !== null ||
|
||||
(fileOfficeSlides !== null && fileOfficeSlides.length > 0)
|
||||
? 'overflow-hidden'
|
||||
: 'overflow-y-auto'} min-h-0 min-w-0 relative h-full"
|
||||
@@ -323,16 +321,7 @@
|
||||
</audio>
|
||||
</div>
|
||||
{:else if filePdfData !== null}
|
||||
<PdfPagesPreview
|
||||
bind:this={pdfPagesPreviewRef}
|
||||
data={filePdfData}
|
||||
bind:currentSlide
|
||||
{targetPage}
|
||||
singlePage={isPptx}
|
||||
itemLabel={isPptx ? 'Slide' : 'Page'}
|
||||
listLabel={isPptx ? 'Slides' : 'Pages'}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
<PDFViewer bind:this={pdfViewerRef} data={filePdfData} {targetPage} className="w-full h-full" />
|
||||
{:else if fileSqliteData !== null}
|
||||
<SqliteView data={fileSqliteData} />
|
||||
{:else if fileDocxData !== null}
|
||||
@@ -515,10 +504,9 @@
|
||||
<div
|
||||
class="absolute bottom-3 left-1/2 z-10 flex -translate-x-1/2 items-center gap-0.5 rounded-lg border border-gray-200/60 bg-white/90 px-1 py-0.5 shadow-lg backdrop-blur-sm dark:border-gray-700/60 dark:bg-gray-850/90"
|
||||
>
|
||||
<!-- Pinch covers in/out on coarse pointers; reset has no gesture, so it stays -->
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md p-1.5 text-gray-500 transition hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 pointer-coarse:hidden"
|
||||
class="inline-flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md p-1.5 text-gray-500 transition hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
on:click={() => panzoomRef?.zoomOut()}
|
||||
aria-label={$i18n.t('Zoom out')}
|
||||
>
|
||||
@@ -545,7 +533,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md p-1.5 text-gray-500 transition hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 pointer-coarse:hidden"
|
||||
class="inline-flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md p-1.5 text-gray-500 transition hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
on:click={() => panzoomRef?.zoomIn()}
|
||||
aria-label={$i18n.t('Zoom in')}
|
||||
>
|
||||
|
||||
@@ -1858,10 +1858,7 @@
|
||||
id="message-input-container"
|
||||
class="flex-1 flex flex-col relative w-full shadow-lg rounded-3xl border {$temporaryChatEnabled
|
||||
? 'border-dashed border-gray-100 dark:border-gray-800 hover:border-gray-200 focus-within:border-gray-200 hover:dark:border-gray-700 focus-within:dark:border-gray-700'
|
||||
: ' border-gray-100/30 dark:border-gray-850/30 hover:border-gray-200 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800'} {($settings?.highContrastMode ??
|
||||
false)
|
||||
? 'focus-within:outline focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-blue-500 [&_.ProseMirror:focus-visible]:outline-none!'
|
||||
: ''} transition px-0.5 bg-white/5 dark:bg-gray-500/5 backdrop-blur-sm dark:text-gray-100"
|
||||
: ' border-gray-100/30 dark:border-gray-850/30 hover:border-gray-200 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800'} transition px-0.5 bg-white/5 dark:bg-gray-500/5 backdrop-blur-sm dark:text-gray-100"
|
||||
dir={$settings?.chatDirection ?? 'auto'}
|
||||
>
|
||||
{#if atSelectedModel !== undefined}
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
|
||||
function getMessageText(item: any): string {
|
||||
return (item.content ?? [])
|
||||
.filter((p: any) => p && (p.type === 'output_text' || 'text' in p))
|
||||
.filter((p: any) => p.type === 'output_text' || 'text' in p)
|
||||
.map((p: any) => p.text ?? '')
|
||||
.join('\n');
|
||||
}
|
||||
@@ -146,9 +146,7 @@
|
||||
function updateMessageText(idx: number, text: string) {
|
||||
const next = [...output];
|
||||
const item = { ...next[idx] };
|
||||
const parts = (item.content ?? []).filter(
|
||||
(p: any) => p && (p.type === 'output_text' || 'text' in p)
|
||||
);
|
||||
const parts = (item.content ?? []).filter((p: any) => p.type === 'output_text' || 'text' in p);
|
||||
item.content = [{ ...(parts[0] ?? { type: 'output_text' }), text }];
|
||||
next[idx] = item;
|
||||
output = next;
|
||||
@@ -157,7 +155,7 @@
|
||||
|
||||
function getReasoningText(item: any): string {
|
||||
return (item.summary ?? item.content ?? [])
|
||||
.filter((p: any) => p && 'text' in p)
|
||||
.filter((p: any) => 'text' in p)
|
||||
.map((p: any) => p.text ?? '')
|
||||
.join('');
|
||||
}
|
||||
@@ -272,7 +270,7 @@
|
||||
{#if di.type === 'message'}
|
||||
<textarea
|
||||
use:fitContent
|
||||
class="w-full bg-transparent outline-hidden focus-visible:outline-none! resize-none overflow-hidden text-[0.9375rem] p-1.5 rounded-lg"
|
||||
class="w-full bg-transparent outline-hidden resize-none overflow-hidden text-[0.9375rem] p-1.5 rounded-lg"
|
||||
value={getMessageText(di.item)}
|
||||
on:input={(e) => {
|
||||
updateMessageText(di.indices[0], e.target.value);
|
||||
@@ -284,7 +282,7 @@
|
||||
{:else if di.type === 'reasoning'}
|
||||
<textarea
|
||||
use:fitContent
|
||||
class="w-full bg-transparent outline-hidden focus-visible:outline-none! resize-none overflow-hidden text-[0.9375rem] text-gray-500 dark:text-gray-400 p-1.5 rounded-lg"
|
||||
class="w-full bg-transparent outline-hidden resize-none overflow-hidden text-[0.9375rem] text-gray-500 dark:text-gray-400 p-1.5 rounded-lg"
|
||||
value={getReasoningText(di.item)}
|
||||
on:input={(e) => {
|
||||
updateReasoningText(di.indices[0], e.target.value);
|
||||
|
||||
@@ -144,6 +144,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Generation speed, normalized across backends: Ollama reports
|
||||
// eval_count/eval_duration (nanoseconds), llama.cpp-compatible servers
|
||||
// (e.g. our relaxed-MTP llama-server) report timings.predicted_per_second
|
||||
// directly -- note that field name is llama.cpp's generic term for "the
|
||||
// main generation loop's output," i.e. the actual response stream, NOT
|
||||
// the MTP drafter in isolation (which is separately broken out as
|
||||
// draft_n/draft_n_accepted).
|
||||
const getTokensPerSecond = (usage: Record<string, unknown> | undefined | null) => {
|
||||
if (!usage) return null;
|
||||
if (typeof usage.predicted_per_second === 'number') return usage.predicted_per_second;
|
||||
if (typeof usage.eval_count === 'number' && typeof usage.eval_duration === 'number' && usage.eval_duration > 0) {
|
||||
return (usage.eval_count / usage.eval_duration) * 1e9;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
$: tokensPerSecond = getTokensPerSecond(message.usage);
|
||||
|
||||
export let siblings;
|
||||
|
||||
export let setInputText: Function = () => {};
|
||||
@@ -731,12 +748,7 @@
|
||||
{/if}
|
||||
|
||||
{#if edit === true}
|
||||
<div
|
||||
class="w-full bg-gray-50 dark:bg-gray-800 rounded-3xl px-3 py-3 my-2 {($settings?.highContrastMode ??
|
||||
false)
|
||||
? 'focus-within:outline focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-blue-500'
|
||||
: ''}"
|
||||
>
|
||||
<div class="w-full bg-gray-50 dark:bg-gray-800 rounded-3xl px-3 py-3 my-2">
|
||||
{#if editedOutput}
|
||||
<!-- Structured output editor (visual + JSON toggle) -->
|
||||
<OutputEditView
|
||||
@@ -750,7 +762,7 @@
|
||||
<textarea
|
||||
id="message-edit-{message.id}"
|
||||
bind:this={editTextAreaElement}
|
||||
class=" bg-transparent outline-hidden focus-visible:outline-none! w-full resize-none text-[0.9375rem]"
|
||||
class=" bg-transparent outline-hidden w-full resize-none text-[0.9375rem]"
|
||||
bind:value={editedContent}
|
||||
on:input={(e) => {
|
||||
const messagesContainer = document.getElementById('messages-container');
|
||||
@@ -1194,6 +1206,14 @@
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
{#if tokensPerSecond}
|
||||
<span
|
||||
class="text-xs text-gray-400 dark:text-gray-500 px-1 self-center whitespace-nowrap"
|
||||
>
|
||||
{tokensPerSecond.toFixed(1)} tok/s
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if message.usage}
|
||||
<Tooltip
|
||||
content={message.usage
|
||||
|
||||
@@ -163,9 +163,7 @@
|
||||
</div>
|
||||
</ConsecutiveDetailsGroup>
|
||||
{:else if displayItem.type === 'file'}
|
||||
{#if displayItem.item?.displayed || $settings?.terminalFileDisplay === 'inline'}
|
||||
<TerminalOutputFile item={displayItem.item} {chatId} />
|
||||
{/if}
|
||||
<TerminalOutputFile item={displayItem.item} {chatId} />
|
||||
{:else}
|
||||
{@const detailToken = displayItem.token}
|
||||
{#if detailToken.attributes?.type === 'tool_calls'}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
showFileNavPath,
|
||||
terminalServers
|
||||
} from '$lib/stores';
|
||||
import { downloadFileBlob, downloadFilePreview, readFile } from '$lib/apis/terminal';
|
||||
import { downloadFileBlob, readFile } from '$lib/apis/terminal';
|
||||
import FilePreview from '$lib/components/chat/FileNav/FilePreview.svelte';
|
||||
import Icon from '$lib/components/chat/FileNav/Icon.svelte';
|
||||
import { fileIconName } from '$lib/components/chat/FileNav/fileIcon';
|
||||
@@ -143,59 +143,26 @@
|
||||
else if (isVideo(path)) fileVideoUrl = url;
|
||||
else fileAudioUrl = url;
|
||||
} else if (isPdf(path) || isSqlite(path) || isOffice(path)) {
|
||||
const result = await blobForPreview();
|
||||
if (!result) throw new Error(t('Preview failed'));
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
const ext = getExt(path);
|
||||
|
||||
if (isPdf(path)) {
|
||||
const result = await blobForPreview();
|
||||
if (!result) throw new Error(t('Preview failed'));
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
filePdfData = arrayBuffer;
|
||||
} else if (isSqlite(path)) {
|
||||
const result = await blobForPreview();
|
||||
if (!result) throw new Error(t('Preview failed'));
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
fileSqliteData = arrayBuffer;
|
||||
} else if (ext === 'docx') {
|
||||
const preview = await downloadFilePreview(
|
||||
terminal.url,
|
||||
terminal.key,
|
||||
path,
|
||||
chatId || undefined
|
||||
);
|
||||
if (preview) {
|
||||
filePdfData = await preview.blob.arrayBuffer();
|
||||
} else {
|
||||
const result = await blobForPreview();
|
||||
if (!result) throw new Error(t('Preview failed'));
|
||||
fileDocxData = await result.blob.arrayBuffer();
|
||||
}
|
||||
fileDocxData = arrayBuffer;
|
||||
} else if (ext === 'xlsx' || ext === 'xls') {
|
||||
const result = await blobForPreview();
|
||||
if (!result) throw new Error(t('Preview failed'));
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
const XLSX = await import('xlsx');
|
||||
excelWorkbook = XLSX.read(new Uint8Array(arrayBuffer), { type: 'array' });
|
||||
excelSheetNames = excelWorkbook.SheetNames;
|
||||
if (excelSheetNames.length > 0) {
|
||||
await loadExcelSheet(excelSheetNames[0]);
|
||||
}
|
||||
if (excelSheetNames.length > 0) await loadExcelSheet(excelSheetNames[0]);
|
||||
} else if (ext === 'pptx') {
|
||||
const preview = await downloadFilePreview(
|
||||
terminal.url,
|
||||
terminal.key,
|
||||
path,
|
||||
chatId || undefined
|
||||
);
|
||||
if (preview) {
|
||||
filePdfData = await preview.blob.arrayBuffer();
|
||||
} else {
|
||||
const result = await blobForPreview();
|
||||
if (!result) throw new Error(t('Preview failed'));
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
const { pptxToImages } = await import('$lib/utils/pptxToHtml');
|
||||
const resultImages = await pptxToImages(arrayBuffer);
|
||||
fileOfficeSlides = resultImages.images;
|
||||
}
|
||||
const { pptxToImages } = await import('$lib/utils/pptxToHtml');
|
||||
const result = await pptxToImages(arrayBuffer);
|
||||
fileOfficeSlides = result.images;
|
||||
}
|
||||
} else if (terminal) {
|
||||
fileContent = await readFile(terminal.url, terminal.key, path, chatId || undefined);
|
||||
|
||||
@@ -202,12 +202,7 @@
|
||||
{/if}
|
||||
|
||||
{#if edit === true}
|
||||
<div
|
||||
class=" w-full bg-gray-50 dark:bg-gray-800 rounded-3xl px-4 py-3 mb-2 {($settings?.highContrastMode ??
|
||||
false)
|
||||
? 'focus-within:outline focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-blue-500'
|
||||
: ''}"
|
||||
>
|
||||
<div class=" w-full bg-gray-50 dark:bg-gray-800 rounded-3xl px-4 py-3 mb-2">
|
||||
{#if (editedFiles ?? []).length > 0}
|
||||
<div class="flex items-center flex-wrap gap-2 -mx-2 mb-1">
|
||||
{#each editedFiles as file, fileIdx}
|
||||
@@ -278,7 +273,7 @@
|
||||
<textarea
|
||||
id="message-edit-{message.id}"
|
||||
bind:this={messageEditTextAreaElement}
|
||||
class=" bg-transparent outline-hidden focus-visible:outline-none! w-full resize-none text-[0.9375rem]"
|
||||
class=" bg-transparent outline-hidden w-full resize-none text-[0.9375rem]"
|
||||
bind:value={editedContent}
|
||||
on:input={(e) => {
|
||||
const messagesContainer = document.getElementById('messages-container');
|
||||
|
||||
@@ -179,6 +179,7 @@ function getInlineFileFromToolOutput(callItem?: OutputItem, resultItem?: OutputI
|
||||
typeof result !== 'object' ||
|
||||
result.type !== 'file' ||
|
||||
result.source !== 'open_terminal' ||
|
||||
result.displayed !== true ||
|
||||
result.exists === false ||
|
||||
!result.path ||
|
||||
!result.terminal_selector
|
||||
@@ -373,11 +374,7 @@ export function buildOutputDisplayItems(output: OutputItem[] = []): OutputDispla
|
||||
};
|
||||
|
||||
output.forEach((item, index) => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.type === 'function_call_output') {
|
||||
if (item?.type === 'function_call_output') {
|
||||
const inlineFile = getInlineFileFromToolOutput(toolCallByCallId[item.call_id ?? ''], item);
|
||||
if (inlineFile) {
|
||||
flushDetails();
|
||||
@@ -391,14 +388,14 @@ export function buildOutputDisplayItems(output: OutputItem[] = []): OutputDispla
|
||||
}
|
||||
|
||||
if (
|
||||
item.type === 'function_call' &&
|
||||
item?.type === 'function_call' &&
|
||||
item.name === 'ask_user' &&
|
||||
(item.status === 'pending' || item.status === 'in_progress')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.type && GROUPABLE_OUTPUT_TYPES.has(item.type)) {
|
||||
if (item?.type && GROUPABLE_OUTPUT_TYPES.has(item.type)) {
|
||||
const token = buildDetailToken(item, index === output.length - 1, toolOutputByCallId);
|
||||
if (token) {
|
||||
currentDetailTokens.push(token);
|
||||
@@ -406,7 +403,7 @@ export function buildOutputDisplayItems(output: OutputItem[] = []): OutputDispla
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.type === 'message') {
|
||||
if (item?.type === 'message') {
|
||||
const text = getMessageText(item);
|
||||
if (text.trim()) {
|
||||
flushDetails();
|
||||
@@ -465,12 +462,9 @@ function ensureOutputItem(
|
||||
fallback?: OutputItem
|
||||
): OutputItem {
|
||||
while (output.length <= outputIndex) {
|
||||
// Only the addressed slot gets the event's item; filler slots must not reuse its id.
|
||||
const item =
|
||||
output.length === outputIndex && fallback
|
||||
? { ...fallback }
|
||||
: { type: 'message', status: 'in_progress', role: 'assistant', content: [] };
|
||||
output.push(item);
|
||||
output.push(
|
||||
fallback ?? { type: 'message', status: 'in_progress', role: 'assistant', content: [] }
|
||||
);
|
||||
}
|
||||
output[outputIndex] = { ...output[outputIndex] };
|
||||
return output[outputIndex];
|
||||
@@ -484,17 +478,6 @@ function ensurePart(parts: OutputContentPart[], index: number, fallback?: Output
|
||||
return parts[index];
|
||||
}
|
||||
|
||||
function setPart(
|
||||
parts: OutputContentPart[],
|
||||
index: number,
|
||||
part: OutputContentPart,
|
||||
fallback?: OutputContentPart
|
||||
): void {
|
||||
// Assigning past the end leaves a hole that later spreads turn into undefined parts.
|
||||
ensurePart(parts, index, fallback);
|
||||
parts[index] = part;
|
||||
}
|
||||
|
||||
function findOutputItemIndex(output: OutputItem[], item: OutputItem): number {
|
||||
return output.findIndex(
|
||||
(existing) =>
|
||||
@@ -543,7 +526,7 @@ export function applyResponseStreamEvent(
|
||||
} else if (outputIndex < nextOutput.length) {
|
||||
nextOutput.splice(outputIndex, 0, item);
|
||||
} else {
|
||||
nextOutput.push(item);
|
||||
nextOutput[outputIndex] = item;
|
||||
}
|
||||
return nextOutput;
|
||||
}
|
||||
@@ -554,13 +537,7 @@ export function applyResponseStreamEvent(
|
||||
}
|
||||
const item = { ...event.item };
|
||||
const existingIndex = findOutputItemIndex(nextOutput, item);
|
||||
if (existingIndex >= 0) {
|
||||
nextOutput[existingIndex] = item;
|
||||
} else if (outputIndex < nextOutput.length) {
|
||||
nextOutput[outputIndex] = item;
|
||||
} else {
|
||||
nextOutput.push(item);
|
||||
}
|
||||
nextOutput[existingIndex >= 0 ? existingIndex : outputIndex] = item;
|
||||
return nextOutput;
|
||||
}
|
||||
|
||||
@@ -585,7 +562,7 @@ export function applyResponseStreamEvent(
|
||||
return nextOutput;
|
||||
}
|
||||
item.content = [...(item.content ?? [])];
|
||||
setPart(item.content, event.content_index ?? item.content.length, { ...event.part });
|
||||
item.content[event.content_index ?? item.content.length] = { ...event.part };
|
||||
return nextOutput;
|
||||
}
|
||||
|
||||
@@ -594,8 +571,7 @@ export function applyResponseStreamEvent(
|
||||
return nextOutput;
|
||||
}
|
||||
item.summary = [...(item.summary ?? [])];
|
||||
const summaryIndex = event.summary_index ?? item.summary.length;
|
||||
setPart(item.summary, summaryIndex, { ...event.part }, { type: 'summary_text', text: '' });
|
||||
item.summary[event.summary_index ?? item.summary.length] = { ...event.part };
|
||||
return nextOutput;
|
||||
}
|
||||
|
||||
@@ -625,8 +601,7 @@ export function applyResponseStreamEvent(
|
||||
const typeName = eventType.split('.')[1];
|
||||
if (typeName === 'content_part' && event.part) {
|
||||
item.content = [...(item.content ?? [])];
|
||||
const contentIndex = event.content_index ?? Math.max(item.content.length - 1, 0);
|
||||
setPart(item.content, contentIndex, { ...event.part });
|
||||
item.content[event.content_index ?? Math.max(item.content.length - 1, 0)] = { ...event.part };
|
||||
} else if (typeName === 'function_call_arguments' && event.arguments !== undefined) {
|
||||
item.arguments = event.arguments;
|
||||
} else if (
|
||||
@@ -658,7 +633,7 @@ export function replaceOutputMessageText(
|
||||
}
|
||||
|
||||
const partIndex = item.content.findIndex(
|
||||
(part) => typeof part?.text === 'string' && part.text.includes(oldContent)
|
||||
(part) => typeof part.text === 'string' && part.text.includes(oldContent)
|
||||
);
|
||||
if (partIndex === -1) {
|
||||
return item;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { getLogoutRedirectUrl, updateUserPassword, userSignOut } from '$lib/apis/auths';
|
||||
import { updateUserPassword, userSignOut } from '$lib/apis/auths';
|
||||
import { user } from '$lib/stores';
|
||||
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
@@ -26,13 +27,17 @@
|
||||
// This session is no longer trusted once the password it was issued under changes
|
||||
toast.success($i18n.t('Password updated. Please sign in again.'));
|
||||
|
||||
localStorage.removeItem('token');
|
||||
user.set(null);
|
||||
|
||||
const signOutRes = await userSignOut().catch((error) => {
|
||||
console.error(error);
|
||||
return null;
|
||||
});
|
||||
|
||||
localStorage.removeItem('token');
|
||||
location.href = getLogoutRedirectUrl(signOutRes?.redirect_url);
|
||||
if (signOutRes?.redirect_url) {
|
||||
location.href = signOutRes.redirect_url;
|
||||
}
|
||||
}
|
||||
|
||||
currentPassword = '';
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<slot name="label">{label}</slot>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="shrink-0">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -268,8 +268,6 @@
|
||||
'full width mode',
|
||||
'haptic feedback',
|
||||
'hapticfeedback',
|
||||
'accessibility mode',
|
||||
'accessibilitymode',
|
||||
'high contrast mode',
|
||||
'highcontrastmode',
|
||||
'iframe sandbox allow forms',
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={`app-dropdown-menu rounded-xl! p-0.5! border border-gray-100 dark:border-gray-800 z-50 bg-white text-gray-900 dark:bg-gray-850 dark:text-white shadow-lg ${className} [&_button:hover]:bg-gray-100! dark:[&_button:hover]:bg-gray-900! [&_a:hover]:bg-gray-100! dark:[&_a:hover]:bg-gray-900! [&_button:hover]:text-gray-900 dark:[&_button:hover]:text-gray-100 [&_a:hover]:text-gray-900 dark:[&_a:hover]:text-gray-100 [&>button]:flex [&>button]:w-full [&>button]:h-[1.6875rem]! [&>button]:items-center [&>button]:gap-2! [&>button]:rounded-xl! [&>button]:bg-transparent [&>button]:px-2! [&>button]:text-[0.8125rem]! [&>button]:font-normal! [&>a]:flex [&>a]:w-full [&>a]:h-[1.6875rem]! [&>a]:items-center [&>a]:gap-2! [&>a]:rounded-xl! [&>a]:bg-transparent [&>a]:px-2! [&>a]:text-[0.8125rem]! [&>a]:font-normal! [&_button_svg]:size-3.5! [&_a_svg]:size-3.5! [&>hr]:my-0.5 [&>hr]:mx-1`}
|
||||
class={`rounded-xl! p-0.5! border border-gray-100 dark:border-gray-800 z-50 bg-white text-gray-900 dark:bg-gray-850 dark:text-white shadow-lg ${className} [&_button:hover]:bg-gray-50/40! dark:[&_button:hover]:bg-gray-800/40! [&_a:hover]:bg-gray-50/40! dark:[&_a:hover]:bg-gray-800/40! [&_button:hover]:text-gray-900 dark:[&_button:hover]:text-gray-100 [&_a:hover]:text-gray-900 dark:[&_a:hover]:text-gray-100 [&>button]:flex [&>button]:w-full [&>button]:h-[1.6875rem]! [&>button]:items-center [&>button]:gap-2! [&>button]:rounded-xl! [&>button]:bg-transparent [&>button]:px-2! [&>button]:text-[0.8125rem]! [&>button]:font-normal! [&>a]:flex [&>a]:w-full [&>a]:h-[1.6875rem]! [&>a]:items-center [&>a]:gap-2! [&>a]:rounded-xl! [&>a]:bg-transparent [&>a]:px-2! [&>a]:text-[0.8125rem]! [&>a]:font-normal! [&_button_svg]:size-3.5! [&_a_svg]:size-3.5! [&>hr]:my-0.5 [&>hr]:mx-1`}
|
||||
{style}
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { updateUserInfo } from '$lib/apis/users';
|
||||
import { getUserPosition } from '$lib/utils';
|
||||
import { normalizeAppFontFamily, setAppFontFamily, setTextScale } from '$lib/utils/text-scale';
|
||||
import { setTextScale } from '$lib/utils/text-scale';
|
||||
|
||||
import Minus from '$lib/components/icons/Minus.svelte';
|
||||
import Plus from '$lib/components/icons/Plus.svelte';
|
||||
@@ -115,10 +115,7 @@
|
||||
let showManageImageCompressionModal = false;
|
||||
|
||||
let textScale: number | null = null;
|
||||
let fontFamily: string | null = null;
|
||||
let fontFamilyInput = '';
|
||||
let showTextScaleSlider = false;
|
||||
let showFontFamilyInput = false;
|
||||
const settingRowClass = 'flex items-center justify-between gap-2.5';
|
||||
const settingLabelClass = 'min-w-0 text-xs text-gray-600 dark:text-gray-400';
|
||||
const settingControlClass = 'flex shrink-0 items-center justify-end gap-1.5';
|
||||
@@ -237,18 +234,9 @@
|
||||
};
|
||||
|
||||
export const save = async () => {
|
||||
const normalizedFontFamily = normalizeAppFontFamily(fontFamilyInput) || null;
|
||||
fontFamily = normalizedFontFamily;
|
||||
fontFamilyInput = normalizedFontFamily ?? '';
|
||||
|
||||
if (!externalSettings) {
|
||||
setAppFontFamily(fontFamily || defaultFontFamily());
|
||||
}
|
||||
|
||||
saveSettings({
|
||||
models: [defaultModelId],
|
||||
imageCompressionSize: imageCompressionSize,
|
||||
fontFamily
|
||||
imageCompressionSize: imageCompressionSize
|
||||
});
|
||||
};
|
||||
|
||||
@@ -267,51 +255,6 @@
|
||||
saveSettings({ textScale });
|
||||
};
|
||||
|
||||
const defaultFontFamily = () => normalizeAppFontFamily(defaultSettings.fontFamily) || null;
|
||||
|
||||
const previewFontFamily = (value: string) => {
|
||||
fontFamilyInput = value;
|
||||
fontFamily = normalizeAppFontFamily(value) || null;
|
||||
|
||||
if (!externalSettings) {
|
||||
setAppFontFamily(fontFamily || defaultFontFamily());
|
||||
}
|
||||
};
|
||||
|
||||
const setFontFamilyHandler = (value: string | null) => {
|
||||
const normalized = normalizeAppFontFamily(value);
|
||||
fontFamily = normalized || null;
|
||||
fontFamilyInput = normalized;
|
||||
|
||||
if (!externalSettings) {
|
||||
setAppFontFamily(fontFamily || defaultFontFamily());
|
||||
}
|
||||
|
||||
saveSettings({ fontFamily });
|
||||
};
|
||||
|
||||
const toggleFontFamilyInput = () => {
|
||||
if (!showFontFamilyInput && (fontFamily === null || isDefaultSetting('fontFamily'))) {
|
||||
fontFamily = fontFamily ?? defaultFontFamily();
|
||||
fontFamilyInput = fontFamily ?? '';
|
||||
showFontFamilyInput = true;
|
||||
|
||||
if (!externalSettings) {
|
||||
setAppFontFamily(fontFamily);
|
||||
}
|
||||
} else {
|
||||
showFontFamilyInput = false;
|
||||
fontFamily = null;
|
||||
fontFamilyInput = '';
|
||||
|
||||
if (!externalSettings) {
|
||||
setAppFontFamily(defaultFontFamily());
|
||||
}
|
||||
|
||||
saveSettings({ fontFamily });
|
||||
}
|
||||
};
|
||||
|
||||
const init = () => {
|
||||
titleAutoGenerate = currentSettings?.title?.auto ?? true;
|
||||
autoTags = currentSettings?.autoTags ?? true;
|
||||
@@ -395,10 +338,7 @@
|
||||
webSearch = currentSettings?.webSearch ?? null;
|
||||
|
||||
textScale = currentSettings?.textScale ?? null;
|
||||
fontFamily = normalizeAppFontFamily(currentSettings?.fontFamily) || null;
|
||||
fontFamilyInput = fontFamily ?? '';
|
||||
showTextScaleSlider = false;
|
||||
showFontFamilyInput = false;
|
||||
|
||||
defaultUploadContext = currentSettings?.defaultUploadContext ?? 'focused';
|
||||
};
|
||||
@@ -550,53 +490,13 @@
|
||||
|
||||
<div>
|
||||
<div class={settingRowClass}>
|
||||
<label id="font-family-label" class={settingLabelClass} for="font-family-input">
|
||||
{$i18n.t('Font Family')}
|
||||
</label>
|
||||
|
||||
<div class={settingControlClass}>
|
||||
<button
|
||||
class={actionButtonClass}
|
||||
aria-labelledby="font-family-label font-family-state"
|
||||
type="button"
|
||||
on:click={toggleFontFamilyInput}
|
||||
>
|
||||
<span id="font-family-state">
|
||||
{!showFontFamilyInput && (fontFamily === null || isDefaultSetting('fontFamily'))
|
||||
? $i18n.t('Default')
|
||||
: $i18n.t('Custom')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showFontFamilyInput || (fontFamily !== null && !isDefaultSetting('fontFamily'))}
|
||||
<div class="mt-1.5 flex items-center pb-1">
|
||||
<input
|
||||
id="font-family-input"
|
||||
class="h-7 w-full rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500"
|
||||
type="text"
|
||||
placeholder="Aptos"
|
||||
bind:value={fontFamilyInput}
|
||||
on:input={(event) => previewFontFamily(event.currentTarget.value)}
|
||||
on:change={(event) => setFontFamilyHandler(event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<p class={settingDescriptionClass}>
|
||||
{$i18n.t('Use a local font family for the app interface.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class={settingRowClass}>
|
||||
<div id="accessibility-mode-label" class={settingLabelClass}>
|
||||
{$i18n.t('Accessibility Mode')}
|
||||
<div id="high-contrast-mode-label" class={settingLabelClass}>
|
||||
{$i18n.t('High Contrast Mode')}
|
||||
</div>
|
||||
|
||||
<div class={settingControlClass}>
|
||||
<Switch
|
||||
ariaLabelledbyId="accessibility-mode-label"
|
||||
ariaLabelledbyId="high-contrast-mode-label"
|
||||
tooltip={true}
|
||||
bind:state={highContrastMode}
|
||||
inherited={isDefaultSetting('highContrastMode')}
|
||||
|
||||
@@ -62,20 +62,11 @@
|
||||
return (
|
||||
target instanceof Element &&
|
||||
!!target.closest(
|
||||
'input, textarea, select, [role="dialog"], [role="menu"], [contenteditable="true"], [data-sidebar-no-gesture]'
|
||||
'input, textarea, select, [role="menu"], [contenteditable="true"], [data-sidebar-no-gesture]'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const hasTextSelection = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
return !!selection && !selection.isCollapsed;
|
||||
};
|
||||
|
||||
const canScrollHorizontally = (target: EventTarget | null, dx: number) => {
|
||||
if (!(target instanceof Element) || dx === 0 || typeof window === 'undefined') {
|
||||
return false;
|
||||
@@ -170,7 +161,7 @@
|
||||
};
|
||||
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
if (!enabled || e.touches.length !== 1 || shouldSkipSwipe(e.target) || hasTextSelection()) {
|
||||
if (!enabled || e.touches.length !== 1 || shouldSkipSwipe(e.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -214,11 +205,6 @@
|
||||
const absY = Math.abs(dy);
|
||||
|
||||
if (!locked) {
|
||||
if (hasTextSelection()) {
|
||||
cancelSwipe();
|
||||
return;
|
||||
}
|
||||
|
||||
if ((direction === 'open' && dx > 0) || (direction === 'close' && dx < 0)) {
|
||||
swipeProgress = Math.max(0, Math.min(1, startProgress + dx / panelWidth));
|
||||
}
|
||||
|
||||
@@ -9,9 +9,6 @@
|
||||
export let data: ArrayBuffer | Uint8Array | null = null;
|
||||
export let className = 'w-full h-[70vh]';
|
||||
export let targetPage: number | null = null;
|
||||
export let singlePage = false;
|
||||
export let itemLabel = 'Page';
|
||||
export let onPageChange: ((page: number) => void) | null = null;
|
||||
|
||||
type PdfDocument = import('pdfjs-dist').PDFDocumentProxy;
|
||||
type PdfTextLayer = InstanceType<typeof import('pdfjs-dist').TextLayer>;
|
||||
@@ -25,28 +22,10 @@
|
||||
let zoomLevel = 1;
|
||||
let rerenderTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastRenderedZoom = 1;
|
||||
let pageCount = 0;
|
||||
let renderedPage = 0;
|
||||
let activePage = 1;
|
||||
let loadToken = 0;
|
||||
let renderToken = 0;
|
||||
let scrollFrame: number | null = null;
|
||||
let mounted = false;
|
||||
let loadedSource: ArrayBuffer | Uint8Array | string | null = null;
|
||||
let wheelDelta = 0;
|
||||
let lastWheelNavigationAt = 0;
|
||||
const wheelNavigationThreshold = 80;
|
||||
const wheelNavigationCooldown = 450;
|
||||
const pageShortcutKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'];
|
||||
|
||||
$: selectedPage = singlePage ? (clampDocumentTargetPage(targetPage, pageCount) ?? 1) : activePage;
|
||||
|
||||
// Keep a reference to TextLayer instances so we can update/cancel them
|
||||
let textLayerInstances: PdfTextLayer[] = [];
|
||||
|
||||
const copyPdfData = (pdfData: ArrayBuffer | Uint8Array) =>
|
||||
pdfData instanceof Uint8Array ? pdfData.slice() : pdfData.slice(0);
|
||||
|
||||
const cancelTextLayers = () => {
|
||||
for (const tl of textLayerInstances) {
|
||||
try {
|
||||
@@ -74,11 +53,8 @@
|
||||
}
|
||||
return false;
|
||||
},
|
||||
beforeMouseDown: (e) => {
|
||||
beforeMouseDown: () => {
|
||||
// Only allow drag-to-pan when zoomed in (not at default scale)
|
||||
if ((e?.target as HTMLElement | null)?.closest?.('.textLayer')) {
|
||||
return true;
|
||||
}
|
||||
const transform = pzInstance?.getTransform();
|
||||
if (transform && Math.abs(transform.scale - 1) < 0.01) {
|
||||
return true; // cancel panzoom mouse handling at 1x — allow text selection / normal interaction
|
||||
@@ -124,73 +100,16 @@
|
||||
}
|
||||
};
|
||||
|
||||
export const scrollToPage = async (page: number | null) => {
|
||||
targetPage = page;
|
||||
if (singlePage) {
|
||||
if (targetPage) onPageChange?.(targetPage);
|
||||
} else {
|
||||
await scrollToTargetPage();
|
||||
}
|
||||
};
|
||||
|
||||
const selectPage = async (page: number) => {
|
||||
if (!pdfDoc) return;
|
||||
const nextPage = clampDocumentTargetPage(page, pdfDoc.numPages);
|
||||
if (!nextPage || nextPage === selectedPage) return;
|
||||
|
||||
targetPage = nextPage;
|
||||
activePage = nextPage;
|
||||
onPageChange?.(nextPage);
|
||||
if (!singlePage) await scrollToTargetPage();
|
||||
};
|
||||
|
||||
const scrollToTargetPage = async () => {
|
||||
if (!outerContainer || !sceneElement || !pdfDoc) return;
|
||||
const page = clampDocumentTargetPage(targetPage, pdfDoc.numPages);
|
||||
if (!page) return;
|
||||
|
||||
if (singlePage) return;
|
||||
|
||||
await tick();
|
||||
const pageWrapper = sceneElement.querySelectorAll('.pdf-page-wrapper')[page - 1] as
|
||||
| HTMLElement
|
||||
| undefined;
|
||||
pageWrapper?.scrollIntoView({ block: 'start' });
|
||||
activePage = page;
|
||||
onPageChange?.(page);
|
||||
};
|
||||
|
||||
const syncVisiblePage = () => {
|
||||
scrollFrame = null;
|
||||
if (singlePage || !outerContainer || !sceneElement || !pdfDoc) return;
|
||||
|
||||
const marker = outerContainer.getBoundingClientRect().top + outerContainer.clientHeight * 0.35;
|
||||
let bestPage = activePage;
|
||||
let bestDistance = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const wrapper of sceneElement.querySelectorAll('.pdf-page-wrapper')) {
|
||||
const el = wrapper as HTMLElement;
|
||||
const page = Number(el.dataset.pageNumber);
|
||||
if (!page) continue;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const distance =
|
||||
marker < rect.top ? rect.top - marker : marker > rect.bottom ? marker - rect.bottom : 0;
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
bestPage = page;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestPage !== activePage) {
|
||||
activePage = bestPage;
|
||||
onPageChange?.(bestPage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if (singlePage || scrollFrame !== null) return;
|
||||
scrollFrame = requestAnimationFrame(syncVisiblePage);
|
||||
};
|
||||
|
||||
// Re-render existing canvases at a new zoom level (preserves panzoom transform)
|
||||
@@ -198,15 +117,16 @@
|
||||
if (!pdfDoc || !sceneElement) return;
|
||||
const pdfjs = await import('pdfjs-dist');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const containerWidth = outerContainer?.clientWidth || 800;
|
||||
|
||||
const pageWrappers = sceneElement.querySelectorAll('.pdf-page-wrapper');
|
||||
|
||||
cancelTextLayers();
|
||||
|
||||
for (let i = 0; i < pageWrappers.length; i++) {
|
||||
const page = await pdfDoc.getPage(singlePage ? selectedPage : i + 1);
|
||||
const page = await pdfDoc.getPage(i + 1);
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
const cssScale = getCssScale(viewport);
|
||||
const cssScale = containerWidth / viewport.width;
|
||||
const renderScale = cssScale * forZoom * dpr;
|
||||
const scaledViewport = page.getViewport({ scale: renderScale });
|
||||
const cssViewport = page.getViewport({ scale: cssScale });
|
||||
@@ -242,17 +162,8 @@
|
||||
lastRenderedZoom = forZoom;
|
||||
};
|
||||
|
||||
const getCssScale = (viewport: { width: number; height: number }) => {
|
||||
if (!singlePage) return (outerContainer?.clientWidth || 800) / viewport.width;
|
||||
|
||||
const availableWidth = Math.max(320, (outerContainer?.clientWidth || 800) - 64);
|
||||
const availableHeight = Math.max(220, (outerContainer?.clientHeight || 600) - 64);
|
||||
return Math.min(1, availableWidth / viewport.width, availableHeight / viewport.height);
|
||||
};
|
||||
|
||||
const renderAllPages = async () => {
|
||||
if (!pdfDoc || !sceneElement) return;
|
||||
const token = ++renderToken;
|
||||
|
||||
// Clear previous content
|
||||
sceneElement.innerHTML = '';
|
||||
@@ -261,17 +172,14 @@
|
||||
|
||||
const pdfjs = await import('pdfjs-dist');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const wrappers: HTMLElement[] = [];
|
||||
const firstPage = singlePage ? selectedPage : 1;
|
||||
const lastPage = singlePage ? selectedPage : pdfDoc.numPages;
|
||||
|
||||
for (let i = firstPage; i <= lastPage; i++) {
|
||||
for (let i = 1; i <= pdfDoc.numPages; i++) {
|
||||
const page = await pdfDoc.getPage(i);
|
||||
if (token !== renderToken) return;
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
|
||||
// Scale to fit container width
|
||||
const cssScale = getCssScale(viewport);
|
||||
const containerWidth = outerContainer?.clientWidth || 800;
|
||||
const cssScale = containerWidth / viewport.width;
|
||||
const renderScale = cssScale * dpr;
|
||||
const scaledViewport = page.getViewport({ scale: renderScale });
|
||||
const cssViewport = page.getViewport({ scale: cssScale });
|
||||
@@ -279,7 +187,6 @@
|
||||
// Create page wrapper (positioned container for canvas + text layer)
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'pdf-page-wrapper';
|
||||
wrapper.dataset.pageNumber = String(i);
|
||||
wrapper.style.position = 'relative';
|
||||
wrapper.style.width = `${Math.round(cssScale * viewport.width)}px`;
|
||||
wrapper.style.height = `${Math.round(cssScale * viewport.height)}px`;
|
||||
@@ -310,7 +217,6 @@
|
||||
canvasContext: ctx,
|
||||
viewport: scaledViewport
|
||||
}).promise;
|
||||
if (token !== renderToken) return;
|
||||
|
||||
// Create text layer overlay — pdfjs setLayerDimensions handles its sizing
|
||||
const textLayerDiv = document.createElement('div');
|
||||
@@ -324,87 +230,21 @@
|
||||
viewport: cssViewport
|
||||
});
|
||||
await textLayer.render();
|
||||
if (token !== renderToken) return;
|
||||
textLayerInstances.push(textLayer);
|
||||
|
||||
wrappers.push(wrapper);
|
||||
sceneElement.appendChild(wrapper);
|
||||
}
|
||||
|
||||
sceneElement.replaceChildren(...wrappers);
|
||||
lastRenderedZoom = 1;
|
||||
renderedPage = singlePage ? selectedPage : 0;
|
||||
initPanzoom();
|
||||
await scrollToTargetPage();
|
||||
syncVisiblePage();
|
||||
};
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (!singlePage) return;
|
||||
if (e.ctrlKey || e.metaKey) return;
|
||||
|
||||
const transform = pzInstance?.getTransform();
|
||||
if (transform && Math.abs(transform.scale - 1) >= 0.01) {
|
||||
e.preventDefault();
|
||||
pzInstance?.moveBy(-e.deltaX, -e.deltaY, false);
|
||||
zoomLevel = pzInstance?.getTransform()?.scale ?? 1;
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
if (pageCount <= 1) return;
|
||||
|
||||
const multiplier = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? outerContainer.clientHeight : 1;
|
||||
const dominantDelta = Math.abs(e.deltaY) >= Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
|
||||
wheelDelta += dominantDelta * multiplier;
|
||||
|
||||
const now = Date.now();
|
||||
if (Math.abs(wheelDelta) < wheelNavigationThreshold) return;
|
||||
if (now - lastWheelNavigationAt < wheelNavigationCooldown) {
|
||||
wheelDelta = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
lastWheelNavigationAt = now;
|
||||
void selectPage(selectedPage + (wheelDelta > 0 ? 1 : -1));
|
||||
wheelDelta = 0;
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (
|
||||
!singlePage ||
|
||||
e.defaultPrevented ||
|
||||
e.altKey ||
|
||||
e.ctrlKey ||
|
||||
e.metaKey ||
|
||||
pageCount <= 1 ||
|
||||
!pageShortcutKeys.includes(e.key)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
void selectPage(selectedPage + (e.key === 'ArrowUp' || e.key === 'ArrowLeft' ? -1 : 1));
|
||||
};
|
||||
|
||||
const focusViewer = () => {
|
||||
if (!outerContainer?.contains(document.activeElement)) outerContainer?.focus();
|
||||
};
|
||||
|
||||
const loadPdf = async () => {
|
||||
if (!url && !data) return;
|
||||
|
||||
const source = data ?? url;
|
||||
if (source === loadedSource && pdfDoc) return;
|
||||
const token = ++loadToken;
|
||||
loadedSource = source;
|
||||
loading = true;
|
||||
error = '';
|
||||
renderedPage = 0;
|
||||
pageCount = 0;
|
||||
pzInstance?.dispose();
|
||||
cancelTextLayers();
|
||||
pdfDoc?.destroy();
|
||||
pdfDoc = null;
|
||||
|
||||
try {
|
||||
const pdfjs = await import('pdfjs-dist');
|
||||
@@ -412,7 +252,7 @@
|
||||
|
||||
let pdfData: ArrayBuffer | Uint8Array;
|
||||
if (data) {
|
||||
pdfData = copyPdfData(data);
|
||||
pdfData = data;
|
||||
} else {
|
||||
// Fetch with credentials so auth cookies are sent
|
||||
const res = await fetch(url!, { credentials: 'include' });
|
||||
@@ -420,42 +260,24 @@
|
||||
pdfData = await res.arrayBuffer();
|
||||
}
|
||||
pdfDoc = await pdfjs.getDocument({ data: pdfData }).promise;
|
||||
if (token !== loadToken) return;
|
||||
pageCount = pdfDoc.numPages;
|
||||
activePage = clampDocumentTargetPage(targetPage, pageCount) ?? 1;
|
||||
targetPage = clampDocumentTargetPage(targetPage, pageCount) ?? 1;
|
||||
await renderAllPages();
|
||||
} catch (e) {
|
||||
if (token === loadToken) {
|
||||
console.error('PDF render error:', e);
|
||||
error = 'Failed to load PDF.';
|
||||
}
|
||||
console.error('PDF render error:', e);
|
||||
error = 'Failed to load PDF.';
|
||||
} finally {
|
||||
if (token === loadToken) loading = false;
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
loadPdf();
|
||||
});
|
||||
|
||||
$: if (mounted && (data || url)) {
|
||||
void loadPdf();
|
||||
}
|
||||
|
||||
$: if (!loading && pdfDoc && singlePage && targetPage && selectedPage !== renderedPage) {
|
||||
void renderAllPages();
|
||||
}
|
||||
|
||||
$: if (!loading && pdfDoc && !singlePage && targetPage) {
|
||||
$: if (!loading && pdfDoc && targetPage) {
|
||||
void scrollToTargetPage();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
loadToken++;
|
||||
renderToken++;
|
||||
if (scrollFrame !== null) cancelAnimationFrame(scrollFrame);
|
||||
if (rerenderTimer) clearTimeout(rerenderTimer);
|
||||
pzInstance?.dispose();
|
||||
cancelTextLayers();
|
||||
@@ -477,76 +299,17 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class={singlePage
|
||||
? 'overflow-hidden h-full flex items-center justify-center overscroll-contain'
|
||||
: 'overflow-y-auto h-full'}
|
||||
bind:this={outerContainer}
|
||||
role="application"
|
||||
aria-label={`${itemLabel} viewer`}
|
||||
tabindex="0"
|
||||
on:scroll={handleScroll}
|
||||
on:wheel|nonpassive={handleWheel}
|
||||
on:pointerdown={focusViewer}
|
||||
on:keydown={handleKeyDown}
|
||||
>
|
||||
<div bind:this={sceneElement} class={singlePage ? '' : 'w-full'}></div>
|
||||
<div class="overflow-y-auto h-full" bind:this={outerContainer}>
|
||||
<div bind:this={sceneElement} class="w-full"></div>
|
||||
</div>
|
||||
|
||||
{#if !error && pdfDoc}
|
||||
{#if !loading && !error && pdfDoc}
|
||||
<div
|
||||
class="absolute bottom-3 left-1/2 -translate-x-1/2 z-10 flex items-center gap-0.5 rounded-lg bg-white/90 dark:bg-gray-850/90 backdrop-blur-sm shadow-lg border border-gray-200/60 dark:border-gray-700/60 px-1 py-0.5"
|
||||
>
|
||||
{#if singlePage}
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400 disabled:opacity-30"
|
||||
disabled={selectedPage === 1}
|
||||
on:click={() => selectPage(selectedPage - 1)}
|
||||
aria-label={`Previous ${itemLabel.toLowerCase()}`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-3.5"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span
|
||||
class="shrink-0 min-w-12 text-center text-[0.6875rem] text-gray-500 dark:text-gray-400 tabular-nums"
|
||||
>{selectedPage} / {pageCount}</span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400 disabled:opacity-30"
|
||||
disabled={selectedPage === pageCount}
|
||||
on:click={() => selectPage(selectedPage + 1)}
|
||||
aria-label={`Next ${itemLabel.toLowerCase()}`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-3.5"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<!-- Pinch covers in/out on coarse pointers; reset has no gesture, so it stays -->
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400 pointer-coarse:hidden"
|
||||
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400"
|
||||
on:click={zoomOut}
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
@@ -573,7 +336,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400 pointer-coarse:hidden"
|
||||
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400"
|
||||
on:click={zoomIn}
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.mjs?url';
|
||||
import Spinner from './Spinner.svelte';
|
||||
import PDFViewer from './PDFViewer.svelte';
|
||||
|
||||
export let data: ArrayBuffer | Uint8Array | null = null;
|
||||
export let currentSlide = 0;
|
||||
export let className = '';
|
||||
export let targetPage: number | null = null;
|
||||
export let singlePage = false;
|
||||
export let itemLabel = 'Page';
|
||||
export let listLabel = 'Pages';
|
||||
|
||||
type PdfDocument = import('pdfjs-dist').PDFDocumentProxy;
|
||||
|
||||
let rootEl: HTMLDivElement;
|
||||
let pdfViewerRef: PDFViewer;
|
||||
let viewerData: ArrayBuffer | Uint8Array | null = null;
|
||||
let pageTarget: number | null = null;
|
||||
let thumbsLoading = false;
|
||||
let thumbnails: string[] = [];
|
||||
let thumbnailButtons: Array<HTMLButtonElement | undefined> = [];
|
||||
let hideThumbs = false;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let pdfDoc: PdfDocument | null = null;
|
||||
let loadToken = 0;
|
||||
|
||||
$: safePage = Math.min(Math.max(0, currentSlide), Math.max(0, thumbnails.length - 1));
|
||||
|
||||
const copyPdfData = (pdfData: ArrayBuffer | Uint8Array) =>
|
||||
pdfData instanceof Uint8Array ? pdfData.slice() : pdfData.slice(0);
|
||||
|
||||
const updateLayout = () => {
|
||||
hideThumbs = (rootEl?.clientWidth ?? window.innerWidth) < 720;
|
||||
};
|
||||
|
||||
const trackThumbnail = (node: HTMLButtonElement, index: number) => {
|
||||
thumbnailButtons[index] = node;
|
||||
return {
|
||||
destroy: () => {
|
||||
if (thumbnailButtons[index] === node) thumbnailButtons[index] = undefined;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const scrollSelectedThumbnailIntoView = () => {
|
||||
if (hideThumbs) return;
|
||||
thumbnailButtons[safePage]?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
};
|
||||
|
||||
const selectPage = async (index: number) => {
|
||||
currentSlide = Math.min(Math.max(0, index), Math.max(0, thumbnails.length - 1));
|
||||
pageTarget = currentSlide + 1;
|
||||
await tick();
|
||||
await pdfViewerRef?.scrollToPage?.(pageTarget);
|
||||
scrollSelectedThumbnailIntoView();
|
||||
};
|
||||
|
||||
const loadThumbnails = async (pdfData: ArrayBuffer | Uint8Array) => {
|
||||
const token = ++loadToken;
|
||||
thumbsLoading = true;
|
||||
thumbnails = [];
|
||||
|
||||
try {
|
||||
const pdfjs = await import('pdfjs-dist');
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
|
||||
|
||||
pdfDoc?.destroy();
|
||||
pdfDoc = await pdfjs.getDocument({ data: pdfData }).promise;
|
||||
|
||||
const rendered: string[] = [];
|
||||
for (let i = 1; i <= pdfDoc.numPages; i++) {
|
||||
if (token !== loadToken) return;
|
||||
|
||||
const page = await pdfDoc.getPage(i);
|
||||
const viewport = page.getViewport({ scale: 0.28 });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) continue;
|
||||
|
||||
await page.render({ canvas, canvasContext: ctx, viewport }).promise;
|
||||
rendered.push(canvas.toDataURL('image/png'));
|
||||
}
|
||||
|
||||
if (token === loadToken) thumbnails = rendered;
|
||||
} catch (e) {
|
||||
console.error('PDF thumbnail render error:', e);
|
||||
if (token === loadToken) thumbnails = [];
|
||||
} finally {
|
||||
if (token === loadToken) thumbsLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
$: if (data) {
|
||||
viewerData = copyPdfData(data);
|
||||
void loadThumbnails(copyPdfData(data));
|
||||
} else {
|
||||
viewerData = null;
|
||||
thumbnails = [];
|
||||
}
|
||||
|
||||
$: if (targetPage) {
|
||||
currentSlide = Math.max(0, targetPage - 1);
|
||||
pageTarget = targetPage;
|
||||
}
|
||||
|
||||
$: if (thumbnails.length > 0) {
|
||||
void tick().then(scrollSelectedThumbnailIntoView);
|
||||
}
|
||||
|
||||
export const resetView = () => {
|
||||
pdfViewerRef?.resetView();
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
currentSlide = page - 1;
|
||||
if (singlePage) pageTarget = page;
|
||||
void tick().then(scrollSelectedThumbnailIntoView);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
resizeObserver = new ResizeObserver(updateLayout);
|
||||
if (rootEl) resizeObserver.observe(rootEl);
|
||||
updateLayout();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
loadToken++;
|
||||
resizeObserver?.disconnect();
|
||||
pdfDoc?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={rootEl}
|
||||
class="relative grid {hideThumbs
|
||||
? 'grid-cols-[minmax(0,1fr)]'
|
||||
: 'grid-cols-[144px_minmax(0,1fr)]'} min-h-0 bg-transparent text-gray-900 dark:text-gray-100 {className}"
|
||||
>
|
||||
<aside
|
||||
class={hideThumbs
|
||||
? 'hidden'
|
||||
: 'thumbnail-sidebar overflow-y-auto px-2 pt-3 pb-16 border-r border-gray-50 dark:border-gray-850/30 bg-transparent'}
|
||||
aria-label={listLabel}
|
||||
>
|
||||
{#if thumbsLoading && thumbnails.length === 0}
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<Spinner className="size-4" />
|
||||
</div>
|
||||
{:else}
|
||||
{#each thumbnails as thumbnail, index}
|
||||
<button
|
||||
use:trackThumbnail={index}
|
||||
type="button"
|
||||
class="grid grid-cols-[20px_minmax(0,1fr)] items-start gap-2 w-full mb-3 p-0 text-left text-gray-900 dark:text-gray-100"
|
||||
on:click={() => selectPage(index)}
|
||||
aria-label="{itemLabel} {index + 1}"
|
||||
aria-current={safePage === index ? 'true' : undefined}
|
||||
>
|
||||
<span
|
||||
class="pt-1.5 text-[0.6875rem] font-medium text-right {safePage === index
|
||||
? 'text-gray-400 dark:text-gray-500'
|
||||
: 'text-gray-300/70 dark:text-gray-700'}">{index + 1}</span
|
||||
>
|
||||
<span
|
||||
class="block overflow-hidden rounded-md bg-transparent {safePage === index
|
||||
? 'opacity-100'
|
||||
: 'opacity-55 hover:opacity-80'}"
|
||||
>
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt="{itemLabel} {index + 1} thumbnail"
|
||||
class="block w-full h-full object-contain"
|
||||
draggable="false"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<section class="min-w-0 min-h-0 overflow-hidden">
|
||||
{#if viewerData}
|
||||
<PDFViewer
|
||||
bind:this={pdfViewerRef}
|
||||
data={viewerData}
|
||||
targetPage={pageTarget}
|
||||
{singlePage}
|
||||
{itemLabel}
|
||||
onPageChange={handlePageChange}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.thumbnail-sidebar {
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
|
||||
.thumbnail-sidebar:hover,
|
||||
.thumbnail-sidebar:focus,
|
||||
.thumbnail-sidebar:focus-within,
|
||||
.thumbnail-sidebar:active {
|
||||
scrollbar-color: rgba(215, 215, 215, 0.6) transparent;
|
||||
}
|
||||
|
||||
:global(.dark) .thumbnail-sidebar:hover,
|
||||
:global(.dark) .thumbnail-sidebar:focus,
|
||||
:global(.dark) .thumbnail-sidebar:focus-within,
|
||||
:global(.dark) .thumbnail-sidebar:active {
|
||||
scrollbar-color: rgba(67, 67, 67, 0.6) transparent;
|
||||
}
|
||||
|
||||
.thumbnail-sidebar::-webkit-scrollbar-thumb {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.thumbnail-sidebar:hover::-webkit-scrollbar-thumb,
|
||||
.thumbnail-sidebar:focus::-webkit-scrollbar-thumb,
|
||||
.thumbnail-sidebar:focus-within::-webkit-scrollbar-thumb,
|
||||
.thumbnail-sidebar:active::-webkit-scrollbar-thumb {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.thumbnail-sidebar::-webkit-scrollbar-corner {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -7,8 +7,6 @@
|
||||
export let currentSlide = 0;
|
||||
export let className = '';
|
||||
export let targetPage: number | null = null;
|
||||
export let itemLabel = 'Slide';
|
||||
export let listLabel = 'Slides';
|
||||
|
||||
let rootEl: HTMLDivElement;
|
||||
let stageEl: HTMLElement;
|
||||
@@ -121,10 +119,6 @@
|
||||
}
|
||||
};
|
||||
|
||||
const focusPreview = () => {
|
||||
if (!rootEl?.contains(document.activeElement)) rootEl?.focus();
|
||||
};
|
||||
|
||||
const zoomIn = () => {
|
||||
if (!pzInstance || !stageEl) return;
|
||||
pzInstance.zoomTo(stageEl.clientWidth / 2, stageEl.clientHeight / 2, 1.25);
|
||||
@@ -231,13 +225,10 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeyDown} />
|
||||
|
||||
<div
|
||||
bind:this={rootEl}
|
||||
role="application"
|
||||
aria-label={`${itemLabel} preview`}
|
||||
tabindex="0"
|
||||
on:keydown={handleKeyDown}
|
||||
on:pointerdown={focusPreview}
|
||||
class="relative grid {hideThumbs
|
||||
? 'grid-cols-[minmax(0,1fr)]'
|
||||
: 'grid-cols-[144px_minmax(0,1fr)]'} min-h-0 bg-transparent text-gray-900 dark:text-gray-100 {className}"
|
||||
@@ -245,8 +236,8 @@
|
||||
<aside
|
||||
class={hideThumbs
|
||||
? 'hidden'
|
||||
: 'thumbnail-sidebar overflow-y-auto px-2 pt-3 pb-16 border-r border-gray-50 dark:border-gray-850/30 bg-transparent'}
|
||||
aria-label={listLabel}
|
||||
: 'pptx-slide-rail overflow-y-auto px-2 pt-3 pb-16 border-r border-gray-50 dark:border-gray-850/30 bg-transparent'}
|
||||
aria-label="Slides"
|
||||
>
|
||||
{#each slides as slide, index}
|
||||
<button
|
||||
@@ -254,7 +245,7 @@
|
||||
type="button"
|
||||
class="grid grid-cols-[20px_minmax(0,1fr)] items-start gap-2 w-full mb-3 p-0 text-left text-gray-900 dark:text-gray-100"
|
||||
on:click={() => selectSlide(index)}
|
||||
aria-label="{itemLabel} {index + 1}"
|
||||
aria-label="Slide {index + 1}"
|
||||
aria-current={safeSlide === index ? 'true' : undefined}
|
||||
>
|
||||
<span
|
||||
@@ -269,7 +260,7 @@
|
||||
>
|
||||
<img
|
||||
src={slide}
|
||||
alt="{itemLabel} {index + 1} thumbnail"
|
||||
alt="Slide {index + 1} thumbnail"
|
||||
class="block w-full h-full object-contain"
|
||||
draggable="false"
|
||||
/>
|
||||
@@ -292,7 +283,7 @@
|
||||
<img
|
||||
bind:this={slideImgEl}
|
||||
src={selectedSlide}
|
||||
alt="{itemLabel} {safeSlide + 1}"
|
||||
alt="Slide {safeSlide + 1}"
|
||||
class="block w-full h-full object-contain rounded"
|
||||
draggable="false"
|
||||
on:load={onSlideLoad}
|
||||
@@ -312,7 +303,7 @@
|
||||
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400 disabled:opacity-30"
|
||||
disabled={safeSlide === 0}
|
||||
on:click={() => selectSlide(safeSlide - 1)}
|
||||
aria-label={`Previous ${itemLabel.toLowerCase()}`}
|
||||
aria-label="Previous slide"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -336,7 +327,7 @@
|
||||
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400 disabled:opacity-30"
|
||||
disabled={safeSlide === slides.length - 1}
|
||||
on:click={() => selectSlide(safeSlide + 1)}
|
||||
aria-label={`Next ${itemLabel.toLowerCase()}`}
|
||||
aria-label="Next slide"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -351,10 +342,9 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Pinch covers in/out on coarse pointers; reset has no gesture, so it stays -->
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400 pointer-coarse:hidden"
|
||||
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400"
|
||||
on:click={zoomOut}
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
@@ -381,7 +371,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400 pointer-coarse:hidden"
|
||||
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400"
|
||||
on:click={zoomIn}
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
@@ -401,36 +391,36 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.thumbnail-sidebar {
|
||||
.pptx-slide-rail {
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
|
||||
.thumbnail-sidebar:hover,
|
||||
.thumbnail-sidebar:focus,
|
||||
.thumbnail-sidebar:focus-within,
|
||||
.thumbnail-sidebar:active {
|
||||
.pptx-slide-rail:hover,
|
||||
.pptx-slide-rail:focus,
|
||||
.pptx-slide-rail:focus-within,
|
||||
.pptx-slide-rail:active {
|
||||
scrollbar-color: rgba(215, 215, 215, 0.6) transparent;
|
||||
}
|
||||
|
||||
:global(.dark) .thumbnail-sidebar:hover,
|
||||
:global(.dark) .thumbnail-sidebar:focus,
|
||||
:global(.dark) .thumbnail-sidebar:focus-within,
|
||||
:global(.dark) .thumbnail-sidebar:active {
|
||||
:global(.dark) .pptx-slide-rail:hover,
|
||||
:global(.dark) .pptx-slide-rail:focus,
|
||||
:global(.dark) .pptx-slide-rail:focus-within,
|
||||
:global(.dark) .pptx-slide-rail:active {
|
||||
scrollbar-color: rgba(67, 67, 67, 0.6) transparent;
|
||||
}
|
||||
|
||||
.thumbnail-sidebar::-webkit-scrollbar-thumb {
|
||||
.pptx-slide-rail::-webkit-scrollbar-thumb {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.thumbnail-sidebar:hover::-webkit-scrollbar-thumb,
|
||||
.thumbnail-sidebar:focus::-webkit-scrollbar-thumb,
|
||||
.thumbnail-sidebar:focus-within::-webkit-scrollbar-thumb,
|
||||
.thumbnail-sidebar:active::-webkit-scrollbar-thumb {
|
||||
.pptx-slide-rail:hover::-webkit-scrollbar-thumb,
|
||||
.pptx-slide-rail:focus::-webkit-scrollbar-thumb,
|
||||
.pptx-slide-rail:focus-within::-webkit-scrollbar-thumb,
|
||||
.pptx-slide-rail:active::-webkit-scrollbar-thumb {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.thumbnail-sidebar::-webkit-scrollbar-corner {
|
||||
.pptx-slide-rail::-webkit-scrollbar-corner {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,8 +15,6 @@
|
||||
|
||||
let panelElement: HTMLDivElement | null = null;
|
||||
let isResizing = false;
|
||||
let activePointerId: number | null = null;
|
||||
let activeResizer: HTMLElement | null = null;
|
||||
let startClientX = 0;
|
||||
let startWidth = 0;
|
||||
|
||||
@@ -50,31 +48,19 @@
|
||||
onClose();
|
||||
};
|
||||
|
||||
const resizeStartHandler = (e: PointerEvent) => {
|
||||
const resizeStartHandler = (e: MouseEvent) => {
|
||||
if (!open) return;
|
||||
|
||||
e.preventDefault();
|
||||
isResizing = true;
|
||||
activePointerId = e.pointerId;
|
||||
activeResizer = e.currentTarget as HTMLElement;
|
||||
activeResizer.setPointerCapture?.(e.pointerId);
|
||||
startClientX = e.clientX;
|
||||
startWidth = width;
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
const resizeEndHandler = (e?: PointerEvent) => {
|
||||
const resizeEndHandler = () => {
|
||||
if (!isResizing) return;
|
||||
if (e && activePointerId !== null && e.pointerId !== activePointerId) return;
|
||||
|
||||
isResizing = false;
|
||||
if (activePointerId !== null) {
|
||||
if (activeResizer?.hasPointerCapture?.(activePointerId)) {
|
||||
activeResizer.releasePointerCapture(activePointerId);
|
||||
}
|
||||
}
|
||||
activePointerId = null;
|
||||
activeResizer = null;
|
||||
document.body.style.userSelect = '';
|
||||
persistWidth();
|
||||
};
|
||||
@@ -123,16 +109,14 @@
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
on:pointermove={(e) => {
|
||||
on:mousemove={(e) => {
|
||||
if (!isResizing) return;
|
||||
if (activePointerId !== null && e.pointerId !== activePointerId) return;
|
||||
resizeHandler(e.clientX);
|
||||
}}
|
||||
on:resize={() => {
|
||||
if (open) width = clamp(width);
|
||||
}}
|
||||
on:pointerup={resizeEndHandler}
|
||||
on:pointercancel={resizeEndHandler}
|
||||
on:mouseup={resizeEndHandler}
|
||||
/>
|
||||
|
||||
{#if open}
|
||||
@@ -141,7 +125,7 @@
|
||||
<div
|
||||
class="relative flex items-center justify-center group border-l border-gray-50 dark:border-gray-850/30 hover:border-gray-200 dark:hover:border-gray-800 transition z-20 bg-transparent p-0 appearance-none"
|
||||
id={resizerId}
|
||||
on:pointerdown={resizeStartHandler}
|
||||
on:mousedown={resizeStartHandler}
|
||||
on:keydown={resizeKeyHandler}
|
||||
role="separator"
|
||||
tabindex="0"
|
||||
@@ -150,7 +134,6 @@
|
||||
>
|
||||
<span
|
||||
class="absolute -left-1.5 -right-1.5 -top-0 -bottom-0 z-20 cursor-col-resize bg-transparent"
|
||||
style="touch-action: none;"
|
||||
></span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -164,7 +147,7 @@
|
||||
<div
|
||||
class="relative flex items-center justify-center group border-r border-gray-50 dark:border-gray-850/30 hover:border-gray-200 dark:hover:border-gray-800 transition z-20 bg-transparent p-0 appearance-none"
|
||||
id={resizerId}
|
||||
on:pointerdown={resizeStartHandler}
|
||||
on:mousedown={resizeStartHandler}
|
||||
on:keydown={resizeKeyHandler}
|
||||
role="separator"
|
||||
tabindex="0"
|
||||
@@ -173,7 +156,6 @@
|
||||
>
|
||||
<span
|
||||
class="absolute -left-1.5 -right-1.5 -top-0 -bottom-0 z-20 cursor-col-resize bg-transparent"
|
||||
style="touch-action: none;"
|
||||
></span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -74,21 +74,11 @@
|
||||
contentEl.style.bottom = 'auto';
|
||||
}
|
||||
|
||||
const contentWidth = contentEl.offsetWidth || 0;
|
||||
|
||||
if (align === 'end') {
|
||||
let right = window.innerWidth - rect.right;
|
||||
if (right + contentWidth > window.innerWidth) {
|
||||
right = window.innerWidth - contentWidth - 16;
|
||||
}
|
||||
contentEl.style.right = `${Math.max(16, right)}px`;
|
||||
contentEl.style.right = `${window.innerWidth - rect.right}px`;
|
||||
contentEl.style.left = 'auto';
|
||||
} else {
|
||||
let left = rect.left;
|
||||
if (left + contentWidth + 16 > window.innerWidth) {
|
||||
left = window.innerWidth - contentWidth - 16;
|
||||
}
|
||||
contentEl.style.left = `${Math.max(16, left)}px`;
|
||||
contentEl.style.left = `${rect.left}px`;
|
||||
contentEl.style.right = 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
{#if (valves[property] ?? null) !== null}
|
||||
<!-- {valves[property]} -->
|
||||
<div class="flex mt-0.5 mb-0.5 space-x-2">
|
||||
<div class=" flex-1 min-w-0">
|
||||
<div class=" flex-1">
|
||||
{#if valvesSpec.properties[property]?.enum ?? null}
|
||||
<select
|
||||
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30"
|
||||
|
||||
@@ -44,10 +44,6 @@
|
||||
export let orderBy = 'updated_at';
|
||||
export let direction = 'desc'; // 'asc' or 'desc'
|
||||
|
||||
$: if (!show) {
|
||||
query = '';
|
||||
}
|
||||
|
||||
export let chatList = null;
|
||||
export let allChatsLoaded = false;
|
||||
export let chatListLoading = false;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
import { marked } from 'marked';
|
||||
|
||||
import { getAdminDetails, getLogoutRedirectUrl, userSignOut } from '$lib/apis/auths';
|
||||
import { getAdminDetails, userSignOut } from '$lib/apis/auths';
|
||||
import { onMount, tick, getContext } from 'svelte';
|
||||
import { config } from '$lib/stores';
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
return null;
|
||||
});
|
||||
localStorage.removeItem('token');
|
||||
location.href = getLogoutRedirectUrl(res?.redirect_url);
|
||||
location.href = res?.redirect_url ?? '/auth';
|
||||
}}>{$i18n.t('Sign Out')}</button
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -600,20 +600,13 @@
|
||||
const MAX_WIDTH = 480;
|
||||
|
||||
let isResizing = false;
|
||||
let activePointerId: number | null = null;
|
||||
let activeResizer: HTMLElement | null = null;
|
||||
|
||||
let startWidth = 0;
|
||||
let startClientX = 0;
|
||||
|
||||
const resizeStartHandler = (e: PointerEvent) => {
|
||||
const resizeStartHandler = (e: MouseEvent) => {
|
||||
if ($mobile) return;
|
||||
|
||||
e.preventDefault();
|
||||
isResizing = true;
|
||||
activePointerId = e.pointerId;
|
||||
activeResizer = e.currentTarget as HTMLElement;
|
||||
activeResizer.setPointerCapture?.(e.pointerId);
|
||||
|
||||
startClientX = e.clientX;
|
||||
startWidth = $sidebarWidth ?? 245;
|
||||
@@ -621,23 +614,15 @@
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
const resizeEndHandler = (e?: PointerEvent) => {
|
||||
const resizeEndHandler = () => {
|
||||
if (!isResizing) return;
|
||||
if (e && activePointerId !== null && e.pointerId !== activePointerId) return;
|
||||
|
||||
isResizing = false;
|
||||
|
||||
if (activePointerId !== null && activeResizer?.hasPointerCapture?.(activePointerId)) {
|
||||
activeResizer.releasePointerCapture(activePointerId);
|
||||
}
|
||||
activePointerId = null;
|
||||
activeResizer = null;
|
||||
|
||||
document.body.style.userSelect = '';
|
||||
localStorage.setItem('sidebarWidth', String($sidebarWidth));
|
||||
};
|
||||
|
||||
const resizeSidebarHandler = (endClientX: number) => {
|
||||
const resizeSidebarHandler = (endClientX) => {
|
||||
const dx = endClientX - startClientX;
|
||||
const newSidebarWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + dx));
|
||||
|
||||
@@ -645,12 +630,6 @@
|
||||
document.documentElement.style.setProperty('--sidebar-width', `${newSidebarWidth}px`);
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
if (isResizing) {
|
||||
document.body.style.userSelect = '';
|
||||
}
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const width = Number(localStorage.getItem('sidebarWidth'));
|
||||
@@ -882,13 +861,13 @@
|
||||
/>
|
||||
|
||||
<svelte:window
|
||||
on:pointermove={(e) => {
|
||||
on:mousemove={(e) => {
|
||||
if (!isResizing) return;
|
||||
if (activePointerId !== null && e.pointerId !== activePointerId) return;
|
||||
resizeSidebarHandler(e.clientX);
|
||||
}}
|
||||
on:pointerup={resizeEndHandler}
|
||||
on:pointercancel={resizeEndHandler}
|
||||
on:mouseup={() => {
|
||||
resizeEndHandler();
|
||||
}}
|
||||
/>
|
||||
|
||||
<MobileSwipePanel
|
||||
@@ -961,7 +940,7 @@
|
||||
aria-label={$showSidebar ? $i18n.t('Close Sidebar') : $i18n.t('Open Sidebar')}
|
||||
>
|
||||
<div
|
||||
class="self-center flex size-[calc(30px*var(--app-text-scale,1))] items-center justify-center rounded-lg transition group-hover:bg-gray-100 dark:group-hover:bg-gray-900"
|
||||
class="self-center flex size-[calc(30px*var(--app-text-scale,1))] items-center justify-center rounded-lg transition group-hover:bg-gray-50 dark:group-hover:bg-gray-900"
|
||||
>
|
||||
<!-- LICENSE covers this Open WebUI sidebar logo.
|
||||
Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
@@ -995,7 +974,7 @@
|
||||
aria-label={$i18n.t('New Chat')}
|
||||
>
|
||||
<div
|
||||
class="self-center flex size-[calc(30px*var(--app-text-scale,1))] items-center justify-center rounded-lg transition group-hover:bg-gray-100 dark:group-hover:bg-gray-900"
|
||||
class="self-center flex size-[calc(30px*var(--app-text-scale,1))] items-center justify-center rounded-lg transition group-hover:bg-gray-50 dark:group-hover:bg-gray-900"
|
||||
>
|
||||
<EditPencilIcon className="size-4" strokeWidth="1.5" />
|
||||
</div>
|
||||
@@ -1017,7 +996,7 @@
|
||||
aria-label={$i18n.t('Search')}
|
||||
>
|
||||
<div
|
||||
class="self-center flex size-[calc(30px*var(--app-text-scale,1))] items-center justify-center rounded-lg transition group-hover:bg-gray-100 dark:group-hover:bg-gray-900"
|
||||
class="self-center flex size-[calc(30px*var(--app-text-scale,1))] items-center justify-center rounded-lg transition group-hover:bg-gray-50 dark:group-hover:bg-gray-900"
|
||||
>
|
||||
<SearchIcon className="size-4" strokeWidth="1.5" />
|
||||
</div>
|
||||
@@ -1048,7 +1027,7 @@
|
||||
? ($settings?.highContrastMode ?? false)
|
||||
? 'bg-black/[0.035] dark:bg-white/[0.06]'
|
||||
: 'bg-black/[0.035] dark:bg-white/[0.045]'
|
||||
: 'group-hover:bg-gray-100 dark:group-hover:bg-gray-900'}"
|
||||
: 'group-hover:bg-gray-50 dark:group-hover:bg-gray-900'}"
|
||||
>
|
||||
{#if itemId === 'notes'}
|
||||
<NotesIcon className="size-4" strokeWidth="1.5" />
|
||||
@@ -1081,7 +1060,7 @@
|
||||
aria-label={$i18n.t('User menu')}
|
||||
>
|
||||
<div
|
||||
class="self-center relative flex size-[calc(30px*var(--app-text-scale,1))] items-center justify-center rounded-lg transition group-hover:bg-gray-100 dark:group-hover:bg-gray-900"
|
||||
class="self-center relative flex size-[calc(30px*var(--app-text-scale,1))] items-center justify-center rounded-lg transition group-hover:bg-gray-50 dark:group-hover:bg-gray-900"
|
||||
>
|
||||
<img
|
||||
src={`${WEBUI_API_BASE_URL}/users/${$user?.id}/profile/image`}
|
||||
@@ -1144,7 +1123,7 @@
|
||||
class="sidebar px-1 pt-1.5 pb-1 flex justify-between space-x-1 text-gray-600 dark:text-gray-400 sticky top-0 z-10 -mb-2"
|
||||
>
|
||||
<a
|
||||
class="flex items-center rounded-xl size-8.5 h-full justify-center hover:bg-gray-100 dark:hover:bg-gray-900 transition no-drag-region"
|
||||
class="flex items-center rounded-xl size-8.5 h-full justify-center hover:bg-gray-50 dark:hover:bg-gray-900 transition no-drag-region"
|
||||
href="/"
|
||||
draggable="false"
|
||||
on:click={newChatHandler}
|
||||
@@ -1176,7 +1155,7 @@
|
||||
placement="bottom"
|
||||
>
|
||||
<button
|
||||
class="flex size-[1.875rem] justify-center items-center rounded-lg hover:bg-gray-100 dark:hover:bg-gray-900 transition {isWindows
|
||||
class="flex size-[1.875rem] justify-center items-center rounded-lg hover:bg-gray-50 dark:hover:bg-gray-900 transition {isWindows
|
||||
? 'cursor-pointer'
|
||||
: 'cursor-[w-resize]'}"
|
||||
on:click={() => {
|
||||
@@ -1211,7 +1190,7 @@
|
||||
<div class="px-1 flex justify-center text-gray-700 dark:text-gray-300">
|
||||
<a
|
||||
id="sidebar-new-chat-button"
|
||||
class="group grow flex items-center space-x-2 rounded-xl px-2 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-900 transition outline-none"
|
||||
class="group grow flex items-center space-x-2 rounded-xl px-2 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-900 transition outline-none"
|
||||
href="/"
|
||||
draggable="false"
|
||||
on:click={newChatHandler}
|
||||
@@ -1232,7 +1211,7 @@
|
||||
<div class="px-1 flex justify-center text-gray-700 dark:text-gray-300">
|
||||
<button
|
||||
id="sidebar-search-button"
|
||||
class="group grow flex items-center space-x-2 rounded-xl px-2 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-900 transition outline-none"
|
||||
class="group grow flex items-center space-x-2 rounded-xl px-2 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-900 transition outline-none"
|
||||
on:click={() => {
|
||||
showSearch.set(true);
|
||||
}}
|
||||
@@ -1265,7 +1244,7 @@
|
||||
? ($settings?.highContrastMode ?? false)
|
||||
? 'bg-black/[0.035] dark:bg-white/[0.06]'
|
||||
: 'bg-black/[0.035] dark:bg-white/[0.045]'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-900'}"
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-900'}"
|
||||
href={meta.href}
|
||||
on:click={itemClickHandler}
|
||||
draggable="false"
|
||||
@@ -1504,7 +1483,7 @@
|
||||
<div slot="content">
|
||||
<DropdownMenu className="min-w-[10.625rem]">
|
||||
<button
|
||||
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900"
|
||||
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
|
||||
on:click={markAllChatsReadHandler}
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
@@ -1713,7 +1692,7 @@
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class=" flex items-center rounded-xl py-1.5 px-1.5 w-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
|
||||
class=" flex items-center rounded-xl py-1.5 px-1.5 w-full hover:bg-gray-50 dark:hover:bg-gray-900 transition"
|
||||
aria-label={$i18n.t('User menu')}
|
||||
>
|
||||
<div class=" self-center mr-3 relative flex-shrink-0">
|
||||
@@ -1747,15 +1726,14 @@
|
||||
|
||||
{#if !$mobile && visible}
|
||||
<div
|
||||
class="relative flex items-center justify-center group border-r border-gray-50 dark:border-gray-850/30 hover:border-gray-200 dark:hover:border-gray-800 transition z-20 bg-transparent p-0 appearance-none"
|
||||
class="relative flex items-center justify-center group border-l border-gray-50 dark:border-gray-850/30 hover:border-gray-200 dark:hover:border-gray-800 transition z-20"
|
||||
id="sidebar-resizer"
|
||||
on:pointerdown={resizeStartHandler}
|
||||
on:mousedown={resizeStartHandler}
|
||||
role="separator"
|
||||
>
|
||||
<div
|
||||
class=" absolute -left-1.5 -right-1.5 -top-0 -bottom-0 z-20 cursor-col-resize bg-transparent"
|
||||
style="touch-action: none;"
|
||||
></div>
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
: 'bg-black/[0.035] dark:bg-white/[0.045] selected'
|
||||
: $mobile
|
||||
? ''
|
||||
: ' hover:bg-gray-100 dark:hover:bg-gray-900 group-hover:bg-gray-100 dark:group-hover:bg-gray-900'
|
||||
: ' hover:bg-gray-50 dark:hover:bg-gray-900 group-hover:bg-gray-50 dark:group-hover:bg-gray-900'
|
||||
} whitespace-nowrap text-ellipsis transition`;
|
||||
|
||||
const selectChatHandler = (event?: MouseEvent) => {
|
||||
@@ -609,7 +609,7 @@
|
||||
? ($settings?.highContrastMode ?? false)
|
||||
? 'bg-black/[0.035] dark:bg-white/[0.055] selected'
|
||||
: 'bg-black/[0.035] dark:bg-white/[0.045] selected'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-900 group-hover:bg-gray-100 dark:group-hover:bg-gray-900'} whitespace-nowrap text-ellipsis relative transition {generating
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-900 group-hover:bg-gray-50 dark:group-hover:bg-gray-900'} whitespace-nowrap text-ellipsis relative transition {generating
|
||||
? 'cursor-not-allowed'
|
||||
: ''}"
|
||||
>
|
||||
|
||||
@@ -311,7 +311,7 @@
|
||||
{#if $user?.role === 'admin' || ($user.permissions?.chat?.share ?? true)}
|
||||
<button
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 w-full"
|
||||
on:click={() => {
|
||||
shareHandler();
|
||||
}}
|
||||
@@ -326,7 +326,7 @@
|
||||
<button
|
||||
slot="trigger"
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 w-full"
|
||||
>
|
||||
<DownloadIcon className="size-3.5" strokeWidth="1.5" />
|
||||
<div class="flex items-center">{$i18n.t('Download')}</div>
|
||||
@@ -334,7 +334,7 @@
|
||||
|
||||
<button
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 w-full"
|
||||
on:click={() => {
|
||||
downloadJSONExport();
|
||||
}}
|
||||
@@ -344,7 +344,7 @@
|
||||
|
||||
<button
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 w-full"
|
||||
on:click={() => {
|
||||
downloadTxt();
|
||||
}}
|
||||
@@ -354,7 +354,7 @@
|
||||
|
||||
<button
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 select-none w-full"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 select-none w-full"
|
||||
on:click={() => {
|
||||
downloadPdf();
|
||||
}}
|
||||
@@ -366,7 +366,7 @@
|
||||
|
||||
<button
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 w-full"
|
||||
on:click={() => {
|
||||
dropdown.close();
|
||||
renameHandler();
|
||||
@@ -378,7 +378,7 @@
|
||||
|
||||
<button
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 w-full"
|
||||
on:click={() => {
|
||||
dropdown.close();
|
||||
markUnreadHandler();
|
||||
@@ -392,7 +392,7 @@
|
||||
|
||||
<button
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 w-full"
|
||||
on:click={() => {
|
||||
dropdown.close();
|
||||
pinHandler();
|
||||
@@ -410,7 +410,7 @@
|
||||
{#if $user?.role === 'admin' || ($user?.permissions?.chat?.import ?? true)}
|
||||
<button
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 w-full"
|
||||
on:click={() => {
|
||||
dropdown.close();
|
||||
cloneChatHandler();
|
||||
@@ -426,7 +426,7 @@
|
||||
<button
|
||||
slot="trigger"
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 select-none w-full"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 select-none w-full"
|
||||
>
|
||||
<FolderIcon className="size-3.5" />
|
||||
<div class="flex items-center">{$i18n.t('Move')}</div>
|
||||
@@ -435,7 +435,7 @@
|
||||
{#each $folders.sort((a, b) => b.updated_at - a.updated_at) as folder}
|
||||
<button
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 overflow-hidden w-full"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 overflow-hidden w-full"
|
||||
on:click={() => {
|
||||
moveChatHandler(chatId, folder.id);
|
||||
}}
|
||||
@@ -452,7 +452,7 @@
|
||||
|
||||
<button
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 w-full"
|
||||
on:click={() => {
|
||||
archiveChatHandler();
|
||||
}}
|
||||
@@ -464,7 +464,7 @@
|
||||
{#if $user?.role === 'admin' || ($user?.permissions?.chat?.delete ?? true)}
|
||||
<button
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 w-full"
|
||||
on:click={() => {
|
||||
deleteHandler();
|
||||
}}
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
<div slot="content">
|
||||
<DropdownMenu className="min-w-[10.625rem]">
|
||||
<button
|
||||
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900"
|
||||
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
|
||||
on:click={() => {
|
||||
onCreateSubFolder();
|
||||
}}
|
||||
@@ -60,7 +60,7 @@
|
||||
<hr class="border-gray-50/30 dark:border-gray-800/30 mx-1 my-0.5" />
|
||||
|
||||
<button
|
||||
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900"
|
||||
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
|
||||
on:click={() => {
|
||||
show = false;
|
||||
onMarkAllRead();
|
||||
@@ -73,7 +73,7 @@
|
||||
<hr class="border-gray-50/30 dark:border-gray-800/30 mx-1 my-0.5" />
|
||||
|
||||
<button
|
||||
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900"
|
||||
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
|
||||
on:click={() => {
|
||||
onEdit();
|
||||
}}
|
||||
@@ -83,7 +83,7 @@
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900"
|
||||
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
|
||||
on:click={() => {
|
||||
onExport();
|
||||
}}
|
||||
@@ -93,7 +93,7 @@
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900"
|
||||
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
|
||||
on:click={() => {
|
||||
onShare();
|
||||
}}
|
||||
@@ -105,7 +105,7 @@
|
||||
<hr class="border-gray-50/30 dark:border-gray-800/30 mx-1 my-0.5" />
|
||||
|
||||
<button
|
||||
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900"
|
||||
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
|
||||
on:click={() => {
|
||||
onDelete();
|
||||
}}
|
||||
|
||||
@@ -119,7 +119,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
aria-label={$i18n.t('Close')}
|
||||
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||||
on:click={() => {
|
||||
show = false;
|
||||
|
||||
@@ -428,8 +428,6 @@
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
delete folderRegistry[folderId];
|
||||
|
||||
if (folderElement) {
|
||||
folderElement.removeEventListener('dragover', onDragOver);
|
||||
folderElement.removeEventListener('drop', onDrop);
|
||||
@@ -744,7 +742,7 @@
|
||||
<div class="w-full group">
|
||||
<div
|
||||
id="folder-{folderId}-button"
|
||||
class="relative w-full py-1 px-1.5 rounded-xl flex items-center gap-1.5 hover:bg-gray-100 dark:hover:bg-gray-900 transition {$selectedFolder?.id ===
|
||||
class="relative w-full py-1 px-1.5 rounded-xl flex items-center gap-1.5 hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition {$selectedFolder?.id ===
|
||||
folderId
|
||||
? 'bg-gray-100/80 dark:bg-gray-850/50 selected'
|
||||
: ''}"
|
||||
@@ -780,7 +778,7 @@
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="text-gray-600 dark:text-gray-400 transition-all p-1 hover:bg-gray-100 dark:hover:bg-gray-900 rounded-lg"
|
||||
class="text-gray-600 dark:text-gray-400 transition-all p-1 hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-lg"
|
||||
on:click={(e) => {
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { fade, slide } from 'svelte/transition';
|
||||
|
||||
import { getUsage } from '$lib/apis';
|
||||
import { getLogoutRedirectUrl, getSessionUser, userSignOut } from '$lib/apis/auths';
|
||||
import { getSessionUser, userSignOut } from '$lib/apis/auths';
|
||||
|
||||
import { showSettings, mobile, showSidebar, user, config, settings } from '$lib/stores';
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
{#if $user}
|
||||
<div>
|
||||
<button
|
||||
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-xs w-full hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none text-left"
|
||||
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-xs w-full hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none text-left"
|
||||
type="button"
|
||||
on:click={async () => {
|
||||
show = false;
|
||||
@@ -166,7 +166,7 @@
|
||||
{#if $user?.status_emoji || $user?.status_message}
|
||||
<div class="user-menu-status">
|
||||
<button
|
||||
class="w-full h-[1.6875rem] gap-2 rounded-xl px-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none text-xs flex items-center text-left"
|
||||
class="w-full h-[1.6875rem] gap-2 rounded-xl px-2 hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none text-xs flex items-center text-left"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
show = false;
|
||||
@@ -218,7 +218,7 @@
|
||||
{:else}
|
||||
<div class="user-menu-status">
|
||||
<button
|
||||
class="w-full h-[1.6875rem] gap-2 rounded-xl px-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none text-xs flex items-center text-left"
|
||||
class="w-full h-[1.6875rem] gap-2 rounded-xl px-2 hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none text-xs flex items-center text-left"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
show = false;
|
||||
@@ -243,7 +243,7 @@
|
||||
<a
|
||||
href="/workspace"
|
||||
draggable="false"
|
||||
class="flex flex-1 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
|
||||
class="flex flex-1 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
|
||||
on:click={async (e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
|
||||
e.preventDefault();
|
||||
@@ -287,7 +287,7 @@
|
||||
<a
|
||||
href="/notes"
|
||||
draggable="false"
|
||||
class="flex flex-1 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
|
||||
class="flex flex-1 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
|
||||
on:click={async (e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
|
||||
e.preventDefault();
|
||||
@@ -331,7 +331,7 @@
|
||||
<a
|
||||
href="/calendar"
|
||||
draggable="false"
|
||||
class="flex flex-1 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
|
||||
class="flex flex-1 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
|
||||
on:click={async (e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
|
||||
e.preventDefault();
|
||||
@@ -375,7 +375,7 @@
|
||||
<a
|
||||
href="/automations"
|
||||
draggable="false"
|
||||
class="flex flex-1 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
|
||||
class="flex flex-1 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
|
||||
on:click={async (e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
|
||||
e.preventDefault();
|
||||
@@ -419,7 +419,7 @@
|
||||
<a
|
||||
href="/playground"
|
||||
draggable="false"
|
||||
class="flex flex-1 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
|
||||
class="flex flex-1 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
|
||||
on:click={async (e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
|
||||
e.preventDefault();
|
||||
@@ -468,7 +468,7 @@
|
||||
href="https://docs.openwebui.com"
|
||||
target="_blank"
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
|
||||
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
|
||||
id="chat-share-button"
|
||||
on:click={() => {
|
||||
show = false;
|
||||
@@ -485,7 +485,7 @@
|
||||
href="https://github.com/open-webui/open-webui/releases"
|
||||
target="_blank"
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
|
||||
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
|
||||
id="chat-share-button"
|
||||
on:click={() => {
|
||||
show = false;
|
||||
@@ -499,7 +499,7 @@
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
|
||||
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
|
||||
type="button"
|
||||
id="chat-share-button"
|
||||
on:click={async () => {
|
||||
@@ -525,7 +525,7 @@
|
||||
<a
|
||||
href="/admin"
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
|
||||
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
|
||||
on:click={async (e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
|
||||
return;
|
||||
@@ -547,7 +547,7 @@
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
|
||||
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
|
||||
type="button"
|
||||
on:click={async () => {
|
||||
show = false;
|
||||
@@ -567,13 +567,14 @@
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
|
||||
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
|
||||
type="button"
|
||||
on:click={async () => {
|
||||
const res = await userSignOut();
|
||||
user.set(null);
|
||||
localStorage.removeItem('token');
|
||||
|
||||
location.href = getLogoutRedirectUrl(res?.redirect_url);
|
||||
location.href = res?.redirect_url ?? '/auth';
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -272,11 +272,6 @@
|
||||
viewOption = localStorage?.workspaceViewOption || '';
|
||||
sourceOption = localStorage?.workspaceKnowledgeSourceOption || '';
|
||||
loaded = true;
|
||||
await tick();
|
||||
|
||||
if (items === null && !itemsLoading) {
|
||||
await init();
|
||||
}
|
||||
|
||||
if (showCreateOnMount) {
|
||||
showCreateModal = true;
|
||||
|
||||
@@ -619,51 +619,6 @@
|
||||
return currentPath && path ? `${currentPath}/${path}` : currentPath || path;
|
||||
};
|
||||
|
||||
const uploadManifestEntries = async (
|
||||
entries: DirectoryManifestEntry[],
|
||||
resolveDirectoryId: (entry: DirectoryManifestEntry) => string | null | undefined
|
||||
) => {
|
||||
let failedCount = 0;
|
||||
|
||||
for (const [index, entry] of entries.entries()) {
|
||||
const displayPath = entry.path ? `${entry.path}/${entry.filename}` : entry.filename;
|
||||
syncing = $i18n.t('Uploading {{current}}/{{total}}: {{file}}', {
|
||||
current: index + 1,
|
||||
total: entries.length,
|
||||
file: displayPath
|
||||
});
|
||||
|
||||
const fileObject = new File([entry.file], entry.filename, { type: entry.file.type });
|
||||
const uploadedFile = await uploadFile(localStorage.token, fileObject, {
|
||||
knowledge_id: knowledge.id,
|
||||
file_hash: entry.checksum,
|
||||
directory_id: resolveDirectoryId(entry)
|
||||
}).catch((error) => ({ error }));
|
||||
|
||||
if (!uploadedFile || uploadedFile.error) {
|
||||
const error = uploadedFile?.error;
|
||||
const reason =
|
||||
typeof error === 'string'
|
||||
? error
|
||||
: (error?.detail ?? error?.message ?? $i18n.t('Failed to upload file.'));
|
||||
|
||||
failedCount++;
|
||||
console.error('Upload failed:', displayPath, reason);
|
||||
}
|
||||
}
|
||||
|
||||
if (failedCount > 0) {
|
||||
toast.error(
|
||||
$i18n.t('Upload failed for {{failed}} of {{total}} files.', {
|
||||
failed: failedCount,
|
||||
total: entries.length
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return failedCount;
|
||||
};
|
||||
|
||||
const uploadDirectoryEntries = async (entries: DirectoryFileEntry[]) => {
|
||||
if (!knowledge) return;
|
||||
|
||||
@@ -690,14 +645,30 @@
|
||||
|
||||
const directoryIdByPath = await createMissingDirectories(diff);
|
||||
|
||||
const failedCount = await uploadManifestEntries(manifest, (entry) =>
|
||||
entry.path ? directoryIdByPath[getDirectoryUploadPath(entry.path)] : currentDirectoryId
|
||||
);
|
||||
let uploadedCount = 0;
|
||||
for (const entry of manifest) {
|
||||
uploadedCount++;
|
||||
const displayPath = entry.path ? `${entry.path}/${entry.filename}` : entry.filename;
|
||||
syncing = $i18n.t('Uploading {{current}}/{{total}}: {{file}}', {
|
||||
current: uploadedCount,
|
||||
total: manifest.length,
|
||||
file: displayPath
|
||||
});
|
||||
|
||||
if (failedCount === 0) {
|
||||
toast.success($i18n.t('File uploaded successfully'));
|
||||
const fileObject = new File([entry.file], entry.filename, { type: entry.file.type });
|
||||
await uploadFile(localStorage.token, fileObject, {
|
||||
knowledge_id: knowledge.id,
|
||||
file_hash: entry.checksum,
|
||||
directory_id: entry.path
|
||||
? directoryIdByPath[getDirectoryUploadPath(entry.path)]
|
||||
: currentDirectoryId
|
||||
}).catch((e) => {
|
||||
toast.error(`${e}`);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
toast.success($i18n.t('File uploaded successfully'));
|
||||
init();
|
||||
} catch (e) {
|
||||
toast.error(`${e}`);
|
||||
@@ -752,24 +723,36 @@
|
||||
diff.modified.some((m: any) => m.filename === entry.filename && m.path === entry.path)
|
||||
);
|
||||
|
||||
const failedCount = await uploadManifestEntries(filesToUpload, (entry) =>
|
||||
entry.path ? directoryIdByPath[entry.path] : null
|
||||
);
|
||||
let uploadedCount = 0;
|
||||
for (const entry of filesToUpload) {
|
||||
uploadedCount++;
|
||||
const displayPath = entry.path ? `${entry.path}/${entry.filename}` : entry.filename;
|
||||
syncing = $i18n.t('Uploading {{current}}/{{total}}: {{file}}', {
|
||||
current: uploadedCount,
|
||||
total: filesToUpload.length,
|
||||
file: displayPath
|
||||
});
|
||||
|
||||
const fileObject = new File([entry.file], entry.filename, { type: entry.file.type });
|
||||
await uploadFile(localStorage.token, fileObject, {
|
||||
knowledge_id: knowledge.id,
|
||||
file_hash: entry.checksum,
|
||||
directory_id: entry.path ? directoryIdByPath[entry.path] : null
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
// ── 7. Report ──
|
||||
if (failedCount === 0) {
|
||||
toast.success(
|
||||
$i18n.t(
|
||||
'Sync complete: {{added}} added, {{modified}} modified, {{deleted}} deleted, {{unmodified}} unmodified',
|
||||
{
|
||||
added: diff.added.length,
|
||||
modified: diff.modified.length,
|
||||
deleted: diff.deleted.length,
|
||||
unmodified: diff.unmodified_count
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
toast.success(
|
||||
$i18n.t(
|
||||
'Sync complete: {{added}} added, {{modified}} modified, {{deleted}} deleted, {{unmodified}} unmodified',
|
||||
{
|
||||
added: diff.added.length,
|
||||
modified: diff.modified.length,
|
||||
deleted: diff.deleted.length,
|
||||
unmodified: diff.unmodified_count
|
||||
}
|
||||
)
|
||||
);
|
||||
init();
|
||||
} catch (e) {
|
||||
toast.error(`${e}`);
|
||||
|
||||
@@ -735,14 +735,14 @@
|
||||
<Tooltip content={model.name} className="min-w-0" placement="top-start">
|
||||
<a
|
||||
href={`/?model=${encodeURIComponent(model.id)}`}
|
||||
class="block truncate text-[0.8125rem] leading-5 text-gray-800 group-hover:underline dark:text-gray-200"
|
||||
class="truncate text-[0.8125rem] leading-5 text-gray-800 group-hover:underline dark:text-gray-200"
|
||||
>
|
||||
{model.name}
|
||||
</a>
|
||||
</Tooltip>
|
||||
|
||||
<div
|
||||
class="hidden min-w-0 max-w-[40%] shrink-0 truncate text-[0.6875rem] leading-5 text-gray-500 sm:block"
|
||||
class="min-w-0 max-w-[40%] shrink-0 truncate text-[0.6875rem] leading-5 text-gray-500"
|
||||
>
|
||||
{model.id}
|
||||
</div>
|
||||
|
||||
@@ -96,7 +96,6 @@
|
||||
"Access List": "",
|
||||
"Access prohibited": "",
|
||||
"Access updated": "",
|
||||
"Accessibility Mode": "",
|
||||
"Accessible to all users": "",
|
||||
"Account": "الحساب",
|
||||
"Account Activation Pending": "",
|
||||
@@ -1338,7 +1337,6 @@
|
||||
"Follow up: {{question}}": "",
|
||||
"Follow-Up Auto-Generation": "",
|
||||
"Followed instructions perfectly": "اتبعت التعليمات على أكمل وجه",
|
||||
"Font Family": "",
|
||||
"for placeholders": "",
|
||||
"Force OCR": "",
|
||||
"Forking chat...": "",
|
||||
@@ -1443,6 +1441,7 @@
|
||||
"Hide Hidden Files": "",
|
||||
"Hide Model": "",
|
||||
"High": "",
|
||||
"High Contrast Mode": "",
|
||||
"History": "",
|
||||
"Home": "",
|
||||
"Host": "",
|
||||
@@ -2959,7 +2958,6 @@
|
||||
"Use": "",
|
||||
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "",
|
||||
"Use a Gemini API key for image generation.": "",
|
||||
"Use a local font family for the app interface.": "",
|
||||
"Use a valid event name or pattern like user.*": "",
|
||||
"Use a wider chat layout on large displays.": "",
|
||||
"Use an API key when your ComfyUI server requires one.": "",
|
||||
|
||||
@@ -96,7 +96,6 @@
|
||||
"Access List": "",
|
||||
"Access prohibited": "",
|
||||
"Access updated": "",
|
||||
"Accessibility Mode": "",
|
||||
"Accessible to all users": "متاح لجميع المستخدمين",
|
||||
"Account": "الحساب",
|
||||
"Account Activation Pending": "انتظار تفعيل الحساب",
|
||||
@@ -1338,7 +1337,6 @@
|
||||
"Follow up: {{question}}": "",
|
||||
"Follow-Up Auto-Generation": "",
|
||||
"Followed instructions perfectly": "اتبعت التعليمات على أكمل وجه",
|
||||
"Font Family": "",
|
||||
"for placeholders": "",
|
||||
"Force OCR": "",
|
||||
"Forking chat...": "",
|
||||
@@ -1443,6 +1441,7 @@
|
||||
"Hide Hidden Files": "",
|
||||
"Hide Model": "",
|
||||
"High": "",
|
||||
"High Contrast Mode": "",
|
||||
"History": "",
|
||||
"Home": "الصفحة الرئيسية",
|
||||
"Host": "المضيف",
|
||||
@@ -2959,7 +2958,6 @@
|
||||
"Use": "",
|
||||
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "",
|
||||
"Use a Gemini API key for image generation.": "",
|
||||
"Use a local font family for the app interface.": "",
|
||||
"Use a valid event name or pattern like user.*": "",
|
||||
"Use a wider chat layout on large displays.": "",
|
||||
"Use an API key when your ComfyUI server requires one.": "",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"Access List": "Giriş siyahısı",
|
||||
"Access prohibited": "",
|
||||
"Access updated": "",
|
||||
"Accessibility Mode": "",
|
||||
"Accessible to all users": "Bütün istifadəçilər üçün əlçatandır",
|
||||
"Account": "Hesab",
|
||||
"Account Activation Pending": "Hesabın aktivləşdirilməsi gözlənilir",
|
||||
@@ -1298,7 +1297,6 @@
|
||||
"Follow up: {{question}}": "Davam sualı: {{question}}",
|
||||
"Follow-Up Auto-Generation": "Davam suallarının avtomatik yaradılması",
|
||||
"Followed instructions perfectly": "Təlimatlara mükəmməl əməl edildi",
|
||||
"Font Family": "",
|
||||
"for placeholders": "yer tutucular üçün",
|
||||
"Force OCR": "Məcburi OCR",
|
||||
"Forking chat...": "",
|
||||
@@ -1403,6 +1401,7 @@
|
||||
"Hide Hidden Files": "",
|
||||
"Hide Model": "Modeli gizlət",
|
||||
"High": "Yüksək",
|
||||
"High Contrast Mode": "Yüksək kontrast rejimi",
|
||||
"History": "Tarixçə",
|
||||
"Home": "Ana səhifə",
|
||||
"Host": "Host",
|
||||
@@ -2903,7 +2902,6 @@
|
||||
"Use": "İstifadə et",
|
||||
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "Potensial olaraq daha yaxşı dəqiqlik üçün /v1/audio/transcriptions əvəzinə /v1/chat/completions son nöqtəsindən istifadə edin.",
|
||||
"Use a Gemini API key for image generation.": "",
|
||||
"Use a local font family for the app interface.": "",
|
||||
"Use a valid event name or pattern like user.*": "",
|
||||
"Use a wider chat layout on large displays.": "",
|
||||
"Use an API key when your ComfyUI server requires one.": "",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"Access List": "",
|
||||
"Access prohibited": "",
|
||||
"Access updated": "",
|
||||
"Accessibility Mode": "",
|
||||
"Accessible to all users": "Достъпно за всички потребители",
|
||||
"Account": "Акаунт",
|
||||
"Account Activation Pending": "Активирането на акаунта е в процес на изчакване",
|
||||
@@ -1298,7 +1297,6 @@
|
||||
"Follow up: {{question}}": "",
|
||||
"Follow-Up Auto-Generation": "",
|
||||
"Followed instructions perfectly": "Следвайте инструкциите перфектно",
|
||||
"Font Family": "",
|
||||
"for placeholders": "",
|
||||
"Force OCR": "",
|
||||
"Forking chat...": "",
|
||||
@@ -1403,6 +1401,7 @@
|
||||
"Hide Hidden Files": "",
|
||||
"Hide Model": "",
|
||||
"High": "",
|
||||
"High Contrast Mode": "",
|
||||
"History": "",
|
||||
"Home": "Начало",
|
||||
"Host": "Хост",
|
||||
@@ -2903,7 +2902,6 @@
|
||||
"Use": "",
|
||||
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "",
|
||||
"Use a Gemini API key for image generation.": "",
|
||||
"Use a local font family for the app interface.": "",
|
||||
"Use a valid event name or pattern like user.*": "",
|
||||
"Use a wider chat layout on large displays.": "",
|
||||
"Use an API key when your ComfyUI server requires one.": "",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"Access List": "",
|
||||
"Access prohibited": "",
|
||||
"Access updated": "",
|
||||
"Accessibility Mode": "",
|
||||
"Accessible to all users": "",
|
||||
"Account": "একাউন্ট",
|
||||
"Account Activation Pending": "",
|
||||
@@ -1298,7 +1297,6 @@
|
||||
"Follow up: {{question}}": "",
|
||||
"Follow-Up Auto-Generation": "",
|
||||
"Followed instructions perfectly": "নির্দেশাবলী নিখুঁতভাবে অনুসরণ করা হয়েছে",
|
||||
"Font Family": "",
|
||||
"for placeholders": "",
|
||||
"Force OCR": "",
|
||||
"Forking chat...": "",
|
||||
@@ -1403,6 +1401,7 @@
|
||||
"Hide Hidden Files": "",
|
||||
"Hide Model": "",
|
||||
"High": "",
|
||||
"High Contrast Mode": "",
|
||||
"History": "",
|
||||
"Home": "",
|
||||
"Host": "",
|
||||
@@ -2903,7 +2902,6 @@
|
||||
"Use": "",
|
||||
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "",
|
||||
"Use a Gemini API key for image generation.": "",
|
||||
"Use a local font family for the app interface.": "",
|
||||
"Use a valid event name or pattern like user.*": "",
|
||||
"Use a wider chat layout on large displays.": "",
|
||||
"Use an API key when your ComfyUI server requires one.": "",
|
||||
|
||||
@@ -61,7 +61,6 @@
|
||||
"Access List": "",
|
||||
"Access prohibited": "",
|
||||
"Access updated": "",
|
||||
"Accessibility Mode": "",
|
||||
"Accessible to all users": "བེད་སྤྱོད་མཁན་ཡོངས་ལ་འཛུལ་སྤྱོད་ཆོག་པ།",
|
||||
"Account": "རྩིས་ཁྲ།",
|
||||
"Account Activation Pending": "རྩིས་ཁྲ་སྒུལ་བསྐྱོད་སྒུག་བཞིན་པ།",
|
||||
@@ -1288,7 +1287,6 @@
|
||||
"Follow up: {{question}}": "",
|
||||
"Follow-Up Auto-Generation": "",
|
||||
"Followed instructions perfectly": "ལམ་སྟོན་ཡང་དག་པར་བསྒྲུབས།",
|
||||
"Font Family": "",
|
||||
"for placeholders": "",
|
||||
"Force OCR": "",
|
||||
"Forking chat...": "",
|
||||
@@ -1393,6 +1391,7 @@
|
||||
"Hide Hidden Files": "",
|
||||
"Hide Model": "དཔེ་དབྱིབས་སྦ་བ།",
|
||||
"High": "",
|
||||
"High Contrast Mode": "",
|
||||
"History": "",
|
||||
"Home": "གཙོ་ངོས།",
|
||||
"Host": "Host",
|
||||
@@ -2889,7 +2888,6 @@
|
||||
"Use": "",
|
||||
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "",
|
||||
"Use a Gemini API key for image generation.": "",
|
||||
"Use a local font family for the app interface.": "",
|
||||
"Use a valid event name or pattern like user.*": "",
|
||||
"Use a wider chat layout on large displays.": "",
|
||||
"Use an API key when your ComfyUI server requires one.": "",
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
"Access List": "",
|
||||
"Access prohibited": "",
|
||||
"Access updated": "",
|
||||
"Accessibility Mode": "",
|
||||
"Accessible to all users": "",
|
||||
"Account": "Račun",
|
||||
"Account Activation Pending": "",
|
||||
@@ -1308,7 +1307,6 @@
|
||||
"Follow up: {{question}}": "",
|
||||
"Follow-Up Auto-Generation": "",
|
||||
"Followed instructions perfectly": "Savršeno slijedio upute",
|
||||
"Font Family": "",
|
||||
"for placeholders": "",
|
||||
"Force OCR": "",
|
||||
"Forking chat...": "",
|
||||
@@ -1413,6 +1411,7 @@
|
||||
"Hide Hidden Files": "",
|
||||
"Hide Model": "",
|
||||
"High": "",
|
||||
"High Contrast Mode": "",
|
||||
"History": "",
|
||||
"Home": "",
|
||||
"Host": "",
|
||||
@@ -2917,7 +2916,6 @@
|
||||
"Use": "",
|
||||
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "",
|
||||
"Use a Gemini API key for image generation.": "",
|
||||
"Use a local font family for the app interface.": "",
|
||||
"Use a valid event name or pattern like user.*": "",
|
||||
"Use a wider chat layout on large displays.": "",
|
||||
"Use an API key when your ComfyUI server requires one.": "",
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
"Access List": "Llista d'accés",
|
||||
"Access prohibited": "Accés prohibit",
|
||||
"Access updated": "Accés actualitzat",
|
||||
"Accessibility Mode": "",
|
||||
"Accessible to all users": "Accessible a tots els usuaris",
|
||||
"Account": "Compte",
|
||||
"Account Activation Pending": "Activació del compte pendent",
|
||||
@@ -148,7 +147,7 @@
|
||||
"Advanced MinerU parsing parameters in JSON format.": "Paràmetres avançats d'anàlisi de MinerU en format JSON.",
|
||||
"Advanced Parameters": "Paràmetres avançats",
|
||||
"Advanced Params": "Paràmetres avançats",
|
||||
"After changing the embedding model, reindex knowledge, knowledge search, and memory vectors for changes to take effect.": "Després de canviar el model d'incrustació, torna a indexar el coneixement, la cerca de coneixement i els vectors de memòria perquè els canvis tinguin efecte.",
|
||||
"After changing the embedding model, reindex knowledge, knowledge search, and memory vectors for changes to take effect.": "",
|
||||
"All": "Tots",
|
||||
"All Chats": "Tots els xats",
|
||||
"All chats have been unarchived.": "Tots els xats han estat desarxivats.",
|
||||
@@ -162,8 +161,8 @@
|
||||
"All time": "Sempre",
|
||||
"All Users": "Tots els usuaris",
|
||||
"All users and system events": "Tots els usuaris i esdeveniments del sistema",
|
||||
"Allow": "Permetre",
|
||||
"Allow {{NAME}}?": "Vols permetre {{NAME}}?",
|
||||
"Allow": "",
|
||||
"Allow {{NAME}}?": "",
|
||||
"Allow artifacts to access same-origin browser APIs inside the sandbox.": "Permetre que els artefactes accedeixin a les API del navegador del mateix origen dins del sandbox.",
|
||||
"Allow automatic names for new chats.": "Permetre noms automàtics per a xats nous.",
|
||||
"Allow browser notifications for completed responses.": "Permetre les notificacions del navegador per a les respostes completades.",
|
||||
@@ -180,7 +179,7 @@
|
||||
"Allow Continue Response": "Permetre continuar la resposta",
|
||||
"Allow delegated sub-agents to keep running while the parent chat continues.": "Permetre que els subagents delegats continuïn funcionant mentre continua el xat principal.",
|
||||
"Allow Delete Messages": "Permetre eliminar missatges",
|
||||
"Allow downloads inside sandboxed iframes.": "Permet descàrregues dins d'iframes sandbox.",
|
||||
"Allow downloads inside sandboxed iframes.": "",
|
||||
"Allow File Upload": "Permetre la pujada d'arxius",
|
||||
"Allow forms inside sandboxed artifact iframes.": "Permetre formularis dins d'iframes d'artefactes en sandbox.",
|
||||
"Allow Google Drive as a document source.": "Permetre Google Drive com a font de documents.",
|
||||
@@ -193,15 +192,15 @@
|
||||
"Allow public write access": "Permetre accés públic d'escriptura",
|
||||
"Allow Rate Response": "Permetre valorar les respostes",
|
||||
"Allow Regenerate Response": "Permetre regenerar respostes",
|
||||
"Allow scripts inside sandboxed iframes.": "Permetre scripts dins d'iframes en sandbox.",
|
||||
"Allow scripts inside sandboxed iframes.": "",
|
||||
"Allow Sharing With Groups": "Permetre compartir amb grups",
|
||||
"Allow Sharing With Users": "Permetre compartir amb usuaris",
|
||||
"Allow Speech to Text": "Permetre Parla a Text",
|
||||
"Allow Temporary Chat": "Permetre el xat temporal",
|
||||
"Allow terminal previews to access same-origin browser APIs.": "Permet que les previsualitzacions del terminal accedeixin a les API del navegador del mateix origen.",
|
||||
"Allow terminal previews to access same-origin browser APIs.": "",
|
||||
"Allow Text to Speech": "Permetre Text a Parla",
|
||||
"Allow the AI to delegate tasks to sub-agents. Each sub-agent creates a real chat with full tool access. Uses additional LLM calls.": "Permetre que l'IA delegui tasques als subagents. Cada subagent crea un xat real amb accés complet a l'eina. Utilitza trucades addicionals de LLM.",
|
||||
"Allow Tool Call": "Permetre la crida a l'eina",
|
||||
"Allow Tool Call": "",
|
||||
"Allow User Location": "Permetre la ubicació de l'usuari",
|
||||
"Allow users to access calendar features.": "Permetre als usuaris accedir a les funcions del calendari.",
|
||||
"Allow users to authenticate with an LDAP directory.": "Permetre als usuaris autenticar-se amb un directori LDAP.",
|
||||
@@ -245,7 +244,7 @@
|
||||
"API Key": "clau API",
|
||||
"API Key / Token": "Clau de l'API/Token",
|
||||
"API Key created.": "clau API creada.",
|
||||
"API Key deleted.": "S'ha suprimit la clau de l'API.",
|
||||
"API Key deleted.": "",
|
||||
"API Key Endpoint Restrictions": "Restriccions del punt d'accés de la Clau API",
|
||||
"API key for embedding requests.": "Clau API per incrustar sol·licituds.",
|
||||
"API key sent to Docling.": "Clau de l'API enviada a Docling.",
|
||||
@@ -299,7 +298,7 @@
|
||||
"Ask a question": "Fer una pregunta",
|
||||
"Ask a test question": "Fes una pregunta de prova",
|
||||
"Ask this knowledge source a test question": "Fes una pregunta de prova a aquesta font de coneixement",
|
||||
"Ask User": "Pregunta a l'usuari",
|
||||
"Ask User": "",
|
||||
"Assistant": "Assistent",
|
||||
"Assistant messages": "Missatges de l'assistent",
|
||||
"Async Embedding Processing": "Procés d'incrustat asíncron",
|
||||
@@ -321,7 +320,7 @@
|
||||
"Auth required": "Es requereix l'autenticació",
|
||||
"Authenticate": "Autenticar",
|
||||
"Authentication": "Autenticació",
|
||||
"Authorize OAuth": "Autoritzar OAuth",
|
||||
"Authorize OAuth": "",
|
||||
"Auto": "Automàtic",
|
||||
"Auto (Random)": "Automàtic (aleatori)",
|
||||
"Auto Redirect": "Redirecció automàtica",
|
||||
@@ -425,7 +424,7 @@
|
||||
"Change User Role": "Canviar el rol d'usuari",
|
||||
"Channel": "Canal",
|
||||
"Channel deleted successfully": "Canal suprimit correctament",
|
||||
"Channel is required": "El canal és obligatori",
|
||||
"Channel is required": "",
|
||||
"Channel Name": "Nom del canal",
|
||||
"Channel name cannot be empty.": "El nom del canal no pot estar buit.",
|
||||
"Channel name must be less than 128 characters": "El nom del canal ha de tenir menys de 128 caràcters",
|
||||
@@ -435,7 +434,7 @@
|
||||
"Character": "Personatge",
|
||||
"Chat": "Xat",
|
||||
"Chat actions": "Accions de xat",
|
||||
"Chat archived.": "Xat arxivat",
|
||||
"Chat archived.": "Xar arxivat",
|
||||
"Chat Background Image": "Imatge de fons del xat",
|
||||
"Chat Bubble UI": "Chat Bubble UI",
|
||||
"Chat Completions": "Completacions de xat",
|
||||
@@ -446,14 +445,14 @@
|
||||
"Chat forked": "Xat bifurcat",
|
||||
"Chat history": "Historial de xats",
|
||||
"Chat History": "Historial de xats",
|
||||
"Chat Hover Previews": "Previsualitzacions del xat",
|
||||
"Chat Hover Previews": "",
|
||||
"Chat ID": "ID del xat",
|
||||
"Chat is too short to compact": "El xat és massa curt per compactar",
|
||||
"Chat moved successfully": "El xat s'ha mogut correctament",
|
||||
"Chat Permissions": "Permisos del xat",
|
||||
"Chat Tags Auto-Generation": "Generació automàtica d'etiquetes del xat",
|
||||
"Chat unshared successfully.": "El xat s'ha deixat de compartir correctament",
|
||||
"Chat Uploads": "Càrregues de xat",
|
||||
"Chat Uploads": "",
|
||||
"Chat Variables": "Variables de xat",
|
||||
"Chat Variables have conflicting model definitions": "Les variables de xat tenen definicions de model contradictòries",
|
||||
"chats": "xats",
|
||||
@@ -462,12 +461,12 @@
|
||||
"Chats Public Sharing": "Compartició pública dels xats",
|
||||
"Check Again": "Comprovar-ho de nou",
|
||||
"Check for updates": "Comprovar si hi ha actualitzacions",
|
||||
"Check OAuth Discovery": "Comprovar OAuth Discovery",
|
||||
"Check OAuth Discovery": "",
|
||||
"Checking for updates...": "Comprovant actualitzacions...",
|
||||
"Choose a dedicated model for context compaction summaries. Current Model follows the active chat model.": "Escull un model dedicat per als resums de compactació de context. El model actual segueix el model de xat actiu.",
|
||||
"Choose a model before saving...": "Triar un model abans de desar...",
|
||||
"Choose automatic, left-to-right, or right-to-left text flow.": "Escull el flux de text automàtic, d'esquerra a dreta o de dreta a esquerra.",
|
||||
"Choose channel": "Tria el canal",
|
||||
"Choose channel": "",
|
||||
"Choose fallback models for background tasks. Current Model follows the active chat model.": "Escull models alternatius per a tasques en segon pla. El model actual segueix el model de xat actiu.",
|
||||
"Choose folder": "Escull la carpeta",
|
||||
"Choose how extracted text is split before indexing.": "Escull com es divideix el text extret abans de la indexació.",
|
||||
@@ -490,7 +489,7 @@
|
||||
"Choose the speech provider used for assistant audio output.": "Escull el proveïdor de veu utilitzat per a la sortida d'àudio de l'assistent.",
|
||||
"Choose the transcription provider used for audio input.": "Escull el proveïdor de transcripció utilitzat per a l'entrada d'àudio.",
|
||||
"Choose where model responses to root-level channel mentions are posted.": "Escull on es publiquen les respostes del model a les mencions del canal arrel.",
|
||||
"Choose where terminal display_file results appear by default.": "Tria on apareixen els resultats del fitxer_visualització del terminal per defecte.",
|
||||
"Choose where terminal display_file results appear by default.": "",
|
||||
"Choose whether Enter sends immediately or uses Ctrl+Enter.": "Escull si Enter envia immediatament o utilitza Ctrl+Enter.",
|
||||
"Choose whether the app opens to the default home or chat view.": "Escull si l'aplicació s'obre a la vista d'inici o de xat predeterminada.",
|
||||
"Chunk Min Size Target": "Objectiu de mida mínima del fragment",
|
||||
@@ -511,7 +510,7 @@
|
||||
"Clear Memory": "Esborrar la memòria",
|
||||
"Clear search": "Esborrar la cerca",
|
||||
"Clear status": "Esborrar l'estat",
|
||||
"Clear vector storage and knowledge indexing data.": "Esborrar l'emmagatzematge de vectors i dades d'indexació de coneixement.",
|
||||
"Clear vector storage and knowledge indexing data.": "Esboarrar l'emmagatzematge de vectors i dades d'indexació de coneixement.",
|
||||
"click here": "prem aquí",
|
||||
"Click here for filter guides.": "Clica aquí per l'ajuda dels filtres.",
|
||||
"Click here for help.": "Clica aquí per obtenir ajuda.",
|
||||
@@ -536,14 +535,14 @@
|
||||
"Close": "Tancar",
|
||||
"Close Banner": "Tancar el bàner",
|
||||
"Close chat controls": "Tancar els controls de xat",
|
||||
"Close citation modal": "Tancar la finestra modal de citació",
|
||||
"Close citation modal": "Tancar el model de citatció",
|
||||
"Close Configure Connection Modal": "Tancar la finestra de configuració de la connexió",
|
||||
"Close feedback": "Tancar el retorn",
|
||||
"Close modal": "Tancar el modal",
|
||||
"Close Modal": "Tancar el modal",
|
||||
"Close Sidebar": "Tancar la barra lateral",
|
||||
"cloud": "núvol",
|
||||
"CMU ARCTIC speaker embedding name": "Nom del parlant d'incrustació CMU ARCTIC",
|
||||
"CMU ARCTIC speaker embedding name": "Nom de l'altaveu d'incrustació CMU ARCTIC",
|
||||
"Code Block": "Bloc de codi",
|
||||
"Code Editor": "Editor de codi",
|
||||
"Code execution": "Execució de codi",
|
||||
@@ -559,7 +558,7 @@
|
||||
"Collapse code blocks by default.": "Col·lapsar els blocs de codi per defecte.",
|
||||
"Collection": "Col·lecció",
|
||||
"Collection Field": "Camp de col·lecció",
|
||||
"Collections": "Col·leccions",
|
||||
"Collections": "Col·lecccions",
|
||||
"Color": "Color",
|
||||
"Combine semantic and keyword retrieval.": "Combinar la recuperació semàntica i de paraules clau.",
|
||||
"ComfyUI": "ComfyUI",
|
||||
@@ -628,7 +627,7 @@
|
||||
"Content Extraction Engine": "Motor d'extracció de contingut",
|
||||
"Content Field": "Camp de contingut",
|
||||
"Content lengths (character counts only)": "Mida del contingut (només els caràcters)",
|
||||
"Content matches": "Coincidència de contingut",
|
||||
"Content matches": "",
|
||||
"Context": "Context",
|
||||
"Context compacted": "Context compactat",
|
||||
"Context Compaction": "Compactació del context",
|
||||
@@ -653,7 +652,7 @@
|
||||
"Conversation saved successfully": "Conversa desada correctament",
|
||||
"Convert long pasted text into a file attachment.": "Converteix el text enganxat llargament en un fitxer adjunt.",
|
||||
"Copied": "Copiat",
|
||||
"Copied link to clipboard": "Enllaç copiat al porta-retalls",
|
||||
"Copied link to clipboard": "Enllaç copiat al portaretalls",
|
||||
"Copied shared chat URL to clipboard!": "S'ha copiat la URL compartida al porta-retalls!",
|
||||
"Copied to clipboard": "Copiat al porta-retalls",
|
||||
"Copy": "Copiar",
|
||||
@@ -709,7 +708,7 @@
|
||||
"Credential used for Document Intelligence.": "Credencial utilitzada per a Document Intelligence.",
|
||||
"Credentials used to authenticate with the Jupyter server.": "Credencials utilitzades per autenticar-se amb el servidor Jupyter.",
|
||||
"CSV Import": "Importar CSV",
|
||||
"CSV: Name,Email": "CSV: Name,Email",
|
||||
"CSV: Name,Email": "",
|
||||
"Ctrl+Enter to Send": "Ctrl+Enter per enviar",
|
||||
"Current": "Actual",
|
||||
"Current Model": "Model actual",
|
||||
@@ -734,7 +733,7 @@
|
||||
"Database": "Base de dades",
|
||||
"Datalab Marker API": "API de Datalab Marker",
|
||||
"Datalab Marker service endpoint used for document parsing.": "Punt final del servei Datalab Marker utilitzat per a l'anàlisi de documents.",
|
||||
"Date is required": "La data és necessària",
|
||||
"Date is required": "",
|
||||
"Date Modified": "Data de modificació",
|
||||
"Day": "Dia",
|
||||
"DD/MM/YYYY": "DD/MM/YYYY",
|
||||
@@ -748,9 +747,9 @@
|
||||
"Default action buttons will be used.": "S'utilitzaran els botons d'acció per defecte",
|
||||
"Default description enabled": "Descripcions per defecte habilitades",
|
||||
"Default Features": "Característiques per defecte",
|
||||
"Default Filters": "Filtres per defecte",
|
||||
"Default Filters": "Filres per defecte",
|
||||
"Default Group": "Grup per defecte",
|
||||
"Default Interface Settings": "Configuració de la interfície per defecte",
|
||||
"Default Interface Settings": "",
|
||||
"Default Model": "Model per defecte",
|
||||
"Default model updated": "Model per defecte actualitzat",
|
||||
"Default permissions": "Permisos per defecte",
|
||||
@@ -772,7 +771,7 @@
|
||||
"Delete All Chats": "Eliminar tots els xats",
|
||||
"Delete all contents inside this directory": "Eliminar tot el contingut d'aquesta carpeta",
|
||||
"Delete all contents inside this folder": "Eliminar tot el contingut d'aquesta carpeta",
|
||||
"Delete API Key?": "Vols suprimir la clau de l'API?",
|
||||
"Delete API Key?": "",
|
||||
"Delete automation?": "Eliminar l'automatització",
|
||||
"Delete calendar": "Eliminar el calendari",
|
||||
"Delete Calendar": "Eliminar el calendari",
|
||||
@@ -800,9 +799,9 @@
|
||||
"Deleted {{name}}": "S'ha eliminat {{name}}",
|
||||
"Deleted {{ok}} of {{total}} items": "S'han eliminat {{ok}} de {{total}} ítems",
|
||||
"Deleted User": "Usuari eliminat",
|
||||
"Denied {{NAME}}": "S'ha denegat {{NAME}}",
|
||||
"Deny": "Denegar",
|
||||
"Deny Tool Call": "Denegar la crida a l'eina",
|
||||
"Denied {{NAME}}": "",
|
||||
"Deny": "",
|
||||
"Deny Tool Call": "",
|
||||
"Deployment names are required for Azure OpenAI": "Els noms de desplegament són requerits per Azure OpenAI",
|
||||
"Desc": "Descendent",
|
||||
"Describe the edit...": "Descriu l'edició...",
|
||||
@@ -811,7 +810,7 @@
|
||||
"Describe your knowledge base and objectives": "Descriu la teva base de coneixement i objectius",
|
||||
"Description": "Descripció",
|
||||
"Deselect": "Deseleccionar",
|
||||
"Destination": "Destinació",
|
||||
"Destination": "",
|
||||
"Detect Artifacts Automatically": "Detectar automàticament els artefactes",
|
||||
"Detect generated artifacts and show them in the artifact workspace.": "Detectar els artefactes generats i mostrar-los a l'espai de treball d'artefactes.",
|
||||
"Detected Variables": "Variables detectades",
|
||||
@@ -878,27 +877,27 @@
|
||||
"Don't like the style": "No t'agrada l'estil?",
|
||||
"Done": "Fet",
|
||||
"Download": "Descarregar",
|
||||
"Download \"{{searchValue}}\"": "Descarrega \"{{searchValue}}\"",
|
||||
"Download \"{{searchValue}}\" from {{provider}}": "Descarrega \"{{searchValue}}\" de {{provider}}",
|
||||
"Download \"{{searchValue}}\"": "",
|
||||
"Download \"{{searchValue}}\" from {{provider}}": "",
|
||||
"Download & Delete": "Descarregar i suprimir",
|
||||
"Download all users as CSV.": "Descarregar tots els usuaris com a CSV.",
|
||||
"Download as JSON": "Descarregar com a JSON",
|
||||
"Download as SVG": "Descarrega com a SVG",
|
||||
"Download canceled": "Descàrrega cancel·lada",
|
||||
"Download CSV Template": "Descarregar la plantilla CSV",
|
||||
"Download CSV Template": "",
|
||||
"Download every user's chat history as JSON.": "Descarregar l'historial de xat de cada usuari com a JSON.",
|
||||
"Download failed": "La descarrega ha fallat",
|
||||
"Download Model": "Descarregar model",
|
||||
"Download Model": "",
|
||||
"Download the application database when supported.": "Descarregar la base de dades de l'aplicació quan sigui compatible.",
|
||||
"Download the current admin configuration as JSON.": "Descarregar la configuració actual de l'administració com a JSON.",
|
||||
"Download your chat history as a JSON export.": "Descarregar el teu historial de xat com a exportació JSON.",
|
||||
"Downloading \"{{searchValue}}\"": "Descarregant \"{{searchValue}}\"",
|
||||
"Downloading \"{{searchValue}}\"": "",
|
||||
"Downloading stats...": "Descarregant estadístiques...",
|
||||
"Drag to reorder": "Arrossegar per reordenar",
|
||||
"Draw": "Dibuixar",
|
||||
"Drop any files here to upload": "Arrossega aquí qualsevol fitxer per pujar-lo",
|
||||
"Drop files here": "Arrossega els arxius aquí",
|
||||
"Drop to upload": "Arrossega per carregar",
|
||||
"Drop to upload": "",
|
||||
"DuckDuckGo": "DuckDuckGo",
|
||||
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "p. ex. '30s','10m'. Les unitats de temps vàlides són 's', 'm', 'h'.",
|
||||
"e.g. 'low', 'medium', 'high'": "p. ex. 'baix', 'mitjà', 'alt'",
|
||||
@@ -916,7 +915,7 @@
|
||||
"e.g. my_tools": "p. ex. les_meves_eines",
|
||||
"e.g. pdf, docx, txt": "p. ex. pdf, docx, txt",
|
||||
"e.g. Step-by-step instructions for code reviews": "p. ex. instruccions pas a pas per a revisions de codi",
|
||||
"e.g. Tell me a fun fact": "p. ex. digues-me quelcom divertit",
|
||||
"e.g. Tell me a fun fact": "p. ex. digues-me quelcome divertit",
|
||||
"e.g. Tell me a fun fact about the Roman Empire": "p. ex. digues-me quelcom divertit sobre l'imperi Romà",
|
||||
"e.g. Tools for performing various operations": "p. ex. Eines per dur a terme operacions",
|
||||
"e.g., 3, 4, 5 (leave blank for default)": "p. ex. 3, 4, 5 (deixa-ho en blanc per utilitzar el per defecte)",
|
||||
@@ -957,7 +956,7 @@
|
||||
"Emojis": "Emojis",
|
||||
"Empty": "Buit",
|
||||
"Empty message": "Missatge buit",
|
||||
"Enable accessibility-focused visual enhancements.": "Activa les millores visuals centrades en l'accessibilitat.",
|
||||
"Enable accessibility-focused visual enhancements.": "",
|
||||
"Enable All": "Habilitar tot",
|
||||
"Enable background sub-agents": "Activar els subagents en segon pla",
|
||||
"Enable Code Execution": "Permetre l'execució de codi",
|
||||
@@ -1050,7 +1049,7 @@
|
||||
"Enter New Password": "Introdueix un nova contrasenya",
|
||||
"Enter Number of Steps (e.g. 50)": "Introdueix el nombre de passos (p. ex. 50)",
|
||||
"Enter Ollama Cloud API Key": "Introdueix la clau API de Ollama Cloud",
|
||||
"Enter OpenSERP Base URL": "Introduïu l'URL base d'OpenSERP",
|
||||
"Enter OpenSERP Base URL": "",
|
||||
"Enter PaddleOCR-vl API Base URL": "Introdueix la URL Base de PaddleOCR-vl API",
|
||||
"Enter PaddleOCR-vl API Token": "Introdueix el Token de PaddleOCR-vl API",
|
||||
"Enter Perplexity API Key": "Introdueix la clau API de Perplexity",
|
||||
@@ -1219,7 +1218,7 @@
|
||||
"Failed to load DOCX file. Please try downloading it instead.": "No s'ha pogut carregar el fitxer DOCX. Si us plau, prova de descarregar-lo.",
|
||||
"Failed to load Excel/CSV file. Please try downloading it instead.": "No s'ha pogut carregar el fitxer Excel/CSV. Si us plau, prova de descarregar-lo.",
|
||||
"Failed to load file content.": "No s'ha pogut carregar el contingut del fitxer",
|
||||
"Failed to load folder": "No s'ha pogut carregar la carpeta",
|
||||
"Failed to load folder": "",
|
||||
"Failed to load Interface settings": "No s'ha pogut carregar la configuració de la interfície",
|
||||
"Failed to load policy: {{error}}": "No s'ha pogut carregar la política: {{error}}",
|
||||
"Failed to load PPTX file. Please try downloading it instead.": "No s'ha pogut carregar el fitxer PPTX. Si us plau, prova de descarregar-lo.",
|
||||
@@ -1238,7 +1237,7 @@
|
||||
"Failed to save policy: {{error}}": "No s'ha pogut desar la política: {{error}}",
|
||||
"Failed to save terminal servers": "No s'han pogut desar els servidors de terminal",
|
||||
"Failed to save webhook": "No s'ha pogut desar el webhook",
|
||||
"Failed to search files": "No s'han pogut cercar els fitxers",
|
||||
"Failed to search files": "",
|
||||
"Failed to unshare chat.": "No s'ha pogut deixar de compartir el xat.",
|
||||
"Failed to update settings": "No s'han pogut actualitzar les preferències",
|
||||
"Failed to update status": "No s'ha pogut actualitzar l'estat",
|
||||
@@ -1273,9 +1272,9 @@
|
||||
"File Upload": "Pujar arxiu",
|
||||
"File uploaded successfully": "Arxiu pujat satisfactòriament",
|
||||
"Filename": "Nom de l'arxiu",
|
||||
"Filename matches": "El nom del fitxer coincideix",
|
||||
"Filename matches": "",
|
||||
"Files": "Arxius",
|
||||
"Filesystem": "Sistema de fitxers",
|
||||
"Filesystem": "",
|
||||
"Fill the required fields first.": "Omple primer els camps obligatoris.",
|
||||
"Fill the source fields and test query first.": "Omple primer els camps de la font i la consulta de prova.",
|
||||
"Filter": "Filtre",
|
||||
@@ -1308,7 +1307,6 @@
|
||||
"Follow up: {{question}}": "Continuació: {{question}}",
|
||||
"Follow-Up Auto-Generation": "Generació automàtica d'indicacions de continuació",
|
||||
"Followed instructions perfectly": "S'han seguit les instruccions perfectament",
|
||||
"Font Family": "",
|
||||
"for placeholders": "per a marcadors de posició",
|
||||
"Force OCR": "Forçar OCR",
|
||||
"Forking chat...": "S'està bifurcant el xat...",
|
||||
@@ -1410,9 +1408,10 @@
|
||||
"Hide": "Amaga",
|
||||
"Hide All": "Amagar tots",
|
||||
"Hide from Sidebar": "Amagar de la barra lateral",
|
||||
"Hide Hidden Files": "Amaga els fitxers ocults",
|
||||
"Hide Hidden Files": "",
|
||||
"Hide Model": "Amagar el model",
|
||||
"High": "Alt",
|
||||
"High Contrast Mode": "Mode d'alt contrast",
|
||||
"History": "Historial",
|
||||
"Home": "Inici",
|
||||
"Host": "Servidor",
|
||||
@@ -1427,14 +1426,14 @@
|
||||
"I acknowledge that I have read and I understand the implications of my action. I am aware of the risks associated with executing arbitrary code and I have verified the trustworthiness of the source.": "Afirmo que he llegit i entenc les implicacions de la meva acció. Soc conscient dels riscos associats a l'execució de codi arbitrari i he verificat la fiabilitat de la font.",
|
||||
"ID": "ID",
|
||||
"ID cannot contain \":\" or \"|\" characters": "L'ID no pot contenir caràcters \":\" ni \"|\"",
|
||||
"ID copied to clipboard": "L'ID s'ha copiat al porta-retalls",
|
||||
"ID copied to clipboard": "L'ID s'ha copiat al portaretalls",
|
||||
"Identity audit": "Auditoria d'identitat",
|
||||
"Idle only": "Només inactiu",
|
||||
"Idle Timeout": "Temps d'espera d'inactivitat",
|
||||
"iframe Sandbox Allow Downloads": "Permetre descàrregues iframe Sandbox",
|
||||
"iframe Sandbox Allow Downloads": "",
|
||||
"iframe Sandbox Allow Forms": "Permetre formularis sandbox iframe",
|
||||
"iframe Sandbox Allow Same Origin": "Permetre same-origin sandbox iframe",
|
||||
"iframe Sandbox Allow Scripts": "Permetre scripts sandbox iframe",
|
||||
"iframe Sandbox Allow Scripts": "",
|
||||
"Image": "Imatge",
|
||||
"Image Compression": "Compressió d'imatges",
|
||||
"Image Compression Height": "Alçada de la compressió d'imatges",
|
||||
@@ -1449,7 +1448,7 @@
|
||||
"Image Prompt Generation": "Generació d'indicacions d'imatge",
|
||||
"Image Prompt Generation Prompt": "Indicació per a la generació d'indicacions d'imatge",
|
||||
"Image Size": "Mida de la imatge",
|
||||
"Image unavailable": "Imatge no disponible",
|
||||
"Image unavailable": "",
|
||||
"image/*, video/*": "imatge/*, vídeo/*",
|
||||
"Images": "Imatges",
|
||||
"Import": "Importar",
|
||||
@@ -1457,7 +1456,7 @@
|
||||
"Import chat history from a JSON export file.": "Importar l'historial de xats a partir d'un fitxer JSON",
|
||||
"Import Chats": "Importar xats",
|
||||
"Import Config": "Importar la configuració",
|
||||
"Import CSV": "Importar CSV",
|
||||
"Import CSV": "",
|
||||
"Import From Link": "Importar des d'un enllaç",
|
||||
"Import JSON": "Importar JSON",
|
||||
"Import Models": "Importar models",
|
||||
@@ -1469,7 +1468,7 @@
|
||||
"Important Update": "Actualització important",
|
||||
"Imported automations successfully": "Automatitzacions importades correctament",
|
||||
"Imported notes successfully": "Notes importades correctament",
|
||||
"Importing...": "S'està important...",
|
||||
"Importing...": "",
|
||||
"Inactive": "No actiu",
|
||||
"Include": "Incloure",
|
||||
"Include saved memories in the system context.": "Incloure les memòries desades al context del sistema.",
|
||||
@@ -1483,10 +1482,10 @@
|
||||
"Inject stored memories into conversation context": "Injectar la memòria emmagatzemats al context de la conversa",
|
||||
"Inject the entire content as context for comprehensive processing, this is recommended for complex queries.": "Injectar tot el contingut com a context per a un processament complet, això es recomana per a consultes complexes.",
|
||||
"Inject the entire content as context for comprehensive processing.": "Injectar tot el contingut com a context per a un processament complet.",
|
||||
"Inline": "En línia",
|
||||
"Inline": "",
|
||||
"Input": "Entrada",
|
||||
"Input Key (e.g. text, unet_name, steps)": "Entra la clau (p. ex. text, unet_name, steps)",
|
||||
"Input needed": "Es necessita entrada",
|
||||
"Input needed": "",
|
||||
"Input Variables": "Variables d'entrada",
|
||||
"Insert": "Inserir",
|
||||
"Insert Follow-Up Prompt to Input": "Inserir un missatge de continuació per a l'entrada",
|
||||
@@ -1503,7 +1502,7 @@
|
||||
"Interface": "Interfície",
|
||||
"Interface Settings Access": "Accés a la configuració de la interfície",
|
||||
"Invalid file format.": "Format d'arxiu no vàlid.",
|
||||
"Invalid format.": "Format no vàlid.",
|
||||
"Invalid format.": "",
|
||||
"Invalid JSON file": "Arxiu JSON no vàlid",
|
||||
"Invalid JSON format": "Format JSON no vàlid",
|
||||
"Invalid JSON format for ComfyUI Edit Workflow.": "Format JSON no vàlid per al Workflow de ComfyUI d'edició",
|
||||
@@ -1544,7 +1543,7 @@
|
||||
"Knowledge Access": "Accés al coneixement",
|
||||
"Knowledge Base": "Base de coneixement",
|
||||
"Knowledge base has been reset": "La base de coneixement s'ha reiniciat",
|
||||
"Knowledge base not found.": "No s'ha trobat la base de coneixement.",
|
||||
"Knowledge base not found.": "",
|
||||
"Knowledge created successfully.": "Coneixement creat correctament.",
|
||||
"Knowledge deleted successfully.": "Coneixement eliminat correctament.",
|
||||
"Knowledge Description": "Descripció del coneixement",
|
||||
@@ -1618,8 +1617,8 @@
|
||||
"Live": "Live",
|
||||
"llama.cpp": "llama.cpp",
|
||||
"LLMs can make mistakes. Verify important information.": "Els models de llenguatge poden cometre errors. Verifica la informació important.",
|
||||
"LM Studio": "LM Studio",
|
||||
"Load Model": "Carregar model",
|
||||
"LM Studio": "",
|
||||
"Load Model": "",
|
||||
"Loaded": "Carregat",
|
||||
"Loader": "Carregador",
|
||||
"Loading Kokoro.js...": "Carregant Kokoro.js",
|
||||
@@ -1665,7 +1664,7 @@
|
||||
"Max concurrent": "Màxim concurrents",
|
||||
"Max iterations": "Màxim d'iteracions",
|
||||
"Max output": "Sortida màxima",
|
||||
"Max Speakers": "Nombre màxim de parlants",
|
||||
"Max Speakers": "Nombre màxim d'altaveus",
|
||||
"Max tokens to retrieve (1024-32768, default 8192)": "Nombre màxim de tokens a obtenir (1024-32768, per defecte 8192)",
|
||||
"Max Upload Count": "Nombre màxim de càrregues",
|
||||
"Max Upload Size": "Mida màxima de càrrega",
|
||||
@@ -1748,18 +1747,18 @@
|
||||
"Model Capabilities": "Capacitats del model",
|
||||
"Model created successfully!": "Model creat correctament",
|
||||
"Model Defaults": "Models per defecte",
|
||||
"Model deleted successfully": "El model s'ha suprimit correctament",
|
||||
"Model download started": "S'ha iniciat la descàrrega del model",
|
||||
"Model deleted successfully": "",
|
||||
"Model download started": "",
|
||||
"Model filesystem path detected. Model shortname is required for update, cannot continue.": "S'ha detectat el camí del sistema de fitxers del model. És necessari un nom curt del model per actualitzar, no es pot continuar.",
|
||||
"Model Filtering": "Filtrat de models",
|
||||
"Model ID": "Identificador del model",
|
||||
"Model ID is already added": "L'identificador del model ja s'ha afegit",
|
||||
"Model ID is already added": "",
|
||||
"Model ID is required.": "L'ID del model és necessari",
|
||||
"Model IDs": "Identificadors del model",
|
||||
"Model is now private": "El model ara és privat",
|
||||
"Model is now public": "El model ara és públic",
|
||||
"Model list refreshed": "S'ha actualitzat la llista de models",
|
||||
"Model loaded successfully": "El model s'ha carregat correctament",
|
||||
"Model list refreshed": "",
|
||||
"Model loaded successfully": "",
|
||||
"Model Name": "Nom del model",
|
||||
"Model name already exists, please choose a different one": "Aquest nom de model ja existeix, si us plau, tria'n un altre",
|
||||
"Model Name is required.": "El nom del model és necessari",
|
||||
@@ -1772,7 +1771,7 @@
|
||||
"Model Parameters": "Paràmetres del model",
|
||||
"Model Params": "Paràmetres del model",
|
||||
"Model Permissions": "Permisos dels models",
|
||||
"Model providers": "Proveïdors de models",
|
||||
"Model providers": "",
|
||||
"Model removed from pinned models": "Model eliminat dels models fixats",
|
||||
"Model removed from selected models": "S'ha eliminat el model dels models seleccionats",
|
||||
"Model Response Mode": "Mode de resposta del model",
|
||||
@@ -1838,7 +1837,7 @@
|
||||
"New Tool": "Nova eina",
|
||||
"New Webhook": "Nou webhook",
|
||||
"new-channel": "nou-canal",
|
||||
"Next": "Següent",
|
||||
"Next": "",
|
||||
"Next message": "Missatge següent",
|
||||
"Next month": "El mes vinent",
|
||||
"Next page": "Pàgina següent",
|
||||
@@ -1850,12 +1849,12 @@
|
||||
"No additional headers are sent unless configured.": "No s'envia cap capçalera addicional tret que estigui configurada.",
|
||||
"No authentication": "Sense autenticació",
|
||||
"No automations found": "No s'ha trobat cap automatització",
|
||||
"No channels": "Sense canals",
|
||||
"No channels": "",
|
||||
"No chat alerts": "No hi ha alertes de xat",
|
||||
"No chat history": "No hi ha historial de xat",
|
||||
"No chat to compact": "No hi ha xat per compactar",
|
||||
"No chat to fork": "No hi ha cap xat a bifurcar",
|
||||
"No chats": "No hi ha xats",
|
||||
"No chats": "",
|
||||
"No chats found": "No s'han trobat xats",
|
||||
"No chats found for this user.": "No s'han trobat xats per a aquest usuari.",
|
||||
"No chats found.": "No s'ha trobat xats.",
|
||||
@@ -1947,7 +1946,7 @@
|
||||
"OAuth 2.1 (Static)": "OAuth 2.1 (Estàtic)",
|
||||
"OAuth client identifier from the provider.": "Identificador de client OAuth del proveïdor.",
|
||||
"OAuth client secret from the provider.": "Secret del client OAuth del proveïdor.",
|
||||
"OAuth discovery successful": "La descoberta d'OAuth ha estat correcta",
|
||||
"OAuth discovery successful": "",
|
||||
"OAuth ID": "ID OAuth",
|
||||
"OAuth Resource Parameter": "Paràmetre de recurs OAuth",
|
||||
"OAuth Scopes": "Àmbits d'OAuth",
|
||||
@@ -1972,7 +1971,7 @@
|
||||
"OneDrive": "OneDrive",
|
||||
"Only active during Voice Mode.": "Només actiu en el mode de Veu.",
|
||||
"Only active when \"Paste Large Text as File\" setting is toggled on.": "Només està actiu quan l'opció \"Enganxa text gran com a fitxer\" està activada.",
|
||||
"Only active when a tool call is waiting for approval.": "Només està actiu quan una crida d'eina està esperant aprovació.",
|
||||
"Only active when a tool call is waiting for approval.": "",
|
||||
"Only active when the chat input is in focus and an LLM is generating a response.": "Només s'activa quan l'entrada del xat està en focus i un LLM està generant una resposta.",
|
||||
"Only active when the chat input is in focus.": "Només actiu quan l'entrada del xat està en focus.",
|
||||
"Only alphanumeric characters and hyphens are allowed": "Només es permeten caràcters alfanumèrics i guions",
|
||||
@@ -1994,7 +1993,7 @@
|
||||
"Open create menu": "Obrir el menú de creació",
|
||||
"Open detail blocks by default.": "Obrir blocs de detalls per defecte.",
|
||||
"Open file": "Obrir arxiu",
|
||||
"Open Folder": "Obre la carpeta",
|
||||
"Open Folder": "",
|
||||
"Open in full screen": "Obrir en pantalla complerta",
|
||||
"Open in new tab": "Obrir en una nova pestanya",
|
||||
"Open link": "Obrir l'enllaç",
|
||||
@@ -2025,12 +2024,12 @@
|
||||
"OpenAPI Spec": "Especificació OpenAPI",
|
||||
"openapi.json URL or Path": "Camí o URL a openapi.json",
|
||||
"OpenID discovery URL for this provider.": "URL de descobriment d'OpenID per a aquest proveïdor.",
|
||||
"OpenSERP URL": "URL d'OpenSERP",
|
||||
"OpenSERP URL": "",
|
||||
"optional": "opcional",
|
||||
"Optional": "Opcional",
|
||||
"Optional API key for Ollama requests.": "Clau API opcional per a peticions a Ollama.",
|
||||
"or": "o",
|
||||
"Orchestrator": "Orquestrador",
|
||||
"Orchestrator": "",
|
||||
"Ordered List": "Llista ordenada",
|
||||
"Other": "Altres",
|
||||
"out of": "de",
|
||||
@@ -2054,13 +2053,13 @@
|
||||
"Passthrough params": "Paràmetres de pas",
|
||||
"Password": "Contrasenya",
|
||||
"Password for the bind DN.": "Contrasenya per al DN d'enllaç.",
|
||||
"Password updated. Please sign in again.": "Contrasenya actualitzada. Si us plau, torna a iniciar la sessió.",
|
||||
"Password updated. Please sign in again.": "",
|
||||
"Passwords do not match.": "Les contrasenyes no coincideixen",
|
||||
"Paste inserted prompts as rich text when possible.": "Enganxar les sol·licituds inserides com a text enriquit quan sigui possible.",
|
||||
"Paste Large Text as File": "Enganxa un text llarg com a fitxer",
|
||||
"Path": "Camí",
|
||||
"Path copied": "Camí copiat",
|
||||
"Pause a response to ask the user a clarifying question": "Posa en pausa una resposta per fer una pregunta d'aclariment a l'usuari",
|
||||
"Pause a response to ask the user a clarifying question": "",
|
||||
"Paused": "Pausat",
|
||||
"PDF document (.pdf)": "Document PDF (.pdf)",
|
||||
"PDF Extract Images (OCR)": "Extreu imatges del PDF (OCR)",
|
||||
@@ -2072,8 +2071,8 @@
|
||||
"Pending Accounts": "Comptes pendents",
|
||||
"Pending User Overlay Content": "Contingut de la finestra d'usuari pendent",
|
||||
"Pending User Overlay Title": "Títol de la finestra d'usuari pendent",
|
||||
"Per automation": "Per automatització",
|
||||
"Per chat": "Per xat",
|
||||
"Per automation": "",
|
||||
"Per chat": "",
|
||||
"Percentage of recent messages to keep after older messages are summarized.": "Percentatge de missatges recents que cal conservar després de resumir els missatges més antics.",
|
||||
"Permanently delete every chat after confirmation.": "Suprimeix permanentment cada xat després de la confirmació.",
|
||||
"Permission denied when accessing media devices": "Permís denegat en accedir a dispositius multimèdia",
|
||||
@@ -2121,7 +2120,7 @@
|
||||
"Please enter Client ID and Client Secret": "Si us plau, introdueix l'ID de client i el secret del client.",
|
||||
"Please fill in all fields.": "Emplena tots els camps, si us plau.",
|
||||
"Please register the OAuth client": "Si us plau, registra el client OAuth",
|
||||
"Please save the connection before authorizing OAuth": "Si us plau, desa la connexió abans d'autoritzar OAuth",
|
||||
"Please save the connection before authorizing OAuth": "",
|
||||
"Please save the connection to persist the OAuth client information and do not change the ID": "Si us plau, desa la connexió per conservar la informació del client OAuth i no canviïs l'ID.",
|
||||
"Please select a model.": "Si us plau, selecciona un model.",
|
||||
"Please select a reason": "Si us plau, selecciona una raó",
|
||||
@@ -2139,14 +2138,14 @@
|
||||
"Preferred transcript language codes, separated by commas.": "Codis de llengua de transcripció preferits, separats per comes.",
|
||||
"Prefix ID": "Identificador del prefix",
|
||||
"Prefix ID is used to avoid conflicts with other connections by adding a prefix to the model IDs - leave empty to disable": "L'identificador de prefix s'utilitza per evitar conflictes amb altres connexions afegint un prefix als ID de model; deixa'l en blanc per desactivar-lo.",
|
||||
"Preparing {{NAME}}...": "S'està preparant {{NAME}}...",
|
||||
"Preparing {{NAME}}...": "",
|
||||
"Preparing download...": "Preparant la descàrrega...",
|
||||
"Press keys...": "Prem una tecla...",
|
||||
"Prevent File Creation": "Prevenir la creació d'arxius",
|
||||
"Preview": "Previsualització",
|
||||
"Preview Access": "Previsualitza l'accés",
|
||||
"Preview failed": "La previsualització ha fallat",
|
||||
"Previous": "Anterior",
|
||||
"Preview failed": "",
|
||||
"Previous": "",
|
||||
"Previous 30 days": "30 dies anteriors",
|
||||
"Previous 7 days": "7 dies anteriors",
|
||||
"Previous message": "Missatge anterior",
|
||||
@@ -2206,13 +2205,13 @@
|
||||
"Reasoning Effort": "Esforç de raonament",
|
||||
"Reasoning Tags": "Etiqueta de raonament",
|
||||
"Reasoning text...": "Text de raonament...",
|
||||
"Rebuild knowledge file, knowledge search, and memory vectors using the current embedding model.": "Reconstruir el fitxer de coneixement, la cerca de coneixement i els vectors de memòria utilitzant el model d'inserció actual.",
|
||||
"Rebuild vectors for existing knowledge files, knowledge search, and memories.": "Reconstruir vectors per a fitxers de coneixement existents, cerca de coneixement i memòries.",
|
||||
"Rebuild knowledge file, knowledge search, and memory vectors using the current embedding model.": "",
|
||||
"Rebuild vectors for existing knowledge files, knowledge search, and memories.": "",
|
||||
"Receives matching events across the instance, including system/config events and events associated with any user.": "Rep esdeveniments coincidents a tota la instància, inclosos els esdeveniments del sistema/configuració i els esdeveniments associats a qualsevol usuari.",
|
||||
"Receives matching events that are not associated with a user.": "Rep esdeveniments coincidents que no estan associats a un usuari.",
|
||||
"Receives matching user-associated events only when the actor, user subject, or user data matches these users or current group members. System/config events are not sent.": "Rep esdeveniments coincidents associats a l'usuari només quan l'actor, el subjecte de l'usuari o les dades de l'usuari coincideixen amb aquests usuaris o membres del grup actual. No s'envien esdeveniments de sistema/configuració.",
|
||||
"Recently Used": "Recentment utilitzat",
|
||||
"Recommended": "Recomanat",
|
||||
"Recommended": "",
|
||||
"Reconnected": "Reconnectat",
|
||||
"Record": "Enregistrar",
|
||||
"Record voice": "Enregistrar la veu",
|
||||
@@ -2239,8 +2238,8 @@
|
||||
"Registration failed": "El registre ha fallat",
|
||||
"Registration successful": "Registre correcte",
|
||||
"Reindex": "Reindexar",
|
||||
"Reindex Embedding Data": "Reindexar les dades d'incrustació",
|
||||
"Reindex Knowledge and Memory Vectors": "Reindexar els vectors de coneixement i memòria",
|
||||
"Reindex Embedding Data": "",
|
||||
"Reindex Knowledge and Memory Vectors": "",
|
||||
"Release Notes": "Notes de la versió",
|
||||
"Releases": "Versions",
|
||||
"Relevance": "Rellevància",
|
||||
@@ -2290,7 +2289,7 @@
|
||||
"Reset Upload Directory": "Restableix el directori de pujades",
|
||||
"Reset Vector Storage/Knowledge": "Restableix el Repositori de vectors/Coneixement",
|
||||
"Reset view": "Netejar la vista",
|
||||
"Reset zoom": "Restableix el zoom",
|
||||
"Reset zoom": "",
|
||||
"Response": "Resposta",
|
||||
"Response Auto-Scroll": "Desplaçament automàtic de resposta",
|
||||
"Response notifications cannot be activated as the website permissions have been denied. Please visit your browser settings to grant the necessary access.": "Les notificacions de resposta no es poden activar perquè els permisos del lloc web han estat rebutjats. Comprova les preferències del navegador per donar l'accés necessari.",
|
||||
@@ -2358,12 +2357,12 @@
|
||||
"Search and view user chat history": "Cerca i mostra l'historial de xats",
|
||||
"Search Automations": "Cercar automatitzacions",
|
||||
"Search Base": "Base de cerca",
|
||||
"Search channels": "Cerca canals",
|
||||
"Search channels": "",
|
||||
"Search channels and channel messages": "Cerca els canals i els missatges als canals",
|
||||
"Search Chats": "Cercar xats",
|
||||
"Search Collection": "Cercar col·leccions",
|
||||
"Search Files": "Cerca arxius",
|
||||
"Search files and contents": "Cerca fitxers i continguts",
|
||||
"Search files and contents": "",
|
||||
"Search filters": "Filtres de cerca",
|
||||
"Search Filters": "Filtres de cerca",
|
||||
"Search folders": "Cercar carpetes",
|
||||
@@ -2430,7 +2429,7 @@
|
||||
"Select an embedding model engine": "Seleccionar un motor d'incrustació",
|
||||
"Select an engine": "Seleccionar un motor",
|
||||
"Select an installed pipeline to configure or remove.": "Seleccionar una pipeline instal·lada per configurar o eliminar",
|
||||
"Select an instance": "Selecciona una instància",
|
||||
"Select an instance": "",
|
||||
"Select an Ollama instance": "Seleccionar una instància d'Ollama",
|
||||
"Select an option": "Seleccionar una opció",
|
||||
"Select an output format": "Seleccionar un format de sortida",
|
||||
@@ -2449,7 +2448,7 @@
|
||||
"Select the Gemini endpoint method to call.": "Seleccionar el punt de connexió de Gemini a cridar.",
|
||||
"Select the local model precision used by Kokoro.js.": "Seleccionar la precisió del model local utilitzat per Kohoro.js.",
|
||||
"Select the Pipelines server to manage.": "Seleccionar el servidor de Pipelines a gestionar.",
|
||||
"Select the Tika server API version.": "Selecciona la versió de l'API del servidor Tika.",
|
||||
"Select the Tika server API version.": "",
|
||||
"Select Tool": "Selecionar eina",
|
||||
"Select view": "Seleccionar una vista",
|
||||
"Selected": "Seleccionat",
|
||||
@@ -2495,7 +2494,7 @@
|
||||
"Set embedding model": "Establir el model d'incrustació",
|
||||
"Set embedding model (e.g. {{model}})": "Establir el model d'incrustació (p. ex. {{model}})",
|
||||
"Set reranking model (e.g. {{model}})": "Establir el model de reavaluació (p. ex. {{model}})",
|
||||
"Set system-wide interface defaults for every account. Personal settings override these defaults.": "Establir els valors predeterminats de la interfície a tot el sistema per a cada compte. La configuració personal anul·la aquests valors predeterminats.",
|
||||
"Set system-wide interface defaults for every account. Personal settings override these defaults.": "",
|
||||
"Set the birth date saved with your profile.": "Establir la data de naixement desada amb el teu perfil.",
|
||||
"Set the default system prompt for new chats.": "Establir la sol·licitud predeterminada del sistema per a nous xats.",
|
||||
"Set the display name shown across your account.": "Establir el nom de visualització que es mostra al teu compte.",
|
||||
@@ -2513,13 +2512,13 @@
|
||||
"Sets the stop sequences to use. When this pattern is encountered, the LLM will stop generating text and return. Multiple stop patterns may be set by specifying multiple separate stop parameters in a modelfile.": "Establir les seqüències d'aturada a utilitzar. Quan es trobi aquest patró, el LLM deixarà de generar text. Es poden establir diversos patrons de parada especificant diversos paràmetres de parada separats en un fitxer model.",
|
||||
"Setting": "Preferència",
|
||||
"Settings": "Preferències",
|
||||
"settings configured": "configuració establerta",
|
||||
"settings configured": "",
|
||||
"Settings Permissions": "Preferències de permisos",
|
||||
"Settings saved successfully!": "Les preferències s'han desat correctament",
|
||||
"Shapes the short label generated for each chat.": "Donar forma a l'etiqueta curta generada per a cada xat.",
|
||||
"Share": "Compartir",
|
||||
"Share Chat": "Compartir el xat",
|
||||
"Share link copied to clipboard.": "Compartir l'enllaç copiat al porta-retalls",
|
||||
"Share link copied to clipboard.": "Compartir l'enllaç copiat al portaretalls",
|
||||
"Share to Open WebUI Community": "Compartir amb la comunitat OpenWebUI",
|
||||
"Share your background and interests": "Compartir la teva informació i interessos",
|
||||
"Share your current location with features that can use it.": "Compartir la teva ubicació actual amb funcions que la puguin utilitzar.",
|
||||
@@ -2530,7 +2529,7 @@
|
||||
"Sharing Permissions": "Compartir els permisos",
|
||||
"Show": "Mostrar",
|
||||
"Show \"What's New\" Modal on Login": "Veure 'Què hi ha de nou' a l'entrada",
|
||||
"Show a floating preview of recent messages when hovering a chat in the sidebar.": "Mostra una vista prèvia flotant dels missatges recents quan passes el cursor per un xat a la barra lateral.",
|
||||
"Show a floating preview of recent messages when hovering a chat in the sidebar.": "",
|
||||
"Show admin contact details while an account waits for approval.": "Mostrar les dades de contacte de l'administrador mentre un compte espera l'aprovació.",
|
||||
"Show All": "Mostrar tot",
|
||||
"Show all ({{COUNT}} characters)": "Mostra tot ({{COUNT}} caràcters)",
|
||||
@@ -2539,8 +2538,8 @@
|
||||
"Show Files on Terminal Select": "Mostra fitxers en seleccionar terminal",
|
||||
"Show formatting controls in the rich text composer.": "Mostrar els controls de format a l'editor de text enriquit.",
|
||||
"Show Formatting Toolbar": "Mostrar la barra de format",
|
||||
"Show Full access and Ask for approval in the chat input menu.": "Mostra «Accés complet» i «Demana aprovació» al menú d'entrada del xat.",
|
||||
"Show Hidden Files": "Mostra els fitxers ocults",
|
||||
"Show Full access and Ask for approval in the chat input menu.": "",
|
||||
"Show Hidden Files": "",
|
||||
"Show image preview": "Mostrar la previsualització de la imatge",
|
||||
"Show Model": "Mostrar el model",
|
||||
"Show more": "Mostrar-ne més",
|
||||
@@ -2555,7 +2554,7 @@
|
||||
"Show your support!": "Mostra el teu suport!",
|
||||
"Show your username label instead of You in chat bubbles.": "Mostrar l'etiqueta del teu nom d'usuari en lloc de Tu a les bombolles de xat.",
|
||||
"Showcased creativity": "Creativitat mostrada",
|
||||
"Sidebar": "Barra lateral",
|
||||
"Sidebar": "",
|
||||
"Sign in": "Iniciar sessió",
|
||||
"Sign in to {{WEBUI_NAME}}": "Iniciar sessió a {{WEBUI_NAME}}",
|
||||
"Sign in to {{WEBUI_NAME}} with LDAP": "Iniciar sessió a {{WEBUI_NAME}} amb LDAP",
|
||||
@@ -2566,7 +2565,7 @@
|
||||
"simultaneous sub-agents": "subagents simultanis",
|
||||
"Single": "Únic",
|
||||
"Sink List": "Enfonsar la llista",
|
||||
"Size": "Mida",
|
||||
"Size": "",
|
||||
"sk-1234": "sk-1234",
|
||||
"Skill created successfully": "Habilitat creada correctament",
|
||||
"Skill deleted successfully": "Habilitat suprimida correctament",
|
||||
@@ -2606,7 +2605,7 @@
|
||||
"Start new chats as temporary unless changed.": "Iniciar xats nous com a temporals tret que es modifiqui.",
|
||||
"Start of the channel": "Inici del canal",
|
||||
"Start Tag": "Etiqueta d'inici",
|
||||
"Start the chat to use this terminal.": "Inicia el xat per utilitzar aquest terminal.",
|
||||
"Start the chat to use this terminal.": "",
|
||||
"Starting in {{count}} minutes_one": "Començant en {{count}} minut",
|
||||
"Starting in {{count}} minutes_many": "Començant en {{count}} minuts",
|
||||
"Starting in {{count}} minutes_other": "Començant en {{count}} minuts",
|
||||
@@ -2636,7 +2635,7 @@
|
||||
"Su_day_of_week": "Diumenge",
|
||||
"Sub Claim": "Reclamació sub",
|
||||
"Sub-agents": "Subagents",
|
||||
"Submit answers": "Envia les respostes",
|
||||
"Submit answers": "",
|
||||
"Submit question": "Enviar la pregunta",
|
||||
"Submit suggestion": "Enviar un suggeriment",
|
||||
"Subtitle": "Subtítol",
|
||||
@@ -2684,7 +2683,7 @@
|
||||
"Task List": "Llista de tasques",
|
||||
"Task Management": "Gestió de tasques",
|
||||
"Task Model": "Model de tasques",
|
||||
"Task Model Parameters": "Paràmetres del model de tasca",
|
||||
"Task Model Parameters": "",
|
||||
"Tasks": "Tasques",
|
||||
"tasks completed": "tasques completades",
|
||||
"Tavily API Key": "Clau API de Tavily",
|
||||
@@ -2694,9 +2693,9 @@
|
||||
"Temporary Chat": "Xat temporal",
|
||||
"Temporary Chat by Default": "Xat temporal per defecte",
|
||||
"Terminal": "Terminal",
|
||||
"Terminal Contexts": "Contextos del terminal",
|
||||
"Terminal File Display": "Visualització de fitxers de terminal",
|
||||
"Terminal Preview Allow Same Origin": "Vista prèvia del terminal permet el mateix origen",
|
||||
"Terminal Contexts": "",
|
||||
"Terminal File Display": "",
|
||||
"Terminal Preview Allow Same Origin": "",
|
||||
"Terminal servers saved": "Servidors de terminal desats",
|
||||
"Test": "Prova",
|
||||
"Test must pass before a source is created.": "El test s'ha de passar abans de crear una font.",
|
||||
@@ -2728,7 +2727,7 @@
|
||||
"The temperature of the model. Increasing the temperature will make the model answer more creatively.": "La temperatura del model. Augmentar la temperatura farà que el model respongui de manera més creativa.",
|
||||
"Theme": "Tema",
|
||||
"There was an error syncing your stats. Please try again.": "S'ha produït un error en sincronitzar les estadístiques. Torna-ho a provar.",
|
||||
"These settings are read from environment variables and cannot be edited here while {{ENV_VAR}} is disabled.": "Aquests paràmetres es llegeixen des de variables d'entorn i no es poden editar aquí mentre {{ENV_VAR}} estigui desactivat.",
|
||||
"These settings are read from environment variables and cannot be edited here while {{ENV_VAR}} is disabled.": "",
|
||||
"thinking, output_config": "pensant, output_config",
|
||||
"Thinking...": "Pensant...",
|
||||
"This action cannot be undone. Do you wish to continue?": "Aquesta acció no es pot desfer. Vols continuar?",
|
||||
@@ -2749,10 +2748,10 @@
|
||||
"This will delete": "Això eliminarà",
|
||||
"This will delete <strong>{{NAME}}</strong> and <strong>all its contents</strong>.": "Això eliminarà <strong>{{NAME}}</strong> i <strong>tots els continguts</strong>.",
|
||||
"This will delete all models including custom models and cannot be undone.": "Això eliminarà tots els models incloent els personalitzats i no es pot desfer",
|
||||
"This will delete the cached model and cannot be undone.": "Això suprimirà el model a la memòria cau i no es pot desfer.",
|
||||
"This will delete the cached model and cannot be undone.": "",
|
||||
"This will permanently delete the calendar \"{{name}}\" and all its events. This action cannot be undone.": "Això eliminarà permanentment el calendari \"{{name}}\" i tots els seus esdeveniments. Aquesta acció no es pot desfer.",
|
||||
"This will remove all files and directories from this knowledge base. This action cannot be undone.": "Això eliminarà tots els fitxers i carpetes d'aquesta base de coneixement. Aquesta acció no es pot desfer.",
|
||||
"This will revoke the current API key.": "Això revocarà la clau de l'API actual.",
|
||||
"This will revoke the current API key.": "",
|
||||
"Thorough explanation": "Explicació en detall",
|
||||
"Thought": "Pensament",
|
||||
"Thought for {{DURATION}}": "He pensat durant {{DURATION}}",
|
||||
@@ -2762,12 +2761,12 @@
|
||||
"Through": "A través de",
|
||||
"Thumbs up/down ratings from users on model responses": "Valoracions de 'm'agrada'/'no' dels usuaris sobre les respostes del model",
|
||||
"Tika": "Tika",
|
||||
"Tika 3.x": "Tika 3.x",
|
||||
"Tika 4.x": "Tika 4.x",
|
||||
"Tika 3.x": "",
|
||||
"Tika 4.x": "",
|
||||
"Tika server endpoint used for content extraction.": "Punt final del servidor Tika utilitzat per a l'extracció de contingut.",
|
||||
"Tika Server URL": "URL del servidor Tika",
|
||||
"Tika Server URL required.": "La URL del servidor Tika és obligatòria.",
|
||||
"Tika Server Version": "Versió del servidor Tika",
|
||||
"Tika Server Version": "",
|
||||
"Tiktoken": "Tiktoken",
|
||||
"Time": "Temps",
|
||||
"Time & Calculation": "Temps i càlculs",
|
||||
@@ -2787,7 +2786,7 @@
|
||||
"To attach knowledge base here, add them to the \"Knowledge\" workspace first.": "Per adjuntar la base de coneixement aquí, afegeix-la primer a l'espai de treball \"Coneixement\".",
|
||||
"To learn more about available endpoints, visit our documentation.": "Per obtenir més informació sobre els punts d'accés disponibles, visita la nostra documentació.",
|
||||
"To learn more about SpeechT5,": "Per obtenir més informació sobre SpeechT5,",
|
||||
"To see the available CMU Arctic speaker embeddings,": "Per veure les incrustacions dels parlants CMU Arctic disponibles,",
|
||||
"To see the available CMU Arctic speaker embeddings,": "Per veure les incrustacions d'altaveus CMU Arctic disponibles,",
|
||||
"To select actions here, add them to the \"Functions\" workspace first.": "Per seleccionar accions aquí, afegeix-les primer a l'espai de treball \"Funcions\".",
|
||||
"To select default filters here, enable toggleable filters for this model first.": "Per seleccionar els filtres predeterminats aquí, primer activa els filtres que es poden canviar per a aquest model.",
|
||||
"To select filters here, add them to the \"Functions\" workspace first.": "Per seleccionar filtres aquí, afegeix-los primer a l'espai de treball \"Funcions\".",
|
||||
@@ -2815,7 +2814,7 @@
|
||||
"tokens": "tokens",
|
||||
"Tokens": "Tokens",
|
||||
"Too verbose": "Massa explícit",
|
||||
"tool": "eina",
|
||||
"tool": "",
|
||||
"Tool created successfully": "Eina creada correctament",
|
||||
"Tool deleted successfully": "Eina eliminada correctament",
|
||||
"Tool Description": "Descripció de l'eina",
|
||||
@@ -2824,7 +2823,7 @@
|
||||
"tool loops per sub-agent": "bucles d'eina per subagent",
|
||||
"Tool Menu": "Menú d'eines",
|
||||
"Tool Name": "Nom de l'eina",
|
||||
"Tool Permissions": "Permisos d'eina",
|
||||
"Tool Permissions": "",
|
||||
"Tool Servers": "Servidors d'eines",
|
||||
"Tool updated successfully": "Eina actualitzada correctament",
|
||||
"Tools": "Eines",
|
||||
@@ -2849,10 +2848,10 @@
|
||||
"TTS Voice": "Veu TTS",
|
||||
"Tu_day_of_week": "Dimarts",
|
||||
"Type": "Tipus",
|
||||
"Type a model ref": "Escriu un model de referència",
|
||||
"Type a model ref": "",
|
||||
"Type here...": "Escriu aquí...",
|
||||
"Type Hugging Face Resolve (Download) URL": "Escriu la URL de Resolució (Descàrrega) de Hugging Face",
|
||||
"Type your answer": "Escriu la teva resposta",
|
||||
"Type your answer": "",
|
||||
"Uh-oh! There was an issue with the response.": "Vaja! Hi ha hagut una incidència amb la resposta.",
|
||||
"UI": "UI",
|
||||
"UI Scale": "Mida de la interfície gràfica",
|
||||
@@ -2863,7 +2862,7 @@
|
||||
"unknown": "desconegut",
|
||||
"Unknown": "Desconegut",
|
||||
"Unknown User": "Usuari desconegut",
|
||||
"Unload Model": "Descarregar model",
|
||||
"Unload Model": "",
|
||||
"Unloads {{FROM_NOW}}": "Es descarrega {{FROM_NOW}}",
|
||||
"Unmute": "No silenciar",
|
||||
"Unpin": "Alliberar",
|
||||
@@ -2897,7 +2896,7 @@
|
||||
"Upload a workflow.json file exported as API format from ComfyUI.": "Carregar un fitxer workflow.json exportat com a format API des de ComfyUI.",
|
||||
"Upload Audio": "Pujar àudio",
|
||||
"Upload directory": "Pujar directori",
|
||||
"Upload failed": "La càrrega ha fallat",
|
||||
"Upload failed": "",
|
||||
"Upload files": "Pujar fitxers",
|
||||
"Upload Files": "Pujar fitxers",
|
||||
"Upload Model": "Pujar model",
|
||||
@@ -2917,7 +2916,6 @@
|
||||
"Use": "Ús",
|
||||
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "Fes servir l'endpoint /v1/chat/completions en comptes de /v1/audio/transcriptions per a una precisió potencialment millor.",
|
||||
"Use a Gemini API key for image generation.": "Utilitzar una clau API Gemini per a la generació d'imatges.",
|
||||
"Use a local font family for the app interface.": "",
|
||||
"Use a valid event name or pattern like user.*": "Utilitza un nom o un patró d'esdeveniment vàlid com ara usuari.*",
|
||||
"Use a wider chat layout on large displays.": "Utilitzar un disseny de xat més ampli en pantalles grans.",
|
||||
"Use an API key when your ComfyUI server requires one.": "Utilitzar una clau API quan el teu servidor ComfyUI ho requereixi.",
|
||||
@@ -2948,7 +2946,7 @@
|
||||
"User location successfully retrieved.": "Ubicació de l'usuari obtinguda correctament",
|
||||
"User menu": "Menú d'usuari",
|
||||
"User messages": "Missatges d'usuari",
|
||||
"User not found.": "No s'ha trobat l'usuari.",
|
||||
"User not found.": "",
|
||||
"User Preview": "Previsualitzar l'usuari",
|
||||
"User ratings (thumbs up/down)": "Valoracions dels usuaris (polze amunt/avall)",
|
||||
"User Status": "Estats d'usuari",
|
||||
@@ -2983,7 +2981,7 @@
|
||||
"Version": "Versió",
|
||||
"Version {{selectedVersion}} of {{totalVersions}}": "Versió {{selectedVersion}} de {{totalVersions}}",
|
||||
"Version deleted": "Versió eliminada",
|
||||
"View channel": "Veure canal",
|
||||
"View channel": "",
|
||||
"View chat": "Veure xat",
|
||||
"View Replies": "Veure les respostes",
|
||||
"View Result from {{NAME}}": "Mostra el resultat de {{NAME}}",
|
||||
@@ -3003,7 +3001,7 @@
|
||||
"Voice Mode Prompt": "Indicació per al mode de veu",
|
||||
"Wait for the current response to finish before compacting.": "Esperar que finalitzi la resposta actual abans de compactar.",
|
||||
"Wait for the current response to finish before forking.": "Esperar que finalitzi la resposta actual abans de bifurcar.",
|
||||
"Waiting for upload": "Esperant la pujada",
|
||||
"Waiting for upload": "",
|
||||
"Waiting for upload...": "Esperant per pujar...",
|
||||
"Warning": "Avís",
|
||||
"Warning:": "Avís:",
|
||||
@@ -3104,6 +3102,6 @@
|
||||
"YouTube": "YouTube",
|
||||
"Youtube Language": "Idioma de YouTube",
|
||||
"Youtube Proxy URL": "URL de Proxy de Youtube",
|
||||
"Zoom in": "Apropa",
|
||||
"Zoom out": "Allunya"
|
||||
"Zoom in": "",
|
||||
"Zoom out": ""
|
||||
}
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"Access List": "",
|
||||
"Access prohibited": "",
|
||||
"Access updated": "",
|
||||
"Accessibility Mode": "",
|
||||
"Accessible to all users": "",
|
||||
"Account": "Account",
|
||||
"Account Activation Pending": "",
|
||||
@@ -1298,7 +1297,6 @@
|
||||
"Follow up: {{question}}": "",
|
||||
"Follow-Up Auto-Generation": "",
|
||||
"Followed instructions perfectly": "",
|
||||
"Font Family": "",
|
||||
"for placeholders": "",
|
||||
"Force OCR": "",
|
||||
"Forking chat...": "",
|
||||
@@ -1403,6 +1401,7 @@
|
||||
"Hide Hidden Files": "",
|
||||
"Hide Model": "",
|
||||
"High": "",
|
||||
"High Contrast Mode": "",
|
||||
"History": "",
|
||||
"Home": "",
|
||||
"Host": "",
|
||||
@@ -2903,7 +2902,6 @@
|
||||
"Use": "",
|
||||
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "",
|
||||
"Use a Gemini API key for image generation.": "",
|
||||
"Use a local font family for the app interface.": "",
|
||||
"Use a valid event name or pattern like user.*": "",
|
||||
"Use a wider chat layout on large displays.": "",
|
||||
"Use an API key when your ComfyUI server requires one.": "",
|
||||
|
||||
@@ -82,7 +82,6 @@
|
||||
"Access List": "",
|
||||
"Access prohibited": "",
|
||||
"Access updated": "",
|
||||
"Accessibility Mode": "",
|
||||
"Accessible to all users": "Přístupné pro všechny uživatele",
|
||||
"Account": "Účet",
|
||||
"Account Activation Pending": "Čeká se na aktivaci účtu",
|
||||
@@ -1318,7 +1317,6 @@
|
||||
"Follow up: {{question}}": "",
|
||||
"Follow-Up Auto-Generation": "Automatické generování následných otázek",
|
||||
"Followed instructions perfectly": "Dokonale dodržel pokyny",
|
||||
"Font Family": "",
|
||||
"for placeholders": "",
|
||||
"Force OCR": "Vynutit OCR",
|
||||
"Forking chat...": "",
|
||||
@@ -1423,6 +1421,7 @@
|
||||
"Hide Hidden Files": "",
|
||||
"Hide Model": "Skrýt model",
|
||||
"High": "Vysoká",
|
||||
"High Contrast Mode": "Režim vysokého kontrastu",
|
||||
"History": "",
|
||||
"Home": "Domů",
|
||||
"Host": "Hostitel",
|
||||
@@ -2931,7 +2930,6 @@
|
||||
"Use": "",
|
||||
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "",
|
||||
"Use a Gemini API key for image generation.": "",
|
||||
"Use a local font family for the app interface.": "",
|
||||
"Use a valid event name or pattern like user.*": "",
|
||||
"Use a wider chat layout on large displays.": "",
|
||||
"Use an API key when your ComfyUI server requires one.": "",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"Access List": "",
|
||||
"Access prohibited": "",
|
||||
"Access updated": "",
|
||||
"Accessibility Mode": "",
|
||||
"Accessible to all users": "Tilgængelig for alle brugere",
|
||||
"Account": "Profil",
|
||||
"Account Activation Pending": "Aktivering af profil afventer",
|
||||
@@ -1298,7 +1297,6 @@
|
||||
"Follow up: {{question}}": "",
|
||||
"Follow-Up Auto-Generation": "Automatisk opfølgnings generering",
|
||||
"Followed instructions perfectly": "Fulgte instruktionerne perfekt",
|
||||
"Font Family": "",
|
||||
"for placeholders": "",
|
||||
"Force OCR": "Gennemtving OCR",
|
||||
"Forking chat...": "",
|
||||
@@ -1403,6 +1401,7 @@
|
||||
"Hide Hidden Files": "",
|
||||
"Hide Model": "Skjul model",
|
||||
"High": "Høj",
|
||||
"High Contrast Mode": "Høj kontrast tilstand",
|
||||
"History": "",
|
||||
"Home": "Hjem",
|
||||
"Host": "Vært",
|
||||
@@ -2903,7 +2902,6 @@
|
||||
"Use": "",
|
||||
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "Brug /v1/chat/completions endpointet i stedet for /v1/audio/transcriptions for potentielt øget nøjagtighed.",
|
||||
"Use a Gemini API key for image generation.": "",
|
||||
"Use a local font family for the app interface.": "",
|
||||
"Use a valid event name or pattern like user.*": "",
|
||||
"Use a wider chat layout on large displays.": "",
|
||||
"Use an API key when your ComfyUI server requires one.": "",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -68,7 +68,6 @@
|
||||
"Access List": "",
|
||||
"Access prohibited": "",
|
||||
"Access updated": "",
|
||||
"Accessibility Mode": "",
|
||||
"Accessible to all users": "",
|
||||
"Account": "Account",
|
||||
"Account Activation Pending": "",
|
||||
@@ -1298,7 +1297,6 @@
|
||||
"Follow up: {{question}}": "",
|
||||
"Follow-Up Auto-Generation": "",
|
||||
"Followed instructions perfectly": "",
|
||||
"Font Family": "",
|
||||
"for placeholders": "",
|
||||
"Force OCR": "",
|
||||
"Forking chat...": "",
|
||||
@@ -1403,6 +1401,7 @@
|
||||
"Hide Hidden Files": "",
|
||||
"Hide Model": "",
|
||||
"High": "",
|
||||
"High Contrast Mode": "",
|
||||
"History": "",
|
||||
"Home": "",
|
||||
"Host": "",
|
||||
@@ -2903,7 +2902,6 @@
|
||||
"Use": "",
|
||||
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "",
|
||||
"Use a Gemini API key for image generation.": "",
|
||||
"Use a local font family for the app interface.": "",
|
||||
"Use a valid event name or pattern like user.*": "",
|
||||
"Use a wider chat layout on large displays.": "",
|
||||
"Use an API key when your ComfyUI server requires one.": "",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"Access List": "",
|
||||
"Access prohibited": "",
|
||||
"Access updated": "",
|
||||
"Accessibility Mode": "",
|
||||
"Accessible to all users": "Προσβάσιμο σε όλους τους χρήστες",
|
||||
"Account": "Λογαριασμός",
|
||||
"Account Activation Pending": "Ενεργοποίηση Λογαριασμού Εκκρεμεί",
|
||||
@@ -1298,7 +1297,6 @@
|
||||
"Follow up: {{question}}": "",
|
||||
"Follow-Up Auto-Generation": "Αυτόματη Δημιουργία Συνέχισης",
|
||||
"Followed instructions perfectly": "Ακολούθησε τις οδηγίες τέλεια",
|
||||
"Font Family": "",
|
||||
"for placeholders": "",
|
||||
"Force OCR": "",
|
||||
"Forking chat...": "",
|
||||
@@ -1403,6 +1401,7 @@
|
||||
"Hide Hidden Files": "",
|
||||
"Hide Model": "Απόκρυψη Μοντέλου",
|
||||
"High": "",
|
||||
"High Contrast Mode": "Λειτουργία Υψηλής Αντίθεσης",
|
||||
"History": "",
|
||||
"Home": "Αρχική",
|
||||
"Host": "Διακομιστής",
|
||||
@@ -2903,7 +2902,6 @@
|
||||
"Use": "",
|
||||
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "",
|
||||
"Use a Gemini API key for image generation.": "",
|
||||
"Use a local font family for the app interface.": "",
|
||||
"Use a valid event name or pattern like user.*": "",
|
||||
"Use a wider chat layout on large displays.": "",
|
||||
"Use an API key when your ComfyUI server requires one.": "",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"Access List": "",
|
||||
"Access prohibited": "",
|
||||
"Access updated": "",
|
||||
"Accessibility Mode": "",
|
||||
"Accessible to all users": "",
|
||||
"Account": "",
|
||||
"Account Activation Pending": "",
|
||||
@@ -1298,7 +1297,6 @@
|
||||
"Follow up: {{question}}": "",
|
||||
"Follow-Up Auto-Generation": "",
|
||||
"Followed instructions perfectly": "",
|
||||
"Font Family": "",
|
||||
"for placeholders": "",
|
||||
"Force OCR": "",
|
||||
"Forking chat...": "",
|
||||
@@ -1403,6 +1401,7 @@
|
||||
"Hide Hidden Files": "",
|
||||
"Hide Model": "",
|
||||
"High": "",
|
||||
"High Contrast Mode": "",
|
||||
"History": "",
|
||||
"Home": "",
|
||||
"Host": "",
|
||||
@@ -2903,7 +2902,6 @@
|
||||
"Use": "",
|
||||
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "",
|
||||
"Use a Gemini API key for image generation.": "",
|
||||
"Use a local font family for the app interface.": "",
|
||||
"Use a valid event name or pattern like user.*": "",
|
||||
"Use a wider chat layout on large displays.": "",
|
||||
"Use an API key when your ComfyUI server requires one.": "",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"Access List": "",
|
||||
"Access prohibited": "",
|
||||
"Access updated": "",
|
||||
"Accessibility Mode": "",
|
||||
"Accessible to all users": "",
|
||||
"Account": "",
|
||||
"Account Activation Pending": "",
|
||||
@@ -1298,7 +1297,6 @@
|
||||
"Follow up: {{question}}": "",
|
||||
"Follow-Up Auto-Generation": "",
|
||||
"Followed instructions perfectly": "",
|
||||
"Font Family": "",
|
||||
"for placeholders": "",
|
||||
"Force OCR": "",
|
||||
"Forking chat...": "",
|
||||
@@ -1403,6 +1401,7 @@
|
||||
"Hide Hidden Files": "",
|
||||
"Hide Model": "",
|
||||
"High": "",
|
||||
"High Contrast Mode": "",
|
||||
"History": "",
|
||||
"Home": "",
|
||||
"Host": "",
|
||||
@@ -2884,7 +2883,6 @@
|
||||
"Upload Audio": "",
|
||||
"Upload directory": "",
|
||||
"Upload failed": "",
|
||||
"Upload failed for {{failed}} of {{total}} files.": "",
|
||||
"Upload files": "",
|
||||
"Upload Files": "",
|
||||
"Upload Model": "",
|
||||
@@ -2904,7 +2902,6 @@
|
||||
"Use": "",
|
||||
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "",
|
||||
"Use a Gemini API key for image generation.": "",
|
||||
"Use a local font family for the app interface.": "",
|
||||
"Use a valid event name or pattern like user.*": "",
|
||||
"Use a wider chat layout on large displays.": "",
|
||||
"Use an API key when your ComfyUI server requires one.": "",
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
"Access List": "Lista de Permisos",
|
||||
"Access prohibited": "",
|
||||
"Access updated": "Permisos actualizados",
|
||||
"Accessibility Mode": "",
|
||||
"Accessible to all users": "Accesible para todos los usuarios",
|
||||
"Account": "Cuenta",
|
||||
"Account Activation Pending": "Activación de cuenta Pendiente",
|
||||
@@ -1308,7 +1307,6 @@
|
||||
"Follow up: {{question}}": "Seguimiento: {{question}}",
|
||||
"Follow-Up Auto-Generation": "Seguimiento Auto-Generación",
|
||||
"Followed instructions perfectly": "Siguió las instrucciones perfectamente",
|
||||
"Font Family": "",
|
||||
"for placeholders": "para marcadores de posición",
|
||||
"Force OCR": "Forzar OCR",
|
||||
"Forking chat...": "",
|
||||
@@ -1413,6 +1411,7 @@
|
||||
"Hide Hidden Files": "",
|
||||
"Hide Model": "Ocultar Modelo",
|
||||
"High": "Alto",
|
||||
"High Contrast Mode": "Modo Alto Contraste",
|
||||
"History": "Historial",
|
||||
"Home": "Inicio",
|
||||
"Host": "Host",
|
||||
@@ -2917,7 +2916,6 @@
|
||||
"Use": "Usar",
|
||||
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "Usar el endpoint /v1/chat/completions en vez de /v1/audio/transcriptions para una (posible) mayor precisión.",
|
||||
"Use a Gemini API key for image generation.": "",
|
||||
"Use a local font family for the app interface.": "",
|
||||
"Use a valid event name or pattern like user.*": "",
|
||||
"Use a wider chat layout on large displays.": "",
|
||||
"Use an API key when your ComfyUI server requires one.": "",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"Access List": "Juurdepääsu nimekiri",
|
||||
"Access prohibited": "",
|
||||
"Access updated": "",
|
||||
"Accessibility Mode": "",
|
||||
"Accessible to all users": "Kättesaadav kõigile kasutajatele",
|
||||
"Account": "Konto",
|
||||
"Account Activation Pending": "Konto aktiveerimine ootel",
|
||||
@@ -1298,7 +1297,6 @@
|
||||
"Follow up: {{question}}": "Järelküsimus: {{question}}",
|
||||
"Follow-Up Auto-Generation": "Järelküsimuste automaatne genereerimine",
|
||||
"Followed instructions perfectly": "Järgis juhiseid täiuslikult",
|
||||
"Font Family": "",
|
||||
"for placeholders": "kohatäidete jaoks",
|
||||
"Force OCR": "Sunni OCR",
|
||||
"Forking chat...": "",
|
||||
@@ -1403,6 +1401,7 @@
|
||||
"Hide Hidden Files": "",
|
||||
"Hide Model": "Peida mudel",
|
||||
"High": "Kõrge",
|
||||
"High Contrast Mode": "Kõrge kontrastsuse režiim",
|
||||
"History": "Ajalugu",
|
||||
"Home": "Avaleht",
|
||||
"Host": "Host",
|
||||
@@ -2903,7 +2902,6 @@
|
||||
"Use": "Kasuta",
|
||||
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "Kasuta /v1/chat/completions lõpp-punkti /v1/audio/transcriptions asemel potentsiaalselt parema täpsuse saavutamiseks.",
|
||||
"Use a Gemini API key for image generation.": "",
|
||||
"Use a local font family for the app interface.": "",
|
||||
"Use a valid event name or pattern like user.*": "",
|
||||
"Use a wider chat layout on large displays.": "",
|
||||
"Use an API key when your ComfyUI server requires one.": "",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"Access List": "",
|
||||
"Access prohibited": "",
|
||||
"Access updated": "",
|
||||
"Accessibility Mode": "",
|
||||
"Accessible to all users": "Erabiltzaile guztientzat eskuragarri",
|
||||
"Account": "Kontua",
|
||||
"Account Activation Pending": "Kontuaren Aktibazioa Zain",
|
||||
@@ -1298,7 +1297,6 @@
|
||||
"Follow up: {{question}}": "",
|
||||
"Follow-Up Auto-Generation": "",
|
||||
"Followed instructions perfectly": "Jarraibideak perfektuki jarraitu ditu",
|
||||
"Font Family": "",
|
||||
"for placeholders": "",
|
||||
"Force OCR": "",
|
||||
"Forking chat...": "",
|
||||
@@ -1403,6 +1401,7 @@
|
||||
"Hide Hidden Files": "",
|
||||
"Hide Model": "",
|
||||
"High": "",
|
||||
"High Contrast Mode": "",
|
||||
"History": "",
|
||||
"Home": "",
|
||||
"Host": "Ostalaria",
|
||||
@@ -2903,7 +2902,6 @@
|
||||
"Use": "",
|
||||
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "",
|
||||
"Use a Gemini API key for image generation.": "",
|
||||
"Use a local font family for the app interface.": "",
|
||||
"Use a valid event name or pattern like user.*": "",
|
||||
"Use a wider chat layout on large displays.": "",
|
||||
"Use an API key when your ComfyUI server requires one.": "",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"Access List": "",
|
||||
"Access prohibited": "",
|
||||
"Access updated": "",
|
||||
"Accessibility Mode": "",
|
||||
"Accessible to all users": "قابل دسترسی برای همه کاربران",
|
||||
"Account": "حساب کاربری",
|
||||
"Account Activation Pending": "فعال\u200cسازی حساب در حال انتظار",
|
||||
@@ -1298,7 +1297,6 @@
|
||||
"Follow up: {{question}}": "",
|
||||
"Follow-Up Auto-Generation": "تولید خودکار پیگیری",
|
||||
"Followed instructions perfectly": "دستورالعمل ها را کاملا دنبال کرد",
|
||||
"Font Family": "",
|
||||
"for placeholders": "",
|
||||
"Force OCR": "اجبار به OCR",
|
||||
"Forking chat...": "",
|
||||
@@ -1403,6 +1401,7 @@
|
||||
"Hide Hidden Files": "",
|
||||
"Hide Model": "پنهان کردن مدل",
|
||||
"High": "بالا",
|
||||
"High Contrast Mode": "حالت کنتراست بالا",
|
||||
"History": "",
|
||||
"Home": "خانه",
|
||||
"Host": "میزبان",
|
||||
@@ -2903,7 +2902,6 @@
|
||||
"Use": "",
|
||||
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "از نقطه پایانی /v1/chat/completions به جای /v1/audio/transcriptions برای دقت بالقوه بهتر استفاده کنید.",
|
||||
"Use a Gemini API key for image generation.": "",
|
||||
"Use a local font family for the app interface.": "",
|
||||
"Use a valid event name or pattern like user.*": "",
|
||||
"Use a wider chat layout on large displays.": "",
|
||||
"Use an API key when your ComfyUI server requires one.": "",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"Access List": "Pääsylista",
|
||||
"Access prohibited": "Pääsy kielletty",
|
||||
"Access updated": "Käyttöoikeus päivitetty",
|
||||
"Accessibility Mode": "",
|
||||
"Accessible to all users": "Käytettävissä kaikille käyttäjille",
|
||||
"Account": "Tili",
|
||||
"Account Activation Pending": "Tilin aktivointi odottaa",
|
||||
@@ -1298,7 +1297,6 @@
|
||||
"Follow up: {{question}}": "Jatkokysymys: {{question}}",
|
||||
"Follow-Up Auto-Generation": "Jatkokysymysten automaattinen luonti",
|
||||
"Followed instructions perfectly": "Noudatti ohjeita täydellisesti",
|
||||
"Font Family": "",
|
||||
"for placeholders": "",
|
||||
"Force OCR": "Pakota OCR",
|
||||
"Forking chat...": "Haarautetaan keskustelua...",
|
||||
@@ -1403,6 +1401,7 @@
|
||||
"Hide Hidden Files": "",
|
||||
"Hide Model": "Piilota malli",
|
||||
"High": "Korkea",
|
||||
"High Contrast Mode": "Korkean kontrastin tila",
|
||||
"History": "Historia",
|
||||
"Home": "Koti",
|
||||
"Host": "Palvelin",
|
||||
@@ -2903,7 +2902,6 @@
|
||||
"Use": "Käytä",
|
||||
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "Käytä /v1/chat/completions-päätteitä/v1/audio/transcriptions-pääteen sijaan mahdollisesti paremman tarkkuuden saavuttamiseksi.",
|
||||
"Use a Gemini API key for image generation.": "",
|
||||
"Use a local font family for the app interface.": "",
|
||||
"Use a valid event name or pattern like user.*": "Käytä kelvollista tapahtuman nimeä tai mallia, kuten user.*",
|
||||
"Use a wider chat layout on large displays.": "Käytä laajempaa keskustelun asettelua suurilla näytöillä.",
|
||||
"Use an API key when your ComfyUI server requires one.": "Käytä API-avainta, kun ComfyUI-palvelimesi vaatii sitä.",
|
||||
|
||||
@@ -69,7 +69,6 @@
|
||||
"Access List": "",
|
||||
"Access prohibited": "",
|
||||
"Access updated": "",
|
||||
"Accessibility Mode": "",
|
||||
"Accessible to all users": "",
|
||||
"Account": "Account",
|
||||
"Account Activation Pending": "",
|
||||
@@ -1299,7 +1298,6 @@
|
||||
"Follow up: {{question}}": "",
|
||||
"Follow-Up Auto-Generation": "",
|
||||
"Followed instructions perfectly": "",
|
||||
"Font Family": "",
|
||||
"for placeholders": "",
|
||||
"Force OCR": "",
|
||||
"Forking chat...": "",
|
||||
@@ -1404,6 +1402,7 @@
|
||||
"Hide Hidden Files": "",
|
||||
"Hide Model": "",
|
||||
"High": "",
|
||||
"High Contrast Mode": "",
|
||||
"History": "Kasaysayan",
|
||||
"Home": "Tahanan",
|
||||
"Host": "",
|
||||
@@ -2906,7 +2905,6 @@
|
||||
"Use": "",
|
||||
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "",
|
||||
"Use a Gemini API key for image generation.": "",
|
||||
"Use a local font family for the app interface.": "",
|
||||
"Use a valid event name or pattern like user.*": "",
|
||||
"Use a wider chat layout on large displays.": "",
|
||||
"Use an API key when your ComfyUI server requires one.": "",
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"Access List": "Atgongdarlisti",
|
||||
"Access prohibited": "Atgongd bannað",
|
||||
"Access updated": "Atgongd dagførd",
|
||||
"Accessibility Mode": "",
|
||||
"Accessible to all users": "Atkomuligt hjá øllum brúkarum",
|
||||
"Account": "Konta",
|
||||
"Account Activation Pending": "Virkjan av kontu bíðar",
|
||||
@@ -1298,7 +1297,6 @@
|
||||
"Follow up: {{question}}": "Uppfylging: {{question}}",
|
||||
"Follow-Up Auto-Generation": "Sjálvvirkandi gerð av uppfylging",
|
||||
"Followed instructions perfectly": "Fylgdi vegleiðingunum fullkomiliga",
|
||||
"Font Family": "",
|
||||
"for placeholders": "til plásshaldarar",
|
||||
"Force OCR": "Tvinga OCR",
|
||||
"Forking chat...": "Avgreinar kjatt...",
|
||||
@@ -1403,6 +1401,7 @@
|
||||
"Hide Hidden Files": "",
|
||||
"Hide Model": "Fjal modell",
|
||||
"High": "Høgt",
|
||||
"High Contrast Mode": "Háur kontrastur",
|
||||
"History": "Søga",
|
||||
"Home": "Heim",
|
||||
"Host": "Vertur",
|
||||
@@ -2903,7 +2902,6 @@
|
||||
"Use": "Nýt",
|
||||
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "Nýt /v1/chat/completions-endpointið í staðin fyri /v1/audio/transcriptions fyri møguliga betri neyvleika.",
|
||||
"Use a Gemini API key for image generation.": "Nýt ein Gemini API-lykil til myndagerð.",
|
||||
"Use a local font family for the app interface.": "",
|
||||
"Use a valid event name or pattern like user.*": "Nýt eitt gyldugt hendingarnavn ella mynstur sum user.*",
|
||||
"Use a wider chat layout on large displays.": "Nýt eitt breiðari kjattsnið á stórum skermum.",
|
||||
"Use an API key when your ComfyUI server requires one.": "Nýt ein API-lykil, tá ComfyUI-tænastarin krevur tað.",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user