mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-08 03:08:02 +00:00
commit
736707eeee
175 changed files with 5685 additions and 4136 deletions
137
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
137
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
|
|
@ -1,5 +1,5 @@
|
|||
name: Bug Report
|
||||
description: Create a detailed bug report to help us improve Open WebUI.
|
||||
description: Tell us what broke in Open WebUI.
|
||||
title: 'issue: '
|
||||
labels: ['bug', 'triage']
|
||||
body:
|
||||
|
|
@ -8,35 +8,28 @@ body:
|
|||
value: |
|
||||
# Bug Report
|
||||
|
||||
## Important Notes
|
||||
Use this for real, reproducible bugs. A clear issue is the most useful contribution: include the affected workflow, the expected result, the actual result, and the details needed for someone else to reproduce it.
|
||||
|
||||
- **Before submitting a bug report**: Please check the [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions) sections to see if a similar issue has already been reported. If unsure, start a discussion first, as this helps us efficiently focus on improving the project. Duplicates may be closed without notice. **Please search for existing issues AND discussions. No matter open or closed.**
|
||||
Before submitting, search open and closed [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions). The issue may already be reported or fixed on `dev`.
|
||||
|
||||
- Check for opened, **but also for (recently) CLOSED issues** as the issue you are trying to report **might already have been fixed on the dev branch!**
|
||||
Please do not open a code pull request for this report unless a maintainer asks for one, or the change is only i18n/localization. If you want to share code as reference, include it here as a local diff or patch. Actionable reproduction details are the most useful next step.
|
||||
|
||||
- **Respectful collaboration**: Open WebUI is a volunteer-driven project with a single maintainer and contributors who also have full-time jobs. Please be constructive and respectful in your communication.
|
||||
|
||||
- **Contributing**: If you encounter an issue, consider submitting a pull request or forking the project. We prioritize preventing contributor burnout to maintain Open WebUI's quality.
|
||||
|
||||
- **Bug Reproducibility**: If a bug cannot be reproduced using a `:main` or `:dev` Docker setup or with `pip install` on Python 3.11, community assistance may be required. In such cases, we will move it to the "[Issues](https://github.com/open-webui/open-webui/discussions/categories/issues)" Discussions section. Your help is appreciated!
|
||||
|
||||
- **Scope**: If you want to report a SECURITY VULNERABILITY, then do so through our [GitHub security page](https://github.com/open-webui/open-webui/security).
|
||||
Security vulnerabilities must not be reported publicly. Use the [GitHub security page](https://github.com/open-webui/open-webui/security) instead.
|
||||
|
||||
- type: checkboxes
|
||||
id: issue-check
|
||||
attributes:
|
||||
label: Check Existing Issues
|
||||
description: Confirm that you’ve checked for existing reports before submitting a new one.
|
||||
label: Before Submitting
|
||||
options:
|
||||
- label: I have searched for any existing and/or related issues.
|
||||
- label: I searched open and closed issues and discussions for an existing report.
|
||||
required: true
|
||||
- label: I have searched for any existing and/or related discussions.
|
||||
- label: I checked whether this is already fixed on the `dev` branch or latest source.
|
||||
required: true
|
||||
- label: I have also searched in the CLOSED issues AND CLOSED discussions and found no related items (your issue might already be addressed on the development branch!).
|
||||
- label: I understand that maintainers want a well-written issue before any code pull request.
|
||||
required: true
|
||||
- label: I have checked whether this issue is already fixed on the `dev` branch or in the latest source.
|
||||
- label: I am using the latest available version of Open WebUI for my install method.
|
||||
required: true
|
||||
- label: I am using the latest version of Open WebUI.
|
||||
- label: This is not a security vulnerability.
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
|
|
@ -45,9 +38,9 @@ body:
|
|||
label: Installation Method
|
||||
description: How did you install Open WebUI?
|
||||
options:
|
||||
- Git Clone
|
||||
- Pip Install
|
||||
- Docker
|
||||
- Pip Install
|
||||
- Git Clone
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
|
@ -56,67 +49,51 @@ body:
|
|||
id: open-webui-version
|
||||
attributes:
|
||||
label: Open WebUI Version
|
||||
description: Specify the version (e.g., v0.11.0)
|
||||
description: Specify the version, commit, or image tag.
|
||||
placeholder: v0.11.0, dev commit SHA, or Docker tag
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: ollama-version
|
||||
attributes:
|
||||
label: Ollama Version (if applicable)
|
||||
description: Specify the version (e.g., v0.32.5, or v0.32.6-rc0)
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: operating-system
|
||||
attributes:
|
||||
label: Operating System
|
||||
description: Specify the OS (e.g., Windows 11, macOS Tahoe, Ubuntu 26.04, Debian 13)
|
||||
description: Specify the OS and version.
|
||||
placeholder: Windows 11, macOS Tahoe, Ubuntu 26.04, Debian 13
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: browser
|
||||
attributes:
|
||||
label: Browser (if applicable)
|
||||
description: Specify the browser/version (e.g., Chrome 151.0, Firefox 153.0.3)
|
||||
label: Browser
|
||||
description: If the bug appears in the browser, include browser and version.
|
||||
placeholder: Chrome 151.0, Firefox 153.0.3
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: confirmation
|
||||
- type: input
|
||||
id: ollama-version
|
||||
attributes:
|
||||
label: Confirmation
|
||||
description: Ensure the following prerequisites have been met.
|
||||
options:
|
||||
- label: I have read and followed all instructions in `README.md`.
|
||||
required: true
|
||||
- label: I am using the latest version of **both** Open WebUI and Ollama.
|
||||
required: true
|
||||
- label: I have included the browser console logs.
|
||||
required: true
|
||||
- label: I have included the Docker container logs.
|
||||
required: true
|
||||
- label: I have **provided every relevant configuration, setting, and environment variable used in my setup.**
|
||||
required: true
|
||||
- label: I have clearly **listed every relevant configuration, custom setting, environment variable, and command-line option that influences my setup** (such as Docker Compose overrides, .env values, browser settings, authentication configurations, etc).
|
||||
required: true
|
||||
- label: |
|
||||
I have documented **step-by-step reproduction instructions that are precise, sequential, and leave nothing to interpretation**. My steps:
|
||||
- Start with the initial platform/version/OS and dependencies used,
|
||||
- Specify exact install/launch/configure commands,
|
||||
- List URLs visited, user input (incl. example values/emails/passwords if needed),
|
||||
- Describe all options and toggles enabled or changed,
|
||||
- Include any files or environmental changes,
|
||||
- Identify the expected and actual result at each stage,
|
||||
- Ensure any reasonably skilled user can follow and hit the same issue.
|
||||
required: true
|
||||
label: Ollama Version
|
||||
description: Include this if Ollama is involved.
|
||||
placeholder: v0.32.5
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Summary
|
||||
description: What is wrong, in a few sentences?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: Describe what should have happened.
|
||||
description: What should have happened?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
|
@ -124,7 +101,7 @@ body:
|
|||
id: actual-behavior
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
description: Describe what actually happened.
|
||||
description: What actually happened?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
|
@ -132,32 +109,21 @@ body:
|
|||
id: reproduction-steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: |
|
||||
Please provide a **very detailed, step-by-step guide** to reproduce the issue. Your instructions should be so clear and precise that anyone can follow them without guesswork. Include every relevant detail—settings, configuration options, exact commands used, values entered, and any prerequisites or environment variables.
|
||||
**If full reproduction steps and all relevant settings are not provided, your issue may not be addressed.**
|
||||
**If your steps to reproduction are incomplete, lacking detail or not reproducible, your issue can not be addressed.**
|
||||
|
||||
description: Include the exact commands, settings, URLs, model/provider setup, and user actions needed to hit the bug.
|
||||
placeholder: |
|
||||
Example (include every detail):
|
||||
1. Start with a clean Ubuntu 26.04 install.
|
||||
2. Install Docker v29.7.1 and start the service.
|
||||
3. Clone the Open WebUI repo (git clone ...).
|
||||
4. Use the Docker Compose file without modifications.
|
||||
5. Open browser Chrome 151.0 in incognito mode.
|
||||
6. Go to http://localhost:8080 and log in with user "test@example.com".
|
||||
7. Set the language to "English" and theme to "Dark".
|
||||
8. Attempt to connect to Ollama at "http://localhost:11434".
|
||||
9. Observe that the error message "Connection refused" appears at the top right.
|
||||
|
||||
Please list each step carefully and include all relevant configuration, settings, and options.
|
||||
1. Start Open WebUI with ...
|
||||
2. Configure ...
|
||||
3. Open ...
|
||||
4. Click ...
|
||||
5. See ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs-screenshots
|
||||
attributes:
|
||||
label: Logs & Screenshots
|
||||
description: Include relevant logs, errors, or screenshots to help diagnose the issue.
|
||||
placeholder: 'Attach logs from the browser console, Docker logs, or error messages.'
|
||||
label: Logs, Screenshots, and Config
|
||||
description: Include relevant browser console logs, server/container logs, screenshots, and configuration. If something does not apply, say so.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
|
@ -165,13 +131,6 @@ body:
|
|||
id: additional-info
|
||||
attributes:
|
||||
label: Additional Information
|
||||
description: Provide any extra details that may assist in understanding the issue.
|
||||
description: Anything else that might help us understand the report.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Note
|
||||
**If the bug report is incomplete, does not follow instructions or is lacking details it may not be addressed.** Ensure that you've followed all the **README.md** and **troubleshooting.md** guidelines, and provide all necessary information for us to reproduce the issue.
|
||||
Thank you for contributing to Open WebUI!
|
||||
|
|
|
|||
85
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
85
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
|
|
@ -1,78 +1,73 @@
|
|||
name: Feature Request
|
||||
description: Suggest a new feature or improvement
|
||||
description: Describe what you would like Open WebUI to support.
|
||||
title: 'feat: '
|
||||
labels: ['triage']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Before Submitting
|
||||
# Feature Request
|
||||
|
||||
Please check **open AND closed** [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions) for similar requests. If you find one, add your input there instead.
|
||||
Describe the requested behavior, the problem it solves, and any examples, mockups, screenshots, or workflows that clarify the request. A clear issue or discussion is the most useful contribution.
|
||||
|
||||
### Scope Guidelines
|
||||
Search open and closed [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions) before submitting. If the request needs broad product, UX, architecture, compatibility, or maintenance discussion, please start in [Discussions](https://github.com/open-webui/open-webui/discussions) so the community can weigh in.
|
||||
|
||||
Feature requests that require significant implementation effort should be posted in the **Ideas** section of [Discussions](https://github.com/open-webui/open-webui/discussions) instead. We will move oversized feature requests to Discussions to keep the Issues tab focused on actionable items.
|
||||
Please do not open a code pull request for this request unless a maintainer asks for one, or the change is only i18n/localization. If you want to share code as reference, include it here as a local diff or patch. Clear product context is the most useful next step.
|
||||
|
||||
If your request might impact the broader community, please open a Discussion first so others can weigh in on the design.
|
||||
|
||||
### Be Respectful
|
||||
|
||||
Open WebUI is a volunteer-driven project maintained by a small team. We value constructive, positive communication. Please be mindful of maintainers' time and energy.
|
||||
|
||||
### Contributing
|
||||
|
||||
If you encounter an issue, we encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout and maintain project quality.
|
||||
|
||||
### Reproducibility
|
||||
|
||||
If a bug cannot be reproduced with a `:main` or `:dev` Docker setup, or a `pip install` with Python 3.11, it may be moved to the "Issues" section in Discussions for community assistance.
|
||||
Security vulnerabilities must not be reported publicly. Use the [GitHub security page](https://github.com/open-webui/open-webui/security) instead.
|
||||
|
||||
- type: checkboxes
|
||||
id: existing-issue
|
||||
id: existing-request
|
||||
attributes:
|
||||
label: Check Existing Issues
|
||||
description: Confirm you have searched for similar requests.
|
||||
label: Before Submitting
|
||||
options:
|
||||
- label: I have searched all existing **open AND closed** issues and discussions and found none comparable to my request.
|
||||
- label: I searched open and closed issues and discussions for an existing request.
|
||||
required: true
|
||||
- label: I have checked whether this request is already implemented on the `dev` branch or in the latest source.
|
||||
- label: I checked whether this already exists on the `dev` branch or latest source.
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: feature-scope
|
||||
attributes:
|
||||
label: Verify Feature Scope
|
||||
description: Confirm this request belongs in Issues rather than Discussions.
|
||||
options:
|
||||
- label: I believe this feature request is appropriately scoped for the Issues section as described above.
|
||||
- label: I understand that maintainers want a well-written issue or discussion before any code pull request.
|
||||
required: true
|
||||
- label: This request is not a security vulnerability.
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem-description
|
||||
attributes:
|
||||
label: Problem Description
|
||||
description: Is this related to a problem? Describe the pain point clearly.
|
||||
placeholder: "e.g., I'm frustrated when..."
|
||||
label: Problem
|
||||
description: What is missing, frustrating, confusing, or unnecessarily hard today?
|
||||
placeholder: "I'm trying to..., but..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution-description
|
||||
id: desired-behavior
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: Describe what you would like to happen.
|
||||
label: Desired Behavior
|
||||
description: What would you like to happen instead?
|
||||
placeholder: "I would like Open WebUI to..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: why-it-matters
|
||||
attributes:
|
||||
label: Why This Matters
|
||||
description: Who benefits, and what workflow does this unlock or improve?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: examples
|
||||
attributes:
|
||||
label: Examples or References
|
||||
description: Add mockups, screenshots, links, prompts, workflows, or examples from other tools.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: alternatives-considered
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Describe any alternative solutions or workarounds you have considered.
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, mockups, or screenshots about the feature request.
|
||||
label: Alternatives or Workarounds
|
||||
description: What have you tried instead, if anything?
|
||||
validations:
|
||||
required: false
|
||||
|
|
|
|||
138
.github/pull_request_template.md
vendored
138
.github/pull_request_template.md
vendored
|
|
@ -1,126 +1,94 @@
|
|||
<!--
|
||||
⚠️ CRITICAL CHECKS FOR CONTRIBUTORS (READ, DON'T DELETE) ⚠️
|
||||
1. Target the `dev` branch. PRs targeting `main` will be automatically closed.
|
||||
2. First-time contributors should not open pull requests directly unless the pull request contains only i18n/localization updates.
|
||||
Do not open a PR as the first step.
|
||||
For real, reproducible bugs, start with a well-described Issue that explains the problem, why it matters, and what outcome you are looking for.
|
||||
For feature requests, enhancements, behavior changes, UI/UX changes, architecture changes, suspected fixes, or unconfirmed approaches, start with an active Discussion.
|
||||
If you want to propose an implementation, include it only as a reference in the Issue or Discussion, such as a local diff, patch, or branch.
|
||||
Opening an Issue or Discussion does not mean a PR is the right next step. Maintainers will confirm when a PR would be useful.
|
||||
We ask for this because PRs, especially from first-time contributors, often need broader maintainer context on product direction, scope, architecture, UX, edge cases, compatibility, documentation, and long-term maintenance before implementation.
|
||||
We may close unsolicited PRs without review.
|
||||
Contributors with a history of successful merged PRs may be given more latitude.
|
||||
3. Do NOT delete the CLA section at the bottom. It is required for the bot to accept your PR.
|
||||
Important checks for contributors:
|
||||
1. Target the `dev` branch. PRs targeting `main` will be closed.
|
||||
2. Code pull requests are not the default contribution path.
|
||||
3. Do not open a code PR as the first step. Start with a well-written Issue or Discussion unless a maintainer asked for the PR or the change is only i18n/localization.
|
||||
4. Do not delete the Contributor License Agreement section at the bottom. The CLA bot requires it.
|
||||
-->
|
||||
|
||||
# Pull Request Checklist
|
||||
# Pull Request
|
||||
|
||||
### Do not open a pull request as the first step.
|
||||
Thanks for wanting to improve Open WebUI. The most useful contribution is usually a clear, well-written Issue, not an unsolicited code pull request.
|
||||
|
||||
For real, reproducible bugs, start with a well-described [Issue](https://github.com/open-webui/open-webui/issues) that explains the problem, why it matters, and what outcome you are looking for.
|
||||
Open a code pull request only when a maintainer asks for one, or when the change is only i18n/localization. For real, reproducible bugs, start with a well-described [Issue](https://github.com/open-webui/open-webui/issues). For feature requests, UI/UX changes, behavior changes, architecture changes, suspected fixes, or unconfirmed approaches, start with an active [Discussion](https://github.com/open-webui/open-webui/discussions).
|
||||
|
||||
For feature requests, enhancements, behavior changes, UI/UX changes, architecture changes, suspected fixes, or unconfirmed approaches, start with an active [Discussion](https://github.com/open-webui/open-webui/discussions). Merely opening a discussion is not enough; it needs to be actively discussed.
|
||||
Before continuing, make sure the linked Issue or Discussion explains the user-facing problem, the expected outcome, the affected workflow, and any examples, logs, screenshots, constraints, or reproduction details needed for maintainers to evaluate it.
|
||||
|
||||
If you want to propose an implementation, include it only as a reference in the Issue or Discussion, such as a local diff, patch, or branch.
|
||||
If you have implementation notes, include them as reference in the Issue or Discussion. If you want to share code as reference, include it there as a local diff, patch, or branch note. Do not open a pull request for reference code.
|
||||
|
||||
Opening an Issue or Discussion does not mean a PR is the right next step. Maintainers will confirm when a PR would be useful.
|
||||
Unsolicited PRs may be closed without review, especially when they introduce product, architecture, compatibility, dependency, or maintenance decisions that have not been discussed.
|
||||
|
||||
We ask for this because PRs, especially from first-time contributors, often need broader maintainer context on product direction, scope, architecture, UX, edge cases, compatibility, documentation, and long-term maintenance before implementation.
|
||||
## Checklist
|
||||
|
||||
Unsolicited PRs may be closed without review. Contributors with a history of successful merged PRs may be given more latitude.
|
||||
- [ ] This PR targets the `dev` branch.
|
||||
- [ ] This PR links to a well-described, confirmed Issue or active Discussion: `Closes #___` / `Relates to #___`.
|
||||
- [ ] A maintainer explicitly asked me to open this PR, or this PR only updates i18n/localization.
|
||||
- [ ] The change is one logical unit with no unrelated commits.
|
||||
- [ ] I matched nearby code patterns and avoided unnecessary new settings, abstractions, or dependencies.
|
||||
- [ ] I manually tested the changed workflow and any nearby behavior that could be affected.
|
||||
- [ ] I updated relevant docs, including the [Open WebUI Docs Repository](https://github.com/open-webui/docs), if needed.
|
||||
- [ ] I added screenshots for UI changes, and a recording when motion or interaction matters.
|
||||
- [ ] I reviewed any AI-generated code before submitting it.
|
||||
- [ ] The PR title uses one of the prefixes listed below.
|
||||
|
||||
<!--
|
||||
### ⚠️ Important: Your PR is a contribution, not a guarantee of merge.
|
||||
## Title Prefix
|
||||
|
||||
We appreciate thoughtful contributions. Pull requests are for implementation-ready changes that have already been requested, confirmed, or actively discussed in a linked Issue or Discussion. Feature ideas, behavior changes, UI/UX changes, architecture changes, suspected fixes, and unconfirmed approaches should start as an Issue or Discussion instead.
|
||||
Use one of the following prefixes:
|
||||
|
||||
Before opening a PR, make sure the change has a clear linked problem, follows nearby patterns, has been manually tested, and accounts for related or downstream behavior. PRs that are ideas, prototypes, unresolved design questions, unchecked AI-generated code, symptom-only patches, one-off patches, or changes where affected paths have not been checked will usually be closed.
|
||||
- **BREAKING CHANGE**: Changes affecting backward compatibility
|
||||
- **build**: Build system or dependency changes
|
||||
- **ci**: CI/CD workflow changes
|
||||
- **chore**: Refactoring, cleanup, or non-functional changes
|
||||
- **docs**: Documentation additions or updates
|
||||
- **feat**: New features or enhancements
|
||||
- **fix**: Bug fixes or corrections
|
||||
- **i18n**: Internationalization or localization changes
|
||||
- **perf**: Performance improvements
|
||||
- **refactor**: Code restructuring
|
||||
|
||||
The most impactful way to contribute to Open WebUI is through well-written bug reports, detailed feature discussions, and thoughtful ideas. These directly shape the project. If you do open a pull request, please know that Open WebUI is held to the highest standard of code quality, consistency, and architectural coherence, and every line merged becomes something the core team must own, maintain, and support indefinitely. Submitted code may be refactored, rewritten, or used as inspiration for a different implementation. This is not a reflection of your work's quality. It is how we ensure that a small team can deeply understand and evolve every part of the codebase.
|
||||
-->
|
||||
## Summary
|
||||
|
||||
**Before submitting, make sure you've checked and filled out the following:**
|
||||
Describe the change, the problem it solves, and the impact on users.
|
||||
|
||||
- [ ] **Linked Issue/Discussion:** This PR references an existing, well-described [Issue](https://github.com/open-webui/open-webui/issues) for a real bug or an active, substantive [Discussion](https://github.com/open-webui/open-webui/discussions) for a feature request or enhancement — `Closes #___` / `Relates to #___`.
|
||||
- [ ] **First-time contributor policy:** This is not my first contribution to Open WebUI, this PR contains only i18n/localization updates, or a maintainer explicitly asked me to open this PR after reviewing the linked Issue or Discussion.
|
||||
- [ ] **Target branch:** The pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.**
|
||||
- [ ] **Description:** A concise description of the changes is provided below.
|
||||
- [ ] **Changelog:** A changelog entry following [Keep a Changelog](https://keepachangelog.com/) format is included at the bottom.
|
||||
- [ ] **Documentation:** Relevant documentation has been added or updated in the [Open WebUI Docs Repository](https://github.com/open-webui/docs).
|
||||
- [ ] **Dependencies:** Any new or updated dependencies are explained, tested, and documented.
|
||||
- [ ] **Testing:** **Manual** end-to-end tests have been performed to verify the fix/feature works correctly and does not introduce regressions. Screenshots or recordings are included where applicable.
|
||||
- [ ] **User-facing changes:** I have confirmed whether this PR changes the UI. If it does, screenshots are required, and a video recording is recommended.
|
||||
- [ ] **No Unchecked AI Code:** This PR is either human-written or has undergone thorough human review AND manual testing. Unreviewed AI-generated PRs may be closed immediately.
|
||||
- [ ] **Self-Review:** A self-review of the code has been performed, ensuring adherence to project coding standards.
|
||||
- [ ] **Architecture:** Smart defaults are preferred over new settings. Local state is used for ephemeral UI logic. Major architectural or UX changes have been discussed first.
|
||||
- [ ] **Git Hygiene:** The PR is atomic (one logical change), rebased on `dev`, and contains no unrelated commits.
|
||||
- [ ] **Title Prefix:** The PR title uses one of the following prefixes:
|
||||
- **BREAKING CHANGE**: Changes affecting backward compatibility
|
||||
- **build**: Build system or dependency changes
|
||||
- **ci**: CI/CD workflow changes
|
||||
- **chore**: Refactoring, cleanup, or non-functional changes
|
||||
- **docs**: Documentation additions or updates
|
||||
- **feat**: New features or enhancements
|
||||
- **fix**: Bug fixes or corrections
|
||||
- **i18n**: Internationalization or localization changes
|
||||
- **perf**: Performance improvements
|
||||
- **refactor**: Code restructuring
|
||||
## Testing
|
||||
|
||||
# Changelog Entry
|
||||
List the exact manual checks you ran. Include commands, setup details, screenshots, or recordings where helpful.
|
||||
|
||||
### Description
|
||||
|
||||
- [Describe the changes, including motivation and impact]
|
||||
## Changelog Entry
|
||||
|
||||
### Added
|
||||
|
||||
- [New features, functionalities, or additions]
|
||||
-
|
||||
|
||||
### Changed
|
||||
|
||||
- [Changes, updates, refactorings, or optimizations]
|
||||
|
||||
### Deprecated
|
||||
|
||||
- [Deprecated functionality or features]
|
||||
|
||||
### Removed
|
||||
|
||||
- [Removed features, files, or functionalities]
|
||||
-
|
||||
|
||||
### Fixed
|
||||
|
||||
- [Bug fixes or corrections]
|
||||
-
|
||||
|
||||
### Removed
|
||||
|
||||
-
|
||||
|
||||
### Security
|
||||
|
||||
- [Security-related changes or vulnerability fixes]
|
||||
-
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **BREAKING CHANGE**: [Changes affecting compatibility or functionality]
|
||||
-
|
||||
|
||||
---
|
||||
## Additional Context
|
||||
|
||||
### Additional Information
|
||||
Add anything maintainers should know before review.
|
||||
|
||||
- [Any additional context, notes, or references to related issues/commits]
|
||||
|
||||
### Screenshots or Videos
|
||||
|
||||
- [Attach screenshots or videos for user-facing changes. For UI changes, screenshots are required, and a video recording is recommended.]
|
||||
|
||||
### Contributor License Agreement
|
||||
## Contributor License Agreement
|
||||
|
||||
<!--
|
||||
🚨 DO NOT DELETE THE TEXT BELOW 🚨
|
||||
Keep the "Contributor License Agreement" confirmation text intact.
|
||||
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
|
||||
|
||||
Your PR will NOT be reviewed or merged until you check the box below confirming that you have read and agree to the terms of the CLA.
|
||||
DO NOT DELETE THIS SECTION.
|
||||
Your PR will not be reviewed or merged until you check the box below confirming that you have read and agree to the CLA.
|
||||
-->
|
||||
|
||||
- [ ] By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
|
||||
|
||||
> [!NOTE]
|
||||
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
|
||||
|
|
|
|||
14
.github/workflows/docker.yaml
vendored
14
.github/workflows/docker.yaml
vendored
|
|
@ -311,7 +311,6 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
if: ${{ !cancelled() && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) }}
|
||||
needs: [merge]
|
||||
continue-on-error: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
|
@ -403,5 +402,16 @@ jobs:
|
|||
[ -z "$TAG" ] && continue
|
||||
DEST="${DOCKERHUB_IMAGE}:${TAG}"
|
||||
echo " -> ${DEST}"
|
||||
docker buildx imagetools create -t "${DEST}" "${SOURCE}"
|
||||
for ATTEMPT in 1 2 3; do
|
||||
if docker buildx imagetools create -t "${DEST}" "${SOURCE}" && \
|
||||
docker buildx imagetools inspect "${DEST}"; then
|
||||
break
|
||||
fi
|
||||
if [ "${ATTEMPT}" = "3" ]; then
|
||||
echo "Failed to copy ${DEST} after ${ATTEMPT} attempts"
|
||||
exit 1
|
||||
fi
|
||||
echo "Copy attempt ${ATTEMPT} for ${DEST} failed, retrying in 15s..."
|
||||
sleep 15
|
||||
done
|
||||
done <<< "${{ steps.tags.outputs.tags }}"
|
||||
|
|
|
|||
56
CHANGELOG.md
56
CHANGELOG.md
|
|
@ -5,6 +5,62 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.11.2] - 2026-08-31
|
||||
|
||||
### Added
|
||||
|
||||
- 🖼️ **Richer previews for terminal files.** Word documents and slide decks produced in the terminal are now previewed as the finished document rather than an approximation, and every document preview gains a page strip down the side with numbered thumbnails you can click to jump straight to a page, and the notice warning that a preview might differ from the download is gone now that it does not. [Commit](https://github.com/open-webui/open-webui/commit/061fb434328ea8365cc4961519e8adbe5ac7b0c1), [Commit](https://github.com/open-webui/open-webui/commit/9d95a0148b3180ba48186bc6928713f969a69047), [Commit](https://github.com/open-webui/open-webui/commit/ae549d3e4af672a63a809e8fe2a6c91e0bfc1067), [Commit](https://github.com/open-webui/open-webui/commit/78d8c9166f1b6626b9aa559a812183ce216eef4f), [Commit](https://github.com/open-webui/open-webui/commit/492ccf3ac0d9aec1f3f2bc6825a3e6d3b32aa0d1)
|
||||
- ⚡ **Less overhead on every message.** Deployments without pipelines configured, which is the default, no longer pay setup work for them on each chat message and background task. [#29146](https://github.com/open-webui/open-webui/pull/29146)
|
||||
- 🏎️ **Lighter model list refreshes.** On deployments running several workers, a refresh that finds the model list unchanged no longer has every worker rewrite the whole list to the shared cache, cutting the work and the traffic each refresh costs. [#29264](https://github.com/open-webui/open-webui/pull/29264)
|
||||
- 🧵 **Smoother busy websocket servers.** Large Redis-backed websocket deployments now spend far less time sweeping old sessions, reading their own session data back from Redis, and checking every outgoing websocket message for file attachments Open WebUI does not send, so channel posts, collaboration updates, heartbeats, and live chat updates put less load on busy servers. [#28835](https://github.com/open-webui/open-webui/pull/28835), [#28180](https://github.com/open-webui/open-webui/pull/28180)
|
||||
- 🧰 **A new request filter step.** Filter authors can now use `request` to adjust the payload right before each model call, including follow-up calls after tool use, while existing `inlet`, `stream`, and `outlet` filters keep working as before. [Commit](https://github.com/open-webui/open-webui/commit/2daa610cba514dfe2eae008b1fc440d4ddb0e470), [Commit](https://github.com/open-webui/open-webui/commit/2a4ef46ac86ada3447eb6d6c50fd4c1241eebd4d)
|
||||
- 🤏 **More room in file previews on touch screens.** File previews on phones and tablets no longer show zoom buttons that sit over an already small preview, leaving pinch to zoom to do the job. [#29176](https://github.com/open-webui/open-webui/pull/29176), [#29152](https://github.com/open-webui/open-webui/issues/29152)
|
||||
- 👆 **Resizing panels by touch.** The divider beside the main sidebar or a side panel can now be dragged on a touchscreen or with a stylus, and a drag made with the mouse keeps following the pointer when it leaves the window. [Commit](https://github.com/open-webui/open-webui/commit/cfa2d25317b426c80dca43829afbf113eeccc365), [Commit](https://github.com/open-webui/open-webui/commit/f0ffa7508e75946434794941378675e0c7151eae), [Commit](https://github.com/open-webui/open-webui/commit/e250be48ee16d71202651e4109a175910a6519bb)
|
||||
- 🔤 **Choice of interface font.** Interface settings now offer a font family field where you can name a font installed on your computer and watch it apply across the interface as you type, with an empty field returning to the standard font. [Commit](https://github.com/open-webui/open-webui/commit/b9765fe97943831e17a68490cd01c70a6ccf1c43)
|
||||
- ♿ **Wider accessibility mode coverage.** Accessibility mode now lifts the contrast of more of the interface, including muted text, table text, icons, the colours elements change to on hover, and the highlight behind sidebar and menu items you point at, so low-contrast areas meet the level the accessibility guidelines ask for in both light and dark themes, and the close button on the folder dialog now announces itself to screen readers, as do document and slide previews, which can now be reached with the keyboard. [Commit](https://github.com/open-webui/open-webui/commit/e6031ea6daec897eddbcf0c8b03c2d6fad2b506e), [Commit](https://github.com/open-webui/open-webui/commit/797b4c51d1e31f614c8e6eba14ee347c61ad0c7a), [#29160](https://github.com/open-webui/open-webui/pull/29160), [Commit](https://github.com/open-webui/open-webui/commit/e4694f82ebd2e916c099e5e921c0ca8445ba88a7), [Commit](https://github.com/open-webui/open-webui/commit/120409ef016c1e866247e2aa1eba7606f797a20c)
|
||||
- 🌐 **Translation updates.** Polish, Simplified Chinese, German, Irish, Catalan, and Portuguese (Brazil) translations were expanded, corrected, and brought up to date with newer interface text. [#29029](https://github.com/open-webui/open-webui/pull/29029), [#29043](https://github.com/open-webui/open-webui/pull/29043), [#29108](https://github.com/open-webui/open-webui/pull/29108), [#29151](https://github.com/open-webui/open-webui/pull/29151), [#29184](https://github.com/open-webui/open-webui/pull/29184), [#29179](https://github.com/open-webui/open-webui/pull/29179), [#29247](https://github.com/open-webui/open-webui/pull/29247), [#29217](https://github.com/open-webui/open-webui/pull/29217)
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🛡️ **Security Advisory**: This release includes security and access-control fixes. We recommend updating production deployments at your earliest convenience. Not all security fixes in this version may be enumerated in the fixed section. Some may be withheld for a short time to give administrators time to upgrade. [Advisories](https://github.com/open-webui/open-webui/security)
|
||||
- 💥 **Replies stop breaking mid-chat.** A conversation no longer fails in the browser and stops showing the assistant reply until you reload, and chats already saved in that state display and can be edited again. [#29250](https://github.com/open-webui/open-webui/pull/29250), [#29244](https://github.com/open-webui/open-webui/issues/29244)
|
||||
- ⌨️ **Preview shortcuts stay in the preview.** Arrow keys now page a document or slide preview only while that preview has focus, instead of paging it from anywhere on the page and taking the arrow keys away from whatever you were typing in. [Commit](https://github.com/open-webui/open-webui/commit/b356b80f8c86d110951301b83607de245b4ef6cc), [Commit](https://github.com/open-webui/open-webui/commit/e4694f82ebd2e916c099e5e921c0ca8445ba88a7)
|
||||
- 🧊 **Streaming no longer stalls on reasoning models.** A reply from a reasoning model now streams through to the end instead of showing its first few words and then freezing until generation finishes. [#29053](https://github.com/open-webui/open-webui/pull/29053), [#29035](https://github.com/open-webui/open-webui/issues/29035)
|
||||
- 💭 **Thinking stays in Thoughts.** After a model uses a tool, its reasoning for the next step now appears in the collapsed Thoughts section instead of being written into the reply as ordinary text. [#29052](https://github.com/open-webui/open-webui/pull/29052), [#29040](https://github.com/open-webui/open-webui/issues/29040)
|
||||
- 🧠 **Large tool calls stream faster through Anthropic-compatible clients.** When a streamed tool call sends a large block of arguments, Open WebUI now waits until that block can actually be complete before checking it, instead of doing the same expensive check again on every tiny piece. [#28858](https://github.com/open-webui/open-webui/pull/28858)
|
||||
- 🛑 **Stop works across instances.** Stopping a response now takes effect on deployments that spread people across several instances backed by a Redis cluster, instead of the reply continuing to the end regardless. [#29165](https://github.com/open-webui/open-webui/pull/29165), [#19840](https://github.com/open-webui/open-webui/issues/19840)
|
||||
- 🎯 **Fewer needless chat reloads.** A conversation holding an older unfinished reply no longer reloads itself each time some other response in it finishes, reloading only for the reply the update actually concerns. [Commit](https://github.com/open-webui/open-webui/commit/22379ded1aebf0537e1ee3f00a20809d47646bf6)
|
||||
- 📖 **Banners with underlined text.** A banner containing underlined text now displays instead of failing to render. [#29118](https://github.com/open-webui/open-webui/pull/29118), [#29115](https://github.com/open-webui/open-webui/issues/29115)
|
||||
- 🧰 **Pinned models start with their own tools.** Starting a chat from a pinned model in the sidebar now applies that model's tools and skills instead of carrying over the ones from the model you used last. [#29058](https://github.com/open-webui/open-webui/pull/29058), [#29050](https://github.com/open-webui/open-webui/issues/29050)
|
||||
- ❓ **Rejected ask_user calls no longer kill the reply.** If a model asks the user a question in a way Open WebUI refuses, the model now receives that error and can continue or retry, instead of leaving the chat stopped with no answer. [#29252](https://github.com/open-webui/open-webui/pull/29252), [#29077](https://github.com/open-webui/open-webui/issues/29077)
|
||||
- 🚫 **Disabled models no longer vanish.** Turning a model off in the admin Models list now keeps it in view so you can turn it back on, instead of it disappearing with no way to recover it. [#29037](https://github.com/open-webui/open-webui/pull/29037), [#29036](https://github.com/open-webui/open-webui/issues/29036)
|
||||
- ✂️ **Message text kept intact.** Text containing angle brackets and a dollar sign is no longer mistaken for a skill mention and silently removed before your message reaches the model. [#29051](https://github.com/open-webui/open-webui/pull/29051), [#29041](https://github.com/open-webui/open-webui/issues/29041)
|
||||
- 📆 **Moved calendar events stay visible.** Changing an event's date no longer makes it disappear from the calendar, and events already stuck in that state show up again. [#29085](https://github.com/open-webui/open-webui/pull/29085), [#29067](https://github.com/open-webui/open-webui/issues/29067)
|
||||
- 🔁 **Repeats follow the event.** A repeating event now works out its occurrences from its own date and time, where a repeat rule carrying a start date of its own could place them on the wrong weekday or at the wrong hour. [Commit](https://github.com/open-webui/open-webui/commit/a93c5080380447e3ab9113cacb73b61c57cbc308)
|
||||
- 🔟 **Repeating automations keep their count.** An automation set to run a fixed number of times is no longer shown as running once and quietly rewritten to a single run the next time you open and save it. [#29261](https://github.com/open-webui/open-webui/pull/29261), [#29263](https://github.com/open-webui/open-webui/pull/29263)
|
||||
- 🗓️ **Schedules survive a save.** An automation carrying a start date now shows its real schedule in the list and keeps its weekly or monthly setting when saved, instead of showing raw rule text and falling back to daily. [#29263](https://github.com/open-webui/open-webui/pull/29263)
|
||||
- 🧩 **Custom automation schedules stay custom.** Reopening an automation with a custom repeat rule no longer loses the custom rule from the editor. [#29260](https://github.com/open-webui/open-webui/pull/29260)
|
||||
- 🔢 **Accurate admin user counts.** The counts on the admin Users tabs now follow your search and reset when you switch tabs, instead of showing stale or unfiltered numbers. [#29080](https://github.com/open-webui/open-webui/pull/29080), [#29079](https://github.com/open-webui/open-webui/issues/29079)
|
||||
- 🧷 **Sidebar folders stop acting stale.** A folder that has been removed from the sidebar is now removed from the internal sidebar registry too, so later sidebar actions do not target a folder that is no longer on screen. [#29121](https://github.com/open-webui/open-webui/pull/29121)
|
||||
- 📱 **Readable model list on small screens.** The model identifier and timestamp in the workspace model list no longer overlap the model name on narrow displays. [#29084](https://github.com/open-webui/open-webui/pull/29084), [#29083](https://github.com/open-webui/open-webui/issues/29083)
|
||||
- 🧱 **Long setting values stop crushing labels.** Settings rows now let long controls shrink inside the available space instead of squeezing the label beside them. [#29229](https://github.com/open-webui/open-webui/pull/29229)
|
||||
- 🧭 **Model defaults panels stay usable.** The model capabilities and prompt suggestion sections in admin settings now scroll inside their panels instead of growing past the available space. [#29235](https://github.com/open-webui/open-webui/pull/29235)
|
||||
- 📲 **Dropdowns stay on screen.** A dropdown near the edge of a narrow screen now shifts inward to stay fully visible instead of opening past the edge with its option labels cut off. [#29226](https://github.com/open-webui/open-webui/pull/29226), [#29225](https://github.com/open-webui/open-webui/issues/29225)
|
||||
- 🌗 **Dropdown lists follow the theme.** The list a dropdown opens now takes its light or dark colouring from the rest of the interface instead of the system default. [Commit](https://github.com/open-webui/open-webui/commit/58a3fadbf336029733d6e5fdde05fd5a24e656fa)
|
||||
- 🎛️ **Valves dialog stays in bounds.** A valve whose selected options form a long line no longer stretches its input past the edge of the dialog and over the page behind it. [#29203](https://github.com/open-webui/open-webui/pull/29203), [#29202](https://github.com/open-webui/open-webui/issues/29202)
|
||||
- 🧹 **Shared chats search resets.** Reopening the shared chats dialog now starts with an empty search box and the full list, rather than a leftover search term above unfiltered results. [#29082](https://github.com/open-webui/open-webui/pull/29082), [#29081](https://github.com/open-webui/open-webui/issues/29081)
|
||||
- 🔎 **Case-insensitive search beyond English.** Searching and filtering by tag now ignore letter case for accented and non-Latin text on installations backed by SQLite, where only unaccented English letters were matched whatever their case. [Commit](https://github.com/open-webui/open-webui/commit/26f37426b7ef6c0c3b1363f413ac9a83ccaaeb71)
|
||||
- 🏷️ **Labels with colons and example URLs translate correctly.** Interface text such as `Warning:`, `http://localhost:8000`, and `https://mineru.net/api/v4` now appears correctly instead of being misread by the translation system. [#29161](https://github.com/open-webui/open-webui/pull/29161), [#29154](https://github.com/open-webui/open-webui/issues/29154)
|
||||
- 🔑 **Precise account matching on SQLite.** Signing in through an identity provider now matches your account on its exact identifier, so accounts whose identifier is an unusually long or zero-padded number are no longer at risk of being confused with another. [Commit](https://github.com/open-webui/open-webui/commit/17cc566707cd2ee78b460e39c7fad23b9c258c70)
|
||||
- 🗃️ **Knowledge list loads reliably.** The Knowledge page in the workspace now fills in its list on opening instead of occasionally staying empty. [Commit](https://github.com/open-webui/open-webui/commit/e6031ea6daec897eddbcf0c8b03c2d6fad2b506e)
|
||||
- 🎟️ **Tool servers without a key.** A tool server connection saved without a key is no longer sent an empty authorization header, which some servers refused outright. [Commit](https://github.com/open-webui/open-webui/commit/26f37426b7ef6c0c3b1363f413ac9a83ccaaeb71)
|
||||
- 🐍 **Code interpreter starts on Windows.** The built-in code interpreter now serves its script and WebAssembly files with the browser-safe file types Windows hosts sometimes overwrite, so Pyodide can load instead of failing before code runs. [#29139](https://github.com/open-webui/open-webui/pull/29139), [#29133](https://github.com/open-webui/open-webui/issues/29133)
|
||||
|
||||
### Changed
|
||||
|
||||
- ⏱️ **Daily limit on repeats.** A calendar event can no longer be set to repeat more often than once a day, and saving one that repeats more often is now refused with a message explaining the limit. [Commit](https://github.com/open-webui/open-webui/commit/a93c5080380447e3ab9113cacb73b61c57cbc308)
|
||||
- 🏷️ **High Contrast Mode is now Accessibility Mode.** The interface setting previously called 'High Contrast Mode' is now called 'Accessibility Mode', with the same switch in the same place. [Commit](https://github.com/open-webui/open-webui/commit/e6031ea6daec897eddbcf0c8b03c2d6fad2b506e)
|
||||
|
||||
## [0.11.1] - 2026-08-25
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ from __future__ import annotations
|
|||
import errno
|
||||
from enum import Enum
|
||||
|
||||
|
||||
_ERRNO_MESSAGES = {
|
||||
errno.ENAMETOOLONG: 'File name is too long.',
|
||||
errno.ENOSPC: 'The server is out of storage space.',
|
||||
|
|
@ -121,6 +120,7 @@ class ERROR_MESSAGES(str, Enum):
|
|||
AUTOMATION_COUNT_REQUIRES_DTSTART = (
|
||||
'RRULE with COUNT requires an explicit DTSTART line to anchor the occurrence window'
|
||||
)
|
||||
CALENDAR_RRULE_TOO_FREQUENT = 'Recurring events cannot repeat more often than daily'
|
||||
|
||||
FEATURE_DISABLED = lambda name='': f'{name} is disabled'
|
||||
INPUT_TOO_LONG = lambda size='': f'Input prompt exceeds maximum length of {size}'
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
|
@ -321,6 +322,45 @@ elif 'sqlite' in SQLALCHEMY_DATABASE_URL:
|
|||
|
||||
def _apply_sqlite_pragmas(dbapi_connection):
|
||||
"""Apply all configured SQLite PRAGMAs to a raw DBAPI connection."""
|
||||
# SQLite LIKE folds ASCII only; SQLAlchemy SQLite ILIKE compiles to lower(x) LIKE lower(?).
|
||||
compiled_patterns = {}
|
||||
|
||||
def like(pattern, value, escape=None):
|
||||
if pattern is None or value is None:
|
||||
return None
|
||||
|
||||
pattern = str(pattern).lower()
|
||||
escape = str(escape).lower() if escape is not None else None
|
||||
key = (pattern, escape)
|
||||
compiled = compiled_patterns.get(key)
|
||||
if compiled is False:
|
||||
return False
|
||||
if compiled is None:
|
||||
regex = []
|
||||
escaped = False
|
||||
for char in pattern:
|
||||
if escape and not escaped and char == escape:
|
||||
escaped = True
|
||||
continue
|
||||
regex.append(
|
||||
'.*' if not escaped and char == '%' else '.' if not escaped and char == '_' else re.escape(char)
|
||||
)
|
||||
escaped = False
|
||||
if escaped:
|
||||
compiled = False
|
||||
if len(compiled_patterns) >= 512:
|
||||
compiled_patterns.clear()
|
||||
compiled_patterns[key] = compiled
|
||||
return False
|
||||
compiled = re.compile(''.join(regex), re.DOTALL)
|
||||
if len(compiled_patterns) >= 512:
|
||||
compiled_patterns.clear()
|
||||
compiled_patterns[key] = compiled
|
||||
|
||||
return compiled.fullmatch(str(value).lower()) is not None
|
||||
|
||||
dbapi_connection.create_function('like', 2, like, deterministic=True)
|
||||
dbapi_connection.create_function('like', 3, like, deterministic=True)
|
||||
cursor = dbapi_connection.cursor()
|
||||
if DATABASE_ENABLE_SQLITE_WAL:
|
||||
cursor.execute('PRAGMA journal_mode=WAL')
|
||||
|
|
|
|||
|
|
@ -2980,6 +2980,11 @@ async def check_db_health():
|
|||
|
||||
|
||||
# --- static assets & files ---
|
||||
# Windows registry entries can override these with text/plain, which breaks module and wasm loading
|
||||
mimetypes.add_type('text/javascript', '.js')
|
||||
mimetypes.add_type('text/javascript', '.mjs')
|
||||
mimetypes.add_type('application/wasm', '.wasm')
|
||||
|
||||
# Serve build-time static assets (CSS, JS, images, favicon, etc.)
|
||||
app.mount('/static', StaticFiles(directory=STATIC_DIR), name='static')
|
||||
|
||||
|
|
@ -3025,7 +3030,6 @@ def swagger_ui_html(*args, **kwargs):
|
|||
applications.get_swagger_ui_html = swagger_ui_html
|
||||
|
||||
if os.path.exists(FRONTEND_BUILD_DIR):
|
||||
mimetypes.add_type('text/javascript', '.js')
|
||||
pyodide_dir = FRONTEND_BUILD_DIR / 'pyodide'
|
||||
if os.path.exists(pyodide_dir):
|
||||
app.mount('/pyodide', CORSStaticFiles(directory=pyodide_dir), name='pyodide')
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ from typing import Optional
|
|||
from uuid import uuid4
|
||||
|
||||
from open_webui.internal.db import Base, get_async_db_context
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.users import User, UserModel, UserResponse
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from open_webui.utils.automations import rrule_interval_seconds
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
|
|
@ -26,6 +28,7 @@ from sqlalchemy import (
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
MIN_CALENDAR_RRULE_INTERVAL_SECONDS = 24 * 60 * 60
|
||||
|
||||
|
||||
####################
|
||||
|
|
@ -191,6 +194,20 @@ class CalendarEventForm(BaseModel):
|
|||
meta: Optional[dict] = None
|
||||
attendees: Optional[list[dict]] = None
|
||||
|
||||
@field_validator('rrule')
|
||||
@classmethod
|
||||
def reject_sub_daily_rrule(cls, value: Optional[str]) -> Optional[str]:
|
||||
if value:
|
||||
try:
|
||||
interval = rrule_interval_seconds(value)
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise ValueError(ERROR_MESSAGES.AUTOMATION_INVALID_RRULE(e))
|
||||
if interval is not None and interval < MIN_CALENDAR_RRULE_INTERVAL_SECONDS:
|
||||
raise ValueError(ERROR_MESSAGES.CALENDAR_RRULE_TOO_FREQUENT)
|
||||
return value
|
||||
|
||||
|
||||
class CalendarEventUpdateForm(BaseModel):
|
||||
calendar_id: Optional[str] = None
|
||||
|
|
@ -207,6 +224,20 @@ class CalendarEventUpdateForm(BaseModel):
|
|||
is_cancelled: Optional[bool] = None
|
||||
attendees: Optional[list[dict]] = None
|
||||
|
||||
@field_validator('rrule')
|
||||
@classmethod
|
||||
def reject_sub_daily_rrule(cls, value: Optional[str]) -> Optional[str]:
|
||||
if value:
|
||||
try:
|
||||
interval = rrule_interval_seconds(value)
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise ValueError(ERROR_MESSAGES.AUTOMATION_INVALID_RRULE(e))
|
||||
if interval is not None and interval < MIN_CALENDAR_RRULE_INTERVAL_SECONDS:
|
||||
raise ValueError(ERROR_MESSAGES.CALENDAR_RRULE_TOO_FREQUENT)
|
||||
return value
|
||||
|
||||
|
||||
class RSVPForm(BaseModel):
|
||||
status: str # 'accepted' | 'declined' | 'tentative' | 'pending'
|
||||
|
|
@ -533,7 +564,8 @@ class CalendarEventTable:
|
|||
& (CalendarEvent.start_at < end)
|
||||
& or_(
|
||||
CalendarEvent.end_at.is_(None) & (CalendarEvent.start_at >= start),
|
||||
CalendarEvent.end_at.isnot(None) & (CalendarEvent.end_at > start),
|
||||
CalendarEvent.end_at.isnot(None)
|
||||
& ((CalendarEvent.end_at > start) | (CalendarEvent.start_at >= start)),
|
||||
)
|
||||
),
|
||||
# Recurring: fetch all (expansion in Python)
|
||||
|
|
|
|||
|
|
@ -362,7 +362,7 @@ class MessageStats(BaseModel):
|
|||
token_count: int | None = None
|
||||
timestamp: int | None = None
|
||||
rating: int | None = None # Derived from message.annotation.rating
|
||||
tags: list[str | None] = None # Derived from message.annotation.tags
|
||||
tags: list[str] | None = None # Derived from message.annotation.tags
|
||||
|
||||
|
||||
class ChatHistoryStats(BaseModel):
|
||||
|
|
@ -2558,7 +2558,7 @@ class ChatTable:
|
|||
file_ids: list[str],
|
||||
user_id: str,
|
||||
db: AsyncSession | None = None,
|
||||
) -> list[ChatFileModel | None]:
|
||||
) -> list[ChatFileModel] | None:
|
||||
if not file_ids:
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ class ModelForm(BaseModel):
|
|||
name: str
|
||||
meta: ModelMeta
|
||||
params: ModelParams
|
||||
access_grants: list[dict | None] = None
|
||||
access_grants: list[dict] | None = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
|
|
@ -191,7 +191,7 @@ class ModelsTable:
|
|||
async def _to_model_model(
|
||||
self,
|
||||
model: Model,
|
||||
access_grants: list[AccessGrantModel | None] = None,
|
||||
access_grants: list[AccessGrantModel] | None = None,
|
||||
db: AsyncSession | None = None,
|
||||
) -> ModelModel:
|
||||
if isinstance(model.meta, dict):
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ class PromptModel(BaseModel):
|
|||
content: str
|
||||
data: dict | None = None
|
||||
meta: dict | None = None
|
||||
tags: list[str | None] = None
|
||||
tags: list[str] | None = None
|
||||
is_active: bool | None = True
|
||||
version_id: str | None = None
|
||||
created_at: int | None = None
|
||||
|
|
@ -86,8 +86,8 @@ class PromptForm(BaseModel):
|
|||
content: str
|
||||
data: dict | None = None
|
||||
meta: dict | None = None
|
||||
tags: list[str | None] = None
|
||||
access_grants: list[dict | None] = None
|
||||
tags: list[str] | None = None
|
||||
access_grants: list[dict] | None = None
|
||||
version_id: str | None = None # Active version
|
||||
commit_message: str | None = None # For history tracking
|
||||
is_production: bool | None = True # Whether to set new version as production
|
||||
|
|
@ -100,7 +100,7 @@ class PromptsTable:
|
|||
async def _to_prompt_model(
|
||||
self,
|
||||
prompt: Prompt,
|
||||
access_grants: list[AccessGrantModel | None] = None,
|
||||
access_grants: list[AccessGrantModel] | None = None,
|
||||
db: AsyncSession | None = None,
|
||||
) -> PromptModel:
|
||||
prompt_model = PromptModel.model_validate(prompt)
|
||||
|
|
@ -334,8 +334,9 @@ class PromptsTable:
|
|||
tag_lower = tag.lower()
|
||||
|
||||
if dialect_name == 'sqlite':
|
||||
tag_lower = tag.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
|
||||
tag_clause = text(
|
||||
'EXISTS (SELECT 1 FROM json_each(prompt.tags) t WHERE LOWER(t.value) = :tag_val)'
|
||||
"EXISTS (SELECT 1 FROM json_each(prompt.tags) t WHERE t.value LIKE :tag_val ESCAPE '\\')"
|
||||
)
|
||||
elif dialect_name == 'postgresql':
|
||||
tag_clause = text(
|
||||
|
|
@ -557,7 +558,7 @@ class PromptsTable:
|
|||
prompt_id: str,
|
||||
name: str,
|
||||
command: str,
|
||||
tags: list[str | None] = None,
|
||||
tags: list[str] | None = None,
|
||||
db: AsyncSession | None = None,
|
||||
) -> PromptModel | None:
|
||||
"""Update only name, command, and tags (no history created)."""
|
||||
|
|
|
|||
|
|
@ -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,7 +367,9 @@ class UsersTable:
|
|||
query = select(User).where(sub_expr == sub)
|
||||
# SQLite preserves JSON numeric type here; Postgres ->> already compares numeric JSON as text.
|
||||
if session.get_bind().dialect.name == 'sqlite' and sub.isdecimal():
|
||||
query = select(User).where(or_(sub_expr == sub, sub_expr == int(sub)))
|
||||
sub_int = int(sub)
|
||||
if str(sub_int) == sub and sub_int <= 2**63 - 1:
|
||||
query = select(User).where(or_(sub_expr == sub, sub_expr == sub_int))
|
||||
row = (await session.execute(query)).scalars().first()
|
||||
return UserModel.model_validate(row) if row else None
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ async def search_brave(
|
|||
api_key: str,
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: list[str | None] | None = None,
|
||||
filter_list: list[str] | None = None,
|
||||
) -> list[SearchResult]:
|
||||
"""Query the Brave Web Search API and return normalised results.
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ log = logging.getLogger(__name__)
|
|||
def search_duckduckgo(
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: list[str | None] = None,
|
||||
filter_list: list[str] | None = None,
|
||||
concurrent_requests: int | None = None,
|
||||
backend: str | None = 'auto',
|
||||
) -> list[SearchResult]:
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ async def search_google_pse(
|
|||
search_engine_id: str,
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: list[str | None] | None = None,
|
||||
filter_list: list[str] | None = None,
|
||||
referer: str | None = None,
|
||||
) -> list[SearchResult]:
|
||||
"""Query Google Programmable Search Engine with automatic pagination.
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ def search_microsoft_web_iq(
|
|||
api_key: str,
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: list[str | None] | None = None,
|
||||
filter_list: list[str] | None = None,
|
||||
language: str = 'en',
|
||||
user=None,
|
||||
) -> list[SearchResult]:
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
|||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def search_mojeek(api_key: str, query: str, count: int, filter_list: list[str | None] = None) -> list[SearchResult]:
|
||||
def search_mojeek(api_key: str, query: str, count: int, filter_list: list[str] | None = None) -> list[SearchResult]:
|
||||
"""Search using Mojeek's Search API and return the results as a list of SearchResult objects.
|
||||
|
||||
Args:
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ async def search_openserp(
|
|||
base_url: str,
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: list[str | None] | None = None,
|
||||
filter_list: list[str] | None = None,
|
||||
) -> list[SearchResult]:
|
||||
"""Query an OpenSERP instance and return normalised results.
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ async def search_searxng(
|
|||
query_url: str,
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: list[str | None] | None = None,
|
||||
filter_list: list[str] | None = None,
|
||||
**kwargs,
|
||||
) -> list[SearchResult]:
|
||||
"""Query a SearXNG instance and return results sorted by relevance score.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ async def search_serper(
|
|||
api_key: str,
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: list[str | None] | None = None,
|
||||
filter_list: list[str] | None = None,
|
||||
) -> list[SearchResult]:
|
||||
"""Query the serper.dev Google Search API and return normalised results.
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ async def search_serphouse(
|
|||
domain: str,
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: list[str | None] | None = None,
|
||||
filter_list: list[str] | None = None,
|
||||
) -> list[SearchResult]:
|
||||
"""Query SERPHouse and return normalised organic results."""
|
||||
session = await get_session()
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ def search_serply(
|
|||
limit: int = 10,
|
||||
device_type: str = 'desktop',
|
||||
proxy_location: str = 'US',
|
||||
filter_list: list[str | None] = None,
|
||||
filter_list: list[str] | None = None,
|
||||
) -> list[SearchResult]:
|
||||
"""Search using serper.dev's API and return the results as a list of SearchResult objects.
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ async def search_serpstack(
|
|||
api_key: str,
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: list[str | None] | None = None,
|
||||
filter_list: list[str] | None = None,
|
||||
https_enabled: bool = True,
|
||||
) -> list[SearchResult]:
|
||||
"""Query the serpstack.com API and return normalised results.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ def search_tavily(
|
|||
api_key: str,
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: list[str | None] = None,
|
||||
filter_list: list[str] | None = None,
|
||||
# **kwargs,
|
||||
) -> list[SearchResult]:
|
||||
"""Search using Tavily's Search API and return the results as a list of SearchResult objects.
|
||||
|
|
|
|||
|
|
@ -1242,7 +1242,7 @@ async def post_new_message(
|
|||
except Exception as e:
|
||||
log.debug(e)
|
||||
|
||||
active_user_ids = get_user_ids_from_room(f'channel:{channel.id}')
|
||||
active_user_ids = await get_user_ids_from_room(f'channel:{channel.id}')
|
||||
|
||||
# NOTE: We intentionally do NOT pass db to background_handler.
|
||||
# Background tasks should manage their own short-lived sessions to avoid
|
||||
|
|
|
|||
|
|
@ -731,7 +731,7 @@ async def set_code_execution_config(
|
|||
class ModelsConfigForm(BaseModel):
|
||||
DEFAULT_MODELS: str | None
|
||||
DEFAULT_PINNED_MODELS: str | None
|
||||
MODEL_ORDER_LIST: list[str | None]
|
||||
MODEL_ORDER_LIST: list[str] | None
|
||||
DEFAULT_MODEL_METADATA: dict | None = None
|
||||
DEFAULT_MODEL_PARAMS: dict | None = None
|
||||
|
||||
|
|
|
|||
|
|
@ -552,7 +552,12 @@ async def upload_image(request, image_data, content_type, metadata, user, db=Non
|
|||
)
|
||||
|
||||
url = request.app.url_path_for('get_file_content_by_id', id=file_item.id)
|
||||
return file_item, url
|
||||
return file_item, {
|
||||
'id': file_item.id,
|
||||
'url': url,
|
||||
'name': (file_item.meta or {}).get('name') or file_item.filename,
|
||||
'content_type': (file_item.meta or {}).get('content_type'),
|
||||
}
|
||||
|
||||
|
||||
@router.post('/generations')
|
||||
|
|
@ -668,8 +673,8 @@ async def image_generations(
|
|||
else:
|
||||
image_data, content_type = await get_image_data(image['b64_json'])
|
||||
|
||||
_, url = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
|
||||
images.append({'url': url})
|
||||
_, image_file = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
|
||||
images.append(image_file)
|
||||
return images
|
||||
|
||||
elif image_config.IMAGE_GENERATION_ENGINE == 'gemini':
|
||||
|
|
@ -712,21 +717,21 @@ async def image_generations(
|
|||
if model.endswith(':predict'):
|
||||
for image in res['predictions']:
|
||||
image_data, content_type = await get_image_data(image['bytesBase64Encoded'])
|
||||
_, url = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
|
||||
images.append({'url': url})
|
||||
_, image_file = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
|
||||
images.append(image_file)
|
||||
elif model.endswith(':generateContent'):
|
||||
for image in res['candidates']:
|
||||
for part in image['content']['parts']:
|
||||
if part.get('inlineData', {}).get('data'):
|
||||
image_data, content_type = await get_image_data(part['inlineData']['data'])
|
||||
_, url = await upload_image(
|
||||
_, image_file = await upload_image(
|
||||
request,
|
||||
image_data,
|
||||
content_type,
|
||||
{**data, **metadata},
|
||||
user,
|
||||
)
|
||||
images.append({'url': url})
|
||||
images.append(image_file)
|
||||
|
||||
return images
|
||||
|
||||
|
|
@ -776,14 +781,14 @@ async def image_generations(
|
|||
headers,
|
||||
trusted_base_url=image_config.COMFYUI_BASE_URL,
|
||||
)
|
||||
_, url = await upload_image(
|
||||
_, image_file = await upload_image(
|
||||
request,
|
||||
image_data,
|
||||
content_type,
|
||||
{**form_data.model_dump(exclude_none=True), **metadata},
|
||||
user,
|
||||
)
|
||||
images.append({'url': url})
|
||||
images.append(image_file)
|
||||
return images
|
||||
elif image_config.IMAGE_GENERATION_ENGINE == 'automatic1111' or image_config.IMAGE_GENERATION_ENGINE == '':
|
||||
# Automatic1111 holds one checkpoint instance-wide, so set_image_model
|
||||
|
|
@ -823,14 +828,14 @@ async def image_generations(
|
|||
|
||||
for image in res['images']:
|
||||
image_data, content_type = await get_image_data(image)
|
||||
_, url = await upload_image(
|
||||
_, image_file = await upload_image(
|
||||
request,
|
||||
image_data,
|
||||
content_type,
|
||||
{**data, 'info': res['info'], **metadata},
|
||||
user,
|
||||
)
|
||||
images.append({'url': url})
|
||||
images.append(image_file)
|
||||
return images
|
||||
except Exception as e:
|
||||
error = e
|
||||
|
|
@ -1039,8 +1044,8 @@ async def image_edits(
|
|||
else:
|
||||
image_data, content_type = await get_image_data(image['b64_json'])
|
||||
|
||||
_, url = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
|
||||
images.append({'url': url})
|
||||
_, image_file = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
|
||||
images.append(image_file)
|
||||
return images
|
||||
|
||||
elif image_config.IMAGE_EDIT_ENGINE == 'gemini':
|
||||
|
|
@ -1089,14 +1094,14 @@ async def image_edits(
|
|||
for part in image['content']['parts']:
|
||||
if part.get('inlineData', {}).get('data'):
|
||||
image_data, content_type = await get_image_data(part['inlineData']['data'])
|
||||
_, url = await upload_image(
|
||||
_, image_file = await upload_image(
|
||||
request,
|
||||
image_data,
|
||||
content_type,
|
||||
{**data, **metadata},
|
||||
user,
|
||||
)
|
||||
images.append({'url': url})
|
||||
images.append(image_file)
|
||||
|
||||
return images
|
||||
|
||||
|
|
@ -1173,14 +1178,14 @@ async def image_edits(
|
|||
headers,
|
||||
trusted_base_url=image_config.IMAGES_EDIT_COMFYUI_BASE_URL,
|
||||
)
|
||||
_, url = await upload_image(
|
||||
_, image_file = await upload_image(
|
||||
request,
|
||||
image_data,
|
||||
content_type,
|
||||
{**form_data.model_dump(exclude_none=True), **metadata},
|
||||
user,
|
||||
)
|
||||
images.append({'url': url})
|
||||
images.append(image_file)
|
||||
|
||||
return images
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -1074,7 +1074,7 @@ async def update_external_knowledge_source(
|
|||
|
||||
|
||||
class KnowledgeFilesResponse(KnowledgeResponse):
|
||||
files: list[FileMetadataResponse | None] = None
|
||||
files: list[FileMetadataResponse] | None = None
|
||||
write_access: bool | None = False
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ from open_webui.env import (
|
|||
ENABLE_FORWARD_USER_INFO_HEADERS,
|
||||
FORWARD_SESSION_INFO_HEADER_CHAT_ID,
|
||||
MODELS_CACHE_TTL,
|
||||
REDIS_KEY_PREFIX,
|
||||
)
|
||||
from open_webui.events import EVENTS, publish_event, publish_model_provider_request_failed
|
||||
from open_webui.internal.db import get_async_session
|
||||
|
|
@ -56,6 +57,7 @@ log = logging.getLogger(__name__)
|
|||
# in ZlibError. See https://github.com/aio-libs/aiohttp/issues/4462.
|
||||
_STRIP_PROXY_HEADERS = frozenset({'Content-Encoding', 'Content-Length', 'Transfer-Encoding'})
|
||||
_MODEL_LIST_TIMEOUT = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
|
||||
BASE_MODELS_CACHE_KEY = f'{REDIS_KEY_PREFIX}:models:base'
|
||||
|
||||
|
||||
def _clean_proxy_headers(raw_headers) -> dict:
|
||||
|
|
@ -319,6 +321,9 @@ async def update_config(
|
|||
)
|
||||
|
||||
await get_all_models.cache.clear()
|
||||
redis = getattr(request.app.state, 'redis', None)
|
||||
if redis is not None:
|
||||
await redis.delete(BASE_MODELS_CACHE_KEY)
|
||||
request.app.state.BASE_MODELS = []
|
||||
request.app.state.OLLAMA_MODELS = {}
|
||||
models = getattr(request.app.state, 'MODELS', None)
|
||||
|
|
@ -970,12 +975,12 @@ class GenerateCompletionForm(BaseModel):
|
|||
model: str
|
||||
prompt: str | None = None
|
||||
suffix: str | None = None
|
||||
images: list[str | None] = None
|
||||
images: list[str] | None = None
|
||||
format: Union[dict, str | None] = None
|
||||
options: dict | None = None
|
||||
system: str | None = None
|
||||
template: str | None = None
|
||||
context: list[int | None] = None
|
||||
context: list[int] | None = None
|
||||
stream: bool | None = True
|
||||
raw: bool | None = None
|
||||
keep_alive: Union[int, str | None] = None
|
||||
|
|
@ -1026,8 +1031,8 @@ class ChatMessage(BaseModel):
|
|||
|
||||
role: str
|
||||
content: str | None = None
|
||||
tool_calls: list[dict | None] = None
|
||||
images: list[str | None] = None
|
||||
tool_calls: list[dict] | None = None
|
||||
images: list[str] | None = None
|
||||
model_config = ConfigDict(extra='allow')
|
||||
|
||||
@validator('content', pre=True)
|
||||
|
|
@ -1048,7 +1053,7 @@ class GenerateChatCompletionForm(BaseModel):
|
|||
template: str | None = None
|
||||
stream: bool | None = True
|
||||
keep_alive: Union[int, str | None] = None
|
||||
tools: list[dict | None] = None
|
||||
tools: list[dict] | None = None
|
||||
model_config = ConfigDict(extra='allow')
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ from open_webui.env import (
|
|||
ENABLE_OPENAI_API_PASSTHROUGH,
|
||||
FORWARD_SESSION_INFO_HEADER_CHAT_ID,
|
||||
MODELS_CACHE_TTL,
|
||||
REDIS_KEY_PREFIX,
|
||||
)
|
||||
from open_webui.events import EVENTS, publish_event, publish_model_provider_request_failed
|
||||
from open_webui.internal.db import get_async_session
|
||||
|
|
@ -76,6 +77,7 @@ log = logging.getLogger(__name__)
|
|||
_STRIP_PROXY_HEADERS = frozenset({'Content-Encoding', 'Content-Length', 'Transfer-Encoding'})
|
||||
_MODEL_LIST_TIMEOUT = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
|
||||
_UNSUPPORTED_OPENAI_MODEL_KEYWORDS = ('babbage', 'dall-e', 'davinci', 'embedding', 'tts', 'whisper')
|
||||
BASE_MODELS_CACHE_KEY = f'{REDIS_KEY_PREFIX}:models:base'
|
||||
|
||||
|
||||
def _clean_proxy_headers(raw_headers) -> dict:
|
||||
|
|
@ -345,6 +347,9 @@ async def get_openai_connection(idx: int) -> tuple[str, str, dict]:
|
|||
|
||||
async def clear_openai_model_cache(request: Request):
|
||||
await get_all_models.cache.clear()
|
||||
redis = getattr(request.app.state, 'redis', None)
|
||||
if redis is not None:
|
||||
await redis.delete(BASE_MODELS_CACHE_KEY)
|
||||
request.app.state.BASE_MODELS = []
|
||||
request.app.state.OPENAI_MODELS = {}
|
||||
models = getattr(request.app.state, 'MODELS', None)
|
||||
|
|
@ -571,14 +576,7 @@ async def update_config(request: Request, form_data: OpenAIConfigForm, user=Depe
|
|||
}
|
||||
)
|
||||
|
||||
await get_all_models.cache.clear()
|
||||
request.app.state.BASE_MODELS = []
|
||||
request.app.state.OPENAI_MODELS = {}
|
||||
models = getattr(request.app.state, 'MODELS', None)
|
||||
if hasattr(models, 'clear'):
|
||||
models.clear()
|
||||
else:
|
||||
request.app.state.MODELS = {}
|
||||
await clear_openai_model_cache(request)
|
||||
|
||||
await publish_event(
|
||||
request,
|
||||
|
|
|
|||
|
|
@ -69,6 +69,9 @@ async def process_pipeline_inlet_filter(request, payload, user, models):
|
|||
if 'pipeline' in model:
|
||||
sorted_filters.append(model)
|
||||
|
||||
if not sorted_filters:
|
||||
return payload
|
||||
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
for filter in sorted_filters:
|
||||
urlIdx = filter.get('urlIdx')
|
||||
|
|
@ -132,6 +135,9 @@ async def process_pipeline_outlet_filter(request, payload, user, models):
|
|||
if 'pipeline' in model:
|
||||
sorted_filters = [model] + sorted_filters
|
||||
|
||||
if not sorted_filters:
|
||||
return payload
|
||||
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
for filter in sorted_filters:
|
||||
urlIdx = filter.get('urlIdx')
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ class PromptVersionUpdateForm(BaseModel):
|
|||
class PromptMetadataForm(BaseModel):
|
||||
name: str
|
||||
command: str
|
||||
tags: list[str | None] = None
|
||||
tags: list[str] | None = None
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
|
|
|||
|
|
@ -790,7 +790,7 @@ class WebConfig(BaseModel):
|
|||
WEB_SEARCH_TRUST_ENV: bool | None = None
|
||||
WEB_SEARCH_RESULT_COUNT: int | None = None
|
||||
WEB_SEARCH_CONCURRENT_REQUESTS: int | None = None
|
||||
WEB_SEARCH_DOMAIN_FILTER_LIST: list[str | None] = []
|
||||
WEB_SEARCH_DOMAIN_FILTER_LIST: list[str] | None = []
|
||||
WEB_FETCH_MAX_CONTENT_LENGTH: int | None = None
|
||||
WEB_LOADER_CONCURRENT_REQUESTS: int | None = None
|
||||
BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: bool | None = None
|
||||
|
|
@ -848,7 +848,7 @@ class WebConfig(BaseModel):
|
|||
EXTERNAL_WEB_SEARCH_API_KEY: str | None = None
|
||||
EXTERNAL_WEB_LOADER_URL: str | None = None
|
||||
EXTERNAL_WEB_LOADER_API_KEY: str | None = None
|
||||
YOUTUBE_LOADER_LANGUAGE: list[str | None] = None
|
||||
YOUTUBE_LOADER_LANGUAGE: list[str] | None = None
|
||||
YOUTUBE_LOADER_PROXY_URL: str | None = None
|
||||
YOUTUBE_LOADER_TRANSLATION: str | None = None
|
||||
YANDEX_WEB_SEARCH_URL: str | None = None
|
||||
|
|
@ -938,7 +938,7 @@ class ConfigForm(BaseModel):
|
|||
FILE_MAX_COUNT: Union[int, str | None] = None
|
||||
FILE_IMAGE_COMPRESSION_WIDTH: Union[int, str | None] = None
|
||||
FILE_IMAGE_COMPRESSION_HEIGHT: Union[int, str | None] = None
|
||||
ALLOWED_FILE_EXTENSIONS: list[str | None] = None
|
||||
ALLOWED_FILE_EXTENSIONS: list[str] | None = None
|
||||
|
||||
# Integration settings
|
||||
ENABLE_GOOGLE_DRIVE_INTEGRATION: bool | None = None
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import logging
|
|||
import random
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import pycrdt as Y
|
||||
import socketio
|
||||
|
|
@ -47,6 +48,7 @@ from open_webui.utils.redis import (
|
|||
get_redis_connection,
|
||||
get_sentinels_from_env,
|
||||
)
|
||||
from socketio.packet import Packet
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -65,6 +67,17 @@ def get_room_sid_map(manager, namespace: str, room: str):
|
|||
return manager.rooms.get(namespace, {}).get(room)
|
||||
|
||||
|
||||
class JSONOnlyPacket(Packet):
|
||||
"""Packet class for JSON-serializable payloads only, skipping python-socketio's per-emit binary scan."""
|
||||
|
||||
uses_binary_events = False
|
||||
|
||||
@classmethod
|
||||
def reconstruct_binary(cls, data: Any, attachments: list[bytes]):
|
||||
"""Normalize client attachments to int lists, the form the Yjs handlers store and apply."""
|
||||
return super().reconstruct_binary(data, [list(attachment) for attachment in attachments])
|
||||
|
||||
|
||||
if WEBSOCKET_MANAGER == 'redis':
|
||||
sentinel_hosts = WEBSOCKET_SENTINEL_HOSTS or ''
|
||||
ws_redis_url = (
|
||||
|
|
@ -77,6 +90,7 @@ if WEBSOCKET_MANAGER == 'redis':
|
|||
cors_allowed_origins=SOCKETIO_CORS_ORIGINS,
|
||||
async_mode='asgi',
|
||||
json=SOCKETIO_JSON,
|
||||
serializer=JSONOnlyPacket,
|
||||
transports=(['websocket'] if ENABLE_WEBSOCKET_SUPPORT else ['polling']),
|
||||
allow_upgrades=ENABLE_WEBSOCKET_SUPPORT,
|
||||
always_connect=True,
|
||||
|
|
@ -91,6 +105,7 @@ else:
|
|||
cors_allowed_origins=SOCKETIO_CORS_ORIGINS,
|
||||
async_mode='asgi',
|
||||
json=SOCKETIO_JSON,
|
||||
serializer=JSONOnlyPacket,
|
||||
transports=(['websocket'] if ENABLE_WEBSOCKET_SUPPORT else ['polling']),
|
||||
allow_upgrades=ENABLE_WEBSOCKET_SUPPORT,
|
||||
always_connect=True,
|
||||
|
|
@ -175,6 +190,13 @@ YDOC_MANAGER = YdocManager(
|
|||
)
|
||||
|
||||
|
||||
def get_session_pool_batches():
|
||||
"""All session pool entries, in bounded batches for the Redis backing."""
|
||||
if WEBSOCKET_MANAGER == 'redis':
|
||||
return SESSION_POOL.scan_batches()
|
||||
return [list(SESSION_POOL.items())]
|
||||
|
||||
|
||||
async def periodic_session_pool_cleanup():
|
||||
"""Reap orphaned SESSION_POOL entries that missed heartbeats (e.g. crashed instance)."""
|
||||
retry_delay = random.uniform(WEBSOCKET_REDIS_LOCK_TIMEOUT / 2, WEBSOCKET_REDIS_LOCK_TIMEOUT)
|
||||
|
|
@ -192,14 +214,20 @@ async def periodic_session_pool_cleanup():
|
|||
break
|
||||
|
||||
now = int(time.time())
|
||||
for sid in list(SESSION_POOL.keys()):
|
||||
entry = SESSION_POOL.get(sid)
|
||||
if entry and now - entry.get('last_seen_at', 0) > SESSION_POOL_TIMEOUT:
|
||||
log.warning(f'Reaping orphaned session {sid} (user {entry.get("id")})')
|
||||
try:
|
||||
del SESSION_POOL[sid]
|
||||
except KeyError:
|
||||
pass
|
||||
for batch in get_session_pool_batches():
|
||||
expired = [
|
||||
sid
|
||||
for sid, entry in batch
|
||||
if entry and now - entry.get('last_seen_at', 0) > SESSION_POOL_TIMEOUT
|
||||
]
|
||||
if expired:
|
||||
log.warning('Reaping %d orphaned session(s) from the session pool', len(expired))
|
||||
if WEBSOCKET_MANAGER == 'redis':
|
||||
SESSION_POOL.delete_many(*expired)
|
||||
else:
|
||||
for sid in expired:
|
||||
SESSION_POOL.pop(sid, None)
|
||||
await asyncio.sleep(0) # don't hold the loop for the whole sweep
|
||||
|
||||
next_cleanup_at = time.monotonic() + SESSION_POOL_TIMEOUT
|
||||
lock_lost = False
|
||||
|
|
@ -283,6 +311,14 @@ def get_user_id_from_session_pool(sid):
|
|||
return None
|
||||
|
||||
|
||||
async def get_socket_session_user(sid: str) -> dict | None:
|
||||
"""Session user from this worker's local Socket.IO store; only locally connected sids are ever looked up."""
|
||||
try:
|
||||
return (await sio.get_session(sid)).get('user')
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
|
||||
def get_session_ids_from_room(room):
|
||||
"""Get all session IDs from a specific room."""
|
||||
members = get_room_sid_map(sio.manager, '/', room)
|
||||
|
|
@ -296,19 +332,9 @@ def get_session_ids_by_user_id(user_id: str) -> list[str]:
|
|||
return list(session_ids)
|
||||
|
||||
|
||||
def get_user_ids_from_room(room):
|
||||
active_session_ids = get_session_ids_from_room(room)
|
||||
|
||||
# Single pool lookup per session (each .get is a Redis round trip
|
||||
# when the session pool is Redis-backed).
|
||||
active_user_ids = list(
|
||||
{
|
||||
entry['id']
|
||||
for entry in (SESSION_POOL.get(session_id) for session_id in active_session_ids)
|
||||
if entry is not None
|
||||
}
|
||||
)
|
||||
return active_user_ids
|
||||
async def get_user_ids_from_room(room) -> set[str]:
|
||||
users = [await get_socket_session_user(session_id) for session_id in get_session_ids_from_room(room)]
|
||||
return {user['id'] for user in users if user}
|
||||
|
||||
|
||||
async def emit_to_users(event: str, data: dict, user_ids: list[str]):
|
||||
|
|
@ -364,7 +390,7 @@ async def disconnect_user_sessions(user_id: str):
|
|||
|
||||
@sio.on('usage')
|
||||
async def usage(sid, data):
|
||||
if sid in SESSION_POOL:
|
||||
if await get_socket_session_user(sid):
|
||||
model_id = data['model']
|
||||
# Record the timestamp for the last update
|
||||
current_time = int(time.time())
|
||||
|
|
@ -446,7 +472,7 @@ async def user_join(sid, data):
|
|||
|
||||
@sio.on('heartbeat')
|
||||
async def heartbeat(sid, data):
|
||||
user = SESSION_POOL.get(sid)
|
||||
user = await get_socket_session_user(sid)
|
||||
if user:
|
||||
SESSION_POOL[sid] = {**user, 'last_seen_at': int(time.time())}
|
||||
await Users.update_last_active_by_id(user['id'])
|
||||
|
|
@ -519,7 +545,7 @@ async def channel_events(sid, data):
|
|||
event_data = data['data']
|
||||
event_type = event_data['type']
|
||||
|
||||
user = SESSION_POOL.get(sid)
|
||||
user = await get_socket_session_user(sid)
|
||||
|
||||
if not user:
|
||||
return
|
||||
|
|
@ -559,15 +585,7 @@ async def get_folder_unread_counts(user_id: str) -> dict[str, int]:
|
|||
|
||||
@sio.on('events:chat')
|
||||
async def chat_events(sid, data):
|
||||
try:
|
||||
session = await sio.get_session(sid)
|
||||
user = session.get('user')
|
||||
except KeyError:
|
||||
user = None
|
||||
|
||||
if not user:
|
||||
user = SESSION_POOL.get(sid)
|
||||
|
||||
user = await get_socket_session_user(sid)
|
||||
if not user:
|
||||
return
|
||||
|
||||
|
|
@ -621,7 +639,7 @@ def normalize_document_id(document_id: str) -> str:
|
|||
@sio.on('ydoc:document:join')
|
||||
async def ydoc_document_join(sid, data):
|
||||
"""Handle user joining a document"""
|
||||
user = SESSION_POOL.get(sid)
|
||||
user = await get_socket_session_user(sid)
|
||||
if not user:
|
||||
return
|
||||
|
||||
|
|
@ -781,7 +799,7 @@ async def yjs_document_update(sid, data):
|
|||
return
|
||||
|
||||
# Verify write permission — room membership only proves read access
|
||||
user = SESSION_POOL.get(sid)
|
||||
user = await get_socket_session_user(sid)
|
||||
if not user:
|
||||
return
|
||||
|
||||
|
|
@ -851,7 +869,7 @@ async def yjs_document_update(sid, data):
|
|||
@sio.on('ydoc:document:leave')
|
||||
async def yjs_document_leave(sid, data):
|
||||
"""Handle user leaving a document"""
|
||||
user = SESSION_POOL.get(sid)
|
||||
user = await get_socket_session_user(sid)
|
||||
if not user: # authenticated session required (parity with sibling handlers)
|
||||
return
|
||||
try:
|
||||
|
|
@ -883,7 +901,7 @@ async def yjs_document_leave(sid, data):
|
|||
@sio.on('ydoc:awareness:update')
|
||||
async def yjs_awareness_update(sid, data):
|
||||
"""Handle awareness updates (cursors, selections, etc.)"""
|
||||
user = SESSION_POOL.get(sid)
|
||||
user = await get_socket_session_user(sid)
|
||||
if not user: # authenticated session required (parity with sibling handlers)
|
||||
return
|
||||
try:
|
||||
|
|
@ -911,9 +929,8 @@ async def disconnect(sid, reason=None):
|
|||
del SESSION_POOL[sid]
|
||||
|
||||
# Clean up USAGE_POOL entries for this session
|
||||
for model_id in list(USAGE_POOL.keys()):
|
||||
connections = USAGE_POOL.get(model_id)
|
||||
if connections and sid in connections:
|
||||
for model_id, connections in list(USAGE_POOL.items()):
|
||||
if sid in connections:
|
||||
del connections[sid]
|
||||
if not connections:
|
||||
del USAGE_POOL[model_id]
|
||||
|
|
@ -938,20 +955,26 @@ async def _make_channel_emitter(request_info):
|
|||
state = {'last_emit_at': 0.0, 'output': []}
|
||||
THROTTLE_INTERVAL = 0.15 # ~6 updates/sec
|
||||
|
||||
async def _emit_channel_update(content: str, done: bool = False, output: list | None = None):
|
||||
async def _emit_channel_update(
|
||||
content: str,
|
||||
done: bool = False,
|
||||
output: list | None = None,
|
||||
data: dict | None = None,
|
||||
):
|
||||
from open_webui.models.messages import MessageForm, Messages
|
||||
|
||||
msg = await Messages.get_message_by_id(message_id)
|
||||
if not msg or msg.channel_id != channel_id:
|
||||
return
|
||||
|
||||
update_form = MessageForm(content=content, data={'output': output} if output else None)
|
||||
update_data = data or ({'output': output} if output else None)
|
||||
update_form = MessageForm(content=content, data=update_data)
|
||||
if done:
|
||||
# Merge done flag into existing meta (preserve model_id etc.)
|
||||
existing_meta = msg.meta or {}
|
||||
update_form = MessageForm(
|
||||
content=content,
|
||||
data={'output': output} if output else None,
|
||||
data=update_data,
|
||||
meta={**existing_meta, 'done': True},
|
||||
)
|
||||
|
||||
|
|
@ -1000,6 +1023,29 @@ async def _make_channel_emitter(request_info):
|
|||
state['last_emit_at'] = now
|
||||
await _emit_channel_update(content, False, state['output'])
|
||||
|
||||
elif event_type in ('files', 'chat:message:files'):
|
||||
from open_webui.models.messages import Messages
|
||||
|
||||
files = event_data.get('data', {}).get('files', [])
|
||||
if not files:
|
||||
return
|
||||
|
||||
msg = await Messages.get_message_by_id(message_id)
|
||||
if not msg or msg.channel_id != channel_id:
|
||||
return
|
||||
|
||||
existing_files = (msg.data or {}).get('files')
|
||||
for file in files:
|
||||
if isinstance(file, dict) and file.get('id'):
|
||||
file['url'] = file['id']
|
||||
await Channels.add_file_to_channel_by_id(channel_id, file['id'], msg.user_id)
|
||||
await Channels.set_file_message_id_in_channel_by_id(channel_id, file['id'], message_id)
|
||||
|
||||
if isinstance(existing_files, list):
|
||||
files.extend(existing_files)
|
||||
|
||||
await _emit_channel_update(msg.content, data={'files': files})
|
||||
|
||||
elif event_type == 'chat:message:error':
|
||||
error = event_data.get('data', {}).get('error', {})
|
||||
error_content = error.get('content', 'An error occurred') if isinstance(error, dict) else str(error)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from open_webui.utils.json_codec import JSONCodec
|
|||
from open_webui.utils.redis import get_redis_connection
|
||||
|
||||
YDOC_KEY_PREFIX = f'{REDIS_KEY_PREFIX}:ydoc:documents'
|
||||
SCAN_BATCH_SIZE = 200
|
||||
|
||||
|
||||
class RedisLock:
|
||||
|
|
@ -112,6 +113,22 @@ class RedisDict:
|
|||
def items(self):
|
||||
return [(k, JSONCodec.loads(v)) for k, v in self.redis.hgetall(self.name).items()]
|
||||
|
||||
def scan_batches(self):
|
||||
"""Yield lists of (key, value) pairs via incremental HSCAN; a field may repeat across batches."""
|
||||
cursor = 0
|
||||
while True:
|
||||
cursor, batch = self.redis.hscan(self.name, cursor, count=SCAN_BATCH_SIZE)
|
||||
if batch:
|
||||
yield [(k, JSONCodec.loads(v)) for k, v in batch.items()]
|
||||
if cursor == 0:
|
||||
break
|
||||
|
||||
def delete_many(self, *keys):
|
||||
"""Delete fields in one HDEL; no keys is a no-op (HDEL rejects an empty field list)."""
|
||||
if keys:
|
||||
self.redis.hdel(self.name, *keys)
|
||||
self._last_signature = None
|
||||
|
||||
def set(self, mapping: dict):
|
||||
if not mapping:
|
||||
self.clear()
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ async def redis_task_command_listener(app):
|
|||
while True:
|
||||
pubsub = None
|
||||
try:
|
||||
# RedisCluster can't route a pubsub subscribe until initialize() fills its slot cache.
|
||||
await redis.initialize()
|
||||
|
||||
pubsub = redis.pubsub()
|
||||
await pubsub.subscribe(REDIS_PUBSUB_CHANNEL)
|
||||
reconnect_interval = REDIS_PUBSUB_RECONNECT_INTERVAL
|
||||
|
|
|
|||
|
|
@ -389,11 +389,16 @@ async def generate_image(
|
|||
images = await image_generations(
|
||||
request=__request__,
|
||||
form_data=CreateImageForm(prompt=prompt),
|
||||
metadata=(
|
||||
{'channel_id': __chat_id__.removeprefix('channel:'), 'message_id': __message_id__}
|
||||
if isinstance(__chat_id__, str) and __chat_id__.startswith('channel:')
|
||||
else None
|
||||
),
|
||||
user=user,
|
||||
)
|
||||
|
||||
# Prepare file entries for the images
|
||||
image_files = [{'type': 'image', 'url': img['url']} for img in images]
|
||||
image_files = [{'type': 'image', **img} for img in images]
|
||||
|
||||
# Persist files to DB if chat context is available
|
||||
if is_saved_chat_id(__chat_id__) and __message_id__ and images:
|
||||
|
|
@ -457,11 +462,16 @@ async def edit_image(
|
|||
images = await image_edits(
|
||||
request=__request__,
|
||||
form_data=EditImageForm(prompt=prompt, image=image_urls),
|
||||
metadata=(
|
||||
{'channel_id': __chat_id__.removeprefix('channel:'), 'message_id': __message_id__}
|
||||
if isinstance(__chat_id__, str) and __chat_id__.startswith('channel:')
|
||||
else None
|
||||
),
|
||||
user=user,
|
||||
)
|
||||
|
||||
# Prepare file entries for the images
|
||||
image_files = [{'type': 'image', 'url': img['url']} for img in images]
|
||||
image_files = [{'type': 'image', **img} for img in images]
|
||||
|
||||
# Persist files to DB if chat context is available
|
||||
if is_saved_chat_id(__chat_id__) and __message_id__ and images:
|
||||
|
|
|
|||
|
|
@ -877,7 +877,11 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
|
|||
yield f'event: content_block_delta\ndata: {JSONCodec.dumps(block_delta)}\n\n'.encode()
|
||||
|
||||
# Close the block once arguments form complete JSON
|
||||
if tool['started'] and not tool['stopped']:
|
||||
if (
|
||||
tool['started']
|
||||
and not tool['stopped']
|
||||
and (tool['arguments'].rstrip()[-1:] == '}' or tool['arguments'].lstrip()[:1] != '{')
|
||||
):
|
||||
try:
|
||||
JSONCodec.loads(tool['arguments'])
|
||||
tool['stopped'] = True
|
||||
|
|
|
|||
|
|
@ -6,17 +6,20 @@ from open_webui.utils.json_codec import JSONCodec
|
|||
ASK_USER_NAME = 'ask_user'
|
||||
|
||||
|
||||
def get_ask_user_tool_call(tool_calls: list[dict]) -> tuple[dict | None, str | None]:
|
||||
def get_ask_user_tool_calls(tool_calls: list[dict]) -> tuple[list[dict], str | None]:
|
||||
ask_user_calls = [
|
||||
tool_call for tool_call in tool_calls if tool_call.get('function', {}).get('name') == ASK_USER_NAME
|
||||
]
|
||||
if not ask_user_calls:
|
||||
return None, None
|
||||
return [], None
|
||||
if len(tool_calls) != 1:
|
||||
return ask_user_calls[0], 'Error: ask_user must be called by itself after research.'
|
||||
return (
|
||||
ask_user_calls,
|
||||
'Error: ask_user must be the only tool call, so it did not run. Call ask_user on its own.',
|
||||
)
|
||||
if len(ask_user_calls) != 1:
|
||||
return ask_user_calls[0], 'Error: only one ask_user call is allowed per turn.'
|
||||
return ask_user_calls[0], None
|
||||
return ask_user_calls, 'Error: only one ask_user call is allowed per turn.'
|
||||
return ask_user_calls, None
|
||||
|
||||
|
||||
def normalize_ask_user_request(arguments: dict) -> dict:
|
||||
|
|
@ -77,68 +80,70 @@ def normalize_ask_user_request(arguments: dict) -> dict:
|
|||
}
|
||||
|
||||
|
||||
def stage_ask_user_tool_call(
|
||||
def stage_ask_user_tool_calls(
|
||||
tool_calls: list[dict],
|
||||
output: list[dict],
|
||||
make_output_id: Callable[[str], str],
|
||||
) -> dict | None:
|
||||
tool_call, error = get_ask_user_tool_call(tool_calls)
|
||||
if not tool_call:
|
||||
return None
|
||||
) -> tuple[bool, str | None]:
|
||||
ask_user_calls, error = get_ask_user_tool_calls(tool_calls)
|
||||
if not ask_user_calls:
|
||||
return False, None
|
||||
|
||||
call_id = tool_call.get('id') or make_output_id('fc')
|
||||
raw_arguments = tool_call.get('function', {}).get('arguments', '{}')
|
||||
arguments = raw_arguments
|
||||
for tool_call in ask_user_calls:
|
||||
call_id = tool_call.get('id') or make_output_id('fc')
|
||||
raw_arguments = tool_call.get('function', {}).get('arguments', '{}')
|
||||
arguments = raw_arguments
|
||||
|
||||
if not error:
|
||||
try:
|
||||
parsed_arguments = JSONCodec.loads(raw_arguments or '{}')
|
||||
if not isinstance(parsed_arguments, dict):
|
||||
raise ValueError('ask_user arguments must be an object.')
|
||||
arguments = JSONCodec.dumps(normalize_ask_user_request(parsed_arguments))
|
||||
except (JSONCodec.JSONDecodeError, TypeError, ValueError) as exc:
|
||||
error = f'Error: {exc}'
|
||||
if not error:
|
||||
try:
|
||||
parsed_arguments = JSONCodec.loads(raw_arguments or '{}')
|
||||
if not isinstance(parsed_arguments, dict):
|
||||
raise ValueError('ask_user arguments must be an object.')
|
||||
arguments = JSONCodec.dumps(normalize_ask_user_request(parsed_arguments))
|
||||
except (JSONCodec.JSONDecodeError, TypeError, ValueError) as exc:
|
||||
error = f'Error: {exc}'
|
||||
|
||||
item = {
|
||||
'type': 'function_call',
|
||||
'id': call_id or make_output_id('fc'),
|
||||
'call_id': call_id,
|
||||
'name': ASK_USER_NAME,
|
||||
'arguments': arguments,
|
||||
'status': 'completed' if error else 'pending',
|
||||
}
|
||||
item = {
|
||||
'type': 'function_call',
|
||||
'id': call_id or make_output_id('fc'),
|
||||
'call_id': call_id,
|
||||
'name': ASK_USER_NAME,
|
||||
'arguments': arguments,
|
||||
'status': 'completed' if error else 'pending',
|
||||
}
|
||||
|
||||
existing_item = next(
|
||||
(
|
||||
existing
|
||||
for existing in output
|
||||
if existing.get('type') == 'function_call'
|
||||
and (
|
||||
existing.get('call_id') == call_id
|
||||
or existing.get('id') == tool_call.get('id')
|
||||
or (
|
||||
not existing.get('call_id')
|
||||
and existing.get('name') == ASK_USER_NAME
|
||||
and existing.get('status') not in {'rejected', 'failed'}
|
||||
existing_item = next(
|
||||
(
|
||||
existing
|
||||
for existing in output
|
||||
if existing.get('type') == 'function_call'
|
||||
and (
|
||||
existing.get('call_id') == call_id
|
||||
or existing.get('id') == tool_call.get('id')
|
||||
or (
|
||||
not existing.get('call_id')
|
||||
and existing.get('name') == ASK_USER_NAME
|
||||
and existing.get('status') not in {'rejected', 'failed'}
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if existing_item:
|
||||
existing_item.update(item)
|
||||
else:
|
||||
output.append(item)
|
||||
|
||||
if error:
|
||||
output.append(
|
||||
{
|
||||
'type': 'function_call_output',
|
||||
'id': make_output_id('fco'),
|
||||
'call_id': call_id,
|
||||
'output': [{'type': 'input_text', 'text': error}],
|
||||
'status': 'completed',
|
||||
}
|
||||
),
|
||||
None,
|
||||
)
|
||||
if existing_item:
|
||||
existing_item.update(item)
|
||||
else:
|
||||
output.append(item)
|
||||
|
||||
return {'call_id': call_id, 'error': error, 'item': item}
|
||||
# Every invalid call needs its own result, or the UI waits on it forever.
|
||||
if error:
|
||||
output.append(
|
||||
{
|
||||
'type': 'function_call_output',
|
||||
'id': make_output_id('fco'),
|
||||
'call_id': call_id,
|
||||
'output': [{'type': 'input_text', 'text': error}],
|
||||
'status': 'completed',
|
||||
}
|
||||
)
|
||||
|
||||
return True, error
|
||||
|
|
|
|||
|
|
@ -185,6 +185,7 @@ def rrule_interval_seconds(s: str) -> Optional[int]:
|
|||
"""
|
||||
if 'COUNT=1' in s:
|
||||
return None
|
||||
s = '\n'.join(line for line in s.splitlines() if not line.upper().startswith('DTSTART')) or s
|
||||
now = datetime.now()
|
||||
rule = _parse_rule(s, now)
|
||||
first = rule.after(now)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import datetime as dt
|
|||
import logging
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from dateutil.rrule import rrulestr
|
||||
from open_webui.utils.automations import _resolve_tz
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -25,18 +26,17 @@ def expand_recurring_event(
|
|||
Takes an event dict (from CalendarEventModel.model_dump()) and produces
|
||||
one dict per occurrence, with adjusted start_at / end_at.
|
||||
"""
|
||||
from dateutil.rrule import rrulestr
|
||||
|
||||
rrule_str = event_dict.get('rrule')
|
||||
if not rrule_str:
|
||||
return [event_dict]
|
||||
if 'EXRULE' in rrule_str.upper():
|
||||
log.warning(f'EXRULE is not supported for event {event_dict.get("id")}: {rrule_str}')
|
||||
return [event_dict]
|
||||
|
||||
user_timezone = _resolve_tz(tz)
|
||||
|
||||
def to_local_datetime(timestamp_ns: int) -> dt.datetime:
|
||||
if user_timezone:
|
||||
return dt.datetime.fromtimestamp(timestamp_ns / 1_000_000_000, tz=user_timezone).replace(tzinfo=None)
|
||||
return dt.datetime.fromtimestamp(timestamp_ns / 1_000_000_000)
|
||||
return dt.datetime.fromtimestamp(timestamp_ns / 1_000_000_000, tz=user_timezone).replace(tzinfo=None)
|
||||
|
||||
range_start = to_local_datetime(range_start_ns)
|
||||
range_end = to_local_datetime(range_end_ns)
|
||||
|
|
@ -44,10 +44,11 @@ def expand_recurring_event(
|
|||
|
||||
original_start_ns = event_dict['start_at']
|
||||
original_start = to_local_datetime(original_start_ns)
|
||||
rule_str = '\n'.join(line for line in rrule_str.splitlines() if not line.upper().startswith('DTSTART')) or rrule_str
|
||||
|
||||
try:
|
||||
# Anchor to the event's real start so day-of-week / day-of-month are correct
|
||||
rule = rrulestr(rrule_str, dtstart=original_start, ignoretz=True)
|
||||
rule = rrulestr(rule_str, dtstart=original_start, ignoretz=True)
|
||||
except Exception:
|
||||
log.warning(f'Failed to parse RRULE for event {event_dict.get("id")}: {rrule_str}')
|
||||
return [event_dict]
|
||||
|
|
@ -56,13 +57,13 @@ def expand_recurring_event(
|
|||
duration_ns = (original_end_ns - original_start_ns) if original_end_ns else None
|
||||
|
||||
instances = []
|
||||
occurrence_start = rule.after(scan_start, inc=True)
|
||||
previous_start = None
|
||||
for occurrence_start in rule.xafter(scan_start, count=max_instances, inc=True):
|
||||
if occurrence_start >= range_end or occurrence_start == previous_start:
|
||||
break
|
||||
previous_start = occurrence_start
|
||||
|
||||
while occurrence_start and occurrence_start < range_end and len(instances) < max_instances:
|
||||
if user_timezone:
|
||||
instance_start_ns = int(occurrence_start.replace(tzinfo=user_timezone).timestamp() * 1_000_000_000)
|
||||
else:
|
||||
instance_start_ns = int(occurrence_start.timestamp() * 1_000_000_000)
|
||||
instance_start_ns = int(occurrence_start.replace(tzinfo=user_timezone).timestamp() * 1_000_000_000)
|
||||
|
||||
if instance_start_ns >= range_start_ns:
|
||||
instance = {
|
||||
|
|
@ -73,8 +74,6 @@ def expand_recurring_event(
|
|||
}
|
||||
instances.append(instance)
|
||||
|
||||
occurrence_start = rule.after(occurrence_start)
|
||||
|
||||
return instances
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ def _safe_field(key: str, definition: dict[str, Any]) -> dict[str, Any]:
|
|||
'type',
|
||||
}
|
||||
field = {'key': key}
|
||||
for field_key in allowed_keys:
|
||||
for field_key in sorted(allowed_keys):
|
||||
if field_key in definition:
|
||||
field[field_key] = definition[field_key]
|
||||
|
||||
|
|
|
|||
|
|
@ -54,6 +54,16 @@ _IMAGE_MIME_FALLBACK = {
|
|||
async def get_image_base64_from_url(url: str, user=None) -> Optional[str]:
|
||||
try:
|
||||
if url.startswith('http'):
|
||||
from open_webui.models.config import Config
|
||||
|
||||
max_bytes = None
|
||||
try:
|
||||
max_size_mb = int(await Config.get('rag.file.max_size') or 0)
|
||||
except (TypeError, ValueError):
|
||||
max_size_mb = 0
|
||||
if max_size_mb > 0:
|
||||
max_bytes = max_size_mb * 1024 * 1024
|
||||
|
||||
# Validate URL to prevent SSRF attacks against local/private networks.
|
||||
# allow_redirects=False prevents redirect-based SSRF: validate_url() is
|
||||
# called only on the originally-submitted URL; following 3xx redirects
|
||||
|
|
@ -67,7 +77,13 @@ async def get_image_base64_from_url(url: str, user=None) -> Optional[str]:
|
|||
url, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
image_data = await response.read()
|
||||
image_data = bytearray()
|
||||
total = 0
|
||||
async for chunk in response.content.iter_chunked(64 * 1024):
|
||||
total += len(chunk)
|
||||
if max_bytes is not None and total > max_bytes:
|
||||
return None
|
||||
image_data.extend(chunk)
|
||||
encoded_string = base64.b64encode(image_data).decode('utf-8')
|
||||
content_type = response.headers.get('Content-Type', 'image/png')
|
||||
return f'data:{content_type};base64,{encoded_string}'
|
||||
|
|
@ -86,13 +102,14 @@ async def get_image_url_from_base64(request, base64_image_string, metadata, user
|
|||
# Extract base64 image data from the line
|
||||
image_data, content_type = await get_image_data(base64_image_string)
|
||||
if image_data is not None:
|
||||
_, image_url = await upload_image(
|
||||
_, image_file = await upload_image(
|
||||
request,
|
||||
image_data,
|
||||
content_type,
|
||||
metadata,
|
||||
user,
|
||||
)
|
||||
image_url = image_file['url']
|
||||
|
||||
return image_url
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -10,10 +10,16 @@ log = logging.getLogger(__name__)
|
|||
|
||||
class FilterContext:
|
||||
def __init__(self):
|
||||
self.active_filters = None
|
||||
self.valves_by_id = None
|
||||
self.function_valves = {}
|
||||
self.user_valves = {}
|
||||
|
||||
async def get_active_filters(self):
|
||||
if self.active_filters is None:
|
||||
self.active_filters = await Functions.get_active_filter_ids()
|
||||
return self.active_filters
|
||||
|
||||
async def get_function_valves(self, filter_ids, filter_id, Valves):
|
||||
if filter_id not in self.function_valves:
|
||||
if self.valves_by_id is None:
|
||||
|
|
@ -29,6 +35,12 @@ class FilterContext:
|
|||
return self.user_valves[user_valves_key]
|
||||
|
||||
|
||||
def get_filter_context(request):
|
||||
if not hasattr(request.state, 'filter_context'):
|
||||
request.state.filter_context = FilterContext()
|
||||
return request.state.filter_context
|
||||
|
||||
|
||||
async def get_user_valves(filter_id, user_id, UserValves):
|
||||
user_valves_data = await Functions.get_user_valves_by_id_and_user_id(filter_id, user_id)
|
||||
return UserValves(**(user_valves_data if user_valves_data else {}))
|
||||
|
|
@ -57,7 +69,7 @@ async def resolve_filter_pipeline(request, model: dict, enabled_filter_ids: list
|
|||
if not ENABLE_PLUGINS:
|
||||
return [], []
|
||||
|
||||
active_filters = await Functions.get_active_filter_ids()
|
||||
active_filters = await get_filter_context(request).get_active_filters()
|
||||
filter_ids = get_model_filter_ids(model, active_filters)
|
||||
functions_by_id = {function.id: function for function in await Functions.get_functions_by_ids(filter_ids)}
|
||||
|
||||
|
|
@ -166,7 +178,7 @@ async def process_filter_function(
|
|||
request, filter_id, load_from_db=(filter_type != 'stream'), function=function
|
||||
)
|
||||
handler = getattr(function_module, filter_type, None)
|
||||
if not handler:
|
||||
if not callable(handler):
|
||||
return form_data, valves_by_id, None
|
||||
|
||||
skip_files = (
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ from open_webui.tasks import clear_response_stream, save_response_stream
|
|||
from open_webui.utils.access_control import has_connection_access, has_permission
|
||||
from open_webui.utils.access_control.files import get_owner_accessible_folder_files
|
||||
from open_webui.utils.access_control.folders import has_folder_access
|
||||
from open_webui.utils.ask_user import stage_ask_user_tool_call
|
||||
from open_webui.utils.ask_user import stage_ask_user_tool_calls
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.utils.chat_id import is_saved_chat_id
|
||||
from open_webui.utils.code_interpreter import execute_code_jupyter
|
||||
|
|
@ -94,6 +94,7 @@ from open_webui.utils.files import (
|
|||
)
|
||||
from open_webui.utils.filter import (
|
||||
FilterContext,
|
||||
get_filter_context,
|
||||
get_filter_functions,
|
||||
process_filter_functions,
|
||||
)
|
||||
|
|
@ -186,6 +187,12 @@ def _is_tool_result_error(value: Any) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def normalize_messages_for_model(form_data: dict) -> dict:
|
||||
form_data['messages'] = strip_empty_content_blocks(form_data.get('messages', []))
|
||||
form_data['messages'] = merge_system_messages(form_data.get('messages', []))
|
||||
return form_data
|
||||
|
||||
|
||||
async def publish_chat_finished_event(
|
||||
request: Request, user: UserModel, metadata: dict, title: str, content: str, output: list | None = None
|
||||
):
|
||||
|
|
@ -258,12 +265,7 @@ def build_terminal_file_tool_result(
|
|||
if isinstance(tool_result, (list, tuple)) and tool_result and isinstance(tool_result[0], dict):
|
||||
tool_result = tool_result[0]
|
||||
|
||||
if (
|
||||
tool_function_name != 'display_file'
|
||||
or tool_function_params.get('inline') is not True
|
||||
or not isinstance(tool_result, dict)
|
||||
or tool_result.get('exists') is False
|
||||
):
|
||||
if tool_function_name != 'display_file' or not isinstance(tool_result, dict) or tool_result.get('exists') is False:
|
||||
return None
|
||||
|
||||
tool_id = (tool or {}).get('tool_id', '')
|
||||
|
|
@ -284,7 +286,7 @@ def build_terminal_file_tool_result(
|
|||
**tool_result,
|
||||
'type': 'file',
|
||||
'source': 'open_terminal',
|
||||
'displayed': True,
|
||||
**({'displayed': True} if tool_function_params.get('inline') is True else {}),
|
||||
'terminal_selector': terminal_selector,
|
||||
**({'terminal_id': terminal_id} if terminal_id else {}),
|
||||
**({'terminal_url': server_url} if server_url and not terminal_id else {}),
|
||||
|
|
@ -1771,6 +1773,12 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
|
|||
if not chat_id or not isinstance(chat_id, str) or not __event_emitter__:
|
||||
return form_data
|
||||
|
||||
is_channel_chat = chat_id.startswith('channel:')
|
||||
image_metadata = {
|
||||
'message_id': metadata.get('message_id', None),
|
||||
**({'channel_id': chat_id.removeprefix('channel:')} if is_channel_chat else {'chat_id': chat_id}),
|
||||
}
|
||||
|
||||
if not is_saved_chat_id(chat_id):
|
||||
message_list = form_data.get('messages', [])
|
||||
else:
|
||||
|
|
@ -1815,10 +1823,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
|
|||
images = await image_edits(
|
||||
request=request,
|
||||
form_data=EditImageForm(**{'prompt': prompt, 'image': input_images}),
|
||||
metadata={
|
||||
'chat_id': metadata.get('chat_id', None),
|
||||
'message_id': metadata.get('message_id', None),
|
||||
},
|
||||
metadata=image_metadata,
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
|
@ -1836,7 +1841,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
|
|||
'files': [
|
||||
{
|
||||
'type': 'image',
|
||||
'url': image['url'],
|
||||
**image,
|
||||
}
|
||||
for image in images
|
||||
]
|
||||
|
|
@ -1926,10 +1931,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
|
|||
images = await image_generations(
|
||||
request=request,
|
||||
form_data=CreateImageForm(**{'prompt': prompt}),
|
||||
metadata={
|
||||
'chat_id': metadata.get('chat_id', None),
|
||||
'message_id': metadata.get('message_id', None),
|
||||
},
|
||||
metadata=image_metadata,
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
|
@ -1947,7 +1949,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
|
|||
'files': [
|
||||
{
|
||||
'type': 'image',
|
||||
'url': image['url'],
|
||||
**image,
|
||||
}
|
||||
for image in images
|
||||
]
|
||||
|
|
@ -2265,7 +2267,8 @@ def sanitize_tool_pairs(messages: list[dict]) -> list[dict]:
|
|||
return sanitized
|
||||
|
||||
|
||||
SKILL_MENTION_RE = re.compile(r'<(?:\$([^|>]+)(?:\|[^>]*)?|/([^|>]+)\|[^>]*)>')
|
||||
# Ids are validated as [a-z0-9_-]+ on create; matching that keeps ordinary "<$..." text intact.
|
||||
SKILL_MENTION_RE = re.compile(r'<(?:\$([a-z0-9_-]+)(?:\|[^>]*)?|/([a-z0-9_-]+)\|[^>]*)>')
|
||||
|
||||
|
||||
def _get_text_parts(message: dict) -> list[str]:
|
||||
|
|
@ -2287,7 +2290,7 @@ def extract_skill_ids_from_messages(messages: list[dict]) -> set[str]:
|
|||
return ids
|
||||
|
||||
|
||||
SKILL_MENTION_STRIP_RE = re.compile(r'<(?:\$[^|>]+(?:\|([^>]*))?|/[^|>]+\|([^>]*))>')
|
||||
SKILL_MENTION_STRIP_RE = re.compile(r'<(?:\$[a-z0-9_-]+(?:\|([^>]*))?|/[a-z0-9_-]+\|([^>]*))>')
|
||||
|
||||
|
||||
def strip_skill_mentions(messages: list[dict]) -> None:
|
||||
|
|
@ -2397,7 +2400,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
form_data['model'] = selected_model_id
|
||||
metadata['selected_model_id'] = selected_model_id
|
||||
|
||||
# Captured before apply_params_to_form_data pops 'params'; feeds metadata['system_prompt'] below
|
||||
# Captured before apply_params_to_form_data pops 'params'; populates metadata['system_prompt'] below
|
||||
model_system_prompt = (form_data.get('params') or {}).get('system')
|
||||
|
||||
form_data = apply_params_to_form_data(form_data, model)
|
||||
|
|
@ -2623,13 +2626,15 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
except Exception as e:
|
||||
raise e
|
||||
|
||||
filter_functions = []
|
||||
filter_context = get_filter_context(request) if ENABLE_PLUGINS else None
|
||||
if ENABLE_PLUGINS:
|
||||
try:
|
||||
filter_functions = await get_filter_functions(request, model, metadata.get('filter_ids', []))
|
||||
|
||||
form_data, flags = await process_filter_functions(
|
||||
request=request,
|
||||
filter_context=None,
|
||||
filter_context=filter_context,
|
||||
filter_functions=filter_functions,
|
||||
filter_type='inlet',
|
||||
form_data=form_data,
|
||||
|
|
@ -3094,13 +3099,20 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
}
|
||||
)
|
||||
|
||||
# Strip empty text content blocks from multimodal messages
|
||||
# to prevent errors from providers like Gemini and Claude
|
||||
form_data['messages'] = strip_empty_content_blocks(form_data.get('messages', []))
|
||||
if ENABLE_PLUGINS:
|
||||
try:
|
||||
form_data, _ = await process_filter_functions(
|
||||
request=request,
|
||||
filter_context=filter_context,
|
||||
filter_functions=filter_functions,
|
||||
filter_type='request',
|
||||
form_data=form_data,
|
||||
extra_params=extra_params,
|
||||
)
|
||||
except Exception as e:
|
||||
raise Exception(f'{e}')
|
||||
|
||||
# Merge any duplicate system messages into a single message at position 0
|
||||
# to prevent template parsing errors with strict chat templates (e.g. Qwen)
|
||||
form_data['messages'] = merge_system_messages(form_data.get('messages', []))
|
||||
form_data = normalize_messages_for_model(form_data)
|
||||
|
||||
return form_data, metadata, events
|
||||
|
||||
|
|
@ -3225,10 +3237,12 @@ async def execute_tool_call_for_output(request, form_data, user, metadata, event
|
|||
|
||||
async def drain_approved_tool_calls(request, form_data, user, model, metadata) -> bool:
|
||||
chat_id = metadata.get('chat_id')
|
||||
message_id = metadata.get('message_id') or metadata.get('assistant_message_id')
|
||||
if not is_saved_chat_id(chat_id) or not message_id:
|
||||
assistant_message_id = metadata.get('assistant_message_id')
|
||||
# Only a resume/continue payload re-enters an existing message; other paths mint a fresh id with nothing to drain.
|
||||
if not is_saved_chat_id(chat_id) or not assistant_message_id:
|
||||
return False
|
||||
|
||||
message_id = metadata.get('message_id') or assistant_message_id
|
||||
message = await Chats.get_message_by_id_and_message_id(chat_id, message_id)
|
||||
output = message.get('output') if message else None
|
||||
if not isinstance(output, list):
|
||||
|
|
@ -3388,6 +3402,34 @@ async def drain_approved_tool_calls(request, form_data, user, model, metadata) -
|
|||
)
|
||||
form_data['messages'] = sanitize_tool_pairs(form_data['messages'])
|
||||
|
||||
if not paused and ENABLE_PLUGINS:
|
||||
filter_functions = await get_filter_functions(request, model, metadata.get('filter_ids', []))
|
||||
if filter_functions:
|
||||
filtered_form_data, _ = await process_filter_functions(
|
||||
request=request,
|
||||
filter_context=get_filter_context(request),
|
||||
filter_functions=filter_functions,
|
||||
filter_type='request',
|
||||
form_data=form_data,
|
||||
extra_params={
|
||||
'__event_emitter__': event_emitter,
|
||||
'__event_call__': event_caller,
|
||||
'__user__': user.model_dump() if isinstance(user, UserModel) else {},
|
||||
'__metadata__': metadata,
|
||||
'__oauth_token__': await get_system_oauth_token(request, user),
|
||||
'__request__': request,
|
||||
'__model__': model,
|
||||
'__chat_id__': metadata.get('chat_id'),
|
||||
'__message_id__': metadata.get('message_id'),
|
||||
},
|
||||
)
|
||||
if filtered_form_data is not form_data:
|
||||
form_data.clear()
|
||||
form_data.update(filtered_form_data)
|
||||
|
||||
if not paused:
|
||||
normalize_messages_for_model(form_data)
|
||||
|
||||
return paused
|
||||
|
||||
return False
|
||||
|
|
@ -4196,6 +4238,8 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
'__oauth_token__': await get_system_oauth_token(request, user),
|
||||
'__request__': request,
|
||||
'__model__': model,
|
||||
'__chat_id__': metadata.get('chat_id'),
|
||||
'__message_id__': metadata.get('message_id'),
|
||||
}
|
||||
|
||||
filter_functions = (
|
||||
|
|
@ -5222,7 +5266,12 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
reasoning_detail_items,
|
||||
)
|
||||
await save_current_response_stream()
|
||||
data = None
|
||||
# Providers such as OpenRouter send reasoning_details
|
||||
# alongside the reasoning text: only drop the event when
|
||||
# the details were all there was to report, otherwise the
|
||||
# reasoning delta never reaches the client.
|
||||
if not reasoning_content:
|
||||
data = None
|
||||
|
||||
if value:
|
||||
if (
|
||||
|
|
@ -5530,12 +5579,14 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
tool_call_iterations += 1
|
||||
|
||||
response_tool_calls = tool_calls.pop(0)
|
||||
ask_user_stage = stage_ask_user_tool_call(response_tool_calls, output, output_id)
|
||||
if ask_user_stage:
|
||||
if ask_user_stage['error']:
|
||||
await event_emitter({'type': 'chat:completion', 'data': {'output': full_output()}})
|
||||
continue
|
||||
|
||||
ask_user_staged, ask_user_error = stage_ask_user_tool_calls(response_tool_calls, output, output_id)
|
||||
if ask_user_error:
|
||||
response_tool_calls = [
|
||||
tool_call
|
||||
for tool_call in response_tool_calls
|
||||
if tool_call.get('function', {}).get('name') != 'ask_user'
|
||||
]
|
||||
elif ask_user_staged:
|
||||
if is_saved_chat_id(metadata.get('chat_id')) and metadata.get('message_id'):
|
||||
await pause_for_tool_approval(
|
||||
metadata['chat_id'],
|
||||
|
|
@ -5567,7 +5618,8 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
|
||||
tool_approval_mode = metadata.get('params', {}).get('tool_approval_mode', 'full')
|
||||
if (
|
||||
tool_approval_mode == 'ask'
|
||||
response_tool_calls
|
||||
and tool_approval_mode == 'ask'
|
||||
and is_saved_chat_id(metadata.get('chat_id'))
|
||||
and metadata.get('message_id')
|
||||
):
|
||||
|
|
@ -5789,17 +5841,6 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
item['arguments'] = tc.get('function', {}).get('arguments', '{}')
|
||||
break
|
||||
|
||||
# Append a new empty message item for the next response
|
||||
output.append(
|
||||
{
|
||||
'type': 'message',
|
||||
'id': output_id('msg'),
|
||||
'status': 'in_progress',
|
||||
'role': 'assistant',
|
||||
'content': [{'type': 'output_text', 'text': ''}],
|
||||
}
|
||||
)
|
||||
|
||||
# Emit citation sources to the frontend for display
|
||||
if citations_enabled:
|
||||
for source in tool_call_sources:
|
||||
|
|
@ -5938,6 +5979,18 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
}
|
||||
)
|
||||
|
||||
if filter_functions:
|
||||
new_form_data, _ = await process_filter_functions(
|
||||
request=request,
|
||||
filter_context=filter_context,
|
||||
filter_functions=filter_functions,
|
||||
filter_type='request',
|
||||
form_data=new_form_data,
|
||||
extra_params=extra_params,
|
||||
)
|
||||
|
||||
new_form_data = normalize_messages_for_model(new_form_data)
|
||||
|
||||
res = await generate_chat_completion(
|
||||
request,
|
||||
new_form_data,
|
||||
|
|
@ -6146,6 +6199,18 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
],
|
||||
}
|
||||
|
||||
if filter_functions:
|
||||
new_form_data, _ = await process_filter_functions(
|
||||
request=request,
|
||||
filter_context=filter_context,
|
||||
filter_functions=filter_functions,
|
||||
filter_type='request',
|
||||
form_data=new_form_data,
|
||||
extra_params=extra_params,
|
||||
)
|
||||
|
||||
new_form_data = normalize_messages_for_model(new_form_data)
|
||||
|
||||
res = await generate_chat_completion(
|
||||
request,
|
||||
new_form_data,
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ def get_allow_block_lists(filter_list):
|
|||
return allow_list, block_list
|
||||
|
||||
|
||||
def is_string_allowed(string: Union[str, Sequence[str]], filter_list: list[str | None] = None) -> bool:
|
||||
def is_string_allowed(string: Union[str, Sequence[str]], filter_list: list[str] | None = None) -> bool:
|
||||
"""
|
||||
Checks if a string is allowed based on the provided filter list.
|
||||
:param string: The string or sequence of strings to check (e.g., domain or hostname).
|
||||
|
|
@ -150,7 +150,7 @@ def _host_matches_pattern(host: str, pattern: str) -> bool:
|
|||
return host == pattern or host.endswith('.' + pattern)
|
||||
|
||||
|
||||
def is_host_allowed(host: Union[str, Sequence[str]], filter_list: list[str | None] = None) -> bool:
|
||||
def is_host_allowed(host: Union[str, Sequence[str]], filter_list: list[str] | None = None) -> bool:
|
||||
"""Allow/block a hostname (or list of hostnames / resolved IPs) against a
|
||||
WEB_FETCH_FILTER_LIST-style filter, matching on label boundaries.
|
||||
|
||||
|
|
@ -172,7 +172,7 @@ def is_host_allowed(host: Union[str, Sequence[str]], filter_list: list[str | Non
|
|||
return not is_host_blocked(hosts, filter_list)
|
||||
|
||||
|
||||
def is_host_blocked(host: Union[str, Sequence[str]], filter_list: list[str | None] = None) -> bool:
|
||||
def is_host_blocked(host: Union[str, Sequence[str]], filter_list: list[str] | None = None) -> bool:
|
||||
"""Whether a host or resolved address matches a block entry, ignoring any allow entries."""
|
||||
_, block_list = get_allow_block_lists(filter_list)
|
||||
hosts = [host] if isinstance(host, str) else list(host or [])
|
||||
|
|
@ -778,7 +778,7 @@ def openai_chat_chunk_message_template(
|
|||
model: str,
|
||||
content: str | None = None,
|
||||
reasoning_content: str | None = None,
|
||||
tool_calls: list[dict | None] = None,
|
||||
tool_calls: list[dict] | None = None,
|
||||
usage: dict | None = None,
|
||||
message_id: str | None = None,
|
||||
) -> dict:
|
||||
|
|
@ -809,7 +809,7 @@ def openai_chat_completion_message_template(
|
|||
model: str,
|
||||
message: str | None = None,
|
||||
reasoning_content: str | None = None,
|
||||
tool_calls: list[dict | None] = None,
|
||||
tool_calls: list[dict] | None = None,
|
||||
usage: dict | None = None,
|
||||
) -> dict:
|
||||
template = openai_chat_message_template(model)
|
||||
|
|
|
|||
|
|
@ -3,13 +3,12 @@ import copy
|
|||
import logging
|
||||
import sys
|
||||
|
||||
from aiocache import cached
|
||||
from fastapi import Request
|
||||
from open_webui.config import (
|
||||
BYPASS_ADMIN_ACCESS_CONTROL,
|
||||
DEFAULT_ARENA_MODEL,
|
||||
)
|
||||
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, ENABLE_PLUGINS, GLOBAL_LOG_LEVEL
|
||||
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, ENABLE_PLUGINS, GLOBAL_LOG_LEVEL, REDIS_KEY_PREFIX
|
||||
from open_webui.functions import get_function_models
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.config import Config
|
||||
|
|
@ -21,6 +20,7 @@ from open_webui.models.users import UserModel
|
|||
from open_webui.routers import ollama, openai
|
||||
from open_webui.socket.utils import RedisDict
|
||||
from open_webui.utils.access_control import has_access, has_base_model_access
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
from open_webui.utils.plugin import (
|
||||
get_functions_cache,
|
||||
get_function_module_from_cache,
|
||||
|
|
@ -29,6 +29,8 @@ from open_webui.utils.plugin import (
|
|||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
BASE_MODELS_CACHE_KEY = f'{REDIS_KEY_PREFIX}:models:base'
|
||||
|
||||
|
||||
async def fetch_ollama_models(request: Request, user: UserModel = None):
|
||||
raw_ollama_models = await ollama.get_all_models(request, user=user)
|
||||
|
|
@ -74,17 +76,32 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
|||
if refresh:
|
||||
await openai.get_all_models.cache.clear()
|
||||
await ollama.get_all_models.cache.clear()
|
||||
redis = getattr(request.app.state, 'redis', None)
|
||||
if redis is not None:
|
||||
await redis.delete(BASE_MODELS_CACHE_KEY)
|
||||
request.app.state.BASE_MODELS = []
|
||||
|
||||
if (
|
||||
request.app.state.MODELS
|
||||
and request.app.state.BASE_MODELS
|
||||
and (config.get('models.base_models_cache') and not refresh)
|
||||
):
|
||||
redis = getattr(request.app.state, 'redis', None)
|
||||
use_cache = config.get('models.base_models_cache') and not refresh
|
||||
base_models = None
|
||||
|
||||
if use_cache and redis is not None:
|
||||
cached_base_models = await redis.get(BASE_MODELS_CACHE_KEY)
|
||||
if cached_base_models:
|
||||
base_models = JSONCodec.loads(cached_base_models)
|
||||
request.app.state.BASE_MODELS = base_models
|
||||
else:
|
||||
await openai.get_all_models.cache.clear()
|
||||
await ollama.get_all_models.cache.clear()
|
||||
elif use_cache and request.app.state.MODELS and request.app.state.BASE_MODELS:
|
||||
base_models = request.app.state.BASE_MODELS
|
||||
else:
|
||||
|
||||
if base_models is None:
|
||||
base_models = await get_all_base_models(request, user=user)
|
||||
if base_models:
|
||||
request.app.state.BASE_MODELS = base_models
|
||||
if config.get('models.base_models_cache') and redis is not None:
|
||||
await redis.set(BASE_MODELS_CACHE_KEY, JSONCodec.dumps(base_models))
|
||||
else:
|
||||
base_models = request.app.state.BASE_MODELS
|
||||
|
||||
|
|
@ -374,6 +391,8 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
|||
for filter_id in set(model.pop('filter_ids', [])) | global_filter_ids
|
||||
if filter_id in enabled_filter_ids
|
||||
]
|
||||
# Set order varies per process, and an unstable order defeats the RedisDict content signature.
|
||||
filter_ids.sort()
|
||||
|
||||
model['actions'] = []
|
||||
for action_id in action_ids:
|
||||
|
|
|
|||
|
|
@ -148,15 +148,15 @@ async def build_tool_server_headers(
|
|||
cookies = {}
|
||||
|
||||
if auth_type == 'bearer':
|
||||
headers['Authorization'] = f'Bearer {connection.get("key", "")}'
|
||||
headers.update(bearer_auth_header(connection.get('key', '')))
|
||||
elif auth_type == 'session':
|
||||
cookies = request.cookies if hasattr(request, 'cookies') else {}
|
||||
headers['Authorization'] = f'Bearer {request.state.token.credentials}'
|
||||
headers.update(bearer_auth_header(request.state.token.credentials))
|
||||
elif auth_type == 'system_oauth':
|
||||
cookies = request.cookies if hasattr(request, 'cookies') else {}
|
||||
oauth_token = extra_params.get('__oauth_token__', None)
|
||||
if oauth_token:
|
||||
headers['Authorization'] = f'Bearer {oauth_token.get("access_token", "")}'
|
||||
headers.update(bearer_auth_header(oauth_token.get('access_token', '')))
|
||||
elif auth_type in ('oauth_2.1', 'oauth_2.1_static'):
|
||||
try:
|
||||
splits = server_id.split(':')
|
||||
|
|
@ -166,7 +166,7 @@ async def build_tool_server_headers(
|
|||
user.id, f'{connection_type}:{oauth_server_id}'
|
||||
)
|
||||
if oauth_token:
|
||||
headers['Authorization'] = f'Bearer {oauth_token.get("access_token", "")}'
|
||||
headers.update(bearer_auth_header(oauth_token.get('access_token', '')))
|
||||
except Exception as e:
|
||||
log.error(f'Error getting OAuth token: {e}')
|
||||
|
||||
|
|
@ -1338,7 +1338,13 @@ async def get_terminal_servers(request: Request):
|
|||
data = await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:terminal_servers')
|
||||
if data is not None:
|
||||
terminal_servers = JSONCodec.loads(data)
|
||||
request.app.state.TERMINAL_SERVERS = terminal_servers
|
||||
connections = await Config.get('terminal_server.connections', []) or []
|
||||
if terminal_servers or not any(
|
||||
connection.get('url') and connection.get('enabled', True) for connection in connections
|
||||
):
|
||||
request.app.state.TERMINAL_SERVERS = terminal_servers
|
||||
else:
|
||||
terminal_servers = None
|
||||
except Exception as e:
|
||||
log.error(f'Error fetching terminal_servers from Redis: {e}')
|
||||
|
||||
|
|
@ -1362,7 +1368,10 @@ async def get_terminal_tools(
|
|||
- Builds callables that route through the terminal proxy
|
||||
"""
|
||||
connections = await Config.get('terminal_server.connections', []) or []
|
||||
connection = next((c for c in connections if c.get('id') == terminal_id), None)
|
||||
connection = next(
|
||||
(terminal_connection for terminal_connection in connections if terminal_connection.get('id') == terminal_id),
|
||||
None,
|
||||
)
|
||||
if connection is None:
|
||||
raise RuntimeError(f"Terminal server '{terminal_id}' not found")
|
||||
if not connection.get('enabled', True):
|
||||
|
|
@ -1374,7 +1383,7 @@ async def get_terminal_tools(
|
|||
|
||||
# Find the cached spec data for this terminal
|
||||
terminal_servers = await get_terminal_servers(request)
|
||||
server_data = next((s for s in terminal_servers if s.get('id') == terminal_id), None)
|
||||
server_data = next((server for server in terminal_servers if server.get('id') == terminal_id), None)
|
||||
if server_data is None:
|
||||
raise RuntimeError(f"Terminal server '{terminal_id}' is unavailable")
|
||||
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "open-webui",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "open-webui",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^4.5.0",
|
||||
"@codemirror/lang-javascript": "^6.2.2",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "open-webui",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "npm run pyodide:fetch && vite dev --host",
|
||||
|
|
|
|||
120
src/app.css
120
src/app.css
|
|
@ -108,23 +108,23 @@ textarea::-webkit-scrollbar-corner {
|
|||
}
|
||||
|
||||
.input-prose {
|
||||
@apply prose dark:prose-invert !text-[0.9375rem] prose-headings:font-normal prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 whitespace-pre-line;
|
||||
@apply prose dark:prose-invert !text-[0.9375rem] prose-headings:font-medium prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 whitespace-pre-line;
|
||||
}
|
||||
|
||||
.input-prose-sm {
|
||||
@apply prose dark:prose-invert prose-headings:font-normal prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-1 whitespace-pre-line text-sm;
|
||||
@apply prose dark:prose-invert prose-headings:font-medium prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-1 whitespace-pre-line text-sm;
|
||||
}
|
||||
|
||||
.markdown-prose {
|
||||
@apply prose prose-sm dark:prose-invert max-w-none break-words !text-[0.9375rem] font-normal leading-relaxed prose-p:mt-0 prose-p:mb-2 prose-p:font-normal prose-p:leading-relaxed prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-normal prose-headings:leading-snug prose-h1:text-xl prose-h2:text-lg prose-h3:text-base prose-strong:font-medium prose-code:before:content-none prose-code:after:content-none prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5 prose-li:font-normal prose-pre:my-3 prose-table:my-0 prose-blockquote:my-3 prose-blockquote:font-normal prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850/30 prose-img:my-2 [&>:first-child]:mt-0 [&>:last-child]:mb-0 [&_p:first-child]:mt-0 [&_h1:first-child]:mt-0 [&_h2:first-child]:mt-0 [&_h3:first-child]:mt-0 [&_h4:first-child]:mt-0 [&_h5:first-child]:mt-0 [&_h6:first-child]:mt-0;
|
||||
@apply prose prose-sm dark:prose-invert max-w-none break-words !text-[0.9375rem] font-normal leading-relaxed prose-p:mt-0 prose-p:mb-2 prose-p:font-normal prose-p:leading-relaxed prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-medium prose-headings:leading-snug prose-h1:text-xl prose-h2:text-lg prose-h3:text-base prose-strong:font-medium prose-code:before:content-none prose-code:after:content-none prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5 prose-li:font-normal prose-pre:my-3 prose-table:my-0 prose-blockquote:my-3 prose-blockquote:font-normal prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850/30 prose-img:my-2 [&>:first-child]:mt-0 [&>:last-child]:mb-0 [&_p:first-child]:mt-0 [&_h1:first-child]:mt-0 [&_h2:first-child]:mt-0 [&_h3:first-child]:mt-0 [&_h4:first-child]:mt-0 [&_h5:first-child]:mt-0 [&_h6:first-child]:mt-0;
|
||||
}
|
||||
|
||||
.markdown-prose-sm {
|
||||
@apply text-sm prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-normal prose-hr:my-2 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
||||
@apply text-sm prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-medium prose-hr:my-2 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
||||
}
|
||||
|
||||
.markdown-prose-xs {
|
||||
@apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-normal prose-hr:my-0.5 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
||||
@apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-medium prose-hr:my-0.5 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
||||
}
|
||||
|
||||
.markdown a {
|
||||
|
|
@ -173,6 +173,7 @@ li p {
|
|||
}
|
||||
|
||||
select {
|
||||
color-scheme: light;
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236B7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");
|
||||
background-position: right 0rem center;
|
||||
background-repeat: no-repeat;
|
||||
|
|
@ -186,6 +187,10 @@ select {
|
|||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.dark select {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.dark select:not([class*='bg-transparent']) {
|
||||
@apply bg-gray-900 text-gray-300;
|
||||
}
|
||||
|
|
@ -865,77 +870,134 @@ body {
|
|||
padding-bottom: 2rem; /* space for the bottom toolbar */
|
||||
}
|
||||
|
||||
/* High Contrast Mode: placeholders bottom out at 1.58:1 (WCAG 1.4.3 wants 4.5:1). */
|
||||
/* Accessibility Mode: placeholders bottom out at 1.58:1 (WCAG 1.4.3 wants 4.5:1). */
|
||||
@layer utilities {
|
||||
html.high-contrast:not(.dark) :is(input, textarea)::placeholder {
|
||||
color: var(--color-gray-600);
|
||||
color: var(--color-gray-700);
|
||||
}
|
||||
|
||||
html.high-contrast.dark :is(input, textarea)::placeholder {
|
||||
color: var(--color-gray-500);
|
||||
color: var(--color-gray-300);
|
||||
}
|
||||
}
|
||||
|
||||
/* High Contrast Mode: muted text fails WCAG 1.4.3 (2.07:1 light, 3.12:1 dark).
|
||||
/* Accessibility Mode: muted text fails WCAG 1.4.3 (2.07:1 light, 3.12:1 dark).
|
||||
:where() keeps these at low specificity so hover variants still win. */
|
||||
@layer utilities {
|
||||
html:where(.high-contrast):not(:where(.dark)) .text-gray-400 {
|
||||
color: var(--color-gray-600);
|
||||
color: var(--color-gray-700);
|
||||
}
|
||||
|
||||
html:where(.high-contrast).dark .dark\:text-gray-600 {
|
||||
color: var(--color-gray-400);
|
||||
html:where(.high-contrast).dark
|
||||
:is(.dark\:text-gray-400, .dark\:text-gray-500, .dark\:text-gray-600) {
|
||||
color: var(--color-gray-300);
|
||||
}
|
||||
|
||||
html:where(.high-contrast).dark
|
||||
:is(
|
||||
.dark\:hover\:text-gray-100:hover,
|
||||
.dark\:hover\:text-gray-200:hover,
|
||||
.dark\:hover\:text-gray-300:hover,
|
||||
.dark\:hover\:text-gray-400:hover
|
||||
) {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Elements that hover to a lighter grey than the new resting colour. */
|
||||
html:where(.high-contrast):not(:where(.dark)) .hover\:text-gray-500:hover {
|
||||
color: var(--color-gray-800);
|
||||
html:where(.high-contrast):not(:where(.dark))
|
||||
:is(
|
||||
.hover\:text-gray-500:hover,
|
||||
.hover\:text-gray-600:hover,
|
||||
.hover\:text-gray-700:hover,
|
||||
.hover\:text-gray-800:hover
|
||||
) {
|
||||
color: var(--color-gray-900);
|
||||
}
|
||||
}
|
||||
|
||||
/* High Contrast Mode: gray-500 muted text is 2.77:1 in light (WCAG 1.4.3 wants 4.5:1).
|
||||
/* Accessibility Mode: gray-500 muted text is 2.77:1 in light (WCAG 1.4.3 wants 4.5:1).
|
||||
The utility stays in the layer so hover variants still outrank it; the classes below
|
||||
are unlayered, so their override has to be too. */
|
||||
@layer utilities {
|
||||
html:where(.high-contrast):not(:where(.dark)) .text-gray-500 {
|
||||
color: var(--color-gray-600);
|
||||
html:where(.high-contrast):not(:where(.dark)) :is(.text-gray-500, .text-gray-600) {
|
||||
color: var(--color-gray-700);
|
||||
}
|
||||
}
|
||||
|
||||
html.high-contrast:not(.dark) :is(.app-muted, .app-icon-muted, .tiptap table) {
|
||||
color: var(--color-gray-600);
|
||||
color: var(--color-gray-700);
|
||||
}
|
||||
|
||||
/* High Contrast Mode: the lightest muted pair, 1.58:1 in light and 2.14:1 in dark.
|
||||
html.high-contrast.dark :is(.app-muted, .app-icon-muted, .tiptap table) {
|
||||
color: var(--color-gray-300);
|
||||
}
|
||||
|
||||
html.high-contrast:not(.dark)
|
||||
:is(
|
||||
#sidebar .hover\:bg-gray-100:hover,
|
||||
#sidebar .group:hover .group-hover\:bg-gray-100,
|
||||
.app-dropdown-menu :is(button, a):hover
|
||||
) {
|
||||
background-color: var(--color-gray-200) !important;
|
||||
}
|
||||
|
||||
html.high-contrast.dark
|
||||
:is(
|
||||
#sidebar .dark\:hover\:bg-gray-900:hover,
|
||||
#sidebar .group:hover .dark\:group-hover\:bg-gray-900,
|
||||
.app-dropdown-menu :is(button, a):hover
|
||||
) {
|
||||
background-color: var(--color-gray-800) !important;
|
||||
}
|
||||
|
||||
/* Accessibility Mode: the lightest muted pair, 1.58:1 in light and 2.14:1 in dark.
|
||||
text-gray-400/70 is an icon colour, so 1.4.11 governs it, and 2.07:1 misses that too. */
|
||||
@layer utilities {
|
||||
html:where(.high-contrast):not(:where(.dark)) :is(.text-gray-300, .text-gray-400\/70) {
|
||||
color: var(--color-gray-600);
|
||||
color: var(--color-gray-700);
|
||||
}
|
||||
|
||||
html:where(.high-contrast).dark .dark\:text-gray-700 {
|
||||
color: var(--color-gray-400);
|
||||
html:where(.high-contrast).dark :is(.dark\:text-gray-700, .dark\:text-gray-800) {
|
||||
color: var(--color-gray-300);
|
||||
}
|
||||
|
||||
/* Hover states that land lighter than the new resting colour. */
|
||||
html:where(.high-contrast):not(:where(.dark)) .hover\:text-gray-500:hover {
|
||||
color: var(--color-gray-800);
|
||||
html:where(.high-contrast):not(:where(.dark))
|
||||
:is(
|
||||
.hover\:text-gray-500:hover,
|
||||
.hover\:text-gray-600:hover,
|
||||
.hover\:text-gray-700:hover,
|
||||
.hover\:text-gray-800:hover
|
||||
) {
|
||||
color: var(--color-gray-900);
|
||||
}
|
||||
|
||||
html:where(.high-contrast):not(:where(.dark)) .group:hover .group-hover\:text-gray-500 {
|
||||
color: var(--color-gray-800);
|
||||
html:where(.high-contrast):not(:where(.dark))
|
||||
.group:hover
|
||||
:is(.group-hover\:text-gray-500, .group-hover\:text-gray-600, .group-hover\:text-gray-700) {
|
||||
color: var(--color-gray-900);
|
||||
}
|
||||
|
||||
html:where(.high-contrast).dark
|
||||
.group:hover
|
||||
:is(
|
||||
.dark\:group-hover\:text-gray-200,
|
||||
.dark\:group-hover\:text-gray-300,
|
||||
.dark\:group-hover\:text-gray-400
|
||||
) {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* Autocompletion ghost text is 2.65:1 in light; the dark canvas already passes. */
|
||||
html.high-contrast:not(.dark) .ai-autocompletion::after {
|
||||
color: var(--color-gray-600);
|
||||
color: var(--color-gray-700);
|
||||
}
|
||||
|
||||
/* The shimmer gradient is 2.10:1 in light, and clipping it to the text leaves no
|
||||
solid colour to raise; render it as flat text instead. */
|
||||
html.high-contrast:not(.dark) .shimmer {
|
||||
background: none;
|
||||
color: var(--color-gray-700);
|
||||
-webkit-text-fill-color: var(--color-gray-700);
|
||||
color: var(--color-gray-800);
|
||||
-webkit-text-fill-color: var(--color-gray-800);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -407,6 +407,36 @@ export const userSignOut = async () => {
|
|||
return res;
|
||||
};
|
||||
|
||||
export const getLogoutRedirectUrl = (redirectUrl?: string | null) => {
|
||||
const logoutUrl = new URL('/auth?state=logout', window.location.origin);
|
||||
const postLogoutUrl = new URL('/auth', window.location.origin);
|
||||
if (!redirectUrl) {
|
||||
return logoutUrl.href;
|
||||
}
|
||||
|
||||
const url = new URL(redirectUrl, window.location.origin);
|
||||
if (url.origin === window.location.origin && url.pathname === '/auth') {
|
||||
url.searchParams.set('state', 'logout');
|
||||
return url.href;
|
||||
}
|
||||
|
||||
const postLogoutRedirectUri = url.searchParams.get('post_logout_redirect_uri');
|
||||
if (postLogoutRedirectUri) {
|
||||
const configuredPostLogoutUrl = new URL(postLogoutRedirectUri, window.location.origin);
|
||||
if (
|
||||
configuredPostLogoutUrl.origin === window.location.origin &&
|
||||
configuredPostLogoutUrl.pathname === '/auth'
|
||||
) {
|
||||
url.searchParams.set('state', 'logout');
|
||||
}
|
||||
return url.href;
|
||||
}
|
||||
|
||||
url.searchParams.set('post_logout_redirect_uri', postLogoutUrl.href);
|
||||
url.searchParams.set('state', 'logout');
|
||||
return url.href;
|
||||
};
|
||||
|
||||
export const addUser = async (
|
||||
token: string,
|
||||
name: string,
|
||||
|
|
|
|||
|
|
@ -292,6 +292,29 @@ export const downloadFileBlob = async (
|
|||
return { blob, filename };
|
||||
};
|
||||
|
||||
export const downloadFilePreview = async (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
path: string,
|
||||
sessionId?: string
|
||||
): Promise<{ blob: Blob; filename: string } | null> => {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/files/view?path=${encodeURIComponent(path)}&preview=true`;
|
||||
const headers: Record<string, string> = bearerHeaders(apiKey);
|
||||
if (sessionId) headers['X-Session-Id'] = sessionId;
|
||||
const res = await fetch(url, { headers }).catch(() => null);
|
||||
|
||||
if (!res) return null;
|
||||
if (!res.ok) return null;
|
||||
|
||||
const contentType = res.headers.get('content-type') ?? '';
|
||||
const filename = path.split('/').pop() ?? 'file';
|
||||
if (!contentType.includes('application/pdf')) return null;
|
||||
|
||||
const blob = await res.blob().catch(() => null);
|
||||
if (!blob) return null;
|
||||
return { blob, filename };
|
||||
};
|
||||
|
||||
export const archiveFromTerminal = async (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
|
|
|
|||
|
|
@ -100,6 +100,14 @@ class Filter:
|
|||
|
||||
return body
|
||||
|
||||
def request(self, body: dict, __user__: Optional[dict] = None) -> dict:
|
||||
# Modify the request body before each model/provider call.
|
||||
print(f"request:{__name__}")
|
||||
print(f"request:body:{body}")
|
||||
print(f"request:user:{__user__}")
|
||||
|
||||
return body
|
||||
|
||||
def outlet(self, body: dict, __user__: Optional[dict] = None) -> dict:
|
||||
# Modify or analyze the response body after processing by the API.
|
||||
# This function is the post-processor for the API, which can be used to modify the response
|
||||
|
|
@ -201,6 +209,14 @@ class Filter:
|
|||
|
||||
return body
|
||||
|
||||
def request(self, body: dict, user: Optional[dict] = None) -> dict:
|
||||
# Modify the request body before each model/provider call.
|
||||
print(f"request:{__name__}")
|
||||
print(f"request:body:{body}")
|
||||
print(f"request:user:{user}")
|
||||
|
||||
return body
|
||||
|
||||
def outlet(self, body: dict, user: Optional[dict] = None) -> dict:
|
||||
# Modify or analyze the response body after processing by the API.
|
||||
# This function is the post-processor for the API, which can be used to modify the response
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
<slot name="label">{label}</slot>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0">
|
||||
<div class="min-w-0">
|
||||
<slot {labelId} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -269,6 +269,14 @@
|
|||
|
||||
baseModels = await getBaseModels(localStorage.token, selectedTag);
|
||||
allModels = await getModels(localStorage.token);
|
||||
|
||||
const providerModels = await getModels(localStorage.token, null, true);
|
||||
const allModelIds = new Set<string>(allModels.map((model: ModelListItem) => model.id));
|
||||
allModels = [
|
||||
...allModels,
|
||||
...providerModels.filter((model: ModelListItem) => !allModelIds.has(model.id))
|
||||
];
|
||||
|
||||
const baseModelIds = new Set<string>(baseModels.map((model: ModelListItem) => model.id));
|
||||
|
||||
models = allModels
|
||||
|
|
|
|||
|
|
@ -168,7 +168,11 @@
|
|||
</button>
|
||||
|
||||
{#if showCapabilities}
|
||||
<div class="pb-2" on:click={updateDirty} on:change={updateDirty}>
|
||||
<div
|
||||
class="max-h-[24rem] overflow-y-auto pb-2 pr-1 scrollbar-hover"
|
||||
on:click={updateDirty}
|
||||
on:change={updateDirty}
|
||||
>
|
||||
<Capabilities bind:capabilities={defaultCapabilities} />
|
||||
|
||||
{#if availableFeatures.length > 0}
|
||||
|
|
@ -231,7 +235,12 @@
|
|||
</button>
|
||||
|
||||
{#if showPromptSuggestions}
|
||||
<div class="pb-2" on:click={updateDirty} on:change={updateDirty} on:input={updateDirty}>
|
||||
<div
|
||||
class="max-h-[24rem] overflow-y-auto pb-2 pr-1 scrollbar-hover"
|
||||
on:click={updateDirty}
|
||||
on:change={updateDirty}
|
||||
on:input={updateDirty}
|
||||
>
|
||||
<PromptSuggestions bind:promptSuggestions />
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@
|
|||
scrollToTab(selectedTab);
|
||||
}
|
||||
|
||||
$: if (loaded && selectedTab) {
|
||||
loadCounts();
|
||||
}
|
||||
|
||||
const scrollToTab = (tabId) => {
|
||||
const tabElement = document.getElementById(tabId);
|
||||
if (tabElement) {
|
||||
|
|
@ -58,7 +62,6 @@
|
|||
await goto('/');
|
||||
}
|
||||
|
||||
await loadCounts();
|
||||
loaded = true;
|
||||
|
||||
const containerElement = document.getElementById('users-tabs-container');
|
||||
|
|
|
|||
|
|
@ -48,6 +48,10 @@
|
|||
return (b.member_count ?? 0) - (a.member_count ?? 0) || a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
$: if (loaded) {
|
||||
adminGroupCount.set(filteredGroups.length);
|
||||
}
|
||||
|
||||
/** @type {any} */
|
||||
let defaultPermissions = {};
|
||||
|
||||
|
|
@ -56,7 +60,6 @@
|
|||
|
||||
const setGroups = async () => {
|
||||
groups = await getGroups(localStorage.token);
|
||||
adminGroupCount.set(groups.length);
|
||||
};
|
||||
|
||||
/** @param {any} updatedGroup */
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@
|
|||
};
|
||||
|
||||
const formatSchedule = (rrule: string): string => {
|
||||
if (rrule.includes('COUNT=1')) {
|
||||
if (/COUNT=1(?!\d)/.test(rrule)) {
|
||||
const match = rrule.match(/DTSTART:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})/);
|
||||
if (match) {
|
||||
const d = new Date(`${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}`);
|
||||
|
|
@ -109,6 +109,9 @@
|
|||
|
||||
const parts: Record<string, string> = {};
|
||||
rrule
|
||||
.split(/\s+/)
|
||||
.filter((line) => !line.toUpperCase().startsWith('DTSTART'))
|
||||
.join('')
|
||||
.replace('RRULE:', '')
|
||||
.split(';')
|
||||
.forEach((part) => {
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@
|
|||
|
||||
export const parseRrule = (s: string) => {
|
||||
// Detect ONCE (COUNT=1 with DTSTART)
|
||||
if (s.includes('COUNT=1')) {
|
||||
if (/COUNT=1(?!\d)/.test(s)) {
|
||||
frequency = 'ONCE';
|
||||
const match = s.match(/DTSTART:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})/);
|
||||
if (match) {
|
||||
|
|
@ -116,7 +116,10 @@
|
|||
return;
|
||||
}
|
||||
const parts: Record<string, string> = {};
|
||||
s.replace('RRULE:', '')
|
||||
s.split(/\s+/)
|
||||
.filter((line) => !line.toUpperCase().startsWith('DTSTART'))
|
||||
.join('')
|
||||
.replace('RRULE:', '')
|
||||
.split(';')
|
||||
.forEach((p) => {
|
||||
const [k, v] = p.split('=');
|
||||
|
|
@ -125,6 +128,7 @@
|
|||
const freq = parts.FREQ || 'DAILY';
|
||||
if (!['HOURLY', 'DAILY', 'WEEKLY', 'MONTHLY'].includes(freq)) {
|
||||
frequency = 'CUSTOM';
|
||||
prevFrequency = 'CUSTOM';
|
||||
customRrule = s;
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,7 +129,12 @@
|
|||
loading = true;
|
||||
try {
|
||||
const startNs = dateTimeToNs(startDate, allDay ? '00:00' : startTime);
|
||||
const endNs = endDate ? dateTimeToNs(endDate, allDay ? '23:59' : endTime) : undefined;
|
||||
let endNs = endDate ? dateTimeToNs(endDate, allDay ? '23:59' : endTime) : undefined;
|
||||
if (endNs !== undefined && endNs < startNs) {
|
||||
const duration =
|
||||
event?.end_at && event.end_at > event.start_at ? event.end_at - event.start_at : 0;
|
||||
endNs = startNs + duration;
|
||||
}
|
||||
|
||||
if (event && !event.meta?.automation_id) {
|
||||
const result = await updateCalendarEvent(localStorage.token, event.id, {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,10 @@
|
|||
const startDate = new Date(startMs);
|
||||
const endDate = new Date(endMs);
|
||||
const d = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());
|
||||
const last = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate()).getTime();
|
||||
const last = Math.max(
|
||||
d.getTime(),
|
||||
new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate()).getTime()
|
||||
);
|
||||
while (d.getTime() <= last) {
|
||||
const key = d.getTime().toString();
|
||||
(map[key] ??= []).push(e);
|
||||
|
|
@ -90,7 +93,7 @@
|
|||
const dayEndMs = dayStartMs + 86_400_000;
|
||||
return filteredEvents.filter((e) => {
|
||||
const startMs = e.start_at / NS;
|
||||
const endMs = (e.end_at || e.start_at) / NS;
|
||||
const endMs = Math.max(startMs, (e.end_at || e.start_at) / NS);
|
||||
return startMs < dayEndMs && endMs >= dayStartMs;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1144,6 +1144,7 @@
|
|||
const terminalEventHandler = (type: string, data: any) => {
|
||||
if (type === 'terminal:display_file') {
|
||||
if (!data?.path) return;
|
||||
if ($settings?.terminalFileDisplay === 'inline') return;
|
||||
displayFileHandler(data.path, { showControls, showFileNavPath }, { page: data?.page });
|
||||
} else if (type === 'terminal:write_file' || type === 'terminal:replace_file_content') {
|
||||
if (!data?.path) return;
|
||||
|
|
@ -1219,7 +1220,11 @@
|
|||
} else if (type === 'chat:active') {
|
||||
if (!data?.active) {
|
||||
taskIds = null;
|
||||
if ($chatId && !$temporaryChatEnabled && hasPendingAssistantLeaf()) {
|
||||
if (
|
||||
$chatId &&
|
||||
!$temporaryChatEnabled &&
|
||||
hasPendingAssistantLeaf(event?.message_id ?? null)
|
||||
) {
|
||||
await loadChat();
|
||||
}
|
||||
if ($chatId && !$temporaryChatEnabled) {
|
||||
|
|
@ -1499,9 +1504,9 @@
|
|||
} catch {}
|
||||
};
|
||||
|
||||
const hasPendingAssistantLeaf = () =>
|
||||
Object.values(history.messages).some(
|
||||
(message) =>
|
||||
const hasPendingAssistantLeaf = (messageId: string | null = null) =>
|
||||
(messageId ? [history.messages[messageId]] : Object.values(history.messages)).some(
|
||||
(message: any) =>
|
||||
message?.role === 'assistant' && !message.done && (message.childrenIds?.length ?? 0) === 0
|
||||
);
|
||||
|
||||
|
|
@ -2113,8 +2118,9 @@
|
|||
|
||||
autoScroll = true;
|
||||
|
||||
await resetInput();
|
||||
// resetInput() must stay last: the selected model's defaults override the draft's selection.
|
||||
await restoreChatInput(sessionStorage.getItem('chat-input'));
|
||||
await resetInput();
|
||||
await chatId.set('');
|
||||
await chatTitle.set('');
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@
|
|||
// Persists across mount/unmount cycles (module-level, not per-instance)
|
||||
let savedPath = '/';
|
||||
let savedFileRoot = null;
|
||||
const treeExpandedCache = new Map<string, string[]>();
|
||||
const treeContentsCache = new Map<string, [string, any[]][]>();
|
||||
/** @type {Map<string, string[]>} */
|
||||
const treeExpandedCache = new Map();
|
||||
/** @type {Map<string, [string, any[]][]>} */
|
||||
const treeContentsCache = new Map();
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
|
|
@ -23,6 +25,7 @@
|
|||
listFiles,
|
||||
readFile,
|
||||
downloadFileBlob,
|
||||
downloadFilePreview,
|
||||
archiveFromTerminal,
|
||||
uploadToTerminal,
|
||||
createDirectory,
|
||||
|
|
@ -865,19 +868,37 @@
|
|||
);
|
||||
if (result) fileSqliteData = await result.blob.arrayBuffer();
|
||||
} else if (isOffice(filePath)) {
|
||||
const result = await downloadFileBlob(
|
||||
terminal.url,
|
||||
terminal.key,
|
||||
filePath,
|
||||
chatId ?? undefined
|
||||
);
|
||||
if (result) {
|
||||
const ext = getFileExt(filePath);
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
try {
|
||||
if (ext === 'docx') {
|
||||
const ext = getFileExt(filePath);
|
||||
try {
|
||||
if (ext === 'docx') {
|
||||
const preview = await downloadFilePreview(
|
||||
terminal.url,
|
||||
terminal.key,
|
||||
filePath,
|
||||
chatId ?? undefined
|
||||
);
|
||||
if (preview) {
|
||||
filePdfData = await preview.blob.arrayBuffer();
|
||||
} else {
|
||||
const result = await downloadFileBlob(
|
||||
terminal.url,
|
||||
terminal.key,
|
||||
filePath,
|
||||
chatId ?? undefined
|
||||
);
|
||||
if (!result) throw new Error('Preview failed');
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
fileDocxData = arrayBuffer;
|
||||
} else if (ext === 'xlsx') {
|
||||
}
|
||||
} else if (ext === 'xlsx') {
|
||||
const result = await downloadFileBlob(
|
||||
terminal.url,
|
||||
terminal.key,
|
||||
filePath,
|
||||
chatId ?? undefined
|
||||
);
|
||||
if (result) {
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
const XLSX = await import('xlsx');
|
||||
const wb = XLSX.read(new Uint8Array(arrayBuffer), { type: 'array' });
|
||||
excelWorkbook = wb;
|
||||
|
|
@ -889,16 +910,34 @@
|
|||
const DOMPurify = (await import('dompurify')).default;
|
||||
fileOfficeHtml = DOMPurify.sanitize(result.html);
|
||||
}
|
||||
} else if (ext === 'pptx') {
|
||||
}
|
||||
} else if (ext === 'pptx') {
|
||||
const preview = await downloadFilePreview(
|
||||
terminal.url,
|
||||
terminal.key,
|
||||
filePath,
|
||||
chatId ?? undefined
|
||||
);
|
||||
if (preview) {
|
||||
filePdfData = await preview.blob.arrayBuffer();
|
||||
} else {
|
||||
const result = await downloadFileBlob(
|
||||
terminal.url,
|
||||
terminal.key,
|
||||
filePath,
|
||||
chatId ?? undefined
|
||||
);
|
||||
if (!result) throw new Error('Preview failed');
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
const { pptxToImages } = await import('$lib/utils/pptxToHtml');
|
||||
const result = await pptxToImages(arrayBuffer);
|
||||
fileOfficeSlides = result.images;
|
||||
const fallback = await pptxToImages(arrayBuffer);
|
||||
fileOfficeSlides = fallback.images;
|
||||
currentSlide = 0;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to render Office file:', e);
|
||||
fileContent = `Error previewing file: ${e instanceof Error ? e.message : 'Unknown error'}`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to render Office file:', e);
|
||||
fileContent = `Error previewing file: ${e instanceof Error ? e.message : 'Unknown error'}`;
|
||||
}
|
||||
} else {
|
||||
fileContent = await readFile(terminal.url, terminal.key, filePath, chatId ?? undefined);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
import { isCodeFile } from '$lib/utils/codeHighlight';
|
||||
import { initMermaid, renderMermaidDiagram } from '$lib/utils';
|
||||
import Spinner from '../../common/Spinner.svelte';
|
||||
import PDFViewer from '../../common/PDFViewer.svelte';
|
||||
import PdfPagesPreview from '../../common/PdfPagesPreview.svelte';
|
||||
import PanzoomContainer from '../../common/PanzoomContainer.svelte';
|
||||
import DocxPreview from '../../common/DocxPreview.svelte';
|
||||
import PptxPreview from '../../common/PptxPreview.svelte';
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
import SqliteView from './SqliteView.svelte';
|
||||
import FileCodeEditor from './FileCodeEditor.svelte';
|
||||
|
||||
let pdfViewerRef: PDFViewer;
|
||||
let pdfPagesPreviewRef: PdfPagesPreview;
|
||||
let fileCodeEditorRef: FileCodeEditor;
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
|
@ -116,6 +116,7 @@
|
|||
$: isNotebook = getExt(selectedFile) === 'ipynb';
|
||||
$: isCode = isCodeFile(selectedFile);
|
||||
$: csvDelimiter = getExt(selectedFile) === 'tsv' ? '\t' : ',';
|
||||
$: isPptx = getExt(selectedFile) === 'pptx';
|
||||
|
||||
// For HTML files on system terminals (proxy URL), use path-based serving
|
||||
// so the iframe can resolve relative CSS/JS/image references via cookie auth.
|
||||
|
|
@ -279,13 +280,14 @@
|
|||
};
|
||||
|
||||
export const resetPdfView = () => {
|
||||
pdfViewerRef?.resetView();
|
||||
pdfPagesPreviewRef?.resetView();
|
||||
};
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex-1 {fileImageUrl !== null ||
|
||||
fileDocxData !== null ||
|
||||
filePdfData !== null ||
|
||||
(fileOfficeSlides !== null && fileOfficeSlides.length > 0)
|
||||
? 'overflow-hidden'
|
||||
: 'overflow-y-auto'} min-h-0 min-w-0 relative h-full"
|
||||
|
|
@ -321,7 +323,16 @@
|
|||
</audio>
|
||||
</div>
|
||||
{:else if filePdfData !== null}
|
||||
<PDFViewer bind:this={pdfViewerRef} data={filePdfData} {targetPage} className="w-full h-full" />
|
||||
<PdfPagesPreview
|
||||
bind:this={pdfPagesPreviewRef}
|
||||
data={filePdfData}
|
||||
bind:currentSlide
|
||||
{targetPage}
|
||||
singlePage={isPptx}
|
||||
itemLabel={isPptx ? 'Slide' : 'Page'}
|
||||
listLabel={isPptx ? 'Slides' : 'Pages'}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
{:else if fileSqliteData !== null}
|
||||
<SqliteView data={fileSqliteData} />
|
||||
{:else if fileDocxData !== null}
|
||||
|
|
@ -504,9 +515,10 @@
|
|||
<div
|
||||
class="absolute bottom-3 left-1/2 z-10 flex -translate-x-1/2 items-center gap-0.5 rounded-lg border border-gray-200/60 bg-white/90 px-1 py-0.5 shadow-lg backdrop-blur-sm dark:border-gray-700/60 dark:bg-gray-850/90"
|
||||
>
|
||||
<!-- Pinch covers in/out on coarse pointers; reset has no gesture, so it stays -->
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md p-1.5 text-gray-500 transition hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
class="inline-flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md p-1.5 text-gray-500 transition hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 pointer-coarse:hidden"
|
||||
on:click={() => panzoomRef?.zoomOut()}
|
||||
aria-label={$i18n.t('Zoom out')}
|
||||
>
|
||||
|
|
@ -533,7 +545,7 @@
|
|||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md p-1.5 text-gray-500 transition hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
class="inline-flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md p-1.5 text-gray-500 transition hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 pointer-coarse:hidden"
|
||||
on:click={() => panzoomRef?.zoomIn()}
|
||||
aria-label={$i18n.t('Zoom in')}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1858,7 +1858,10 @@
|
|||
id="message-input-container"
|
||||
class="flex-1 flex flex-col relative w-full shadow-lg rounded-3xl border {$temporaryChatEnabled
|
||||
? 'border-dashed border-gray-100 dark:border-gray-800 hover:border-gray-200 focus-within:border-gray-200 hover:dark:border-gray-700 focus-within:dark:border-gray-700'
|
||||
: ' border-gray-100/30 dark:border-gray-850/30 hover:border-gray-200 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800'} transition px-0.5 bg-white/5 dark:bg-gray-500/5 backdrop-blur-sm dark:text-gray-100"
|
||||
: ' border-gray-100/30 dark:border-gray-850/30 hover:border-gray-200 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800'} {($settings?.highContrastMode ??
|
||||
false)
|
||||
? 'focus-within:outline focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-blue-500 [&_.ProseMirror:focus-visible]:outline-none!'
|
||||
: ''} transition px-0.5 bg-white/5 dark:bg-gray-500/5 backdrop-blur-sm dark:text-gray-100"
|
||||
dir={$settings?.chatDirection ?? 'auto'}
|
||||
>
|
||||
{#if atSelectedModel !== undefined}
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@
|
|||
|
||||
function getMessageText(item: any): string {
|
||||
return (item.content ?? [])
|
||||
.filter((p: any) => p.type === 'output_text' || 'text' in p)
|
||||
.filter((p: any) => p && (p.type === 'output_text' || 'text' in p))
|
||||
.map((p: any) => p.text ?? '')
|
||||
.join('\n');
|
||||
}
|
||||
|
|
@ -146,7 +146,9 @@
|
|||
function updateMessageText(idx: number, text: string) {
|
||||
const next = [...output];
|
||||
const item = { ...next[idx] };
|
||||
const parts = (item.content ?? []).filter((p: any) => p.type === 'output_text' || 'text' in p);
|
||||
const parts = (item.content ?? []).filter(
|
||||
(p: any) => p && (p.type === 'output_text' || 'text' in p)
|
||||
);
|
||||
item.content = [{ ...(parts[0] ?? { type: 'output_text' }), text }];
|
||||
next[idx] = item;
|
||||
output = next;
|
||||
|
|
@ -155,7 +157,7 @@
|
|||
|
||||
function getReasoningText(item: any): string {
|
||||
return (item.summary ?? item.content ?? [])
|
||||
.filter((p: any) => 'text' in p)
|
||||
.filter((p: any) => p && 'text' in p)
|
||||
.map((p: any) => p.text ?? '')
|
||||
.join('');
|
||||
}
|
||||
|
|
@ -270,7 +272,7 @@
|
|||
{#if di.type === 'message'}
|
||||
<textarea
|
||||
use:fitContent
|
||||
class="w-full bg-transparent outline-hidden resize-none overflow-hidden text-[0.9375rem] p-1.5 rounded-lg"
|
||||
class="w-full bg-transparent outline-hidden focus-visible:outline-none! resize-none overflow-hidden text-[0.9375rem] p-1.5 rounded-lg"
|
||||
value={getMessageText(di.item)}
|
||||
on:input={(e) => {
|
||||
updateMessageText(di.indices[0], e.target.value);
|
||||
|
|
@ -282,7 +284,7 @@
|
|||
{:else if di.type === 'reasoning'}
|
||||
<textarea
|
||||
use:fitContent
|
||||
class="w-full bg-transparent outline-hidden resize-none overflow-hidden text-[0.9375rem] text-gray-500 dark:text-gray-400 p-1.5 rounded-lg"
|
||||
class="w-full bg-transparent outline-hidden focus-visible:outline-none! resize-none overflow-hidden text-[0.9375rem] text-gray-500 dark:text-gray-400 p-1.5 rounded-lg"
|
||||
value={getReasoningText(di.item)}
|
||||
on:input={(e) => {
|
||||
updateReasoningText(di.indices[0], e.target.value);
|
||||
|
|
|
|||
|
|
@ -731,7 +731,12 @@
|
|||
{/if}
|
||||
|
||||
{#if edit === true}
|
||||
<div class="w-full bg-gray-50 dark:bg-gray-800 rounded-3xl px-3 py-3 my-2">
|
||||
<div
|
||||
class="w-full bg-gray-50 dark:bg-gray-800 rounded-3xl px-3 py-3 my-2 {($settings?.highContrastMode ??
|
||||
false)
|
||||
? 'focus-within:outline focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-blue-500'
|
||||
: ''}"
|
||||
>
|
||||
{#if editedOutput}
|
||||
<!-- Structured output editor (visual + JSON toggle) -->
|
||||
<OutputEditView
|
||||
|
|
@ -745,7 +750,7 @@
|
|||
<textarea
|
||||
id="message-edit-{message.id}"
|
||||
bind:this={editTextAreaElement}
|
||||
class=" bg-transparent outline-hidden w-full resize-none text-[0.9375rem]"
|
||||
class=" bg-transparent outline-hidden focus-visible:outline-none! w-full resize-none text-[0.9375rem]"
|
||||
bind:value={editedContent}
|
||||
on:input={(e) => {
|
||||
const messagesContainer = document.getElementById('messages-container');
|
||||
|
|
|
|||
|
|
@ -163,7 +163,9 @@
|
|||
</div>
|
||||
</ConsecutiveDetailsGroup>
|
||||
{:else if displayItem.type === 'file'}
|
||||
<TerminalOutputFile item={displayItem.item} {chatId} />
|
||||
{#if displayItem.item?.displayed || $settings?.terminalFileDisplay === 'inline'}
|
||||
<TerminalOutputFile item={displayItem.item} {chatId} />
|
||||
{/if}
|
||||
{:else}
|
||||
{@const detailToken = displayItem.token}
|
||||
{#if detailToken.attributes?.type === 'tool_calls'}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
showFileNavPath,
|
||||
terminalServers
|
||||
} from '$lib/stores';
|
||||
import { downloadFileBlob, readFile } from '$lib/apis/terminal';
|
||||
import { downloadFileBlob, downloadFilePreview, readFile } from '$lib/apis/terminal';
|
||||
import FilePreview from '$lib/components/chat/FileNav/FilePreview.svelte';
|
||||
import Icon from '$lib/components/chat/FileNav/Icon.svelte';
|
||||
import { fileIconName } from '$lib/components/chat/FileNav/fileIcon';
|
||||
|
|
@ -143,26 +143,59 @@
|
|||
else if (isVideo(path)) fileVideoUrl = url;
|
||||
else fileAudioUrl = url;
|
||||
} else if (isPdf(path) || isSqlite(path) || isOffice(path)) {
|
||||
const result = await blobForPreview();
|
||||
if (!result) throw new Error(t('Preview failed'));
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
const ext = getExt(path);
|
||||
|
||||
if (isPdf(path)) {
|
||||
const result = await blobForPreview();
|
||||
if (!result) throw new Error(t('Preview failed'));
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
filePdfData = arrayBuffer;
|
||||
} else if (isSqlite(path)) {
|
||||
const result = await blobForPreview();
|
||||
if (!result) throw new Error(t('Preview failed'));
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
fileSqliteData = arrayBuffer;
|
||||
} else if (ext === 'docx') {
|
||||
fileDocxData = arrayBuffer;
|
||||
const preview = await downloadFilePreview(
|
||||
terminal.url,
|
||||
terminal.key,
|
||||
path,
|
||||
chatId || undefined
|
||||
);
|
||||
if (preview) {
|
||||
filePdfData = await preview.blob.arrayBuffer();
|
||||
} else {
|
||||
const result = await blobForPreview();
|
||||
if (!result) throw new Error(t('Preview failed'));
|
||||
fileDocxData = await result.blob.arrayBuffer();
|
||||
}
|
||||
} else if (ext === 'xlsx' || ext === 'xls') {
|
||||
const result = await blobForPreview();
|
||||
if (!result) throw new Error(t('Preview failed'));
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
const XLSX = await import('xlsx');
|
||||
excelWorkbook = XLSX.read(new Uint8Array(arrayBuffer), { type: 'array' });
|
||||
excelSheetNames = excelWorkbook.SheetNames;
|
||||
if (excelSheetNames.length > 0) await loadExcelSheet(excelSheetNames[0]);
|
||||
if (excelSheetNames.length > 0) {
|
||||
await loadExcelSheet(excelSheetNames[0]);
|
||||
}
|
||||
} else if (ext === 'pptx') {
|
||||
const { pptxToImages } = await import('$lib/utils/pptxToHtml');
|
||||
const result = await pptxToImages(arrayBuffer);
|
||||
fileOfficeSlides = result.images;
|
||||
const preview = await downloadFilePreview(
|
||||
terminal.url,
|
||||
terminal.key,
|
||||
path,
|
||||
chatId || undefined
|
||||
);
|
||||
if (preview) {
|
||||
filePdfData = await preview.blob.arrayBuffer();
|
||||
} else {
|
||||
const result = await blobForPreview();
|
||||
if (!result) throw new Error(t('Preview failed'));
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
const { pptxToImages } = await import('$lib/utils/pptxToHtml');
|
||||
const resultImages = await pptxToImages(arrayBuffer);
|
||||
fileOfficeSlides = resultImages.images;
|
||||
}
|
||||
}
|
||||
} else if (terminal) {
|
||||
fileContent = await readFile(terminal.url, terminal.key, path, chatId || undefined);
|
||||
|
|
|
|||
|
|
@ -202,7 +202,12 @@
|
|||
{/if}
|
||||
|
||||
{#if edit === true}
|
||||
<div class=" w-full bg-gray-50 dark:bg-gray-800 rounded-3xl px-4 py-3 mb-2">
|
||||
<div
|
||||
class=" w-full bg-gray-50 dark:bg-gray-800 rounded-3xl px-4 py-3 mb-2 {($settings?.highContrastMode ??
|
||||
false)
|
||||
? 'focus-within:outline focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-blue-500'
|
||||
: ''}"
|
||||
>
|
||||
{#if (editedFiles ?? []).length > 0}
|
||||
<div class="flex items-center flex-wrap gap-2 -mx-2 mb-1">
|
||||
{#each editedFiles as file, fileIdx}
|
||||
|
|
@ -273,7 +278,7 @@
|
|||
<textarea
|
||||
id="message-edit-{message.id}"
|
||||
bind:this={messageEditTextAreaElement}
|
||||
class=" bg-transparent outline-hidden w-full resize-none text-[0.9375rem]"
|
||||
class=" bg-transparent outline-hidden focus-visible:outline-none! w-full resize-none text-[0.9375rem]"
|
||||
bind:value={editedContent}
|
||||
on:input={(e) => {
|
||||
const messagesContainer = document.getElementById('messages-container');
|
||||
|
|
|
|||
|
|
@ -179,7 +179,6 @@ function getInlineFileFromToolOutput(callItem?: OutputItem, resultItem?: OutputI
|
|||
typeof result !== 'object' ||
|
||||
result.type !== 'file' ||
|
||||
result.source !== 'open_terminal' ||
|
||||
result.displayed !== true ||
|
||||
result.exists === false ||
|
||||
!result.path ||
|
||||
!result.terminal_selector
|
||||
|
|
@ -374,7 +373,11 @@ export function buildOutputDisplayItems(output: OutputItem[] = []): OutputDispla
|
|||
};
|
||||
|
||||
output.forEach((item, index) => {
|
||||
if (item?.type === 'function_call_output') {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.type === 'function_call_output') {
|
||||
const inlineFile = getInlineFileFromToolOutput(toolCallByCallId[item.call_id ?? ''], item);
|
||||
if (inlineFile) {
|
||||
flushDetails();
|
||||
|
|
@ -388,14 +391,14 @@ export function buildOutputDisplayItems(output: OutputItem[] = []): OutputDispla
|
|||
}
|
||||
|
||||
if (
|
||||
item?.type === 'function_call' &&
|
||||
item.type === 'function_call' &&
|
||||
item.name === 'ask_user' &&
|
||||
(item.status === 'pending' || item.status === 'in_progress')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item?.type && GROUPABLE_OUTPUT_TYPES.has(item.type)) {
|
||||
if (item.type && GROUPABLE_OUTPUT_TYPES.has(item.type)) {
|
||||
const token = buildDetailToken(item, index === output.length - 1, toolOutputByCallId);
|
||||
if (token) {
|
||||
currentDetailTokens.push(token);
|
||||
|
|
@ -403,7 +406,7 @@ export function buildOutputDisplayItems(output: OutputItem[] = []): OutputDispla
|
|||
return;
|
||||
}
|
||||
|
||||
if (item?.type === 'message') {
|
||||
if (item.type === 'message') {
|
||||
const text = getMessageText(item);
|
||||
if (text.trim()) {
|
||||
flushDetails();
|
||||
|
|
@ -462,9 +465,12 @@ function ensureOutputItem(
|
|||
fallback?: OutputItem
|
||||
): OutputItem {
|
||||
while (output.length <= outputIndex) {
|
||||
output.push(
|
||||
fallback ?? { type: 'message', status: 'in_progress', role: 'assistant', content: [] }
|
||||
);
|
||||
// Only the addressed slot gets the event's item; filler slots must not reuse its id.
|
||||
const item =
|
||||
output.length === outputIndex && fallback
|
||||
? { ...fallback }
|
||||
: { type: 'message', status: 'in_progress', role: 'assistant', content: [] };
|
||||
output.push(item);
|
||||
}
|
||||
output[outputIndex] = { ...output[outputIndex] };
|
||||
return output[outputIndex];
|
||||
|
|
@ -478,6 +484,17 @@ function ensurePart(parts: OutputContentPart[], index: number, fallback?: Output
|
|||
return parts[index];
|
||||
}
|
||||
|
||||
function setPart(
|
||||
parts: OutputContentPart[],
|
||||
index: number,
|
||||
part: OutputContentPart,
|
||||
fallback?: OutputContentPart
|
||||
): void {
|
||||
// Assigning past the end leaves a hole that later spreads turn into undefined parts.
|
||||
ensurePart(parts, index, fallback);
|
||||
parts[index] = part;
|
||||
}
|
||||
|
||||
function findOutputItemIndex(output: OutputItem[], item: OutputItem): number {
|
||||
return output.findIndex(
|
||||
(existing) =>
|
||||
|
|
@ -526,7 +543,7 @@ export function applyResponseStreamEvent(
|
|||
} else if (outputIndex < nextOutput.length) {
|
||||
nextOutput.splice(outputIndex, 0, item);
|
||||
} else {
|
||||
nextOutput[outputIndex] = item;
|
||||
nextOutput.push(item);
|
||||
}
|
||||
return nextOutput;
|
||||
}
|
||||
|
|
@ -537,7 +554,13 @@ export function applyResponseStreamEvent(
|
|||
}
|
||||
const item = { ...event.item };
|
||||
const existingIndex = findOutputItemIndex(nextOutput, item);
|
||||
nextOutput[existingIndex >= 0 ? existingIndex : outputIndex] = item;
|
||||
if (existingIndex >= 0) {
|
||||
nextOutput[existingIndex] = item;
|
||||
} else if (outputIndex < nextOutput.length) {
|
||||
nextOutput[outputIndex] = item;
|
||||
} else {
|
||||
nextOutput.push(item);
|
||||
}
|
||||
return nextOutput;
|
||||
}
|
||||
|
||||
|
|
@ -562,7 +585,7 @@ export function applyResponseStreamEvent(
|
|||
return nextOutput;
|
||||
}
|
||||
item.content = [...(item.content ?? [])];
|
||||
item.content[event.content_index ?? item.content.length] = { ...event.part };
|
||||
setPart(item.content, event.content_index ?? item.content.length, { ...event.part });
|
||||
return nextOutput;
|
||||
}
|
||||
|
||||
|
|
@ -571,7 +594,8 @@ export function applyResponseStreamEvent(
|
|||
return nextOutput;
|
||||
}
|
||||
item.summary = [...(item.summary ?? [])];
|
||||
item.summary[event.summary_index ?? item.summary.length] = { ...event.part };
|
||||
const summaryIndex = event.summary_index ?? item.summary.length;
|
||||
setPart(item.summary, summaryIndex, { ...event.part }, { type: 'summary_text', text: '' });
|
||||
return nextOutput;
|
||||
}
|
||||
|
||||
|
|
@ -601,7 +625,8 @@ export function applyResponseStreamEvent(
|
|||
const typeName = eventType.split('.')[1];
|
||||
if (typeName === 'content_part' && event.part) {
|
||||
item.content = [...(item.content ?? [])];
|
||||
item.content[event.content_index ?? Math.max(item.content.length - 1, 0)] = { ...event.part };
|
||||
const contentIndex = event.content_index ?? Math.max(item.content.length - 1, 0);
|
||||
setPart(item.content, contentIndex, { ...event.part });
|
||||
} else if (typeName === 'function_call_arguments' && event.arguments !== undefined) {
|
||||
item.arguments = event.arguments;
|
||||
} else if (
|
||||
|
|
@ -633,7 +658,7 @@ export function replaceOutputMessageText(
|
|||
}
|
||||
|
||||
const partIndex = item.content.findIndex(
|
||||
(part) => typeof part.text === 'string' && part.text.includes(oldContent)
|
||||
(part) => typeof part?.text === 'string' && part.text.includes(oldContent)
|
||||
);
|
||||
if (partIndex === -1) {
|
||||
return item;
|
||||
|
|
|
|||
|
|
@ -54,10 +54,19 @@
|
|||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-label={$i18n.t('Select {{modelName}} model', { modelName: item.label })}
|
||||
class="focus-ring group/item flex h-8 w-full cursor-pointer select-none items-center rounded-xl px-2 text-left text-[0.8125rem] font-normal text-gray-700 outline-hidden transition-colors duration-75 hover:bg-gray-50/40 dark:text-gray-100 dark:hover:bg-gray-800/40 {index ===
|
||||
selectedModelIdx && !compareEnabled
|
||||
? 'bg-gray-50/70 dark:bg-gray-800/60'
|
||||
: ''} {isSelected ? 'bg-gray-50/70 dark:bg-gray-800/60' : ''}"
|
||||
class="focus-ring group/item flex h-8 w-full cursor-pointer select-none items-center rounded-xl px-2 text-left text-[0.8125rem] font-normal text-gray-700 outline-hidden transition-colors duration-75 dark:text-gray-100 {($settings?.highContrastMode ??
|
||||
false)
|
||||
? 'hover:bg-gray-200 dark:hover:bg-gray-800'
|
||||
: 'hover:bg-gray-50/40 dark:hover:bg-gray-800/40'} {index === selectedModelIdx &&
|
||||
!compareEnabled
|
||||
? ($settings?.highContrastMode ?? false)
|
||||
? 'bg-gray-200 dark:bg-gray-800'
|
||||
: 'bg-gray-50/70 dark:bg-gray-800/60'
|
||||
: ''} {isSelected
|
||||
? ($settings?.highContrastMode ?? false)
|
||||
? 'bg-gray-200 dark:bg-gray-800'
|
||||
: 'bg-gray-50/70 dark:bg-gray-800/60'
|
||||
: ''}"
|
||||
data-arrow-selected={index === selectedModelIdx}
|
||||
data-value={item.value}
|
||||
on:click={() => {
|
||||
|
|
|
|||
|
|
@ -1081,8 +1081,12 @@
|
|||
<button
|
||||
type="button"
|
||||
class="focus-ring flex size-[1.375rem] shrink-0 items-center justify-center rounded-lg transition-colors duration-100 {compareEnabled
|
||||
? 'bg-gray-50 text-gray-700 hover:bg-gray-50 dark:bg-gray-800/60 dark:text-gray-200 dark:hover:bg-gray-800/60'
|
||||
: 'text-gray-500 hover:bg-gray-50/40 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800/40 dark:hover:text-gray-200'}"
|
||||
? ($settings?.highContrastMode ?? false)
|
||||
? 'bg-gray-200 text-gray-900 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-800'
|
||||
: 'bg-gray-50 text-gray-700 hover:bg-gray-50 dark:bg-gray-800/60 dark:text-gray-200 dark:hover:bg-gray-800/60'
|
||||
: ($settings?.highContrastMode ?? false)
|
||||
? 'text-gray-700 hover:bg-gray-200 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100'
|
||||
: 'text-gray-500 hover:bg-gray-50/40 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800/40 dark:hover:text-gray-100'}"
|
||||
aria-label={$i18n.t('Compare')}
|
||||
aria-pressed={compareEnabled}
|
||||
on:click={() => {
|
||||
|
|
@ -1100,8 +1104,14 @@
|
|||
placeholder={$i18n.t('All')}
|
||||
align="end"
|
||||
items={modelFilterItems}
|
||||
triggerClass="relative flex h-[1.375rem] max-w-32 items-center gap-0.5 rounded-xl bg-transparent px-1.5 text-[0.6875rem] font-normal text-gray-400 transition-colors duration-100 hover:bg-gray-50/40 hover:text-gray-600 dark:text-gray-500 dark:hover:bg-gray-800/40 dark:hover:text-gray-300"
|
||||
itemClass="flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-[0.8125rem] capitalize hover:bg-gray-50/40 hover:text-gray-900 dark:hover:bg-gray-800/40 dark:hover:text-gray-100"
|
||||
triggerClass="relative flex h-[1.375rem] max-w-32 items-center gap-0.5 rounded-xl bg-transparent px-1.5 text-[0.6875rem] font-normal transition-colors duration-100 {($settings?.highContrastMode ??
|
||||
false)
|
||||
? 'text-gray-700 hover:bg-gray-200 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100'
|
||||
: 'text-gray-500 hover:bg-gray-50/40 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800/40 dark:hover:text-gray-100'}"
|
||||
itemClass="flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-[0.8125rem] capitalize {($settings?.highContrastMode ??
|
||||
false)
|
||||
? 'hover:bg-gray-200 hover:text-gray-900 dark:hover:bg-gray-800 dark:hover:text-gray-100'
|
||||
: 'hover:bg-gray-50/40 hover:text-gray-900 dark:hover:bg-gray-800/40 dark:hover:text-gray-100'}"
|
||||
contentClass="min-w-36 model-selector-child-menu"
|
||||
onChange={setModelFilter}
|
||||
/>
|
||||
|
|
@ -1195,7 +1205,9 @@
|
|||
data-arrow-selected={selectedModelIdx === filteredItems.length + targetIndex}
|
||||
class="flex h-8 w-full select-none items-center gap-2 rounded-xl px-2 text-left text-[0.8125rem] font-normal text-gray-700 outline-hidden transition-colors duration-75 dark:text-gray-100 {selectedModelIdx ===
|
||||
filteredItems.length + targetIndex
|
||||
? 'bg-gray-50/70 dark:bg-gray-800/60'
|
||||
? ($settings?.highContrastMode ?? false)
|
||||
? 'bg-gray-200 dark:bg-gray-800'
|
||||
: 'bg-gray-50/70 dark:bg-gray-800/60'
|
||||
: ''}"
|
||||
>
|
||||
<Spinner className="size-3 shrink-0 text-gray-400 dark:text-gray-500" />
|
||||
|
|
@ -1243,9 +1255,14 @@
|
|||
role="option"
|
||||
aria-selected={selectedModelIdx === filteredItems.length + targetIndex}
|
||||
data-arrow-selected={selectedModelIdx === filteredItems.length + targetIndex}
|
||||
class="focus-ring flex h-8 w-full cursor-pointer select-none items-center gap-2 rounded-xl px-2 text-left text-[0.8125rem] font-normal text-gray-700 outline-hidden transition-colors duration-75 hover:bg-gray-50/40 dark:text-gray-100 dark:hover:bg-gray-800/40 {selectedModelIdx ===
|
||||
class="focus-ring flex h-8 w-full cursor-pointer select-none items-center gap-2 rounded-xl px-2 text-left text-[0.8125rem] font-normal text-gray-700 outline-hidden transition-colors duration-75 dark:text-gray-100 {($settings?.highContrastMode ??
|
||||
false)
|
||||
? 'hover:bg-gray-200 dark:hover:bg-gray-800'
|
||||
: 'hover:bg-gray-50/40 dark:hover:bg-gray-800/40'} {selectedModelIdx ===
|
||||
filteredItems.length + targetIndex
|
||||
? 'bg-gray-50/70 dark:bg-gray-800/60'
|
||||
? ($settings?.highContrastMode ?? false)
|
||||
? 'bg-gray-200 dark:bg-gray-800'
|
||||
: 'bg-gray-50/70 dark:bg-gray-800/60'
|
||||
: ''}"
|
||||
on:click={() => {
|
||||
downloadModelHandler(target);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { updateUserPassword, userSignOut } from '$lib/apis/auths';
|
||||
import { user } from '$lib/stores';
|
||||
import { getLogoutRedirectUrl, updateUserPassword, userSignOut } from '$lib/apis/auths';
|
||||
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
|
@ -27,17 +26,13 @@
|
|||
// This session is no longer trusted once the password it was issued under changes
|
||||
toast.success($i18n.t('Password updated. Please sign in again.'));
|
||||
|
||||
localStorage.removeItem('token');
|
||||
user.set(null);
|
||||
|
||||
const signOutRes = await userSignOut().catch((error) => {
|
||||
console.error(error);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (signOutRes?.redirect_url) {
|
||||
location.href = signOutRes.redirect_url;
|
||||
}
|
||||
localStorage.removeItem('token');
|
||||
location.href = getLogoutRedirectUrl(signOutRes?.redirect_url);
|
||||
}
|
||||
|
||||
currentPassword = '';
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
<slot name="label">{label}</slot>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0">
|
||||
<div class="min-w-0">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -268,6 +268,8 @@
|
|||
'full width mode',
|
||||
'haptic feedback',
|
||||
'hapticfeedback',
|
||||
'accessibility mode',
|
||||
'accessibilitymode',
|
||||
'high contrast mode',
|
||||
'highcontrastmode',
|
||||
'iframe sandbox allow forms',
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
</script>
|
||||
|
||||
<div
|
||||
class={`rounded-xl! p-0.5! border border-gray-100 dark:border-gray-800 z-50 bg-white text-gray-900 dark:bg-gray-850 dark:text-white shadow-lg ${className} [&_button:hover]:bg-gray-50/40! dark:[&_button:hover]:bg-gray-800/40! [&_a:hover]:bg-gray-50/40! dark:[&_a:hover]:bg-gray-800/40! [&_button:hover]:text-gray-900 dark:[&_button:hover]:text-gray-100 [&_a:hover]:text-gray-900 dark:[&_a:hover]:text-gray-100 [&>button]:flex [&>button]:w-full [&>button]:h-[1.6875rem]! [&>button]:items-center [&>button]:gap-2! [&>button]:rounded-xl! [&>button]:bg-transparent [&>button]:px-2! [&>button]:text-[0.8125rem]! [&>button]:font-normal! [&>a]:flex [&>a]:w-full [&>a]:h-[1.6875rem]! [&>a]:items-center [&>a]:gap-2! [&>a]:rounded-xl! [&>a]:bg-transparent [&>a]:px-2! [&>a]:text-[0.8125rem]! [&>a]:font-normal! [&_button_svg]:size-3.5! [&_a_svg]:size-3.5! [&>hr]:my-0.5 [&>hr]:mx-1`}
|
||||
class={`app-dropdown-menu rounded-xl! p-0.5! border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg ${className} [&>button]:flex [&>button]:w-full [&>button]:h-[1.6875rem] [&>button]:items-center [&>button]:gap-2 [&>button]:rounded-xl! [&>button]:px-2! [&>button]:text-[13px] [&>button:hover]:bg-gray-50/60 dark:[&>button:hover]:bg-gray-800/60 [&>a]:flex [&>a]:w-full [&>a]:h-[1.6875rem] [&>a]:items-center [&>a]:gap-2 [&>a]:rounded-xl! [&>a]:px-2! [&>a]:text-[13px] [&>a:hover]:bg-gray-50/60 dark:[&>a:hover]:bg-gray-800/60 [&>button_svg]:size-3.5 [&>a_svg]:size-3.5 [&>hr]:my-0.5 [&>hr]:mx-1`}
|
||||
{style}
|
||||
>
|
||||
<slot />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { flyAndScale } from '$lib/utils/transitions';
|
||||
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
|
||||
import { settings } from '$lib/stores';
|
||||
import { tick } from 'svelte';
|
||||
|
||||
/** CSS classes for the sub-content container */
|
||||
|
|
@ -153,7 +154,14 @@
|
|||
<svelte:window on:scroll|capture={positionContent} on:resize={positionContent} />
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div use:trigger class="w-full" on:mouseenter={handleMouseEnter} on:mouseleave={handleMouseLeave}>
|
||||
<div
|
||||
use:trigger
|
||||
class="w-full {($settings?.highContrastMode ?? false)
|
||||
? '[&>button:hover]:bg-gray-200! dark:[&>button:hover]:bg-gray-800!'
|
||||
: '[&>button:hover]:bg-gray-50/60 dark:[&>button:hover]:bg-gray-800/60'}"
|
||||
on:mouseenter={handleMouseEnter}
|
||||
on:mouseleave={handleMouseLeave}
|
||||
>
|
||||
<slot name="trigger" />
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
import { toast } from 'svelte-sonner';
|
||||
import { updateUserInfo } from '$lib/apis/users';
|
||||
import { getUserPosition } from '$lib/utils';
|
||||
import { setTextScale } from '$lib/utils/text-scale';
|
||||
import { normalizeAppFontFamily, setAppFontFamily, setTextScale } from '$lib/utils/text-scale';
|
||||
|
||||
import Minus from '$lib/components/icons/Minus.svelte';
|
||||
import Plus from '$lib/components/icons/Plus.svelte';
|
||||
|
|
@ -115,7 +115,10 @@
|
|||
let showManageImageCompressionModal = false;
|
||||
|
||||
let textScale: number | null = null;
|
||||
let fontFamily: string | null = null;
|
||||
let fontFamilyInput = '';
|
||||
let showTextScaleSlider = false;
|
||||
let showFontFamilyInput = false;
|
||||
const settingRowClass = 'flex items-center justify-between gap-2.5';
|
||||
const settingLabelClass = 'min-w-0 text-xs text-gray-600 dark:text-gray-400';
|
||||
const settingControlClass = 'flex shrink-0 items-center justify-end gap-1.5';
|
||||
|
|
@ -234,9 +237,18 @@
|
|||
};
|
||||
|
||||
export const save = async () => {
|
||||
const normalizedFontFamily = normalizeAppFontFamily(fontFamilyInput) || null;
|
||||
fontFamily = normalizedFontFamily;
|
||||
fontFamilyInput = normalizedFontFamily ?? '';
|
||||
|
||||
if (!externalSettings) {
|
||||
setAppFontFamily(fontFamily || defaultFontFamily());
|
||||
}
|
||||
|
||||
saveSettings({
|
||||
models: [defaultModelId],
|
||||
imageCompressionSize: imageCompressionSize
|
||||
imageCompressionSize: imageCompressionSize,
|
||||
fontFamily
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -255,6 +267,51 @@
|
|||
saveSettings({ textScale });
|
||||
};
|
||||
|
||||
const defaultFontFamily = () => normalizeAppFontFamily(defaultSettings.fontFamily) || null;
|
||||
|
||||
const previewFontFamily = (value: string) => {
|
||||
fontFamilyInput = value;
|
||||
fontFamily = normalizeAppFontFamily(value) || null;
|
||||
|
||||
if (!externalSettings) {
|
||||
setAppFontFamily(fontFamily || defaultFontFamily());
|
||||
}
|
||||
};
|
||||
|
||||
const setFontFamilyHandler = (value: string | null) => {
|
||||
const normalized = normalizeAppFontFamily(value);
|
||||
fontFamily = normalized || null;
|
||||
fontFamilyInput = normalized;
|
||||
|
||||
if (!externalSettings) {
|
||||
setAppFontFamily(fontFamily || defaultFontFamily());
|
||||
}
|
||||
|
||||
saveSettings({ fontFamily });
|
||||
};
|
||||
|
||||
const toggleFontFamilyInput = () => {
|
||||
if (!showFontFamilyInput && (fontFamily === null || isDefaultSetting('fontFamily'))) {
|
||||
fontFamily = fontFamily ?? defaultFontFamily();
|
||||
fontFamilyInput = fontFamily ?? '';
|
||||
showFontFamilyInput = true;
|
||||
|
||||
if (!externalSettings) {
|
||||
setAppFontFamily(fontFamily);
|
||||
}
|
||||
} else {
|
||||
showFontFamilyInput = false;
|
||||
fontFamily = null;
|
||||
fontFamilyInput = '';
|
||||
|
||||
if (!externalSettings) {
|
||||
setAppFontFamily(defaultFontFamily());
|
||||
}
|
||||
|
||||
saveSettings({ fontFamily });
|
||||
}
|
||||
};
|
||||
|
||||
const init = () => {
|
||||
titleAutoGenerate = currentSettings?.title?.auto ?? true;
|
||||
autoTags = currentSettings?.autoTags ?? true;
|
||||
|
|
@ -338,7 +395,10 @@
|
|||
webSearch = currentSettings?.webSearch ?? null;
|
||||
|
||||
textScale = currentSettings?.textScale ?? null;
|
||||
fontFamily = normalizeAppFontFamily(currentSettings?.fontFamily) || null;
|
||||
fontFamilyInput = fontFamily ?? '';
|
||||
showTextScaleSlider = false;
|
||||
showFontFamilyInput = false;
|
||||
|
||||
defaultUploadContext = currentSettings?.defaultUploadContext ?? 'focused';
|
||||
};
|
||||
|
|
@ -490,13 +550,53 @@
|
|||
|
||||
<div>
|
||||
<div class={settingRowClass}>
|
||||
<div id="high-contrast-mode-label" class={settingLabelClass}>
|
||||
{$i18n.t('High Contrast Mode')}
|
||||
<label id="font-family-label" class={settingLabelClass} for="font-family-input">
|
||||
{$i18n.t('Font Family')}
|
||||
</label>
|
||||
|
||||
<div class={settingControlClass}>
|
||||
<button
|
||||
class={actionButtonClass}
|
||||
aria-labelledby="font-family-label font-family-state"
|
||||
type="button"
|
||||
on:click={toggleFontFamilyInput}
|
||||
>
|
||||
<span id="font-family-state">
|
||||
{!showFontFamilyInput && (fontFamily === null || isDefaultSetting('fontFamily'))
|
||||
? $i18n.t('Default')
|
||||
: $i18n.t('Custom')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showFontFamilyInput || (fontFamily !== null && !isDefaultSetting('fontFamily'))}
|
||||
<div class="mt-1.5 flex items-center pb-1">
|
||||
<input
|
||||
id="font-family-input"
|
||||
class="h-7 w-full rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500"
|
||||
type="text"
|
||||
placeholder="Aptos"
|
||||
bind:value={fontFamilyInput}
|
||||
on:input={(event) => previewFontFamily(event.currentTarget.value)}
|
||||
on:change={(event) => setFontFamilyHandler(event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<p class={settingDescriptionClass}>
|
||||
{$i18n.t('Use a local font family for the app interface.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class={settingRowClass}>
|
||||
<div id="accessibility-mode-label" class={settingLabelClass}>
|
||||
{$i18n.t('Accessibility Mode')}
|
||||
</div>
|
||||
|
||||
<div class={settingControlClass}>
|
||||
<Switch
|
||||
ariaLabelledbyId="high-contrast-mode-label"
|
||||
ariaLabelledbyId="accessibility-mode-label"
|
||||
tooltip={true}
|
||||
bind:state={highContrastMode}
|
||||
inherited={isDefaultSetting('highContrastMode')}
|
||||
|
|
|
|||
|
|
@ -62,11 +62,20 @@
|
|||
return (
|
||||
target instanceof Element &&
|
||||
!!target.closest(
|
||||
'input, textarea, select, [role="menu"], [contenteditable="true"], [data-sidebar-no-gesture]'
|
||||
'input, textarea, select, [role="dialog"], [role="menu"], [contenteditable="true"], [data-sidebar-no-gesture]'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const hasTextSelection = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
return !!selection && !selection.isCollapsed;
|
||||
};
|
||||
|
||||
const canScrollHorizontally = (target: EventTarget | null, dx: number) => {
|
||||
if (!(target instanceof Element) || dx === 0 || typeof window === 'undefined') {
|
||||
return false;
|
||||
|
|
@ -161,7 +170,7 @@
|
|||
};
|
||||
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
if (!enabled || e.touches.length !== 1 || shouldSkipSwipe(e.target)) {
|
||||
if (!enabled || e.touches.length !== 1 || shouldSkipSwipe(e.target) || hasTextSelection()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -205,6 +214,11 @@
|
|||
const absY = Math.abs(dy);
|
||||
|
||||
if (!locked) {
|
||||
if (hasTextSelection()) {
|
||||
cancelSwipe();
|
||||
return;
|
||||
}
|
||||
|
||||
if ((direction === 'open' && dx > 0) || (direction === 'close' && dx < 0)) {
|
||||
swipeProgress = Math.max(0, Math.min(1, startProgress + dx / panelWidth));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@
|
|||
export let data: ArrayBuffer | Uint8Array | null = null;
|
||||
export let className = 'w-full h-[70vh]';
|
||||
export let targetPage: number | null = null;
|
||||
export let singlePage = false;
|
||||
export let itemLabel = 'Page';
|
||||
export let onPageChange: ((page: number) => void) | null = null;
|
||||
|
||||
type PdfDocument = import('pdfjs-dist').PDFDocumentProxy;
|
||||
type PdfTextLayer = InstanceType<typeof import('pdfjs-dist').TextLayer>;
|
||||
|
|
@ -22,10 +25,28 @@
|
|||
let zoomLevel = 1;
|
||||
let rerenderTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastRenderedZoom = 1;
|
||||
let pageCount = 0;
|
||||
let renderedPage = 0;
|
||||
let activePage = 1;
|
||||
let loadToken = 0;
|
||||
let renderToken = 0;
|
||||
let scrollFrame: number | null = null;
|
||||
let mounted = false;
|
||||
let loadedSource: ArrayBuffer | Uint8Array | string | null = null;
|
||||
let wheelDelta = 0;
|
||||
let lastWheelNavigationAt = 0;
|
||||
const wheelNavigationThreshold = 80;
|
||||
const wheelNavigationCooldown = 450;
|
||||
const pageShortcutKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'];
|
||||
|
||||
$: selectedPage = singlePage ? (clampDocumentTargetPage(targetPage, pageCount) ?? 1) : activePage;
|
||||
|
||||
// Keep a reference to TextLayer instances so we can update/cancel them
|
||||
let textLayerInstances: PdfTextLayer[] = [];
|
||||
|
||||
const copyPdfData = (pdfData: ArrayBuffer | Uint8Array) =>
|
||||
pdfData instanceof Uint8Array ? pdfData.slice() : pdfData.slice(0);
|
||||
|
||||
const cancelTextLayers = () => {
|
||||
for (const tl of textLayerInstances) {
|
||||
try {
|
||||
|
|
@ -53,8 +74,11 @@
|
|||
}
|
||||
return false;
|
||||
},
|
||||
beforeMouseDown: () => {
|
||||
beforeMouseDown: (e) => {
|
||||
// Only allow drag-to-pan when zoomed in (not at default scale)
|
||||
if ((e?.target as HTMLElement | null)?.closest?.('.textLayer')) {
|
||||
return true;
|
||||
}
|
||||
const transform = pzInstance?.getTransform();
|
||||
if (transform && Math.abs(transform.scale - 1) < 0.01) {
|
||||
return true; // cancel panzoom mouse handling at 1x — allow text selection / normal interaction
|
||||
|
|
@ -100,16 +124,73 @@
|
|||
}
|
||||
};
|
||||
|
||||
export const scrollToPage = async (page: number | null) => {
|
||||
targetPage = page;
|
||||
if (singlePage) {
|
||||
if (targetPage) onPageChange?.(targetPage);
|
||||
} else {
|
||||
await scrollToTargetPage();
|
||||
}
|
||||
};
|
||||
|
||||
const selectPage = async (page: number) => {
|
||||
if (!pdfDoc) return;
|
||||
const nextPage = clampDocumentTargetPage(page, pdfDoc.numPages);
|
||||
if (!nextPage || nextPage === selectedPage) return;
|
||||
|
||||
targetPage = nextPage;
|
||||
activePage = nextPage;
|
||||
onPageChange?.(nextPage);
|
||||
if (!singlePage) await scrollToTargetPage();
|
||||
};
|
||||
|
||||
const scrollToTargetPage = async () => {
|
||||
if (!outerContainer || !sceneElement || !pdfDoc) return;
|
||||
const page = clampDocumentTargetPage(targetPage, pdfDoc.numPages);
|
||||
if (!page) return;
|
||||
|
||||
if (singlePage) return;
|
||||
|
||||
await tick();
|
||||
const pageWrapper = sceneElement.querySelectorAll('.pdf-page-wrapper')[page - 1] as
|
||||
| HTMLElement
|
||||
| undefined;
|
||||
pageWrapper?.scrollIntoView({ block: 'start' });
|
||||
activePage = page;
|
||||
onPageChange?.(page);
|
||||
};
|
||||
|
||||
const syncVisiblePage = () => {
|
||||
scrollFrame = null;
|
||||
if (singlePage || !outerContainer || !sceneElement || !pdfDoc) return;
|
||||
|
||||
const marker = outerContainer.getBoundingClientRect().top + outerContainer.clientHeight * 0.35;
|
||||
let bestPage = activePage;
|
||||
let bestDistance = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const wrapper of sceneElement.querySelectorAll('.pdf-page-wrapper')) {
|
||||
const el = wrapper as HTMLElement;
|
||||
const page = Number(el.dataset.pageNumber);
|
||||
if (!page) continue;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const distance =
|
||||
marker < rect.top ? rect.top - marker : marker > rect.bottom ? marker - rect.bottom : 0;
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
bestPage = page;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestPage !== activePage) {
|
||||
activePage = bestPage;
|
||||
onPageChange?.(bestPage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if (singlePage || scrollFrame !== null) return;
|
||||
scrollFrame = requestAnimationFrame(syncVisiblePage);
|
||||
};
|
||||
|
||||
// Re-render existing canvases at a new zoom level (preserves panzoom transform)
|
||||
|
|
@ -117,16 +198,15 @@
|
|||
if (!pdfDoc || !sceneElement) return;
|
||||
const pdfjs = await import('pdfjs-dist');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const containerWidth = outerContainer?.clientWidth || 800;
|
||||
|
||||
const pageWrappers = sceneElement.querySelectorAll('.pdf-page-wrapper');
|
||||
|
||||
cancelTextLayers();
|
||||
|
||||
for (let i = 0; i < pageWrappers.length; i++) {
|
||||
const page = await pdfDoc.getPage(i + 1);
|
||||
const page = await pdfDoc.getPage(singlePage ? selectedPage : i + 1);
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
const cssScale = containerWidth / viewport.width;
|
||||
const cssScale = getCssScale(viewport);
|
||||
const renderScale = cssScale * forZoom * dpr;
|
||||
const scaledViewport = page.getViewport({ scale: renderScale });
|
||||
const cssViewport = page.getViewport({ scale: cssScale });
|
||||
|
|
@ -162,8 +242,17 @@
|
|||
lastRenderedZoom = forZoom;
|
||||
};
|
||||
|
||||
const getCssScale = (viewport: { width: number; height: number }) => {
|
||||
if (!singlePage) return (outerContainer?.clientWidth || 800) / viewport.width;
|
||||
|
||||
const availableWidth = Math.max(320, (outerContainer?.clientWidth || 800) - 64);
|
||||
const availableHeight = Math.max(220, (outerContainer?.clientHeight || 600) - 64);
|
||||
return Math.min(1, availableWidth / viewport.width, availableHeight / viewport.height);
|
||||
};
|
||||
|
||||
const renderAllPages = async () => {
|
||||
if (!pdfDoc || !sceneElement) return;
|
||||
const token = ++renderToken;
|
||||
|
||||
// Clear previous content
|
||||
sceneElement.innerHTML = '';
|
||||
|
|
@ -172,14 +261,17 @@
|
|||
|
||||
const pdfjs = await import('pdfjs-dist');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const wrappers: HTMLElement[] = [];
|
||||
const firstPage = singlePage ? selectedPage : 1;
|
||||
const lastPage = singlePage ? selectedPage : pdfDoc.numPages;
|
||||
|
||||
for (let i = 1; i <= pdfDoc.numPages; i++) {
|
||||
for (let i = firstPage; i <= lastPage; i++) {
|
||||
const page = await pdfDoc.getPage(i);
|
||||
if (token !== renderToken) return;
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
|
||||
// Scale to fit container width
|
||||
const containerWidth = outerContainer?.clientWidth || 800;
|
||||
const cssScale = containerWidth / viewport.width;
|
||||
const cssScale = getCssScale(viewport);
|
||||
const renderScale = cssScale * dpr;
|
||||
const scaledViewport = page.getViewport({ scale: renderScale });
|
||||
const cssViewport = page.getViewport({ scale: cssScale });
|
||||
|
|
@ -187,6 +279,7 @@
|
|||
// Create page wrapper (positioned container for canvas + text layer)
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'pdf-page-wrapper';
|
||||
wrapper.dataset.pageNumber = String(i);
|
||||
wrapper.style.position = 'relative';
|
||||
wrapper.style.width = `${Math.round(cssScale * viewport.width)}px`;
|
||||
wrapper.style.height = `${Math.round(cssScale * viewport.height)}px`;
|
||||
|
|
@ -217,6 +310,7 @@
|
|||
canvasContext: ctx,
|
||||
viewport: scaledViewport
|
||||
}).promise;
|
||||
if (token !== renderToken) return;
|
||||
|
||||
// Create text layer overlay — pdfjs setLayerDimensions handles its sizing
|
||||
const textLayerDiv = document.createElement('div');
|
||||
|
|
@ -230,21 +324,87 @@
|
|||
viewport: cssViewport
|
||||
});
|
||||
await textLayer.render();
|
||||
if (token !== renderToken) return;
|
||||
textLayerInstances.push(textLayer);
|
||||
|
||||
sceneElement.appendChild(wrapper);
|
||||
wrappers.push(wrapper);
|
||||
}
|
||||
|
||||
sceneElement.replaceChildren(...wrappers);
|
||||
lastRenderedZoom = 1;
|
||||
renderedPage = singlePage ? selectedPage : 0;
|
||||
initPanzoom();
|
||||
await scrollToTargetPage();
|
||||
syncVisiblePage();
|
||||
};
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (!singlePage) return;
|
||||
if (e.ctrlKey || e.metaKey) return;
|
||||
|
||||
const transform = pzInstance?.getTransform();
|
||||
if (transform && Math.abs(transform.scale - 1) >= 0.01) {
|
||||
e.preventDefault();
|
||||
pzInstance?.moveBy(-e.deltaX, -e.deltaY, false);
|
||||
zoomLevel = pzInstance?.getTransform()?.scale ?? 1;
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
if (pageCount <= 1) return;
|
||||
|
||||
const multiplier = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? outerContainer.clientHeight : 1;
|
||||
const dominantDelta = Math.abs(e.deltaY) >= Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
|
||||
wheelDelta += dominantDelta * multiplier;
|
||||
|
||||
const now = Date.now();
|
||||
if (Math.abs(wheelDelta) < wheelNavigationThreshold) return;
|
||||
if (now - lastWheelNavigationAt < wheelNavigationCooldown) {
|
||||
wheelDelta = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
lastWheelNavigationAt = now;
|
||||
void selectPage(selectedPage + (wheelDelta > 0 ? 1 : -1));
|
||||
wheelDelta = 0;
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (
|
||||
!singlePage ||
|
||||
e.defaultPrevented ||
|
||||
e.altKey ||
|
||||
e.ctrlKey ||
|
||||
e.metaKey ||
|
||||
pageCount <= 1 ||
|
||||
!pageShortcutKeys.includes(e.key)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
void selectPage(selectedPage + (e.key === 'ArrowUp' || e.key === 'ArrowLeft' ? -1 : 1));
|
||||
};
|
||||
|
||||
const focusViewer = () => {
|
||||
if (!outerContainer?.contains(document.activeElement)) outerContainer?.focus();
|
||||
};
|
||||
|
||||
const loadPdf = async () => {
|
||||
if (!url && !data) return;
|
||||
|
||||
const source = data ?? url;
|
||||
if (source === loadedSource && pdfDoc) return;
|
||||
const token = ++loadToken;
|
||||
loadedSource = source;
|
||||
loading = true;
|
||||
error = '';
|
||||
renderedPage = 0;
|
||||
pageCount = 0;
|
||||
pzInstance?.dispose();
|
||||
cancelTextLayers();
|
||||
pdfDoc?.destroy();
|
||||
pdfDoc = null;
|
||||
|
||||
try {
|
||||
const pdfjs = await import('pdfjs-dist');
|
||||
|
|
@ -252,7 +412,7 @@
|
|||
|
||||
let pdfData: ArrayBuffer | Uint8Array;
|
||||
if (data) {
|
||||
pdfData = data;
|
||||
pdfData = copyPdfData(data);
|
||||
} else {
|
||||
// Fetch with credentials so auth cookies are sent
|
||||
const res = await fetch(url!, { credentials: 'include' });
|
||||
|
|
@ -260,24 +420,42 @@
|
|||
pdfData = await res.arrayBuffer();
|
||||
}
|
||||
pdfDoc = await pdfjs.getDocument({ data: pdfData }).promise;
|
||||
if (token !== loadToken) return;
|
||||
pageCount = pdfDoc.numPages;
|
||||
activePage = clampDocumentTargetPage(targetPage, pageCount) ?? 1;
|
||||
targetPage = clampDocumentTargetPage(targetPage, pageCount) ?? 1;
|
||||
await renderAllPages();
|
||||
} catch (e) {
|
||||
console.error('PDF render error:', e);
|
||||
error = 'Failed to load PDF.';
|
||||
if (token === loadToken) {
|
||||
console.error('PDF render error:', e);
|
||||
error = 'Failed to load PDF.';
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
if (token === loadToken) loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
loadPdf();
|
||||
});
|
||||
|
||||
$: if (!loading && pdfDoc && targetPage) {
|
||||
$: if (mounted && (data || url)) {
|
||||
void loadPdf();
|
||||
}
|
||||
|
||||
$: if (!loading && pdfDoc && singlePage && targetPage && selectedPage !== renderedPage) {
|
||||
void renderAllPages();
|
||||
}
|
||||
|
||||
$: if (!loading && pdfDoc && !singlePage && targetPage) {
|
||||
void scrollToTargetPage();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
loadToken++;
|
||||
renderToken++;
|
||||
if (scrollFrame !== null) cancelAnimationFrame(scrollFrame);
|
||||
if (rerenderTimer) clearTimeout(rerenderTimer);
|
||||
pzInstance?.dispose();
|
||||
cancelTextLayers();
|
||||
|
|
@ -299,17 +477,76 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="overflow-y-auto h-full" bind:this={outerContainer}>
|
||||
<div bind:this={sceneElement} class="w-full"></div>
|
||||
<div
|
||||
class={singlePage
|
||||
? 'overflow-hidden h-full flex items-center justify-center overscroll-contain'
|
||||
: 'overflow-y-auto h-full'}
|
||||
bind:this={outerContainer}
|
||||
role="application"
|
||||
aria-label={`${itemLabel} viewer`}
|
||||
tabindex="0"
|
||||
on:scroll={handleScroll}
|
||||
on:wheel|nonpassive={handleWheel}
|
||||
on:pointerdown={focusViewer}
|
||||
on:keydown={handleKeyDown}
|
||||
>
|
||||
<div bind:this={sceneElement} class={singlePage ? '' : 'w-full'}></div>
|
||||
</div>
|
||||
|
||||
{#if !loading && !error && pdfDoc}
|
||||
{#if !error && pdfDoc}
|
||||
<div
|
||||
class="absolute bottom-3 left-1/2 -translate-x-1/2 z-10 flex items-center gap-0.5 rounded-lg bg-white/90 dark:bg-gray-850/90 backdrop-blur-sm shadow-lg border border-gray-200/60 dark:border-gray-700/60 px-1 py-0.5"
|
||||
>
|
||||
{#if singlePage}
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400 disabled:opacity-30"
|
||||
disabled={selectedPage === 1}
|
||||
on:click={() => selectPage(selectedPage - 1)}
|
||||
aria-label={`Previous ${itemLabel.toLowerCase()}`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-3.5"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span
|
||||
class="shrink-0 min-w-12 text-center text-[0.6875rem] text-gray-500 dark:text-gray-400 tabular-nums"
|
||||
>{selectedPage} / {pageCount}</span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400 disabled:opacity-30"
|
||||
disabled={selectedPage === pageCount}
|
||||
on:click={() => selectPage(selectedPage + 1)}
|
||||
aria-label={`Next ${itemLabel.toLowerCase()}`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-3.5"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<!-- Pinch covers in/out on coarse pointers; reset has no gesture, so it stays -->
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400"
|
||||
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400 pointer-coarse:hidden"
|
||||
on:click={zoomOut}
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
|
|
@ -336,7 +573,7 @@
|
|||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400"
|
||||
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400 pointer-coarse:hidden"
|
||||
on:click={zoomIn}
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
|
|
|
|||
238
src/lib/components/common/PdfPagesPreview.svelte
Normal file
238
src/lib/components/common/PdfPagesPreview.svelte
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.mjs?url';
|
||||
import Spinner from './Spinner.svelte';
|
||||
import PDFViewer from './PDFViewer.svelte';
|
||||
|
||||
export let data: ArrayBuffer | Uint8Array | null = null;
|
||||
export let currentSlide = 0;
|
||||
export let className = '';
|
||||
export let targetPage: number | null = null;
|
||||
export let singlePage = false;
|
||||
export let itemLabel = 'Page';
|
||||
export let listLabel = 'Pages';
|
||||
|
||||
type PdfDocument = import('pdfjs-dist').PDFDocumentProxy;
|
||||
|
||||
let rootEl: HTMLDivElement;
|
||||
let pdfViewerRef: PDFViewer;
|
||||
let viewerData: ArrayBuffer | Uint8Array | null = null;
|
||||
let pageTarget: number | null = null;
|
||||
let thumbsLoading = false;
|
||||
let thumbnails: string[] = [];
|
||||
let thumbnailButtons: Array<HTMLButtonElement | undefined> = [];
|
||||
let hideThumbs = false;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let pdfDoc: PdfDocument | null = null;
|
||||
let loadToken = 0;
|
||||
|
||||
$: safePage = Math.min(Math.max(0, currentSlide), Math.max(0, thumbnails.length - 1));
|
||||
|
||||
const copyPdfData = (pdfData: ArrayBuffer | Uint8Array) =>
|
||||
pdfData instanceof Uint8Array ? pdfData.slice() : pdfData.slice(0);
|
||||
|
||||
const updateLayout = () => {
|
||||
hideThumbs = (rootEl?.clientWidth ?? window.innerWidth) < 720;
|
||||
};
|
||||
|
||||
const trackThumbnail = (node: HTMLButtonElement, index: number) => {
|
||||
thumbnailButtons[index] = node;
|
||||
return {
|
||||
destroy: () => {
|
||||
if (thumbnailButtons[index] === node) thumbnailButtons[index] = undefined;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const scrollSelectedThumbnailIntoView = () => {
|
||||
if (hideThumbs) return;
|
||||
thumbnailButtons[safePage]?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
};
|
||||
|
||||
const selectPage = async (index: number) => {
|
||||
currentSlide = Math.min(Math.max(0, index), Math.max(0, thumbnails.length - 1));
|
||||
pageTarget = currentSlide + 1;
|
||||
await tick();
|
||||
await pdfViewerRef?.scrollToPage?.(pageTarget);
|
||||
scrollSelectedThumbnailIntoView();
|
||||
};
|
||||
|
||||
const loadThumbnails = async (pdfData: ArrayBuffer | Uint8Array) => {
|
||||
const token = ++loadToken;
|
||||
thumbsLoading = true;
|
||||
thumbnails = [];
|
||||
|
||||
try {
|
||||
const pdfjs = await import('pdfjs-dist');
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
|
||||
|
||||
pdfDoc?.destroy();
|
||||
pdfDoc = await pdfjs.getDocument({ data: pdfData }).promise;
|
||||
|
||||
const rendered: string[] = [];
|
||||
for (let i = 1; i <= pdfDoc.numPages; i++) {
|
||||
if (token !== loadToken) return;
|
||||
|
||||
const page = await pdfDoc.getPage(i);
|
||||
const viewport = page.getViewport({ scale: 0.28 });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) continue;
|
||||
|
||||
await page.render({ canvas, canvasContext: ctx, viewport }).promise;
|
||||
rendered.push(canvas.toDataURL('image/png'));
|
||||
}
|
||||
|
||||
if (token === loadToken) thumbnails = rendered;
|
||||
} catch (e) {
|
||||
console.error('PDF thumbnail render error:', e);
|
||||
if (token === loadToken) thumbnails = [];
|
||||
} finally {
|
||||
if (token === loadToken) thumbsLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
$: if (data) {
|
||||
viewerData = copyPdfData(data);
|
||||
void loadThumbnails(copyPdfData(data));
|
||||
} else {
|
||||
viewerData = null;
|
||||
thumbnails = [];
|
||||
}
|
||||
|
||||
$: if (targetPage) {
|
||||
currentSlide = Math.max(0, targetPage - 1);
|
||||
pageTarget = targetPage;
|
||||
}
|
||||
|
||||
$: if (thumbnails.length > 0) {
|
||||
void tick().then(scrollSelectedThumbnailIntoView);
|
||||
}
|
||||
|
||||
export const resetView = () => {
|
||||
pdfViewerRef?.resetView();
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
currentSlide = page - 1;
|
||||
if (singlePage) pageTarget = page;
|
||||
void tick().then(scrollSelectedThumbnailIntoView);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
resizeObserver = new ResizeObserver(updateLayout);
|
||||
if (rootEl) resizeObserver.observe(rootEl);
|
||||
updateLayout();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
loadToken++;
|
||||
resizeObserver?.disconnect();
|
||||
pdfDoc?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={rootEl}
|
||||
class="relative grid {hideThumbs
|
||||
? 'grid-cols-[minmax(0,1fr)]'
|
||||
: 'grid-cols-[144px_minmax(0,1fr)]'} min-h-0 bg-transparent text-gray-900 dark:text-gray-100 {className}"
|
||||
>
|
||||
<aside
|
||||
class={hideThumbs
|
||||
? 'hidden'
|
||||
: 'thumbnail-sidebar overflow-y-auto px-2 pt-3 pb-16 border-r border-gray-50 dark:border-gray-850/30 bg-transparent'}
|
||||
aria-label={listLabel}
|
||||
>
|
||||
{#if thumbsLoading && thumbnails.length === 0}
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<Spinner className="size-4" />
|
||||
</div>
|
||||
{:else}
|
||||
{#each thumbnails as thumbnail, index}
|
||||
<button
|
||||
use:trackThumbnail={index}
|
||||
type="button"
|
||||
class="grid grid-cols-[20px_minmax(0,1fr)] items-start gap-2 w-full mb-3 p-0 text-left text-gray-900 dark:text-gray-100"
|
||||
on:click={() => selectPage(index)}
|
||||
aria-label="{itemLabel} {index + 1}"
|
||||
aria-current={safePage === index ? 'true' : undefined}
|
||||
>
|
||||
<span
|
||||
class="pt-1.5 text-[0.6875rem] font-medium text-right {safePage === index
|
||||
? 'text-gray-400 dark:text-gray-500'
|
||||
: 'text-gray-300/70 dark:text-gray-700'}">{index + 1}</span
|
||||
>
|
||||
<span
|
||||
class="block overflow-hidden rounded-md bg-transparent {safePage === index
|
||||
? 'opacity-100'
|
||||
: 'opacity-55 hover:opacity-80'}"
|
||||
>
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt="{itemLabel} {index + 1} thumbnail"
|
||||
class="block w-full h-full object-contain"
|
||||
draggable="false"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<section class="min-w-0 min-h-0 overflow-hidden">
|
||||
{#if viewerData}
|
||||
<PDFViewer
|
||||
bind:this={pdfViewerRef}
|
||||
data={viewerData}
|
||||
targetPage={pageTarget}
|
||||
{singlePage}
|
||||
{itemLabel}
|
||||
onPageChange={handlePageChange}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.thumbnail-sidebar {
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
|
||||
.thumbnail-sidebar:hover,
|
||||
.thumbnail-sidebar:focus,
|
||||
.thumbnail-sidebar:focus-within,
|
||||
.thumbnail-sidebar:active {
|
||||
scrollbar-color: rgba(215, 215, 215, 0.6) transparent;
|
||||
}
|
||||
|
||||
:global(.dark) .thumbnail-sidebar:hover,
|
||||
:global(.dark) .thumbnail-sidebar:focus,
|
||||
:global(.dark) .thumbnail-sidebar:focus-within,
|
||||
:global(.dark) .thumbnail-sidebar:active {
|
||||
scrollbar-color: rgba(67, 67, 67, 0.6) transparent;
|
||||
}
|
||||
|
||||
.thumbnail-sidebar::-webkit-scrollbar-thumb {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.thumbnail-sidebar:hover::-webkit-scrollbar-thumb,
|
||||
.thumbnail-sidebar:focus::-webkit-scrollbar-thumb,
|
||||
.thumbnail-sidebar:focus-within::-webkit-scrollbar-thumb,
|
||||
.thumbnail-sidebar:active::-webkit-scrollbar-thumb {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.thumbnail-sidebar::-webkit-scrollbar-corner {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -7,6 +7,8 @@
|
|||
export let currentSlide = 0;
|
||||
export let className = '';
|
||||
export let targetPage: number | null = null;
|
||||
export let itemLabel = 'Slide';
|
||||
export let listLabel = 'Slides';
|
||||
|
||||
let rootEl: HTMLDivElement;
|
||||
let stageEl: HTMLElement;
|
||||
|
|
@ -119,6 +121,10 @@
|
|||
}
|
||||
};
|
||||
|
||||
const focusPreview = () => {
|
||||
if (!rootEl?.contains(document.activeElement)) rootEl?.focus();
|
||||
};
|
||||
|
||||
const zoomIn = () => {
|
||||
if (!pzInstance || !stageEl) return;
|
||||
pzInstance.zoomTo(stageEl.clientWidth / 2, stageEl.clientHeight / 2, 1.25);
|
||||
|
|
@ -225,10 +231,13 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeyDown} />
|
||||
|
||||
<div
|
||||
bind:this={rootEl}
|
||||
role="application"
|
||||
aria-label={`${itemLabel} preview`}
|
||||
tabindex="0"
|
||||
on:keydown={handleKeyDown}
|
||||
on:pointerdown={focusPreview}
|
||||
class="relative grid {hideThumbs
|
||||
? 'grid-cols-[minmax(0,1fr)]'
|
||||
: 'grid-cols-[144px_minmax(0,1fr)]'} min-h-0 bg-transparent text-gray-900 dark:text-gray-100 {className}"
|
||||
|
|
@ -236,8 +245,8 @@
|
|||
<aside
|
||||
class={hideThumbs
|
||||
? 'hidden'
|
||||
: 'pptx-slide-rail overflow-y-auto px-2 pt-3 pb-16 border-r border-gray-50 dark:border-gray-850/30 bg-transparent'}
|
||||
aria-label="Slides"
|
||||
: 'thumbnail-sidebar overflow-y-auto px-2 pt-3 pb-16 border-r border-gray-50 dark:border-gray-850/30 bg-transparent'}
|
||||
aria-label={listLabel}
|
||||
>
|
||||
{#each slides as slide, index}
|
||||
<button
|
||||
|
|
@ -245,7 +254,7 @@
|
|||
type="button"
|
||||
class="grid grid-cols-[20px_minmax(0,1fr)] items-start gap-2 w-full mb-3 p-0 text-left text-gray-900 dark:text-gray-100"
|
||||
on:click={() => selectSlide(index)}
|
||||
aria-label="Slide {index + 1}"
|
||||
aria-label="{itemLabel} {index + 1}"
|
||||
aria-current={safeSlide === index ? 'true' : undefined}
|
||||
>
|
||||
<span
|
||||
|
|
@ -260,7 +269,7 @@
|
|||
>
|
||||
<img
|
||||
src={slide}
|
||||
alt="Slide {index + 1} thumbnail"
|
||||
alt="{itemLabel} {index + 1} thumbnail"
|
||||
class="block w-full h-full object-contain"
|
||||
draggable="false"
|
||||
/>
|
||||
|
|
@ -283,7 +292,7 @@
|
|||
<img
|
||||
bind:this={slideImgEl}
|
||||
src={selectedSlide}
|
||||
alt="Slide {safeSlide + 1}"
|
||||
alt="{itemLabel} {safeSlide + 1}"
|
||||
class="block w-full h-full object-contain rounded"
|
||||
draggable="false"
|
||||
on:load={onSlideLoad}
|
||||
|
|
@ -303,7 +312,7 @@
|
|||
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400 disabled:opacity-30"
|
||||
disabled={safeSlide === 0}
|
||||
on:click={() => selectSlide(safeSlide - 1)}
|
||||
aria-label="Previous slide"
|
||||
aria-label={`Previous ${itemLabel.toLowerCase()}`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
|
@ -327,7 +336,7 @@
|
|||
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400 disabled:opacity-30"
|
||||
disabled={safeSlide === slides.length - 1}
|
||||
on:click={() => selectSlide(safeSlide + 1)}
|
||||
aria-label="Next slide"
|
||||
aria-label={`Next ${itemLabel.toLowerCase()}`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
|
@ -342,9 +351,10 @@
|
|||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Pinch covers in/out on coarse pointers; reset has no gesture, so it stays -->
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400"
|
||||
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400 pointer-coarse:hidden"
|
||||
on:click={zoomOut}
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
|
|
@ -371,7 +381,7 @@
|
|||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400"
|
||||
class="shrink-0 min-w-7 h-7 inline-flex items-center justify-center p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400 pointer-coarse:hidden"
|
||||
on:click={zoomIn}
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
|
|
@ -391,36 +401,36 @@
|
|||
</div>
|
||||
|
||||
<style>
|
||||
.pptx-slide-rail {
|
||||
.thumbnail-sidebar {
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
|
||||
.pptx-slide-rail:hover,
|
||||
.pptx-slide-rail:focus,
|
||||
.pptx-slide-rail:focus-within,
|
||||
.pptx-slide-rail:active {
|
||||
.thumbnail-sidebar:hover,
|
||||
.thumbnail-sidebar:focus,
|
||||
.thumbnail-sidebar:focus-within,
|
||||
.thumbnail-sidebar:active {
|
||||
scrollbar-color: rgba(215, 215, 215, 0.6) transparent;
|
||||
}
|
||||
|
||||
:global(.dark) .pptx-slide-rail:hover,
|
||||
:global(.dark) .pptx-slide-rail:focus,
|
||||
:global(.dark) .pptx-slide-rail:focus-within,
|
||||
:global(.dark) .pptx-slide-rail:active {
|
||||
:global(.dark) .thumbnail-sidebar:hover,
|
||||
:global(.dark) .thumbnail-sidebar:focus,
|
||||
:global(.dark) .thumbnail-sidebar:focus-within,
|
||||
:global(.dark) .thumbnail-sidebar:active {
|
||||
scrollbar-color: rgba(67, 67, 67, 0.6) transparent;
|
||||
}
|
||||
|
||||
.pptx-slide-rail::-webkit-scrollbar-thumb {
|
||||
.thumbnail-sidebar::-webkit-scrollbar-thumb {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.pptx-slide-rail:hover::-webkit-scrollbar-thumb,
|
||||
.pptx-slide-rail:focus::-webkit-scrollbar-thumb,
|
||||
.pptx-slide-rail:focus-within::-webkit-scrollbar-thumb,
|
||||
.pptx-slide-rail:active::-webkit-scrollbar-thumb {
|
||||
.thumbnail-sidebar:hover::-webkit-scrollbar-thumb,
|
||||
.thumbnail-sidebar:focus::-webkit-scrollbar-thumb,
|
||||
.thumbnail-sidebar:focus-within::-webkit-scrollbar-thumb,
|
||||
.thumbnail-sidebar:active::-webkit-scrollbar-thumb {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.pptx-slide-rail::-webkit-scrollbar-corner {
|
||||
.thumbnail-sidebar::-webkit-scrollbar-corner {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@
|
|||
|
||||
let panelElement: HTMLDivElement | null = null;
|
||||
let isResizing = false;
|
||||
let activePointerId: number | null = null;
|
||||
let activeResizer: HTMLElement | null = null;
|
||||
let startClientX = 0;
|
||||
let startWidth = 0;
|
||||
|
||||
|
|
@ -48,19 +50,31 @@
|
|||
onClose();
|
||||
};
|
||||
|
||||
const resizeStartHandler = (e: MouseEvent) => {
|
||||
const resizeStartHandler = (e: PointerEvent) => {
|
||||
if (!open) return;
|
||||
|
||||
e.preventDefault();
|
||||
isResizing = true;
|
||||
activePointerId = e.pointerId;
|
||||
activeResizer = e.currentTarget as HTMLElement;
|
||||
activeResizer.setPointerCapture?.(e.pointerId);
|
||||
startClientX = e.clientX;
|
||||
startWidth = width;
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
const resizeEndHandler = () => {
|
||||
const resizeEndHandler = (e?: PointerEvent) => {
|
||||
if (!isResizing) return;
|
||||
if (e && activePointerId !== null && e.pointerId !== activePointerId) return;
|
||||
|
||||
isResizing = false;
|
||||
if (activePointerId !== null) {
|
||||
if (activeResizer?.hasPointerCapture?.(activePointerId)) {
|
||||
activeResizer.releasePointerCapture(activePointerId);
|
||||
}
|
||||
}
|
||||
activePointerId = null;
|
||||
activeResizer = null;
|
||||
document.body.style.userSelect = '';
|
||||
persistWidth();
|
||||
};
|
||||
|
|
@ -109,14 +123,16 @@
|
|||
</script>
|
||||
|
||||
<svelte:window
|
||||
on:mousemove={(e) => {
|
||||
on:pointermove={(e) => {
|
||||
if (!isResizing) return;
|
||||
if (activePointerId !== null && e.pointerId !== activePointerId) return;
|
||||
resizeHandler(e.clientX);
|
||||
}}
|
||||
on:resize={() => {
|
||||
if (open) width = clamp(width);
|
||||
}}
|
||||
on:mouseup={resizeEndHandler}
|
||||
on:pointerup={resizeEndHandler}
|
||||
on:pointercancel={resizeEndHandler}
|
||||
/>
|
||||
|
||||
{#if open}
|
||||
|
|
@ -125,7 +141,7 @@
|
|||
<div
|
||||
class="relative flex items-center justify-center group border-l border-gray-50 dark:border-gray-850/30 hover:border-gray-200 dark:hover:border-gray-800 transition z-20 bg-transparent p-0 appearance-none"
|
||||
id={resizerId}
|
||||
on:mousedown={resizeStartHandler}
|
||||
on:pointerdown={resizeStartHandler}
|
||||
on:keydown={resizeKeyHandler}
|
||||
role="separator"
|
||||
tabindex="0"
|
||||
|
|
@ -134,6 +150,7 @@
|
|||
>
|
||||
<span
|
||||
class="absolute -left-1.5 -right-1.5 -top-0 -bottom-0 z-20 cursor-col-resize bg-transparent"
|
||||
style="touch-action: none;"
|
||||
></span>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -147,7 +164,7 @@
|
|||
<div
|
||||
class="relative flex items-center justify-center group border-r border-gray-50 dark:border-gray-850/30 hover:border-gray-200 dark:hover:border-gray-800 transition z-20 bg-transparent p-0 appearance-none"
|
||||
id={resizerId}
|
||||
on:mousedown={resizeStartHandler}
|
||||
on:pointerdown={resizeStartHandler}
|
||||
on:keydown={resizeKeyHandler}
|
||||
role="separator"
|
||||
tabindex="0"
|
||||
|
|
@ -156,6 +173,7 @@
|
|||
>
|
||||
<span
|
||||
class="absolute -left-1.5 -right-1.5 -top-0 -bottom-0 z-20 cursor-col-resize bg-transparent"
|
||||
style="touch-action: none;"
|
||||
></span>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -163,7 +163,7 @@ export class SocketIOCollaborationProvider {
|
|||
document_id: this.documentId,
|
||||
user_id: this.user?.id,
|
||||
socket_id: this.socket.id,
|
||||
update: Y.encodeStateAsUpdate(this.doc)
|
||||
update: Array.from(Y.encodeStateAsUpdate(this.doc))
|
||||
});
|
||||
} else {
|
||||
console.warn('Yjs document is empty, not sending state.');
|
||||
|
|
|
|||
|
|
@ -74,11 +74,21 @@
|
|||
contentEl.style.bottom = 'auto';
|
||||
}
|
||||
|
||||
const contentWidth = contentEl.offsetWidth || 0;
|
||||
|
||||
if (align === 'end') {
|
||||
contentEl.style.right = `${window.innerWidth - rect.right}px`;
|
||||
let right = window.innerWidth - rect.right;
|
||||
if (right + contentWidth > window.innerWidth) {
|
||||
right = window.innerWidth - contentWidth - 16;
|
||||
}
|
||||
contentEl.style.right = `${Math.max(16, right)}px`;
|
||||
contentEl.style.left = 'auto';
|
||||
} else {
|
||||
contentEl.style.left = `${rect.left}px`;
|
||||
let left = rect.left;
|
||||
if (left + contentWidth + 16 > window.innerWidth) {
|
||||
left = window.innerWidth - contentWidth - 16;
|
||||
}
|
||||
contentEl.style.left = `${Math.max(16, left)}px`;
|
||||
contentEl.style.right = 'auto';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@
|
|||
{#if (valves[property] ?? null) !== null}
|
||||
<!-- {valves[property]} -->
|
||||
<div class="flex mt-0.5 mb-0.5 space-x-2">
|
||||
<div class=" flex-1">
|
||||
<div class=" flex-1 min-w-0">
|
||||
{#if valvesSpec.properties[property]?.enum ?? null}
|
||||
<select
|
||||
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30"
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@
|
|||
export let orderBy = 'updated_at';
|
||||
export let direction = 'desc'; // 'asc' or 'desc'
|
||||
|
||||
$: if (!show) {
|
||||
query = '';
|
||||
}
|
||||
|
||||
export let chatList = null;
|
||||
export let allChatsLoaded = false;
|
||||
export let chatListLoading = false;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import DOMPurify from 'dompurify';
|
||||
import { marked } from 'marked';
|
||||
|
||||
import { getAdminDetails, userSignOut } from '$lib/apis/auths';
|
||||
import { getAdminDetails, getLogoutRedirectUrl, userSignOut } from '$lib/apis/auths';
|
||||
import { onMount, tick, getContext } from 'svelte';
|
||||
import { config } from '$lib/stores';
|
||||
|
||||
|
|
@ -75,7 +75,7 @@
|
|||
return null;
|
||||
});
|
||||
localStorage.removeItem('token');
|
||||
location.href = res?.redirect_url ?? '/auth';
|
||||
location.href = getLogoutRedirectUrl(res?.redirect_url);
|
||||
}}>{$i18n.t('Sign Out')}</button
|
||||
>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -600,13 +600,20 @@
|
|||
const MAX_WIDTH = 480;
|
||||
|
||||
let isResizing = false;
|
||||
let activePointerId: number | null = null;
|
||||
let activeResizer: HTMLElement | null = null;
|
||||
|
||||
let startWidth = 0;
|
||||
let startClientX = 0;
|
||||
|
||||
const resizeStartHandler = (e: MouseEvent) => {
|
||||
const resizeStartHandler = (e: PointerEvent) => {
|
||||
if ($mobile) return;
|
||||
|
||||
e.preventDefault();
|
||||
isResizing = true;
|
||||
activePointerId = e.pointerId;
|
||||
activeResizer = e.currentTarget as HTMLElement;
|
||||
activeResizer.setPointerCapture?.(e.pointerId);
|
||||
|
||||
startClientX = e.clientX;
|
||||
startWidth = $sidebarWidth ?? 245;
|
||||
|
|
@ -614,15 +621,23 @@
|
|||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
const resizeEndHandler = () => {
|
||||
const resizeEndHandler = (e?: PointerEvent) => {
|
||||
if (!isResizing) return;
|
||||
if (e && activePointerId !== null && e.pointerId !== activePointerId) return;
|
||||
|
||||
isResizing = false;
|
||||
|
||||
if (activePointerId !== null && activeResizer?.hasPointerCapture?.(activePointerId)) {
|
||||
activeResizer.releasePointerCapture(activePointerId);
|
||||
}
|
||||
activePointerId = null;
|
||||
activeResizer = null;
|
||||
|
||||
document.body.style.userSelect = '';
|
||||
localStorage.setItem('sidebarWidth', String($sidebarWidth));
|
||||
};
|
||||
|
||||
const resizeSidebarHandler = (endClientX) => {
|
||||
const resizeSidebarHandler = (endClientX: number) => {
|
||||
const dx = endClientX - startClientX;
|
||||
const newSidebarWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + dx));
|
||||
|
||||
|
|
@ -630,6 +645,12 @@
|
|||
document.documentElement.style.setProperty('--sidebar-width', `${newSidebarWidth}px`);
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
if (isResizing) {
|
||||
document.body.style.userSelect = '';
|
||||
}
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const width = Number(localStorage.getItem('sidebarWidth'));
|
||||
|
|
@ -861,13 +882,13 @@
|
|||
/>
|
||||
|
||||
<svelte:window
|
||||
on:mousemove={(e) => {
|
||||
on:pointermove={(e) => {
|
||||
if (!isResizing) return;
|
||||
if (activePointerId !== null && e.pointerId !== activePointerId) return;
|
||||
resizeSidebarHandler(e.clientX);
|
||||
}}
|
||||
on:mouseup={() => {
|
||||
resizeEndHandler();
|
||||
}}
|
||||
on:pointerup={resizeEndHandler}
|
||||
on:pointercancel={resizeEndHandler}
|
||||
/>
|
||||
|
||||
<MobileSwipePanel
|
||||
|
|
@ -940,7 +961,7 @@
|
|||
aria-label={$showSidebar ? $i18n.t('Close Sidebar') : $i18n.t('Open Sidebar')}
|
||||
>
|
||||
<div
|
||||
class="self-center flex size-[calc(30px*var(--app-text-scale,1))] items-center justify-center rounded-lg transition group-hover:bg-gray-50 dark:group-hover:bg-gray-900"
|
||||
class="self-center flex size-[calc(30px*var(--app-text-scale,1))] items-center justify-center rounded-lg transition group-hover:bg-gray-100 dark:group-hover:bg-gray-900"
|
||||
>
|
||||
<!-- LICENSE covers this Open WebUI sidebar logo.
|
||||
Do not alter, remove, obscure, or replace it except as LICENSE permits:
|
||||
|
|
@ -974,7 +995,7 @@
|
|||
aria-label={$i18n.t('New Chat')}
|
||||
>
|
||||
<div
|
||||
class="self-center flex size-[calc(30px*var(--app-text-scale,1))] items-center justify-center rounded-lg transition group-hover:bg-gray-50 dark:group-hover:bg-gray-900"
|
||||
class="self-center flex size-[calc(30px*var(--app-text-scale,1))] items-center justify-center rounded-lg transition group-hover:bg-gray-100 dark:group-hover:bg-gray-900"
|
||||
>
|
||||
<EditPencilIcon className="size-4" strokeWidth="1.5" />
|
||||
</div>
|
||||
|
|
@ -996,7 +1017,7 @@
|
|||
aria-label={$i18n.t('Search')}
|
||||
>
|
||||
<div
|
||||
class="self-center flex size-[calc(30px*var(--app-text-scale,1))] items-center justify-center rounded-lg transition group-hover:bg-gray-50 dark:group-hover:bg-gray-900"
|
||||
class="self-center flex size-[calc(30px*var(--app-text-scale,1))] items-center justify-center rounded-lg transition group-hover:bg-gray-100 dark:group-hover:bg-gray-900"
|
||||
>
|
||||
<SearchIcon className="size-4" strokeWidth="1.5" />
|
||||
</div>
|
||||
|
|
@ -1027,7 +1048,7 @@
|
|||
? ($settings?.highContrastMode ?? false)
|
||||
? 'bg-black/[0.035] dark:bg-white/[0.06]'
|
||||
: 'bg-black/[0.035] dark:bg-white/[0.045]'
|
||||
: 'group-hover:bg-gray-50 dark:group-hover:bg-gray-900'}"
|
||||
: 'group-hover:bg-gray-100 dark:group-hover:bg-gray-900'}"
|
||||
>
|
||||
{#if itemId === 'notes'}
|
||||
<NotesIcon className="size-4" strokeWidth="1.5" />
|
||||
|
|
@ -1060,7 +1081,7 @@
|
|||
aria-label={$i18n.t('User menu')}
|
||||
>
|
||||
<div
|
||||
class="self-center relative flex size-[calc(30px*var(--app-text-scale,1))] items-center justify-center rounded-lg transition group-hover:bg-gray-50 dark:group-hover:bg-gray-900"
|
||||
class="self-center relative flex size-[calc(30px*var(--app-text-scale,1))] items-center justify-center rounded-lg transition group-hover:bg-gray-100 dark:group-hover:bg-gray-900"
|
||||
>
|
||||
<img
|
||||
src={`${WEBUI_API_BASE_URL}/users/${$user?.id}/profile/image`}
|
||||
|
|
@ -1123,7 +1144,7 @@
|
|||
class="sidebar px-1 pt-1.5 pb-1 flex justify-between space-x-1 text-gray-600 dark:text-gray-400 sticky top-0 z-10 -mb-2"
|
||||
>
|
||||
<a
|
||||
class="flex items-center rounded-xl size-8.5 h-full justify-center hover:bg-gray-50 dark:hover:bg-gray-900 transition no-drag-region"
|
||||
class="flex items-center rounded-xl size-8.5 h-full justify-center hover:bg-gray-100 dark:hover:bg-gray-900 transition no-drag-region"
|
||||
href="/"
|
||||
draggable="false"
|
||||
on:click={newChatHandler}
|
||||
|
|
@ -1155,7 +1176,7 @@
|
|||
placement="bottom"
|
||||
>
|
||||
<button
|
||||
class="flex size-[1.875rem] justify-center items-center rounded-lg hover:bg-gray-50 dark:hover:bg-gray-900 transition {isWindows
|
||||
class="flex size-[1.875rem] justify-center items-center rounded-lg hover:bg-gray-100 dark:hover:bg-gray-900 transition {isWindows
|
||||
? 'cursor-pointer'
|
||||
: 'cursor-[w-resize]'}"
|
||||
on:click={() => {
|
||||
|
|
@ -1190,7 +1211,7 @@
|
|||
<div class="px-1 flex justify-center text-gray-700 dark:text-gray-300">
|
||||
<a
|
||||
id="sidebar-new-chat-button"
|
||||
class="group grow flex items-center space-x-2 rounded-xl px-2 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-900 transition outline-none"
|
||||
class="group grow flex items-center space-x-2 rounded-xl px-2 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-900 transition outline-none"
|
||||
href="/"
|
||||
draggable="false"
|
||||
on:click={newChatHandler}
|
||||
|
|
@ -1211,7 +1232,7 @@
|
|||
<div class="px-1 flex justify-center text-gray-700 dark:text-gray-300">
|
||||
<button
|
||||
id="sidebar-search-button"
|
||||
class="group grow flex items-center space-x-2 rounded-xl px-2 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-900 transition outline-none"
|
||||
class="group grow flex items-center space-x-2 rounded-xl px-2 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-900 transition outline-none"
|
||||
on:click={() => {
|
||||
showSearch.set(true);
|
||||
}}
|
||||
|
|
@ -1244,7 +1265,7 @@
|
|||
? ($settings?.highContrastMode ?? false)
|
||||
? 'bg-black/[0.035] dark:bg-white/[0.06]'
|
||||
: 'bg-black/[0.035] dark:bg-white/[0.045]'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-900'}"
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-900'}"
|
||||
href={meta.href}
|
||||
on:click={itemClickHandler}
|
||||
draggable="false"
|
||||
|
|
@ -1483,7 +1504,7 @@
|
|||
<div slot="content">
|
||||
<DropdownMenu className="min-w-[10.625rem]">
|
||||
<button
|
||||
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
|
||||
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900"
|
||||
on:click={markAllChatsReadHandler}
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
|
|
@ -1692,7 +1713,7 @@
|
|||
>
|
||||
<button
|
||||
type="button"
|
||||
class=" flex items-center rounded-xl py-1.5 px-1.5 w-full hover:bg-gray-50 dark:hover:bg-gray-900 transition"
|
||||
class=" flex items-center rounded-xl py-1.5 px-1.5 w-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
|
||||
aria-label={$i18n.t('User menu')}
|
||||
>
|
||||
<div class=" self-center mr-3 relative flex-shrink-0">
|
||||
|
|
@ -1726,14 +1747,15 @@
|
|||
|
||||
{#if !$mobile && visible}
|
||||
<div
|
||||
class="relative flex items-center justify-center group border-l border-gray-50 dark:border-gray-850/30 hover:border-gray-200 dark:hover:border-gray-800 transition z-20"
|
||||
class="relative flex items-center justify-center group border-r border-gray-50 dark:border-gray-850/30 hover:border-gray-200 dark:hover:border-gray-800 transition z-20 bg-transparent p-0 appearance-none"
|
||||
id="sidebar-resizer"
|
||||
on:mousedown={resizeStartHandler}
|
||||
on:pointerdown={resizeStartHandler}
|
||||
role="separator"
|
||||
>
|
||||
<div
|
||||
class=" absolute -left-1.5 -right-1.5 -top-0 -bottom-0 z-20 cursor-col-resize bg-transparent"
|
||||
/>
|
||||
style="touch-action: none;"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@
|
|||
: 'bg-black/[0.035] dark:bg-white/[0.045] selected'
|
||||
: $mobile
|
||||
? ''
|
||||
: ' hover:bg-gray-50 dark:hover:bg-gray-900 group-hover:bg-gray-50 dark:group-hover:bg-gray-900'
|
||||
: ' hover:bg-gray-100 dark:hover:bg-gray-900 group-hover:bg-gray-100 dark:group-hover:bg-gray-900'
|
||||
} whitespace-nowrap text-ellipsis transition`;
|
||||
|
||||
const selectChatHandler = (event?: MouseEvent) => {
|
||||
|
|
@ -609,7 +609,7 @@
|
|||
? ($settings?.highContrastMode ?? false)
|
||||
? 'bg-black/[0.035] dark:bg-white/[0.055] selected'
|
||||
: 'bg-black/[0.035] dark:bg-white/[0.045] selected'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-900 group-hover:bg-gray-50 dark:group-hover:bg-gray-900'} whitespace-nowrap text-ellipsis relative transition {generating
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-900 group-hover:bg-gray-100 dark:group-hover:bg-gray-900'} whitespace-nowrap text-ellipsis relative transition {generating
|
||||
? 'cursor-not-allowed'
|
||||
: ''}"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -311,7 +311,7 @@
|
|||
{#if $user?.role === 'admin' || ($user.permissions?.chat?.share ?? true)}
|
||||
<button
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 w-full"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
|
||||
on:click={() => {
|
||||
shareHandler();
|
||||
}}
|
||||
|
|
@ -326,7 +326,7 @@
|
|||
<button
|
||||
slot="trigger"
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 w-full"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
|
||||
>
|
||||
<DownloadIcon className="size-3.5" strokeWidth="1.5" />
|
||||
<div class="flex items-center">{$i18n.t('Download')}</div>
|
||||
|
|
@ -334,7 +334,7 @@
|
|||
|
||||
<button
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 w-full"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
|
||||
on:click={() => {
|
||||
downloadJSONExport();
|
||||
}}
|
||||
|
|
@ -344,7 +344,7 @@
|
|||
|
||||
<button
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 w-full"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
|
||||
on:click={() => {
|
||||
downloadTxt();
|
||||
}}
|
||||
|
|
@ -354,7 +354,7 @@
|
|||
|
||||
<button
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 select-none w-full"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 select-none w-full"
|
||||
on:click={() => {
|
||||
downloadPdf();
|
||||
}}
|
||||
|
|
@ -366,7 +366,7 @@
|
|||
|
||||
<button
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 w-full"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
|
||||
on:click={() => {
|
||||
dropdown.close();
|
||||
renameHandler();
|
||||
|
|
@ -378,7 +378,7 @@
|
|||
|
||||
<button
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 w-full"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
|
||||
on:click={() => {
|
||||
dropdown.close();
|
||||
markUnreadHandler();
|
||||
|
|
@ -392,7 +392,7 @@
|
|||
|
||||
<button
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 w-full"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
|
||||
on:click={() => {
|
||||
dropdown.close();
|
||||
pinHandler();
|
||||
|
|
@ -410,7 +410,7 @@
|
|||
{#if $user?.role === 'admin' || ($user?.permissions?.chat?.import ?? true)}
|
||||
<button
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 w-full"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
|
||||
on:click={() => {
|
||||
dropdown.close();
|
||||
cloneChatHandler();
|
||||
|
|
@ -426,7 +426,7 @@
|
|||
<button
|
||||
slot="trigger"
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 select-none w-full"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 select-none w-full"
|
||||
>
|
||||
<FolderIcon className="size-3.5" />
|
||||
<div class="flex items-center">{$i18n.t('Move')}</div>
|
||||
|
|
@ -435,7 +435,7 @@
|
|||
{#each $folders.sort((a, b) => b.updated_at - a.updated_at) as folder}
|
||||
<button
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 overflow-hidden w-full"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 overflow-hidden w-full"
|
||||
on:click={() => {
|
||||
moveChatHandler(chatId, folder.id);
|
||||
}}
|
||||
|
|
@ -452,7 +452,7 @@
|
|||
|
||||
<button
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 w-full"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
|
||||
on:click={() => {
|
||||
archiveChatHandler();
|
||||
}}
|
||||
|
|
@ -464,7 +464,7 @@
|
|||
{#if $user?.role === 'admin' || ($user?.permissions?.chat?.delete ?? true)}
|
||||
<button
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 w-full"
|
||||
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
|
||||
on:click={() => {
|
||||
deleteHandler();
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@
|
|||
<div slot="content">
|
||||
<DropdownMenu className="min-w-[10.625rem]">
|
||||
<button
|
||||
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
|
||||
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900"
|
||||
on:click={() => {
|
||||
onCreateSubFolder();
|
||||
}}
|
||||
|
|
@ -60,7 +60,7 @@
|
|||
<hr class="border-gray-50/30 dark:border-gray-800/30 mx-1 my-0.5" />
|
||||
|
||||
<button
|
||||
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
|
||||
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900"
|
||||
on:click={() => {
|
||||
show = false;
|
||||
onMarkAllRead();
|
||||
|
|
@ -73,7 +73,7 @@
|
|||
<hr class="border-gray-50/30 dark:border-gray-800/30 mx-1 my-0.5" />
|
||||
|
||||
<button
|
||||
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
|
||||
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900"
|
||||
on:click={() => {
|
||||
onEdit();
|
||||
}}
|
||||
|
|
@ -83,7 +83,7 @@
|
|||
</button>
|
||||
|
||||
<button
|
||||
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
|
||||
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900"
|
||||
on:click={() => {
|
||||
onExport();
|
||||
}}
|
||||
|
|
@ -93,7 +93,7 @@
|
|||
</button>
|
||||
|
||||
<button
|
||||
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
|
||||
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900"
|
||||
on:click={() => {
|
||||
onShare();
|
||||
}}
|
||||
|
|
@ -105,7 +105,7 @@
|
|||
<hr class="border-gray-50/30 dark:border-gray-800/30 mx-1 my-0.5" />
|
||||
|
||||
<button
|
||||
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
|
||||
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] select-none cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900"
|
||||
on:click={() => {
|
||||
onDelete();
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@
|
|||
{/if}
|
||||
</div>
|
||||
<button
|
||||
aria-label={$i18n.t('Close')}
|
||||
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||||
on:click={() => {
|
||||
show = false;
|
||||
|
|
|
|||
|
|
@ -428,6 +428,8 @@
|
|||
});
|
||||
|
||||
onDestroy(() => {
|
||||
delete folderRegistry[folderId];
|
||||
|
||||
if (folderElement) {
|
||||
folderElement.removeEventListener('dragover', onDragOver);
|
||||
folderElement.removeEventListener('drop', onDrop);
|
||||
|
|
@ -742,7 +744,7 @@
|
|||
<div class="w-full group">
|
||||
<div
|
||||
id="folder-{folderId}-button"
|
||||
class="relative w-full py-1 px-1.5 rounded-xl flex items-center gap-1.5 hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition {$selectedFolder?.id ===
|
||||
class="relative w-full py-1 px-1.5 rounded-xl flex items-center gap-1.5 hover:bg-gray-100 dark:hover:bg-gray-900 transition {$selectedFolder?.id ===
|
||||
folderId
|
||||
? 'bg-gray-100/80 dark:bg-gray-850/50 selected'
|
||||
: ''}"
|
||||
|
|
@ -778,7 +780,7 @@
|
|||
}}
|
||||
>
|
||||
<button
|
||||
class="text-gray-600 dark:text-gray-400 transition-all p-1 hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-lg"
|
||||
class="text-gray-600 dark:text-gray-400 transition-all p-1 hover:bg-gray-100 dark:hover:bg-gray-900 rounded-lg"
|
||||
on:click={(e) => {
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
import { fade, slide } from 'svelte/transition';
|
||||
|
||||
import { getUsage } from '$lib/apis';
|
||||
import { getSessionUser, userSignOut } from '$lib/apis/auths';
|
||||
import { getLogoutRedirectUrl, getSessionUser, userSignOut } from '$lib/apis/auths';
|
||||
|
||||
import { showSettings, mobile, showSidebar, user, config, settings } from '$lib/stores';
|
||||
|
||||
|
|
@ -118,7 +118,7 @@
|
|||
{#if $user}
|
||||
<div>
|
||||
<button
|
||||
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-xs w-full hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none text-left"
|
||||
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-xs w-full hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none text-left"
|
||||
type="button"
|
||||
on:click={async () => {
|
||||
show = false;
|
||||
|
|
@ -166,7 +166,7 @@
|
|||
{#if $user?.status_emoji || $user?.status_message}
|
||||
<div class="user-menu-status">
|
||||
<button
|
||||
class="w-full h-[1.6875rem] gap-2 rounded-xl px-2 hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none text-xs flex items-center text-left"
|
||||
class="w-full h-[1.6875rem] gap-2 rounded-xl px-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none text-xs flex items-center text-left"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
show = false;
|
||||
|
|
@ -218,7 +218,7 @@
|
|||
{:else}
|
||||
<div class="user-menu-status">
|
||||
<button
|
||||
class="w-full h-[1.6875rem] gap-2 rounded-xl px-2 hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none text-xs flex items-center text-left"
|
||||
class="w-full h-[1.6875rem] gap-2 rounded-xl px-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none text-xs flex items-center text-left"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
show = false;
|
||||
|
|
@ -243,7 +243,7 @@
|
|||
<a
|
||||
href="/workspace"
|
||||
draggable="false"
|
||||
class="flex flex-1 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
|
||||
class="flex flex-1 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
|
||||
on:click={async (e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
|
||||
e.preventDefault();
|
||||
|
|
@ -287,7 +287,7 @@
|
|||
<a
|
||||
href="/notes"
|
||||
draggable="false"
|
||||
class="flex flex-1 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
|
||||
class="flex flex-1 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
|
||||
on:click={async (e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
|
||||
e.preventDefault();
|
||||
|
|
@ -331,7 +331,7 @@
|
|||
<a
|
||||
href="/calendar"
|
||||
draggable="false"
|
||||
class="flex flex-1 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
|
||||
class="flex flex-1 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
|
||||
on:click={async (e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
|
||||
e.preventDefault();
|
||||
|
|
@ -375,7 +375,7 @@
|
|||
<a
|
||||
href="/automations"
|
||||
draggable="false"
|
||||
class="flex flex-1 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
|
||||
class="flex flex-1 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
|
||||
on:click={async (e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
|
||||
e.preventDefault();
|
||||
|
|
@ -419,7 +419,7 @@
|
|||
<a
|
||||
href="/playground"
|
||||
draggable="false"
|
||||
class="flex flex-1 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
|
||||
class="flex flex-1 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
|
||||
on:click={async (e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
|
||||
e.preventDefault();
|
||||
|
|
@ -468,7 +468,7 @@
|
|||
href="https://docs.openwebui.com"
|
||||
target="_blank"
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
|
||||
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
|
||||
id="chat-share-button"
|
||||
on:click={() => {
|
||||
show = false;
|
||||
|
|
@ -485,7 +485,7 @@
|
|||
href="https://github.com/open-webui/open-webui/releases"
|
||||
target="_blank"
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
|
||||
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
|
||||
id="chat-share-button"
|
||||
on:click={() => {
|
||||
show = false;
|
||||
|
|
@ -499,7 +499,7 @@
|
|||
{/if}
|
||||
|
||||
<button
|
||||
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
|
||||
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
|
||||
type="button"
|
||||
id="chat-share-button"
|
||||
on:click={async () => {
|
||||
|
|
@ -525,7 +525,7 @@
|
|||
<a
|
||||
href="/admin"
|
||||
draggable="false"
|
||||
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
|
||||
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
|
||||
on:click={async (e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
|
||||
return;
|
||||
|
|
@ -547,7 +547,7 @@
|
|||
{/if}
|
||||
|
||||
<button
|
||||
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
|
||||
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
|
||||
type="button"
|
||||
on:click={async () => {
|
||||
show = false;
|
||||
|
|
@ -567,14 +567,13 @@
|
|||
</button>
|
||||
|
||||
<button
|
||||
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition cursor-pointer select-none"
|
||||
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-100 dark:hover:bg-gray-900 transition cursor-pointer select-none"
|
||||
type="button"
|
||||
on:click={async () => {
|
||||
const res = await userSignOut();
|
||||
user.set(null);
|
||||
localStorage.removeItem('token');
|
||||
|
||||
location.href = res?.redirect_url ?? '/auth';
|
||||
location.href = getLogoutRedirectUrl(res?.redirect_url);
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue